diff --git a/README.md b/README.md index adaf0ea..0442127 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ # ink +

Ink logo

-Ink is a functional and minimalistic webapp to write documents in markdown and export them. +Ink is a browser-based markdown workspace for writing notes and documents. It opens a local folder of `.md` files, shows a searchable file tree, renders a live preview, and exports temporary notes without needing a backend server. + +The project goal is deliberately narrow: keep the writing surface fast, local-first, understandable, and easy to ship as one self-contained HTML file. Ink is not a hosted notes service, not a database-backed knowledge base, and not a replacement for a full IDE. It is a small document editor that can live next to your files. ![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) @@ -13,138 +16,440 @@ Ink is a functional and minimalistic webapp to write documents in markdown and e ## Quick Demo -![](assets/demo/ink-demo.gif) +![Ink demo](assets/demo/ink-demo.gif) -## Repo Structure +## What Ink Does + +Ink provides the everyday loop for local markdown writing: + +- Open a local folder as a workspace. +- Scan nested folders for `.md` files. +- Create markdown notes and folders. +- Edit markdown with source, split, or preview-only modes. +- Save changes back to the selected local file. +- Search across filenames and note contents. +- Filter notes by inline tags and frontmatter tags. +- Sort notes by name or modified date. +- Refresh manually or let idle auto-refresh rescan the workspace. +- Use temporary in-memory notes when the browser cannot provide folder access. +- Export temporary notes as JSON or the selected note as Markdown. +- Analyze document strength and generate local Cogito writing questions. +- Switch between built-in visual themes. + +Ink keeps normal workspace notes in your folder as plain `.md` files. The main app has no server-side storage layer. Browser storage is used only for preferences, temporary model cache, and temporary-session behavior. + +## Status + +Ink is usable, but still a small evolving application. The current app is strongest for individual markdown folders and personal writing workflows. + +Known boundaries: + +- Workspace persistence depends on the browser File System Access API. +- Temporary sessions are in memory and must be exported before leaving the page. +- Cogito loads browser-side language models and may need network access, IndexedDB storage, and a capable browser/device. +- The final distributable is a generated file, `ink-app.html`; edit source files and rebuild rather than hand-editing generated output. + +## Browser Support + +For full local folder editing, use a Chromium-based browser with the File System Access API, such as Chrome or Edge. + +When folder access is unavailable, Ink falls back to a temporary in-memory session. This mode is useful for mobile browsers, unsupported desktop browsers, or quick trials, but it cannot persist notes to your filesystem. Use the export commands before closing the tab. + +The editor itself is a client-side web app. A built `ink-app.html` can be opened directly in a browser, hosted from static file hosting, or served locally during development. + +## Getting Started As A User + +If you only want to use Ink: + +1. Open `ink-app.html` in a modern browser. +2. Choose `File -> Open Workspace`. +3. Select a folder that contains markdown files, or an empty folder where Ink can create them. +4. Allow read/write folder permission when the browser asks. +5. Choose `File -> New Note`, type a note name, and start writing. +6. Save with `Edit -> Save` or `Ctrl/Cmd + S`. + +Ink reads `.md` files recursively. Files with other extensions are ignored by the workspace tree. + +## Workspace Model + +A workspace is a local directory handle selected through the browser. Ink stores the handle in memory for the current page session and requests read/write permission before scanning or saving. + +The scan behavior is intentionally simple: + +- Hidden files and folders whose names start with `.` are skipped. +- Only `.md` files are listed. +- Nested folders are shown as collapsible tree nodes. +- Note metadata is read from the first 256 KB of each file during scans. +- Folder scans read several files concurrently so large folders stay responsive. + +Ink does not create an app-specific database for workspace notes. The markdown files remain normal files that can be edited with any other editor. + +## Markdown And Tags + +Markdown rendering uses `marked` and is configured with line breaks enabled. The preview updates as you type. + +Tags are discovered from two places: + +```markdown +--- +tags: [draft, research] +--- + +# A tagged note + +Inline tags like #idea and #writing are also recognized. +``` + +Frontmatter supports inline lists like `tags: [draft, research]` and block lists: + +```yaml +--- +tags: + - draft + - research +--- +``` + +Tags are normalized to lowercase and shown as filter buttons in the workspace sidebar. + +## Editor Views + +Ink has three editor modes: + +- `Source`: markdown editor only. +- `Split`: markdown editor and rendered preview side by side. +- `Preview`: rendered preview only. + +The chosen mode is stored in `localStorage` under `ink-editor-view-mode`. + +## Menus And Shortcuts + +Ink uses a compact menu bar instead of a large toolbar. On macOS, shortcut hints use `Cmd`; on other platforms they use `Ctrl`. + +| Action | Menu | Shortcut | +| --- | --- | --- | +| New note | `File -> New Note` | `Ctrl/Cmd + E` | +| New folder | `File -> New Folder` | none | +| Open workspace | `File -> Open Workspace` | `Ctrl/Cmd + Shift + O` | +| Close workspace | `File -> Close Workspace` | none | +| Export temporary notes as JSON | `File -> Export -> Export JSON` | `Ctrl/Cmd + Shift + S` | +| Export selected temporary note as Markdown | `File -> Export -> Export Markdown` | `Ctrl/Cmd + Shift + M` | +| Save current note | `Edit -> Save` | `Ctrl/Cmd + S` | +| Save as a new note | `Edit -> Save As...` | none | +| Refresh workspace | `Edit -> Refresh` | `Ctrl/Cmd + L` | +| Toggle sort mode | `Edit -> Sort` or sidebar `Sort` | none | + +The sidebar also includes search, refresh, tag filters, note counts, and a collapse control. + +## Temporary Sessions + +Temporary sessions are enabled automatically when `showDirectoryPicker` is unavailable. They can also be triggered by agent-created notes before a workspace is open. + +Temporary-session behavior: + +- Notes exist only in browser memory. +- Folders are not supported. +- Save updates the in-memory note. +- Refresh is disabled because there is no filesystem to scan. +- `Export JSON` downloads all temporary notes. +- `Export Markdown` downloads the selected temporary note. + +Use temporary sessions for quick drafting or unsupported browsers, but treat export as mandatory if the work matters. + +## Cogito Writing Assistance + +Cogito is the writing-assistance panel opened from the top-right `Cogito` button. It has two parts. + +`Document strength` is deterministic analysis. It scores and explains clarity, structure, readability, engagement, and section-level priorities without using a language model. You can run it manually, enable rerun-on-change, navigate suggestions, and export a Markdown report. + +`Improve with Cogito` generates exactly three question-only coaching prompts grounded in the latest sentence. If document analysis is available, Cogito uses a compact summary of strengths and priorities to focus those questions. Questions are not inserted automatically; each one has an explicit insert action. + +Cogito model options: + +- `Lite`: `Llama-3.2-1B-Instruct-q4f32_1-MLC`, the faster default. +- `Deep`: `Qwen3-8B-q4f16_1-MLC`, a larger model with fallback to Lite if model loading fails. + +Cogito loads WebLLM in the browser from `https://esm.run/@mlc-ai/web-llm` and stores model data in IndexedDB. The core editor can still work without Cogito, but Cogito needs network access for first load and enough browser storage for the selected model. + +## Themes + +Ink ships with these themes: + +- Default (Dark) +- Classic +- Cobalt +- Monokai +- Office +- Twilight +- Xcode + +Theme selection is stored in `localStorage` under `ink-theme`. + +## Agent Note Tool + +The generated app includes a WebMCP-compatible note creation form. It is normally hidden and appears only when an agent uses the `create_note` tool. The tool accepts a title, body, and optional tag, then creates a markdown note in the active workspace or in a temporary session. + +This keeps agent-created notes inside the same visible workflow as user-created notes. + +## Installing For Development + +Requirements: + +- Node.js with npm. +- A modern browser for manual testing. +- Chrome or another Cypress-supported browser for end-to-end tests. + +Install dependencies: + +```bash +npm install +``` + +Build the app: + +```bash +npm run build +``` + +The build writes `ink-app.html`, the single-file app with inlined CSS and JavaScript. + +Watch and rebuild on source changes: -The structure of the repo is as follows: +```bash +npm run watch +``` + +Serve locally for browser testing: + +```bash +npm run serve:test +``` + +Then open: + +```text +http://127.0.0.1:4173/ink-app.html +``` + +## Build System + +Ink does not use a full app framework. The build pipeline is intentionally direct: + +- TypeScript source is bundled with `esbuild`. +- SCSS is compiled with `sass`. +- `build/compile-and-assemble.js` assembles the final single-file app. +- `ink.template.html` provides the HTML shell. +- Generated CSS and JavaScript are injected into the template. +- Favicon assets are regenerated during the normal build. + +Canonical build command: + +```bash +npm run build +``` + +Makefile wrappers are available: + +```bash +make lint +make build +make watch +make test +make test-qunit +make test-cypress +make repomix +``` + +## Testing + +Automated tests cover pure utilities, app modules, browser workflows, and the generated build. + +Run QUnit tests: +```bash +npm run test:qunit +``` + +Run Cypress end-to-end tests: + +```bash +npm run test:cypress ``` + +Run the full suite: + +```bash +npm test +``` + +The Cypress suite covers the main editor flow, workspace actions, menu behavior, mobile fallback behavior, editor view modes, document analysis, Cogito mode, and demo capture support. + +## Demo Capture + +Record the first-use flow: + +```bash +npm run demo:record +``` + +This starts the local test server, launches headless Chrome, opens Ink, uses a fake workspace, creates `getting-started.md`, writes markdown, saves, and switches to preview. The recording is written to: + +```text +assets/demo/ink-demo.webm +``` + +On macOS, when `avconvert` can convert the recording, the script also writes: + +```text +assets/demo/ink-demo.mp4 +``` + +Generate a GIF from the recorded video: + +```bash +npm run demo:gif +``` + +This writes: + +```text +assets/demo/ink-demo.gif +``` + +Keep the MP4 as the canonical demo artifact when possible because it is smaller and clearer than a full-app GIF. + +## Repo Structure + +```text 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) + app.ts App entrypoint + tags.ts Frontmatter and inline tag parsing + app/ + app-controller.ts Main composition root + auto-refresh.ts Idle workspace rescan behavior + cogito.ts WebLLM-backed coaching questions + document-linter/ Deterministic document analysis + dom.ts Required DOM reference collection + editor-preview.ts Markdown preview and editor view modes + fs-api.ts File System Access API helpers + menu-actions.ts Menu actions and shortcut labels + sidebar.ts Sidebar collapse state + toast-status.ts Toast and status badge behavior + tree-render.ts Workspace tree, search, and tags + ui-events.ts Browser event wiring + workspace-io.ts Workspace, file, and temporary-session actions test-support/ - storage-fixture.ts (Test-only storage helpers) - styles.scss (The app styles, in SCSS) + storage-fixture.ts Test-only storage helpers dist/ - app.min.js - styles.min.css -ink.template.html (The HTML template source) -ink-app.html (The final single-page app, with inline - - -

Declarative WebMCP Demo

-

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

+* [CWE-159 Improper Handling of Invalid Use of Special Elements](https://cwe.mitre.org/data/definitions/159.html) -
- +* [CWE-470 Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')](https://cwe.mitre.org/data/definitions/470.html) - +* [CWE-493 Critical Public Variable Without Final Modifier](https://cwe.mitre.org/data/definitions/493.html) - +* [CWE-500 Public Static Field Not Marked Final](https://cwe.mitre.org/data/definitions/500.html) - -
- - - +* [CWE-564 SQL Injection: Hibernate](https://cwe.mitre.org/data/definitions/564.html) - -#!/bin/sh -# Pre-commit hook to run lint and update repomix output +* [CWE-610 Externally Controlled Reference to a Resource in Another Sphere](https://cwe.mitre.org/data/definitions/610.html) -echo "Running ESLint..." -npm run lint || exit 1 +* [CWE-643 Improper Neutralization of Data within XPath Expressions ('XPath Injection')](https://cwe.mitre.org/data/definitions/643.html) -echo "Running repomix..." -npx repomix@latest +* [CWE-644 Improper Neutralization of HTTP Headers for Scripting Syntax](https://cwe.mitre.org/data/definitions/644.html) -# Add the repomix output -git add repomix-output.xml +* [CWE-917 Improper Neutralization of Special Elements used in an Expression Language Statement ('Expression Language Injection')](https://cwe.mitre.org/data/definitions/917.html) - -function createFakeFileHandle(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() {}, - }; - }, - __read() { - return content; - }, - }; -} + +# A06:2025 Insecure Design ![icon](../assets/TOP_10_Icons_Final_Insecure_Design.png){: style="height:80px;width:80px" align="right"} -function createFakeDirectoryHandle(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 = 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, - }; -} +## Background. -describe("cogito mode", () => { - const workspaceName = "workspace-cogito"; - const fileStem = "cogito-note"; - const markdown = "The project should prioritize local-first writing workflows."; +Insecure Design slides two spots from #4 to #6 in the ranking as **[A02:2025-Security Misconfiguration](A02_2025-Security_Misconfiguration.md)** and **[A03:2025-Software Supply Chain Failures](A03_2025-Software_Supply_Chain_Failures.md)** leapfrog it. This category was introduced in 2021, and we have seen noticeable improvements in the industry related to threat modeling and a greater emphasis on secure design. This category focuses on risks related to design and architectural flaws, with a call for more use of threat modeling, secure design patterns, and reference architectures. This includes flaws in the business logic of an application, e.g. the lack of defining unwanted or unexpected state changes inside an application. As a community, we need to move beyond "shift-left" in the coding space, to pre-code activities such as requirements writing and application design, that are critical for the principles of Secure by Design (e.g. see **[Establish a Modern AppSec Program: Planning and Design Phase](0x03_2025-Establishing_a_Modern_Application_Security_Program.md)**). Notable Common Weakness Enumerations (CWEs) include *CWE-256: Unprotected Storage of Credentials, CWE-269 Improper Privilege Management, CWE-434 Unrestricted Upload of File with Dangerous Type, CWE-501: Trust Boundary Violation, and CWE-522: Insufficiently Protected Credentials.* - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - const root = createFakeDirectoryHandle(workspaceName); - win.prompt = (message) => { - if (message.includes("New note name")) { - return fileStem; - } - return null; - }; +## Score table. - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - win.__fakeWorkspace = root; - win.__cogitoCreateEngineCalls = []; - win.__cogitoCompletions = []; - win.__cogitoDeletedModels = []; - win.__INK_TEST_WEBLLM__ = { - prebuiltAppConfig: { model_list: [] }, - async deleteModelAllInfoInCache(modelId) { - win.__cogitoDeletedModels.push(modelId); - }, - async CreateMLCEngine(modelId, options = {}) { - win.__cogitoCreateEngineCalls.push(modelId); - options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); - 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?", - ], - }), - }, - }, - ], - }; - }, - }, - }, - }; - }, - }; - }, - }); - }); + + + + + + + + + + + + + + + + + + + + + + + +
CWEs Mapped + Max Incidence Rate + Avg Incidence Rate + Max Coverage + Avg Coverage + Avg Weighted Exploit + Avg Weighted Impact + Total Occurrences + Total CVEs +
39 + 22.18% + 1.86% + 88.76% + 35.18% + 6.96 + 4.05 + 729,882 + 7,647 +
- it("assesses the document, uses findings for coaching, and inserts one question", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - 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"); - cy.get("#documentLinterToggleBtn").should("not.exist"); - cy.get("#documentLinterPanel").should("not.exist"); - cy.get("#editorViewSourceBtn").click(); - cy.get("#editorSplit").should("have.class", "view-source").and("have.class", "with-cogito"); - cy.get("#cogitoPanel").should("be.visible"); - cy.get("#editorViewPreviewBtn").click(); - cy.get("#editorSplit").should("have.class", "view-preview").and("have.class", "with-cogito"); - cy.get("#cogitoPanel").should("be.visible"); - cy.get("#editorViewSplitBtn").click(); +## Description. - cy.get("#documentLinterAnalyzeBtn").click(); - cy.get("#documentLinterStatus").should("contain", "Analysis complete"); - cy.get("#documentLinterResults").should("contain", "Overall"); +Insecure design is a broad category representing different weaknesses, expressed as “missing or ineffective control design.” Insecure design is not the source for all other Top Ten risk categories. Note that there is a difference between insecure design and insecure implementation. We differentiate between design flaws and implementation defects for a reason, they have different root causes, take place at different times in the development process, and have different remediations. A secure design can still have implementation defects leading to vulnerabilities that may be exploited. An insecure design cannot be fixed by a perfect implementation as needed security controls were never created to defend against specific attacks. One of the factors that contributes to insecure design is the lack of business risk profiling inherent in the software or system being developed, and thus the failure to determine what level of security design is required. - cy.get("#cogitoDeepBtn").click().should("have.class", "active"); - cy.get("#cogitoLiteBtn").should("not.have.class", "active"); +Three key parts of having a secure design are: - cy.get("#cogitoGenerateBtn").click(); +* Gathering Requirements and Resource Management +* Creating a Secure Design +* Having a Secure Development Lifecycle - 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?"); - 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); - expect(win.__cogitoCompletions[0].messages[1].content).to.contain("Document strength:"); - expect(win.__cogitoCompletions[0].messages[1].content).to.contain("Highest-priority improvements:"); - }); +### Requirements and Resource Management - 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`); - }); +Collect and negotiate the business requirements for an application with the business, including the protection requirements concerning confidentiality, integrity, availability, and authenticity of all data assets and the expected business logic. Take into account how exposed your application will be and if you need segregation of tenants (beyond those needed for access control). Compile the technical requirements, including functional and non-functional security requirements. Plan and negotiate the budget covering all design, build, testing, and operation, including security activities. - it("marks existing questions stale when document analysis changes", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - cy.get("#cogitoToggleBtn").click(); - cy.get("#documentLinterAnalyzeBtn").click(); - cy.get("#documentLinterStatus").should("contain", "Analysis complete"); - cy.get("#cogitoGenerateBtn").click(); - cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); - cy.get("#editor").type(" More evidence is needed."); - cy.get("#documentLinterAnalyzeBtn").click(); - cy.get("#cogitoStatus").should("contain", "Regenerate"); - cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); - }); +### Secure Design - it("repairs a failed cache and falls back from Deep to Lite", () => { - cy.window().then((win) => { - win.__INK_TEST_WEBLLM__.CreateMLCEngine = async (modelId, options = {}) => { - win.__cogitoCreateEngineCalls.push(modelId); - if (modelId === "Qwen3-8B-q4f16_1-MLC") { - throw new Error("Failed to execute 'add' on 'Cache': Cache.add() encountered a network error"); - } - options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); - return { - chat: { - completions: { - async create(payload) { - win.__cogitoCompletions.push(payload); - return { - choices: [{ - message: { - content: JSON.stringify({ - questions: ["Fallback one?", "Fallback two?", "Fallback three?"], - }), - }, - }], - }; - }, - }, - }, - }; - }; - }); +Secure design is a culture and methodology that constantly evaluates threats and ensures that code is robustly designed and tested to prevent known attack methods. Threat modeling should be integrated into refinement sessions (or similar activities); look for changes in data flows and access control or other security controls. In the user story development, determine the correct flow and failure states, ensure they are well understood and agreed upon by the responsible and impacted parties. Analyze assumptions and conditions for expected and failure flows to ensure they remain accurate and desirable. Determine how to validate the assumptions and enforce conditions needed for proper behaviors. Ensure the results are documented in the user story. Learn from mistakes and offer positive incentives to promote improvements. Secure design is neither an add-on nor a tool that you can add to software. - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - cy.get("#cogitoToggleBtn").click(); - cy.get("#cogitoDeepBtn").click(); - cy.get("#cogitoGenerateBtn").click(); - cy.get("#statusBadge", { timeout: 10000 }).should("contain", "Cogito questions ready"); - cy.get("#cogitoLiteBtn").should("have.class", "active"); - cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); - cy.window().then((win) => { - expect(win.__cogitoDeletedModels).to.deep.equal(["Qwen3-8B-q4f16_1-MLC"]); - expect(win.__cogitoCreateEngineCalls).to.deep.equal([ - "Qwen3-8B-q4f16_1-MLC", - "Qwen3-8B-q4f16_1-MLC", - "Llama-3.2-1B-Instruct-q4f32_1-MLC", - ]); - }); - }); +### Secure Development Lifecycle - it("repairs the Lite model cache and retries Lite successfully", () => { - cy.window().then((win) => { - let liteAttempts = 0; - win.__INK_TEST_WEBLLM__.CreateMLCEngine = async (modelId, options = {}) => { - win.__cogitoCreateEngineCalls.push(modelId); - if (modelId === "Llama-3.2-1B-Instruct-q4f32_1-MLC") { - liteAttempts += 1; - if (liteAttempts === 1) { - throw new Error("Failed to execute 'add' on 'Cache': Cache.add() encountered a network error"); - } - } - options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); - return { - chat: { - completions: { - async create(payload) { - win.__cogitoCompletions.push(payload); - return { - choices: [{ - message: { - content: JSON.stringify({ - questions: ["Lite retry one?", "Lite retry two?", "Lite retry three?"], - }), - }, - }], - }; - }, - }, - }, - }; - }; - }); +Secure software requires a secure development lifecycle, a secure design pattern, a paved road methodology, a secure component library, appropriate tooling, threat modeling, and incident post-mortems that are used to improve the process. Reach out to your security specialists at the beginning of a software project, throughout the project, and for ongoing software maintenance. Consider leveraging the [OWASP Software Assurance Maturity Model (SAMM)](https://owaspsamm.org/) to help structure your secure software development efforts. - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - cy.get("#cogitoToggleBtn").click(); - cy.get("#cogitoLiteBtn").should("have.class", "active"); - cy.get("#cogitoGenerateBtn").click(); +Often self-responsibility of developers is underappreciated. Foster a culture of awareness, responsibility and proactive risk mitigation. Regular exchanges about security (e.g. during threat modeling sessions) can generate a mindset for including security in all important design decisions. - cy.get("#statusBadge", { timeout: 10000 }).should("contain", "Cogito questions ready"); - cy.get("#cogitoLiteBtn").should("have.class", "active"); - cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); - cy.window().then((win) => { - expect(win.__cogitoDeletedModels).to.deep.equal([ - "Llama-3.2-1B-Instruct-q4f32_1-MLC", - ]); - expect(win.__cogitoCreateEngineCalls).to.deep.equal([ - "Llama-3.2-1B-Instruct-q4f32_1-MLC", - "Llama-3.2-1B-Instruct-q4f32_1-MLC", - ]); - }); - }); -}); -
- -function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); +## How to prevent. - 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 createFakeDirectoryHandle(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 = 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; - }, - }; -} +* Establish and use a secure development lifecycle with AppSec professionals to help evaluate and design security and privacy-related controls +* Establish and use a library of secure design patterns or paved-road components +* Use threat modeling for critical parts of the application such as authentication, access control, business logic, and key flows +* User threat modeling as an educational tool to generate a security mindset +* Integrate security language and controls into user stories +* Integrate plausibility checks at each tier of your application (from frontend to backend) +* Write unit and integration tests to validate that all critical flows are resistant to the threat model. Compile use-cases *and* misuse-cases for each tier of your application. +* Segregate tier layers on the system and network layers, depending on the exposure and protection needs +* Segregate tenants robustly by design throughout all tiers -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); +## Example attack scenarios. - win.prompt = (message) => { - if (message.includes("New note name")) { - return noteName; - } - return null; - }; +**Scenario #1:** A credential recovery workflow might include “questions and answers,” which is prohibited by NIST 800-63b, the OWASP ASVS, and the OWASP Top 10. Questions and answers cannot be trusted as evidence of identity, as more than one person can know the answers. Such functionality should be removed and replaced with a more secure design. - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - }, - }); - }); +**Scenario #2:** A cinema chain allows group booking discounts and has a maximum of fifteen attendees before requiring a deposit. Attackers could threat model this flow and test if they can find an attack vector in the business logic of the application, e.g. booking six hundred seats and all cinemas at once in a few requests, causing a massive loss of income. - 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 }); +**Scenario #3:** A retail chain’s e-commerce website does not have protection against bots run by scalpers buying high-end video cards to resell on auction websites. This creates terrible publicity for the video card makers and retail chain owners, and enduring bad blood with enthusiasts who cannot obtain these cards at any price. Careful anti-bot design and domain logic rules, such as purchases made within a few seconds of availability, might identify inauthentic purchases and reject such transactions. - cy.get("#editorSplit").should("have.class", "view-split"); - cy.get("#editorViewSplitBtn").should("have.class", "active"); - 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"); +## References. - 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"); - 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"); - }); -}); - - -function createFakeDirectoryHandle(name) { - const entries = new Map(); +* [OWASP Cheat Sheet: Secure Design Principles](https://cheatsheetseries.owasp.org/cheatsheets/Secure_Product_Design_Cheat_Sheet.html) +* [OWASP SAMM: Design | Secure Architecture](https://owaspsamm.org/model/design/secure-architecture/) +* [OWASP SAMM: Design | Threat Assessment](https://owaspsamm.org/model/design/threat-assessment/) +* [NIST – Guidelines on Minimum Standards for Developer Verification of Software](https://www.nist.gov/publications/guidelines-minimum-standards-developer-verification-software) +* [The Threat Modeling Manifesto](https://threatmodelingmanifesto.org/) +* [Awesome Threat Modeling](https://github.com/hysnsec/awesome-threat-modelling) - 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, - }; -} -function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); +## List of Mapped CWEs - 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; - }, - }; -} +* [CWE-73 External Control of File Name or Path](https://cwe.mitre.org/data/definitions/73.html) -describe("removed toolbar buttons verification", () => { - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - const root = createFakeDirectoryHandle("test-workspace"); - win.prompt = (message) => { - if (message.includes("New note name")) { - return "test-note"; - } - return null; - }; - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - win.__fakeWorkspace = root; - }, - }); - }); +* [CWE-183 Permissive List of Allowed Inputs](https://cwe.mitre.org/data/definitions/183.html) - describe("5. QUnit-equivalent DOM verification", () => { - it("5.1 #saveBtn is NOT present in ink-app.html", () => { - cy.get("body").then(($body) => { - const saveBtn = $body.find("#saveBtn"); - expect(saveBtn.length).to.eq(0); - }); - }); +* [CWE-256 Unprotected Storage of Credentials](https://cwe.mitre.org/data/definitions/256.html) - it("5.2 #exportJsonBtn is NOT present in ink-app.html", () => { - cy.get("body").then(($body) => { - const exportJsonBtn = $body.find("#exportJsonBtn"); - expect(exportJsonBtn.length).to.eq(0); - }); - }); +* [CWE-266 Incorrect Privilege Assignment](https://cwe.mitre.org/data/definitions/266.html) - it("5.3 #exportMdBtn is NOT present in ink-app.html", () => { - cy.get("body").then(($body) => { - const exportMdBtn = $body.find("#exportMdBtn"); - expect(exportMdBtn.length).to.eq(0); - }); - }); +* [CWE-269 Improper Privilege Management](https://cwe.mitre.org/data/definitions/269.html) - it("5.4 #newNoteBtn, #newFolderBtn, and #openFolderBtn are NOT present in ink-app.html", () => { - cy.get("body").then(($body) => { - expect($body.find("#newNoteBtn").length).to.eq(0); - expect($body.find("#newFolderBtn").length).to.eq(0); - expect($body.find("#openFolderBtn").length).to.eq(0); - }); - }); +* [CWE-286 Incorrect User Management](https://cwe.mitre.org/data/definitions/286.html) - it("5.5 #dirtyDot IS present in ink-app.html after button removal", () => { - cy.get("#dirtyDot").should("exist"); - }); +* [CWE-311 Missing Encryption of Sensitive Data](https://cwe.mitre.org/data/definitions/311.html) - it("5.6 #statusBadge IS present in ink-app.html after button removal", () => { - cy.get("#statusBadge").should("exist"); - }); - }); +* [CWE-312 Cleartext Storage of Sensitive Information](https://cwe.mitre.org/data/definitions/312.html) - describe("6. Cypress integration tests for menu and keyboard shortcuts", () => { - it("6.1 Save button is absent from the editor header but save menu item exists", () => { - cy.get("#saveBtn").should("not.exist"); - cy.get("[data-action=\"save\"]").should("exist"); - cy.get("[data-action=\"save\"]").contains("Save"); - }); +* [CWE-313 Cleartext Storage in a File or on Disk](https://cwe.mitre.org/data/definitions/313.html) - it("6.2 Menu items for New Note and Open Workspace exist", () => { - cy.get("[data-action=\"new-note\"]").should("exist"); - cy.get("[data-action=\"new-note\"]").contains("New Note"); - cy.get("[data-action=\"open-workspace\"]").should("exist"); - cy.get("[data-action=\"open-workspace\"]").contains("Open Workspace"); - }); - - it("6.3 Export menu items exist", () => { - cy.get("[data-action=\"export-json\"]").should("exist"); - cy.get("[data-action=\"export-json\"]").contains("Export JSON"); - cy.get("[data-action=\"export-markdown\"]").should("exist"); - cy.get("[data-action=\"export-markdown\"]").contains("Export Markdown"); - }); - - it("6.4 Keyboard shortcut hints are shown in menu items", () => { - cy.get("[data-action=\"save\"]").should("exist"); - cy.get("[data-action=\"new-note\"]").should("exist"); - cy.get("[data-action=\"open-workspace\"]").should("exist"); - }); - - it("6.5 Status badge and dirty dot are in the correct location after button removal", () => { - cy.get("#editor").should("exist"); - cy.get("#dirtyDot").should("exist"); - cy.get("#statusBadge").should("exist"); - - cy.get("#dirtyDot").parent().within(() => { - cy.get("#statusBadge").should("exist"); - }); - }); - }); -}); - - - -import "./commands"; -import "@cypress/code-coverage/support"; - -Cypress.on("uncaught:exception", () => false); - - - -## ADDED Requirements +* [CWE-316 Cleartext Storage of Sensitive Information in Memory](https://cwe.mitre.org/data/definitions/316.html) -### Requirement: Cogito Mode Menu Bar Button -The system SHALL provide a Cogito Mode button in the menu bar's top-right action area. +* [CWE-362 Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')](https://cwe.mitre.org/data/definitions/362.html) -#### 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 +* [CWE-382 J2EE Bad Practices: Use of System.exit()](https://cwe.mitre.org/data/definitions/382.html) -#### 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 +* [CWE-419 Unprotected Primary Channel](https://cwe.mitre.org/data/definitions/419.html) -### 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. +* [CWE-434 Unrestricted Upload of File with Dangerous Type](https://cwe.mitre.org/data/definitions/434.html) -#### 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 +* [CWE-436 Interpretation Conflict](https://cwe.mitre.org/data/definitions/436.html) -### Requirement: Fixed Coaching Prompt Contract -The system SHALL generate questions using a fixed writing-coach instruction that outputs JSON only with exactly three questions. +* [CWE-444 Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling')](https://cwe.mitre.org/data/definitions/444.html) -#### 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 +* [CWE-451 User Interface (UI) Misrepresentation of Critical Information](https://cwe.mitre.org/data/definitions/451.html) -### Requirement: Last-Sentence Grounding -The system SHALL ground generated questions in the user's most recent sentence from the active markdown content. +* [CWE-454 External Initialization of Trusted Variables or Data Stores](https://cwe.mitre.org/data/definitions/454.html) -#### 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 +* [CWE-472 External Control of Assumed-Immutable Web Parameter](https://cwe.mitre.org/data/definitions/472.html) -### Requirement: Exactly Three Rendered Questions -The system SHALL display exactly three generated questions in the Cogito Mode side panel. +* [CWE-501 Trust Boundary Violation](https://cwe.mitre.org/data/definitions/501.html) -#### 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 +* [CWE-522 Insufficiently Protected Credentials](https://cwe.mitre.org/data/definitions/522.html) -#### 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 +* [CWE-525 Use of Web Browser Cache Containing Sensitive Information](https://cwe.mitre.org/data/definitions/525.html) -### Requirement: AI Question Markdown Insertion Format -The system SHALL insert selected questions into the markdown document using a standardized AI block format. +* [CWE-539 Use of Persistent Cookies Containing Sensitive Information](https://cwe.mitre.org/data/definitions/539.html) -#### 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 +* [CWE-598 Use of GET Request Method With Sensitive Query Strings](https://cwe.mitre.org/data/definitions/598.html) -### Requirement: Web-LLM Runtime Integration -The system SHALL use `https://esm.run/@mlc-ai/web-llm` for in-browser question generation. +* [CWE-602 Client-Side Enforcement of Server-Side Security](https://cwe.mitre.org/data/definitions/602.html) -#### Scenario: Runtime is available -- **WHEN** Cogito Mode initializes successfully -- **THEN** question generation SHALL execute through the web-llm runtime in the browser +* [CWE-628 Function Call with Incorrectly Specified Arguments](https://cwe.mitre.org/data/definitions/628.html) -#### 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 - +* [CWE-642 External Control of Critical State Data](https://cwe.mitre.org/data/definitions/642.html) - -## 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. +* [CWE-646 Reliance on File Name or Extension of Externally-Supplied File](https://cwe.mitre.org/data/definitions/646.html) -## 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. +* [CWE-653 Insufficient Compartmentalization](https://cwe.mitre.org/data/definitions/653.html) -## 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. +* [CWE-656 Reliance on Security Through Obscurity](https://cwe.mitre.org/data/definitions/656.html) -## 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. +* [CWE-657 Violation of Secure Design Principles](https://cwe.mitre.org/data/definitions/657.html) -## 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. +* [CWE-676 Use of Potentially Dangerous Function](https://cwe.mitre.org/data/definitions/676.html) -## 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? - +* [CWE-693 Protection Mechanism Failure](https://cwe.mitre.org/data/definitions/693.html) - -# Change: Add Cogito Mode Question Assistant +* [CWE-799 Improper Control of Interaction Frequency](https://cwe.mitre.org/data/definitions/799.html) -## 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. +* [CWE-807 Reliance on Untrusted Inputs in a Security Decision](https://cwe.mitre.org/data/definitions/807.html) -## 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. +* [CWE-841 Improper Enforcement of Behavioral Workflow](https://cwe.mitre.org/data/definitions/841.html) -## 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) - +* [CWE-1021 Improper Restriction of Rendered UI Layers or Frames](https://cwe.mitre.org/data/definitions/1021.html) - -## 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. +* [CWE-1022 Use of Web Link to Untrusted Target with window.opener Access](https://cwe.mitre.org/data/definitions/1022.html) -## 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. +* [CWE-1125 Excessive Attack Surface](https://cwe.mitre.org/data/definitions/1125.html) - -## 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. - -#### 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 - -#### 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 + +# A07:2025 Authentication Failures ![icon](../assets/TOP_10_Icons_Final_Identification_and_Authentication_Failures.png){: style="height:80px;width:80px" align="right"} -#### 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" -#### Scenario: Provide readability metrics -- **WHEN** the system analyzes prose content -- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level +## Background. -#### Scenario: Assess skimmability -- **WHEN** the system analyzes document structure -- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure +Authentication Failures maintains its position at #7 with a slight name change to more accurately reflect the 36 CWEs in this category. Despite benefits from standardized frameworks, this category has kept its #7 rank from 2021. Notable CWEs included are *CWE-259 Use of Hard-coded Password*, *CWE-297: Improper Validation of Certificate with Host Mismatch*, *CWE-287: Improper Authentication*, *CWE-384: Session Fixation*, and *CWE-798 Use of Hard-coded Credentials*. -#### Scenario: Score engagement proxies -- **WHEN** the system analyzes content for engagement -- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance -#### Scenario: Check style quality -- **WHEN** the system analyzes prose -- **THEN** it identifies spelling errors and style consistency issues +## Score table. -#### Scenario: Export suggestions as markdown -- **WHEN** the user requests export -- **THEN** the system outputs the suggestions in markdown format - - -## 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. + + + + + + + + + + + + + + + + + + + + + + + +
CWEs Mapped + Max Incidence Rate + Avg Incidence Rate + Max Coverage + Avg Coverage + Avg Weighted Exploit + Avg Weighted Impact + Total Occurrences + Total CVEs +
36 + 15.80% + 2.92% + 100.00% + 37.14% + 7.69 + 4.44 + 1,120,673 + 7,147 +
-#### 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 -#### 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 -#### 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" +## Description. -#### Scenario: Provide readability metrics -- **WHEN** the system analyzes prose content -- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level +When an attacker is able to trick a system into recognizing an invalid or incorrect user as legitimate, this vulnerability is present. There may be authentication weaknesses if the application: -#### Scenario: Assess skimmability -- **WHEN** the system analyzes document structure -- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure +* Permits automated attacks such as credential stuffing, where the attacker has a breached list of valid usernames and passwords. More recently this type of attack has been expanded to include hybrid password attacks credential stuffing (also known as password spray attacks), where the attacker uses variations or increments of spilled credentials to gain access, for instance trying Password1!, Password2!, Password3! and so on. -#### Scenario: Score engagement proxies -- **WHEN** the system analyzes content for engagement -- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance +* Permits brute force or other automated, scripted attacks that are not quickly blocked. -#### Scenario: Check style quality -- **WHEN** the system analyzes prose -- **THEN** it identifies spelling errors and style consistency issues +* Permits default, weak, or well-known passwords, such as "Password1" or "admin" username with an "admin" password. -#### Scenario: Export suggestions as markdown -- **WHEN** the user requests export -- **THEN** the system outputs the suggestions in markdown format -
+* Allows users to create new accounts with already known-breached credentials. - -# Change: Add Document Linter +* Allows use of weak or ineffective credential recovery and forgot-password processes, such as "knowledge-based answers," which cannot be made safe. -## 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. +* Uses plain text, encrypted, or weakly hashed passwords data stores (see[ A04:2025-Cryptographic Failures](https://owasp.org/Top10/2025/A04_2025-Cryptographic_Failures/)). -## 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 +* Has missing or ineffective multi-factor authentication. -## 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) - +* Allows use of weak or ineffective fallbacks if multi-factor authentication is not available. - -## 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 - +* Exposes session identifier in the URL, a hidden field, or another insecure location that is accessible to the client. - -## ADDED Requirements -### Requirement: ESLint Code Quality -The project MUST use ESLint to enforce code quality standards on JavaScript files. +* Reuses the same session identifier after successful login. -#### Scenario: ESLint runs successfully -- **WHEN** `npm run lint` is executed -- **THEN** ESLint analyzes all JavaScript files and reports any violations +* Does not correctly invalidate user sessions or authentication tokens (mainly single sign-on (SSO) tokens) during logout or a period of inactivity. -#### Scenario: Build includes lint check -- **WHEN** the build process runs -- **THEN** lint check is performed and build fails if errors exist - +* Does not correctly assert the scope and intended audience of the provided credentials. - -# Change: Add ESLint to the project +## How to prevent. -## 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. +* Where possible, implement and enforce use of multi-factor authentication to prevent automated credential stuffing, brute force, and stolen credential reuse attacks. -## 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 +* Where possible, encourage and enable the use of password managers, to help users make better choices. -## 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 - +* Do not ship or deploy with any default credentials, particularly for admin users. - -## 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 - +* Implement weak password checks, such as testing new or changed passwords against the top 10,000 worst passwords list. - -# Change: Add a Declarative WebMCP Note Creation Tool +* During new account creation and password changes validate against lists of known breached credentials (eg: using [haveibeenpwned.com](https://haveibeenpwned.com)). -## Why -Ink already supports creating markdown notes, but it does not expose that capability through a declarative WebMCP tool. Adding a form-backed tool lets browser agents create notes through a stable contract instead of brittle UI automation. +* Align password length, complexity, and rotation policies with [National Institute of Standards and Technology (NIST) 800-63b's guidelines in section 5.1.1](https://pages.nist.gov/800-63-3/sp800-63b.html#:~:text=5.1.1%20Memorized%20Secrets) for Memorized Secrets or other modern, evidence-based password policies. -## What Changes -- Add a declarative WebMCP `create_note` form to `ink.template.html` -- Route declarative form submissions into Ink's existing note creation flows -- Return a structured response for agent-invoked submissions without navigating away -- Fall back to a temporary in-memory session when no workspace is open so the tool remains usable +* Do not force human beings to rotate passwords unless you suspect breach. If you suspect breach, force password resets immediately. -## Impact -- Affected specs: webmcp-notes (new capability) -- Affected code: `ink.template.html`, `src/app/ui-events.ts`, `src/app/workspace-io.ts`, `src/app/app-controller.ts`, `src/app/dom.ts`, `src/app/types.ts`, `.github/copilot-instructions.md` -- No breaking changes to existing keyboard or menu note workflows - +* Ensure registration, credential recovery, and API pathways are hardened against account enumeration attacks by using the same messages for all outcomes (“Invalid username or password.”). - -## ADDED Requirements +* Limit or increasingly delay failed login attempts but be careful not to create a denial of service scenario. Log all failures and alert administrators when credential stuffing, brute force, or other attacks are detected or suspected. -### Requirement: Declarative Note Tool Exposure -The system SHALL expose a declarative WebMCP tool for creating notes from the app shell HTML. +* Use a server-side, secure, built-in session manager that generates a new random session ID with high entropy after login. Session identifiers should not be in the URL, be securely stored in a secure cookie, and invalidated after logout, idle, and absolute timeouts. -#### Scenario: Agent discovers the note creation tool -- **WHEN** an agent inspects the ink application page -- **THEN** the page SHALL expose a form with `toolname` and `tooldescription` metadata -- **AND** the form SHALL define note creation parameters for title, body, and optional tag using form controls +* Ideally, use a premade, well-trusted system to handle authentication, identity, and session management. Transfer this risk whenever possible by buying and utilizing a hardened and well tested system. -### Requirement: Declarative Tool Submission Behavior -The system SHALL process declarative note submissions through Ink's existing note creation behavior without navigating away from the app. +* Verify the intended use of provided credentials, e.g. for JWTs validate `aud`, `iss` claims and scopes -#### Scenario: Agent submits the declarative note tool -- **WHEN** the declarative note form is submitted by an agent -- **THEN** the system SHALL create a new note using the provided title, body, and optional tag -- **AND** the system SHALL prevent full-page navigation -- **AND** the system SHALL return a structured response describing the created note -#### Scenario: Human submits the declarative note form -- **WHEN** the declarative note form is submitted manually in the page -- **THEN** the system SHALL create a new note using the same flow as an agent submission -- **AND** the application SHALL remain on the current page +## Example attack scenarios. -### Requirement: No-Workspace Fallback -The system SHALL keep the declarative note tool usable even when no filesystem workspace is open. +**Scenario #1:** Credential stuffing, the use of lists of known username and password combinations, is now a very common attack. More recently attackers have been found to ‘increment’ or otherwise adjust passwords, based on common human behavior. For instance, changing ‘Winter2025’ to ‘Winter2026’, or ‘ILoveMyDog6’ to ‘ILoveMyDog7’ or ‘ILoveMyDog5’. This adjusting of password attempts is called a hybrid credential stuffing attack or a password spray attack, and they can be even more effective than the traditional version. If an application does not implement defences against automated threats (brute force, scripts, or bots) or credential stuffing, the application can be used as a password oracle to determine if the credentials are valid and gain unauthorized access. -#### Scenario: Declarative note tool is used before opening a workspace -- **WHEN** the user or agent submits the declarative note tool with no open workspace -- **THEN** the system SHALL create the note in a temporary in-memory session -- **AND** the UI SHALL indicate that the session is temporary - +**Scenario #2:** Most successful authentication attacks occur due to the continued use of passwords as the sole authentication factor. Once considered best practices, password rotation and complexity requirements encourage users to both reuse passwords and use weak passwords. Organizations are recommended to stop these practices per NIST 800-63 and to enforce use of multi-factor authentication on all important systems. - -## 1. Implementation -- [x] 1.1 Add a declarative WebMCP note form to `ink.template.html` -- [x] 1.2 Add DOM references for the WebMCP form and fields -- [x] 1.3 Add a workspace action that creates notes from declarative form input -- [x] 1.4 Intercept WebMCP form submission and return an agent response without page navigation -- [x] 1.5 Ensure note creation works with both open workspaces and temporary in-memory sessions -- [x] 1.6 Update `.github/copilot-instructions.md` with the new WebMCP integration guidance +**Scenario #3:** Application session timeouts aren't implemented correctly. A user uses a public computer to access an application and instead of selecting "logout," the user simply closes the browser tab and walks away. Another Example for this is, if a Single Sign on (SSO) session can not be closed by a Single Logout (SLO). That is, a single login logs you into, for example, your mail reader, your document system, and your chat system. But logging out happens only to the current system. If an attacker uses the same browser after the victim thinks they have successfully logged out, but with the user still authenticated to some of the applications, then can access the victim's account. The same issue can happen in offices and enterprises when a sensitive application has not been properly exited and a colleague has (temporary) access to the unlocked computer. -## 2. Validation -- [x] 2.1 Validate the OpenSpec change with `openspec validate add-webmcp-notes-tool --strict` -- [x] 2.2 Build the app successfully -- [x] 2.3 Run the QUnit test suite successfully - +## References. - -## MODIFIED Requirements +* [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) -### Requirement: Keyboard Shortcuts for Common Operations -The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. +* [OWASP Secure Coding Practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/stable-en/01-introduction/05-introduction) -#### Scenario: Save shortcut -- **WHEN** a user presses the platform-appropriate plain save shortcut (Ctrl+S on Windows/Linux, Cmd+S on Mac) -- **THEN** the saveCurrentNote functionality is triggered -- **AND** the shortcut displayed in the menu reflects the user's platform -#### Scenario: Export JSON shortcut -- **WHEN** a user presses the platform-appropriate export shortcut (Ctrl+Shift+S on Windows/Linux, Cmd+Shift+S on Mac) -- **THEN** the exportAsJson functionality is triggered -- **AND** the shortcut displayed in the menu reflects the user's platform -- **AND** saveCurrentNote is not triggered for that same key press +## List of Mapped CWEs -### Requirement: Shortcut Key Conflict Resolution -The application SHALL resolve overlapping shortcut chords so the documented action still runs in every supported focus state. +* [CWE-258 Empty Password in Configuration File](https://cwe.mitre.org/data/definitions/258.html) -#### Scenario: Focused editor does not swallow export chord -- **WHEN** the editor textarea has focus and a user presses Ctrl+Shift+S on Windows/Linux or Cmd+Shift+S on Mac -- **THEN** the exportAsJson functionality is triggered -- **AND** the focused-editor save handler does not intercept the chord as plain save +* [CWE-259 Use of Hard-coded Password](https://cwe.mitre.org/data/definitions/259.html) -#### Scenario: Focused editor still saves with plain save chord -- **WHEN** the editor textarea has focus and a user presses Ctrl+S on Windows/Linux or Cmd+S on Mac -- **THEN** the saveCurrentNote functionality is triggered -- **AND** no export action is triggered - +* [CWE-287 Improper Authentication](https://cwe.mitre.org/data/definitions/287.html) - -# Change: Fix Export JSON Shortcut Precedence +* [CWE-288 Authentication Bypass Using an Alternate Path or Channel](https://cwe.mitre.org/data/definitions/288.html) -## Why +* [CWE-289 Authentication Bypass by Alternate Name](https://cwe.mitre.org/data/definitions/289.html) -The application advertises Cmd/Ctrl+Shift+S as the Export JSON shortcut, but when the editor has focus that chord is currently captured by the editor's Cmd/Ctrl+S save handler. This causes the current note to save instead of exporting all notes as JSON, which breaks the documented shortcut behavior. +* [CWE-290 Authentication Bypass by Spoofing](https://cwe.mitre.org/data/definitions/290.html) -## What Changes +* [CWE-291 Reliance on IP Address for Authentication](https://cwe.mitre.org/data/definitions/291.html) -- Ensure the editor-scoped save shortcut only handles the plain Cmd/Ctrl+S chord -- Preserve Cmd/Ctrl+Shift+S for Export JSON even when the editor has focus -- Document shortcut precedence so longer modified chords are not swallowed by shorter handlers -- Add regression coverage for focused-editor shortcut behavior +* [CWE-293 Using Referer Field for Authentication](https://cwe.mitre.org/data/definitions/293.html) -## Non-Regression Guarantees +* [CWE-294 Authentication Bypass by Capture-replay](https://cwe.mitre.org/data/definitions/294.html) -- Plain Cmd/Ctrl+S continues to save the current note from the editor and window scope -- Cmd/Ctrl+Shift+S exports JSON from the editor and window scope -- Existing shortcuts for new note, open workspace, refresh, and markdown export continue to work -- The documented platform-aware shortcut labels remain unchanged +* [CWE-295 Improper Certificate Validation](https://cwe.mitre.org/data/definitions/295.html) -## Testing Requirements +* [CWE-297 Improper Validation of Certificate with Host Mismatch](https://cwe.mitre.org/data/definitions/297.html) -- Add automated coverage for Cmd/Ctrl+Shift+S while the editor textarea has focus -- Add automated coverage confirming plain Cmd/Ctrl+S still saves while the editor textarea has focus -- Validate that shortcut handling does not trigger both save and export for a single key press +* [CWE-298 Improper Validation of Certificate with Host Mismatch](https://cwe.mitre.org/data/definitions/298.html) -## Impact +* [CWE-299 Improper Validation of Certificate with Host Mismatch](https://cwe.mitre.org/data/definitions/299.html) -- Affected specs: `keyboard-shortcuts` -- Affected code: `src/app/ui-events.ts`, focused-editor shortcut handling, Cypress shortcut regression coverage -- User experience: Export JSON matches the documented Cmd/Ctrl+Shift+S shortcut in all focus states - +* [CWE-300 Channel Accessible by Non-Endpoint](https://cwe.mitre.org/data/definitions/300.html) - -## 1. Implementation +* [CWE-302 Authentication Bypass by Assumed-Immutable Data](https://cwe.mitre.org/data/definitions/302.html) -- [x] 1.1 Update the editor keydown handler to reserve Cmd/Ctrl+Shift+S for JSON export instead of save -- [x] 1.2 Keep plain Cmd/Ctrl+S mapped to save in both editor-focused and global shortcut paths -- [x] 1.3 Keep shortcut handling linear so a single key chord triggers at most one action +* [CWE-303 Incorrect Implementation of Authentication Algorithm](https://cwe.mitre.org/data/definitions/303.html) -## 2. Testing +* [CWE-304 Missing Critical Step in Authentication](https://cwe.mitre.org/data/definitions/304.html) -- [x] 2.1 Add Cypress coverage for Cmd/Ctrl+Shift+S while `#editor` has focus -- [x] 2.2 Add Cypress coverage for Cmd/Ctrl+S while `#editor` has focus -- [x] 2.3 Verify the JSON export shortcut does not also trigger save side effects +* [CWE-305 Authentication Bypass by Primary Weakness](https://cwe.mitre.org/data/definitions/305.html) -## 3. Validation +* [CWE-306 Missing Authentication for Critical Function](https://cwe.mitre.org/data/definitions/306.html) -- [x] 3.1 Run `openspec validate fix-export-json-shortcut-precedence --strict` -- [x] 3.2 Run the relevant automated shortcut regression tests after implementation - +* [CWE-307 Improper Restriction of Excessive Authentication Attempts](https://cwe.mitre.org/data/definitions/307.html) - -## 1. Sidebar Toggle Visibility on Mobile +* [CWE-308 Use of Single-factor Authentication](https://cwe.mitre.org/data/definitions/308.html) -- [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. +* [CWE-309 Use of Password System for Primary Authentication](https://cwe.mitre.org/data/definitions/309.html) -## 2. Sidebar Collapsed State Collapses Vertically on Mobile +* [CWE-346 Origin Validation Error](https://cwe.mitre.org/data/definitions/346.html) -- [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. +* [CWE-350 Reliance on Reverse DNS Resolution for a Security-Critical Action](https://cwe.mitre.org/data/definitions/350.html) -## 3. App Grid Fills Viewport Height on Mobile +* [CWE-384 Session Fixation](https://cwe.mitre.org/data/definitions/384.html) -- [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. +* [CWE-521 Weak Password Requirements](https://cwe.mitre.org/data/definitions/521.html) -## 4. Editor and Preview Fill Available Vertical Space +* [CWE-613 Insufficient Session Expiration](https://cwe.mitre.org/data/definitions/613.html) -- [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. +* [CWE-620 Unverified Password Change](https://cwe.mitre.org/data/definitions/620.html) -## 5. Build and Verification +* [CWE-640 Weak Password Recovery Mechanism for Forgotten Password](https://cwe.mitre.org/data/definitions/640.html) -- [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. - +* [CWE-798 Use of Hard-coded Credentials](https://cwe.mitre.org/data/definitions/798.html) - -## MODIFIED Requirements -### Requirement: Mobile Text Input Support -The system SHALL provide a comfortable editing experience on mobile devices without unwanted zoom behavior. +* [CWE-940 Improper Verification of Source of a Communication Channel](https://cwe.mitre.org/data/definitions/940.html) -#### Scenario: Tap textarea on mobile device -- **WHEN** user taps the textarea on a mobile device -- **THEN** the textarea does not trigger automatic browser zoom -- **AND** the font-size is at least 16px to prevent iOS Safari zoom behavior +* [CWE-941 Incorrectly Specified Destination in a Communication Channel](https://cwe.mitre.org/data/definitions/941.html) -#### Scenario: Desktop editing experience unchanged -- **WHEN** user edits on desktop viewport -- **THEN** the textarea maintains the original 14px font-size -- **AND** the editing experience remains consistent with previous behavior - +* [CWE-1390 Weak Authentication](https://cwe.mitre.org/data/definitions/1390.html) - -## 1. Implementation -- [x] 1.1 Add 16px font-size for textarea on mobile viewports -- [x] 1.2 Build and verify fix in output - +* [CWE-1391 Use of Weak Credentials](https://cwe.mitre.org/data/definitions/1391.html) - -# Change: Harden Document Linter +* [CWE-1392 Use of Default Credentials](https://cwe.mitre.org/data/definitions/1392.html) -## Why -The Document Linter rewrite (`improve-document-linter-output`) shipped a markdown-aware report and a useful overview, but several rough edges remain that limit trust in the output: +* [CWE-1393 Use of Default Password](https://cwe.mitre.org/data/definitions/1393.html) + -- The analyzer is wrapped in a fake 100ms `setTimeout` for no real reason, which makes the async path untestable and misleading. -- Sentence tokenization is duplicated in two places with two different regexes, so the analyzer and the report can disagree. -- The engagement score is hard-coded to 68 with tiny adjustments, so the score doesn't track content quality. -- Strengths detection depends on a literal phrase ("this chapter is about") and a hand-picked list of ancient-Near-East place names, so most documents surface no strengths. -- `parseMarkdownBlocks` does not handle ordered lists, Setext-style headings, indented code blocks, or fenced code without a closing fence. -- The "section that needs attention" lookup in `buildOverview` uses a loose regex over note text, silently coupling two pieces of code. -- Test coverage is thin: no edge-case parsing tests, no isolated section-builder test, no snapshot test for the exported Markdown report, no controller-level tests. + +# A08:2025 Software or Data Integrity Failures ![icon](../assets/TOP_10_Icons_Final_Software_and_Data_Integrity_Failures.png){: style="height:80px;width:80px" align="right"} -This change addresses the bug-class and coverage-class issues so the linter is reliable enough to build on (e.g. with a "rerun on change" toggle or a localization layer) in a follow-up. +## Background. -## What Changes -- Make the analysis pipeline deterministic and properly testable: remove the artificial `setTimeout`, unify sentence tokenization, deduplicate the pseudo-section-label logic, and replace the regex-based overview lookup with an explicit flag. -- Replace the hard-coded engagement baseline with signal-based heuristics, and add an aggregate overall score to the panel and the exported report. -- Generalize the strengths heuristics so strong writing surfaces positive feedback regardless of topic. -- Broaden `parseMarkdownBlocks` to cover ordered lists, Setext headings, indented code, and unterminated fenced code. -- Expand QUnit coverage: parsing edge cases, isolated section builder, snapshot test for the Markdown report, empty/heading-only documents, and a JSDOM-based controller test. -- Extract the suggestion and overview copy into a small message module so it can be localized later (no new translations shipped here). -- Keep the existing panel HTML shape and Markdown report format as the contract; this change tightens internals and tests, not the public surface. +Software or Data Integrity Failures continues at #8, with a slight, clarifying name change from "Software *and* Data Integrity Failures". This category is focused on the failure to maintain trust boundaries and verify the integrity of software, code, and data artifacts at a lower level than Software Supply Chain Failures. This category focuses on making assumptions related to software updates and critical data, without verifying integrity. Notable Common Weakness Enumerations (CWEs) include *CWE-829: Inclusion of Functionality from Untrusted Control Sphere*, *CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes*, and *CWE-502: Deserialization of Untrusted Data*. -## Impact -- Affected specs: document-linter -- Affected code: `src/app/document-linter/document-linter.ts`, `tests/qunit/document-linter.test.js` -- Runtime dependency: None -- Breaking changes: none - - -## 1. Implementation +## Score table. -## 1. Analyzer correctness -- [x] 1.1 Remove the artificial 100ms `setTimeout` in `runAnalysis`; make analysis synchronous (or use a real async path such as `requestIdleCallback` if a non-blocking UI is needed) -- [x] 1.2 Unify sentence tokenization — `countSentences` and `splitSentences` should use one helper with one rule -- [x] 1.3 Deduplicate `detectPseudoSectionLabel` and the inline label check inside `buildDocumentSections` -- [x] 1.4 Replace the regex-based "section that needs attention" lookup in `buildOverview` with an explicit `needsAttention` flag on the section note -## 2. Scoring -- [x] 2.1 Replace the hard-coded engagement baseline (currently 68) with heuristics derived from observable signals (opening verb energy, presence of a question, directive verbs, ratio of passive voice, etc.) -- [x] 2.2 Add an aggregate "Overall" score (weighted mean or simple min) to both the panel and the exported report + + + + + + + + + + + + + + + + + + + + + + + +
CWEs Mapped + Max Incidence Rate + Avg Incidence Rate + Max Coverage + Avg Coverage + Avg Weighted Exploit + Avg Weighted Impact + Total Occurrences + Total CVEs +
14 + 8.98% + 2.75% + 78.52% + 45.49% + 7.11 + 4.79 + 501,327 + 3,331 +
-## 3. Strengths detection -- [x] 3.1 Generalize the "this chapter is about" / mnemonic / place-name heuristics into generic detectors (dates, numbers, named entities, mnemonic patterns like "Remember…", parallel list items) -- [x] 3.2 Ensure strong writing that doesn't trigger the literal phrases still surfaces at least one strength -## 4. Parsing coverage -- [x] 4.1 Extend `parseMarkdownBlocks` to handle ordered lists (`1.`, `2.`, …) -- [x] 4.2 Extend `parseMarkdownBlocks` to handle Setext-style headings (`===`, `---`) -- [x] 4.3 Extend `parseMarkdownBlocks` to handle indented code blocks and fenced code without a closing fence -- [x] 4.4 Decide on a behavior for the implicit "Lead" section when a document starts with code or a quote, and name/document it -## 5. Test coverage -- [x] 5.1 Add QUnit tests for `parseMarkdownBlocks` edge cases: nested lists, ordered lists, multi-paragraph blockquotes, code without a closing fence, headings without a space -- [x] 5.2 Add QUnit tests for `buildDocumentSections` in isolation (not just via end-to-end analyzer tests) -- [x] 5.3 Add a snapshot test for `buildDocumentLinterReport` Markdown output -- [x] 5.4 Add tests for empty, single-word, and heading-only documents -- [x] 5.5 Add tests for the controller (`createDocumentLinterController`) using a JSDOM harness — assert panel HTML shape, toast/status callbacks, and "no content" guard -- [x] 5.6 Add a test that proves list items are not miscounted as sentences (markdown-aware classification) +## Description. -## 6. Maintainability -- [x] 6.1 Extract all suggestion and overview copy into a `messages.ts` (or equivalent) module so non-English UI is possible later -- [x] 6.2 Document the sentence-tokenizer limitation in a code comment near the helper -
+Software and data integrity failures relate to code and infrastructure that does not protect against invalid or untrusted code or data being treated as trusted and valid. An example of this is where an application relies upon plugins, libraries, or modules from untrusted sources, repositories, and content delivery networks (CDNs). An insecure CI/CD pipeline without consuming and providing software integrity checks can introduce the potential for unauthorized access, insecure or malicious code, or system compromise. Another example of this is a CI/CD that pulls code or artifacts from untrusted places and/or doesn’t verify them before use (by checking the signature or similar mechanism). Lastly, many applications now include auto-update functionality, where updates are downloaded without sufficient integrity verification and applied to the previously trusted application. Attackers could potentially upload their own updates to be distributed and run on all installations. Another example is where objects or data are encoded or serialized into a structure that an attacker can see and modify is vulnerable to insecure deserialization. - -## MODIFIED 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. -#### 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 -- **AND** the report highlights the highest-priority fixes first -- **AND** the report summary distinguishes between structural issues, prose issues, and low-signal sections +## How to prevent. -#### 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 -#### 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" -- **AND** it avoids classifying markdown bullets, headings, and quoted callouts as generic long sentences -#### Scenario: Provide readability metrics -- **WHEN** the system analyzes prose content -- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level +* Use digital signatures or similar mechanisms to verify the software or data is from the expected source and has not been altered. +* Ensure libraries and dependencies, such as npm or Maven, are only consuming trusted repositories. If you have a higher risk profile, consider hosting an internal known-good repository that's vetted. +* Ensure that there is a review process for code and configuration changes to minimize the chance that malicious code or configuration could be introduced into your software pipeline. +* Ensure that your CI/CD pipeline has proper segregation, configuration, and access control to ensure the integrity of the code flowing through the build and deploy processes. +* Ensure that unsigned or unencrypted serialized data is not received from untrusted clients and subsequently used without some form of integrity check or digital signature to detect tampering or replay of the serialized data. -#### Scenario: Assess skimmability -- **WHEN** the system analyzes document structure -- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure -#### Scenario: Score engagement proxies -- **WHEN** the system analyzes content for engagement -- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance +## Example attack scenarios. -#### Scenario: Check style quality -- **WHEN** the system analyzes prose -- **THEN** it identifies spelling errors and style consistency issues +**Scenario #1 Inclusion of Web Functionality from an Untrusted Source:** A company uses an external service provider to provide support functionality. For convenience, it has a DNS mapping for `myCompany.SupportProvider.com` to `support.myCompany.com`. This means that all cookies, including authentication cookies, set on the `myCompany.com` domain will now be sent to the support provider. Anyone with access to the support provider’s infrastructure can steal the cookies of all of your users that have visited `support.myCompany.com` and perform a session hijacking attack. -#### Scenario: Export suggestions as markdown -- **WHEN** the user requests export -- **THEN** the system outputs the suggestions in markdown format -- **AND** the export includes a concise summary, prioritized fixes, and section-level detail - +**Scenario #2 Update without signing:** Many home routers, set-top boxes, device firmware, and others do not verify updates via signed firmware. Unsigned firmware is a growing target for attackers and is expected to only get worse. This is a major concern as many times there is no mechanism to remediate other than to fix in a future version and wait for previous versions to age out. - -# Change: Improve Document Linter Output Quality +**Scenario #3 Use of Package from an Untrusted Source:** A developer has trouble finding the updated version of a package they are looking for, so they download it not from the regular, trusted package manager, but from a website online. The package is not signed, and thus there is no opportunity to ensure integrity. The package includes malicious code. -## Why -The current Document Linter output is technically correct but too generic to be useful on real study and writing notes. It over-flags list items as long sentences, repeats low-signal style warnings, and does not surface a clear priority order or a concise summary that helps the reader act quickly. +**Scenario #4 Insecure Deserialization:** A React application calls a set of Spring Boot microservices. Being functional programmers, they tried to ensure that their code is immutable. The solution they came up with is serializing the user state and passing it back and forth with each request. An attacker notices the "rO0" Java object signature (in base64) and uses the [Java Deserialization Scanner](https://github.com/federicodotta/Java-Deserialization-Scanner) to gain remote code execution on the application server. -## What Changes -- Upgrade the linter report to prioritize the most useful feedback first -- Make sentence and paragraph analysis markdown-aware so list items, headings, and quoted callouts are not misclassified as generic prose -- Add a stronger summary layer that highlights the top issues, strongest sections, and what to fix first -- Add section-level analysis so the linter can reason about headings, label-style section starts, and dense sub-parts of a document -- Reduce dull, repetitive phrasing in favor of more specific, content-aware suggestions -- Keep the report export in markdown, but make the exported structure more editorial and easier to scan +## References. -## Impact -- Affected specs: document-linter -- Affected code: Document Linter analysis pipeline, markdown report builder, export output -- Runtime dependency: None -- Breaking changes: none - +* [OWASP Cheat Sheet: Software Supply Chain Security](https://cheatsheetseries.owasp.org/cheatsheets/Software_Supply_Chain_Security_Cheat_Sheet.html) +* [OWASP Cheat Sheet: Infrastructure as Code](https://cheatsheetseries.owasp.org/cheatsheets/Infrastructure_as_Code_Security_Cheat_Sheet.html) +* [OWASP Cheat Sheet: Deserialization](https://wiki.owasp.org/index.php/Deserialization_Cheat_Sheet) +* [SAFECode Software Integrity Controls](https://safecode.org/publication/SAFECode_Software_Integrity_Controls0610.pdf) +* [A 'Worst Nightmare' Cyberattack: The Untold Story Of The SolarWinds Hack](https://www.npr.org/2021/04/16/985439655/a-worst-nightmare-cyberattack-the-untold-story-of-the-solarwinds-hack) +* [CodeCov Bash Uploader Compromise](https://about.codecov.io/security-update) +* [Securing DevOps by Julien Vehent](https://www.manning.com/books/securing-devops) +* [Insecure Deserialization by Tenendo](https://tenendo.com/insecure-deserialization/) - -## 1. Implementation -- [x] 1.1 Improve markdown-aware parsing so headings, bullets, and callouts are not treated like plain prose -- [x] 1.2 Rewrite the report builder to include a concise summary, top issues, and prioritized fixes -- [x] 1.3 Replace generic suggestion phrasing with more specific, content-aware language -- [x] 1.4 Keep export output in markdown while making it more readable and action-oriented -- [x] 1.5 Add tests that cover the new report structure and the markdown-aware classification rules - - -## ADDED Requirements -### Requirement: Modular app controller -The system SHALL decompose the app controller into focused feature modules with explicit interfaces and a shared `AppState` passed as an argument. +## List of Mapped CWEs -#### Scenario: Explicit module boundaries -- **WHEN** controller responsibilities are extracted -- **THEN** each module exports named functions for its feature area -- **AND** modules do not access global state or hidden singletons +* [CWE-345 Insufficient Verification of Data Authenticity](https://cwe.mitre.org/data/definitions/345.html) -#### Scenario: Controller orchestration -- **WHEN** the app initializes -- **THEN** the controller constructs `AppState` once -- **AND** passes it explicitly into each module function +* [CWE-353 Missing Support for Integrity Check](https://cwe.mitre.org/data/definitions/353.html) -### Requirement: Centralized UI event wiring -The system SHALL register UI event listeners in a dedicated `ui-events.ts` module that receives `DomRefs`, `AppState`, and explicit action callbacks. +* [CWE-426 Untrusted Search Path](https://cwe.mitre.org/data/definitions/426.html) -#### Scenario: UI events registration -- **WHEN** UI events are attached -- **THEN** the controller calls `ui-events` with callbacks for menu actions, shortcuts, and workspace flows +* [CWE-427 Uncontrolled Search Path Element](https://cwe.mitre.org/data/definitions/427.html) -### Requirement: Build/test entrypoint clarity -The system SHALL keep `build/compile-and-assemble.js` as the canonical build entry and use a dedicated test build helper for `npm run build:test`. +* [CWE-494 Download of Code Without Integrity Check](https://cwe.mitre.org/data/definitions/494.html) -#### Scenario: Build entry documentation -- **WHEN** build instructions are documented -- **THEN** they reference `build/compile-and-assemble.js` as the canonical entrypoint +* [CWE-502 Deserialization of Untrusted Data](https://cwe.mitre.org/data/definitions/502.html) -#### Scenario: Test build helper -- **WHEN** the test build runs -- **THEN** it uses a single helper script that centralizes the esbuild configuration - +* [CWE-506 Embedded Malicious Code](https://cwe.mitre.org/data/definitions/506.html) - -## Context -The refactor plan identifies `src/app/bootstrap.ts` as a coupling hotspot that mixes UI wiring, state mutation, File System Access API flows, and rendering. The build/test scripts also have ambiguous naming and duplicated configuration. +* [CWE-509 Replicating Malicious Code (Virus or Worm)](https://cwe.mitre.org/data/definitions/509.html) -## Goals / Non-Goals -- Goals: - - Decompose the controller into explicit feature modules with clear boundaries. - - Keep state transitions linear and explicit through shared `AppState`. - - Clarify build and test entrypoints while preserving single-file output. -- Non-Goals: - - Change user-visible behavior, UI layout, or feature set. - - Introduce new dependencies or build frameworks. +* [CWE-565 Reliance on Cookies without Validation and Integrity Checking](https://cwe.mitre.org/data/definitions/565.html) -## Decisions -- Decision: Keep a single `AppState` object and pass it explicitly into modules. -- Decision: Centralize event registration in `ui-events.ts` with injected callbacks. -- Decision: Preserve `src/app.ts` as the sole entrypoint and rename the controller file only. -- Decision: Add a `build/build-test.js` helper to own test bundle configuration. +* [CWE-784 Reliance on Cookies without Validation and Integrity Checking in a Security Decision](https://cwe.mitre.org/data/definitions/784.html) -## Risks / Trade-offs -- Risk: Refactor could introduce regressions in shortcuts or file system flows. - - Mitigation: Manual smoke testing of open/save/refresh and menu shortcuts after each extraction. +* [CWE-829 Inclusion of Functionality from Untrusted Control Sphere](https://cwe.mitre.org/data/definitions/829.html) -## Migration Plan -1. Extract modules one by one, keeping the controller as an orchestration layer. -2. Rename the controller file and update imports once module boundaries are stable. -3. Update build/test scripts and documentation last to avoid churn. +* [CWE-830 Inclusion of Web Functionality from an Untrusted Source](https://cwe.mitre.org/data/definitions/830.html) -## Open Questions -- Resolved: use `app-controller.ts` as the controller filename. +* [CWE-915 Improperly Controlled Modification of Dynamically-Determined Object Attributes](https://cwe.mitre.org/data/definitions/915.html) + +* [CWE-926 Improper Export of Android Application Components](https://cwe.mitre.org/data/definitions/926.html) - -# Change: Modularize app controller and build helpers + +# A09:2025 Security Logging & Alerting Failures ![icon](../assets/TOP_10_Icons_Final_Security_Logging_and_Monitoring_Failures.png){: style="height:80px;width:80px" align="right"} -## Why -The refactor plan calls for separating the large controller module into explicit feature units and clarifying build/test entrypoints so the codebase stays predictable for LLM maintenance without changing behavior. -## What Changes -- Split `src/app/bootstrap.ts` into small feature modules with explicit interfaces and shared `AppState`. -- Introduce a dedicated `ui-events.ts` to centralize UI wiring and keyboard/menu shortcuts. -- Rename `bootstrap.ts` to a clearer controller name while keeping `src/app.ts` as the entrypoint. -- Add a test build helper and converge on `build/compile-and-assemble.js` as the canonical build entry. +## Background. -## Impact -- Affected specs: codebase-maintainability -- Affected code: src/app/bootstrap.ts, src/app/*.ts, build/*.js, package.json, README.md - +Security Logging & Alerting Failures retains its position at #9. This category has a slight name change to emphasize the alerting function needed to induce action on relevant logging events. This category will always be underrepresented in the data, and for the third time voted into a position in the list from the community survey participants. This category is incredibly difficult to test for, and has minimal representation in the CVE/CVSS data (only 723 CVEs); but can be very impactful for visibility and incident alerting and forensics. This category includes issues with *properly handling output encoding to log files (CWE-117), inserting sensitive data into log files (CWE-532), and insufficient logging (CWE-778).* - -## 1. Implementation -- [x] 1.1 Map `InkApp` responsibilities to module boundaries from the refactor plan. -- [x] 1.2 Extract feature modules (menu actions, workspace IO, tree render, editor preview, toast/status, auto-refresh) with explicit exports. -- [x] 1.3 Add `ui-events.ts` to register UI event listeners and shortcuts via injected callbacks. -- [x] 1.4 Rename `bootstrap.ts` to the chosen controller filename and update imports/exports. -- [x] 1.5 Introduce `build/build-test.js` and update `npm run build:test` to use it. -- [x] 1.6 Update documentation to reference `build/compile-and-assemble.js` as the canonical build entry. -- [x] 1.7 Run build/test verification and smoke checks for workspace open/save/preview flows (automated via Cypress). - - -## ADDED Requirements -### Requirement: Refactor and simplification plan -The system SHALL provide a refactor and simplification plan that aligns the codebase with the AI-first coding principles. +## Score table. -#### Scenario: Prioritized recommendations -- **WHEN** a plan is produced -- **THEN** it includes high, medium, and low priority sections -- **AND** only recommends changes that improve alignment with the coding principles -#### Scenario: Deletion and renaming guidance -- **WHEN** a plan is produced -- **THEN** it identifies code or files that can be deleted -- **AND** it calls out candidates for renaming or restructuring + + + + + + + + + + + + + + + + + + + + + + + +
CWEs Mapped + Max Incidence Rate + Avg Incidence Rate + Max Coverage + Avg Coverage + Avg Weighted Exploit + Avg Weighted Impact + Total Occurrences + Total CVEs +
5 + 11.33% + 3.91% + 85.96% + 46.48% + 7.19 + 2.65 + 260,288 + 723 +
-#### Scenario: No-change guidance -- **WHEN** a plan is produced -- **THEN** it states which areas do not need change to avoid unnecessary churn -
- -# Refactor and Simplification Plan -## Audit Summary (Current Coupling) -- `src/app/bootstrap.ts` (~1335 lines) concentrates UI wiring, state mutation, File System Access API flows, tree rendering, preview rendering, and menu/shortcut behavior in one class. This is the primary coupling hotspot. -- `src/app/dom.ts`, `src/app/fs-api.ts`, `src/app/types.ts`, and `src/app/utils.ts` are already small, stable modules, but the higher-level orchestration still lives in `bootstrap.ts`. -- Build pipeline entry is `build/compile-and-assemble.js`, while `build/build.js` and `build/inject.js` act as compatibility aliases for older names. This creates naming ambiguity without functional separation. -- The test build script (`npm run build:test`) bundles multiple small modules into `dist/test/*` with repeated `esbuild` invocations, which is explicit but manually duplicated. +## Description. -## High Priority (Safety + Clarity) -1. Split `InkApp` in `src/app/bootstrap.ts` into small feature modules with explicit interfaces. - - Suggested modules: `menu-actions.ts`, `workspace-io.ts`, `tree-render.ts`, `editor-preview.ts`, `toast-status.ts`, `auto-refresh.ts`. - - Keep a single `AppState` and pass it explicitly into each module to avoid hidden coupling. -2. Flatten UI event wiring into a dedicated initializer. - - Move `attachEventListeners`, `attachMenuEventListeners`, and keyboard shortcuts into a `ui-events.ts` module that accepts `DomRefs`, `AppState`, and a small set of action callbacks. -3. Make state transitions explicit and sequential. - - Extract high-risk mutation flows (open workspace, refresh, save, save-as, close workspace) into functions that return updated state or mutate a specific slice, then re-render. - - This keeps control flow linear and reduces the chance of side effects crossing feature boundaries. +Without logging and monitoring, attacks and breaches cannot be detected, and without alerting it is very difficult to respond quickly and effectively during a security incident. Insufficient logging, continuous monitoring, detection, and alerting to initiate active responses occurs any time: -## Medium Priority (Structure + Naming) -1. Rename `bootstrap.ts` to a clearer responsibility label. - - Suggested: `app-controller.ts` (or `app-core.ts`) while keeping `src/app.ts` as the only entrypoint. -2. Consolidate build script naming to reduce ambiguity. - - Choose one canonical entry (`build/compile-and-assemble.js`) and update `README.md` + `openspec/project.md` to reference it. - - Keep `build/build.js` and `build/inject.js` as temporary aliases only if needed for compatibility. -3. Consolidate repeated `esbuild` calls in `npm run build:test`. - - Introduce a small build helper (e.g., `build/build-test.js`) that centralizes the esbuild config for test bundles and reduces duplication. -## Low Priority (Cleanup) -1. Re-evaluate `dist/test/*` as committed artifacts. - - If they are only generated outputs, consider excluding them from source control after verifying no workflows depend on committed copies. -2. Remove tiny utility duplication when the same logic appears in multiple spots. - - Example targets: repeated toast message formatting, repeated tree render error handling. +* Auditable events, such as logins, failed logins, and high-value transactions, are not logged or logged inconsistently (for instance, only logging successful logins, but not failed attempts). +* Warnings and errors generate no, inadequate, or unclear log messages. +* The integrity of logs is not properly protected from tampering. +* Logs of applications and APIs are not monitored for suspicious activity. +* Logs are only stored locally, and not properly backedup. +* Appropriate alerting thresholds and response escalation processes are not in place or effective. Alerts are not received or reviewed within a reasonable amount of time. +* Penetration testing and scans by dynamic application security testing (DAST) tools (such as Burp or ZAP) do not trigger alerts. +* The application cannot detect, escalate, or alert for active attacks in real-time or near real-time. +* You are vulnerable to sensitive information leakage by making logging and alerting events visible to a user or an attacker (see [A01:2025-Broken Access Control](A01_2025-Broken_Access_Control.md)), or by logging sensitive information that should not be logged (such as PII or PHI). +* You are vulnerable to injections or attacks on the logging or monitoring systems if log data is not correctly encoded. +* The application is missing or mishandling errors and other exceptional conditions, such that the system is unaware there was an error, and is therefore unable to log there was a problem. +* Adequate ‘use cases’ for issuing alerts are missing or outdated to recognize a special situation. +* Too many false positive alerts make it impossible to distinguish important alerts from unimportant ones, resulting in them being recognized too late or not at all (physical overload of the SOC team). +* Detected alerts cannot be processed correctly because the playbook for the use case is incomplete, out of date, or missing. -## Deletions / Renames / Restructures -- **Rename**: `src/app/bootstrap.ts` -> `src/app/app-controller.ts` (keep `src/app.ts` as entrypoint). -- **Restructure**: Extract feature modules from `bootstrap.ts` into separate files under `src/app/` with explicit, flat function exports. -- **Delete (optional, only after confirming no external usage)**: `build/build.js` and `build/inject.js` once documentation and scripts use the canonical build entry. -- **Restructure**: Move test build steps into a dedicated build helper (e.g., `build/build-test.js`) and have `npm run build:test` call it. -## Areas to Keep Unchanged (Avoid Churn) -- `src/app/dom.ts`, `src/app/fs-api.ts`, `src/app/utils.ts`, `src/app/types.ts` are already narrow and should remain stable. -- `src/app.ts` should remain a tiny entrypoint that delegates to app initialization. -- `build/compile-and-assemble.js` should stay the canonical build pipeline entry unless a larger build redesign is approved. -- `ink.template.html` and `src/styles.scss` should not be refactored during maintainability-only changes. +## How to prevent. -## Guardrails -- Preserve user-visible behavior (workspace open/save/edit, sidebar interactions, preview rendering) while refactoring. -- Keep build output as a single self-contained `ink-app.html`. - +Developers should implement some or all the following controls, depending on the risk of the application: - -# Change: Refactor and simplify for LLM-friendly maintenance - -## Why -The core UI/controller logic lives in a single large module, which increases coupling and makes safe regeneration harder. A structured refactor plan will align the codebase with the AI-first principles without changing behavior unnecessarily. -## What Changes -- Produce a refactor and simplification plan aligned to the repo's LLM coding principles. -- Define high/medium/low priority refactor themes with deletion/rename/restructure guidance. -- Call out areas that should remain unchanged to avoid churn. +* Ensure all login, access control, and server-side input validation failures can be logged with sufficient user context to identify suspicious or malicious accounts and held for enough time to allow delayed forensic analysis. +* Ensure that every part of your app that contains a security control is logged, whether it succeeds or fails. +* Ensure that logs are generated in a format that log management solutions can easily consume. +* Ensure log data is encoded correctly to prevent injections or attacks on the logging or monitoring systems. +* Ensure all transactions have an audit trail with integrity controls to prevent tampering or deletion, such as append-only database tables or similar. +* Ensure all transactions that throw an error are rolled back and started over. Always fail closed. +* If your application or its users behave suspiciously, issue an alert. Create guidance for your developers on this topic so they can code against this or buy a system for this. +* DevSecOps and security teams should establish effective monitoring and alerting use cases including playbooks such that suspicious activities are detected and responded to quickly by the Security Operations Center (SOC) team. +* Add ‘honeytokens’ as traps for attackers into your application e.g. into the database, data, as real and/or technical user identity. As they are not used in normal business, any access generates logging data that can be alerted with nearly no false positives. +* Behavior analysis and AI support could be optionally an additional technique to support low rates of false positives for alerts. +* Establish or adopt an incident response and recovery plan, such as National Institute of Standards and Technology (NIST) 800-61r2 or later. Teach your software developers what application attacks and incidents look like, so they can report them. -## Impact -- Affected specs: codebase-maintainability -- Affected code: src/app/bootstrap.ts, src/app/*, build/*, tests/*, docs - +There are commercial and open-source application protection products such as the OWASP ModSecurity Core Rule Set, and open-source log correlation software, such as the Elasticsearch, Logstash, Kibana (ELK) stack, that feature custom dashboards and alerting that may help you combat these issues. There are also commercial observability tools that can help you respond to or block attacks in close to real-time. - -## 1. Planning Output -- [x] 1.1 Audit current modules and build/test pipeline for coupling and duplication. -- [x] 1.2 Draft a prioritized refactor plan with high/medium/low sections. -- [x] 1.3 Identify deletions/renames/restructures that reduce complexity. -- [x] 1.4 Document what should remain unchanged to avoid churn. - - -# Change: Replace Emojis with SVG Icons +## Example attack scenarios. -## Why +**Scenario #1:** A children's health plan provider's website operator couldn't detect a breach due to a lack of monitoring and logging. An external party informed the health plan provider that an attacker had accessed and modified thousands of sensitive health records of more than 3.5 million children. A post-incident review found that the website developers had not addressed significant vulnerabilities. As there was no logging or monitoring of the system, the data breach could have been in progress since 2013, a period of more than seven years. -The application currently uses Unicode emojis (📁, 📂, 📝, 🗂️, ✓) as UI icons across the file tree and status messages. Emojis render inconsistently across operating systems, browsers, and display densities — a folder icon looks different on macOS, Windows, and Linux. This inconsistency undermines the clean, focused aesthetic of Ink and gives the interface an informal, unpolished appearance. +**Scenario #2:** A major Indian airline had a data breach involving more than ten years' worth of personal data of millions of passengers, including passport and credit card data. The data breach occurred at a third-party cloud hosting provider, who notified the airline of the breach after some time. -Replacing emojis with a curated set of inline SVG icons delivers pixel-perfect, resolution-independent rendering everywhere, brings visual consistency, and allows full control over color, size, and hover states through CSS — resulting in a significantly more modern and professional look. +**Scenario #3:** A major European airline suffered a GDPR reportable breach. The breach was reportedly caused by payment application security vulnerabilities exploited by attackers, who harvested more than 400,000 customer payment records. The airline was fined 20 million pounds as a result by the privacy regulator. -## What Changes -- Select a lightweight, offline-compatible SVG icon set (e.g. Lucide Icons or Heroicons) that can be inlined directly into the HTML template and TypeScript source, preserving the no-external-dependencies constraint. -- Define a small icon registry/helper that produces `` strings for each named icon, reusable across template and script code. -- Replace all emoji usage in `src/app/tree-render.ts` (📁, 📂, 📝) with the corresponding inline SVG icons. -- Replace the emoji in `ink.template.html` (🗂️ in the "Open Workspace" sidebar button) with an inline SVG icon. -- Replace the plain-text checkmark (✓) used in status and toast messages in `src/app/workspace-io.ts` with a styled SVG check icon or a dedicated CSS-driven indicator class, ensuring screen readers still receive meaningful text. -- Add SCSS rules to size, color, and align the new SVG icons consistently with surrounding text and buttons. -- Ensure no regressions in layout, accessibility (ARIA labels remain intact), or build output (single-file constraint). +## References. -## Impact +- [OWASP Proactive Controls: C9: Implement Logging and Monitoring](https://top10proactive.owasp.org/archive/2024/the-top-10/c9-security-logging-and-monitoring/) -- Affected files: - - `src/app/tree-render.ts` — emoji icon strings replaced with SVG helper calls - - `src/app/workspace-io.ts` — checkmark emoji in status/toast strings replaced - - `ink.template.html` — 🗂️ emoji replaced in the sidebar Open Workspace button - - `src/styles.scss` — new icon sizing and color utility rules - - `build/compile-and-assemble.js` — verify SVG strings survive the inline assembly step without encoding issues -- Affected specs: none existing -- No new runtime dependencies introduced; SVG markup is embedded at build time -- Build output remains a single self-contained HTML file - +- [OWASP Application Security Verification Standard: V16 Security Logging and Error Handling](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x25-V16-Security-Logging-and-Error-Handling.md) - -## MODIFIED Requirements +- [OWASP Cheat Sheet: Application Logging Vocabulary](https://cheatsheetseries.owasp.org/cheatsheets/Application_Logging_Vocabulary_Cheat_Sheet.html) -### Requirement: Keyboard Shortcuts for Common Operations -The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. +- [OWASP Cheat Sheet: Logging](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) -#### Scenario: New Note shortcut -- **WHEN** a user presses the platform-appropriate shortcut (Ctrl+E on Windows/Linux, Cmd+E on Mac) -- **THEN** the createNewNote functionality is triggered -- **AND** the same behavior as clicking "New Note" menu item occurs -- **AND** the shortcut displayed in the menu reflects the user's platform +- [Data Integrity: Recovering from Ransomware and Other Destructive Events](https://csrc.nist.gov/publications/detail/sp/1800-11/final) -#### Scenario: Open Workspace shortcut -- **WHEN** a user presses the platform-appropriate shortcut (Ctrl+Shift+O on Windows/Linux, Cmd+Shift+O on Mac) -- **THEN** the openWorkspace functionality is triggered -- **AND** the shortcut displayed in the menu reflects the user's platform +- [Data Integrity: Identifying and Protecting Assets Against Ransomware and Other Destructive Events](https://csrc.nist.gov/publications/detail/sp/1800-25/final) -#### Scenario: Save shortcut -- **WHEN** a user presses the platform-appropriate shortcut (Ctrl+S on Windows/Linux, Cmd+S on Mac) -- **THEN** the saveCurrentNote functionality is triggered -- **AND** the shortcut displayed in the menu reflects the user's platform +- [Data Integrity: Detecting and Responding to Ransomware and Other Destructive Events](https://csrc.nist.gov/publications/detail/sp/1800-26/final) -#### Scenario: Refresh shortcut -- **WHEN** a user presses the platform-appropriate shortcut (Ctrl+L on Windows/Linux, Cmd+L on Mac) -- **THEN** the rescanWorkspace functionality is triggered -- **AND** the shortcut displayed in the menu reflects the user's platform +- [Real world example of such failures in Snowflake Breach](https://www.huntress.com/threat-library/data-breach/snowflake-data-breach) -#### Scenario: Export JSON shortcut -- **WHEN** a user presses the platform-appropriate shortcut (Ctrl+Shift+S on Windows/Linux, Cmd+Shift+S on Mac) -- **THEN** the exportAsJson functionality is triggered -- **AND** the shortcut displayed in the menu reflects the user's platform -#### Scenario: Export Markdown shortcut -- **WHEN** a user presses the platform-appropriate shortcut (Ctrl+Shift+M on Windows/Linux, Cmd+Shift+M on Mac) -- **THEN** the exportAsMarkdown functionality is triggered -- **AND** the shortcut displayed in the menu reflects the user's platform +## List of Mapped CWEs -### Requirement: Platform-Aware Shortcut Display -The application SHALL detect the user's operating system and display the appropriate keyboard shortcut modifiers. +* [CWE-117 Improper Output Neutralization for Logs](https://cwe.mitre.org/data/definitions/117.html) -#### Scenario: Platform detection on load -- **WHEN** the application loads -- **THEN** the system detects whether the user is on macOS or Windows/Linux -- **AND** all menu shortcuts are rendered with the appropriate modifier key +* [CWE-221 Information Loss of Omission](https://cwe.mitre.org/data/definitions/221.html) -#### Scenario: macOS shortcut display -- **WHEN** the application runs on macOS -- **THEN** all keyboard shortcuts display "Cmd" as the modifier -- **AND** shortcut examples include Cmd+E, Cmd+S, Cmd+Shift+O +* [CWE-223 Omission of Security-relevant Information](https://cwe.mitre.org/data/definitions/223.html) -#### Scenario: Windows/Linux shortcut display -- **WHEN** the application runs on Windows or Linux -- **THEN** all keyboard shortcuts display "Ctrl" as the modifier -- **AND** shortcut examples include Ctrl+E, Ctrl+S, Ctrl+Shift+O +* [CWE-532 Insertion of Sensitive Information into Log File](https://cwe.mitre.org/data/definitions/532.html) -### Requirement: Platform Detection Test Coverage -The application SHALL have automated tests to verify platform-aware shortcut behavior. +* [CWE-778 Insufficient Logging](https://cwe.mitre.org/data/definitions/778.html) + -#### Scenario: Platform detection test on macOS -- **WHEN** automated tests mock navigator.platform to return "MacIntel" or similar -- **THEN** the platform detection function returns true for Mac -- **AND** shortcuts display "Cmd" in the UI + +# A10:2025 Mishandling of Exceptional Conditions ![icon](../assets/TOP_10_Icons_Final_Mishandling_of_Exceptional_Conditions.png){: style="height:80px;width:80px" align="right"} -#### Scenario: Platform detection test on Windows -- **WHEN** automated tests mock navigator.platform to return "Win32" -- **THEN** the platform detection function returns false for Mac -- **AND** shortcuts display "Ctrl" in the UI -#### Scenario: Keyboard shortcut integration tests -- **WHEN** Cypress tests simulate keyboard shortcuts -- **THEN** the correct action is triggered for each platform-appropriate shortcut -- **AND** no regressions occur in existing shortcut functionality - +## Background. - -## MODIFIED Requirements +Mishandling of Exceptional Conditions is a new category for 2025. This category contains 24 CWEs and focuses on improper error handling, logical errors, failing open, and other related scenarios stemming from abnormal conditions and systems may encounter. This category has some CWEs that were previously associated with poor code quality. That was too general for us; in our opinion, this more specific category provides better guidance. -### Requirement: Menu Bar Structure -The application SHALL provide a horizontal menu bar at the top of the application window containing File, Edit, and Export menu items. +Notable CWEs included in this category: *CWE-209 Generation of Error Message Containing Sensitive Information, CWE-234 Failure to Handle Missing Parameter, CWE-274 Improper Handling of Insufficient Privileges, CWE-476 NULL Pointer Dereference,* and *CWE-636 Not Failing Securely ('Failing Open')*. -#### 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 Export menu items -#### 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 +## Score table. -### Requirement: File Menu Functionality -The File menu SHALL provide access to workspace, file management, and export operations. -#### 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 + + + + + + + + + + + + + + + + + + + + + + + +
CWEs Mapped + Max Incidence Rate + Avg Incidence Rate + Max Coverage + Avg Coverage + Avg Weighted Exploit + Avg Weighted Impact + Total Occurrences + Total CVEs +
24 + 20.67% + 2.95% + 100.00% + 37.95% + 7.11 + 3.81 + 769,581 + 3,416 +
-#### 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: 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: 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 +## Description. -### Requirement: Export Submenu Functionality -The Export submenu under File SHALL provide access to data export operations. +Mishandling exceptional conditions in software happens when programs fail to prevent, detect, and respond to unusual and unpredictable situations, which leads to crashes, unexpected behavior, and sometimes vulnerabilities. This can involve one or more of the following 3 failings; the application doesn’t prevent an unusual situation from happening, it doesn’t identify the situation as it is happening, and/or it responds poorly or not at all to the situation afterwards. -#### Scenario: Export submenu is accessible -- **WHEN** a user clicks the File menu -- **THEN** an Export submenu is visible containing export options + -#### Scenario: Export JSON menu item -- **WHEN** a user clicks "Export JSON" in the File > Export submenu -- **THEN** the existing exportAsJson functionality is triggered -- **AND** the same behavior as clicking the "Export JSON" button occurs +Exceptional conditions can be caused by missing, poor, or incomplete input validation, or late, high level error handling instead at the functions where they occur, or unexpected environmental states such as memory, privilege, or network issues, inconsistent exception handling, or exceptions that are not handled at all, allowing the system to fall into an unknown and unpredictable state. Any time an application is unsure of its next instruction, an exceptional condition has been mishandled. Hard-to-find errors and exceptions can threaten the security of the whole application for a long time. -#### Scenario: Export Markdown menu item -- **WHEN** a user clicks "Export Markdown" in the File > Export submenu -- **THEN** the existing exportAsMarkdown functionality is triggered -- **AND** the same behavior as clicking the "Export MD" button occurs + -### Requirement: Menu Structure Test Coverage -The application SHALL have automated tests to verify the menu restructuring and export submenu functionality. +Many different security vulnerabilities can happen when we mishandle exceptional conditions, -#### Scenario: Export submenu existence test -- **WHEN** Cypress tests check the DOM structure -- **THEN** the Export submenu exists under the File menu -- **AND** both Export JSON and Export Markdown items are present +such as logic bugs, overflows, race conditions, fraudulent transactions, or issues with memory, state, resource, timing, authentication, and authorization. These types of vulnerabilities can negatively affect the confidentiality, availability, and/or integrity of a system or it’s data. Attackers manipulate an application's flawed error handling to strike this vulnerability. -#### Scenario: Export submenu functionality test -- **WHEN** Cypress tests click Export JSON via File > Export submenu -- **THEN** the exportAsJson function is triggered -- **AND** the expected JSON file is downloaded -#### Scenario: No regressions in existing menu items -- **WHEN** Cypress tests click each menu item -- **THEN** all existing functionality continues to work -- **AND** no breaking changes occur to File, Edit, or View menus -
+## How to prevent. - -# Change: Update Menu Shortcuts for Platform Awareness +In order to handle an exceptional condition properly we must plan for such situations (expect the worst). We must ‘catch’ every possible system error directly at the place where they occur and then handle it (which means do something meaningful to solve the problem and ensure we recover from the issue). As part of the handling, we should include throwing an error (to inform the user in an understandable way), logging of the event, as well as issuing an alert if we feel that is justified. We should also have a global exception handler in place in case there is ever something we have missed. Ideally, we would also have monitoring and/or observability tooling or functionality that watches for repeated errors or patterns that indicate an on-going attack, that could issue a response, defense, or blocking of some kind. This can help us block and respond to scripts and bots that focus on our error handling weaknesses. -## Why + -The menu bar currently displays incorrect keyboard shortcuts that don't account for platform differences between macOS (Cmd key) and Windows/Linux (Ctrl key). Additionally, the "Import/Export" menu should be renamed to just "Export" and restructured as a submenu under the File menu for better organization. +Catching and handling exceptional conditions ensures that the underlying infrastructure of our programs are not left to deal with unpredictable situations. If you are part way through a transaction of any kind, it is extremely important that you roll back every part of the transaction and start again (also known as failing closed). Attempting to recover a transaction part way through is often where we create unrecoverable mistakes. -## What Changes + -- Add platform detection to determine if user is on macOS or Windows/Linux -- Update keyboard shortcut display to show Cmd+E on macOS and Ctrl+E on Windows/Linux -- Rename "Import/Export" menu to "Export" -- Move Export items (JSON, Markdown) to be a submenu under File menu -- Update all shortcut displays throughout the menu to be platform-aware -- Update keyboard-shortcuts spec scenarios to ensure platform-aware behavior is documented +Whenever possible, add rate limiting, resource quotas, throttling, and other limits wherever possible, to prevent exceptional conditions in the first place. Nothing in information technology should be limitless, as this leads to a lack of application resilience, denial of service, successful brute force attacks, and extraordinary cloud bills. \ +Consider whether identical repeated errors, above a certain rate, should only be outputted as statistics showing how often they have occurred and in what time frame. This information should be appended to the original message so as not to interfere with automated logging and monitoring, see [A09:2025 Security Logging & Alerting Failures](A09_2025-Security_Logging_and_Alerting_Failures.md). -## Non-Regression Guarantees +On top of this, we would want to include strict input validation (with sanitization or escaping for potentially hazardous characters that we must accept), and *centralized* error handling, logging, monitoring, and alerting, and a global exception handler. One application should not multiple functions for handling exceptional conditions, it should be performed in one place, the same way each time. We should also create project security requirements for all the advice in this section, perform threat modelling and/or secure design review activities in the design phase of our projects, perform code review or static analysis, as well as execute stress, performance, and penetration testing of the final system. -- All existing menu functionality must continue to work (New Note, New Folder, Open/Close Workspace, Save, Refresh, Sort, Collapse Sidebar) -- All keyboard shortcuts must continue to trigger the correct actions -- Theme switching must continue to work -- The export functionality (JSON and Markdown) must continue to work exactly as before -- The UI layout and styling must remain consistent + -## Testing Requirements +If possible, your entire organization should handle exceptional conditions in the same way, as it makes it easier to review and audit code for errors in this important security control. -- Add Cypress tests for platform-aware shortcut detection -- Add Cypress tests for Export submenu visibility and functionality -- Add tests to verify all menu items still trigger correct actions after restructuring -- Add tests to verify keyboard shortcuts work on both Mac and Windows/Linux platforms -## Impact +## Example attack scenarios. -- Affected specs: `menu-bar`, `keyboard-shortcuts` -- Affected code: Menu rendering in `src/app/dom.ts` or `src/app/bootstrap.ts` -- User experience: Shortcuts will correctly reflect user's platform - +**Scenario #1:** Resource exhaustion via mishandling of exceptional conditions (Denial of Service) could be caused if the application catches exceptions when files are uploaded, but doesn’t properly release resources after. Each new exception leaves resources locked or otherwise unavailable, until all resources are used up. - -## 1. Implementation +**Scenario #2:** Sensitive data exposure via improper handling or database errors that reveals the full system error to the user. The attacker continues to force errors in order to use the sensitive system information to create a better SQL injection attack. The sensitive data in the user error messages are reconnaissance. -- [x] 1.1 Add platform detection utility function to detect macOS vs Windows/Linux -- [x] 1.2 Update menu rendering to use platform-appropriate modifier (Cmd vs Ctrl) at initialization -- [x] 1.3 Rename "Import/Export" menu to "Export" in the menu structure -- [x] 1.4 Move Export JSON and Export Markdown items under File menu as submenu -- [x] 1.5 Update keyboard shortcut scenarios in spec to reflect platform-aware behavior +**Scenario #3:** State corruption in financial transactions could be caused by an attacker interrupting a multi-step transaction via network disruptions. Imagine the transaction order was: debit user account, credit destination account, log transaction. If the system doesn’t properly roll back the entire transaction (fail closed) when there is an error part way through, the attacker could potentially drain the user’s account, or possibly a race condition that allows the attacker to send money to the destination multiple times. -## 2. Regression Prevention -- [x] 2.1 Verify all File menu items work (New Note, New Folder, Open/Close Workspace, Exit) -- [x] 2.2 Verify all Edit menu items work (Save, Save As, Refresh, Sort, Collapse Sidebar) -- [x] 2.3 Verify all View menu items work (theme switching) -- [x] 2.4 Verify Export submenu items work (Export JSON, Export Markdown) -- [x] 2.5 Verify keyboard shortcuts still trigger correct actions -- [x] 2.6 Verify existing toolbar buttons still work alongside menu +## References. -## 3. Testing +OWASP MASVS‑RESILIENCE -- [x] 3.1 Add Cypress test for platform detection (mock navigator.platform) -- [x] 3.2 Add Cypress test for correct shortcut display on macOS -- [x] 3.3 Add Cypress test for correct shortcut display on Windows/Linux -- [x] 3.4 Add Cypress test for Export submenu visibility under File menu -- [x] 3.5 Add Cypress test for Export JSON functionality via menu -- [x] 3.6 Add Cypress test for Export Markdown functionality via menu -- [x] 3.7 Add Cypress test for keyboard shortcut triggers (Cmd+E, Ctrl+E, etc.) +- [OWASP Cheat Sheet: Logging](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) -## 4. Validation +- [OWASP Cheat Sheet: Error Handling](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html) -- [x] 4.1 Verify shortcuts display correctly on macOS (Cmd) -- [x] 4.2 Verify shortcuts display correctly on Windows/Linux (Ctrl) -- [x] 4.3 Verify Export submenu appears under File menu -- [x] 4.4 Verify existing menu functionality still works - +- [OWASP Application Security Verification Standard (ASVS): V16.5 Error Handling](https://github.com/OWASP/ASVS/blob/master/5.0/en/0x25-V16-Security-Logging-and-Error-Handling.md#v165-error-handling) - -# A01:2025 Broken Access Control ![icon](../assets/TOP_10_Icons_Final_Broken_Access_Control.png){: style="height:80px;width:80px" align="right"} +- [OWASP Testing Guide: 4.8.1 Testing for Error Handling](https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/08-Testing_for_Error_Handling/01-Testing_For_Improper_Error_Handling) +* [Best practices for exceptions (Microsoft, .Net)](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions) +* [Clean Code and the Art of Exception Handling (Toptal)](https://www.toptal.com/developers/abap/clean-code-and-the-art-of-exception-handling) -## Background. +* [General error handling rules (Google for Developers)](https://developers.google.com/tech-writing/error-messages/error-handling) -Maintaining its position at #1 in the Top Ten, 100% of the applications tested were found to have some form of broken access control. Notable CWEs included are *CWE-200: Exposure of Sensitive Information to an Unauthorized Actor*, *CWE-201: Exposure of Sensitive Information Through Sent Data*, *CWE-918 Server-Side Request Forgery (SSRF)*, and *CWE-352: Cross-Site Request Forgery (CSRF)*. This category has the highest number of occurrences in the contributed data, and second highest number of related CVEs. +* [Example of real-world mishandling of an exceptional condition](https://www.firstreference.com/blog/human-error-and-internal-control-failures-cause-us62m-fine/) +## List of Mapped CWEs +* [CWE-209 Generation of Error Message Containing Sensitive Information](https://cwe.mitre.org/data/definitions/209.html) +* [CWE-215 Insertion of Sensitive Information Into Debugging Code](https://cwe.mitre.org/data/definitions/215.html) +* [CWE-234 Failure to Handle Missing Parameter](https://cwe.mitre.org/data/definitions/234.html) +* [CWE-235 Improper Handling of Extra Parameters](https://cwe.mitre.org/data/definitions/235.html) +* [CWE-248 Uncaught Exception](https://cwe.mitre.org/data/definitions/248.html) +* [CWE-252 Unchecked Return Value](https://cwe.mitre.org/data/definitions/252.html) +* [CWE-274 Improper Handling of Insufficient Privileges](https://cwe.mitre.org/data/definitions/274.html) +* [CWE-280 Improper Handling of Insufficient Permissions or Privileges](https://cwe.mitre.org/data/definitions/280.html) +* [CWE-369 Divide By Zero](https://cwe.mitre.org/data/definitions/369.html) +* [CWE-390 Detection of Error Condition Without Action](https://cwe.mitre.org/data/definitions/390.html) +* [CWE-391 Unchecked Error Condition](https://cwe.mitre.org/data/definitions/391.html) +* [CWE-394 Unexpected Status Code or Return Value](https://cwe.mitre.org/data/definitions/394.html) +* [CWE-396 Declaration of Catch for Generic Exception](https://cwe.mitre.org/data/definitions/396.html) +* [CWE-397 Declaration of Throws for Generic Exception](https://cwe.mitre.org/data/definitions/397.html) +* [CWE-460 Improper Cleanup on Thrown Exception](https://cwe.mitre.org/data/definitions/460.html) +* [CWE-476 NULL Pointer Dereference](https://cwe.mitre.org/data/definitions/476.html) +* [CWE-478 Missing Default Case in Multiple Condition Expression](https://cwe.mitre.org/data/definitions/478.html) +* [CWE-484 Omitted Break Statement in Switch](https://cwe.mitre.org/data/definitions/484.html) +* [CWE-550 Server-generated Error Message Containing Sensitive Information](https://cwe.mitre.org/data/definitions/550.html) +* [CWE-636 Not Failing Securely ('Failing Open')](https://cwe.mitre.org/data/definitions/636.html) +* [CWE-703 Improper Check or Handling of Exceptional Conditions](https://cwe.mitre.org/data/definitions/703.html) +* [CWE-754 Improper Check for Unusual or Exceptional Conditions](https://cwe.mitre.org/data/definitions/754.html) +* [CWE-755 Improper Handling of Exceptional Conditions](https://cwe.mitre.org/data/definitions/755.html) +* [CWE-756 Missing Custom Error Page](https://cwe.mitre.org/data/definitions/756.html) + -## Score table. + +# OpenSpec Instructions +Instructions for AI coding assistants using OpenSpec for spec-driven development. - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
40 - 20.15% - 3.74% - 100.00% - 42.93% - 7.04 - 3.84 - 1,839,701 - 32,654 -
+## TL;DR Quick Checklist +- 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 +## Three-Stage Workflow -## Description. +### 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 -Access control enforces policy such that users cannot act outside of their intended permissions. Failures typically lead to unauthorized information disclosure, modification or destruction of all data, or performing a business function outside the user's limits. Common access control vulnerabilities include: +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" +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior -* Violation of the principle of least privilege, commonly known as deny by default, where access should only be granted for particular capabilities, roles, or users, but is available to anyone. -* Bypassing access control checks by modifying the URL (parameter tampering or force browsing), internal application state, or the HTML page, or by using an attack tool that modifies API requests. -* Permitting viewing or editing someone else's account by providing its unique identifier (insecure direct object references) -* An accessible API with missing access controls for POST, PUT, and DELETE. -* Elevation of privilege. Acting as a user without being logged in or or gaining privileges beyond those expected of the logged in user (e.g. admin access). -* Metadata manipulation, such as replaying or tampering with a JSON Web Token (JWT) access control token, a cookie or hidden field manipulated to elevate privileges, or abusing JWT invalidation. -* CORS misconfiguration allows API access from unauthorized or untrusted origins. -* Force browsing (guessing URLs) to authenticated pages as an unauthenticated user or to privileged pages as a standard user. +**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. +### 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 -## How to prevent. +### 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 -Access control is only effective when implemented in trusted server-side code or serverless APIs, where the attacker cannot modify the access control check or metadata. +## Before Any Task +**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 +**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 -* Except for public resources, deny by default. -* Implement access control mechanisms once and reuse them throughout the application, including minimizing Cross-Origin Resource Sharing (CORS) usage. -* Model access controls should enforce record ownership rather than allowing users to create, read, update, or delete any record. -* Unique application business limit requirements should be enforced by domain models. -* Disable web server directory listing and ensure file metadata (e.g., .git) and backup files are not present within web roots. -* Log access control failures, alert admins when appropriate (e.g., repeated failures). -* Implement rate limits on API and controller access to minimize the harm from automated attack tooling. -* Stateful session identifiers should be invalidated on the server after logout. Stateless JWT tokens should be short-lived to minimize the window of opportunity for an attacker. For longer-lived JWTs, consider using refresh tokens and following OAuth standards to revoke access. -* Use well-established toolkits or patterns that provide simple, declarative access controls. +### 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` -Developers and QA staff should include functional access control in their unit and integration tests. +## Quick Start +### CLI Commands -## Example attack scenarios. +```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) -**Scenario #1:** The application uses unverified data in an SQL call that is accessing account information: +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode -``` -pstmt.setString(1, request.getParameter("acct")); -ResultSet results = pstmt.executeQuery( ); +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict ``` +### Command Flags -An attacker can simply modify the browser's 'acct' parameter to send any desired account number. If not correctly verified, the attacker can access any user's account. +- `--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) +## Directory Structure ``` -https://example.com/app/accountInfo?acct=notmyacct +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 ``` +## Creating Change Proposals -**Scenario #2:** An attacker simply forces browsers to target URLs. Admin rights are required for access to the admin page. - +### Decision Tree ``` -https://example.com/app/getappInfo -https://example.com/app/admin_getappInfo +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) ``` +### Proposal Structure -If an unauthenticated user can access either page, it's a flaw. If a non-admin can access the admin page, this is a flaw. +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) -**Scenario #3:** An application puts all of their access control in their front-end. While the attacker cannot get to `https://example.com/app/admin_getappInfo` due to JavaScript code running in the browser, they can simply execute: +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] +## Why +[1-2 sentences on problem/opportunity] -``` -$ curl https://example.com/app/admin_getappInfo -``` +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` -from the command line. +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result -## References. +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] -* [OWASP Proactive Controls: C1: Implement Access Control](https://top10proactive.owasp.org/archive/2024/the-top-10/c1-accesscontrol/) -* [OWASP Application Security Verification Standard: V8 Authorization](https://github.com/OWASP/ASVS/blob/master/5.0/en/0x17-V8-Authorization.md) -* [OWASP Testing Guide: Authorization Testing](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/05-Authorization_Testing/README) -* [OWASP Cheat Sheet: Authorization](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html) -* [PortSwigger: Exploiting CORS misconfiguration](https://portswigger.net/blog/exploiting-cors-misconfigurations-for-bitcoins-and-bounties) -* [OAuth: Revoking Access](https://www.oauth.com/oauth2-servers/listing-authorizations/revoking-access/) +## 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. +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 +``` -## List of Mapped CWEs +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 -* [CWE-22 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')](https://cwe.mitre.org/data/definitions/22.html) +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] -* [CWE-23 Relative Path Traversal](https://cwe.mitre.org/data/definitions/23.html) +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] -* [CWE-36 Absolute Path Traversal](https://cwe.mitre.org/data/definitions/36.html) +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] -* [CWE-59 Improper Link Resolution Before File Access ('Link Following')](https://cwe.mitre.org/data/definitions/59.html) +## Risks / Trade-offs +- [Risk] → Mitigation -* [CWE-61 UNIX Symbolic Link (Symlink) Following](https://cwe.mitre.org/data/definitions/61.html) +## Migration Plan +[Steps, rollback] -* [CWE-65 Windows Hard Link](https://cwe.mitre.org/data/definitions/65.html) +## Open Questions +- [...] +``` -* [CWE-200 Exposure of Sensitive Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/200.html) +## Spec File Format -* [CWE-201 Exposure of Sensitive Information Through Sent Data](https://cwe.mitre.org/data/definitions/201.html) +### Critical: Scenario Formatting -* [CWE-219 Storage of File with Sensitive Data Under Web Root](https://cwe.mitre.org/data/definitions/219.html) +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` -* [CWE-276 Incorrect Default Permissions](https://cwe.mitre.org/data/definitions/276.html) +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` -* [CWE-281 Improper Preservation of Permissions](https://cwe.mitre.org/data/definitions/281.html) +Every requirement MUST have at least one scenario. -* [CWE-282 Improper Ownership Management](https://cwe.mitre.org/data/definitions/282.html) +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) -* [CWE-283 Unverified Ownership](https://cwe.mitre.org/data/definitions/283.html) +### Delta Operations -* [CWE-284 Improper Access Control](https://cwe.mitre.org/data/definitions/284.html) +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes -* [CWE-285 Improper Authorization](https://cwe.mitre.org/data/definitions/285.html) +Headers matched with `trim(header)` - whitespace ignored. -* [CWE-352 Cross-Site Request Forgery (CSRF)](https://cwe.mitre.org/data/definitions/352.html) +#### 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. -* [CWE-359 Exposure of Private Personal Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/359.html) +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. -* [CWE-377 Insecure Temporary File](https://cwe.mitre.org/data/definitions/377.html) +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:`. -* [CWE-379 Creation of Temporary File in Directory with Insecure Permissions](https://cwe.mitre.org/data/definitions/379.html) +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` -* [CWE-402 Transmission of Private Resources into a New Sphere ('Resource Leak')](https://cwe.mitre.org/data/definitions/402.html) +## Troubleshooting -* [CWE-424 Improper Protection of Alternate Path](https://cwe.mitre.org/data/definitions/424.html) +### Common Errors -* [CWE-425 Direct Request ('Forced Browsing')](https://cwe.mitre.org/data/definitions/425.html) +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) -* [CWE-441 Unintended Proxy or Intermediary ('Confused Deputy')](https://cwe.mitre.org/data/definitions/441.html) +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers -* [CWE-497 Exposure of Sensitive System Information to an Unauthorized Control Sphere](https://cwe.mitre.org/data/definitions/497.html) +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` -* [CWE-538 Insertion of Sensitive Information into Externally-Accessible File or Directory](https://cwe.mitre.org/data/definitions/538.html) +### Validation Tips -* [CWE-540 Inclusion of Sensitive Information in Source Code](https://cwe.mitre.org/data/definitions/540.html) +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict -* [CWE-548 Exposure of Information Through Directory Listing](https://cwe.mitre.org/data/definitions/548.html) +# Debug delta parsing +openspec show [change] --json | jq '.deltas' -* [CWE-552 Files or Directories Accessible to External Parties](https://cwe.mitre.org/data/definitions/552.html) +# Check specific requirement +openspec show [spec] --json -r 1 +``` -* [CWE-566 Authorization Bypass Through User-Controlled SQL Primary Key](https://cwe.mitre.org/data/definitions/566.html) +## Happy Path Script -* [CWE-601 URL Redirection to Untrusted Site ('Open Redirect')](https://cwe.mitre.org/data/definitions/601.html) +```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 -* [CWE-615 Inclusion of Sensitive Information in Source Code Comments](https://cwe.mitre.org/data/definitions/615.html) +# 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 -* [CWE-639 Authorization Bypass Through User-Controlled Key](https://cwe.mitre.org/data/definitions/639.html) +# 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. -* [CWE-668 Exposure of Resource to Wrong Sphere](https://cwe.mitre.org/data/definitions/668.html) +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF -* [CWE-732 Incorrect Permission Assignment for Critical Resource](https://cwe.mitre.org/data/definitions/732.html) +# 4) Validate +openspec validate $CHANGE --strict +``` -* [CWE-749 Exposed Dangerous Method or Function](https://cwe.mitre.org/data/definitions/749.html) +## Multi-Capability Example -* [CWE-862 Missing Authorization](https://cwe.mitre.org/data/definitions/862.html) +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` -* [CWE-863 Incorrect Authorization](https://cwe.mitre.org/data/definitions/863.html) +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` -* [CWE-918 Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html) - -* [CWE-922 Insecure Storage of Sensitive Information](https://cwe.mitre.org/data/definitions/922.html) - -* [CWE-1275 Sensitive Cookie with Improper SameSite Attribute](https://cwe.mitre.org/data/definitions/1275.html) -
+notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` - -# A02:2025 Security Misconfiguration ![icon](../assets/TOP_10_Icons_Final_Security_Misconfiguration.png){: style="height:80px;width:80px" align="right"} +## Best Practices +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns -## Background. +### 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 -Moving up from #5 in the previous edition, 100% of the applications tested were found to have some form of misconfiguration, with an average incidence rate of 3.00%, and over 719k occurrences of a Common Weakness Enumeration (CWE) in this risk category. With more shifts into highly configurable software, it's not surprising to see this category moving up. Notable CWEs included are *CWE-16 Configuration* and *CWE-611 Improper Restriction of XML External Entity Reference (XXE)*. +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" -## Score table. +### 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. +## Tool Selection Guide - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
16 - 27.70% - 3.00% - 100.00% - 52.35% - 7.96 - 3.97 - 719,084 - 1,375 -
+| 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 | +## Error Recovery +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals -## Description. +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted -Security misconfiguration is when a system, application, or cloud service is set up incorrectly from a security perspective, creating vulnerabilities. +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification -The application might be vulnerable if: +## Quick Reference +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior -* It is missing appropriate security hardening across any part of the application stack or improperly configured permissions on cloud services. -* Unnecessary features are enabled or installed (e.g., unnecessary ports, services, pages, accounts, testing frameworks, or privileges). -* Default accounts and their passwords are still enabled and unchanged. -* A lack of central configuration for intercepting excessive error messages. Error handling reveals stack traces or other overly informative error messages to users. -* For upgraded systems, the latest security features are disabled or not configured securely. -* Excessive prioritization of backward compatibility leading to insecure configuration. -* The security settings in the application servers, application frameworks (e.g., Struts, Spring, ASP.NET), libraries, databases, etc., are not set to secure values. -* The server does not send security headers or directives, or they are not set to secure values. +### 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) +``` -Without a concerted, repeatable application security configuration hardening process, systems are at a higher risk. +Remember: Specs are truth. Changes are proposals. Keep them in sync. +
+ +/** + * Auth UI Controller + * Manages auth-related UI and connects auth modules with the app + * @module auth/auth-controller + */ -## How to prevent. +import type { DomRefs, GitHubUser } from "./types"; +import type { GitHubAuthManager } from "../auth/github"; +import type { GitHubUserManager } from "../auth/user"; -Secure installation processes should be implemented, including: +export interface AuthController { + initialize: () => void; + getCurrentUser: () => GitHubUser | null; + isAuthenticated: () => boolean; + getToken: () => string | 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 = ""; + } + } -* A repeatable hardening process enabling the fast and easy deployment of another environment that is appropriately locked down. Development, QA, and production environments should all be configured identically, with different credentials used in each environment. This process should be automated to minimize the effort required to set up a new secure environment. -* A minimal platform without any unnecessary features, components, documentation, or samples. Remove or do not install unused features and frameworks. -* A task to review and update the configurations appropriate to all security notes, updates, and patches as part of the patch management process (see [A03 Software Supply Chain Failures](A03_2025-Software_Supply_Chain_Failures.md)). Review cloud storage permissions (e.g., S3 bucket permissions). -* A segmented application architecture provides effective and secure separation between components or tenants, with segmentation, containerization, or cloud security groups (ACLs). -* Sending security directives to clients, e.g., Security Headers. -* An automated process to verify the effectiveness of the configurations and settings in all environments. -* Proactively add a central configuration to intercept excessive error messages as a backup. -* If these verifications are not automated, they should be manually verified annually at a minimum. -* Use identity federation, short-lived credentials, or role-based access mechanisms provided by the underlying platform instead of embedding static keys or secrets in code, configuration files, or pipelines. + 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 = ""; + } + } + function handleAuthError(error: Error): void { + els.authError.textContent = error.message; + els.authError.style.display = "block"; + els.authStatus.textContent = ""; + } -## Example attack scenarios. + function handleAuthStep(message: string | null): void { + if (message) { + els.authStatus.textContent = message; + els.authError.style.display = "none"; + } else { + els.authStatus.textContent = ""; + } + } -**Scenario #1:** The application server comes with sample applications not removed from the production server. These sample applications have known security flaws that attackers use to compromise the server. Suppose one of these applications is the admin console, and default accounts weren't changed. In that case, the attacker logs in with the default password and takes over. + async function startLogin(): Promise { + try { + els.authError.style.display = "none"; -**Scenario #2:** Directory listing is not disabled on the server. An attacker discovers they can simply list directories. The attacker finds and downloads the compiled Java classes, which they decompile and reverse engineer to view the code. The attacker then finds a severe access control flaw in the application. + // Start the OAuth flow - this will redirect to GitHub + await authManager.startAuthFlow(); -**Scenario #3:** The application server's configuration allows detailed error messages, such as stack traces to be returned to users. This potentially exposes sensitive information or underlying flaws, such as component versions that are known to be vulnerable. + // 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))); + } + } + } -**Scenario #4:** A cloud service provider (CSP) defaults to having sharing permissions open to the Internet. This allows sensitive data stored within cloud storage to be accessed. + function handleLogout(): void { + authManager.logout(); + userManager.clearCache(); + updateAuthUI(false); + updateUserUI(null); + showToast("Logged out successfully."); + } + /** + * Handles OAuth callback - called when returning from GitHub + */ + async function handleCallback(): Promise { + try { + const success = await authManager.handleCallback(); -## References. + if (success) { + const token = authManager.getToken(); + if (token) { + const user = await userManager.fetchUser(token); + updateUserUI(user); + showToast("Successfully logged in with GitHub!", { persist: false }); + } + } -* [OWASP Testing Guide: Configuration Management](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/README) -* [OWASP Testing Guide: Testing for Error Codes](https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/08-Testing_for_Error_Handling/01-Testing_For_Improper_Error_Handling) -* [Application Security Verification Standard V13 Configuration](https://github.com/OWASP/ASVS/blob/master/5.0/en/0x22-V13-Configuration.md) -* [NIST Guide to General Server Hardening](https://csrc.nist.gov/publications/detail/sp/800-123/final) -* [CIS Security Configuration Guides/Benchmarks](https://www.cisecurity.org/cis-benchmarks/) -* [Amazon S3 Bucket Discovery and Enumeration](https://blog.websecurify.com/2017/10/aws-s3-bucket-discovery.html) -* ScienceDirect: Security Misconfiguration - -## List of Mapped CWEs - -* [CWE-5 J2EE Misconfiguration: Data Transmission Without Encryption](https://cwe.mitre.org/data/definitions/5.html) + return success; + } catch (error) { + if (error instanceof Error) { + handleAuthError(error); + } else { + handleAuthError(new Error(String(error))); + } + return false; + } + } -* [CWE-11 ASP.NET Misconfiguration: Creating Debug Binary](https://cwe.mitre.org/data/definitions/11.html) + function handleModalClose(): void { + els.authModal.style.display = "none"; + } -* [CWE-13 ASP.NET Misconfiguration: Password in Configuration File](https://cwe.mitre.org/data/definitions/13.html) + 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"); -* [CWE-15 External Control of System or Configuration Setting](https://cwe.mitre.org/data/definitions/15.html) + if (isCallback) { + // Handle the OAuth callback + handleCallback().then(() => { + // Clean up URL after handling + }).catch(() => { + // Error already handled in handleCallback + }); + } -* [CWE-16 Configuration](https://cwe.mitre.org/data/definitions/16.html) + authManager.subscribe({ + onStateChange: (state) => { + updateAuthUI(state.isAuthenticated); + }, + onError: handleAuthError, + onAuthStep: handleAuthStep, + }); -* [CWE-260 Password in Configuration File](https://cwe.mitre.org/data/definitions/260.html) + els.loginBtn.addEventListener("click", () => { + startLogin(); + }); -* [CWE-315 Cleartext Storage of Sensitive Information in a Cookie](https://cwe.mitre.org/data/definitions/315.html) + els.logoutBtn.addEventListener("click", () => { + handleLogout(); + }); -* [CWE-489 Active Debug Code](https://cwe.mitre.org/data/definitions/489.html) + els.authModalCloseBtn.addEventListener("click", () => { + handleModalClose(); + }); -* [CWE-526 Exposure of Sensitive Information Through Environmental Variables](https://cwe.mitre.org/data/definitions/526.html) + els.authModal.addEventListener("click", (event) => { + if (event.target === els.authModal) { + handleModalClose(); + } + }); -* [CWE-547 Use of Hard-coded, Security-relevant Constants](https://cwe.mitre.org/data/definitions/547.html) + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && els.authModal.style.display !== "none") { + handleModalClose(); + } + }); -* [CWE-611 Improper Restriction of XML External Entity Reference](https://cwe.mitre.org/data/definitions/611.html) + if (authManager.restoreSession()) { + const token = authManager.getToken(); + if (token) { + userManager.fetchUser(token).then((user) => { + updateUserUI(user); + }).catch(() => { + authManager.logout(); + userManager.clearCache(); + }); + } + } + } -* [CWE-614 Sensitive Cookie in HTTPS Session Without 'Secure' Attribute](https://cwe.mitre.org/data/definitions/614.html) + return { + initialize, + getCurrentUser: () => currentUser, + isAuthenticated: () => authManager.isAuthenticated(), + getToken: () => authManager.getToken(), + }; +} + -* [CWE-776 Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')](https://cwe.mitre.org/data/definitions/776.html) + +import type { PermissionCapableHandle } from "./types"; -* [CWE-942 Permissive Cross-domain Policy with Untrusted Domains](https://cwe.mitre.org/data/definitions/942.html) +export function isFileSystemApiAvailable(): boolean { + return Boolean(window.showDirectoryPicker && window.FileSystemHandle); +} -* [CWE-1004 Sensitive Cookie Without 'HttpOnly' Flag](https://cwe.mitre.org/data/definitions/1004.html) +export function isInMemoryMode(): boolean { + return !isFileSystemApiAvailable(); +} -* [CWE-1174 ASP.NET Misconfiguration: Improper Model Validation](https://cwe.mitre.org/data/definitions/1174.html) - +export async function ensurePermission( + handle: PermissionCapableHandle | null, + mode: "read" | "readwrite" = "read", +): Promise { + if (!handle) { + return false; + } - -# A03:2025 Software Supply Chain Failures ![icon](../assets/TOP_10_Icons_Final_Vulnerable_Outdated_Components.png){: style="height:80px;width:80px" align="right"} + if (!handle.queryPermission || !handle.requestPermission) { + return true; + } + const descriptor = { mode }; + const current = await handle.queryPermission(descriptor); + if (current === "granted") { + return true; + } -## Background. + const requested = await handle.requestPermission(descriptor); + return requested === "granted"; +} + -This was top-ranked in the Top 10 community survey with exactly 50% respondents ranking it #1. Since initially appearing in the 2013 Top 10 as "A9 – Using Components with Known Vulnerabilities", the risk has grown in scope to include all supply chain failures, not just ones involving known vulnerabilities. Despite this increased scope, supply chain failures continue to be a challenge to identify with only 11 Common Vulnerability and Exposures (CVEs) having the related CWEs. However, when tested and reported in the contributed data, this category has the highest average incidence rate at 5.19%. The relevant CWEs are *CWE-477: Use of Obsolete Function, CWE-1104: Use of Unmaintained Third Party Components*, CWE-1329: *Reliance on Component That is Not Updateable*, and *CWE-1395: Dependency on Vulnerable Third-Party Component*. + +export function escapeHtml(value: string): string { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + + +/** + * GitHub OAuth Authentication Module + * Implements RFC 7636 PKCE OAuth Web Flow for browser-based apps + * @module auth/github + */ -## Score table. +import type { AuthState, AuthEventMap, AuthListeners } from "../app/types"; +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"; - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
6 - 9.56% - 5.72% - 65.42% - 27.47% - 8.17 - 5.23 - 215,248 - 11 -
+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; +/** + * 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", +} +/** + * 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"; + } +} -## Description. +interface TokenResponse { + access_token?: string; + token_type?: string; + scope?: string; + error?: string; + error_description?: string; +} -Software supply chain failures are breakdowns or other compromises in the process of building, distributing, or updating software. They are often caused by vulnerabilities or malicious changes in third-party code, tools, or other dependencies that the system relies on. +/** + * 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); +} -You are likely vulnerable if: +/** + * 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, ""); +} -* you do not carefully track the versions of all components that you use (both client-side and server-side). This includes components you directly use as well as nested (transitive) dependencies. -* the software is vulnerable, unsupported, or out of date. This includes the OS, web/application server, database management system (DBMS), applications, APIs and all components, runtime environments, and libraries. -* you do not scan for vulnerabilities regularly and subscribe to security bulletins related to the components you use. -* you do not have a change management process or tracking of changes within your supply chain, including tracking IDEs, IDE extensions and updates, changes to your organization's code repository, sandboxes, image and library repositories, the way artifacts are created and stored, etc. Every part of your supply chain should be documented, especially changes. -* you have not hardened every part of your supply chain, with a special focus on access control and the application of least privilege. -* your supply chain systems do not have any separation of duty. No single person should be able to write code and promote it all the way to production without oversight from another human being. -* components from untrusted sources, across any part of the tech stack, are used in or can impact on production environments. -* you do not fix or upgrade the underlying platform, frameworks, and dependencies in a risk-based, timely fashion. This commonly happens in environments when patching is a monthly or quarterly task under change control, leaving organizations open to days or months of unnecessary exposure before fixing vulnerabilities. -* software developers do not test the compatibility of updated, upgraded, or patched libraries. -* you do not secure the configurations of every part of your system (see [A02:2025-Security Misconfiguration](https://owasp.org/Top10/2025/A02_2025-Security_Misconfiguration/)). -* your CI/CD pipeline has weaker security than the systems it builds and deploys, especially if it is complex. - - -## How to prevent. +/** + * 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); +} -There should be a patch management process in place to: +/** + * Parses GitHub API JSON response + */ +async function parseJsonResponse(response: Response): Promise { + const json = await response.json(); + return json as T; +} +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +/** + * Creates the auth state manager + */ +export function createAuthManager() { + const listeners: AuthListeners = { + onStateChange: null, + onError: null, + onAuthStep: null, + }; -* Centrally generate and manage the Software Bill of Materials (SBOM) of your entire software. -* Track not just your direct dependencies, but their (transitive) dependencies, and so on. -* Reduce attack surface by removing unused dependencies, unnecessary features, components, files, and documentation. -* Continuously inventory the versions of both client-side and server-side components (e.g., frameworks, libraries) and their dependencies using tools like OWASP Dependency Track, OWASP Dependency Check, retire.js, etc. -* Continuously monitor sources like Common Vulnerability and Exposures (CVE), National Vulnerability Database (NVD), and [Open Source Vulnerabilities (OSV)](https://osv.dev/) for vulnerabilities in the components you use. Use software composition analysis, software supply chain, or security-focused SBOM tools to automate the process. Subscribe to alerts for security vulnerabilities related to components you use. -* Only obtain components from official (trusted) sources over secure links. Prefer signed packages to reduce the chance of including a modified, malicious component (see [A08:2025-Software and Data Integrity Failures](https://owasp.org/Top10/2025/A08_2025-Software_or_Data_Integrity_Failures/)). -* Deliberately choose which version of a dependency you use and upgrade only when there is need. -* Monitor for libraries and components that are unmaintained or do not create security patches for older versions. If patching is not possible, consider migrating to an alternative. If that is not possible, consider deploying a virtual patch to monitor, detect, or protect against the discovered issue. -* Update your CI/CD, IDE, and any other developer tooling regularly -* Avoid deploying updates to all systems simultaneously. Use staged rollouts or canary deployments to limit exposure in case a trusted vendor is compromised. + let state: AuthState = { + isAuthenticated: false, + isLoading: false, + error: null, + authStep: null, + }; + let currentToken: string | null = null; -There should be a change management process or tracking system in place to track changes to: + function emitStateChange(): void { + if (listeners.onStateChange) { + listeners.onStateChange({ ...state }); + } + } -* CI/CD settings (all build tools and pipelines) -* Code repositories -* Sandbox areas -* Developer IDEs -* SBOM tooling, and created artifacts -* Logging systems and logs -* Third party integrations, such as SaaS -* Artifact repositories -* Container registries + 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(); + } -Harden the following systems, which includes enabling MFA and locking down IAM: + function setState(updates: Partial): void { + state = { ...state, ...updates }; + emitStateChange(); + } -* Your code repository (which includes not checking in secrets, protecting branches, backups) -* Developer workstations (regular patching, MFA, monitoring, and more) -* Your build server & CI/CD (separation of duties, access control, signed builds, environment-scoped secrets, tamper-evident logs, more) -* Your artifacts (ensure integrity via provenance, signing, and time stamping, promote artifacts rather than rebuilding for each environment, ensure builds are immutable) -* Infrastructure as code (managed like all code, including use of PRs and version control) + /** + * Retrieves token from localStorage + */ + function getStoredToken(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + } -Every organization must ensure an ongoing plan for monitoring, triaging, and applying updates or configuration changes for the lifetime of the application or portfolio. + /** + * 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.", + ); + } + } + /** + * Clears stored token from localStorage + */ + function clearStoredToken(): void { + try { + localStorage.removeItem(STORAGE_KEY); + currentToken = null; + } catch { + // Ignore storage errors on logout + } + } -## Example attack scenarios. + /** + * 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.", + ); + } + } -**Scenario #1:** A trusted vendor is compromised with malware, leading to your computer systems being compromised when you upgrade. The most famous example of this is probably: + /** + * 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; + } + } + /** + * 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()}`); + } + /** + * Gets redirect URI based on environment + */ + function getRedirectUri(): string { + return window.location.origin + window.location.pathname; + } -* The 2019 SolarWinds compromise that led to ~18,000 organizations being compromised. [https://www.npr.org/2021/04/16/985439655/a-worst-nightmare-cyberattack-the-untold-story-of-the-solarwinds-hack](https://www.npr.org/2021/04/16/985439655/a-worst-nightmare-cyberattack-the-untold-story-of-the-solarwinds-hack) + /** + * 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(""); + } -**Scenario #2:** A trusted vendor is compromised such that it behaves maliciously only under a specific condition. + /** + * Starts the OAuth PKCE flow + * Redirects browser to GitHub authorization page + */ + async function startAuthFlow(): Promise { + setState({ isLoading: true, error: null }); + emitAuthStep("Preparing authentication..."); + try { + // Generate PKCE code_verifier and code_challenge + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + // Store verifier in sessionStorage for later token exchange + storeCodeVerifier(codeVerifier); -* The 2025 Bybit theft of $1.5 billion was caused by [a supply chain attack in wallet software](https://www.sygnia.co/blog/sygnia-investigation-bybit-hack/) that only executed when the target wallet was being used. + // Build authorization URL + const authUrl = await buildAuthorizationUrl(codeChallenge); -**Scenario #3:** The [`Shai-Hulud` supply chain attack](https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem) in 2025 was the first successful self-propagating npm worm. Attacks seeded malicious versions of popular packages, which used a post-install script to harvest and exfiltrate sensitive data to public GitHub repositories. The malware would also detect npm tokens in the victim environment, and automatically use them to push malicious versions of any accessible package. The worm reached over 500 package versions before being disrupted by npm. This supply chain attack was advanced, fast-spreading, and damaging, and by targeting developer machines it demonstrated developers themselves are now prime targets for supply chain attacks. + emitAuthStep("Redirecting to GitHub..."); -**Scenario #4:** Components typically run with the same privileges as the application itself, so flaws in any component can result in serious impact. Such flaws can be accidental (e.g., coding error) or intentional (e.g., a backdoor in a component). Some example exploitable component vulnerabilities discovered are: + // 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; + } + } -* CVE-2017-5638, a Struts 2 remote code execution vulnerability that enables the execution of arbitrary code on the server, has been blamed for significant breaches. -* CVE-2021-44228 ("Log4Shell"), an Apache Log4j remote code execution zero-day vulnerability, has been blamed for ransomware, cryptomining, and other attack campaigns. + /** + * Handles the OAuth callback + * Called when user is redirected back from GitHub + */ + async function handleCallback(): Promise { + const url = new URL(window.location.href); + // Check for error in URL (user denied access or other errors) + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); -## References + 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; + } -* [OWASP Application Security Verification Standard: V15 Secure Coding and Architecture](https://owasp.org/www-project-application-security-verification-standard/) -* [OWASP Cheat Sheet Series: Dependency Graph SBOM](https://cheatsheetseries.owasp.org/cheatsheets/Dependency_Graph_SBOM_Cheat_Sheet.html) -* [OWASP Cheat Sheet Series: Vulnerable Dependency Management](https://cheatsheetseries.owasp.org/cheatsheets/Vulnerable_Dependency_Management_Cheat_Sheet.html) -* [OWASP Dependency-Track](https://owasp.org/www-project-dependency-track/) -* [OWASP CycloneDX](https://owasp.org/www-project-cyclonedx/) -* [OWASP Application Security Verification Standard: V1 Architecture, design and threat modelling](https://owasp-aasvs.readthedocs.io/en/latest/v1.html) -* [OWASP Dependency Check (for Java and .NET libraries)](https://owasp.org/www-project-dependency-check/) -* OWASP Testing Guide - Map Application Architecture (OTG-INFO-010) -* [OWASP Virtual Patching Best Practices](https://owasp.org/www-community/Virtual_Patching_Best_Practices) -* [The Unfortunate Reality of Insecure Libraries](https://www.scribd.com/document/105692739/JeffWilliamsPreso-Sm) -* [MITRE Common Vulnerabilities and Exposures (CVE) search](https://www.cve.org) -* [National Vulnerability Database (NVD)](https://nvd.nist.gov) -* [Retire.js for detecting known vulnerable JavaScript libraries](https://retirejs.github.io/retire.js/) -* [GitHub Advisory Database](https://github.com/advisories) -* Ruby Libraries Security Advisory Database and Tools -* [SAFECode Software Integrity Controls (PDF)](https://safecode.org/publication/SAFECode_Software_Integrity_Controls0610.pdf) -* [Glassworm supply chain attack](https://thehackernews.com/2025/10/self-spreading-glassworm-infects-vs.html) -* [PhantomRaven supply chain attack campaign](https://thehackernews.com/2025/10/phantomraven-malware-found-in-126-npm.html) - - -## List of Mapped CWEs - -* [CWE-447 Use of Obsolete Function](https://cwe.mitre.org/data/definitions/447.html) - -* [CWE-1035 2017 Top 10 A9: Using Components with Known Vulnerabilities](https://cwe.mitre.org/data/definitions/1035.html) + const authError = new AuthError( + AuthErrorType.InvalidRequest, + errorDescription || `Authentication error: ${error}`, + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } -* [CWE-1104 Use of Unmaintained Third Party Components](https://cwe.mitre.org/data/definitions/1104.html) + // 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; + } -* [CWE-1329 Reliance on Component That is Not Updateable](https://cwe.mitre.org/data/definitions/1329.html) + // 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; + } -* [CWE-1357 Reliance on Insufficiently Trustworthy Component](https://cwe.mitre.org/data/definitions/1357.html) + setState({ isLoading: true }); + emitAuthStep("Exchanging code for token..."); -* [CWE-1395 Dependency on Vulnerable Third-Party Component](https://cwe.mitre.org/data/definitions/1395.html) -
+ try { + const token = await exchangeCodeForToken(code, codeVerifier); - -# A04:2025 Cryptographic Failures ![icon](../assets/TOP_10_Icons_Final_Crypto_Failures.png){: style="height:80px;width:80px" align="right"} + storeToken(token); + setState({ + isAuthenticated: true, + isLoading: false, + error: null, + authStep: null, + }); + clearUrlParams(); + return true; + } catch (e) { + if (e instanceof AuthError) { + setState({ isLoading: false, error: e.message }); + emitError(e); + clearUrlParams(); + return false; + } + const authError = new AuthError( + AuthErrorType.NetworkError, + `Token exchange failed: ${String(e)}`, + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + } -## Background. + /** + * 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); + } -Moving down two positions to #4, this weakness focuses on failures related to the lack of cryptography, insufficiently strong cryptography, leaking of cryptographic keys, and related errors. Three of the most common Common Weakness Enumerations (CWEs) in this risk involved the use of a weak pseudo-random number generator: *CWE-327 Use of a Broken or Risky Cryptographic Algorithm, CWE-331: Insufficient Entropy*, *CWE-1241: Use of Predictable Algorithm in Random Number Generator*, and *CWE-338 Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)*. + /** + * 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", + }), + }); + if (!response.ok) { + const authError = new AuthError( + AuthErrorType.ServerError, + `Token request failed: ${response.status}`, + ); + throw authError; + } + const data = await parseJsonResponse(response); -## Score table. + if (data.error) { + if (data.error === "access_denied") { + throw new AuthError( + AuthErrorType.AccessDenied, + "Authorization denied. Please try again.", + ); + } + if (data.error === "expired_token") { + throw new AuthError( + AuthErrorType.Timeout, + "Authorization expired. Please try again.", + ); + } - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
32 - 13.77% - 3.80% - 100.00% - 47.74% - 7.23 - 3.90 - 1,665,348 - 2,185 -
+ throw new AuthError( + AuthErrorType.InvalidRequest, + data.error_description || `Authentication error: ${data.error}`, + ); + } + if (!data.access_token) { + throw new AuthError( + AuthErrorType.ServerError, + "No access token in response.", + ); + } + return data.access_token; + } -## Description. + /** + * 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; + } -Generally speaking, all data in transit should be encrypted at the [transport layer](https://en.wikipedia.org/wiki/Transport_layer) ([OSI layer](https://en.wikipedia.org/wiki/OSI_model) 4). Previous hurdles such as CPU performance and private key/certificate management are now handled by CPUs having instructions designed to accelerate encryption (eg: [AES support](https://en.wikipedia.org/wiki/AES_instruction_set)) and private key and certificate management being simplified by services like [LetsEncrypt.org](https://LetsEncrypt.org) with major cloud vendors providing even more tightly integrated certificate management services for their specific platforms. + /** + * 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, + }); + } -Beyond securing the transport layer, it is important to determine what data needs encryption at rest as well as what data needs extra encryption in transit (at the [application layer](https://en.wikipedia.org/wiki/Application_layer), OSI layer 7). For example, passwords, credit card numbers, health records, personal information, and business secrets require extra protection, especially if that data falls under privacy laws, e.g., EU's General Data Protection Regulation (GDPR), or regulations such as PCI Data Security Standard (PCI DSS). For all such data: + /** + * Gets current access token + */ + function getToken(): string | null { + return currentToken; + } + /** + * Checks if currently authenticated + */ + function isAuthenticated(): boolean { + return state.isAuthenticated; + } + /** + * Gets current auth state + */ + function getState(): AuthState { + return { ...state }; + } -* Are any old or weak cryptographic algorithms or protocols used either by default or in older code? -* Are default crypto keys in use, are weak crypto keys generated, are keys re-used, or is proper key management and rotation missing? -* Are crypto keys checked into source code repositories? -* Is encryption not enforced, e.g., are any HTTP headers (browser) security directives or headers missing? -* Is the received server certificate and the trust chain properly validated? -* Are initialization vectors ignored, reused, or not generated sufficiently secure for the cryptographic mode of operation? Is an insecure mode of operation such as ECB in use? Is encryption used when authenticated encryption is more appropriate? -* Are passwords being used as cryptographic keys in the absence of a password based key derivation function? -* Is randomness used that was not designed to meet cryptographic requirements? Even if the correct function is chosen, does it need to be seeded by the developer, and if not, has the developer over-written the strong seeding functionality built into it with a seed that lacks sufficient entropy/unpredictability? -* Are deprecated hash functions such as MD5 or SHA1 in use, or are non-cryptographic hash functions used when cryptographic hash functions are needed? -* Are cryptographic error messages or side channel information exploitable, for example in the form of padding oracle attacks? -* Can the cryptographic algorithm be downgraded or bypassed? - -See references ASVS: Cryptography (V11), Secure Communication (V12) and Data Protection (V14). - - -## How to prevent. + /** + * 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; + } -Do the following, at a minimum, and consult the references: + return () => { + if (listenersMap.onStateChange) listeners.onStateChange = null; + if (listenersMap.onError) listeners.onError = null; + if (listenersMap.onAuthStep) listeners.onAuthStep = null; + }; + } + return { + startAuthFlow, + handleCallback, + restoreSession, + logout, + getToken, + isAuthenticated, + getState, + subscribe, + }; +} +export type GitHubAuthManager = ReturnType; +
-* Classify and label data processed, stored, or transmitted by an application. Identify which data is sensitive according to privacy laws, regulatory requirements, or business needs. -* Store your most sensitive keys in a hardware or cloud-based HSM. -* Use well-trusted implementations of cryptographic algorithms whenever possible. -* Don't store sensitive data unnecessarily. Discard it as soon as possible or use PCI DSS compliant tokenization or even truncation. Data that is not retained cannot be stolen. -* Make sure to encrypt all sensitive data at rest. -* Ensure up-to-date and strong standard algorithms, protocols, and keys are in place; use proper key management. -* Encrypt all data in transit with protocols >= TLS 1.2 only, with forward secrecy (FS) ciphers, drop support for cipher block chaining (CBC) ciphers, support quantum key change algorithms. For HTTPS enforce encryption using HTTP Strict Transport Security (HSTS). Check everything with a tool. -* Disable caching for responses that contain sensitive data. This includes caching in your CDN, web server, and any application caching (eg: Redis). -* Apply required security controls as per the data classification. -* Do not use unencrypted protocols such as FTP, and STARTTLS. Avoid using SMTP for transmitting confidential data. -* Store passwords using strong adaptive and salted hashing functions with a work factor (delay factor), such as Argon2, yescrypt, scrypt or PBKDF2-HMAC-SHA-512. For legacy systems using bcrypt, get more advice at [OWASP Cheat Sheet: Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) -* Initialization vectors must be chosen appropriate for the mode of operation. This could mean using a CSPRNG (cryptographically secure pseudo random number generator). For modes that require a nonce, the initialization vector (IV) does not need a CSPRNG. In all cases, the IV should never be used twice for a fixed key. -* Always use authenticated encryption instead of just encryption. -* Keys should be generated cryptographically randomly and stored in memory as byte arrays. If a password is used, then it must be converted to a key via an appropriate password base key derivation function. -* Ensure that cryptographic randomness is used where appropriate and that it has not been seeded in a predictable way or with low entropy. Most modern APIs do not require the developer to seed the CSPRNG to be secure. -* Avoid deprecated cryptographic functions, block building methods and padding schemes, such as MD5, SHA1, Cipher Block Chaining Mode (CBC), PKCS number 1 v1.5. -* Ensure settings and configurations meet security requirements by having them reviewed by security specialists, tools designed for this purpose, or both. -* You need to prepare now for post quantum cryptography (PQC), see reference (ENISA) so that high risk systems are safe no later than the end of 2030. + +/** + * 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 + */ +import type { GitHubUser } from "../app/types"; -## Example attack scenarios. +const GITHUB_API_USER_URL = "https://api.github.com/user"; -**Scenario #1**: A site doesn't use or enforce TLS for all pages or supports weak encryption. An attacker monitors network traffic (e.g., at an insecure wireless network), downgrades connections from HTTPS to HTTP, intercepts requests, and steals the user's session cookie. The attacker then replays this cookie and hijacks the user's (authenticated) session, accessing or modifying the user's private data. Instead of the above they could alter all transported data, e.g., the recipient of a money transfer. +interface GitHubApiUserResponse { + login: string; + id: number; + avatar_url: string; + name: string | null; +} -**Scenario #2**: The password database uses unsalted or simple hashes to store everyone's passwords. A file upload flaw allows an attacker to retrieve the password database. All the unsalted hashes can be exposed with a rainbow table of pre-calculated hashes. Hashes generated by simple or fast hash functions may be cracked by GPUs, even if they were salted. +/** + * Creates a user info manager with in-memory caching + */ +export function createUserManager() { + let cachedUser: GitHubUser | null = null; + /** + * Fetches user profile from GitHub API + * Caches result in memory for subsequent calls + */ + async function fetchUser(token: string): Promise { + if (cachedUser) { + return cachedUser; + } -## References. + const response = await fetch(GITHUB_API_USER_URL, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch user profile: ${response.status}`); + } + const data: GitHubApiUserResponse = await response.json(); -* [OWASP Proactive Controls: C2: Use Cryptography to Protect Data ](https://top10proactive.owasp.org/archive/2024/the-top-10/c2-crypto/) -* [OWASP Application Security Verification Standard (ASVS): ](https://owasp.org/www-project-application-security-verification-standard) [V11,](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x20-V11-Cryptography.md) [12, ](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x21-V12-Secure-Communication.md) [14](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x23-V14-Data-Protection.md) -* [OWASP Cheat Sheet: Transport Layer Protection](https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html) -* [OWASP Cheat Sheet: User Privacy Protection](https://cheatsheetseries.owasp.org/cheatsheets/User_Privacy_Protection_Cheat_Sheet.html) -* [OWASP Cheat Sheet: Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) -* [OWASP Cheat Sheet: Cryptographic Storage](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html) -* [OWASP Cheat Sheet: HSTS](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Strict_Transport_Security_Cheat_Sheet.html) -* [OWASP Testing Guide: Testing for weak cryptography](https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/09-Testing_for_Weak_Cryptography/README) -* [ENISA: A Coordinated Implementation Roadmap for the Transition to Post-Quantum Cryptography](https://digital-strategy.ec.europa.eu/en/library/coordinated-implementation-roadmap-transition-post-quantum-cryptography) -* [NIST Releases First 3 Finalized Post-Quantum Encryption Standards](https://www.nist.gov/news-events/news/2024/08/nist-releases-first-3-finalized-post-quantum-encryption-standards) + cachedUser = { + login: data.login, + id: data.id, + avatarUrl: data.avatar_url, + name: data.name, + }; + return cachedUser; + } -## List of Mapped CWEs + /** + * Clears cached user data + * Called on logout + */ + function clearCache(): void { + cachedUser = null; + } -* [CWE-261 Weak Encoding for Password](https://cwe.mitre.org/data/definitions/261.html) + /** + * Gets cached user without fetching + */ + function getCachedUser(): GitHubUser | null { + return cachedUser; + } -* [CWE-296 Improper Following of a Certificate's Chain of Trust](https://cwe.mitre.org/data/definitions/296.html) + return { + fetchUser, + clearCache, + getCachedUser, + }; +} -* [CWE-319 Cleartext Transmission of Sensitive Information](https://cwe.mitre.org/data/definitions/319.html) +export type GitHubUserManager = ReturnType; + -* [CWE-320 Key Management Errors (Prohibited)](https://cwe.mitre.org/data/definitions/320.html) + +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; +} -* [CWE-321 Use of Hard-coded Cryptographic Key](https://cwe.mitre.org/data/definitions/321.html) +const STORAGE_PREFIX = "ink.workspace."; -* [CWE-322 Key Exchange without Entity Authentication](https://cwe.mitre.org/data/definitions/322.html) +function workspaceKey(name: string): string { + return `${STORAGE_PREFIX}${name}`; +} -* [CWE-323 Reusing a Nonce, Key Pair in Encryption](https://cwe.mitre.org/data/definitions/323.html) +function normalizeWorkspaceName(name: string): string { + return name.trim(); +} -* [CWE-324 Use of a Key Past its Expiration Date](https://cwe.mitre.org/data/definitions/324.html) +function parseWorkspace(value: string | null): Record { + if (!value) { + return {}; + } -* [CWE-325 Missing Required Cryptographic Step](https://cwe.mitre.org/data/definitions/325.html) + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== "object") { + return {}; + } -* [CWE-326 Inadequate Encryption Strength](https://cwe.mitre.org/data/definitions/326.html) + const files: Record = {}; + for (const [key, content] of Object.entries(parsed as Record)) { + if (typeof key === "string" && typeof content === "string") { + files[key] = content; + } + } -* [CWE-327 Use of a Broken or Risky Cryptographic Algorithm](https://cwe.mitre.org/data/definitions/327.html) + return files; + } catch { + return {}; + } +} -* [CWE-328 Reversible One-Way Hash](https://cwe.mitre.org/data/definitions/328.html) +function writeWorkspace(storage: KeyValueStorage, workspace: string, files: Record): void { + storage.setItem(workspaceKey(workspace), JSON.stringify(files)); +} -* [CWE-329 Not Using a Random IV with CBC Mode](https://cwe.mitre.org/data/definitions/329.html) +export function listWorkspaces(storage: KeyValueStorage): string[] { + const workspaces: string[] = []; -* [CWE-330 Use of Insufficiently Random Values](https://cwe.mitre.org/data/definitions/330.html) + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key || !key.startsWith(STORAGE_PREFIX)) { + continue; + } -* [CWE-331 Insufficient Entropy](https://cwe.mitre.org/data/definitions/331.html) + workspaces.push(key.slice(STORAGE_PREFIX.length)); + } -* [CWE-332 Insufficient Entropy in PRNG](https://cwe.mitre.org/data/definitions/332.html) + return workspaces.sort((a, b) => a.localeCompare(b)); +} -* [CWE-334 Small Space of Random Values](https://cwe.mitre.org/data/definitions/334.html) +export function ensureWorkspace(storage: KeyValueStorage, workspaceName: string): string { + const normalizedName = normalizeWorkspaceName(workspaceName); + if (!normalizedName) { + throw new Error("Workspace name cannot be empty."); + } -* [CWE-335 Incorrect Usage of Seeds in Pseudo-Random Number Generator(PRNG)](https://cwe.mitre.org/data/definitions/335.html) + const key = workspaceKey(normalizedName); + if (storage.getItem(key) === null) { + writeWorkspace(storage, normalizedName, {}); + } -* [CWE-336 Same Seed in Pseudo-Random Number Generator (PRNG)](https://cwe.mitre.org/data/definitions/336.html) + return normalizedName; +} -* [CWE-337 Predictable Seed in Pseudo-Random Number Generator (PRNG)](https://cwe.mitre.org/data/definitions/337.html) +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)); +} -* [CWE-338 Use of Cryptographically Weak Pseudo-Random Number Generator(PRNG)](https://cwe.mitre.org/data/definitions/338.html) +export function createFile(storage: KeyValueStorage, workspaceName: string, fileName: string): string { + const normalizedFileName = fileName.trim(); + if (!normalizedFileName) { + throw new Error("File name cannot be empty."); + } -* [CWE-340 Generation of Predictable Numbers or Identifiers](https://cwe.mitre.org/data/definitions/340.html) + const workspace = ensureWorkspace(storage, workspaceName); + const files = parseWorkspace(storage.getItem(workspaceKey(workspace))); + if (!(normalizedFileName in files)) { + files[normalizedFileName] = ""; + writeWorkspace(storage, workspace, files); + } -* [CWE-342 Predictable Exact Value from Previous Values](https://cwe.mitre.org/data/definitions/342.html) + return normalizedFileName; +} -* [CWE-347 Improper Verification of Cryptographic Signature](https://cwe.mitre.org/data/definitions/347.html) +export function readFile(storage: KeyValueStorage, workspaceName: string, fileName: string): string { + const files = parseWorkspace(storage.getItem(workspaceKey(workspaceName))); + return files[fileName] ?? ""; +} -* [CWE-523 Unprotected Transport of Credentials](https://cwe.mitre.org/data/definitions/523.html) +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); +} + -* [CWE-757 Selection of Less-Secure Algorithm During Negotiation('Algorithm Downgrade')](https://cwe.mitre.org/data/definitions/757.html) + +export function normalizeTag(value: string): string { + return (value || "") + .trim() + .replace(/^#+/, "") + .replace(/[^\w\-/]+/g, "") + .toLowerCase(); +} -* [CWE-759 Use of a One-Way Hash without a Salt](https://cwe.mitre.org/data/definitions/759.html) +export function extractFrontMatter(text: string): string { + if (!text.startsWith("---")) { + return ""; + } -* [CWE-760 Use of a One-Way Hash with a Predictable Salt](https://cwe.mitre.org/data/definitions/760.html) + const end = text.indexOf("\n---", 3); + if (end === -1) { + return ""; + } -* [CWE-780 Use of RSA Algorithm without OAEP](https://cwe.mitre.org/data/definitions/780.html) + return text.slice(3, end).trim(); +} -* [CWE-916 Use of Password Hash With Insufficient Computational Effort](https://cwe.mitre.org/data/definitions/916.html) +export function parseFrontmatterTags(frontMatter: string): Set { + const tags = new Set(); + const lines = frontMatter.split("\n"); -* [CWE-1240 Use of a Cryptographic Primitive with a Risky Implementation](https://cwe.mitre.org/data/definitions/1240.html) + for (const line of lines) { + const inlineListMatch = line.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i); + if (!inlineListMatch) { + continue; + } -* [CWE-1241 Use of Predictable Algorithm in Random Number Generator](https://cwe.mitre.org/data/definitions/1241.html) - + const parts = inlineListMatch[1] + .split(",") + .map((part) => normalizeTag(part.replace(/["']/g, ""))); - -# A05:2025 Injection ![icon](../assets/TOP_10_Icons_Final_Injection.png){: style="height:80px;width:80px" align="right"} + for (const part of parts) { + if (part) { + tags.add(part); + } + } + } -## Background. + let inTagsBlock = false; + for (const line of lines) { + if (/^\s*tags\s*:\s*$/i.test(line)) { + inTagsBlock = true; + continue; + } -Injection falls two spots from #3 to #5 in the ranking, maintaining its position relative to A04:2025-Cryptographic Failures and A06:2025-Insecure Design. Injection is one of the most tested categories with 100% of applications tested for some form of injection. It had the greatest number of CVEs for any category, with 37 CWEs in this category. Injection includes Cross-site Scripting (high frequency/low impact) with more than 30k CVEs and SQL Injection (low frequency/high impact) with more than 14k CVEs. The massive number of reported CVEs for CWE-79 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') brings down the average weighted impact of this category. + 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; + } -## Score table. + if (line.trim() !== "" && !/^\s+/.test(line)) { + inTagsBlock = false; + } + } + return tags; +} - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
37 - 13.77% - 3.08% - 100.00% - 42.93% - 7.15 - 4.32 - 1,404,249 - 62,445 -
+export function parseTags(text: string): Set { + const tags = new Set(); + const frontMatter = extractFrontMatter(text); + if (frontMatter) { + const frontMatterTags = parseFrontmatterTags(frontMatter); + for (const tag of frontMatterTags) { + tags.add(tag); + } + } + 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); + } + } + } -## Description. + return tags; +} +
-An injection vulnerability is an application flaw that allows untrusted user input to be sent to an interpreter (e.g. a browser, database, the command line) and causes the interpreter to execute parts of that input as commands. + +import QUnit from "qunit"; -An application is vulnerable to attack when: +QUnit.module("mobile fallback - JSON export format"); -* User-supplied data is not validated, filtered, or sanitized by the application. -* Dynamic queries or non-parameterized calls without context-aware escaping are used directly in the interpreter. -* Unsanitized data is used within object-relational mapping (ORM) search parameters to extract additional, sensitive records. -* Potentially hostile data is directly used or concatenated. The SQL or command contains the structure and malicious data in dynamic queries, commands, or stored procedures. +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"]), + }, + ]; -Some of the more common injections are SQL, NoSQL, OS command, Object Relational Mapping (ORM), LDAP, and Expression Language (EL) or Object Graph Navigation Library (OGNL) injection. The concept is identical among all interpreters. Detection is best achieved by a combination of source code review along with automated testing (including fuzzing) of all parameters, headers, URL, cookies, JSON, SOAP, and XML data inputs. The addition of static (SAST), dynamic (DAST), and interactive (IAST) application security testing tools into the CI/CD pipeline can also be helpful to identify injection flaws before production deployment. + 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(), + })), + }; -A related class of injection vulnerabilities has become common in LLMs. These are discussed separately in the [OWASP LLM Top 10](https://genai.owasp.org/llm-top-10/), specifically [LLM01:2025 Prompt Injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/). + 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"); +}); -## How to prevent. +QUnit.module("mobile fallback - in-memory note management"); -The best means to prevent injection requires keeping data separate from commands and queries: +QUnit.test("in-memory notes can be created and found by relPath", (assert) => { + const inMemoryNotes = []; -* The preferred option is to use a safe API, which avoids using the interpreter entirely, provides a parameterized interface, or migrates to Object Relational Mapping Tools (ORMs). -**Note:** Even when parameterized, stored procedures can still introduce SQL injection if PL/SQL or T-SQL concatenates queries and data or executes hostile data with EXECUTE IMMEDIATE or exec(). + const note1 = { + name: "note1.md", + relPath: "note1.md", + content: "# Note 1", + lastModified: Date.now(), + tags: new Set(["tag1"]), + }; -When it is not possible to separate the data from commands, you can reduce threats using the following techniques. + const note2 = { + name: "note2.md", + relPath: "note2.md", + content: "# Note 2", + lastModified: Date.now(), + tags: new Set(["tag2"]), + }; -* Use positive server-side input validation. This is not a complete defense as many applications require special characters, such as text areas or APIs for mobile applications. -* For any residual dynamic queries, escape special characters using the specific escape syntax for that interpreter. -**Note:** SQL structures such as table names, column names, and so on cannot be escaped, and thus user-supplied structure names are dangerous. This is a common issue in report-writing software. + inMemoryNotes.push(note1); + inMemoryNotes.push(note2); -**Warning** these techniques involve parsing and escaping complex strings, making them error-prone and not robust in the face of minor changes to the underlying system. + 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"); +}); -## Example attack scenarios. +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(), + }; -**Scenario #1:** An application uses untrusted data in the construction of the following vulnerable SQL call: + note.content = "# Updated"; + note.lastModified = Date.now(); -``` -String query = "SELECT * FROM accounts WHERE custID='" + request.getParameter("id") + "'"; -``` + assert.strictEqual(note.content, "# Updated", "Content should be updated"); + assert.ok(note.lastModified > 0, "Last modified should be updated"); +}); -An attacker modifies the 'id' parameter value in their browser to send: `' OR '1'='1`. For example: +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() }, + ]; -``` -http://example.com/app/accountView?id=' OR '1'='1 -``` + const count = inMemoryNotes.length; + assert.strictEqual(count, 3, "Should have 3 notes"); +}); -This changes the meaning of the query to return all records from the accounts table. More dangerous attacks could modify or delete data or even invoke stored procedures. - -**Scenario #2:** An application's blind trust in frameworks may result in queries that are still vulnerable. For example, Hibernate Query Language (HQL): - -``` -Query HQLQuery = session.createQuery("FROM accounts WHERE custID='" + request.getParameter("id") + "'"); -``` +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"]) }, + ]; -An attacker supplies: `' OR custID IS NOT NULL OR custID='`. This bypasses the filter and returns all accounts. While HQL has fewer dangerous functions than raw SQL, it still allows unauthorized data access when user input is concatenated into queries. + const workNotes = inMemoryNotes.filter((n) => n.tags.has("work")); + assert.strictEqual(workNotes.length, 2, "Should have 2 work notes"); +}); -**Scenario #3:** An application passes user input directly to an OS command: +QUnit.test("markdown filename extraction from full path", (assert) => { + const relPath = "folder/subfolder/note.md"; + const fileName = relPath.split("/").pop(); -``` -String cmd = "nslookup " + request.getParameter("domain"); -Runtime.getRuntime().exec(cmd); -``` + assert.strictEqual(fileName, "note.md", "Should extract correct filename"); + assert.ok(fileName.endsWith(".md"), "Should be markdown file"); +}); + -An attacker supplies `example.com; cat /etc/passwd` to execute arbitrary commands on the server. + +import QUnit from "qunit"; +import { + createFile, + ensureWorkspace, + listFiles, + listWorkspaces, + readFile, + saveFile, +} from "../../dist/test/storage.js"; -## References. +class MemoryStorage { + constructor() { + this.values = new Map(); + } -* [OWASP Proactive Controls: Secure Database Access](https://owasp.org/www-project-proactive-controls/v3/en/c3-secure-database) -* [OWASP ASVS: V5 Input Validation and Encoding](https://owasp.org/www-project-application-security-verification-standard) -* [OWASP Testing Guide: SQL Injection,](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05-Testing_for_SQL_Injection) [Command Injection](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/12-Testing_for_Command_Injection), and [ORM Injection](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05.7-Testing_for_ORM_Injection) -* [OWASP Cheat Sheet: Injection Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Injection_Prevention_Cheat_Sheet.html) -* [OWASP Cheat Sheet: SQL Injection Prevention](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html) -* [OWASP Cheat Sheet: Injection Prevention in Java](https://cheatsheetseries.owasp.org/cheatsheets/Injection_Prevention_Cheat_Sheet_in_Java.html) -* [OWASP Cheat Sheet: Query Parameterization](https://cheatsheetseries.owasp.org/cheatsheets/Query_Parameterization_Cheat_Sheet.html) -* [OWASP Automated Threats to Web Applications – OAT-014](https://owasp.org/www-project-automated-threats-to-web-applications/) -* [PortSwigger: Server-side template injection](https://portswigger.net/kb/issues/00101080_serversidetemplateinjection) -* [Awesome Fuzzing: a list of fuzzing resources](https://github.com/secfigo/Awesome-Fuzzing) + get length() { + return this.values.size; + } + key(index) { + return Array.from(this.values.keys())[index] ?? null; + } + getItem(key) { + return this.values.has(key) ? this.values.get(key) : null; + } -## List of Mapped CWEs + setItem(key, value) { + this.values.set(key, value); + } -* [CWE-20 Improper Input Validation](https://cwe.mitre.org/data/definitions/20.html) + removeItem(key) { + this.values.delete(key); + } +} -* [CWE-74 Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')](https://cwe.mitre.org/data/definitions/74.html) +QUnit.module("storage"); -* [CWE-76 Improper Neutralization of Equivalent Special Elements](https://cwe.mitre.org/data/definitions/76.html) +QUnit.test("ensureWorkspace trims name and rejects empty names", (assert) => { + const storage = new MemoryStorage(); -* [CWE-77 Improper Neutralization of Special Elements used in a Command ('Command Injection')](https://cwe.mitre.org/data/definitions/77.html) + assert.throws( + () => ensureWorkspace(storage, " "), + /Workspace name cannot be empty\./, + ); -* [CWE-78 Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')](https://cwe.mitre.org/data/definitions/78.html) + const name = ensureWorkspace(storage, " project-a "); + assert.strictEqual(name, "project-a"); + assert.deepEqual(listWorkspaces(storage), ["project-a"]); +}); -* [CWE-79 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')](https://cwe.mitre.org/data/definitions/79.html) +QUnit.test("createFile rejects empty names and creates file once", (assert) => { + const storage = new MemoryStorage(); -* [CWE-80 Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS)](https://cwe.mitre.org/data/definitions/80.html) + ensureWorkspace(storage, "notes"); -* [CWE-83 Improper Neutralization of Script in Attributes in a Web Page](https://cwe.mitre.org/data/definitions/83.html) + assert.throws( + () => createFile(storage, "notes", " \n "), + /File name cannot be empty\./, + ); -* [CWE-86 Improper Neutralization of Invalid Characters in Identifiers in Web Pages](https://cwe.mitre.org/data/definitions/86.html) + createFile(storage, "notes", "daily.md"); + createFile(storage, "notes", "daily.md"); -* [CWE-88 Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')](https://cwe.mitre.org/data/definitions/88.html) + assert.deepEqual(listFiles(storage, "notes"), ["daily.md"]); + assert.strictEqual(readFile(storage, "notes", "daily.md"), ""); +}); -* [CWE-89 Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')](https://cwe.mitre.org/data/definitions/89.html) +QUnit.test("listWorkspaces and listFiles are sorted", (assert) => { + const storage = new MemoryStorage(); -* [CWE-90 Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')](https://cwe.mitre.org/data/definitions/90.html) + ensureWorkspace(storage, "zeta"); + ensureWorkspace(storage, "alpha"); + ensureWorkspace(storage, "beta"); -* [CWE-91 XML Injection (aka Blind XPath Injection)](https://cwe.mitre.org/data/definitions/91.html) + createFile(storage, "alpha", "z.md"); + createFile(storage, "alpha", "a.md"); + createFile(storage, "alpha", "m.md"); -* [CWE-93 Improper Neutralization of CRLF Sequences ('CRLF Injection')](https://cwe.mitre.org/data/definitions/93.html) + assert.deepEqual(listWorkspaces(storage), ["alpha", "beta", "zeta"]); + assert.deepEqual(listFiles(storage, "alpha"), ["a.md", "m.md", "z.md"]); +}); -* [CWE-94 Improper Control of Generation of Code ('Code Injection')](https://cwe.mitre.org/data/definitions/94.html) +QUnit.test("readFile returns empty string when file does not exist", (assert) => { + const storage = new MemoryStorage(); + ensureWorkspace(storage, "empty"); -* [CWE-95 Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')](https://cwe.mitre.org/data/definitions/95.html) + assert.strictEqual(readFile(storage, "empty", "missing.md"), ""); + assert.strictEqual(readFile(storage, "missing-workspace", "missing.md"), ""); +}); -* [CWE-96 Improper Neutralization of Directives in Statically Saved Code ('Static Code Injection')](https://cwe.mitre.org/data/definitions/96.html) +QUnit.test("saveFile creates missing workspace and persists content", (assert) => { + const storage = new MemoryStorage(); -* [CWE-97 Improper Neutralization of Server-Side Includes (SSI) Within a Web Page](https://cwe.mitre.org/data/definitions/97.html) + saveFile(storage, "new-workspace", "created.md", "# Hello"); -* [CWE-98 Improper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion')](https://cwe.mitre.org/data/definitions/98.html) + assert.deepEqual(listWorkspaces(storage), ["new-workspace"]); + assert.deepEqual(listFiles(storage, "new-workspace"), ["created.md"]); + assert.strictEqual(readFile(storage, "new-workspace", "created.md"), "# Hello"); +}); -* [CWE-99 Improper Control of Resource Identifiers ('Resource Injection')](https://cwe.mitre.org/data/definitions/99.html) +QUnit.test("integration flow: workspace -> create file -> save -> read", (assert) => { + const storage = new MemoryStorage(); -* [CWE-103 Struts: Incomplete validate() Method Definition](https://cwe.mitre.org/data/definitions/103.html) + ensureWorkspace(storage, "workspace-a"); + createFile(storage, "workspace-a", "notes.md"); + saveFile(storage, "workspace-a", "notes.md", "# Ink\n\nSaved content"); -* [CWE-104 Struts: Form Bean Does Not Extend Validation Class](https://cwe.mitre.org/data/definitions/104.html) + assert.strictEqual( + readFile(storage, "workspace-a", "notes.md"), + "# Ink\n\nSaved content", + ); +}); -* [CWE-112 Missing XML Validation](https://cwe.mitre.org/data/definitions/112.html) +QUnit.test("integration flow: data is isolated between workspaces", (assert) => { + const storage = new MemoryStorage(); -* [CWE-113 Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Response Splitting')](https://cwe.mitre.org/data/definitions/113.html) + createFile(storage, "workspace-a", "shared.md"); + createFile(storage, "workspace-b", "shared.md"); -* [CWE-114 Process Control](https://cwe.mitre.org/data/definitions/114.html) + saveFile(storage, "workspace-a", "shared.md", "A"); + saveFile(storage, "workspace-b", "shared.md", "B"); -* [CWE-115 Misinterpretation of Output](https://cwe.mitre.org/data/definitions/115.html) + assert.strictEqual(readFile(storage, "workspace-a", "shared.md"), "A"); + assert.strictEqual(readFile(storage, "workspace-b", "shared.md"), "B"); +}); + -* [CWE-116 Improper Encoding or Escaping of Output](https://cwe.mitre.org/data/definitions/116.html) + +import QUnit from "qunit"; +import { createUserManager } from "../../dist/test/user.js"; -* [CWE-129 Improper Validation of Array Index](https://cwe.mitre.org/data/definitions/129.html) +QUnit.module("auth/user", (hooks) => { + let mockFetch; + let originalFetch; -* [CWE-159 Improper Handling of Invalid Use of Special Elements](https://cwe.mitre.org/data/definitions/159.html) + 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; + }); -* [CWE-470 Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')](https://cwe.mitre.org/data/definitions/470.html) + hooks.afterEach(function () { + globalThis.fetch = originalFetch; + }); -* [CWE-493 Critical Public Variable Without Final Modifier](https://cwe.mitre.org/data/definitions/493.html) + QUnit.test("fetchUser returns user data", async function (assert) { + const userManager = createUserManager(); + const user = await userManager.fetchUser("test_token"); -* [CWE-500 Public Static Field Not Marked Final](https://cwe.mitre.org/data/definitions/500.html) + 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"); + }); -* [CWE-564 SQL Injection: Hibernate](https://cwe.mitre.org/data/definitions/564.html) + QUnit.test("fetchUser caches result", async function (assert) { + const userManager = createUserManager(); + let fetchCalls = 0; -* [CWE-610 Externally Controlled Reference to a Resource in Another Sphere](https://cwe.mitre.org/data/definitions/610.html) + 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", + }; + }, + }; + }; -* [CWE-643 Improper Neutralization of Data within XPath Expressions ('XPath Injection')](https://cwe.mitre.org/data/definitions/643.html) + await userManager.fetchUser("test_token"); + await userManager.fetchUser("test_token"); -* [CWE-644 Improper Neutralization of HTTP Headers for Scripting Syntax](https://cwe.mitre.org/data/definitions/644.html) + assert.strictEqual(fetchCalls, 1, "Should only make one fetch call due to caching"); + }); -* [CWE-917 Improper Neutralization of Special Elements used in an Expression Language Statement ('Expression Language Injection')](https://cwe.mitre.org/data/definitions/917.html) - + QUnit.test("clearCache removes cached user", async function (assert) { + const userManager = createUserManager(); + await userManager.fetchUser("test_token"); - -# A06:2025 Insecure Design ![icon](../assets/TOP_10_Icons_Final_Insecure_Design.png){: style="height:80px;width:80px" align="right"} + assert.ok(userManager.getCachedUser() !== null); + userManager.clearCache(); -## Background. + assert.strictEqual(userManager.getCachedUser(), null); + }); -Insecure Design slides two spots from #4 to #6 in the ranking as **[A02:2025-Security Misconfiguration](A02_2025-Security_Misconfiguration.md)** and **[A03:2025-Software Supply Chain Failures](A03_2025-Software_Supply_Chain_Failures.md)** leapfrog it. This category was introduced in 2021, and we have seen noticeable improvements in the industry related to threat modeling and a greater emphasis on secure design. This category focuses on risks related to design and architectural flaws, with a call for more use of threat modeling, secure design patterns, and reference architectures. This includes flaws in the business logic of an application, e.g. the lack of defining unwanted or unexpected state changes inside an application. As a community, we need to move beyond "shift-left" in the coding space, to pre-code activities such as requirements writing and application design, that are critical for the principles of Secure by Design (e.g. see **[Establish a Modern AppSec Program: Planning and Design Phase](0x03_2025-Establishing_a_Modern_Application_Security_Program.md)**). Notable Common Weakness Enumerations (CWEs) include *CWE-256: Unprotected Storage of Credentials, CWE-269 Improper Privilege Management, CWE-434 Unrestricted Upload of File with Dangerous Type, CWE-501: Trust Boundary Violation, and CWE-522: Insufficiently Protected Credentials.* + QUnit.test("getCachedUser returns null before fetch", function (assert) { + const userManager = createUserManager(); + assert.strictEqual(userManager.getCachedUser(), null); + }); + QUnit.test("fetchUser throws on API error", async function (assert) { + const userManager = createUserManager(); -## Score table. + globalThis.fetch = async () => ({ + ok: false, + status: 401, + async json() { + return {}; + }, + }); + try { + await userManager.fetchUser("invalid_token"); + assert.ok(false, "Should have thrown"); + } catch (error) { + assert.ok(error.message.includes("Failed to fetch user profile")); + } + }); - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
39 - 22.18% - 1.86% - 88.76% - 35.18% - 6.96 - 4.05 - 729,882 - 7,647 -
+ QUnit.test("fetchUser uses Authorization header", async function (assert) { + const userManager = createUserManager(); + let capturedHeaders = null; + 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", + }; + }, + }; + }; + await userManager.fetchUser("test_token_xyz"); -## Description. + assert.strictEqual(capturedHeaders?.get("Authorization"), "Bearer test_token_xyz"); + assert.strictEqual(capturedHeaders?.get("Accept"), "application/vnd.github+json"); + }); +}); +
-Insecure design is a broad category representing different weaknesses, expressed as “missing or ineffective control design.” Insecure design is not the source for all other Top Ten risk categories. Note that there is a difference between insecure design and insecure implementation. We differentiate between design flaws and implementation defects for a reason, they have different root causes, take place at different times in the development process, and have different remediations. A secure design can still have implementation defects leading to vulnerabilities that may be exploited. An insecure design cannot be fixed by a perfect implementation as needed security controls were never created to defend against specific attacks. One of the factors that contributes to insecure design is the lack of business risk profiling inherent in the software or system being developed, and thus the failure to determine what level of security design is required. + +# Dependencies +node_modules/ -Three key parts of having a secure design are: +# OS/editor noise +.DS_Store +*.log -* Gathering Requirements and Resource Management -* Creating a Secure Design -* Having a Secure Development Lifecycle +# Local tool state +.opencode/ +# Cypress artifacts +cypress/screenshots/ +cypress/videos/ +cypress/downloads/ +coverage/ +.nyc_output/ + -### Requirements and Resource Management + +{ + "extension": [".ts", ".js"], + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"], + "reporter": ["html", "text-summary", "json"], + "report-dir": "coverage" +} + -Collect and negotiate the business requirements for an application with the business, including the protection requirements concerning confidentiality, integrity, availability, and authenticity of all data assets and the expected business logic. Take into account how exposed your application will be and if you need segregation of tenants (beyond those needed for access control). Compile the technical requirements, including functional and non-functional security requirements. Plan and negotiate the budget covering all design, build, testing, and operation, including security activities. + +modules = ["web", "nodejs-20"] +[agent] +expertMode = true +[nix] +channel = "stable-25_05" +packages = ["gh"] -### Secure Design +[workflows] +runButton = "Project" -Secure design is a culture and methodology that constantly evaluates threats and ensures that code is robustly designed and tested to prevent known attack methods. Threat modeling should be integrated into refinement sessions (or similar activities); look for changes in data flows and access control or other security controls. In the user story development, determine the correct flow and failure states, ensure they are well understood and agreed upon by the responsible and impacted parties. Analyze assumptions and conditions for expected and failure flows to ensure they remain accurate and desirable. Determine how to validate the assumptions and enforce conditions needed for proper behaviors. Ensure the results are documented in the user story. Learn from mistakes and offer positive incentives to promote improvements. Secure design is neither an add-on nor a tool that you can add to software. +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" -### Secure Development Lifecycle +[[workflows.workflow]] +name = "Start application" +author = "agent" -Secure software requires a secure development lifecycle, a secure design pattern, a paved road methodology, a secure component library, appropriate tooling, threat modeling, and incident post-mortems that are used to improve the process. Reach out to your security specialists at the beginning of a software project, throughout the project, and for ongoing software maintenance. Consider leveraging the [OWASP Software Assurance Maturity Model (SAMM)](https://owaspsamm.org/) to help structure your secure software development efforts. +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npx http-server . -p 5000 --proxy http://localhost:5000? -s" +waitForPort = 5000 -Often self-responsibility of developers is underappreciated. Foster a culture of awareness, responsibility and proactive risk mitigation. Regular exchanges about security (e.g. during threat modeling sessions) can generate a mindset for including security in all important design decisions. +[workflows.workflow.metadata] +outputType = "webview" +[deployment] +deploymentTarget = "static" +build = ["npm", "run", "build"] +publicDir = "." -## How to prevent. +[[ports]] +localPort = 5000 +externalPort = 80 + + + +# OpenSpec Instructions +These instructions are for AI assistants working in this project. -* Establish and use a secure development lifecycle with AppSec professionals to help evaluate and design security and privacy-related controls -* Establish and use a library of secure design patterns or paved-road components -* Use threat modeling for critical parts of the application such as authentication, access control, business logic, and key flows -* User threat modeling as an educational tool to generate a security mindset -* Integrate security language and controls into user stories -* Integrate plausibility checks at each tier of your application (from frontend to backend) -* Write unit and integration tests to validate that all critical flows are resistant to the threat model. Compile use-cases *and* misuse-cases for each tier of your application. -* Segregate tier layers on the system and network layers, depending on the exposure and protection needs -* Segregate tenants robustly by design throughout all tiers +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 -## Example attack scenarios. +Keep this managed block so 'openspec update' can refresh the instructions. -**Scenario #1:** A credential recovery workflow might include “questions and answers,” which is prohibited by NIST 800-63b, the OWASP ASVS, and the OWASP Top 10. Questions and answers cannot be trusted as evidence of identity, as more than one person can know the answers. Such functionality should be removed and replaced with a more secure design. + -**Scenario #2:** A cinema chain allows group booking discounts and has a maximum of fifteen attendees before requiring a deposit. Attackers could threat model this flow and test if they can find an attack vector in the business logic of the application, e.g. booking six hundred seats and all cinemas at once in a few requests, causing a massive loss of income. +## Agent Instructions -**Scenario #3:** A retail chain’s e-commerce website does not have protection against bots run by scalpers buying high-end video cards to resell on auction websites. This creates terrible publicity for the video card makers and retail chain owners, and enduring bad blood with enthusiasts who cannot obtain these cards at any price. Careful anti-bot design and domain logic rules, such as purchases made within a few seconds of availability, might identify inauthentic purchases and reject such transactions. +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. -## References. +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. -* [OWASP Cheat Sheet: Secure Design Principles](https://cheatsheetseries.owasp.org/cheatsheets/Secure_Product_Design_Cheat_Sheet.html) -* [OWASP SAMM: Design | Secure Architecture](https://owaspsamm.org/model/design/secure-architecture/) -* [OWASP SAMM: Design | Threat Assessment](https://owaspsamm.org/model/design/threat-assessment/) -* [NIST – Guidelines on Minimum Standards for Developer Verification of Software](https://www.nist.gov/publications/guidelines-minimum-standards-developer-verification-software) -* [The Threat Modeling Manifesto](https://threatmodelingmanifesto.org/) -* [Awesome Threat Modeling](https://github.com/hysnsec/awesome-threat-modelling) +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. -## List of Mapped CWEs +## Mandatory Coding Principles -* [CWE-73 External Control of File Name or Path](https://cwe.mitre.org/data/definitions/73.html) +These coding principles are mandatory: -* [CWE-183 Permissive List of Allowed Inputs](https://cwe.mitre.org/data/definitions/183.html) +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. -* [CWE-256 Unprotected Storage of Credentials](https://cwe.mitre.org/data/definitions/256.html) +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. -* [CWE-266 Incorrect Privilege Assignment](https://cwe.mitre.org/data/definitions/266.html) +3. Functions and Modules + - Keep control flow linear and simple. + - Use small-to-medium functions; avoid deeply nested logic. + - Pass state explicitly; avoid globals. -* [CWE-269 Improper Privilege Management](https://cwe.mitre.org/data/definitions/269.html) +4. Naming and Comments + - Use descriptive-but-simple names. + - Comment only to note invariants, assumptions, or external requirements. -* [CWE-286 Incorrect User Management](https://cwe.mitre.org/data/definitions/286.html) +5. Logging and Errors + - Emit detailed, structured logs at key boundaries. + - Make errors explicit and informative. -* [CWE-311 Missing Encryption of Sensitive Data](https://cwe.mitre.org/data/definitions/311.html) +6. Regenerability + - Write code so any file/module can be rewritten from scratch without breaking the system. + - Prefer clear, declarative configuration (JSON/YAML/etc.). -* [CWE-312 Cleartext Storage of Sensitive Information](https://cwe.mitre.org/data/definitions/312.html) +7. Platform Use + - Use platform conventions directly and simply (e.g., WinUI/WPF) without over-abstracting. -* [CWE-313 Cleartext Storage in a File or on Disk](https://cwe.mitre.org/data/definitions/313.html) +8. Modifications + - When extending/refactoring, follow existing patterns. + - Prefer full-file rewrites over micro-edits unless told otherwise. -* [CWE-316 Cleartext Storage of Sensitive Information in Memory](https://cwe.mitre.org/data/definitions/316.html) +9. Quality + - Favor deterministic, testable behavior. + - Keep tests simple and focused on verifying observable behavior. -* [CWE-362 Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')](https://cwe.mitre.org/data/definitions/362.html) +## Pre-commit Hook Setup -* [CWE-382 J2EE Bad Practices: Use of System.exit()](https://cwe.mitre.org/data/definitions/382.html) +To enforce repomix updates locally, set up the pre-commit hook: -* [CWE-419 Unprotected Primary Channel](https://cwe.mitre.org/data/definitions/419.html) +```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 +``` -* [CWE-434 Unrestricted Upload of File with Dangerous Type](https://cwe.mitre.org/data/definitions/434.html) +## README Maintenance -* [CWE-436 Interpretation Conflict](https://cwe.mitre.org/data/definitions/436.html) +Keep `README.md` useful for first-time users, not only contributors. When user-facing behavior changes, update the README with the practical workflow, browser/storage expectations, build/test commands, troubleshooting notes, and any generated assets that users should know about. + -* [CWE-444 Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling')](https://cwe.mitre.org/data/definitions/444.html) + +{ + "presets": ["@babel/preset-typescript"], + "plugins": ["babel-plugin-istanbul"] +} + -* [CWE-451 User Interface (UI) Misrepresentation of Critical Information](https://cwe.mitre.org/data/definitions/451.html) + +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} + -* [CWE-454 External Initialization of Trusted Variables or Data Stores](https://cwe.mitre.org/data/definitions/454.html) + +MIT License -* [CWE-472 External Control of Assumed-Immutable Web Parameter](https://cwe.mitre.org/data/definitions/472.html) +Copyright (c) 2026 Federico Viscioletti -* [CWE-501 Trust Boundary Violation](https://cwe.mitre.org/data/definitions/501.html) +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: -* [CWE-522 Insufficiently Protected Credentials](https://cwe.mitre.org/data/definitions/522.html) +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -* [CWE-525 Use of Web Browser Cache Containing Sensitive Information](https://cwe.mitre.org/data/definitions/525.html) +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. + -* [CWE-539 Use of Persistent Cookies Containing Sensitive Information](https://cwe.mitre.org/data/definitions/539.html) + +{ + "$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}" + } + } + } +} + -* [CWE-598 Use of GET Request Method With Sensitive Query Strings](https://cwe.mitre.org/data/definitions/598.html) + +# Ink - Markdown Editor Web App -* [CWE-602 Client-Side Enforcement of Server-Side Security](https://cwe.mitre.org/data/definitions/602.html) +## Overview +Ink is a functional and minimalistic single-page web application for writing documents in markdown and exporting them. -* [CWE-628 Function Call with Incorrectly Specified Arguments](https://cwe.mitre.org/data/definitions/628.html) +## 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 -* [CWE-642 External Control of Critical State Data](https://cwe.mitre.org/data/definitions/642.html) +## 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 -* [CWE-646 Reliance on File Name or Extension of Externally-Supplied File](https://cwe.mitre.org/data/definitions/646.html) +## Build +```bash +npm install +npm run build # One-time build +npm run watch # Auto-rebuild on changes +``` -* [CWE-653 Insufficient Compartmentalization](https://cwe.mitre.org/data/definitions/653.html) +## Serving (Development) +Workflow "Start application" runs: +``` +npx http-server . -p 5000 -s +``` +App is accessible at: `http://localhost:5000/ink-app.html` -* [CWE-656 Reliance on Security Through Obscurity](https://cwe.mitre.org/data/definitions/656.html) +## Deployment +- Target: Static site +- Build command: `npm run build` +- Public directory: `.` (root, serves `ink-app.html`) -* [CWE-657 Violation of Secure Design Principles](https://cwe.mitre.org/data/definitions/657.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 -* [CWE-676 Use of Potentially Dangerous Function](https://cwe.mitre.org/data/definitions/676.html) +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`. -* [CWE-693 Protection Mechanism Failure](https://cwe.mitre.org/data/definitions/693.html) +## Testing +- QUnit unit tests: `npm run test:qunit` +- Cypress e2e tests: `npm run test:cypress` +- Full suite: `npm test` + -* [CWE-799 Improper Control of Interaction Frequency](https://cwe.mitre.org/data/definitions/799.html) + + + + + + + Declarative WebMCP Demo + + + +

Declarative WebMCP Demo

+

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

-* [CWE-807 Reliance on Untrusted Inputs in a Security Decision](https://cwe.mitre.org/data/definitions/807.html) +
+ -* [CWE-841 Improper Enforcement of Behavioral Workflow](https://cwe.mitre.org/data/definitions/841.html) + -* [CWE-1021 Improper Restriction of Rendered UI Layers or Frames](https://cwe.mitre.org/data/definitions/1021.html) + -* [CWE-1022 Use of Web Link to Untrusted Target with window.opener Access](https://cwe.mitre.org/data/definitions/1022.html) + +
+ + +
-* [CWE-1125 Excessive Attack Surface](https://cwe.mitre.org/data/definitions/1125.html) + +#!/bin/sh +# Pre-commit hook to run lint and update repomix output + +echo "Running ESLint..." +npm run lint || exit 1 + +echo "Running repomix..." +npx repomix@latest + +# Add the repomix output +git add repomix-output.xml - -# A07:2025 Authentication Failures ![icon](../assets/TOP_10_Icons_Final_Identification_and_Authentication_Failures.png){: style="height:80px;width:80px" align="right"} + +name: PR Checks +on: + pull_request: + branches: [main] -## Background. +jobs: + changes: + name: Detect changed files + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.filter.outputs.docs_only }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 -Authentication Failures maintains its position at #7 with a slight name change to more accurately reflect the 36 CWEs in this category. Despite benefits from standardized frameworks, this category has kept its #7 rank from 2021. Notable CWEs included are *CWE-259 Use of Hard-coded Password*, *CWE-297: Improper Validation of Certificate with Host Mismatch*, *CWE-287: Improper Authentication*, *CWE-384: Session Fixation*, and *CWE-798 Use of Hard-coded Credentials*. + - name: Classify pull request changes + id: filter + shell: bash + run: | + 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" -## Score table. + 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 - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
36 - 15.80% - 2.92% - 100.00% - 37.14% - 7.69 - 4.44 - 1,120,673 - 7,147 -
- - - -## Description. - -When an attacker is able to trick a system into recognizing an invalid or incorrect user as legitimate, this vulnerability is present. There may be authentication weaknesses if the application: - -* Permits automated attacks such as credential stuffing, where the attacker has a breached list of valid usernames and passwords. More recently this type of attack has been expanded to include hybrid password attacks credential stuffing (also known as password spray attacks), where the attacker uses variations or increments of spilled credentials to gain access, for instance trying Password1!, Password2!, Password3! and so on. - -* Permits brute force or other automated, scripted attacks that are not quickly blocked. + echo "docs_only=true" >> "$GITHUB_OUTPUT" -* Permits default, weak, or well-known passwords, such as "Password1" or "admin" username with an "admin" password. + test: + name: Build and test + needs: changes + if: needs.changes.outputs.docs_only != 'true' + runs-on: ubuntu-latest -* Allows users to create new accounts with already known-breached credentials. + steps: + - name: Checkout code + uses: actions/checkout@v4 -* Allows use of weak or ineffective credential recovery and forgot-password processes, such as "knowledge-based answers," which cannot be made safe. + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' -* Uses plain text, encrypted, or weakly hashed passwords data stores (see[ A04:2025-Cryptographic Failures](https://owasp.org/Top10/2025/A04_2025-Cryptographic_Failures/)). + - name: Install dependencies + run: npm ci -* Has missing or ineffective multi-factor authentication. + - name: Build + run: npm run build -* Allows use of weak or ineffective fallbacks if multi-factor authentication is not available. + - name: Run QUnit tests + run: npm run test:qunit -* Exposes session identifier in the URL, a hidden field, or another insecure location that is accessible to the client. + - name: Run Cypress tests + uses: cypress-io/github-action@v6 + with: + start: npm run serve:test + wait-on: 'http://127.0.0.1:4173/ink-app.html' + browser: chrome + headless: true + config: video=false -* Reuses the same session identifier after successful login. + - name: Upload Cypress Screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots +
-* Does not correctly invalidate user sessions or authentication tokens (mainly single sign-on (SSO) tokens) during logout or a period of inactivity. + +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -* Does not correctly assert the scope and intended audience of the provided credentials. + 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; + }, + }; +} -## How to prevent. +function createFakeDirectoryHandle(name) { + const entries = new Map(); -* Where possible, implement and enforce use of multi-factor authentication to prevent automated credential stuffing, brute force, and stolen credential reuse attacks. + 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, + }; +} -* Where possible, encourage and enable the use of password managers, to help users make better choices. +describe("ink authoring flow", () => { + const workspaceName = "workspace-a"; + const fileStem = "notes"; + const fileName = `${fileStem}.md`; + const markdown = "# Ink flow\n\nThis is markdown content."; -* Do not ship or deploy with any default credentials, particularly for admin users. + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); -* Implement weak password checks, such as testing new or changed passwords against the top 10,000 worst passwords list. + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; -* During new account creation and password changes validate against lists of known breached credentials (eg: using [haveibeenpwned.com](https://haveibeenpwned.com)). + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); -* Align password length, complexity, and rotation policies with [National Institute of Standards and Technology (NIST) 800-63b's guidelines in section 5.1.1](https://pages.nist.gov/800-63-3/sp800-63b.html#:~:text=5.1.1%20Memorized%20Secrets) for Memorized Secrets or other modern, evidence-based password policies. + 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); -* Do not force human beings to rotate passwords unless you suspect breach. If you suspect breach, force password resets immediately. + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#currentFilename").should("contain", fileName); -* Ensure registration, credential recovery, and API pathways are hardened against account enumeration attacks by using the same messages for all outcomes (“Invalid username or password.”). + cy.get("#editor").clear().type(markdown); + cy.get("[data-action=\"save\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Saved"); -* Limit or increasingly delay failed login attempts but be careful not to create a denial of service scenario. Log all failures and alert administrators when credential stuffing, brute force, or other attacks are detected or suspected. + cy.window().then(async (win) => { + const handle = await win.__fakeWorkspace.getFileHandle(fileName); + expect(handle.__read()).to.eq(markdown); + }); + }); -* Use a server-side, secure, built-in session manager that generates a new random session ID with high entropy after login. Session identifiers should not be in the URL, be securely stored in a secure cookie, and invalidated after logout, idle, and absolute timeouts. + 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 }); -* Ideally, use a premade, well-trusted system to handle authentication, identity, and session management. Transfer this risk whenever possible by buying and utilizing a hardened and well tested system. + cy.get("#editor").clear().type("# Hello World\n\nThis is a paragraph."); -* Verify the intended use of provided credentials, e.g. for JWTs validate `aud`, `iss` claims and scopes + cy.get("#preview").find("h1").should("contain", "Hello World"); + cy.get("#preview").find("p").should("contain", "This is a paragraph."); + }); + it("collapses and expands the left menu, updating accessibility state and editor space", () => { + let expandedEditorWidth = 0; + let collapsedEditorWidth = 0; -## Example attack scenarios. + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "true") + .and("contain", "▶ Collapse") + .and("be.visible"); + cy.get(".app").should("not.have.class", "sidebar-collapsed"); -**Scenario #1:** Credential stuffing, the use of lists of known username and password combinations, is now a very common attack. More recently attackers have been found to ‘increment’ or otherwise adjust passwords, based on common human behavior. For instance, changing ‘Winter2025’ to ‘Winter2026’, or ‘ILoveMyDog6’ to ‘ILoveMyDog7’ or ‘ILoveMyDog5’. This adjusting of password attempts is called a hybrid credential stuffing attack or a password spray attack, and they can be even more effective than the traditional version. If an application does not implement defences against automated threats (brute force, scripts, or bots) or credential stuffing, the application can be used as a password oracle to determine if the credentials are valid and gain unauthorized access. + cy.get("#editor").then(($editor) => { + expandedEditorWidth = $editor[0].getBoundingClientRect().width; + }); -**Scenario #2:** Most successful authentication attacks occur due to the continued use of passwords as the sole authentication factor. Once considered best practices, password rotation and complexity requirements encourage users to both reuse passwords and use weak passwords. Organizations are recommended to stop these practices per NIST 800-63 and to enforce use of multi-factor authentication on all important systems. + cy.get("#sidebarToggleBtn").click(); + cy.get(".app").should("have.class", "sidebar-collapsed"); + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "false") + .and("contain", "▼ Expand"); -**Scenario #3:** Application session timeouts aren't implemented correctly. A user uses a public computer to access an application and instead of selecting "logout," the user simply closes the browser tab and walks away. Another Example for this is, if a Single Sign on (SSO) session can not be closed by a Single Logout (SLO). That is, a single login logs you into, for example, your mail reader, your document system, and your chat system. But logging out happens only to the current system. If an attacker uses the same browser after the victim thinks they have successfully logged out, but with the user still authenticated to some of the applications, then can access the victim's account. The same issue can happen in offices and enterprises when a sensitive application has not been properly exited and a colleague has (temporary) access to the unlocked computer. + cy.get("#editor").then(($editor) => { + collapsedEditorWidth = $editor[0].getBoundingClientRect().width; + expect(collapsedEditorWidth).to.be.greaterThan(expandedEditorWidth); + }); -## References. + 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"); -* [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) + cy.get("#editor").then(($editor) => { + const finalEditorWidth = $editor[0].getBoundingClientRect().width; + expect(finalEditorWidth).to.be.lessThan(collapsedEditorWidth); + }); + }); +}); + -* [OWASP Secure Coding Practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/stable-en/01-introduction/05-introduction) + +function createFakeFileHandle(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() {}, + }; + }, + }; +} -## List of Mapped CWEs - -* [CWE-258 Empty Password in Configuration File](https://cwe.mitre.org/data/definitions/258.html) +function createFakeDirectoryHandle(name) { + const entries = new Map(); -* [CWE-259 Use of Hard-coded Password](https://cwe.mitre.org/data/definitions/259.html) + 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; + }, + }; +} -* [CWE-287 Improper Authentication](https://cwe.mitre.org/data/definitions/287.html) +describe("editor view mode toggle", () => { + const workspaceName = "view-mode-workspace"; + const noteName = "view-mode-note"; -* [CWE-288 Authentication Bypass Using an Alternate Path or Channel](https://cwe.mitre.org/data/definitions/288.html) + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); -* [CWE-289 Authentication Bypass by Alternate Name](https://cwe.mitre.org/data/definitions/289.html) + win.prompt = (message) => { + if (message.includes("New note name")) { + return noteName; + } + return null; + }; -* [CWE-290 Authentication Bypass by Spoofing](https://cwe.mitre.org/data/definitions/290.html) + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + }, + }); + }); -* [CWE-291 Reliance on IP Address for Authentication](https://cwe.mitre.org/data/definitions/291.html) + 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 }); -* [CWE-293 Using Referer Field for Authentication](https://cwe.mitre.org/data/definitions/293.html) + cy.get("#editorSplit").should("have.class", "view-split"); + cy.get("#editorViewSplitBtn").should("have.class", "active"); -* [CWE-294 Authentication Bypass by Capture-replay](https://cwe.mitre.org/data/definitions/294.html) + 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"); -* [CWE-295 Improper Certificate Validation](https://cwe.mitre.org/data/definitions/295.html) + 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"); -* [CWE-297 Improper Validation of Certificate with Host Mismatch](https://cwe.mitre.org/data/definitions/297.html) + 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"); + }); +}); + -* [CWE-298 Improper Validation of Certificate with Host Mismatch](https://cwe.mitre.org/data/definitions/298.html) + +describe("menu bar functionality", () => { + const workspaceName = "test-workspace"; -* [CWE-299 Improper Validation of Certificate with Host Mismatch](https://cwe.mitre.org/data/definitions/299.html) + function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -* [CWE-300 Channel Accessible by Non-Endpoint](https://cwe.mitre.org/data/definitions/300.html) + 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; + }, + }; + } -* [CWE-302 Authentication Bypass by Assumed-Immutable Data](https://cwe.mitre.org/data/definitions/302.html) + function createFakeDirectoryHandle(name) { + const entries = new Map(); -* [CWE-303 Incorrect Implementation of Authentication Algorithm](https://cwe.mitre.org/data/definitions/303.html) + 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, + }; + } -* [CWE-304 Missing Critical Step in Authentication](https://cwe.mitre.org/data/definitions/304.html) + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); -* [CWE-305 Authentication Bypass by Primary Weakness](https://cwe.mitre.org/data/definitions/305.html) + win.prompt = (message) => { + if (message.includes("New note name")) { + return "test-note"; + } + if (message.includes("Folder name")) { + return "test-folder"; + } + return null; + }; -* [CWE-306 Missing Authentication for Critical Function](https://cwe.mitre.org/data/definitions/306.html) + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); -* [CWE-307 Improper Restriction of Excessive Authentication Attempts](https://cwe.mitre.org/data/definitions/307.html) + describe("menu bar structure", () => { + it("contains File, Edit, and View menus", () => { + cy.get("#menuBar").should("exist"); + cy.get(".menu-text").should("contain", "File"); + cy.get(".menu-text").should("contain", "Edit"); + cy.get(".menu-text").should("contain", "View"); + }); -* [CWE-308 Use of Single-factor Authentication](https://cwe.mitre.org/data/definitions/308.html) + it("does not contain Import/Export menu", () => { + cy.get("#menuBar").should("not.contain", "Import/Export"); + }); -* [CWE-309 Use of Password System for Primary Authentication](https://cwe.mitre.org/data/definitions/309.html) + it("contains Export submenu under File menu", () => { + cy.get(".menu-text").contains("File").click(); + cy.get(".submenu-parent").should("contain", "Export"); + cy.get(".submenu-parent .dropdown.submenu").should("contain", "Export JSON"); + cy.get(".submenu-parent .dropdown.submenu").should("contain", "Export Markdown"); + }); + }); -* [CWE-346 Origin Validation Error](https://cwe.mitre.org/data/definitions/346.html) + describe("File menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("File").click(); + }); -* [CWE-350 Reliance on Reverse DNS Resolution for a Security-Critical Action](https://cwe.mitre.org/data/definitions/350.html) + it("New Note menu item exists and triggers createNewNote", () => { + cy.get("[data-action=\"new-note\"]").should("exist"); + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#currentFilename").should("contain", ".md"); + }); -* [CWE-384 Session Fixation](https://cwe.mitre.org/data/definitions/384.html) - -* [CWE-521 Weak Password Requirements](https://cwe.mitre.org/data/definitions/521.html) - -* [CWE-613 Insufficient Session Expiration](https://cwe.mitre.org/data/definitions/613.html) - -* [CWE-620 Unverified Password Change](https://cwe.mitre.org/data/definitions/620.html) - -* [CWE-640 Weak Password Recovery Mechanism for Forgotten Password](https://cwe.mitre.org/data/definitions/640.html) + it("New Folder menu item exists", () => { + cy.get("[data-action=\"new-folder\"]").should("exist"); + }); -* [CWE-798 Use of Hard-coded Credentials](https://cwe.mitre.org/data/definitions/798.html) + it("Open Workspace menu item exists", () => { + cy.get("[data-action=\"open-workspace\"]").should("exist"); + }); -* [CWE-940 Improper Verification of Source of a Communication Channel](https://cwe.mitre.org/data/definitions/940.html) + it("Close Workspace menu item exists", () => { + cy.get("[data-action=\"close-workspace\"]").should("exist"); + }); -* [CWE-941 Incorrectly Specified Destination in a Communication Channel](https://cwe.mitre.org/data/definitions/941.html) + it("Exit menu item exists", () => { + cy.get("[data-action=\"exit\"]").should("exist"); + }); + }); -* [CWE-1390 Weak Authentication](https://cwe.mitre.org/data/definitions/1390.html) + describe("Edit menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("Edit").click(); + }); -* [CWE-1391 Use of Weak Credentials](https://cwe.mitre.org/data/definitions/1391.html) + it("Save menu item exists", () => { + cy.get("[data-action=\"save\"]").should("exist"); + }); -* [CWE-1392 Use of Default Credentials](https://cwe.mitre.org/data/definitions/1392.html) + it("Save As menu item exists", () => { + cy.get("[data-action=\"save-as\"]").should("exist"); + }); -* [CWE-1393 Use of Default Password](https://cwe.mitre.org/data/definitions/1393.html) - + it("Refresh menu item exists", () => { + cy.get("[data-action=\"refresh\"]").should("exist"); + }); - -# A08:2025 Software or Data Integrity Failures ![icon](../assets/TOP_10_Icons_Final_Software_and_Data_Integrity_Failures.png){: style="height:80px;width:80px" align="right"} + it("Sort menu item exists", () => { + cy.get("[data-action=\"sort\"]").should("exist"); + }); + }); -## Background. + describe("View menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("View").click(); + }); -Software or Data Integrity Failures continues at #8, with a slight, clarifying name change from "Software *and* Data Integrity Failures". This category is focused on the failure to maintain trust boundaries and verify the integrity of software, code, and data artifacts at a lower level than Software Supply Chain Failures. This category focuses on making assumptions related to software updates and critical data, without verifying integrity. Notable Common Weakness Enumerations (CWEs) include *CWE-829: Inclusion of Functionality from Untrusted Control Sphere*, *CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes*, and *CWE-502: Deserialization of Untrusted Data*. + it("theme menu items exist", () => { + cy.get("[data-action=\"theme-default\"]").should("exist"); + cy.get("[data-action=\"theme-classic\"]").should("exist"); + cy.get("[data-action=\"theme-cobalt\"]").should("exist"); + cy.get("[data-action=\"theme-monokai\"]").should("exist"); + cy.get("[data-action=\"theme-office\"]").should("exist"); + cy.get("[data-action=\"theme-twilight\"]").should("exist"); + cy.get("[data-action=\"theme-xcode\"]").should("exist"); + }); + }); + describe("Export submenu functionality", () => { + beforeEach(() => { + cy.get(".menu-text").contains("File").click(); + cy.get(".submenu-parent").contains("Export").click(); + }); -## Score table. + it("Export JSON menu item exists in submenu", () => { + cy.get("[data-action=\"export-json\"]").should("exist"); + }); + it("Export Markdown menu item exists in submenu", () => { + cy.get("[data-action=\"export-markdown\"]").should("exist"); + }); + }); - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
14 - 8.98% - 2.75% - 78.52% - 45.49% - 7.11 - 4.79 - 501,327 - 3,331 -
+ describe("platform-aware shortcut detection", () => { + it("displays Cmd on Mac platform", () => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + Object.defineProperty(win.navigator, "platform", { + value: "MacIntel", + writable: true, + }); + const root = createFakeDirectoryHandle(workspaceName); + win.prompt = () => null; + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + }, + }); + cy.get(".menu-text").contains("File").click(); + cy.get("[data-action=\"new-note\"]").within(() => { + cy.get(".menu-shortcut").should("contain", "Cmd"); + }); + }); + it("displays Ctrl on Windows platform", () => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + Object.defineProperty(win.navigator, "platform", { + value: "Win32", + writable: true, + }); + const root = createFakeDirectoryHandle(workspaceName); + win.prompt = () => null; + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + }, + }); + cy.get(".menu-text").contains("File").click(); + cy.get("[data-action=\"new-note\"]").within(() => { + cy.get(".menu-shortcut").should("contain", "Ctrl"); + }); + }); + it("displays Ctrl on Linux platform", () => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + Object.defineProperty(win.navigator, "platform", { + value: "Linux x86_64", + writable: true, + }); + const root = createFakeDirectoryHandle(workspaceName); + win.prompt = () => null; + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + }, + }); + cy.get(".menu-text").contains("File").click(); + cy.get("[data-action=\"new-note\"]").within(() => { + cy.get(".menu-shortcut").should("contain", "Ctrl"); + }); + }); + }); +}); +
-## Description. + +function createFakeDirectoryHandle(name) { + const entries = new Map(); -Software and data integrity failures relate to code and infrastructure that does not protect against invalid or untrusted code or data being treated as trusted and valid. An example of this is where an application relies upon plugins, libraries, or modules from untrusted sources, repositories, and content delivery networks (CDNs). An insecure CI/CD pipeline without consuming and providing software integrity checks can introduce the potential for unauthorized access, insecure or malicious code, or system compromise. Another example of this is a CI/CD that pulls code or artifacts from untrusted places and/or doesn’t verify them before use (by checking the signature or similar mechanism). Lastly, many applications now include auto-update functionality, where updates are downloaded without sufficient integrity verification and applied to the previously trusted application. Attackers could potentially upload their own updates to be distributed and run on all installations. Another example is where objects or data are encoded or serialized into a structure that an attacker can see and modify is vulnerable to insecure deserialization. + 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, + }; +} +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -## How to prevent. + 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; + }, + }; +} +describe("removed toolbar buttons verification", () => { + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle("test-workspace"); + win.prompt = (message) => { + if (message.includes("New note name")) { + return "test-note"; + } + return null; + }; + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); + describe("5. QUnit-equivalent DOM verification", () => { + it("5.1 #saveBtn is NOT present in ink-app.html", () => { + cy.get("body").then(($body) => { + const saveBtn = $body.find("#saveBtn"); + expect(saveBtn.length).to.eq(0); + }); + }); -* Use digital signatures or similar mechanisms to verify the software or data is from the expected source and has not been altered. -* Ensure libraries and dependencies, such as npm or Maven, are only consuming trusted repositories. If you have a higher risk profile, consider hosting an internal known-good repository that's vetted. -* Ensure that there is a review process for code and configuration changes to minimize the chance that malicious code or configuration could be introduced into your software pipeline. -* Ensure that your CI/CD pipeline has proper segregation, configuration, and access control to ensure the integrity of the code flowing through the build and deploy processes. -* Ensure that unsigned or unencrypted serialized data is not received from untrusted clients and subsequently used without some form of integrity check or digital signature to detect tampering or replay of the serialized data. + it("5.2 #exportJsonBtn is NOT present in ink-app.html", () => { + cy.get("body").then(($body) => { + const exportJsonBtn = $body.find("#exportJsonBtn"); + expect(exportJsonBtn.length).to.eq(0); + }); + }); + it("5.3 #exportMdBtn is NOT present in ink-app.html", () => { + cy.get("body").then(($body) => { + const exportMdBtn = $body.find("#exportMdBtn"); + expect(exportMdBtn.length).to.eq(0); + }); + }); -## Example attack scenarios. + it("5.4 #newNoteBtn, #newFolderBtn, and #openFolderBtn are NOT present in ink-app.html", () => { + cy.get("body").then(($body) => { + expect($body.find("#newNoteBtn").length).to.eq(0); + expect($body.find("#newFolderBtn").length).to.eq(0); + expect($body.find("#openFolderBtn").length).to.eq(0); + }); + }); -**Scenario #1 Inclusion of Web Functionality from an Untrusted Source:** A company uses an external service provider to provide support functionality. For convenience, it has a DNS mapping for `myCompany.SupportProvider.com` to `support.myCompany.com`. This means that all cookies, including authentication cookies, set on the `myCompany.com` domain will now be sent to the support provider. Anyone with access to the support provider’s infrastructure can steal the cookies of all of your users that have visited `support.myCompany.com` and perform a session hijacking attack. + it("5.5 #dirtyDot IS present in ink-app.html after button removal", () => { + cy.get("#dirtyDot").should("exist"); + }); -**Scenario #2 Update without signing:** Many home routers, set-top boxes, device firmware, and others do not verify updates via signed firmware. Unsigned firmware is a growing target for attackers and is expected to only get worse. This is a major concern as many times there is no mechanism to remediate other than to fix in a future version and wait for previous versions to age out. + it("5.6 #statusBadge IS present in ink-app.html after button removal", () => { + cy.get("#statusBadge").should("exist"); + }); + }); -**Scenario #3 Use of Package from an Untrusted Source:** A developer has trouble finding the updated version of a package they are looking for, so they download it not from the regular, trusted package manager, but from a website online. The package is not signed, and thus there is no opportunity to ensure integrity. The package includes malicious code. + describe("6. Cypress integration tests for menu and keyboard shortcuts", () => { + it("6.1 Save button is absent from the editor header but save menu item exists", () => { + cy.get("#saveBtn").should("not.exist"); + cy.get("[data-action=\"save\"]").should("exist"); + cy.get("[data-action=\"save\"]").contains("Save"); + }); -**Scenario #4 Insecure Deserialization:** A React application calls a set of Spring Boot microservices. Being functional programmers, they tried to ensure that their code is immutable. The solution they came up with is serializing the user state and passing it back and forth with each request. An attacker notices the "rO0" Java object signature (in base64) and uses the [Java Deserialization Scanner](https://github.com/federicodotta/Java-Deserialization-Scanner) to gain remote code execution on the application server. + it("6.2 Menu items for New Note and Open Workspace exist", () => { + cy.get("[data-action=\"new-note\"]").should("exist"); + cy.get("[data-action=\"new-note\"]").contains("New Note"); + cy.get("[data-action=\"open-workspace\"]").should("exist"); + cy.get("[data-action=\"open-workspace\"]").contains("Open Workspace"); + }); -## References. + it("6.3 Export menu items exist", () => { + cy.get("[data-action=\"export-json\"]").should("exist"); + cy.get("[data-action=\"export-json\"]").contains("Export JSON"); + cy.get("[data-action=\"export-markdown\"]").should("exist"); + cy.get("[data-action=\"export-markdown\"]").contains("Export Markdown"); + }); -* [OWASP Cheat Sheet: Software Supply Chain Security](https://cheatsheetseries.owasp.org/cheatsheets/Software_Supply_Chain_Security_Cheat_Sheet.html) -* [OWASP Cheat Sheet: Infrastructure as Code](https://cheatsheetseries.owasp.org/cheatsheets/Infrastructure_as_Code_Security_Cheat_Sheet.html) -* [OWASP Cheat Sheet: Deserialization](https://wiki.owasp.org/index.php/Deserialization_Cheat_Sheet) -* [SAFECode Software Integrity Controls](https://safecode.org/publication/SAFECode_Software_Integrity_Controls0610.pdf) -* [A 'Worst Nightmare' Cyberattack: The Untold Story Of The SolarWinds Hack](https://www.npr.org/2021/04/16/985439655/a-worst-nightmare-cyberattack-the-untold-story-of-the-solarwinds-hack) -* [CodeCov Bash Uploader Compromise](https://about.codecov.io/security-update) -* [Securing DevOps by Julien Vehent](https://www.manning.com/books/securing-devops) -* [Insecure Deserialization by Tenendo](https://tenendo.com/insecure-deserialization/) + it("6.4 Keyboard shortcut hints are shown in menu items", () => { + cy.get("[data-action=\"save\"]").should("exist"); + cy.get("[data-action=\"new-note\"]").should("exist"); + cy.get("[data-action=\"open-workspace\"]").should("exist"); + }); + it("6.5 Status badge and dirty dot are in the correct location after button removal", () => { + cy.get("#editor").should("exist"); + cy.get("#dirtyDot").should("exist"); + cy.get("#statusBadge").should("exist"); -## List of Mapped CWEs + cy.get("#dirtyDot").parent().within(() => { + cy.get("#statusBadge").should("exist"); + }); + }); + }); +}); + -* [CWE-345 Insufficient Verification of Data Authenticity](https://cwe.mitre.org/data/definitions/345.html) + +## ADDED Requirements -* [CWE-353 Missing Support for Integrity Check](https://cwe.mitre.org/data/definitions/353.html) +### Requirement: Cogito Mode Menu Bar Button +The system SHALL provide a Cogito Mode button in the menu bar's top-right action area. -* [CWE-426 Untrusted Search Path](https://cwe.mitre.org/data/definitions/426.html) +#### 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 -* [CWE-427 Uncontrolled Search Path Element](https://cwe.mitre.org/data/definitions/427.html) +#### 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 -* [CWE-494 Download of Code Without Integrity Check](https://cwe.mitre.org/data/definitions/494.html) +### 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. -* [CWE-502 Deserialization of Untrusted Data](https://cwe.mitre.org/data/definitions/502.html) +#### 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 -* [CWE-506 Embedded Malicious Code](https://cwe.mitre.org/data/definitions/506.html) +### Requirement: Fixed Coaching Prompt Contract +The system SHALL generate questions using a fixed writing-coach instruction that outputs JSON only with exactly three questions. -* [CWE-509 Replicating Malicious Code (Virus or Worm)](https://cwe.mitre.org/data/definitions/509.html) +#### 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 -* [CWE-565 Reliance on Cookies without Validation and Integrity Checking](https://cwe.mitre.org/data/definitions/565.html) +### Requirement: Last-Sentence Grounding +The system SHALL ground generated questions in the user's most recent sentence from the active markdown content. -* [CWE-784 Reliance on Cookies without Validation and Integrity Checking in a Security Decision](https://cwe.mitre.org/data/definitions/784.html) +#### 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 -* [CWE-829 Inclusion of Functionality from Untrusted Control Sphere](https://cwe.mitre.org/data/definitions/829.html) +### Requirement: Exactly Three Rendered Questions +The system SHALL display exactly three generated questions in the Cogito Mode side panel. -* [CWE-830 Inclusion of Web Functionality from an Untrusted Source](https://cwe.mitre.org/data/definitions/830.html) +#### 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 -* [CWE-915 Improperly Controlled Modification of Dynamically-Determined Object Attributes](https://cwe.mitre.org/data/definitions/915.html) +#### 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 -* [CWE-926 Improper Export of Android Application Components](https://cwe.mitre.org/data/definitions/926.html) +### Requirement: AI Question Markdown Insertion Format +The system SHALL insert selected questions into the markdown document using a standardized AI block format. + +#### 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 + +### Requirement: Web-LLM Runtime Integration +The system SHALL use `https://esm.run/@mlc-ai/web-llm` for in-browser question generation. + +#### Scenario: Runtime is available +- **WHEN** Cogito Mode initializes successfully +- **THEN** question generation SHALL execute through the web-llm runtime in the browser + +#### 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 - -# A09:2025 Security Logging & Alerting Failures ![icon](../assets/TOP_10_Icons_Final_Security_Logging_and_Monitoring_Failures.png){: style="height:80px;width:80px" align="right"} + +## 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. +## 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. -## Background. +## 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. -Security Logging & Alerting Failures retains its position at #9. This category has a slight name change to emphasize the alerting function needed to induce action on relevant logging events. This category will always be underrepresented in the data, and for the third time voted into a position in the list from the community survey participants. This category is incredibly difficult to test for, and has minimal representation in the CVE/CVSS data (only 723 CVEs); but can be very impactful for visibility and incident alerting and forensics. This category includes issues with *properly handling output encoding to log files (CWE-117), inserting sensitive data into log files (CWE-532), and insufficient logging (CWE-778).* +## 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. +## 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. -## Score table. +## 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? + + +# Change: Add Cogito Mode Question Assistant - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
5 - 11.33% - 3.91% - 85.96% - 46.48% - 7.19 - 2.65 - 260,288 - 723 -
+## 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. +## 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. +## 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) +
-## Description. + +## 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. -Without logging and monitoring, attacks and breaches cannot be detected, and without alerting it is very difficult to respond quickly and effectively during a security incident. Insufficient logging, continuous monitoring, detection, and alerting to initiate active responses occurs any time: +## 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: Document Linter +The system SHALL provide a linting and review tool for markdown documents that analyzes prose and structure to provide actionable writing suggestions. -* Auditable events, such as logins, failed logins, and high-value transactions, are not logged or logged inconsistently (for instance, only logging successful logins, but not failed attempts). -* Warnings and errors generate no, inadequate, or unclear log messages. -* The integrity of logs is not properly protected from tampering. -* Logs of applications and APIs are not monitored for suspicious activity. -* Logs are only stored locally, and not properly backedup. -* Appropriate alerting thresholds and response escalation processes are not in place or effective. Alerts are not received or reviewed within a reasonable amount of time. -* Penetration testing and scans by dynamic application security testing (DAST) tools (such as Burp or ZAP) do not trigger alerts. -* The application cannot detect, escalate, or alert for active attacks in real-time or near real-time. -* You are vulnerable to sensitive information leakage by making logging and alerting events visible to a user or an attacker (see [A01:2025-Broken Access Control](A01_2025-Broken_Access_Control.md)), or by logging sensitive information that should not be logged (such as PII or PHI). -* You are vulnerable to injections or attacks on the logging or monitoring systems if log data is not correctly encoded. -* The application is missing or mishandling errors and other exceptional conditions, such that the system is unaware there was an error, and is therefore unable to log there was a problem. -* Adequate ‘use cases’ for issuing alerts are missing or outdated to recognize a special situation. -* Too many false positive alerts make it impossible to distinguish important alerts from unimportant ones, resulting in them being recognized too late or not at all (physical overload of the SOC team). -* Detected alerts cannot be processed correctly because the playbook for the use case is incomplete, out of date, or missing. +#### 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 +#### 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 -## How to prevent. +#### 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" -Developers should implement some or all the following controls, depending on the risk of the application: +#### Scenario: Provide readability metrics +- **WHEN** the system analyzes prose content +- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level +#### Scenario: Assess skimmability +- **WHEN** the system analyzes document structure +- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure -* Ensure all login, access control, and server-side input validation failures can be logged with sufficient user context to identify suspicious or malicious accounts and held for enough time to allow delayed forensic analysis. -* Ensure that every part of your app that contains a security control is logged, whether it succeeds or fails. -* Ensure that logs are generated in a format that log management solutions can easily consume. -* Ensure log data is encoded correctly to prevent injections or attacks on the logging or monitoring systems. -* Ensure all transactions have an audit trail with integrity controls to prevent tampering or deletion, such as append-only database tables or similar. -* Ensure all transactions that throw an error are rolled back and started over. Always fail closed. -* If your application or its users behave suspiciously, issue an alert. Create guidance for your developers on this topic so they can code against this or buy a system for this. -* DevSecOps and security teams should establish effective monitoring and alerting use cases including playbooks such that suspicious activities are detected and responded to quickly by the Security Operations Center (SOC) team. -* Add ‘honeytokens’ as traps for attackers into your application e.g. into the database, data, as real and/or technical user identity. As they are not used in normal business, any access generates logging data that can be alerted with nearly no false positives. -* Behavior analysis and AI support could be optionally an additional technique to support low rates of false positives for alerts. -* Establish or adopt an incident response and recovery plan, such as National Institute of Standards and Technology (NIST) 800-61r2 or later. Teach your software developers what application attacks and incidents look like, so they can report them. +#### Scenario: Score engagement proxies +- **WHEN** the system analyzes content for engagement +- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance -There are commercial and open-source application protection products such as the OWASP ModSecurity Core Rule Set, and open-source log correlation software, such as the Elasticsearch, Logstash, Kibana (ELK) stack, that feature custom dashboards and alerting that may help you combat these issues. There are also commercial observability tools that can help you respond to or block attacks in close to real-time. +#### Scenario: Check style quality +- **WHEN** the system analyzes prose +- **THEN** it identifies spelling errors and style consistency issues +#### Scenario: Export suggestions as markdown +- **WHEN** the user requests export +- **THEN** the system outputs the suggestions in markdown format + -## Example attack scenarios. + +## 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. -**Scenario #1:** A children's health plan provider's website operator couldn't detect a breach due to a lack of monitoring and logging. An external party informed the health plan provider that an attacker had accessed and modified thousands of sensitive health records of more than 3.5 million children. A post-incident review found that the website developers had not addressed significant vulnerabilities. As there was no logging or monitoring of the system, the data breach could have been in progress since 2013, a period of more than seven years. +#### 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 -**Scenario #2:** A major Indian airline had a data breach involving more than ten years' worth of personal data of millions of passengers, including passport and credit card data. The data breach occurred at a third-party cloud hosting provider, who notified the airline of the breach after some time. +#### 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 -**Scenario #3:** A major European airline suffered a GDPR reportable breach. The breach was reportedly caused by payment application security vulnerabilities exploited by attackers, who harvested more than 400,000 customer payment records. The airline was fined 20 million pounds as a result by the privacy regulator. +#### 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" +#### Scenario: Provide readability metrics +- **WHEN** the system analyzes prose content +- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level -## References. +#### Scenario: Assess skimmability +- **WHEN** the system analyzes document structure +- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure -- [OWASP Proactive Controls: C9: Implement Logging and Monitoring](https://top10proactive.owasp.org/archive/2024/the-top-10/c9-security-logging-and-monitoring/) +#### Scenario: Score engagement proxies +- **WHEN** the system analyzes content for engagement +- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance -- [OWASP Application Security Verification Standard: V16 Security Logging and Error Handling](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x25-V16-Security-Logging-and-Error-Handling.md) +#### Scenario: Check style quality +- **WHEN** the system analyzes prose +- **THEN** it identifies spelling errors and style consistency issues -- [OWASP Cheat Sheet: Application Logging Vocabulary](https://cheatsheetseries.owasp.org/cheatsheets/Application_Logging_Vocabulary_Cheat_Sheet.html) +#### Scenario: Export suggestions as markdown +- **WHEN** the user requests export +- **THEN** the system outputs the suggestions in markdown format + -- [OWASP Cheat Sheet: Logging](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) + +# Change: Add Document Linter -- [Data Integrity: Recovering from Ransomware and Other Destructive Events](https://csrc.nist.gov/publications/detail/sp/1800-11/final) +## 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. -- [Data Integrity: Identifying and Protecting Assets Against Ransomware and Other Destructive Events](https://csrc.nist.gov/publications/detail/sp/1800-25/final) +## 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 -- [Data Integrity: Detecting and Responding to Ransomware and Other Destructive Events](https://csrc.nist.gov/publications/detail/sp/1800-26/final) +## 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) + -- [Real world example of such failures in Snowflake Breach](https://www.huntress.com/threat-library/data-breach/snowflake-data-breach) + +## 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 + + +## ADDED Requirements +### Requirement: ESLint Code Quality +The project MUST use ESLint to enforce code quality standards on JavaScript files. -## List of Mapped CWEs +#### Scenario: ESLint runs successfully +- **WHEN** `npm run lint` is executed +- **THEN** ESLint analyzes all JavaScript files and reports any violations -* [CWE-117 Improper Output Neutralization for Logs](https://cwe.mitre.org/data/definitions/117.html) +#### Scenario: Build includes lint check +- **WHEN** the build process runs +- **THEN** lint check is performed and build fails if errors exist + -* [CWE-221 Information Loss of Omission](https://cwe.mitre.org/data/definitions/221.html) + +# Change: Add ESLint to the project -* [CWE-223 Omission of Security-relevant Information](https://cwe.mitre.org/data/definitions/223.html) +## 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. -* [CWE-532 Insertion of Sensitive Information into Log File](https://cwe.mitre.org/data/definitions/532.html) +## 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 -* [CWE-778 Insufficient Logging](https://cwe.mitre.org/data/definitions/778.html) +## 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 - -# A10:2025 Mishandling of Exceptional Conditions ![icon](../assets/TOP_10_Icons_Final_Mishandling_of_Exceptional_Conditions.png){: style="height:80px;width:80px" align="right"} + +## 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 + + +# Change: Add a Declarative WebMCP Note Creation Tool -## Background. +## Why +Ink already supports creating markdown notes, but it does not expose that capability through a declarative WebMCP tool. Adding a form-backed tool lets browser agents create notes through a stable contract instead of brittle UI automation. -Mishandling of Exceptional Conditions is a new category for 2025. This category contains 24 CWEs and focuses on improper error handling, logical errors, failing open, and other related scenarios stemming from abnormal conditions and systems may encounter. This category has some CWEs that were previously associated with poor code quality. That was too general for us; in our opinion, this more specific category provides better guidance. +## What Changes +- Add a declarative WebMCP `create_note` form to `ink.template.html` +- Route declarative form submissions into Ink's existing note creation flows +- Return a structured response for agent-invoked submissions without navigating away +- Fall back to a temporary in-memory session when no workspace is open so the tool remains usable -Notable CWEs included in this category: *CWE-209 Generation of Error Message Containing Sensitive Information, CWE-234 Failure to Handle Missing Parameter, CWE-274 Improper Handling of Insufficient Privileges, CWE-476 NULL Pointer Dereference,* and *CWE-636 Not Failing Securely ('Failing Open')*. +## Impact +- Affected specs: webmcp-notes (new capability) +- Affected code: `ink.template.html`, `src/app/ui-events.ts`, `src/app/workspace-io.ts`, `src/app/app-controller.ts`, `src/app/dom.ts`, `src/app/types.ts`, `.github/copilot-instructions.md` +- No breaking changes to existing keyboard or menu note workflows + + +## ADDED Requirements -## Score table. - - - - - - - - - - - - - - - - - - - - - - - - - -
CWEs Mapped - Max Incidence Rate - Avg Incidence Rate - Max Coverage - Avg Coverage - Avg Weighted Exploit - Avg Weighted Impact - Total Occurrences - Total CVEs -
24 - 20.67% - 2.95% - 100.00% - 37.95% - 7.11 - 3.81 - 769,581 - 3,416 -
- +### Requirement: Declarative Note Tool Exposure +The system SHALL expose a declarative WebMCP tool for creating notes from the app shell HTML. +#### Scenario: Agent discovers the note creation tool +- **WHEN** an agent inspects the ink application page +- **THEN** the page SHALL expose a form with `toolname` and `tooldescription` metadata +- **AND** the form SHALL define note creation parameters for title, body, and optional tag using form controls -## Description. +### Requirement: Declarative Tool Submission Behavior +The system SHALL process declarative note submissions through Ink's existing note creation behavior without navigating away from the app. -Mishandling exceptional conditions in software happens when programs fail to prevent, detect, and respond to unusual and unpredictable situations, which leads to crashes, unexpected behavior, and sometimes vulnerabilities. This can involve one or more of the following 3 failings; the application doesn’t prevent an unusual situation from happening, it doesn’t identify the situation as it is happening, and/or it responds poorly or not at all to the situation afterwards. +#### Scenario: Agent submits the declarative note tool +- **WHEN** the declarative note form is submitted by an agent +- **THEN** the system SHALL create a new note using the provided title, body, and optional tag +- **AND** the system SHALL prevent full-page navigation +- **AND** the system SHALL return a structured response describing the created note - +#### Scenario: Human submits the declarative note form +- **WHEN** the declarative note form is submitted manually in the page +- **THEN** the system SHALL create a new note using the same flow as an agent submission +- **AND** the application SHALL remain on the current page -Exceptional conditions can be caused by missing, poor, or incomplete input validation, or late, high level error handling instead at the functions where they occur, or unexpected environmental states such as memory, privilege, or network issues, inconsistent exception handling, or exceptions that are not handled at all, allowing the system to fall into an unknown and unpredictable state. Any time an application is unsure of its next instruction, an exceptional condition has been mishandled. Hard-to-find errors and exceptions can threaten the security of the whole application for a long time. +### Requirement: No-Workspace Fallback +The system SHALL keep the declarative note tool usable even when no filesystem workspace is open. - +#### Scenario: Declarative note tool is used before opening a workspace +- **WHEN** the user or agent submits the declarative note tool with no open workspace +- **THEN** the system SHALL create the note in a temporary in-memory session +- **AND** the UI SHALL indicate that the session is temporary +
-Many different security vulnerabilities can happen when we mishandle exceptional conditions, + +## 1. Implementation +- [x] 1.1 Add a declarative WebMCP note form to `ink.template.html` +- [x] 1.2 Add DOM references for the WebMCP form and fields +- [x] 1.3 Add a workspace action that creates notes from declarative form input +- [x] 1.4 Intercept WebMCP form submission and return an agent response without page navigation +- [x] 1.5 Ensure note creation works with both open workspaces and temporary in-memory sessions +- [x] 1.6 Update `.github/copilot-instructions.md` with the new WebMCP integration guidance -such as logic bugs, overflows, race conditions, fraudulent transactions, or issues with memory, state, resource, timing, authentication, and authorization. These types of vulnerabilities can negatively affect the confidentiality, availability, and/or integrity of a system or it’s data. Attackers manipulate an application's flawed error handling to strike this vulnerability. +## 2. Validation +- [x] 2.1 Validate the OpenSpec change with `openspec validate add-webmcp-notes-tool --strict` +- [x] 2.2 Build the app successfully +- [x] 2.3 Run the QUnit test suite successfully + + +## MODIFIED Requirements -## How to prevent. +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. -In order to handle an exceptional condition properly we must plan for such situations (expect the worst). We must ‘catch’ every possible system error directly at the place where they occur and then handle it (which means do something meaningful to solve the problem and ensure we recover from the issue). As part of the handling, we should include throwing an error (to inform the user in an understandable way), logging of the event, as well as issuing an alert if we feel that is justified. We should also have a global exception handler in place in case there is ever something we have missed. Ideally, we would also have monitoring and/or observability tooling or functionality that watches for repeated errors or patterns that indicate an on-going attack, that could issue a response, defense, or blocking of some kind. This can help us block and respond to scripts and bots that focus on our error handling weaknesses. +#### Scenario: Save shortcut +- **WHEN** a user presses the platform-appropriate plain save shortcut (Ctrl+S on Windows/Linux, Cmd+S on Mac) +- **THEN** the saveCurrentNote functionality is triggered +- **AND** the shortcut displayed in the menu reflects the user's platform - +#### Scenario: Export JSON shortcut +- **WHEN** a user presses the platform-appropriate export shortcut (Ctrl+Shift+S on Windows/Linux, Cmd+Shift+S on Mac) +- **THEN** the exportAsJson functionality is triggered +- **AND** the shortcut displayed in the menu reflects the user's platform +- **AND** saveCurrentNote is not triggered for that same key press -Catching and handling exceptional conditions ensures that the underlying infrastructure of our programs are not left to deal with unpredictable situations. If you are part way through a transaction of any kind, it is extremely important that you roll back every part of the transaction and start again (also known as failing closed). Attempting to recover a transaction part way through is often where we create unrecoverable mistakes. +### Requirement: Shortcut Key Conflict Resolution +The application SHALL resolve overlapping shortcut chords so the documented action still runs in every supported focus state. - +#### Scenario: Focused editor does not swallow export chord +- **WHEN** the editor textarea has focus and a user presses Ctrl+Shift+S on Windows/Linux or Cmd+Shift+S on Mac +- **THEN** the exportAsJson functionality is triggered +- **AND** the focused-editor save handler does not intercept the chord as plain save -Whenever possible, add rate limiting, resource quotas, throttling, and other limits wherever possible, to prevent exceptional conditions in the first place. Nothing in information technology should be limitless, as this leads to a lack of application resilience, denial of service, successful brute force attacks, and extraordinary cloud bills. \ -Consider whether identical repeated errors, above a certain rate, should only be outputted as statistics showing how often they have occurred and in what time frame. This information should be appended to the original message so as not to interfere with automated logging and monitoring, see [A09:2025 Security Logging & Alerting Failures](A09_2025-Security_Logging_and_Alerting_Failures.md). +#### Scenario: Focused editor still saves with plain save chord +- **WHEN** the editor textarea has focus and a user presses Ctrl+S on Windows/Linux or Cmd+S on Mac +- **THEN** the saveCurrentNote functionality is triggered +- **AND** no export action is triggered + -On top of this, we would want to include strict input validation (with sanitization or escaping for potentially hazardous characters that we must accept), and *centralized* error handling, logging, monitoring, and alerting, and a global exception handler. One application should not multiple functions for handling exceptional conditions, it should be performed in one place, the same way each time. We should also create project security requirements for all the advice in this section, perform threat modelling and/or secure design review activities in the design phase of our projects, perform code review or static analysis, as well as execute stress, performance, and penetration testing of the final system. + +# Change: Fix Export JSON Shortcut Precedence - +## Why -If possible, your entire organization should handle exceptional conditions in the same way, as it makes it easier to review and audit code for errors in this important security control. +The application advertises Cmd/Ctrl+Shift+S as the Export JSON shortcut, but when the editor has focus that chord is currently captured by the editor's Cmd/Ctrl+S save handler. This causes the current note to save instead of exporting all notes as JSON, which breaks the documented shortcut behavior. +## What Changes -## Example attack scenarios. +- Ensure the editor-scoped save shortcut only handles the plain Cmd/Ctrl+S chord +- Preserve Cmd/Ctrl+Shift+S for Export JSON even when the editor has focus +- Document shortcut precedence so longer modified chords are not swallowed by shorter handlers +- Add regression coverage for focused-editor shortcut behavior -**Scenario #1:** Resource exhaustion via mishandling of exceptional conditions (Denial of Service) could be caused if the application catches exceptions when files are uploaded, but doesn’t properly release resources after. Each new exception leaves resources locked or otherwise unavailable, until all resources are used up. +## Non-Regression Guarantees -**Scenario #2:** Sensitive data exposure via improper handling or database errors that reveals the full system error to the user. The attacker continues to force errors in order to use the sensitive system information to create a better SQL injection attack. The sensitive data in the user error messages are reconnaissance. +- Plain Cmd/Ctrl+S continues to save the current note from the editor and window scope +- Cmd/Ctrl+Shift+S exports JSON from the editor and window scope +- Existing shortcuts for new note, open workspace, refresh, and markdown export continue to work +- The documented platform-aware shortcut labels remain unchanged -**Scenario #3:** State corruption in financial transactions could be caused by an attacker interrupting a multi-step transaction via network disruptions. Imagine the transaction order was: debit user account, credit destination account, log transaction. If the system doesn’t properly roll back the entire transaction (fail closed) when there is an error part way through, the attacker could potentially drain the user’s account, or possibly a race condition that allows the attacker to send money to the destination multiple times. +## Testing Requirements +- Add automated coverage for Cmd/Ctrl+Shift+S while the editor textarea has focus +- Add automated coverage confirming plain Cmd/Ctrl+S still saves while the editor textarea has focus +- Validate that shortcut handling does not trigger both save and export for a single key press -## References. +## Impact -OWASP MASVS‑RESILIENCE +- Affected specs: `keyboard-shortcuts` +- Affected code: `src/app/ui-events.ts`, focused-editor shortcut handling, Cypress shortcut regression coverage +- User experience: Export JSON matches the documented Cmd/Ctrl+Shift+S shortcut in all focus states + -- [OWASP Cheat Sheet: Logging](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) + +## 1. Implementation -- [OWASP Cheat Sheet: Error Handling](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html) +- [x] 1.1 Update the editor keydown handler to reserve Cmd/Ctrl+Shift+S for JSON export instead of save +- [x] 1.2 Keep plain Cmd/Ctrl+S mapped to save in both editor-focused and global shortcut paths +- [x] 1.3 Keep shortcut handling linear so a single key chord triggers at most one action -- [OWASP Application Security Verification Standard (ASVS): V16.5 Error Handling](https://github.com/OWASP/ASVS/blob/master/5.0/en/0x25-V16-Security-Logging-and-Error-Handling.md#v165-error-handling) +## 2. Testing -- [OWASP Testing Guide: 4.8.1 Testing for Error Handling](https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/08-Testing_for_Error_Handling/01-Testing_For_Improper_Error_Handling) +- [x] 2.1 Add Cypress coverage for Cmd/Ctrl+Shift+S while `#editor` has focus +- [x] 2.2 Add Cypress coverage for Cmd/Ctrl+S while `#editor` has focus +- [x] 2.3 Verify the JSON export shortcut does not also trigger save side effects -* [Best practices for exceptions (Microsoft, .Net)](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions) +## 3. Validation -* [Clean Code and the Art of Exception Handling (Toptal)](https://www.toptal.com/developers/abap/clean-code-and-the-art-of-exception-handling) +- [x] 3.1 Run `openspec validate fix-export-json-shortcut-precedence --strict` +- [x] 3.2 Run the relevant automated shortcut regression tests after implementation + -* [General error handling rules (Google for Developers)](https://developers.google.com/tech-writing/error-messages/error-handling) + +## MODIFIED Requirements +### Requirement: Mobile Text Input Support +The system SHALL provide a comfortable editing experience on mobile devices without unwanted zoom behavior. -* [Example of real-world mishandling of an exceptional condition](https://www.firstreference.com/blog/human-error-and-internal-control-failures-cause-us62m-fine/) +#### Scenario: Tap textarea on mobile device +- **WHEN** user taps the textarea on a mobile device +- **THEN** the textarea does not trigger automatic browser zoom +- **AND** the font-size is at least 16px to prevent iOS Safari zoom behavior -## List of Mapped CWEs -* [CWE-209 Generation of Error Message Containing Sensitive Information](https://cwe.mitre.org/data/definitions/209.html) -* [CWE-215 Insertion of Sensitive Information Into Debugging Code](https://cwe.mitre.org/data/definitions/215.html) -* [CWE-234 Failure to Handle Missing Parameter](https://cwe.mitre.org/data/definitions/234.html) -* [CWE-235 Improper Handling of Extra Parameters](https://cwe.mitre.org/data/definitions/235.html) -* [CWE-248 Uncaught Exception](https://cwe.mitre.org/data/definitions/248.html) -* [CWE-252 Unchecked Return Value](https://cwe.mitre.org/data/definitions/252.html) -* [CWE-274 Improper Handling of Insufficient Privileges](https://cwe.mitre.org/data/definitions/274.html) -* [CWE-280 Improper Handling of Insufficient Permissions or Privileges](https://cwe.mitre.org/data/definitions/280.html) -* [CWE-369 Divide By Zero](https://cwe.mitre.org/data/definitions/369.html) -* [CWE-390 Detection of Error Condition Without Action](https://cwe.mitre.org/data/definitions/390.html) -* [CWE-391 Unchecked Error Condition](https://cwe.mitre.org/data/definitions/391.html) -* [CWE-394 Unexpected Status Code or Return Value](https://cwe.mitre.org/data/definitions/394.html) -* [CWE-396 Declaration of Catch for Generic Exception](https://cwe.mitre.org/data/definitions/396.html) -* [CWE-397 Declaration of Throws for Generic Exception](https://cwe.mitre.org/data/definitions/397.html) -* [CWE-460 Improper Cleanup on Thrown Exception](https://cwe.mitre.org/data/definitions/460.html) -* [CWE-476 NULL Pointer Dereference](https://cwe.mitre.org/data/definitions/476.html) -* [CWE-478 Missing Default Case in Multiple Condition Expression](https://cwe.mitre.org/data/definitions/478.html) -* [CWE-484 Omitted Break Statement in Switch](https://cwe.mitre.org/data/definitions/484.html) -* [CWE-550 Server-generated Error Message Containing Sensitive Information](https://cwe.mitre.org/data/definitions/550.html) -* [CWE-636 Not Failing Securely ('Failing Open')](https://cwe.mitre.org/data/definitions/636.html) -* [CWE-703 Improper Check or Handling of Exceptional Conditions](https://cwe.mitre.org/data/definitions/703.html) -* [CWE-754 Improper Check for Unusual or Exceptional Conditions](https://cwe.mitre.org/data/definitions/754.html) -* [CWE-755 Improper Handling of Exceptional Conditions](https://cwe.mitre.org/data/definitions/755.html) -* [CWE-756 Missing Custom Error Page](https://cwe.mitre.org/data/definitions/756.html) +#### Scenario: Desktop editing experience unchanged +- **WHEN** user edits on desktop viewport +- **THEN** the textarea maintains the original 14px font-size +- **AND** the editing experience remains consistent with previous behavior - -# Project Context + +## 1. Implementation +- [x] 1.1 Add 16px font-size for textarea on mobile viewports +- [x] 1.2 Build and verify fix in output + -## Purpose + +# Change: Harden Document Linter -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. +## Why +The Document Linter rewrite (`improve-document-linter-output`) shipped a markdown-aware report and a useful overview, but several rough edges remain that limit trust in the output: -## Tech Stack +- The analyzer is wrapped in a fake 100ms `setTimeout` for no real reason, which makes the async path untestable and misleading. +- Sentence tokenization is duplicated in two places with two different regexes, so the analyzer and the report can disagree. +- The engagement score is hard-coded to 68 with tiny adjustments, so the score doesn't track content quality. +- Strengths detection depends on a literal phrase ("this chapter is about") and a hand-picked list of ancient-Near-East place names, so most documents surface no strengths. +- `parseMarkdownBlocks` does not handle ordered lists, Setext-style headings, indented code blocks, or fenced code without a closing fence. +- The "section that needs attention" lookup in `buildOverview` uses a loose regex over note text, silently coupling two pieces of code. +- Test coverage is thin: no edge-case parsing tests, no isolated section-builder test, no snapshot test for the exported Markdown report, no controller-level tests. -- **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) +This change addresses the bug-class and coverage-class issues so the linter is reliable enough to build on (e.g. with a "rerun on change" toggle or a localization layer) in a follow-up. -## Project Conventions +## What Changes +- Make the analysis pipeline deterministic and properly testable: remove the artificial `setTimeout`, unify sentence tokenization, deduplicate the pseudo-section-label logic, and replace the regex-based overview lookup with an explicit flag. +- Replace the hard-coded engagement baseline with signal-based heuristics, and add an aggregate overall score to the panel and the exported report. +- Generalize the strengths heuristics so strong writing surfaces positive feedback regardless of topic. +- Broaden `parseMarkdownBlocks` to cover ordered lists, Setext headings, indented code, and unterminated fenced code. +- Expand QUnit coverage: parsing edge cases, isolated section builder, snapshot test for the Markdown report, empty/heading-only documents, and a JSDOM-based controller test. +- Extract the suggestion and overview copy into a small message module so it can be localized later (no new translations shipped here). +- Keep the existing panel HTML shape and Markdown report format as the contract; this change tightens internals and tests, not the public surface. -### Code Style +## Impact +- Affected specs: document-linter +- Affected code: `src/app/document-linter/document-linter.ts`, `tests/qunit/document-linter.test.js` +- Runtime dependency: None +- Breaking changes: none + -- ES6+ JavaScript modules -- Minimal dependencies - prefers vanilla JavaScript -- Single-file build output (ink.html) with inlined assets -- Functional programming patterns preferred + +## 1. Implementation -### Architecture Patterns +## 1. Analyzer correctness +- [x] 1.1 Remove the artificial 100ms `setTimeout` in `runAnalysis`; make analysis synchronous (or use a real async path such as `requestIdleCallback` if a non-blocking UI is needed) +- [x] 1.2 Unify sentence tokenization — `countSentences` and `splitSentences` should use one helper with one rule +- [x] 1.3 Deduplicate `detectPseudoSectionLabel` and the inline label check inside `buildDocumentSections` +- [x] 1.4 Replace the regex-based "section that needs attention" lookup in `buildOverview` with an explicit `needsAttention` flag on the section note -- **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 +## 2. Scoring +- [x] 2.1 Replace the hard-coded engagement baseline (currently 68) with heuristics derived from observable signals (opening verb energy, presence of a question, directive verbs, ratio of passive voice, etc.) +- [x] 2.2 Add an aggregate "Overall" score (weighted mean or simple min) to both the panel and the exported report -### Testing Strategy +## 3. Strengths detection +- [x] 3.1 Generalize the "this chapter is about" / mnemonic / place-name heuristics into generic detectors (dates, numbers, named entities, mnemonic patterns like "Remember…", parallel list items) +- [x] 3.2 Ensure strong writing that doesn't trigger the literal phrases still surfaces at least one strength -- No formal testing framework currently implemented -- Manual testing via browser -- Build verification through file generation +## 4. Parsing coverage +- [x] 4.1 Extend `parseMarkdownBlocks` to handle ordered lists (`1.`, `2.`, …) +- [x] 4.2 Extend `parseMarkdownBlocks` to handle Setext-style headings (`===`, `---`) +- [x] 4.3 Extend `parseMarkdownBlocks` to handle indented code blocks and fenced code without a closing fence +- [x] 4.4 Decide on a behavior for the implicit "Lead" section when a document starts with code or a quote, and name/document it -### Git Workflow +## 5. Test coverage +- [x] 5.1 Add QUnit tests for `parseMarkdownBlocks` edge cases: nested lists, ordered lists, multi-paragraph blockquotes, code without a closing fence, headings without a space +- [x] 5.2 Add QUnit tests for `buildDocumentSections` in isolation (not just via end-to-end analyzer tests) +- [x] 5.3 Add a snapshot test for `buildDocumentLinterReport` Markdown output +- [x] 5.4 Add tests for empty, single-word, and heading-only documents +- [x] 5.5 Add tests for the controller (`createDocumentLinterController`) using a JSDOM harness — assert panel HTML shape, toast/status callbacks, and "no content" guard +- [x] 5.6 Add a test that proves list items are not miscounted as sentences (markdown-aware classification) -- Simple trunk-based development -- Commits should be descriptive of changes -- Main branch contains production-ready code +## 6. Maintainability +- [x] 6.1 Extract all suggestion and overview copy into a `messages.ts` (or equivalent) module so non-English UI is possible later +- [x] 6.2 Document the sentence-tokenizer limitation in a code comment near the helper + -## Domain Context + +## MODIFIED 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. -- **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 +#### 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 +- **AND** the report highlights the highest-priority fixes first +- **AND** the report summary distinguishes between structural issues, prose issues, and low-signal sections -## Important Constraints +#### 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 -- **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 +#### 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" +- **AND** it avoids classifying markdown bullets, headings, and quoted callouts as generic long sentences -## Keyboard Shortcuts +#### Scenario: Provide readability metrics +- **WHEN** the system analyzes prose content +- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level -- **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 +#### Scenario: Assess skimmability +- **WHEN** the system analyzes document structure +- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure -## External Dependencies +#### Scenario: Score engagement proxies +- **WHEN** the system analyzes content for engagement +- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance -- **Marked.js**: Markdown parser library (embedded in build) -- **No External APIs**: Application works completely offline -- **No Backend**: No server-side components required +#### Scenario: Check style quality +- **WHEN** the system analyzes prose +- **THEN** it identifies spelling errors and style consistency issues + +#### Scenario: Export suggestions as markdown +- **WHEN** the user requests export +- **THEN** the system outputs the suggestions in markdown format +- **AND** the export includes a concise summary, prioritized fixes, and section-level detail - -export const DOCUMENT_LINTER_FALLBACK_STRENGTH = - "No clear strengths stand out yet; the draft needs more signal before the linter can praise specific choices."; + +# Change: Improve Document Linter Output Quality -export const DOCUMENT_LINTER_NO_MAJOR_FIXES = "No major fixes stood out."; +## Why +The current Document Linter output is technically correct but too generic to be useful on real study and writing notes. It over-flags list items as long sentences, repeats low-signal style warnings, and does not surface a clear priority order or a concise summary that helps the reader act quickly. -export const DOCUMENT_LINTER_NO_NOTABLE_ISSUES = "No notable issues in this category."; +## What Changes +- Upgrade the linter report to prioritize the most useful feedback first +- Make sentence and paragraph analysis markdown-aware so list items, headings, and quoted callouts are not misclassified as generic prose +- Add a stronger summary layer that highlights the top issues, strongest sections, and what to fix first +- Add section-level analysis so the linter can reason about headings, label-style section starts, and dense sub-parts of a document +- Reduce dull, repetitive phrasing in favor of more specific, content-aware suggestions +- Keep the report export in markdown, but make the exported structure more editorial and easier to scan -export const DOCUMENT_LINTER_NO_SECTION_STRUCTURE = - "This note did not expose any obvious structural sections."; +## Impact +- Affected specs: document-linter +- Affected code: Document Linter analysis pipeline, markdown report builder, export output +- Runtime dependency: None +- Breaking changes: none + -export function openingNeedsStructure(): string { - return "The note opens cleanly, but it still needs a visible structure signal."; -} + +## 1. Implementation +- [x] 1.1 Improve markdown-aware parsing so headings, bullets, and callouts are not treated like plain prose +- [x] 1.2 Rewrite the report builder to include a concise summary, top issues, and prioritized fixes +- [x] 1.3 Replace generic suggestion phrasing with more specific, content-aware language +- [x] 1.4 Keep export output in markdown while making it more readable and action-oriented +- [x] 1.5 Add tests that cover the new report structure and the markdown-aware classification rules + -export function openingTooDense(): string { - return "The first sentence does a lot of work. Tighten it and let the rest of the section carry the supporting detail."; -} + +## MODIFIED Requirements -export function openingNeedsStrongerLead(): string { - return "The opening is understandable, but it could be shaped into a sharper lead sentence."; -} +### Requirement: Cogito Mode Menu Bar Button +The system SHALL provide Cogito as the single writing-assistance button in the menu bar's top-right action area. -export function openingIsInformativeNotDirective(): string { - return "Lead with the payoff or takeaway first, then use the topic sentence to support it."; -} +#### Scenario: User sees the unified writing-assistance entrypoint +- **WHEN** the editor UI is visible +- **THEN** a Cogito button SHALL appear in the top-right menu bar actions +- **AND** the button SHALL expose an accessible label or description identifying document assessment and coaching +- **AND** no standalone Linter button SHALL appear -export function quickTakeStrengthsDetected(): string { - return "There are real strengths here: concrete details and structure cues give the document memory and shape."; -} +#### Scenario: User toggles the unified Cogito panel +- **WHEN** the user clicks the Cogito top-right button +- **THEN** the system SHALL open or close the unified Cogito side panel +- **AND** the toggle action SHALL not interrupt normal editing flow -export function quickTakeNeedsScaffold(): string { - return "The content is solid, but it needs a clearer scaffold so the reader can move through it faster."; -} +### Requirement: Cogito Mode Side Panel +The system SHALL provide one Cogito side panel where users can assess document strength and generate writing-coach questions while editing markdown. -export function quickTakeLowSignal(): string { - return "The draft is too thin or fragmentary to score well yet; add a clearer claim and one or two supporting details."; -} +#### Scenario: User opens Cogito +- **WHEN** the user enables Cogito +- **THEN** the editor SHALL show a Document strength area +- **AND** the editor SHALL show an Improve with Cogito area +- **AND** the panel SHALL not prevent normal markdown editing -export function missingHeading(): string { - return "Add a heading at the top so the chapter reads as a structured note instead of a raw outline."; -} +#### Scenario: Language model is unavailable +- **WHEN** WebLLM cannot initialize or generate questions +- **THEN** the Document strength area SHALL remain usable +- **AND** the system SHALL present a clear non-blocking coaching error state -export function explicitSectionLabel(label: string): string { - return `Turn "${label}" into a heading so the bullet list has a clean anchor.`; -} +### Requirement: Fixed Coaching Prompt Contract +The system SHALL generate questions using a fixed writing-coach instruction that accepts the user's latest sentence and, when available, compact document-analysis context, and outputs JSON only with exactly three questions. -export function bulletsAreDense(): string { - return "The bullet list has good content, but several bullets carry more than one idea. Consider grouping them by theme or splitting the longest ones."; -} +#### Scenario: Prompt uses current analysis +- **WHEN** Cogito requests questions after a document analysis +- **THEN** the system SHALL include a compact representation of the current document strengths and highest-priority improvements +- **AND** the system SHALL include the user's latest sentence +- **AND** the system SHALL instruct the model not to write prose or suggest sentences +- **AND** the system SHALL require exactly three questions as `{ "questions": ["...", "...", "..."] }` +- **AND** no additional non-JSON content SHALL be accepted as valid output -export function structureSimpleOutline(): string { - return "This would scan better with three visible beats: a short lead, a grouped facts section, and a closing takeaway."; -} +#### Scenario: Prompt runs without analysis +- **WHEN** Cogito requests questions before a document analysis exists +- **THEN** the system SHALL generate questions from the user's latest sentence +- **AND** the UI SHALL indicate that analyzing the document can make coaching more focused -export function structureBreakMiddle(): string { - return "The document's logic is good, but the middle section is carrying too much at once. A mid-document heading would make the progression easier to follow."; -} +### Requirement: Last-Sentence Grounding +The system SHALL ground generated questions in the user's most recent sentence and use document-analysis context only to focus the coaching goal. -export function denseAbstractLanguage(): string { - return "This section leans on a few abstract nouns. It still reads clearly, but a couple of them could be replaced with plainer words."; -} +#### 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** each generated question SHALL remain relevant to that sentence +- **AND** available linter findings SHALL guide which weaknesses or strengths the questions explore -export function repeatedWord(): string { - return "A word is repeated back-to-back. Clean that up before worrying about higher-level style."; -} +## ADDED Requirements -export function thinDraft(): string { - return "There is not enough developed prose yet to judge the document fairly. Add a clearer point and at least one supporting sentence."; -} +### Requirement: Analysis Snapshot Awareness +The system SHALL make it clear when displayed Cogito questions no longer match the latest document analysis. -export function thinSection(): string { - return "This section is too thin to evaluate well; add a clearer claim or supporting detail."; -} +#### Scenario: Document analysis changes after question generation +- **WHEN** a new analysis completes after Cogito questions were generated +- **THEN** the existing questions SHALL remain visible +- **AND** the panel SHALL mark them as based on an earlier analysis snapshot +- **AND** the system SHALL require explicit regeneration before replacing them + -export function questionNeedsAnswer(): string { - return "A question can be a good hook, but it needs an immediate answer or payoff."; -} + +## MODIFIED Requirements -export function balancedSection(): string { - return "This section is balanced and easy to follow."; -} +### Requirement: Document Linter +The system SHALL provide deterministic markdown document analysis as the Document strength area within the unified Cogito panel. -export function sectionNeedsHeading(title: string): string { - return `The "${title}" block introduces a real section. Promoting it to a heading would make the document easier to scan.`; -} +#### Scenario: Analyze current document from Cogito +- **WHEN** the user requests analysis in the Document strength area +- **THEN** the system SHALL parse the currently open document +- **AND** the system SHALL show an overall strength score +- **AND** the system SHALL return suggestions grouped by clarity, flow, scannability, and engagement +- **AND** the report SHALL highlight the highest-priority fixes and current strengths -export function sectionDenseBullets(lineStart: number, bulletCount: number): string { - return `The section starting at line ${lineStart} has ${bulletCount} bullets and several carry more than one idea.`; -} +#### Scenario: Analysis remains independent from AI +- **WHEN** the Cogito language model is unavailable, loading, or disabled +- **THEN** document analysis SHALL remain available +- **AND** analysis results SHALL remain deterministic for the same document content -export function sectionLong(lineStart: number): string { - return `The section starting at line ${lineStart} is carrying a lot of text and would be easier to scan if it were split.`; -} +#### Scenario: Generate section-specific suggestions +- **WHEN** the system analyzes a document +- **THEN** it SHALL return actionable suggestions tied to exact lines or sections +- **AND** line references SHALL navigate the editor to the relevant content +- **AND** markdown bullets, headings, quoted callouts, and code SHALL not be misclassified as generic prose -export function sectionLabelPromotion(title: string): string { - return `Turn "${title}:" into a heading so the section reads as intentional structure.`; -} +#### Scenario: Provide document-strength metrics +- **WHEN** the system analyzes document prose and structure +- **THEN** it SHALL calculate category scores and an aggregate overall score +- **AND** it SHALL evaluate readability, headings, bullets, paragraph density, scan-friendly structure, and engagement signals -export function sectionDenseBulletRun(): string { - return "This bullet run is dense; split the longest items or group them by theme."; -} +#### Scenario: Rerun analysis after edits +- **WHEN** the user enables rerun-on-change and edits the document +- **THEN** the Document strength results SHALL refresh from the current content +- **AND** any existing Cogito questions SHALL not be silently regenerated -export function sectionLongMaterial(): string { - return "This section carries a lot of material; consider splitting it into two smaller beats."; -} +#### Scenario: Export suggestions as markdown +- **WHEN** the user requests export from the Document strength area +- **THEN** the system SHALL output the suggestions in markdown format +- **AND** the export SHALL include a concise summary, prioritized fixes, strengths, scores, and section-level detail -export function sectionListNeedsLead(): string { - return "The heading works, but this section is list-only; a short lead sentence could help orient the reader."; -} +#### Scenario: Standalone linter entrypoint is removed +- **WHEN** the editor UI is visible +- **THEN** the system SHALL not show a standalone Linter menu bar button +- **AND** the system SHALL not expose an independently toggled Document Linter side panel +- **AND** all retained linter actions SHALL be available from the unified Cogito panel + -export function sectionNeedsAttention(title: string, note: string): string { - return `The "${title}" section deserves attention: ${note.toLowerCase()}`; -} + +## Context +Ink exposes Cogito and the Document Linter as mutually exclusive side panels. The linter explains what is strong or weak in the whole document, while Cogito asks questions about the latest sentence. Their separate entrypoints make related feedback feel like separate products and prevent Cogito from using the linter's deterministic findings to focus its coaching. -export function strengthConcreteAnchors(): string { - return "Concrete anchors such as names, dates, or numbers make the material easier to remember."; -} +The existing analysis engine, report builder, question parser, model selection, and markdown insertion behavior are useful boundaries and should remain independently testable. This change unifies their presentation and orchestration rather than merging all logic into one large module. -export function strengthMnemonic(): string { - return "The document gives the reader a memory hook, which makes the takeaway easier to retain."; -} +## Goals / Non-Goals +- Goals: + - Provide one top-right Cogito entrypoint for document assessment and coaching. + - Show a clear, understandable document-strength summary before or alongside AI coaching. + - Let Cogito focus its three questions using deterministic linter findings. + - Preserve editing, line navigation, rerun-on-change, report export, and question insertion. + - Keep linting usable when WebLLM is unavailable or still loading. +- Non-Goals: + - Automatically rewriting the user's prose. + - Automatically applying linter suggestions. + - Replacing the deterministic linter with an LLM-based score. + - Changing the linter scoring rules or report schema except where presentation context requires it. + - Loading a language model merely to display document strength. -export function strengthParallelList(): string { - return "The parallel list structure creates a strong rhythm and makes the sequence easy to scan."; -} +## Decisions +- Decision: Use the existing Cogito menu bar button as the only writing-assistance entrypoint. + - Rationale: Cogito is the broader concept and can naturally contain both diagnosis and coaching. +- Decision: Render one side panel with two explicit areas: **Document strength** and **Improve with Cogito**. + - Rationale: a linear assess-then-improve flow is clearer than two competing panels while keeping each responsibility visible. +- Decision: Keep `analyzeDocumentText()` and `buildDocumentLinterReport()` as deterministic linter contracts, and keep Cogito generation in its dedicated module. + - Rationale: the unified experience should not create a monolithic controller or make analysis dependent on AI availability. +- Decision: Supply Cogito with a compact, structured summary of the latest linter analysis plus the latest sentence. + - Rationale: coaching should target observable weaknesses while preserving the current local context that makes questions specific. +- Decision: Preserve the strict JSON response contract containing exactly three questions and the explicit insert action. + - Rationale: the change should improve relevance without turning Cogito into an auto-writer. +- Decision: Run document analysis on explicit user action by default and preserve the existing rerun-on-change option. + - Rationale: this retains predictable cost and timing while allowing users who want live feedback to opt in. +- Decision: Debounce rerun-on-change by 300 ms and bound model context to the latest 1,200 sentence characters plus compact linter lines. + - Rationale: analysis should run once after a typing burst, and unusually long unpunctuated drafts should not inflate WebLLM input. +- Decision: If no current analysis exists, Cogito question generation may run with latest-sentence context alone and the UI should invite the user to analyze for more focused coaching. + - Rationale: analysis improves Cogito but should not become a hard dependency that blocks the existing coaching flow. -export function strengthQuestionLead(): string { - return "The opening question creates forward pull and gives the reader a reason to keep going."; -} +## Risks / Trade-offs +- The unified panel may become visually dense. + - Mitigation: use clear section headings, compact score summaries, and progressive disclosure for detailed category and section feedback. +- Linter output may consume too much model context. + - Mitigation: pass only structured high-priority findings and strengths, not the rendered report or full analysis object. +- Rerun-on-change may update findings while generated questions are still visible. + - Mitigation: mark coaching as based on an earlier analysis snapshot and require explicit regeneration rather than silently replacing questions. +- Removing the standalone button may surprise existing users. + - Mitigation: keep all linter functions visible under the Cogito panel and use document-strength language in Cogito's accessible description and empty state. +- A combined controller could increase coupling. + - Mitigation: retain separate analysis and generation modules, with app-level orchestration passing only typed summaries between them. -export function strengthDirectiveLead(): string { - return "The lead uses clear directive language, which gives the document momentum."; -} +## Migration Plan +1. Restructure the template and styles around one Cogito panel containing document-strength and coaching sections. +2. Remove the standalone Linter button and its independent panel visibility state. +3. Rewire Document Linter controls and rendering into the Cogito panel while preserving analyzer/report contracts. +4. Add a compact analysis-to-Cogito context mapper and extend the fixed prompt input without changing the three-question output schema. +5. Update DOM types, controller orchestration, tests, documentation, and generated single-file output. +6. Verify the unified panel in Source, Split, Preview, and responsive layouts. -export function strengthClearStructure(): string { - return "The document already has enough visible structure that a reader can scan it quickly."; -} +## Resolved Questions +- Detailed linter categories and section notes remain expanded in the initial implementation so all existing feedback stays directly accessible. +- Opening Cogito retains an explicit first-run Analyze action. The coaching flow remains available without analysis and explains that analysis will make its questions more focused. - -import type { AppState } from "./types"; - -type ToastFn = (message: string, options?: { persist?: boolean }) => void; -type StatusFn = (message: string | null, kind?: "neutral" | "ok" | "warn" | "err") => void; - -type EnsurePermission = (handle: NonNullable, mode: "read" | "readwrite") => Promise; -const MINIMUM_IDLE_MS = 5000; - -type RescanWorkspace = (options?: { - silent?: boolean; - throwOnError?: boolean; - showProgress?: boolean; -}) => Promise; + +# Change: Integrate Document Linter into Cogito -export function createAutoRefresh({ - state, - ensurePermission, - rescanWorkspace, - showToast, - setStatus, -}: { - state: AppState; - ensurePermission: EnsurePermission; - rescanWorkspace: RescanWorkspace; - showToast: ToastFn; - setStatus: StatusFn; -}) { - function startAutoRefresh(): void { - stopAutoRefresh(); - state.autoRefreshTimer = setInterval(() => { - runAutoRefresh().catch((error: unknown) => { - showToast(`Auto-refresh failed: ${String(error)}`, { persist: true }); - setStatus("Auto-refresh failed", "err"); - }); - }, state.autoRefreshMs); - } +## Why +Cogito and the Document Linter currently compete as separate top-right actions and separate side panels even though they serve one writing workflow: understand the document's quality, then improve it. Combining them gives writers one clear place to assess document strength and receive focused coaching without choosing between overlapping tools. - async function runAutoRefresh(): Promise { - if (!state.workspaceHandle) { - return; - } +## What Changes +- Remove the standalone **Linter** menu bar button and keep **Cogito** as the single writing-assistance entrypoint. +- Replace the separate Cogito and Document Linter panels with one unified Cogito panel. +- Add a **Document strength** area inside Cogito that presents the linter's overall score, strengths, prioritized issues, category scores, and section-level feedback. +- Keep document analysis deterministic and available without loading the Cogito language model. +- Keep linter controls such as manual analysis, rerun-on-change, line navigation, and markdown report export within the unified panel. +- Add an **Improve with Cogito** area that generates exactly three question-only coaching prompts informed by the current document analysis and the writer's latest sentence. +- Preserve the existing Cogito model selection, recoverable model-loading states, explicit question insertion, and canonical AI markdown block format. +- Update unit and end-to-end coverage around the unified entrypoint, analysis flow, coaching flow, and removal of the standalone Linter UI. - if (state.isDirty) { - return; - } +## Impact +- Affected specs: `cogito-mode`, `document-linter` +- Affected code: `ink.template.html`, panel layout styles, DOM references/types, app-controller orchestration, UI event wiring, Cogito prompt context, Document Linter controller, QUnit tests, Cypress tests, and user/agent guidance +- Runtime dependency: no new dependency; deterministic linting remains client-side and Cogito continues to load WebLLM on demand +- Breaking changes: the standalone Linter button and independently toggled Document Linter panel are removed + - if (Date.now() - state.lastWorkspaceInteractionAt < MINIMUM_IDLE_MS) { - return; - } + +## 1. Unified panel structure +- [x] 1.1 Remove the standalone Linter menu bar button and retain Cogito as the single writing-assistance entrypoint. +- [x] 1.2 Replace the separate Cogito and Document Linter sidebars with one Cogito panel containing Document strength and Improve with Cogito sections. +- [x] 1.3 Update panel styles and responsive layouts so the unified panel works in Source, Split, and Preview modes. +- [x] 1.4 Update accessible labels, descriptions, status copy, and focus behavior for the combined experience. - try { - const permissionGranted = await ensurePermission(state.workspaceHandle, "read"); - if (!permissionGranted) { - throw new Error("Folder permission revoked."); - } - } catch { - stopAutoRefresh(); - showToast("Auto-refresh stopped: folder permission revoked.", { persist: true }); - setStatus("Permission revoked", "err"); - return; - } +## 2. Document strength integration +- [x] 2.1 Render the existing overall score, strengths, priorities, category scores, and section feedback in the Document strength area. +- [x] 2.2 Preserve manual Analyze, rerun-on-change, clickable line navigation, and markdown report export inside Cogito. +- [x] 2.3 Keep `analyzeDocumentText()` and `buildDocumentLinterReport()` stable and independent from WebLLM availability. +- [x] 2.4 Remove obsolete standalone panel visibility state, CSS classes, DOM references, and toggle event wiring. - await rescanWorkspace({ silent: true }); - } +## 3. Cogito coaching integration +- [x] 3.1 Add a typed mapper that reduces the latest linter analysis to compact strengths and prioritized improvement context. +- [x] 3.2 Extend Cogito generation input with the compact analysis context while preserving latest-sentence grounding. +- [x] 3.3 Preserve the strict JSON-only response with exactly three questions, model selection, error states, and explicit markdown insertion. +- [x] 3.4 Indicate when generated questions use an older analysis snapshot and require explicit regeneration after analysis changes. - function stopAutoRefresh(): void { - if (state.autoRefreshTimer) { - clearInterval(state.autoRefreshTimer); - state.autoRefreshTimer = null; - } - } +## 4. Validation +- [x] 4.1 Update QUnit tests for DOM/controller behavior, analysis context mapping, prompt construction, and retained report contracts. +- [x] 4.2 Replace separate Cogito and Document Linter Cypress flows with coverage for the unified entrypoint and full assess-then-improve workflow. +- [x] 4.3 Add assertions that no standalone Linter button or independently toggled linter panel remains. +- [x] 4.4 Run lint, build, QUnit, and Cypress checks and manually smoke-test responsive panel behavior. - return { - startAutoRefresh, - stopAutoRefresh, - runAutoRefresh, - }; -} +## 5. Documentation +- [x] 5.1 Update user-facing documentation to explain document strength and Cogito coaching as one workflow. +- [x] 5.2 Update repository agent guidance to describe the unified panel boundaries and preserved analyzer/generation contracts. - -export const icon = { - folder: () => - ``, + +## ADDED Requirements +### Requirement: Modular app controller +The system SHALL decompose the app controller into focused feature modules with explicit interfaces and a shared `AppState` passed as an argument. - folderOpen: () => - ``, +#### Scenario: Explicit module boundaries +- **WHEN** controller responsibilities are extracted +- **THEN** each module exports named functions for its feature area +- **AND** modules do not access global state or hidden singletons - fileText: () => - ``, +#### Scenario: Controller orchestration +- **WHEN** the app initializes +- **THEN** the controller constructs `AppState` once +- **AND** passes it explicitly into each module function - library: () => - ``, +### Requirement: Centralized UI event wiring +The system SHALL register UI event listeners in a dedicated `ui-events.ts` module that receives `DomRefs`, `AppState`, and explicit action callbacks. - check: () => - ``, -}; - +#### Scenario: UI events registration +- **WHEN** UI events are attached +- **THEN** the controller calls `ui-events` with callbacks for menu actions, shortcuts, and workspace flows - -import type { AppState, DomRefs } from "./types"; +### Requirement: Build/test entrypoint clarity +The system SHALL keep `build/compile-and-assemble.js` as the canonical build entry and use a dedicated test build helper for `npm run build:test`. -export function applySidebarState(els: DomRefs, state: AppState): void { - const isCollapsed = state.isSidebarCollapsed; - els.app.classList.toggle("sidebar-collapsed", isCollapsed); - els.workspaceSidebar.classList.toggle("collapsed", isCollapsed); - els.sidebarToggleBtn.setAttribute("aria-expanded", String(!isCollapsed)); - els.sidebarToggleBtn.setAttribute("aria-label", isCollapsed ? "Expand sidebar" : "Collapse sidebar"); - els.sidebarToggleBtn.title = isCollapsed ? "Expand sidebar" : "Collapse sidebar"; - els.sidebarToggleBtn.textContent = isCollapsed ? "▼ Expand" : "▶ Collapse"; -} +#### Scenario: Build entry documentation +- **WHEN** build instructions are documented +- **THEN** they reference `build/compile-and-assemble.js` as the canonical entrypoint -export function setSidebarCollapsed(els: DomRefs, state: AppState, isCollapsed: boolean): void { - state.isSidebarCollapsed = isCollapsed; - applySidebarState(els, state); -} +#### Scenario: Test build helper +- **WHEN** the test build runs +- **THEN** it uses a single helper script that centralizes the esbuild configuration - -export const VALID_THEMES = [ - "default", - "classic", - "cobalt", - "monokai", - "office", - "twilight", - "xcode", -]; + +## Context +The refactor plan identifies `src/app/bootstrap.ts` as a coupling hotspot that mixes UI wiring, state mutation, File System Access API flows, and rendering. The build/test scripts also have ambiguous naming and duplicated configuration. -export function applyTheme(theme: string): void { - if (!VALID_THEMES.includes(theme)) { - return; - } +## Goals / Non-Goals +- Goals: + - Decompose the controller into explicit feature modules with clear boundaries. + - Keep state transitions linear and explicit through shared `AppState`. + - Clarify build and test entrypoints while preserving single-file output. +- Non-Goals: + - Change user-visible behavior, UI layout, or feature set. + - Introduce new dependencies or build frameworks. - if (theme === "default") { - document.documentElement.removeAttribute("data-theme"); - } else { - document.documentElement.setAttribute("data-theme", theme); - } +## Decisions +- Decision: Keep a single `AppState` object and pass it explicitly into modules. +- Decision: Centralize event registration in `ui-events.ts` with injected callbacks. +- Decision: Preserve `src/app.ts` as the sole entrypoint and rename the controller file only. +- Decision: Add a `build/build-test.js` helper to own test bundle configuration. - try { - localStorage.setItem("ink-theme", theme); - } catch { - // ignore localStorage errors - } +## Risks / Trade-offs +- Risk: Refactor could introduce regressions in shortcuts or file system flows. + - Mitigation: Manual smoke testing of open/save/refresh and menu shortcuts after each extraction. - document.querySelectorAll(".menu-theme-check").forEach((el) => { - el.classList.remove("active"); - }); - const checkEl = document.getElementById(`themeCheck-${theme}`); - if (checkEl) { - checkEl.classList.add("active"); - } -} +## Migration Plan +1. Extract modules one by one, keeping the controller as an orchestration layer. +2. Rename the controller file and update imports once module boundaries are stable. +3. Update build/test scripts and documentation last to avoid churn. -export function loadTheme(): void { - let savedTheme = "default"; - try { - savedTheme = localStorage.getItem("ink-theme") ?? "default"; - } catch { - // ignore localStorage errors - } - applyTheme(VALID_THEMES.includes(savedTheme) ? savedTheme : "default"); -} +## Open Questions +- Resolved: use `app-controller.ts` as the controller filename. - -import type { DomRefs } from "./types"; + +# Change: Modularize app controller and build helpers -export type ToastTimerRef = { - current: ReturnType | null; -}; +## Why +The refactor plan calls for separating the large controller module into explicit feature units and clarifying build/test entrypoints so the codebase stays predictable for LLM maintenance without changing behavior. -export type StatusKind = "neutral" | "ok" | "warn" | "err"; +## What Changes +- Split `src/app/bootstrap.ts` into small feature modules with explicit interfaces and shared `AppState`. +- Introduce a dedicated `ui-events.ts` to centralize UI wiring and keyboard/menu shortcuts. +- Rename `bootstrap.ts` to a clearer controller name while keeping `src/app.ts` as the entrypoint. +- Add a test build helper and converge on `build/compile-and-assemble.js` as the canonical build entry. -export function setStatus(els: DomRefs, message: string | null, kind: StatusKind = "neutral"): void { - els.statusBadge.textContent = message; - els.statusBadge.classList.remove("ok", "warn", "err"); - if (kind !== "neutral") { - els.statusBadge.classList.add(kind); - } -} +## Impact +- Affected specs: codebase-maintainability +- Affected code: src/app/bootstrap.ts, src/app/*.ts, build/*.js, package.json, README.md + -export function createToastController(els: DomRefs, toastTimerRef: ToastTimerRef) { - function showToast(message: string, options: { persist?: boolean } = {}): void { - els.toastMsg.textContent = message; - els.toast.classList.add("show"); + +## 1. Implementation +- [x] 1.1 Map `InkApp` responsibilities to module boundaries from the refactor plan. +- [x] 1.2 Extract feature modules (menu actions, workspace IO, tree render, editor preview, toast/status, auto-refresh) with explicit exports. +- [x] 1.3 Add `ui-events.ts` to register UI event listeners and shortcuts via injected callbacks. +- [x] 1.4 Rename `bootstrap.ts` to the chosen controller filename and update imports/exports. +- [x] 1.5 Introduce `build/build-test.js` and update `npm run build:test` to use it. +- [x] 1.6 Update documentation to reference `build/compile-and-assemble.js` as the canonical build entry. +- [x] 1.7 Run build/test verification and smoke checks for workspace open/save/preview flows (automated via Cypress). + - if (toastTimerRef.current) { - clearTimeout(toastTimerRef.current); - toastTimerRef.current = null; - } + +## ADDED Requirements +### Requirement: Refactor and simplification plan +The system SHALL provide a refactor and simplification plan that aligns the codebase with the AI-first coding principles. - if (!options.persist) { - toastTimerRef.current = setTimeout(() => { - els.toast.classList.remove("show"); - }, 3500); - } - } +#### Scenario: Prioritized recommendations +- **WHEN** a plan is produced +- **THEN** it includes high, medium, and low priority sections +- **AND** only recommends changes that improve alignment with the coding principles - function hideToast(): void { - els.toast.classList.remove("show"); - } +#### Scenario: Deletion and renaming guidance +- **WHEN** a plan is produced +- **THEN** it identifies code or files that can be deleted +- **AND** it calls out candidates for renaming or restructuring - return { showToast, hideToast }; -} +#### Scenario: No-change guidance +- **WHEN** a plan is produced +- **THEN** it states which areas do not need change to avoid unnecessary churn - -import { bootstrapInkApp } from "./app/app-controller"; - -bootstrapInkApp(); - + +# Refactor and Simplification Plan - -import QUnit from "qunit"; -import { - createAuthManager, - generateCodeVerifier, - generateCodeChallenge, - calculateBackoff, - AuthError, - AuthErrorType, -} from "../../dist/test/github.js"; +## Audit Summary (Current Coupling) +- `src/app/bootstrap.ts` (~1335 lines) concentrates UI wiring, state mutation, File System Access API flows, tree rendering, preview rendering, and menu/shortcut behavior in one class. This is the primary coupling hotspot. +- `src/app/dom.ts`, `src/app/fs-api.ts`, `src/app/types.ts`, and `src/app/utils.ts` are already small, stable modules, but the higher-level orchestration still lives in `bootstrap.ts`. +- Build pipeline entry is `build/compile-and-assemble.js`, while `build/build.js` and `build/inject.js` act as compatibility aliases for older names. This creates naming ambiguity without functional separation. +- The test build script (`npm run build:test`) bundles multiple small modules into `dist/test/*` with repeated `esbuild` invocations, which is explicit but manually duplicated. -/** - * Mock localStorage for testing - */ -class MockLocalStorage { - constructor() { - this.store = new Map(); - } +## High Priority (Safety + Clarity) +1. Split `InkApp` in `src/app/bootstrap.ts` into small feature modules with explicit interfaces. + - Suggested modules: `menu-actions.ts`, `workspace-io.ts`, `tree-render.ts`, `editor-preview.ts`, `toast-status.ts`, `auto-refresh.ts`. + - Keep a single `AppState` and pass it explicitly into each module to avoid hidden coupling. +2. Flatten UI event wiring into a dedicated initializer. + - Move `attachEventListeners`, `attachMenuEventListeners`, and keyboard shortcuts into a `ui-events.ts` module that accepts `DomRefs`, `AppState`, and a small set of action callbacks. +3. Make state transitions explicit and sequential. + - Extract high-risk mutation flows (open workspace, refresh, save, save-as, close workspace) into functions that return updated state or mutate a specific slice, then re-render. + - This keeps control flow linear and reduces the chance of side effects crossing feature boundaries. - getItem(key) { - return this.store.get(key) ?? null; - } +## Medium Priority (Structure + Naming) +1. Rename `bootstrap.ts` to a clearer responsibility label. + - Suggested: `app-controller.ts` (or `app-core.ts`) while keeping `src/app.ts` as the only entrypoint. +2. Consolidate build script naming to reduce ambiguity. + - Choose one canonical entry (`build/compile-and-assemble.js`) and update `README.md` + `openspec/project.md` to reference it. + - Keep `build/build.js` and `build/inject.js` as temporary aliases only if needed for compatibility. +3. Consolidate repeated `esbuild` calls in `npm run build:test`. + - Introduce a small build helper (e.g., `build/build-test.js`) that centralizes the esbuild config for test bundles and reduces duplication. - setItem(key, value) { - this.store.set(key, value); - } +## Low Priority (Cleanup) +1. Re-evaluate `dist/test/*` as committed artifacts. + - If they are only generated outputs, consider excluding them from source control after verifying no workflows depend on committed copies. +2. Remove tiny utility duplication when the same logic appears in multiple spots. + - Example targets: repeated toast message formatting, repeated tree render error handling. - removeItem(key) { - this.store.delete(key); - } +## Deletions / Renames / Restructures +- **Rename**: `src/app/bootstrap.ts` -> `src/app/app-controller.ts` (keep `src/app.ts` as entrypoint). +- **Restructure**: Extract feature modules from `bootstrap.ts` into separate files under `src/app/` with explicit, flat function exports. +- **Delete (optional, only after confirming no external usage)**: `build/build.js` and `build/inject.js` once documentation and scripts use the canonical build entry. +- **Restructure**: Move test build steps into a dedicated build helper (e.g., `build/build-test.js`) and have `npm run build:test` call it. - clear() { - this.store.clear(); - } -} +## Areas to Keep Unchanged (Avoid Churn) +- `src/app/dom.ts`, `src/app/fs-api.ts`, `src/app/utils.ts`, `src/app/types.ts` are already narrow and should remain stable. +- `src/app.ts` should remain a tiny entrypoint that delegates to app initialization. +- `build/compile-and-assemble.js` should stay the canonical build pipeline entry unless a larger build redesign is approved. +- `ink.template.html` and `src/styles.scss` should not be refactored during maintainability-only changes. -/** - * Mock sessionStorage for testing - */ -class MockSessionStorage { - constructor() { - this.store = new Map(); - } +## Guardrails +- Preserve user-visible behavior (workspace open/save/edit, sidebar interactions, preview rendering) while refactoring. +- Keep build output as a single self-contained `ink-app.html`. + - getItem(key) { - return this.store.get(key) ?? null; - } + +# Change: Refactor and simplify for LLM-friendly maintenance - setItem(key, value) { - this.store.set(key, value); - } - - removeItem(key) { - this.store.delete(key); - } +## Why +The core UI/controller logic lives in a single large module, which increases coupling and makes safe regeneration harder. A structured refactor plan will align the codebase with the AI-first principles without changing behavior unnecessarily. - clear() { - this.store.clear(); - } -} +## What Changes +- Produce a refactor and simplification plan aligned to the repo's LLM coding principles. +- Define high/medium/low priority refactor themes with deletion/rename/restructure guidance. +- Call out areas that should remain unchanged to avoid churn. -/** - * Mock fetch for testing - */ -class MockFetch { - constructor() { - this.responses = new Map(); - this.callHistory = []; - } +## Impact +- Affected specs: codebase-maintainability +- Affected code: src/app/bootstrap.ts, src/app/*, build/*, tests/*, docs + - mock(url, response) { - this.responses.set(url, response); - } + +## 1. Planning Output +- [x] 1.1 Audit current modules and build/test pipeline for coupling and duplication. +- [x] 1.2 Draft a prioritized refactor plan with high/medium/low sections. +- [x] 1.3 Identify deletions/renames/restructures that reduce complexity. +- [x] 1.4 Document what should remain unchanged to avoid churn. + - getCalls() { - return [...this.callHistory]; - } + +# Change: Replace Emojis with SVG Icons - clear() { - this.responses.clear(); - this.callHistory = []; - } +## Why - async fetch(url, options = {}) { - this.callHistory.push({ url, options }); - const response = this.responses.get(url); - if (!response) { - return { - ok: false, - status: 404, - async json() { - return {}; - }, - }; - } - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - async json() { - return response.body; - }, - }; - } -} +The application currently uses Unicode emojis (📁, 📂, 📝, 🗂️, ✓) as UI icons across the file tree and status messages. Emojis render inconsistently across operating systems, browsers, and display densities — a folder icon looks different on macOS, Windows, and Linux. This inconsistency undermines the clean, focused aesthetic of Ink and gives the interface an informal, unpolished appearance. -QUnit.module("auth/github", () => { - QUnit.module("PKCE functions", () => { - QUnit.test("generateCodeVerifier creates valid length string", function (assert) { - const verifier = generateCodeVerifier(); - assert.ok(verifier.length >= 43 && verifier.length <= 128, - "Code verifier should be 43-128 characters"); - }); +Replacing emojis with a curated set of inline SVG icons delivers pixel-perfect, resolution-independent rendering everywhere, brings visual consistency, and allows full control over color, size, and hover states through CSS — resulting in a significantly more modern and professional look. - QUnit.test("generateCodeVerifier creates URL-safe characters", function (assert) { - const verifier = generateCodeVerifier(); - // Base64url characters: A-Z, a-z, 0-9, -, ., _, ~ - const validChars = /^[A-Za-z0-9\-._~]+$/; - assert.ok(validChars.test(verifier), - "Code verifier should only contain URL-safe characters"); - }); +## What Changes - QUnit.test("generateCodeVerifier creates unique strings", function (assert) { - const verifier1 = generateCodeVerifier(); - const verifier2 = generateCodeVerifier(); - assert.notStrictEqual(verifier1, verifier2, - "Each call should generate a unique verifier"); - }); +- Select a lightweight, offline-compatible SVG icon set (e.g. Lucide Icons or Heroicons) that can be inlined directly into the HTML template and TypeScript source, preserving the no-external-dependencies constraint. +- Define a small icon registry/helper that produces `` strings for each named icon, reusable across template and script code. +- Replace all emoji usage in `src/app/tree-render.ts` (📁, 📂, 📝) with the corresponding inline SVG icons. +- Replace the emoji in `ink.template.html` (🗂️ in the "Open Workspace" sidebar button) with an inline SVG icon. +- Replace the plain-text checkmark (✓) used in status and toast messages in `src/app/workspace-io.ts` with a styled SVG check icon or a dedicated CSS-driven indicator class, ensuring screen readers still receive meaningful text. +- Add SCSS rules to size, color, and align the new SVG icons consistently with surrounding text and buttons. +- Ensure no regressions in layout, accessibility (ARIA labels remain intact), or build output (single-file constraint). - QUnit.test("generateCodeChallenge produces correct format", async function (assert) { - const verifier = "test_verifier_string_with_exact_length_43chars"; - const challenge = await generateCodeChallenge(verifier); +## Impact - // Base64url format (no + or /, no padding) - const base64urlPattern = /^[A-Za-z0-9\-_]+$/; - assert.ok(base64urlPattern.test(challenge), - "Code challenge should be base64url encoded"); - assert.ok(challenge.length > 0, "Code challenge should not be empty"); - }); +- Affected files: + - `src/app/tree-render.ts` — emoji icon strings replaced with SVG helper calls + - `src/app/workspace-io.ts` — checkmark emoji in status/toast strings replaced + - `ink.template.html` — 🗂️ emoji replaced in the sidebar Open Workspace button + - `src/styles.scss` — new icon sizing and color utility rules + - `build/compile-and-assemble.js` — verify SVG strings survive the inline assembly step without encoding issues +- Affected specs: none existing +- No new runtime dependencies introduced; SVG markup is embedded at build time +- Build output remains a single self-contained HTML file + - QUnit.test("S256 produces deterministic result", async function (assert) { - const verifier = "test_verifier_for_determinism_check_12345"; - const challenge1 = await generateCodeChallenge(verifier); - const challenge2 = await generateCodeChallenge(verifier); + +# Project Context - assert.strictEqual(challenge1, challenge2, - "Same verifier should produce same challenge"); - }); - }); +## Purpose - QUnit.module("calculateBackoff", () => { - QUnit.test("returns base delay for first attempt", function (assert) { - const delay = calculateBackoff(0, 5000); - // Base delay + jitter (0-1000) - assert.ok(delay >= 5000 && delay < 6000, "Delay should be around 5000-6000ms"); - }); +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. - QUnit.test("returns exponential delay for subsequent attempts", function (assert) { - const delay2 = calculateBackoff(2, 5000); - const delay3 = calculateBackoff(3, 5000); +## Tech Stack - // 5000 * 2^2 = 20000 + jitter - assert.ok(delay2 >= 20000 && delay2 < 21000, "Delay should be around 20000-21000ms for attempt 2"); +- **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) - // 5000 * 2^3 = 40000 + jitter - assert.ok(delay3 >= 40000 && delay3 < 41000, "Delay should be around 40000-41000ms for attempt 3"); - }); +## Project Conventions - QUnit.test("caps delay at maximum interval", function (assert) { - const delay = calculateBackoff(100, 5000); - assert.ok(delay <= 61000, "Delay should be capped at max interval (60000 + 1000 jitter)"); - }); - }); +### Code Style - QUnit.module("AuthError", () => { - QUnit.test("creates error with correct properties", function (assert) { - const error = new AuthError(AuthErrorType.NetworkError, "Test message"); - assert.strictEqual(error.type, AuthErrorType.NetworkError); - assert.strictEqual(error.message, "Test message"); - assert.strictEqual(error.name, "AuthError"); - }); +- ES6+ JavaScript modules +- Minimal dependencies - prefers vanilla JavaScript +- Single-file build output (ink.html) with inlined assets +- Functional programming patterns preferred - QUnit.test("includes retryAfterMs when provided", function (assert) { - const error = new AuthError(AuthErrorType.ServerError, "Server error", 5000); - assert.strictEqual(error.retryAfterMs, 5000); - }); - }); +### Architecture Patterns - QUnit.module("createAuthManager", (hooks) => { - let mockStorage; - let mockSessionStorage; - let mockFetch; - let originalFetch; - let originalLocalStorage; - let originalSessionStorage; - let mockWindowLocation; +- **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 - hooks.beforeEach(function () { - mockStorage = new MockLocalStorage(); - mockSessionStorage = new MockSessionStorage(); - mockFetch = new MockFetch(); +### Testing Strategy - // Setup mock window.location - mockWindowLocation = { - href: "http://localhost:8000/", - pathname: "/", - hash: "", - }; +- No formal testing framework currently implemented +- Manual testing via browser +- Build verification through file generation - originalFetch = globalThis.fetch; - originalLocalStorage = global.localStorage; - originalSessionStorage = global.sessionStorage; +### Git Workflow - globalThis.fetch = function(url, options) { return mockFetch.fetch(url, options); }; - global.localStorage = mockStorage; - global.sessionStorage = mockSessionStorage; - }); +- Simple trunk-based development +- Commits should be descriptive of changes +- Main branch contains production-ready code - hooks.afterEach(function () { - globalThis.fetch = originalFetch; - global.localStorage = originalLocalStorage; - global.sessionStorage = originalSessionStorage; - mockFetch.clear(); - mockSessionStorage.clear(); - }); +## Domain Context - QUnit.test("starts with unauthenticated state", function (assert) { - const authManager = createAuthManager(); - const state = authManager.getState(); +- **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 - assert.strictEqual(state.isAuthenticated, false); - assert.strictEqual(state.isLoading, false); - assert.strictEqual(state.error, null); - }); +## Important Constraints - QUnit.test("restoreSession returns false when no token stored", function (assert) { - const authManager = createAuthManager(); - const restored = authManager.restoreSession(); +- **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 - assert.strictEqual(restored, false); - assert.strictEqual(authManager.isAuthenticated(), false); - }); +## Keyboard Shortcuts - QUnit.test("restoreSession returns true and authenticates when token exists", function (assert) { - const authManager = createAuthManager(); - mockStorage.setItem("ink_github_token", "test_token_123"); +- **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 - const restored = authManager.restoreSession(); +## External Dependencies - assert.strictEqual(restored, true); - assert.strictEqual(authManager.isAuthenticated(), true); - assert.strictEqual(authManager.getToken(), "test_token_123"); - }); +- **Marked.js**: Markdown parser library (embedded in build) +- **No External APIs**: Application works completely offline +- **No Backend**: No server-side components required + - QUnit.test("logout clears token and resets state", function (assert) { - const authManager = createAuthManager(); - mockStorage.setItem("ink_github_token", "test_token_123"); - authManager.restoreSession(); + +export const DOCUMENT_LINTER_FALLBACK_STRENGTH = + "No clear strengths stand out yet; the draft needs more signal before the linter can praise specific choices."; - authManager.logout(); +export const DOCUMENT_LINTER_NO_MAJOR_FIXES = "No major fixes stood out."; - assert.strictEqual(authManager.isAuthenticated(), false); - assert.strictEqual(authManager.getToken(), null); - assert.strictEqual(mockStorage.getItem("ink_github_token"), null); - }); +export const DOCUMENT_LINTER_NO_NOTABLE_ISSUES = "No notable issues in this category."; - QUnit.test("subscribe receives state change notifications", function (assert) { - const authManager = createAuthManager(); - let stateChanges = 0; - let lastState = null; +export const DOCUMENT_LINTER_NO_SECTION_STRUCTURE = + "This note did not expose any obvious structural sections."; - const unsubscribe = authManager.subscribe({ - onStateChange: function(state) { - stateChanges++; - lastState = state; - }, - }); +export function openingNeedsStructure(): string { + return "The note opens cleanly, but it still needs a visible structure signal."; +} - mockStorage.setItem("ink_github_token", "test_token"); - authManager.restoreSession(); +export function openingTooDense(): string { + return "The first sentence does a lot of work. Tighten it and let the rest of the section carry the supporting detail."; +} - assert.strictEqual(stateChanges, 1); - assert.strictEqual(lastState.isAuthenticated, true); +export function openingNeedsStrongerLead(): string { + return "The opening is understandable, but it could be shaped into a sharper lead sentence."; +} - authManager.logout(); +export function openingIsInformativeNotDirective(): string { + return "Lead with the payoff or takeaway first, then use the topic sentence to support it."; +} - assert.strictEqual(stateChanges, 2); - assert.strictEqual(lastState.isAuthenticated, false); +export function quickTakeStrengthsDetected(): string { + return "There are real strengths here: concrete details and structure cues give the document memory and shape."; +} - unsubscribe(); +export function quickTakeNeedsScaffold(): string { + return "The content is solid, but it needs a clearer scaffold so the reader can move through it faster."; +} - authManager.restoreSession(); - assert.strictEqual(stateChanges, 2, "Should not receive events after unsubscribe"); - }); +export function quickTakeLowSignal(): string { + return "The draft is too thin or fragmentary to score well yet; add a clearer claim and one or two supporting details."; +} - // Note: Tests involving window.location redirect and crypto mocking are skipped - // in this Node.js test environment. These would be tested in browser E2E tests. - }); +export function missingHeading(): string { + return "Add a heading at the top so the chapter reads as a structured note instead of a raw outline."; +} - QUnit.module("token storage", (hooks) => { - let mockStorage; - let mockSessionStorage; - let originalLocalStorage; - let originalSessionStorage; - let mockWindowLocation; +export function explicitSectionLabel(label: string): string { + return `Turn "${label}" into a heading so the bullet list has a clean anchor.`; +} - hooks.beforeEach(function () { - mockStorage = new MockLocalStorage(); - mockSessionStorage = new MockSessionStorage(); - originalLocalStorage = global.localStorage; - originalSessionStorage = global.sessionStorage; - global.localStorage = mockStorage; - global.sessionStorage = mockSessionStorage; +export function bulletsAreDense(): string { + return "The bullet list has good content, but several bullets carry more than one idea. Consider grouping them by theme or splitting the longest ones."; +} - mockWindowLocation = { - href: "http://localhost:8000/", - pathname: "/", - hash: "", - }; - }); +export function structureSimpleOutline(): string { + return "This would scan better with three visible beats: a short lead, a grouped facts section, and a closing takeaway."; +} - hooks.afterEach(function () { - global.localStorage = originalLocalStorage; - global.sessionStorage = originalSessionStorage; - }); +export function structureBreakMiddle(): string { + return "The document's logic is good, but the middle section is carrying too much at once. A mid-document heading would make the progression easier to follow."; +} - QUnit.test("logout removes token from storage", function (assert) { - const authManager = createAuthManager(); - mockStorage.setItem("ink_github_token", "test_token"); +export function denseAbstractLanguage(): string { + return "This section leans on a few abstract nouns. It still reads clearly, but a couple of them could be replaced with plainer words."; +} - authManager.logout(); +export function repeatedWord(): string { + return "A word is repeated back-to-back. Clean that up before worrying about higher-level style."; +} - assert.strictEqual(mockStorage.getItem("ink_github_token"), null); - }); +export function thinDraft(): string { + return "There is not enough developed prose yet to judge the document fairly. Add a clearer point and at least one supporting sentence."; +} - QUnit.test("logout clears code_verifier from sessionStorage", function (assert) { - const authManager = createAuthManager(); - mockSessionStorage.setItem("ink_github_code_verifier", "test_verifier"); +export function thinSection(): string { + return "This section is too thin to evaluate well; add a clearer claim or supporting detail."; +} - authManager.logout(); +export function questionNeedsAnswer(): string { + return "A question can be a good hook, but it needs an immediate answer or payoff."; +} - assert.strictEqual(mockSessionStorage.getItem("ink_github_code_verifier"), null); - }); - }); -}); - +export function balancedSection(): string { + return "This section is balanced and easy to follow."; +} - -import QUnit from "qunit"; -import { - extractLastSentence, - parseCogitoQuestionPayload, - formatCogitoQuestionBlock, - buildCogitoAnalysisContext, - buildCogitoUserPrompt, - isRecoverableModelLoadError, -} from "../../dist/test/cogito.js"; +export function sectionNeedsHeading(title: string): string { + return `The "${title}" block introduces a real section. Promoting it to a heading would make the document easier to scan.`; +} -QUnit.module("cogito"); +export function sectionDenseBullets(lineStart: number, bulletCount: number): string { + return `The section starting at line ${lineStart} has ${bulletCount} bullets and several carry more than one idea.`; +} -QUnit.test("extractLastSentence returns final complete sentence", (assert) => { - const result = extractLastSentence("First sentence. Second sentence? Third sentence!"); - assert.strictEqual(result, "Third sentence!"); -}); +export function sectionLong(lineStart: number): string { + return `The section starting at line ${lineStart} is carrying a lot of text and would be easier to scan if it were split.`; +} -QUnit.test("extractLastSentence handles single sentence without punctuation", (assert) => { - const result = extractLastSentence("A sentence without punctuation"); - assert.strictEqual(result, "A sentence without punctuation"); -}); +export function sectionLabelPromotion(title: string): string { + return `Turn "${title}:" into a heading so the section reads as intentional structure.`; +} -QUnit.test("parseCogitoQuestionPayload validates exactly three questions", (assert) => { - const payload = JSON.stringify({ - questions: ["What changed?", "Why now?", "What evidence supports it?"], - }); - const result = parseCogitoQuestionPayload(payload); - assert.deepEqual(result, ["What changed?", "Why now?", "What evidence supports it?"]); -}); +export function sectionDenseBulletRun(): string { + return "This bullet run is dense; split the longest items or group them by theme."; +} -QUnit.test("parseCogitoQuestionPayload throws when question count is invalid", (assert) => { - assert.throws(() => { - parseCogitoQuestionPayload(JSON.stringify({ questions: ["Only one"] })); - }, /exactly 3 questions/); -}); +export function sectionLongMaterial(): string { + return "This section carries a lot of material; consider splitting it into two smaller beats."; +} -QUnit.test("formatCogitoQuestionBlock returns required markdown block", (assert) => { - const result = formatCogitoQuestionBlock("What is your core claim?"); - assert.strictEqual(result, "> ### AI\nWhat is your core claim?\n"); -}); +export function sectionListNeedsLead(): string { + return "The heading works, but this section is list-only; a short lead sentence could help orient the reader."; +} -QUnit.test("buildCogitoAnalysisContext keeps only compact coaching signals", (assert) => { - const context = buildCogitoAnalysisContext({ - overallScore: 72, - overview: { - priorities: ["Clarify the opening.", "Break up the dense section.", "Add evidence.", "Ignored fourth item."], - strengths: ["Strong structure.", "Concrete examples.", "Ignored third strength."], - quickTake: [], - }, - scores: {}, - sections: [], - documentSections: [], - }); +export function sectionNeedsAttention(title: string, note: string): string { + return `The "${title}" section deserves attention: ${note.toLowerCase()}`; +} - assert.deepEqual(context, { - overallScore: 72, - priorities: ["Clarify the opening.", "Break up the dense section.", "Add evidence."], - strengths: ["Strong structure.", "Concrete examples."], - }); -}); +export function strengthConcreteAnchors(): string { + return "Concrete anchors such as names, dates, or numbers make the material easier to remember."; +} -QUnit.test("buildCogitoUserPrompt combines latest sentence with analysis context", (assert) => { - const prompt = buildCogitoUserPrompt("The conclusion needs sharper evidence.", { - overallScore: 68, - priorities: ["Support the main claim."], - strengths: ["The structure is easy to scan."], - }); +export function strengthMnemonic(): string { + return "The document gives the reader a memory hook, which makes the takeaway easier to retain."; +} - assert.ok(prompt.includes("Last sentence: The conclusion needs sharper evidence.")); - assert.ok(prompt.includes("Document strength: 68/100")); - assert.ok(prompt.includes("Support the main claim.")); - assert.ok(prompt.includes("The structure is easy to scan.")); -}); +export function strengthParallelList(): string { + return "The parallel list structure creates a strong rhythm and makes the sequence easy to scan."; +} -QUnit.test("buildCogitoUserPrompt bounds unpunctuated document context", (assert) => { - const longSentence = "opening " + "detail ".repeat(1000); - const prompt = buildCogitoUserPrompt(longSentence, { - overallScore: 50, - priorities: ["priority ".repeat(1000)], - strengths: ["strength ".repeat(1000)], - }); - const promptSentence = prompt.split("\n")[0]; +export function strengthQuestionLead(): string { + return "The opening question creates forward pull and gives the reader a reason to keep going."; +} - assert.ok(promptSentence.length <= 1215, "latest-sentence context should have a fixed upper bound"); - assert.ok(promptSentence.includes("…"), "truncated context should be visibly marked"); - assert.ok(promptSentence.endsWith("detail"), "the most recent end of the sentence should be retained"); - assert.ok(prompt.length < 1800, "the complete model prompt should remain compact"); -}); +export function strengthDirectiveLead(): string { + return "The lead uses clear directive language, which gives the document momentum."; +} -QUnit.test("isRecoverableModelLoadError identifies cache and network failures", (assert) => { - assert.true( - isRecoverableModelLoadError( - new Error("Failed to execute 'add' on 'Cache': Cache.add() encountered a network error"), - ), - ); - assert.true(isRecoverableModelLoadError(new Error("TypeError: Failed to fetch"))); - assert.false(isRecoverableModelLoadError(new Error("WebGPU is not supported"))); -}); +export function strengthClearStructure(): string { + return "The document already has enough visible structure that a reader can scan it quickly."; +} - -import QUnit from "qunit"; -import { - analyzeDocumentText, - buildDocumentLinterReport, - buildDocumentSections, - countSentences, - createDocumentLinterController, - parseMarkdownBlocks, -} from "../../dist/test/document-linter.js"; + +export const icon = { + folder: () => + ``, -function createClassList() { - const classes = new Set(); - return { - add(value) { - classes.add(value); - }, - remove(value) { - classes.delete(value); - }, - toggle(value, force) { - if (force === undefined) { - if (classes.has(value)) { - classes.delete(value); - return false; - } - classes.add(value); - return true; - } - if (force) { - classes.add(value); - return true; - } - classes.delete(value); - return false; - }, - contains(value) { - return classes.has(value); - }, - }; + folderOpen: () => + ``, + + fileText: () => + ``, + + library: () => + ``, + + check: () => + ``, +}; + + + +import type { AppState, DomRefs } from "./types"; + +export function applySidebarState(els: DomRefs, state: AppState): void { + const isCollapsed = state.isSidebarCollapsed; + els.app.classList.toggle("sidebar-collapsed", isCollapsed); + els.workspaceSidebar.classList.toggle("collapsed", isCollapsed); + els.sidebarToggleBtn.setAttribute("aria-expanded", String(!isCollapsed)); + els.sidebarToggleBtn.setAttribute("aria-label", isCollapsed ? "Expand sidebar" : "Collapse sidebar"); + els.sidebarToggleBtn.title = isCollapsed ? "Expand sidebar" : "Collapse sidebar"; + els.sidebarToggleBtn.textContent = isCollapsed ? "▼ Expand" : "▶ Collapse"; } -class FakeElement { - constructor(tagName = "div") { - this.tagName = tagName.toUpperCase(); - this.hidden = false; - this.disabled = false; - this.textContent = ""; - this.innerHTML = ""; - this.className = ""; - this.children = []; - this.parentNode = null; - this.classList = createClassList(); - this.attributes = new Map(); - this.closestMap = new Map(); - this.clicked = false; - this.listeners = new Map(); - this.selectionStart = 0; - this.selectionEnd = 0; - this.scrollTop = 0; - this.value = ""; +export function setSidebarCollapsed(els: DomRefs, state: AppState, isCollapsed: boolean): void { + state.isSidebarCollapsed = isCollapsed; + applySidebarState(els, state); +} + + + +export const VALID_THEMES = [ + "default", + "classic", + "cobalt", + "monokai", + "office", + "twilight", + "xcode", +]; + +export function applyTheme(theme: string): void { + if (!VALID_THEMES.includes(theme)) { + return; } - appendChild(child) { - child.parentNode = this; - this.children.push(child); - return child; + if (theme === "default") { + document.documentElement.removeAttribute("data-theme"); + } else { + document.documentElement.setAttribute("data-theme", theme); } - removeChild(child) { - this.children = this.children.filter((candidate) => candidate !== child); - child.parentNode = null; - return child; + try { + localStorage.setItem("ink-theme", theme); + } catch { + // ignore localStorage errors } - setAttribute(name, value) { - this.attributes.set(name, String(value)); + document.querySelectorAll(".menu-theme-check").forEach((el) => { + el.classList.remove("active"); + }); + const checkEl = document.getElementById(`themeCheck-${theme}`); + if (checkEl) { + checkEl.classList.add("active"); } +} - getAttribute(name) { - return this.attributes.get(name) ?? null; +export function loadTheme(): void { + let savedTheme = "default"; + try { + savedTheme = localStorage.getItem("ink-theme") ?? "default"; + } catch { + // ignore localStorage errors } + applyTheme(VALID_THEMES.includes(savedTheme) ? savedTheme : "default"); +} + - closest(selector) { - return this.closestMap.get(selector) ?? null; + +import type { DomRefs } from "./types"; + +export type ToastTimerRef = { + current: ReturnType | null; +}; + +export type StatusKind = "neutral" | "ok" | "warn" | "err"; + +export function setStatus(els: DomRefs, message: string | null, kind: StatusKind = "neutral"): void { + els.statusBadge.textContent = message; + els.statusBadge.classList.remove("ok", "warn", "err"); + if (kind !== "neutral") { + els.statusBadge.classList.add(kind); } +} - addEventListener(type, listener) { - const listeners = this.listeners.get(type) ?? []; - listeners.push(listener); - this.listeners.set(type, listeners); +export function createToastController(els: DomRefs, toastTimerRef: ToastTimerRef) { + function showToast(message: string, options: { persist?: boolean } = {}): void { + els.toastMsg.textContent = message; + els.toast.classList.add("show"); + + if (toastTimerRef.current) { + clearTimeout(toastTimerRef.current); + toastTimerRef.current = null; + } + + if (!options.persist) { + toastTimerRef.current = setTimeout(() => { + els.toast.classList.remove("show"); + }, 3500); + } } - dispatchEvent(event) { - event.target = this; - const listeners = this.listeners.get(event.type) ?? []; - listeners.forEach((listener) => listener(event)); - return true; + function hideToast(): void { + els.toast.classList.remove("show"); } - focus() { - global.document.activeElement = this; + return { showToast, hideToast }; +} + + + +import { bootstrapInkApp } from "./app/app-controller"; + +bootstrapInkApp(); + + + +import QUnit from "qunit"; +import { + createAuthManager, + generateCodeVerifier, + generateCodeChallenge, + calculateBackoff, + AuthError, + AuthErrorType, +} from "../../dist/test/github.js"; + +/** + * Mock localStorage for testing + */ +class MockLocalStorage { + constructor() { + this.store = new Map(); } - setSelectionRange(start, end) { - this.selectionStart = start; - this.selectionEnd = end; + getItem(key) { + return this.store.get(key) ?? null; } - click() { - this.clicked = true; + setItem(key, value) { + this.store.set(key, value); } -} -function createControllerDomRefs() { - const editor = new FakeElement("textarea"); - editor.value = ""; + removeItem(key) { + this.store.delete(key); + } - return { - editor, - documentLinterAnalyzeBtn: new FakeElement("button"), - documentLinterExportBtn: new FakeElement("button"), - documentLinterAutoRunToggle: new FakeElement("input"), - documentLinterStatus: new FakeElement("div"), - documentLinterResults: new FakeElement("div"), - }; + clear() { + this.store.clear(); + } } -QUnit.module("document-linter", (hooks) => { - hooks.beforeEach(function () { - this.originalDocument = global.document; - this.originalWindow = global.window; - this.originalURL = global.URL; - this.originalBlob = global.Blob; +/** + * Mock sessionStorage for testing + */ +class MockSessionStorage { + constructor() { + this.store = new Map(); + } - const fakeDocument = { - body: new FakeElement("body"), - activeElement: null, - createElement(tagName) { - return new FakeElement(tagName); - }, - }; + getItem(key) { + return this.store.get(key) ?? null; + } - global.document = fakeDocument; - global.window = { - setTimeout, - clearTimeout, - getComputedStyle() { - return { lineHeight: "20" }; - }, - }; - global.URL = { - created: [], - revoked: [], - createObjectURL(blob) { - this.created.push(blob); - return "blob:fake-report"; - }, - revokeObjectURL(url) { - this.revoked.push(url); - }, - }; - global.Blob = class FakeBlob { - constructor(parts, options) { - this.parts = parts; - this.options = options; - } - }; - }); + setItem(key, value) { + this.store.set(key, value); + } - hooks.afterEach(function () { - global.document = this.originalDocument; - global.window = this.originalWindow; - global.URL = this.originalURL; - global.Blob = this.originalBlob; - }); + removeItem(key) { + this.store.delete(key); + } - QUnit.test("parseMarkdownBlocks handles ordered lists, setext headings, quotes, and open code fences", function (assert) { - const text = `Overview ---- + clear() { + this.store.clear(); + } +} -1. First point -2. Second point - - nested detail +/** + * Mock fetch for testing + */ +class MockFetch { + constructor() { + this.responses = new Map(); + this.callHistory = []; + } -> quoted paragraph -> -> still quoted + mock(url, response) { + this.responses.set(url, response); + } - const value = 1; + getCalls() { + return [...this.callHistory]; + } -\`\`\`js -console.log("open fence") -`; + clear() { + this.responses.clear(); + this.callHistory = []; + } - const blocks = parseMarkdownBlocks(text); + async fetch(url, options = {}) { + this.callHistory.push({ url, options }); + const response = this.responses.get(url); + if (!response) { + return { + ok: false, + status: 404, + async json() { + return {}; + }, + }; + } + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + async json() { + return response.body; + }, + }; + } +} - assert.deepEqual( - blocks.map((block) => block.type), - ["heading", "list_item", "list_item", "list_item", "blockquote", "code", "code"], - "markdown blocks should preserve structure-aware block types", - ); - assert.strictEqual(blocks[0].text, "Overview", "setext heading should be parsed as a heading"); - assert.strictEqual(blocks[4].text, "quoted paragraph still quoted", "blockquote paragraphs should be merged"); - assert.ok(blocks[6].text.includes("console.log(\"open fence\")"), "unterminated fenced code should still become a code block"); - assert.strictEqual(parseMarkdownBlocks("#NoSpace")[0].type, "paragraph", "headings without a space should stay as paragraph text"); - }); +QUnit.module("auth/github", () => { + QUnit.module("PKCE functions", () => { + QUnit.test("generateCodeVerifier creates valid length string", function (assert) { + const verifier = generateCodeVerifier(); + assert.ok(verifier.length >= 43 && verifier.length <= 128, + "Code verifier should be 43-128 characters"); + }); - QUnit.test("buildDocumentSections creates a lead section for quote-first documents and detects label sections", function (assert) { - const text = `> Opening quote + QUnit.test("generateCodeVerifier creates URL-safe characters", function (assert) { + const verifier = generateCodeVerifier(); + // Base64url characters: A-Z, a-z, 0-9, -, ., _, ~ + const validChars = /^[A-Za-z0-9\-._~]+$/; + assert.ok(validChars.test(verifier), + "Code verifier should only contain URL-safe characters"); + }); -Facts to keep: -- One -- Two + QUnit.test("generateCodeVerifier creates unique strings", function (assert) { + const verifier1 = generateCodeVerifier(); + const verifier2 = generateCodeVerifier(); + assert.notStrictEqual(verifier1, verifier2, + "Each call should generate a unique verifier"); + }); -## Timeline -Paragraph after heading.`; + QUnit.test("generateCodeChallenge produces correct format", async function (assert) { + const verifier = "test_verifier_string_with_exact_length_43chars"; + const challenge = await generateCodeChallenge(verifier); - const sections = buildDocumentSections(parseMarkdownBlocks(text)); + // Base64url format (no + or /, no padding) + const base64urlPattern = /^[A-Za-z0-9\-_]+$/; + assert.ok(base64urlPattern.test(challenge), + "Code challenge should be base64url encoded"); + assert.ok(challenge.length > 0, "Code challenge should not be empty"); + }); - assert.deepEqual( - sections.map((section) => ({ title: section.title, kind: section.kind })), - [ - { title: "Lead", kind: "implicit" }, - { title: "Facts to keep", kind: "label" }, - { title: "Timeline", kind: "heading" }, - ], - "sections should preserve implicit lead content before label and heading sections", - ); + QUnit.test("S256 produces deterministic result", async function (assert) { + const verifier = "test_verifier_for_determinism_check_12345"; + const challenge1 = await generateCodeChallenge(verifier); + const challenge2 = await generateCodeChallenge(verifier); + + assert.strictEqual(challenge1, challenge2, + "Same verifier should produce same challenge"); + }); }); - QUnit.test("analyzeDocumentText scores and flags long prose while preserving generic strengths", function (assert) { - const text = `This chapter is about how early cities were formed across river valleys, and it keeps layering context, chronology, social change, and settlement details into one long opening sentence that asks the reader to hold too much at once. + QUnit.module("calculateBackoff", () => { + QUnit.test("returns base delay for first attempt", function (assert) { + const delay = calculateBackoff(0, 5000); + // Base delay + jitter (0-1000) + assert.ok(delay >= 5000 && delay < 6000, "Delay should be around 5000-6000ms"); + }); -Remember this: -- Compare river access with food storage -- Compare crop surplus with trade growth -- Compare dense neighborhoods with social roles`; + QUnit.test("returns exponential delay for subsequent attempts", function (assert) { + const delay2 = calculateBackoff(2, 5000); + const delay3 = calculateBackoff(3, 5000); - const analysis = analyzeDocumentText(text); + // 5000 * 2^2 = 20000 + jitter + assert.ok(delay2 >= 20000 && delay2 < 21000, "Delay should be around 20000-21000ms for attempt 2"); - assert.ok(analysis.scores.readability.score < 100, "readability score should drop for dense prose"); - assert.ok(analysis.overallScore > 0, "analysis should include an aggregate overall score"); - assert.ok( - analysis.scores.readability.suggestions.some((suggestion) => suggestion.includes("Dense sentence")), - "readability suggestions should include the long sentence warning", - ); - assert.ok( - analysis.overview.strengths.some((strength) => strength.includes("memory hook") || strength.includes("structure")), - "generic strengths should be surfaced without topic-specific place-name heuristics", - ); - assert.ok( - analysis.documentSections.some((section) => section.title === "Remember this" && section.needsAttention), - "section analysis should carry an explicit needsAttention flag", - ); - }); + // 5000 * 2^3 = 40000 + jitter + assert.ok(delay3 >= 40000 && delay3 < 41000, "Delay should be around 40000-41000ms for attempt 3"); + }); - QUnit.test("analyzeDocumentText treats thin repetitive drafts as low-signal", function (assert) { - const text = `Gennaro is bruno always always + QUnit.test("caps delay at maximum interval", function (assert) { + const delay = calculateBackoff(100, 5000); + assert.ok(delay <= 61000, "Delay should be capped at max interval (60000 + 1000 jitter)"); + }); + }); -Why not bannarpo?`; - const analysis = analyzeDocumentText(text); + QUnit.module("AuthError", () => { + QUnit.test("creates error with correct properties", function (assert) { + const error = new AuthError(AuthErrorType.NetworkError, "Test message"); + assert.strictEqual(error.type, AuthErrorType.NetworkError); + assert.strictEqual(error.message, "Test message"); + assert.strictEqual(error.name, "AuthError"); + }); - assert.ok(analysis.overallScore < 70, "thin repetitive drafts should not receive a flattering overall score"); - assert.ok( - analysis.sections.find((section) => section.title === "Readability")?.lines.some((line) => line.includes("Repeated word")), - "repeated words should produce an explicit finding", - ); - assert.ok( - analysis.sections.find((section) => section.title === "Structure")?.lines.some((line) => line.includes("Draft is too thin")), - "very short drafts should be flagged as too thin to evaluate well", - ); - assert.ok( - analysis.overview.strengths[0].includes("No clear strengths stand out yet"), - "fallback strengths copy should no longer pretend the draft has a factual backbone", - ); - assert.ok( - analysis.documentSections[0].needsAttention, - "thin lead sections should be marked as needing attention instead of balanced by default", - ); + QUnit.test("includes retryAfterMs when provided", function (assert) { + const error = new AuthError(AuthErrorType.ServerError, "Server error", 5000); + assert.strictEqual(error.retryAfterMs, 5000); + }); }); - QUnit.test("buildDocumentLinterReport produces a stable markdown snapshot", function (assert) { - const text = `Why did the first ports grow so quickly? - -## Signals -- Look for ships -- Look for storage jars -- Look for taxes`; - const analysis = analyzeDocumentText(text); - const report = buildDocumentLinterReport(text, analysis); + QUnit.module("createAuthManager", (hooks) => { + let mockStorage; + let mockSessionStorage; + let mockFetch; + let originalFetch; + let originalLocalStorage; + let originalSessionStorage; + let mockWindowLocation; - assert.strictEqual( - report, - `# Document Linter Review + hooks.beforeEach(function () { + mockStorage = new MockLocalStorage(); + mockSessionStorage = new MockSessionStorage(); + mockFetch = new MockFetch(); -## Overall -- Overall score: 95/100 + // Setup mock window.location + mockWindowLocation = { + href: "http://localhost:8000/", + pathname: "/", + hash: "", + }; -## Quick take -- The opening is understandable, but it could be shaped into a sharper lead sentence. -- The "Lead" section deserves attention: this section is too thin to evaluate well; add a clearer claim or supporting detail. -- There are real strengths here: concrete details and structure cues give the document memory and shape. + originalFetch = globalThis.fetch; + originalLocalStorage = global.localStorage; + originalSessionStorage = global.sessionStorage; -## What to fix first -- No major fixes stood out. + globalThis.fetch = function(url, options) { return mockFetch.fetch(url, options); }; + global.localStorage = mockStorage; + global.sessionStorage = mockSessionStorage; + }); -## What is working -- The opening question creates forward pull and gives the reader a reason to keep going. -- The lead uses clear directive language, which gives the document momentum. -- The parallel list structure creates a strong rhythm and makes the sequence easy to scan. + hooks.afterEach(function () { + globalThis.fetch = originalFetch; + global.localStorage = originalLocalStorage; + global.sessionStorage = originalSessionStorage; + mockFetch.clear(); + mockSessionStorage.clear(); + }); -## Section notes -### Readability -- No notable issues in this category. + QUnit.test("starts with unauthenticated state", function (assert) { + const authManager = createAuthManager(); + const state = authManager.getState(); -### Skimmability -- No notable issues in this category. + assert.strictEqual(state.isAuthenticated, false); + assert.strictEqual(state.isLoading, false); + assert.strictEqual(state.error, null); + }); -### Engagement -- No notable issues in this category. + QUnit.test("restoreSession returns false when no token stored", function (assert) { + const authManager = createAuthManager(); + const restored = authManager.restoreSession(); -### Style -- No notable issues in this category. + assert.strictEqual(restored, false); + assert.strictEqual(authManager.isAuthenticated(), false); + }); -### Structure -- No notable issues in this category. + QUnit.test("restoreSession returns true and authenticates when token exists", function (assert) { + const authManager = createAuthManager(); + mockStorage.setItem("ink_github_token", "test_token_123"); -## Section analysis -### Lead -- Type: implicit -- Lines: 1–1 -- Needs attention: yes -- This section is too thin to evaluate well; add a clearer claim or supporting detail. + const restored = authManager.restoreSession(); -### Signals -- Type: heading -- Lines: 3–6 -- Needs attention: no -- The heading works, but this section is list-only; a short lead sentence could help orient the reader. + assert.strictEqual(restored, true); + assert.strictEqual(authManager.isAuthenticated(), true); + assert.strictEqual(authManager.getToken(), "test_token_123"); + }); -## Snapshot -- Words: 19 -- Sentences: 1 -- Blocks: 5`, - "markdown export should remain stable for the tested document", - ); - }); + QUnit.test("logout clears token and resets state", function (assert) { + const authManager = createAuthManager(); + mockStorage.setItem("ink_github_token", "test_token_123"); + authManager.restoreSession(); - QUnit.test("empty, single-word, and heading-only documents remain analyzable", function (assert) { - const emptyAnalysis = analyzeDocumentText(""); - const singleWordAnalysis = analyzeDocumentText("Hello"); - const headingOnlyAnalysis = analyzeDocumentText("## Title"); + authManager.logout(); - assert.strictEqual(emptyAnalysis.documentSections[0].title, "Document", "empty documents should produce a fallback section"); - assert.strictEqual(singleWordAnalysis.overview.quickTake.length > 0, true, "single-word documents should still produce overview copy"); - assert.strictEqual(headingOnlyAnalysis.documentSections[0].title, "Title", "heading-only documents should preserve the heading section"); - }); + assert.strictEqual(authManager.isAuthenticated(), false); + assert.strictEqual(authManager.getToken(), null); + assert.strictEqual(mockStorage.getItem("ink_github_token"), null); + }); - QUnit.test("list items are not miscounted as prose sentences in the exported snapshot", function (assert) { - const text = `- First item. -- Second item. -- Third item.`; - const analysis = analyzeDocumentText(text); - const report = buildDocumentLinterReport(text, analysis); + QUnit.test("subscribe receives state change notifications", function (assert) { + const authManager = createAuthManager(); + let stateChanges = 0; + let lastState = null; - assert.strictEqual(countSentences(""), 0, "empty text should have zero sentences"); - assert.ok(report.includes("- Sentences: 0"), "list-only documents should not count list items as prose sentences"); - }); + const unsubscribe = authManager.subscribe({ + onStateChange: function(state) { + stateChanges++; + lastState = state; + }, + }); - QUnit.test("createDocumentLinterController renders results, updates status, and guards empty content", async function (assert) { - const els = createControllerDomRefs(); - const toasts = []; - const statuses = []; - const analysisUpdates = []; - let currentText = ""; + mockStorage.setItem("ink_github_token", "test_token"); + authManager.restoreSession(); - const controller = createDocumentLinterController({ - els, - getEditorText: () => currentText, - onEditorContentReplaced: () => {}, - showToast: (message, options) => { - toasts.push({ message, options }); - }, - setStatus: (message, kind) => { - statuses.push({ message, kind }); - }, - onAnalysisUpdated: (analysis, revision) => { - analysisUpdates.push({ analysis, revision }); - }, + assert.strictEqual(stateChanges, 1); + assert.strictEqual(lastState.isAuthenticated, true); + + authManager.logout(); + + assert.strictEqual(stateChanges, 2); + assert.strictEqual(lastState.isAuthenticated, false); + + unsubscribe(); + + authManager.restoreSession(); + assert.strictEqual(stateChanges, 2, "Should not receive events after unsubscribe"); }); - controller.setActive(true); + // Note: Tests involving window.location redirect and crypto mocking are skipped + // in this Node.js test environment. These would be tested in browser E2E tests. + }); - await controller.analyzeDocument(); + QUnit.module("token storage", (hooks) => { + let mockStorage; + let mockSessionStorage; + let originalLocalStorage; + let originalSessionStorage; + let mockWindowLocation; - assert.deepEqual( - toasts[0], - { message: "No content to analyze", options: { persist: true } }, - "empty content should surface a persistent toast", - ); - assert.deepEqual(statuses[0], { message: "No content to analyze", kind: "warn" }, "empty content should set warning status"); + hooks.beforeEach(function () { + mockStorage = new MockLocalStorage(); + mockSessionStorage = new MockSessionStorage(); + originalLocalStorage = global.localStorage; + originalSessionStorage = global.sessionStorage; + global.localStorage = mockStorage; + global.sessionStorage = mockSessionStorage; - currentText = `Remember this: -- Start with the treaty -- Start with the ships -- Start with the tax records`; - els.editor.value = currentText; + mockWindowLocation = { + href: "http://localhost:8000/", + pathname: "/", + hash: "", + }; + }); - await controller.analyzeDocument(); + hooks.afterEach(function () { + global.localStorage = originalLocalStorage; + global.sessionStorage = originalSessionStorage; + }); - assert.strictEqual(els.documentLinterAnalyzeBtn.disabled, false, "analyze button should be re-enabled after analysis"); - assert.strictEqual(els.documentLinterStatus.textContent, "Analysis complete", "status copy should update after analysis"); - assert.ok(els.documentLinterResults.children.length >= 3, "results panel should render summary, categories, and section analysis"); - assert.ok( - els.documentLinterResults.children[0].innerHTML.includes("Overall"), - "summary markup should include the overall score block", - ); - assert.deepEqual(statuses.at(-1), { message: "Analysis complete", kind: "ok" }, "successful analysis should report ok status"); - assert.strictEqual(controller.getLatestAnalysis().overallScore > 0, true, "latest analysis should be available to Cogito"); - assert.strictEqual(controller.getAnalysisRevision(), 1, "analysis revision should advance"); - assert.strictEqual(analysisUpdates.length, 1, "analysis updates should be published to the unified panel orchestrator"); + QUnit.test("logout removes token from storage", function (assert) { + const authManager = createAuthManager(); + mockStorage.setItem("ink_github_token", "test_token"); - els.documentLinterAutoRunToggle.checked = true; - els.documentLinterAutoRunToggle.dispatchEvent({ type: "change" }); - currentText = `${currentText}\n- Start with the market tolls`; - els.editor.value = currentText; - const firstScheduledAnalysis = controller.handleEditorChanged(currentText); - currentText = `${currentText}\n- Finish with the harbor records`; - els.editor.value = currentText; - const secondScheduledAnalysis = controller.handleEditorChanged(currentText); - await Promise.all([firstScheduledAnalysis, secondScheduledAnalysis]); + authManager.logout(); - assert.strictEqual(els.documentLinterStatus.textContent, "Analysis complete", "rerun on change should trigger a fresh analysis"); - assert.strictEqual(controller.getAnalysisRevision(), 2, "rapid edits should coalesce into one new analysis revision"); + assert.strictEqual(mockStorage.getItem("ink_github_token"), null); + }); - currentText = "## Summary\nUpdated text"; - els.editor.value = currentText; - await controller.exportSuggestions(); + QUnit.test("logout clears code_verifier from sessionStorage", function (assert) { + const authManager = createAuthManager(); + mockSessionStorage.setItem("ink_github_code_verifier", "test_verifier"); - assert.deepEqual(statuses.at(-2), { message: "Re-analyzing before export", kind: "warn" }, "export should explicitly announce re-analysis when content changed"); - assert.deepEqual(statuses.at(-1), { message: "Exported report", kind: "ok" }, "export should finish with ok status"); + authManager.logout(); + + assert.strictEqual(mockSessionStorage.getItem("ink_github_code_verifier"), null); + }); }); }); - + import QUnit from "qunit"; -import { applyEditorViewMode, loadEditorViewMode, setEditorViewMode } from "../../dist/test/editor-preview.js"; +import { createAutoRefresh } from "../../dist/test/auto-refresh.js"; + +function createState(overrides = {}) { + return { + workspaceHandle: { name: "vault" }, + autoRefreshMs: 60000, + autoRefreshTimer: null, + isDirty: false, + lastWorkspaceInteractionAt: Date.now() - 6000, + ...overrides, + }; +} + +function createHarness(stateOverrides = {}) { + const calls = { + ensurePermission: [], + rescanWorkspace: [], + statuses: [], + toasts: [], + }; + const state = createState(stateOverrides); + const controller = createAutoRefresh({ + state, + ensurePermission: async (handle, mode) => { + calls.ensurePermission.push({ handle, mode }); + return true; + }, + rescanWorkspace: async (options) => { + calls.rescanWorkspace.push(options); + return true; + }, + showToast: (message, options) => { + calls.toasts.push({ message, options }); + }, + setStatus: (message, kind) => { + calls.statuses.push({ message, kind }); + }, + }); + + return { calls, controller, state }; +} + +QUnit.module("auto-refresh"); + +QUnit.test("recent interaction prevents every background filesystem call", async (assert) => { + const { calls, controller } = createHarness({ + lastWorkspaceInteractionAt: Date.now(), + }); + + await controller.runAutoRefresh(); + + assert.strictEqual(calls.ensurePermission.length, 0, "permission should not be queried while the user is active"); + assert.strictEqual(calls.rescanWorkspace.length, 0, "workspace should not be rescanned while the user is active"); +}); + +QUnit.test("dirty documents prevent every background filesystem call", async (assert) => { + const { calls, controller } = createHarness({ isDirty: true }); + + await controller.runAutoRefresh(); + + assert.strictEqual(calls.ensurePermission.length, 0, "permission should not be queried while edits are unsaved"); + assert.strictEqual(calls.rescanWorkspace.length, 0, "workspace should not be rescanned while edits are unsaved"); +}); + +QUnit.test("idle workspace performs one silent rescan after permission check", async (assert) => { + const { calls, controller, state } = createHarness(); + + await controller.runAutoRefresh(); + + assert.deepEqual( + calls.ensurePermission, + [{ handle: state.workspaceHandle, mode: "read" }], + "idle refresh should check read permission once", + ); + assert.deepEqual( + calls.rescanWorkspace, + [{ silent: true }], + "idle refresh should request exactly one silent rescan", + ); +}); + +QUnit.test("missing workspace does not touch the filesystem", async (assert) => { + const { calls, controller } = createHarness({ workspaceHandle: null }); + + await controller.runAutoRefresh(); + + assert.strictEqual(calls.ensurePermission.length, 0); + assert.strictEqual(calls.rescanWorkspace.length, 0); +}); + +QUnit.test("revoked permission stops refresh and reports the problem", async (assert) => { + const calls = { rescans: 0, statuses: [], toasts: [] }; + const state = createState(); + const controller = createAutoRefresh({ + state, + ensurePermission: async () => false, + rescanWorkspace: async () => { + calls.rescans += 1; + return true; + }, + showToast: (message, options) => { + calls.toasts.push({ message, options }); + }, + setStatus: (message, kind) => { + calls.statuses.push({ message, kind }); + }, + }); + + await controller.runAutoRefresh(); + + assert.strictEqual(calls.rescans, 0, "revoked permission must prevent scanning"); + assert.deepEqual(calls.statuses.at(-1), { message: "Permission revoked", kind: "err" }); + assert.ok(calls.toasts.at(-1).message.includes("permission revoked")); +}); + + + +import QUnit from "qunit"; +import { applyEditorViewMode, loadEditorViewMode, setEditorViewMode } from "../../dist/test/editor-preview.js"; function createClassList() { const classes = new Set(); @@ -37714,25 +37432,6 @@ QUnit.test("leaves plain text unchanged", (assert) => { }); - -# Dependencies -node_modules/ - -# OS/editor noise -.DS_Store -*.log - -# Local tool state -.opencode/ - -# Cypress artifacts -cypress/screenshots/ -cypress/videos/ -cypress/downloads/ -coverage/ -.nyc_output/ - - # This file is a template, and might need editing before it works on your project. # This is a sample GitLab CI/CD configuration file that should run without any modifications. @@ -37837,199 +37536,104 @@ cypress: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - -{ - "extension": [".ts", ".js"], - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts", "src/test-support/**"], - "reporter": ["html", "text-summary", "json"], - "report-dir": "coverage" -} - - - - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. + +import { createRequire } from "node:module"; +import { defineConfig } from "cypress"; -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 +const require = createRequire(import.meta.url); -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines +export default defineConfig({ + video: false, + e2e: { + baseUrl: "http://127.0.0.1:4173", + specPattern: "cypress/e2e/**/*.cy.js", + setupNodeEvents(on, config) { + require("@cypress/code-coverage/task")(on, config); + return config; + }, + }, +}); + -Keep this managed block so 'openspec update' can refresh the instructions. + +import globals from "globals"; - +export default [ + { + ignores: ["dist/**", "node_modules/**"], + files: ["**/*.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + "no-unused-vars": "warn", + "no-undef": "warn", + "semi": ["error", "always"], + "quotes": ["error", "double"], + "indent": ["error", 2], + "comma-dangle": "off", + "no-trailing-spaces": "error", + "eol-last": ["error", "always"], + }, + }, + { + files: ["build/**/*.js"], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + { + files: ["tests/**/*.js"], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + QUnit: "readonly", + }, + }, + }, + { + files: ["cypress/**/*.js"], + languageOptions: { + globals: { + ...globals.browser, + cy: "readonly", + describe: "readonly", + it: "readonly", + before: "readonly", + after: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + expect: "readonly", + }, + }, + }, +]; + -## Agent Instructions + +github: feddernico +ko_fi: feddernico + -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. + +This file is a merged representation of the entire codebase, combined into a single document by Repomix. -Your goal: produce code that is predictable, debuggable, and easy for future LLMs to rewrite or extend. + +This section contains a summary of this file. -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"], - "plugins": ["babel-plugin-istanbul"] -} - - - -import globals from "globals"; - -export default [ - { - ignores: ["dist/**", "node_modules/**"], - files: ["**/*.js"], - languageOptions: { - ecmaVersion: 2022, - sourceType: "module", - globals: { - ...globals.browser, - ...globals.node, - }, - }, - rules: { - "no-unused-vars": "warn", - "no-undef": "warn", - "semi": ["error", "always"], - "quotes": ["error", "double"], - "indent": ["error", 2], - "comma-dangle": "off", - "no-trailing-spaces": "error", - "eol-last": ["error", "always"], - }, - }, - { - files: ["build/**/*.js"], - languageOptions: { - globals: { - ...globals.node, - }, - }, - }, - { - files: ["tests/**/*.js"], - languageOptions: { - globals: { - ...globals.browser, - ...globals.node, - QUnit: "readonly", - }, - }, - }, - { - files: ["cypress/**/*.js"], - languageOptions: { - globals: { - ...globals.browser, - cy: "readonly", - describe: "readonly", - it: "readonly", - before: "readonly", - after: "readonly", - beforeEach: "readonly", - afterEach: "readonly", - expect: "readonly", - }, - }, - }, -]; - - - -github: feddernico -ko_fi: feddernico - - - -This file is a merged representation of the entire codebase, combined into a single document by Repomix. - - -This section contains a summary of this file. - - -This file contains a packed representation of the entire repository's contents. -It is designed to be easily consumable by AI systems for analysis, code review, -or other automated processes. - + +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + The content is organized as follows: @@ -48758,7 +48362,7 @@ jobs: category: "/language:${{matrix.language}}" - + function createFakeFileHandle(name, initialContent = "") { let content = initialContent; let lastModified = Date.now(); @@ -48831,13 +48435,14 @@ function createFakeDirectoryHandle(name) { entries.set(dirName, handle); return handle; }, + __entries: entries, }; } -describe("document linter export", () => { - const workspaceName = "linter-workspace"; - const noteName = "linter-note"; - const noteContent = "# Linter export test\n\nThis sentence is intentionally very long so the readability rule has something to report."; +describe("cogito mode", () => { + const workspaceName = "workspace-cogito"; + const fileStem = "cogito-note"; + const markdown = "The project should prioritize local-first writing workflows."; beforeEach(() => { cy.visit("/ink-app.html", { @@ -48846,7 +48451,7 @@ describe("document linter export", () => { win.prompt = (message) => { if (message.includes("New note name")) { - return noteName; + return fileStem; } return null; }; @@ -48855,5044 +48460,5912 @@ describe("document linter export", () => { win.FileSystemHandle = function FileSystemHandle() {}; win.showDirectoryPicker = async () => root; win.__fakeWorkspace = root; - win.__linterExportBlobs = []; - win.URL.createObjectURL = (blob) => { - win.__linterExportBlobs.push(blob); - return "blob:linter-report"; + win.__cogitoCreateEngineCalls = []; + win.__cogitoCompletions = []; + win.__cogitoDeletedModels = []; + win.__INK_TEST_WEBLLM__ = { + prebuiltAppConfig: { model_list: [] }, + async deleteModelAllInfoInCache(modelId) { + win.__cogitoDeletedModels.push(modelId); + }, + async CreateMLCEngine(modelId, options = {}) { + win.__cogitoCreateEngineCalls.push(modelId); + options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); + + 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?", + ], + }), + }, + }, + ], + }; + }, + }, + }, + }; + }, }; - win.URL.revokeObjectURL = () => {}; }, }); }); - it("exports the current linter report as markdown", () => { + it("assesses the document, uses findings for coaching, and inserts one question", () => { cy.get("[data-action=\"open-workspace\"]").click({ force: true }); cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(noteContent); + cy.get("#editor").clear().type(markdown); + 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"); cy.get("#documentLinterToggleBtn").should("not.exist"); - cy.get("#cogitoToggleBtn").click(); + cy.get("#documentLinterPanel").should("not.exist"); + + cy.get("#editorViewSourceBtn").click(); + cy.get("#editorSplit").should("have.class", "view-source").and("have.class", "with-cogito"); cy.get("#cogitoPanel").should("be.visible"); - cy.get("#documentLinterAnalyzeBtn").click(); - cy.get("#statusBadge").should("contain", "Analysis complete"); + cy.get("#editorViewPreviewBtn").click(); + cy.get("#editorSplit").should("have.class", "view-preview").and("have.class", "with-cogito"); + cy.get("#cogitoPanel").should("be.visible"); + cy.get("#editorViewSplitBtn").click(); - cy.get("#documentLinterExportBtn").click(); - cy.get("#statusBadge").should("contain", "Exported report"); + cy.get("#documentLinterAnalyzeBtn").click(); + cy.get("#documentLinterStatus").should("contain", "Analysis complete"); + cy.get("#documentLinterResults").should("contain", "Overall"); - cy.window().then(async (win) => { - expect(win.__linterExportBlobs).to.have.length(1); - const report = await win.__linterExportBlobs[0].text(); - expect(report).to.contain("# Document Linter Review"); - expect(report).to.contain("## Overall"); - expect(report).to.contain("Overall score:"); - expect(report).to.contain("Readability"); - expect(report).to.contain("Opening is informative, not directive"); - }); - }); -}); - + cy.get("#cogitoDeepBtn").click().should("have.class", "active"); + cy.get("#cogitoLiteBtn").should("not.have.class", "active"); - -function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); + cy.get("#cogitoGenerateBtn").click(); - 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; - }, - }; -} + 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?"); -function createFakeDirectoryHandle(name) { - const entries = new Map(); + 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); + expect(win.__cogitoCompletions[0].messages[1].content).to.contain("Document strength:"); + expect(win.__cogitoCompletions[0].messages[1].content).to.contain("Highest-priority improvements:"); + }); - 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, - }; -} + 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`); + }); -describe("ink authoring flow", () => { - const workspaceName = "workspace-a"; - const fileStem = "notes"; - const fileName = `${fileStem}.md`; - const markdown = "# Ink flow\n\nThis is markdown content."; + it("marks existing questions stale when document analysis changes", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); + cy.get("#cogitoToggleBtn").click(); + cy.get("#documentLinterAnalyzeBtn").click(); + cy.get("#documentLinterStatus").should("contain", "Analysis complete"); + cy.get("#cogitoGenerateBtn").click(); + cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - const root = createFakeDirectoryHandle(workspaceName); + cy.get("#editor").type(" More evidence is needed."); + cy.get("#documentLinterAnalyzeBtn").click(); + cy.get("#cogitoStatus").should("contain", "Regenerate"); + cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); + }); - win.prompt = (message) => { - if (message.includes("New note name")) { - return fileStem; - } - return null; + it("repairs a failed cache and falls back from Deep to Lite", () => { + cy.window().then((win) => { + win.__INK_TEST_WEBLLM__.CreateMLCEngine = async (modelId, options = {}) => { + win.__cogitoCreateEngineCalls.push(modelId); + if (modelId === "Qwen3-8B-q4f16_1-MLC") { + throw new Error("Failed to execute 'add' on 'Cache': Cache.add() encountered a network error"); + } + options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); + return { + chat: { + completions: { + async create(payload) { + win.__cogitoCompletions.push(payload); + return { + choices: [{ + message: { + content: JSON.stringify({ + questions: ["Fallback one?", "Fallback two?", "Fallback three?"], + }), + }, + }], + }; + }, + }, + }, }; - - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - win.__fakeWorkspace = root; - }, + }; }); - }); - 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); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#currentFilename").should("contain", fileName); - cy.get("#editor").clear().type(markdown); - cy.get("[data-action=\"save\"]").click({ force: true }); - cy.get("#statusBadge").should("contain", "Saved"); + cy.get("#cogitoToggleBtn").click(); + cy.get("#cogitoDeepBtn").click(); + cy.get("#cogitoGenerateBtn").click(); - cy.window().then(async (win) => { - const handle = await win.__fakeWorkspace.getFileHandle(fileName); - expect(handle.__read()).to.eq(markdown); + cy.get("#statusBadge", { timeout: 10000 }).should("contain", "Cogito questions ready"); + cy.get("#cogitoLiteBtn").should("have.class", "active"); + cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); + cy.window().then((win) => { + expect(win.__cogitoDeletedModels).to.deep.equal(["Qwen3-8B-q4f16_1-MLC"]); + expect(win.__cogitoCreateEngineCalls).to.deep.equal([ + "Qwen3-8B-q4f16_1-MLC", + "Qwen3-8B-q4f16_1-MLC", + "Llama-3.2-1B-Instruct-q4f32_1-MLC", + ]); }); }); - it("renders markdown preview when editing a note", () => { + it("repairs the Lite model cache and retries Lite successfully", () => { + cy.window().then((win) => { + let liteAttempts = 0; + win.__INK_TEST_WEBLLM__.CreateMLCEngine = async (modelId, options = {}) => { + win.__cogitoCreateEngineCalls.push(modelId); + if (modelId === "Llama-3.2-1B-Instruct-q4f32_1-MLC") { + liteAttempts += 1; + if (liteAttempts === 1) { + throw new Error("Failed to execute 'add' on 'Cache': Cache.add() encountered a network error"); + } + } + options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); + return { + chat: { + completions: { + async create(payload) { + win.__cogitoCompletions.push(payload); + return { + choices: [{ + message: { + content: JSON.stringify({ + questions: ["Lite retry one?", "Lite retry two?", "Lite retry three?"], + }), + }, + }], + }; + }, + }, + }, + }; + }; + }); + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); + cy.get("#cogitoToggleBtn").click(); + cy.get("#cogitoLiteBtn").should("have.class", "active"); + cy.get("#cogitoGenerateBtn").click(); - cy.get("#editor").clear().type("# Hello World\n\nThis is a paragraph."); - - cy.get("#preview").find("h1").should("contain", "Hello World"); - cy.get("#preview").find("p").should("contain", "This is a paragraph."); + cy.get("#statusBadge", { timeout: 10000 }).should("contain", "Cogito questions ready"); + cy.get("#cogitoLiteBtn").should("have.class", "active"); + cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); + cy.window().then((win) => { + expect(win.__cogitoDeletedModels).to.deep.equal([ + "Llama-3.2-1B-Instruct-q4f32_1-MLC", + ]); + expect(win.__cogitoCreateEngineCalls).to.deep.equal([ + "Llama-3.2-1B-Instruct-q4f32_1-MLC", + "Llama-3.2-1B-Instruct-q4f32_1-MLC", + ]); + }); }); +}); + - it("collapses and expands the left menu, updating accessibility state and editor space", () => { - let expandedEditorWidth = 0; - let collapsedEditorWidth = 0; + +# Change: Fix Mobile Textarea Zoom Issue - cy.get("#sidebarToggleBtn") - .should("have.attr", "aria-expanded", "true") - .and("contain", "▶ Collapse") - .and("be.visible"); - cy.get(".app").should("not.have.class", "sidebar-collapsed"); +## Why +On mobile devices, tapping the textarea causes unwanted zoom because the font-size (14px) is below the 16px threshold that triggers automatic zoom on iOS Safari and other mobile browsers. This breaks the user experience and makes editing difficult. - cy.get("#editor").then(($editor) => { - expandedEditorWidth = $editor[0].getBoundingClientRect().width; - }); +## What Changes +- Increase textarea font-size to 16px on mobile devices +- Add media query targeting mobile viewports to apply appropriate font-size +- Maintain existing 14px font-size on desktop where zoom is not an issue - cy.get("#sidebarToggleBtn").click(); - cy.get(".app").should("have.class", "sidebar-collapsed"); - cy.get("#sidebarToggleBtn") - .should("have.attr", "aria-expanded", "false") - .and("contain", "▼ Expand"); +## Impact +- Affected specs: mobile-support +- Affected code: src/styles.scss +- Breaking changes: none (style-only improvement) - cy.get("#editor").then(($editor) => { - collapsedEditorWidth = $editor[0].getBoundingClientRect().width; - expect(collapsedEditorWidth).to.be.greaterThan(expandedEditorWidth); - }); - - 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"); - - cy.get("#editor").then(($editor) => { - const finalEditorWidth = $editor[0].getBoundingClientRect().width; - expect(finalEditorWidth).to.be.lessThan(collapsedEditorWidth); - }); - }); -}); +. - -describe("menu bar functionality", () => { - const workspaceName = "test-workspace"; + +# Design: Replace Emojis with SVG Icons - function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); +## Context - 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; - }, - }; - } +Ink is a single-file, offline-first markdown editor. Its build system inlines all CSS and JavaScript into one HTML file with no external runtime dependencies. Any icon solution must therefore be embeddable at build time — no CDN fonts, no external sprite sheets. - function createFakeDirectoryHandle(name) { - const entries = new Map(); +The application currently uses four distinct emoji icons in the UI: - 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, - }; - } +| Location | Emoji | Semantic meaning | +|---|---|---| +| `tree-render.ts` | 📁 | Collapsed folder in file tree | +| `tree-render.ts` | 📂 | Expanded folder in file tree | +| `tree-render.ts` | 📝 | Markdown file/note in file tree | +| `ink.template.html` | 🗂️ | "Open Workspace" button in sidebar | - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - const root = createFakeDirectoryHandle(workspaceName); +Additionally, the `✓` character is used in status bar messages and toast notifications in `workspace-io.ts` to signal success. - win.prompt = (message) => { - if (message.includes("New note name")) { - return "test-note"; - } - if (message.includes("Folder name")) { - return "test-folder"; - } - return null; - }; +## Goals / Non-Goals - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - win.__fakeWorkspace = root; - }, - }); - }); +### Goals +- Consistent, crisp icon rendering across all operating systems and browsers +- Full CSS controllability (color, size, opacity, transitions) +- Zero additional runtime dependencies — icons embedded inline at build time +- Accessible: icons are decorative where text labels exist; `aria-hidden="true"` applied appropriately +- Minimal diff — change only what renders icons, leave surrounding logic untouched - describe("menu bar structure", () => { - it("contains File, Edit, and View menus", () => { - cy.get("#menuBar").should("exist"); - cy.get(".menu-text").should("contain", "File"); - cy.get(".menu-text").should("contain", "Edit"); - cy.get(".menu-text").should("contain", "View"); - }); +### Non-Goals +- Introduce an icon font (conflicts with offline/single-file constraint) +- Replace every possible decorative character (scope is limited to UI icons listed above) +- Change keyboard shortcuts, layout, or menu structure +- Add animations to icons in this change - it("does not contain Import/Export menu", () => { - cy.get("#menuBar").should("not.contain", "Import/Export"); - }); +## Decisions - it("contains Export submenu under File menu", () => { - cy.get(".menu-text").contains("File").click(); - cy.get(".submenu-parent").should("contain", "Export"); - cy.get(".submenu-parent .dropdown.submenu").should("contain", "Export JSON"); - cy.get(".submenu-parent .dropdown.submenu").should("contain", "Export Markdown"); - }); - }); +### Icon Library Selection +**Decision**: Use inline SVG strings sourced from [Lucide Icons](https://lucide.dev) (MIT licence). - describe("File menu regression tests", () => { - beforeEach(() => { - cy.get(".menu-text").contains("File").click(); - }); +**Rationale**: Lucide provides a consistent, minimal, single-weight line-icon style that matches the clean aesthetic of Ink. Icons are plain SVG paths with no external dependencies, trivially inlineable as template literals in TypeScript. The MIT licence is compatible with the project. - it("New Note menu item exists and triggers createNewNote", () => { - cy.get("[data-action=\"new-note\"]").should("exist"); - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#currentFilename").should("contain", ".md"); - }); +**Mapping**: - it("New Folder menu item exists", () => { - cy.get("[data-action=\"new-folder\"]").should("exist"); - }); +| Emoji | Lucide icon name | SVG element | Rationale | +|---|---|---|---| +| 📁 | `folder` | `` with folder closed path | Standard closed folder | +| 📂 | `folder-open` | `` with folder open path | Standard open folder | +| 📝 | `file-text` | `` with lined document path | Markdown file / note | +| 🗂️ | `library` | `` with stacked books path | A workspace is a *collection* of folders and notes, not a single folder; `library` communicates organised multi-item structure | +| ✓ | `check` | Small inline check SVG, or replaced by a CSS `::before` pseudo-element on `.status-ok` | Success indicator | - it("Open Workspace menu item exists", () => { - cy.get("[data-action=\"open-workspace\"]").should("exist"); - }); +**Alternatives considered**: +- Heroicons: Similar quality but slightly heavier paths; rejected in favour of Lucide's tighter output +- Phosphor Icons: Excellent but larger library; unnecessary for five icons +- Custom hand-drawn SVGs: Maintainability risk; rejected +- Icon font (e.g. Bootstrap Icons via CDN): Violates offline/single-file constraint; rejected - it("Close Workspace menu item exists", () => { - cy.get("[data-action=\"close-workspace\"]").should("exist"); - }); +### Icon Registry Pattern +**Decision**: Create a small helper module `src/app/icons.ts` that exports named functions returning SVG strings. - it("Exit menu item exists", () => { - cy.get("[data-action=\"exit\"]").should("exist"); - }); - }); +```ts +// src/app/icons.ts +export const icon = { + folder: () => ``, - describe("Edit menu regression tests", () => { - beforeEach(() => { - cy.get(".menu-text").contains("Edit").click(); - }); + folderOpen: () => ``, + fileText: () => ``, + check: () => ``, +}; +``` - it("Save menu item exists", () => { - cy.get("[data-action=\"save\"]").should("exist"); - }); +**Rationale**: A single source-of-truth for icon markup prevents duplication. The functions can later accept size or class overrides if needed. - it("Save As menu item exists", () => { - cy.get("[data-action=\"save-as\"]").should("exist"); - }); +**Alternatives considered**: +- Inline SVG strings at each call site: Duplicates markup and is hard to update; rejected +- Importing SVG files via build plugin: Requires changes to the custom build script; rejected for complexity - it("Refresh menu item exists", () => { - cy.get("[data-action=\"refresh\"]").should("exist"); - }); +### Checkmark Replacement Strategy +**Decision**: Replace the `✓` character in status/toast strings with a small inline SVG icon prepended to the message text. The icon carries `aria-hidden="true"`; the surrounding text already provides the semantic meaning. - it("Sort menu item exists", () => { - cy.get("[data-action=\"sort\"]").should("exist"); - }); - }); +**Rationale**: This preserves accessibility (screen readers read the text, not the icon) while giving visual users a crisp icon instead of an emoji. - describe("View menu regression tests", () => { - beforeEach(() => { - cy.get(".menu-text").contains("View").click(); - }); +**Alternatives considered**: +- CSS `::before` pseudo-element on `.status-ok` class: Cleaner separation but requires class to be set reliably on the container; could be adopted in a follow-up refactor +- Keep `✓` as plain Unicode: Renders inconsistently; defeats the purpose of this change - it("theme menu items exist", () => { - cy.get("[data-action=\"theme-default\"]").should("exist"); - cy.get("[data-action=\"theme-classic\"]").should("exist"); - cy.get("[data-action=\"theme-cobalt\"]").should("exist"); - cy.get("[data-action=\"theme-monokai\"]").should("exist"); - cy.get("[data-action=\"theme-office\"]").should("exist"); - cy.get("[data-action=\"theme-twilight\"]").should("exist"); - cy.get("[data-action=\"theme-xcode\"]").should("exist"); - }); - }); +### SCSS Styling +**Decision**: Add a `.icon` utility class in `styles.scss` that normalises SVG icon display. - describe("Export submenu functionality", () => { - beforeEach(() => { - cy.get(".menu-text").contains("File").click(); - cy.get(".submenu-parent").contains("Export").click(); - }); +```scss +.icon { + display: inline-block; + vertical-align: middle; + flex-shrink: 0; + color: inherit; // inherits text colour for easy theming + pointer-events: none; // icons should not intercept clicks +} +``` - it("Export JSON menu item exists in submenu", () => { - cy.get("[data-action=\"export-json\"]").should("exist"); - }); +**Rationale**: A single class handles alignment across all icon placements without per-site overrides. - it("Export Markdown menu item exists in submenu", () => { - cy.get("[data-action=\"export-markdown\"]").should("exist"); - }); - }); +## Risks / Trade-offs - describe("platform-aware shortcut detection", () => { - it("displays Cmd on Mac platform", () => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - Object.defineProperty(win.navigator, "platform", { - value: "MacIntel", - writable: true, - }); - const root = createFakeDirectoryHandle(workspaceName); - win.prompt = () => null; - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - }, - }); - cy.get(".menu-text").contains("File").click(); - cy.get("[data-action=\"new-note\"]").within(() => { - cy.get(".menu-shortcut").should("contain", "Cmd"); - }); - }); +### Risk: SVG strings in template literals may be escaped by the build assembler +**Mitigation**: Audit `build/compile-and-assemble.js` to confirm SVG strings in JavaScript template literals pass through without HTML-entity encoding. Test the built `ink.html` output in a browser before marking complete. - it("displays Ctrl on Windows platform", () => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - Object.defineProperty(win.navigator, "platform", { - value: "Win32", - writable: true, - }); - const root = createFakeDirectoryHandle(workspaceName); - win.prompt = () => null; - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - }, - }); - cy.get(".menu-text").contains("File").click(); - cy.get("[data-action=\"new-note\"]").within(() => { - cy.get(".menu-shortcut").should("contain", "Ctrl"); - }); - }); +### Risk: Icon sizing mismatch with existing layout +**Mitigation**: Use `width="16" height="16"` with `vertical-align: middle` as a baseline; adjust per context in SCSS if needed. Review visually in the sidebar, file tree, and status bar. - it("displays Ctrl on Linux platform", () => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - Object.defineProperty(win.navigator, "platform", { - value: "Linux x86_64", - writable: true, - }); - const root = createFakeDirectoryHandle(workspaceName); - win.prompt = () => null; - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - }, - }); - cy.get(".menu-text").contains("File").click(); - cy.get("[data-action=\"new-note\"]").within(() => { - cy.get(".menu-shortcut").should("contain", "Ctrl"); - }); - }); - }); -}); - +### Risk: Colour contrast regression +**Mitigation**: Using `stroke="currentColor"` means icons inherit the surrounding text colour automatically, preserving existing contrast ratios. - -function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); +## Migration Plan - 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; - }, - }; -} +### Phase 1: Foundation +1. Create `src/app/icons.ts` with SVG strings for all required icons. +2. Add `.icon` CSS class to `styles.scss`. -function createFakeDirectoryHandle(name) { - const entries = new Map(); +### Phase 2: Tree Icons +1. Update `tree-render.ts` to import and use `icon.folder`, `icon.folderOpen`, and `icon.fileText`. +2. Build and visually verify the file tree. - 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, - }; -} +### Phase 3: Template Icon +1. Replace the 🗂️ emoji in `ink.template.html` with the inline Lucide `library` SVG — chosen over `folder-open` to distinguish "workspace" (a collection of folders and notes) from a single folder. -function dispatchShortcut(win, { key, ctrlKey = false, shiftKey = false, altKey = false }) { - const event = new win.KeyboardEvent("keydown", { - key, - ctrlKey, - shiftKey, - altKey, - bubbles: true, - }); - win.dispatchEvent(event); -} +### Phase 4: Status / Toast Checkmark +1. Update `workspace-io.ts` status and toast strings to use `icon.check()` prepended to message text. +2. Verify toast and status bar rendering. -describe("workspace actions regression", () => { - const workspaceName = "workspace-actions"; - const noteName = "note-a"; - const saveAsName = "note-b"; - const markdown = "# Ink regression\n\nSaved content."; +### Phase 5: Build Verification +1. Run the full build (`node build/compile-and-assemble.js`). +2. Open `ink.html` in a browser and inspect all icon placements. +3. Confirm single-file output integrity. - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - Object.defineProperty(win.navigator, "userAgent", { - value: "Windows NT 10.0", - configurable: true, - }); +### Rollback Plan +All changes are isolated to icon strings and SCSS. To rollback: +1. Revert emoji strings in `tree-render.ts`, `ink.template.html`, and `workspace-io.ts`. +2. Remove `icons.ts` and the `.icon` SCSS rule. +No data or logic is affected. - const root = createFakeDirectoryHandle(workspaceName); +## Open Questions - win.prompt = (message) => { - if (message.includes("New note name")) { - return noteName; - } - if (message.includes("Save note as")) { - return saveAsName; - } - if (message.includes("Folder name")) { - return "folder-a"; - } - return null; - }; +1. **Should the check icon in toasts also appear in the "Saved ✓" status bar text, or only in toasts?** — Recommend applying consistently to both for visual cohesion. +2. **Should icon size vary between the file tree (16px) and the sidebar button (20px)?** — The `icon()` functions could accept an optional `size` parameter to handle this; defer to implementation. +3. **Should this change also address future icon needs (e.g. sort, collapse, refresh actions)?** — Out of scope for this change; a follow-up "icon system" change can extend the registry. + - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - win.__fakeWorkspace = root; - }, - }); - }); + +# Tasks: Replace Emojis with SVG Icons - it("save as creates a new file with the saved content", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("#workspaceName").should("contain", workspaceName); +## 1. Foundation - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - cy.get("[data-action=\"save\"]").click({ force: true }); - cy.get("#statusBadge").should("contain", "Saved"); +### 1.1 Icon Registry +- [ ] 1.1.1 Create `src/app/icons.ts` exporting an `icon` object with named SVG string functions +- [ ] 1.1.2 Add `folder` icon (Lucide `folder` — closed folder path) +- [ ] 1.1.3 Add `folderOpen` icon (Lucide `folder-open` — open folder path) +- [ ] 1.1.4 Add `fileText` icon (Lucide `file-text` — lined document path) +- [ ] 1.1.5 Add `library` icon (Lucide `library` — stacked books path; used for "Open Workspace" to convey a collection of folders and notes) +- [ ] 1.1.6 Add `check` icon (Lucide `check` — simple checkmark path) +- [ ] 1.1.7 All SVG elements include `aria-hidden="true"`, `stroke="currentColor"`, `class="icon"`, and `width`/`height` attributes - cy.get("[data-action=\"save-as\"]").click({ force: true }); - cy.get("#statusBadge").should("contain", "Saved as"); - cy.get("#currentFilename").should("contain", `${saveAsName}.md`); +### 1.2 SCSS Utility +- [ ] 1.2.1 Add `.icon` utility class to `src/styles.scss` (`display: inline-block`, `vertical-align: middle`, `flex-shrink: 0`, `pointer-events: none`, `color: inherit`) - cy.window().then(async (win) => { - const handle = await win.__fakeWorkspace.getFileHandle(`${saveAsName}.md`); - expect(handle.__read()).to.eq(markdown); - }); - }); +## 2. File Tree Icons (`src/app/tree-render.ts`) - it("refresh button rescans workspace and updates status", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("#workspaceName").should("contain", workspaceName); - - cy.get("body").click("topLeft"); - cy.get("body").click("bottomRight"); - cy.get("#refreshBtn").click({ force: true }); - cy.get("#statusBadge").should("contain", "Refreshed"); - }); - - it("opens the folder picker immediately and ignores overlapping open requests", () => { - cy.window().then((win) => { - win.__pickerCalls = 0; - win.showDirectoryPicker = () => { - win.__pickerCalls += 1; - return new Promise((resolve) => { - win.__resolveWorkspacePicker = () => resolve(win.__fakeWorkspace); - }); - }; - }); - - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("#statusBadge").should("contain", "Choose a workspace folder"); - cy.window().its("__pickerCalls").should("eq", 1); +- [ ] 2.1 Import `icon` from `./icons` +- [ ] 2.2 Replace `"📁"` with `icon.folder()` (collapsed folder state) +- [ ] 2.3 Replace `"📂"` with `icon.folderOpen()` (expanded folder state) +- [ ] 2.4 Replace both instances of `"📝"` with `icon.fileText()` +- [ ] 2.5 Build and visually verify file tree rendering — icons align with folder/file names - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.window().its("__pickerCalls").should("eq", 1); - cy.get("#statusBadge").should("contain", "Folder picker already open"); +## 3. Sidebar Template Icon (`ink.template.html`) - cy.window().then((win) => { - win.__resolveWorkspacePicker(); - }); - cy.get("#statusBadge").should("contain", "Workspace ready"); - }); +- [ ] 3.1 Replace the `🗂️` emoji inside the Open Workspace `` with the inline Lucide `library` SVG markup (conveys a collection of folders and notes, distinct from the file-tree folder icons) +- [ ] 3.2 Verify the button layout and hover state are unaffected - it("loads workspace files concurrently and reads each file once", () => { - cy.window().then((win) => { - const tracker = { active: 0, maximum: 0, calls: 0 }; - win.__workspaceReadTracker = tracker; +## 4. Status & Toast Checkmark (`src/app/workspace-io.ts`) - for (let index = 0; index < 32; index += 1) { - const name = `note-${index}.md`; - win.__fakeWorkspace.__entries.set(name, { - kind: "file", - name, - async getFile() { - tracker.calls += 1; - tracker.active += 1; - tracker.maximum = Math.max(tracker.maximum, tracker.active); - await new Promise((resolve) => win.setTimeout(resolve, 20)); - tracker.active -= 1; - return new File([`---\ntags: [batch]\n---\n# Note ${index}`], name, { - type: "text/markdown", - lastModified: index, - }); - }, - }); - } - }); +- [ ] 4.1 Replace `"Opened ✓"` → `icon.check() + " Opened"` (or equivalent pattern) +- [ ] 4.2 Replace `"Saved ✓"` → `icon.check() + " Saved"` (two occurrences) +- [ ] 4.3 Replace `"New note created ✓"` → `icon.check() + " New note created"` +- [ ] 4.4 Replace `"Folder created ✓"` → `icon.check() + " Folder created"` +- [ ] 4.5 Replace `"Saved as ${fileName} ✓"` → `` `${icon.check()} Saved as ${fileName}` `` +- [ ] 4.6 Confirm the `icon` import is available in `workspace-io.ts` (add import if needed) +- [ ] 4.7 Verify toast and status bar messages render the check icon correctly in a live browser test - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("#statusBadge").should("contain", "Workspace ready"); - cy.get("#countsPill").should("contain", "32 notes"); - cy.window().then((win) => { - expect(win.__workspaceReadTracker.calls).to.eq(32); - expect(win.__workspaceReadTracker.maximum).to.be.greaterThan(1); - expect(win.__workspaceReadTracker.maximum).to.be.at.most(4); - }); - }); +## 5. Build Verification - it("prioritizes opening a note over an in-progress background scan", () => { - cy.window().then((win) => { - win.__fakeWorkspace.__entries.set( - "priority.md", - createFakeFileHandle("priority.md", "# Priority\n\nOpen me immediately."), - ); - }); +- [ ] 5.1 Run full build (`node build/compile-and-assemble.js`) — build completes without errors +- [ ] 5.2 Open `ink.html` in a browser — all four icon types render as SVG (not emoji, not broken markup) +- [ ] 5.3 Confirm file tree icons (folder closed, folder open, file) display at the correct size and colour +- [ ] 5.4 Confirm sidebar Open Workspace button icon displays correctly +- [ ] 5.5 Confirm toast and status bar check icons display correctly +- [ ] 5.6 Inspect built HTML source — confirm no unintended HTML entity encoding of SVG content +- [ ] 5.7 Verify no layout regressions in sidebar, file tree, or status bar on a standard viewport + - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("#workspaceName").should("contain", workspaceName); - cy.get("#tree .node").contains("priority.md").should("be.visible"); + +import type { AppState } from "./types"; - cy.window().then((win) => { - const tracker = { calls: 0 }; - win.__slowRefreshTracker = tracker; - for (let index = 0; index < 40; index += 1) { - const name = `slow-${index}.md`; - win.__fakeWorkspace.__entries.set(name, { - kind: "file", - name, - async getFile() { - tracker.calls += 1; - await new Promise((resolve) => win.setTimeout(resolve, 250)); - return new File([`# Slow ${index}`], name, { type: "text/markdown" }); - }, - }); - } - }); +type ToastFn = (message: string, options?: { persist?: boolean }) => void; +type StatusFn = (message: string | null, kind?: "neutral" | "ok" | "warn" | "err") => void; - cy.get("#refreshBtn").click({ force: true }); - cy.get("#tree .node").contains("priority.md").click(); - cy.get("#currentFilename").should("contain", "priority.md"); - cy.get("#editor").should("contain.value", "Open me immediately"); +type EnsurePermission = (handle: NonNullable, mode: "read" | "readwrite") => Promise; +const MINIMUM_IDLE_MS = 5000; - cy.wait(350); - cy.window().then((win) => { - expect(win.__slowRefreshTracker.calls).to.be.at.most(4); - }); - }); +type RescanWorkspace = (options?: { + silent?: boolean; + throwOnError?: boolean; + showProgress?: boolean; +}) => Promise; - it("close workspace resets UI state", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); +export function createAutoRefresh({ + state, + ensurePermission, + rescanWorkspace, + showToast, + setStatus, +}: { + state: AppState; + ensurePermission: EnsurePermission; + rescanWorkspace: RescanWorkspace; + showToast: ToastFn; + setStatus: StatusFn; +}) { + function startAutoRefresh(): void { + stopAutoRefresh(); + state.autoRefreshTimer = setInterval(() => { + runAutoRefresh().catch((error: unknown) => { + showToast(`Auto-refresh failed: ${String(error)}`, { persist: true }); + setStatus("Auto-refresh failed", "err"); + }); + }, state.autoRefreshMs); + } - cy.get("[data-action=\"close-workspace\"]").click({ force: true }); - cy.get("#workspaceName").should("contain", "No folder selected"); - cy.get("#tree").should("contain", "Open a folder to begin."); - cy.get("#currentFilename").should("contain", "No note open"); - cy.get("#statusBadge").should("contain", "Ready"); - }); + async function runAutoRefresh(): Promise { + if (!state.workspaceHandle) { + return; + } - it("sort toggle updates labels", () => { - cy.get("#sortBtn").should("contain", "Sort: Name"); - cy.get("[data-action=\"sort\"] .menu-label").should("contain", "Sort: Name"); + if (state.isDirty) { + return; + } - cy.get("#sortBtn").click(); - cy.get("#sortBtn").should("contain", "Sort: Last modified"); - cy.get("[data-action=\"sort\"] .menu-label").should("contain", "Sort: Modified"); - }); + if (Date.now() - state.lastWorkspaceInteractionAt < MINIMUM_IDLE_MS) { + return; + } - it("keyboard shortcuts trigger key workspace actions", () => { - cy.window().then((win) => { - dispatchShortcut(win, { key: "o", ctrlKey: true, shiftKey: true }); - }); - cy.get("#workspaceName").should("contain", workspaceName); + try { + const permissionGranted = await ensurePermission(state.workspaceHandle, "read"); + if (!permissionGranted) { + throw new Error("Folder permission revoked."); + } + } catch { + stopAutoRefresh(); + showToast("Auto-refresh stopped: folder permission revoked.", { persist: true }); + setStatus("Permission revoked", "err"); + return; + } - cy.window().then((win) => { - dispatchShortcut(win, { key: "e", ctrlKey: true }); - }); - cy.get("#currentFilename").should("contain", `${noteName}.md`); + await rescanWorkspace({ silent: true }); + } - cy.get("#editor").clear().type(markdown); - cy.window().then((win) => { - dispatchShortcut(win, { key: "s", ctrlKey: true }); - }); - cy.get("#statusBadge").should("contain", "Saved"); + function stopAutoRefresh(): void { + if (state.autoRefreshTimer) { + clearInterval(state.autoRefreshTimer); + state.autoRefreshTimer = null; + } + } - cy.window().then((win) => { - dispatchShortcut(win, { key: "l", ctrlKey: true }); - }); - cy.get("#statusBadge").should("contain", "Refreshed"); - }); -}); + return { + startAutoRefresh, + stopAutoRefresh, + runAutoRefresh, + }; +} - -# Change: Fix Mobile Textarea Zoom Issue + +import { marked } from "marked"; +import { escapeHtml } from "./utils"; +import type { AppState, DomRefs } from "./types"; +import type { StatusKind } from "./toast-status"; -## Why -On mobile devices, tapping the textarea causes unwanted zoom because the font-size (14px) is below the 16px threshold that triggers automatic zoom on iOS Safari and other mobile browsers. This breaks the user experience and makes editing difficult. +export type EditorViewMode = "split" | "source" | "preview"; -## What Changes -- Increase textarea font-size to 16px on mobile devices -- Add media query targeting mobile viewports to apply appropriate font-size -- Maintain existing 14px font-size on desktop where zoom is not an issue +export const EDITOR_VIEW_MODE_STORAGE_KEY = "ink-editor-view-mode"; +export const VALID_EDITOR_VIEW_MODES: EditorViewMode[] = ["split", "source", "preview"]; -## Impact -- Affected specs: mobile-support -- Affected code: src/styles.scss -- Breaking changes: none (style-only improvement) +function normalizeEditorViewMode(value: string | null): EditorViewMode { + if (value === "source" || value === "preview") { + return value; + } + return "split"; +} -. - - - -# Design: Replace Emojis with SVG Icons - -## Context - -Ink is a single-file, offline-first markdown editor. Its build system inlines all CSS and JavaScript into one HTML file with no external runtime dependencies. Any icon solution must therefore be embeddable at build time — no CDN fonts, no external sprite sheets. - -The application currently uses four distinct emoji icons in the UI: - -| Location | Emoji | Semantic meaning | -|---|---|---| -| `tree-render.ts` | 📁 | Collapsed folder in file tree | -| `tree-render.ts` | 📂 | Expanded folder in file tree | -| `tree-render.ts` | 📝 | Markdown file/note in file tree | -| `ink.template.html` | 🗂️ | "Open Workspace" button in sidebar | +export function loadEditorViewMode(): EditorViewMode { + try { + return normalizeEditorViewMode(localStorage.getItem(EDITOR_VIEW_MODE_STORAGE_KEY)); + } catch { + return "split"; + } +} -Additionally, the `✓` character is used in status bar messages and toast notifications in `workspace-io.ts` to signal success. +export function applyEditorViewMode(els: DomRefs, mode: EditorViewMode): void { + els.editorSplit.classList.toggle("view-split", mode === "split"); + els.editorSplit.classList.toggle("view-source", mode === "source"); + els.editorSplit.classList.toggle("view-preview", mode === "preview"); -## Goals / Non-Goals + els.editorPane.hidden = mode === "preview"; + els.previewPane.hidden = mode === "source"; -### Goals -- Consistent, crisp icon rendering across all operating systems and browsers -- Full CSS controllability (color, size, opacity, transitions) -- Zero additional runtime dependencies — icons embedded inline at build time -- Accessible: icons are decorative where text labels exist; `aria-hidden="true"` applied appropriately -- Minimal diff — change only what renders icons, leave surrounding logic untouched + const buttons: Array<[HTMLButtonElement, EditorViewMode]> = [ + [els.editorViewSourceBtn, "source"], + [els.editorViewSplitBtn, "split"], + [els.editorViewPreviewBtn, "preview"], + ]; -### Non-Goals -- Introduce an icon font (conflicts with offline/single-file constraint) -- Replace every possible decorative character (scope is limited to UI icons listed above) -- Change keyboard shortcuts, layout, or menu structure -- Add animations to icons in this change + buttons.forEach(([button, buttonMode]) => { + const isActive = buttonMode === mode; + button.classList.toggle("active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); +} -## Decisions +export function setEditorViewMode(els: DomRefs, state: AppState, mode: EditorViewMode): void { + if (!VALID_EDITOR_VIEW_MODES.includes(mode)) { + return; + } -### Icon Library Selection -**Decision**: Use inline SVG strings sourced from [Lucide Icons](https://lucide.dev) (MIT licence). + state.editorViewMode = mode; + applyEditorViewMode(els, mode); -**Rationale**: Lucide provides a consistent, minimal, single-weight line-icon style that matches the clean aesthetic of Ink. Icons are plain SVG paths with no external dependencies, trivially inlineable as template literals in TypeScript. The MIT licence is compatible with the project. + try { + localStorage.setItem(EDITOR_VIEW_MODE_STORAGE_KEY, mode); + } catch { + // ignore localStorage errors + } +} -**Mapping**: +export function renderPreview(els: DomRefs, text: string): void { + try { + els.preview.innerHTML = marked.parse(text || "") as string; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + els.preview.innerHTML = `
${escapeHtml(message)}
`; + } +} -| Emoji | Lucide icon name | SVG element | Rationale | -|---|---|---|---| -| 📁 | `folder` | `` with folder closed path | Standard closed folder | -| 📂 | `folder-open` | `` with folder open path | Standard open folder | -| 📝 | `file-text` | `` with lined document path | Markdown file / note | -| 🗂️ | `library` | `` with stacked books path | A workspace is a *collection* of folders and notes, not a single folder; `library` communicates organised multi-item structure | -| ✓ | `check` | Small inline check SVG, or replaced by a CSS `::before` pseudo-element on `.status-ok` | Success indicator | +export function updateDirtyUi( + els: DomRefs, + state: AppState, + setStatus: (message: string | null, kind?: StatusKind) => void, +): void { + els.dirtyDot.classList.toggle("show", state.isDirty); -**Alternatives considered**: -- Heroicons: Similar quality but slightly heavier paths; rejected in favour of Lucide's tighter output -- Phosphor Icons: Excellent but larger library; unnecessary for five icons -- Custom hand-drawn SVGs: Maintainability risk; rejected -- Icon font (e.g. Bootstrap Icons via CDN): Violates offline/single-file constraint; rejected + const openFileName = state.currentRelPath + ? state.currentRelPath.split("/").pop() + : "No note open"; + els.currentFilename.textContent = `${openFileName}${state.isDirty ? " • Unsaved" : ""}`; -### Icon Registry Pattern -**Decision**: Create a small helper module `src/app/icons.ts` that exports named functions returning SVG strings. + if (state.isDirty) { + setStatus("Unsaved changes", "warn"); + } +} +
-```ts -// src/app/icons.ts -export const icon = { - folder: () => ``, + +import type { AppState, DomRefs } from "./types"; +import { applyTheme, VALID_THEMES } from "./theme"; - folderOpen: () => ``, - fileText: () => ``, - check: () => ``, +type ToastFn = (message: string, options?: { persist?: boolean }) => void; +type MenuCallbacks = { + createNewNote: () => Promise; + createNewFolder: () => Promise; + openWorkspace: () => Promise; + closeWorkspace: () => void; + saveCurrentNote: () => Promise; + saveAsNewNote: () => Promise; + handleRefresh: () => void; + exportAsJson: () => void; + exportAsMarkdown: () => void; + setSidebarCollapsed: (isCollapsed: boolean) => void; }; -``` - -**Rationale**: A single source-of-truth for icon markup prevents duplication. The functions can later accept size or class overrides if needed. - -**Alternatives considered**: -- Inline SVG strings at each call site: Duplicates markup and is hard to update; rejected -- Importing SVG files via build plugin: Requires changes to the custom build script; rejected for complexity - -### Checkmark Replacement Strategy -**Decision**: Replace the `✓` character in status/toast strings with a small inline SVG icon prepended to the message text. The icon carries `aria-hidden="true"`; the surrounding text already provides the semantic meaning. - -**Rationale**: This preserves accessibility (screen readers read the text, not the icon) while giving visual users a crisp icon instead of an emoji. - -**Alternatives considered**: -- CSS `::before` pseudo-element on `.status-ok` class: Cleaner separation but requires class to be set reliably on the container; could be adopted in a follow-up refactor -- Keep `✓` as plain Unicode: Renders inconsistently; defeats the purpose of this change - -### SCSS Styling -**Decision**: Add a `.icon` utility class in `styles.scss` that normalises SVG icon display. -```scss -.icon { - display: inline-block; - vertical-align: middle; - flex-shrink: 0; - color: inherit; // inherits text colour for easy theming - pointer-events: none; // icons should not intercept clicks +export function updateMenuShortcuts(els: DomRefs, isMac: boolean): void { + const modifier = isMac ? "Cmd" : "Ctrl"; + const shortcuts = els.menuBar.querySelectorAll(".menu-shortcut"); + shortcuts.forEach((el) => { + const text = el.textContent; + if (text) { + if (text.includes("Cmd/Ctrl")) { + el.textContent = text.replace("Cmd/Ctrl", modifier); + } else if (text.includes("Ctrl")) { + el.textContent = text.replace("Ctrl", modifier); + } + } + }); } -``` - -**Rationale**: A single class handles alignment across all icon placements without per-site overrides. - -## Risks / Trade-offs - -### Risk: SVG strings in template literals may be escaped by the build assembler -**Mitigation**: Audit `build/compile-and-assemble.js` to confirm SVG strings in JavaScript template literals pass through without HTML-entity encoding. Test the built `ink.html` output in a browser before marking complete. - -### Risk: Icon sizing mismatch with existing layout -**Mitigation**: Use `width="16" height="16"` with `vertical-align: middle` as a baseline; adjust per context in SCSS if needed. Review visually in the sidebar, file tree, and status bar. - -### Risk: Colour contrast regression -**Mitigation**: Using `stroke="currentColor"` means icons inherit the surrounding text colour automatically, preserving existing contrast ratios. - -## Migration Plan - -### Phase 1: Foundation -1. Create `src/app/icons.ts` with SVG strings for all required icons. -2. Add `.icon` CSS class to `styles.scss`. - -### Phase 2: Tree Icons -1. Update `tree-render.ts` to import and use `icon.folder`, `icon.folderOpen`, and `icon.fileText`. -2. Build and visually verify the file tree. - -### Phase 3: Template Icon -1. Replace the 🗂️ emoji in `ink.template.html` with the inline Lucide `library` SVG — chosen over `folder-open` to distinguish "workspace" (a collection of folders and notes) from a single folder. - -### Phase 4: Status / Toast Checkmark -1. Update `workspace-io.ts` status and toast strings to use `icon.check()` prepended to message text. -2. Verify toast and status bar rendering. - -### Phase 5: Build Verification -1. Run the full build (`node build/compile-and-assemble.js`). -2. Open `ink.html` in a browser and inspect all icon placements. -3. Confirm single-file output integrity. -### Rollback Plan -All changes are isolated to icon strings and SCSS. To rollback: -1. Revert emoji strings in `tree-render.ts`, `ink.template.html`, and `workspace-io.ts`. -2. Remove `icons.ts` and the `.icon` SCSS rule. -No data or logic is affected. +export function createMenuActions({ + state, + els, + showToast, + renderTree, + callbacks, +}: { + state: AppState; + els: DomRefs; + showToast: ToastFn; + renderTree: () => Promise; + callbacks: MenuCallbacks; +}) { + function toggleSort(): void { + state.sortMode = state.sortMode === "name" ? "modified" : "name"; + els.sortBtn.textContent = `Sort: ${state.sortMode === "name" ? "Name" : "Last modified"}`; -## Open Questions + const sortMenuItem = document.querySelector('[data-action="sort"] .menu-label-text'); + if (sortMenuItem) { + sortMenuItem.textContent = `Sort: ${state.sortMode === "name" ? "Name" : "Modified"}`; + } -1. **Should the check icon in toasts also appear in the "Saved ✓" status bar text, or only in toasts?** — Recommend applying consistently to both for visual cohesion. -2. **Should icon size vary between the file tree (16px) and the sidebar button (20px)?** — The `icon()` functions could accept an optional `size` parameter to handle this; defer to implementation. -3. **Should this change also address future icon needs (e.g. sort, collapse, refresh actions)?** — Out of scope for this change; a follow-up "icon system" change can extend the registry. - + renderTree().catch((error: unknown) => { + showToast(`Sort render failed: ${String(error)}`, { persist: true }); + }); + } - -# Tasks: Replace Emojis with SVG Icons + function handleExit(): void { + if (state.isDirty) { + const shouldExit = confirm("You have unsaved changes. Are you sure you want to exit?"); + if (!shouldExit) { + return; + } + } + window.close(); + } -## 1. Foundation + function handleMenuAction(action: string): void { + switch (action) { + case "new-note": + callbacks.createNewNote().catch((error: unknown) => { + showToast(`Create note failed: ${String(error)}`, { persist: true }); + }); + break; + case "new-folder": + callbacks.createNewFolder().catch((error: unknown) => { + showToast(`Create folder failed: ${String(error)}`, { persist: true }); + }); + break; + case "open-workspace": + callbacks.openWorkspace().catch((error: unknown) => { + showToast(`Failed to open workspace: ${String(error)}`, { persist: true }); + }); + break; + case "close-workspace": + callbacks.closeWorkspace(); + break; + case "exit": + handleExit(); + break; + case "save": + callbacks.saveCurrentNote().catch((error: unknown) => { + showToast(`Save failed: ${String(error)}`, { persist: true }); + }); + break; + case "save-as": + callbacks.saveAsNewNote().catch((error: unknown) => { + showToast(`Save As failed: ${String(error)}`, { persist: true }); + }); + break; + case "refresh": + callbacks.handleRefresh(); + break; + case "sort": + toggleSort(); + break; + case "collapse-sidebar": + callbacks.setSidebarCollapsed(!state.isSidebarCollapsed); + break; + case "export-json": + callbacks.exportAsJson(); + break; + case "export-markdown": + callbacks.exportAsMarkdown(); + break; + case "theme-default": + case "theme-classic": + case "theme-cobalt": + case "theme-monokai": + case "theme-office": + case "theme-twilight": + case "theme-xcode": { + const themeName = action.replace("theme-", ""); + if (VALID_THEMES.includes(themeName)) { + applyTheme(themeName); + } + break; + } + } + } -### 1.1 Icon Registry -- [ ] 1.1.1 Create `src/app/icons.ts` exporting an `icon` object with named SVG string functions -- [ ] 1.1.2 Add `folder` icon (Lucide `folder` — closed folder path) -- [ ] 1.1.3 Add `folderOpen` icon (Lucide `folder-open` — open folder path) -- [ ] 1.1.4 Add `fileText` icon (Lucide `file-text` — lined document path) -- [ ] 1.1.5 Add `library` icon (Lucide `library` — stacked books path; used for "Open Workspace" to convey a collection of folders and notes) -- [ ] 1.1.6 Add `check` icon (Lucide `check` — simple checkmark path) -- [ ] 1.1.7 All SVG elements include `aria-hidden="true"`, `stroke="currentColor"`, `class="icon"`, and `width`/`height` attributes + return { + toggleSort, + handleMenuAction, + handleExit, + }; +} + -### 1.2 SCSS Utility -- [ ] 1.2.1 Add `.icon` utility class to `src/styles.scss` (`display: inline-block`, `vertical-align: middle`, `flex-shrink: 0`, `pointer-events: none`, `color: inherit`) + +import { escapeHtml } from "./utils"; +import { icon } from "./icons"; +import type { + AppState, + DomRefs, + FileHandleLike, + InMemoryNoteRecord, + TreeNode, +} from "./types"; -## 2. File Tree Icons (`src/app/tree-render.ts`) +export type TreeHandlers = { + openNoteByRelPath: (relPath: string, handleHint: FileHandleLike | null) => Promise; + openInMemoryNote: (relPath: string) => Promise; +}; -- [ ] 2.1 Import `icon` from `./icons` -- [ ] 2.2 Replace `"📁"` with `icon.folder()` (collapsed folder state) -- [ ] 2.3 Replace `"📂"` with `icon.folderOpen()` (expanded folder state) -- [ ] 2.4 Replace both instances of `"📝"` with `icon.fileText()` -- [ ] 2.5 Build and visually verify file tree rendering — icons align with folder/file names +type ToastFn = (message: string, options?: { persist?: boolean }) => void; -## 3. Sidebar Template Icon (`ink.template.html`) +export function createTreeRenderer({ + state, + els, + handlers, + showToast, +}: { + state: AppState; + els: DomRefs; + handlers: TreeHandlers; + showToast: ToastFn; +}) { + function renderTags(): void { + const tagCounts = new Map(); + const notes = state.isTemporarySession ? state.inMemoryNotes : state.notes; -- [ ] 3.1 Replace the `🗂️` emoji inside the Open Workspace `` with the inline Lucide `library` SVG markup (conveys a collection of folders and notes, distinct from the file-tree folder icons) -- [ ] 3.2 Verify the button layout and hover state are unaffected + for (const note of notes) { + for (const tag of note.tags) { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + } + } -## 4. Status & Toast Checkmark (`src/app/workspace-io.ts`) + const sorted = [...tagCounts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 50); -- [ ] 4.1 Replace `"Opened ✓"` → `icon.check() + " Opened"` (or equivalent pattern) -- [ ] 4.2 Replace `"Saved ✓"` → `icon.check() + " Saved"` (two occurrences) -- [ ] 4.3 Replace `"New note created ✓"` → `icon.check() + " New note created"` -- [ ] 4.4 Replace `"Folder created ✓"` → `icon.check() + " Folder created"` -- [ ] 4.5 Replace `"Saved as ${fileName} ✓"` → `` `${icon.check()} Saved as ${fileName}` `` -- [ ] 4.6 Confirm the `icon` import is available in `workspace-io.ts` (add import if needed) -- [ ] 4.7 Verify toast and status bar messages render the check icon correctly in a live browser test + els.tagRow.innerHTML = ""; + if (sorted.length === 0) { + return; + } -## 5. Build Verification + const allButton = document.createElement("button"); + allButton.className = `tag${state.tagFilter ? "" : " active"}`; + allButton.textContent = "All"; + allButton.title = "Clear tag filter"; + allButton.addEventListener("click", () => { + state.tagFilter = ""; + renderTags(); + if (state.isTemporarySession) { + renderInMemoryTree(); + } else { + renderTree().catch((error: unknown) => { + showToast(`Tag render failed: ${String(error)}`, { persist: true }); + }); + } + }); + els.tagRow.appendChild(allButton); -- [ ] 5.1 Run full build (`node build/compile-and-assemble.js`) — build completes without errors -- [ ] 5.2 Open `ink.html` in a browser — all four icon types render as SVG (not emoji, not broken markup) -- [ ] 5.3 Confirm file tree icons (folder closed, folder open, file) display at the correct size and colour -- [ ] 5.4 Confirm sidebar Open Workspace button icon displays correctly -- [ ] 5.5 Confirm toast and status bar check icons display correctly -- [ ] 5.6 Inspect built HTML source — confirm no unintended HTML entity encoding of SVG content -- [ ] 5.7 Verify no layout regressions in sidebar, file tree, or status bar on a standard viewport - + for (const [tag, count] of sorted) { + const button = document.createElement("button"); + button.className = `tag${state.tagFilter === tag ? " active" : ""}`; + button.textContent = `#${tag}`; + button.title = `${count} note${count === 1 ? "" : "s"} tagged #${tag}`; + button.addEventListener("click", () => { + state.tagFilter = state.tagFilter === tag ? "" : tag; + renderTags(); + if (state.isTemporarySession) { + renderInMemoryTree(); + } else { + renderTree().catch((error: unknown) => { + showToast(`Tag render failed: ${String(error)}`, { persist: true }); + }); + } + }); + els.tagRow.appendChild(button); + } + } - -import type { DomRefs } from "../types"; -import { - DOCUMENT_LINTER_FALLBACK_STRENGTH, - DOCUMENT_LINTER_NO_MAJOR_FIXES, - DOCUMENT_LINTER_NO_NOTABLE_ISSUES, - DOCUMENT_LINTER_NO_SECTION_STRUCTURE, - balancedSection, - bulletsAreDense, - denseAbstractLanguage, - explicitSectionLabel, - missingHeading, - openingIsInformativeNotDirective, - openingNeedsStrongerLead, - openingNeedsStructure, - openingTooDense, - quickTakeNeedsScaffold, - quickTakeLowSignal, - questionNeedsAnswer, - repeatedWord, - quickTakeStrengthsDetected, - sectionDenseBulletRun, - sectionDenseBullets, - sectionLabelPromotion, - sectionListNeedsLead, - sectionLong, - sectionLongMaterial, - sectionNeedsAttention, - sectionNeedsHeading, - thinDraft, - thinSection, - strengthClearStructure, - strengthConcreteAnchors, - strengthDirectiveLead, - strengthMnemonic, - strengthParallelList, - strengthQuestionLead, - structureBreakMiddle, - structureSimpleOutline, -} from "./messages"; + async function computeMatches(): Promise> { + let notes = [...state.notes]; -type ToastFn = (message: string, options?: { persist?: boolean }) => void; -type SetStatusFn = (message: string, kind?: "ok" | "warn" | "err") => void; + if (state.tagFilter) { + notes = notes.filter((note) => note.tags.has(state.tagFilter)); + } -type LinterCategoryId = "readability" | "skimmability" | "engagement" | "style" | "structure"; -type Severity = "high" | "medium" | "low"; + if (state.sortMode === "modified") { + notes.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); + } else { + notes.sort((a, b) => a.relPath.localeCompare(b.relPath)); + } -type LinterCategoryResult = { - score: number; - suggestions: string[]; -}; + const query = state.searchQuery.trim().toLowerCase(); + if (!query) { + return new Set(notes.map((note) => note.relPath)); + } -type LinterResults = Record; + const matches = new Set(); + for (const note of notes) { + if (note.relPath.toLowerCase().includes(query)) { + matches.add(note.relPath); + continue; + } -export type LinterAnalysis = { - scores: LinterResults; - overallScore: number; - overview: { - quickTake: string[]; - priorities: string[]; - strengths: string[]; - }; - sections: Array<{ - title: string; - notes: string[]; - }>; - documentSections: Array<{ - title: string; - kind: "heading" | "label" | "implicit"; - lineStart: number; - lineEnd: number; - notes: string[]; - needsAttention: boolean; - }>; -}; - -type DocumentLinterController = { - setActive: (isActive: boolean) => void; - handleEditorChanged: (textSnapshot?: string) => Promise; - analyzeDocument: () => Promise; - exportSuggestions: () => Promise; - getLatestAnalysis: () => LinterAnalysis | null; - getAnalysisRevision: () => number; -}; - -type MarkdownBlock = - | { type: "heading"; text: string; lineStart: number; lineEnd: number; level: number } - | { type: "paragraph"; text: string; lineStart: number; lineEnd: number } - | { type: "list_item"; text: string; lineStart: number; lineEnd: number } - | { type: "blockquote"; text: string; lineStart: number; lineEnd: number } - | { type: "code"; text: string; lineStart: number; lineEnd: number }; + try { + const file = await note.handle.getFile(); + const text = (await file.text()).toLowerCase(); + if (text.includes(query)) { + matches.add(note.relPath); + } + } catch { + // Ignore read errors while searching. + } + } -type DocumentSection = { - title: string; - kind: "heading" | "label" | "implicit"; - lineStart: number; - lineEnd: number; - blocks: MarkdownBlock[]; -}; + return matches; + } -type Finding = { - category: LinterCategoryId; - severity: Severity; - title: string; - detail: string; - section: string; - isStrength?: boolean; -}; + async function renderTree(): Promise { + if (!state.fileTree) { + els.tree.innerHTML = '
Open a folder to begin.
'; + return; + } -const CATEGORY_META: Array<{ id: LinterCategoryId; title: string; color: string }> = [ - { id: "readability", title: "Readability", color: "#4CAF50" }, - { id: "skimmability", title: "Skimmability", color: "#2196F3" }, - { id: "engagement", title: "Engagement", color: "#FF9800" }, - { id: "style", title: "Style", color: "#9C27B0" }, - { id: "structure", title: "Structure", color: "#607D8B" }, -]; + const matches = await computeMatches(); + const prunedTree = pruneTree(state.fileTree, matches); -const AUTO_RUN_DEBOUNCE_MS = 300; + els.tree.innerHTML = ""; + if (!prunedTree || prunedTree.type !== "dir" || prunedTree.children.length === 0) { + els.tree.innerHTML = '
No matching notes.
'; + return; + } -function clampScore(score: number): number { - return Math.max(0, Math.min(100, score)); -} + for (const child of prunedTree.children) { + renderNode(child, 0); + } + } -function countWords(text: string): number { - return text.match(/\b\w+\b/g)?.length ?? 0; -} + function pruneTree(node: TreeNode, matches: Set): TreeNode | null { + if (node.type === "file") { + return matches.has(node.relPath) ? node : null; + } -// This tokenizer intentionally prefers deterministic sentence boundaries over perfect -// linguistic coverage. Abbreviations like "e.g." may still count as sentence endings. -export function splitSentences(text: string): string[] { - return text - .replaceAll(/\s+/g, " ") - .split(/(?<=[.!?])\s+/) - .map((sentence) => sentence.trim()) - .filter(Boolean); -} + const children: TreeNode[] = []; + for (const child of node.children) { + const kept = pruneTree(child, matches); + if (kept) { + children.push(kept); + } + } -export function countSentences(text: string): number { - return splitSentences(text).length; -} + if (node.relPath === "") { + return { + ...node, + children, + }; + } -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} + if (children.length === 0) { + return null; + } -function scoreWeight(severity: Severity): number { - if (severity === "high") { - return 3; - } - if (severity === "medium") { - return 2; + return { + ...node, + children, + }; } - return 1; -} - -function createLineButtonMarkup(lineStart: number, label = `Line ${lineStart}`): string { - return ``; -} -function isFencedCodeStart(line: string): boolean { - return /^```/.test(line.trim()); -} + function renderNode(node: TreeNode, depth: number): void { + const row = document.createElement("div"); + row.className = "node"; + row.style.paddingLeft = `${8 + depth * 12}px`; -function isAtxHeading(line: string): boolean { - return /^#{1,6}\s+/.test(line.trim()); -} + if (node.type === "dir") { + const isCollapsed = state.collapsedDirs.has(node.relPath); + row.innerHTML = ` + ${isCollapsed ? "▶" : "▼"} + ${isCollapsed ? icon.folder() : icon.folderOpen()} + ${escapeHtml(node.name)} + `; -function isOrderedListItem(line: string): boolean { - return /^\s*\d+[.)]\s+/.test(line); -} + row.addEventListener("click", (event: MouseEvent) => { + event.stopPropagation(); + if (isCollapsed) { + state.collapsedDirs.delete(node.relPath); + } else { + state.collapsedDirs.add(node.relPath); + } -function isUnorderedListItem(line: string): boolean { - return /^\s*[-*+]\s+/.test(line); -} + renderTree().catch((error: unknown) => { + showToast(`Tree render failed: ${String(error)}`, { persist: true }); + }); + }); -function isListItem(line: string): boolean { - return isUnorderedListItem(line) || isOrderedListItem(line); -} + els.tree.appendChild(row); + if (!isCollapsed) { + for (const child of node.children) { + renderNode(child, depth + 1); + } + } + return; + } -function isBlockquoteLine(line: string): boolean { - return /^\s*>\s?/.test(line); -} + const isActive = node.relPath === state.currentRelPath; + if (isActive) { + row.classList.add("active"); + } -function isIndentedCodeLine(line: string): boolean { - return /^(?:\t| {4,})/.test(line); -} + const meta = + state.sortMode === "modified" && node.noteRef.lastModified + ? new Date(node.noteRef.lastModified).toLocaleDateString() + : ""; -function getSectionLabelTitle(block: MarkdownBlock, nextBlock?: MarkdownBlock): string | null { - if (block.type === "paragraph" && /:\s*$/.test(block.text) && nextBlock?.type === "list_item") { - return block.text.replace(/:\s*$/, ""); - } - return null; -} + row.innerHTML = ` + ${icon.fileText()} + ${escapeHtml(node.noteRef.name)} + ${escapeHtml(meta)} + `; -function listMarkerPrefix(line: string): string { - return line.replace(/^(\s*(?:[-*+]|\d+[.)]))\s+.*$/, "$1"); -} + row.addEventListener("click", (event: MouseEvent) => { + event.stopPropagation(); + handlers.openNoteByRelPath(node.relPath, node.handle).catch((error: unknown) => { + showToast(`Open note failed: ${String(error)}`, { persist: true }); + }); + }); -function stripListMarker(line: string): string { - return line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim(); -} + els.tree.appendChild(row); + } -function normalizeBlockText(lines: string[]): string { - return lines.map((line) => line.trim()).join(" ").replaceAll(/\s+/g, " ").trim(); -} + function renderInMemoryTree(): void { + els.tree.innerHTML = ""; -export function parseMarkdownBlocks(text: string): MarkdownBlock[] { - const lines = text.split("\n"); - const blocks: MarkdownBlock[] = []; + if (state.inMemoryNotes.length === 0) { + els.tree.innerHTML = + '
Temporary session. Create a note to begin.
'; + return; + } - let i = 0; - while (i < lines.length) { - const line = lines[i]; - const trimmed = line.trim(); - const lineNumber = i + 1; + const sortedNotes = [...state.inMemoryNotes].sort((a, b) => a.relPath.localeCompare(b.relPath)); - if (trimmed === "") { - i += 1; - continue; + for (const note of sortedNotes) { + renderInMemoryRow(note); } + } - if (isFencedCodeStart(line)) { - const startLine = lineNumber; - const codeLines: string[] = [line]; - i += 1; - while (i < lines.length && !isFencedCodeStart(lines[i])) { - codeLines.push(lines[i]); - i += 1; - } - if (i < lines.length) { - codeLines.push(lines[i]); - i += 1; - } - blocks.push({ - type: "code", - text: codeLines.join("\n"), - lineStart: startLine, - lineEnd: Math.max(startLine, i), - }); - continue; - } - - if (isIndentedCodeLine(line)) { - const startLine = lineNumber; - const codeLines = [line.replace(/^(?:\t| {4})/, "")]; - i += 1; - while (i < lines.length && (isIndentedCodeLine(lines[i]) || lines[i].trim() === "")) { - codeLines.push(lines[i].trim() === "" ? "" : lines[i].replace(/^(?:\t| {4})/, "")); - i += 1; - } - blocks.push({ - type: "code", - text: codeLines.join("\n"), - lineStart: startLine, - lineEnd: i, - }); - continue; - } + function renderInMemoryRow(note: InMemoryNoteRecord): void { + const row = document.createElement("div"); + row.className = "node"; - if (i + 1 < lines.length && trimmed && /^(=+|-+)\s*$/.test(lines[i + 1].trim())) { - blocks.push({ - type: "heading", - text: trimmed, - lineStart: lineNumber, - lineEnd: lineNumber + 1, - level: lines[i + 1].trim().startsWith("=") ? 1 : 2, - }); - i += 2; - continue; + const isActive = note.relPath === state.currentRelPath; + if (isActive) { + row.classList.add("active"); } - if (isAtxHeading(line)) { - const match = trimmed.match(/^(#{1,6})\s+(.*)$/); - if (match) { - blocks.push({ - type: "heading", - text: match[2].trim(), - lineStart: lineNumber, - lineEnd: lineNumber, - level: match[1].length, - }); - } - i += 1; - continue; - } + const meta = state.sortMode === "modified" ? new Date(note.lastModified).toLocaleDateString() : ""; - if (isListItem(line)) { - const marker = listMarkerPrefix(line); - blocks.push({ - type: "list_item", - text: stripListMarker(line), - lineStart: lineNumber, - lineEnd: lineNumber, - }); - i += 1; - while (i < lines.length) { - const nextLine = lines[i]; - if (nextLine.trim() === "") { - break; - } - if (listMarkerPrefix(nextLine) !== marker || !isListItem(nextLine)) { - break; - } - blocks.push({ - type: "list_item", - text: stripListMarker(nextLine), - lineStart: i + 1, - lineEnd: i + 1, - }); - i += 1; - } - continue; - } + row.innerHTML = ` + ${icon.fileText()} + ${escapeHtml(note.name)} + ${escapeHtml(meta)} + `; - if (isBlockquoteLine(line)) { - const startLine = lineNumber; - const quoteLines: string[] = []; - while (i < lines.length) { - const currentLine = lines[i]; - if (isBlockquoteLine(currentLine)) { - quoteLines.push(currentLine.replace(/^\s*>\s?/, "").trim()); - i += 1; - continue; - } - if (currentLine.trim() === "" && i + 1 < lines.length && isBlockquoteLine(lines[i + 1])) { - quoteLines.push(""); - i += 1; - continue; - } - break; - } - blocks.push({ - type: "blockquote", - text: normalizeBlockText(quoteLines), - lineStart: startLine, - lineEnd: i, - }); - continue; - } + row.addEventListener("click", () => { + handlers.openInMemoryNote(note.relPath); + }); - const startLine = lineNumber; - const paragraphLines: string[] = [trimmed]; - i += 1; - while (i < lines.length) { - const nextLine = lines[i]; - const nextTrimmed = nextLine.trim(); - if ( - nextTrimmed === "" || - isFencedCodeStart(nextLine) || - isAtxHeading(nextLine) || - isListItem(nextLine) || - isBlockquoteLine(nextLine) || - isIndentedCodeLine(nextLine) || - (i + 1 < lines.length && /^(=+|-+)\s*$/.test(lines[i + 1].trim())) - ) { - break; - } - paragraphLines.push(nextTrimmed); - i += 1; - } + els.tree.appendChild(row); + } - blocks.push({ - type: "paragraph", - text: paragraphLines.join(" ").trim(), - lineStart: startLine, - lineEnd: startLine + paragraphLines.length - 1, - }); + function updateCountsPill(): void { + const count = state.inMemoryNotes.length; + els.countsPill.textContent = `${count} note${count === 1 ? "" : "s"}`; } - return blocks; + return { + computeMatches, + renderTree, + renderTags, + renderInMemoryTree, + updateCountsPill, + }; } +
-function getBlocksOfType(blocks: MarkdownBlock[], type: MarkdownBlock["type"]): MarkdownBlock[] { - return blocks.filter((block) => block.type === type); -} + +import QUnit from "qunit"; +import { + extractLastSentence, + parseCogitoQuestionPayload, + formatCogitoQuestionBlock, + buildCogitoAnalysisContext, + buildCogitoUserPrompt, + isRecoverableModelLoadError, +} from "../../dist/test/cogito.js"; -function getPlainTextBlocks(blocks: MarkdownBlock[]): Array> { - return blocks.filter((block): block is Extract => { - return block.type === "paragraph" || block.type === "blockquote"; - }); -} +QUnit.module("cogito"); -function findRepeatedWord(text: string): { word: string; index: number } | null { - const match = text.match(/\b([a-z][a-z'-]{1,})\b(?:\s+\1\b)/i); - if (!match || typeof match.index !== "number") { - return null; - } - return { word: match[1], index: match.index }; -} +QUnit.test("extractLastSentence returns final complete sentence", (assert) => { + const result = extractLastSentence("First sentence. Second sentence? Third sentence!"); + assert.strictEqual(result, "Third sentence!"); +}); -function createFinding( - category: LinterCategoryId, - severity: Severity, - title: string, - detail: string, - section: string, - isStrength = false, -): Finding { - return { category, severity, title, detail, section, isStrength }; -} +QUnit.test("extractLastSentence handles single sentence without punctuation", (assert) => { + const result = extractLastSentence("A sentence without punctuation"); + assert.strictEqual(result, "A sentence without punctuation"); +}); -function summarizeOpeners(blocks: MarkdownBlock[]): string { - const firstParagraph = blocks.find((block) => block.type === "paragraph"); - if (!firstParagraph) { - return openingNeedsStructure(); - } +QUnit.test("parseCogitoQuestionPayload validates exactly three questions", (assert) => { + const payload = JSON.stringify({ + questions: ["What changed?", "Why now?", "What evidence supports it?"], + }); + const result = parseCogitoQuestionPayload(payload); + assert.deepEqual(result, ["What changed?", "Why now?", "What evidence supports it?"]); +}); - if (countWords(firstParagraph.text) >= 22) { - return openingTooDense(); - } +QUnit.test("parseCogitoQuestionPayload throws when question count is invalid", (assert) => { + assert.throws(() => { + parseCogitoQuestionPayload(JSON.stringify({ questions: ["Only one"] })); + }, /exactly 3 questions/); +}); - if (/^(this|these)\b/i.test(firstParagraph.text)) { - return openingIsInformativeNotDirective(); - } +QUnit.test("formatCogitoQuestionBlock returns required markdown block", (assert) => { + const result = formatCogitoQuestionBlock("What is your core claim?"); + assert.strictEqual(result, "> ### AI\nWhat is your core claim?\n"); +}); - return openingNeedsStrongerLead(); -} +QUnit.test("buildCogitoAnalysisContext keeps only compact coaching signals", (assert) => { + const context = buildCogitoAnalysisContext({ + overallScore: 72, + overview: { + priorities: ["Clarify the opening.", "Break up the dense section.", "Add evidence.", "Ignored fourth item."], + strengths: ["Strong structure.", "Concrete examples.", "Ignored third strength."], + quickTake: [], + }, + scores: {}, + sections: [], + documentSections: [], + }); -function detectPseudoSectionLabel(blocks: MarkdownBlock[]): MarkdownBlock | null { - for (let i = 0; i < blocks.length - 1; i += 1) { - const block = blocks[i]; - const nextBlock = blocks[i + 1]; - if (getSectionLabelTitle(block, nextBlock)) { - return block; - } - } - return null; -} + assert.deepEqual(context, { + overallScore: 72, + priorities: ["Clarify the opening.", "Break up the dense section.", "Add evidence."], + strengths: ["Strong structure.", "Concrete examples."], + }); +}); -export function buildDocumentSections(blocks: MarkdownBlock[]): DocumentSection[] { - const sections: DocumentSection[] = []; - let currentSection: DocumentSection | null = null; +QUnit.test("buildCogitoUserPrompt combines latest sentence with analysis context", (assert) => { + const prompt = buildCogitoUserPrompt("The conclusion needs sharper evidence.", { + overallScore: 68, + priorities: ["Support the main claim."], + strengths: ["The structure is easy to scan."], + }); - function startSection(title: string, kind: DocumentSection["kind"], lineStart: number): DocumentSection { - const nextSection: DocumentSection = { - title, - kind, - lineStart, - lineEnd: lineStart, - blocks: [], - }; - sections.push(nextSection); - currentSection = nextSection; - return nextSection; + assert.ok(prompt.includes("Last sentence: The conclusion needs sharper evidence.")); + assert.ok(prompt.includes("Document strength: 68/100")); + assert.ok(prompt.includes("Support the main claim.")); + assert.ok(prompt.includes("The structure is easy to scan.")); +}); + +QUnit.test("buildCogitoUserPrompt bounds unpunctuated document context", (assert) => { + const longSentence = "opening " + "detail ".repeat(1000); + const prompt = buildCogitoUserPrompt(longSentence, { + overallScore: 50, + priorities: ["priority ".repeat(1000)], + strengths: ["strength ".repeat(1000)], + }); + const promptSentence = prompt.split("\n")[0]; + + assert.ok(promptSentence.length <= 1215, "latest-sentence context should have a fixed upper bound"); + assert.ok(promptSentence.includes("…"), "truncated context should be visibly marked"); + assert.ok(promptSentence.endsWith("detail"), "the most recent end of the sentence should be retained"); + assert.ok(prompt.length < 1800, "the complete model prompt should remain compact"); +}); + +QUnit.test("isRecoverableModelLoadError identifies cache and network failures", (assert) => { + assert.true( + isRecoverableModelLoadError( + new Error("Failed to execute 'add' on 'Cache': Cache.add() encountered a network error"), + ), + ); + assert.true(isRecoverableModelLoadError(new Error("TypeError: Failed to fetch"))); + assert.false(isRecoverableModelLoadError(new Error("WebGPU is not supported"))); +}); + + + +import QUnit from "qunit"; +import { + analyzeDocumentText, + buildDocumentLinterReport, + buildDocumentSections, + countSentences, + createDocumentLinterController, + parseMarkdownBlocks, +} from "../../dist/test/document-linter.js"; + +function createClassList() { + const classes = new Set(); + return { + add(value) { + classes.add(value); + }, + remove(value) { + classes.delete(value); + }, + toggle(value, force) { + if (force === undefined) { + if (classes.has(value)) { + classes.delete(value); + return false; + } + classes.add(value); + return true; + } + if (force) { + classes.add(value); + return true; + } + classes.delete(value); + return false; + }, + contains(value) { + return classes.has(value); + }, + }; +} + +class FakeElement { + constructor(tagName = "div") { + this.tagName = tagName.toUpperCase(); + this.hidden = false; + this.disabled = false; + this.textContent = ""; + this.innerHTML = ""; + this.className = ""; + this.children = []; + this.parentNode = null; + this.classList = createClassList(); + this.attributes = new Map(); + this.closestMap = new Map(); + this.clicked = false; + this.listeners = new Map(); + this.selectionStart = 0; + this.selectionEnd = 0; + this.scrollTop = 0; + this.value = ""; } - function ensureLeadSection(block: MarkdownBlock): DocumentSection { - if (currentSection) { - return currentSection; - } - return startSection("Lead", "implicit", block.lineStart); + appendChild(child) { + child.parentNode = this; + this.children.push(child); + return child; } - for (let i = 0; i < blocks.length; i += 1) { - const block = blocks[i]; - const nextBlock = blocks[i + 1]; + removeChild(child) { + this.children = this.children.filter((candidate) => candidate !== child); + child.parentNode = null; + return child; + } - if (block.type === "heading") { - startSection(block.text, "heading", block.lineStart); - continue; - } + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } - const labelTitle = getSectionLabelTitle(block, nextBlock); - if (labelTitle) { - startSection(labelTitle, "label", block.lineStart); - continue; - } + getAttribute(name) { + return this.attributes.get(name) ?? null; + } - const activeSection = ensureLeadSection(block); - activeSection.blocks.push(block); - activeSection.lineEnd = block.lineEnd; + closest(selector) { + return this.closestMap.get(selector) ?? null; } - if (sections.length === 0) { - return [ - { - title: "Document", - kind: "implicit", - lineStart: 1, - lineEnd: 1, - blocks: [], - }, - ]; + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); } - return sections; -} + dispatchEvent(event) { + event.target = this; + const listeners = this.listeners.get(event.type) ?? []; + listeners.forEach((listener) => listener(event)); + return true; + } -function analyzeReadability(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { - const proseBlocks = getPlainTextBlocks(blocks); - const findings: Finding[] = []; - let longSentenceCount = 0; - let repeatedWordCount = 0; + focus() { + global.document.activeElement = this; + } - for (const block of proseBlocks) { - const sentences = splitSentences(block.text); - const repeatedWordMatch = findRepeatedWord(block.text); - if (repeatedWordMatch) { - repeatedWordCount += 1; - if (!findings.some((finding) => finding.title === "Repeated word")) { - findings.push( - createFinding( - "readability", - "medium", - "Repeated word", - repeatedWord(), - `Line ${block.lineStart}`, - ), - ); - } - } - for (const sentence of sentences) { - const wordCount = countWords(sentence); - const charCount = sentence.length; - if (wordCount >= 24 || charCount >= 140) { - longSentenceCount += 1; - if (findings.length < 3) { - const snippet = sentence.length > 70 ? `${sentence.slice(0, 70)}...` : sentence; - findings.push( - createFinding( - "readability", - "medium", - "Dense sentence", - `This sentence is carrying too much at once: "${snippet}". Split the idea or move the setup into a heading.`, - `Line ${block.lineStart}`, - ), - ); - } - } - } + setSelectionRange(start, end) { + this.selectionStart = start; + this.selectionEnd = end; } - const openingBlock = proseBlocks[0]; - if (openingBlock && countWords(openingBlock.text) >= 22) { - findings.unshift( - createFinding( - "readability", - "high", - "Opening is dense", - openingTooDense(), - `Line ${openingBlock.lineStart}`, - ), - ); + click() { + this.clicked = true; } +} - const score = clampScore( - 100 - - longSentenceCount * 14 - - repeatedWordCount * 10 - - (openingBlock && countWords(openingBlock.text) >= 22 ? 8 : 0), - ); +function createControllerDomRefs() { + const editor = new FakeElement("textarea"); + editor.value = ""; return { - result: { - score, - suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), - }, - findings, + editor, + documentLinterAnalyzeBtn: new FakeElement("button"), + documentLinterExportBtn: new FakeElement("button"), + documentLinterAutoRunToggle: new FakeElement("input"), + documentLinterStatus: new FakeElement("div"), + documentLinterResults: new FakeElement("div"), }; } -function analyzeSkimmability( - blocks: MarkdownBlock[], -): { result: LinterCategoryResult; findings: Finding[]; pseudoSectionLabel: MarkdownBlock | null } { - const headings = getBlocksOfType(blocks, "heading"); - const bullets = getBlocksOfType(blocks, "list_item"); - const pseudoSectionLabel = detectPseudoSectionLabel(blocks); - const findings: Finding[] = []; +QUnit.module("document-linter", (hooks) => { + hooks.beforeEach(function () { + this.originalDocument = global.document; + this.originalWindow = global.window; + this.originalURL = global.URL; + this.originalBlob = global.Blob; - if (headings.length === 0) { - findings.push( - createFinding( - "skimmability", - "high", - "Missing heading", - missingHeading(), - "Document", - ), - ); - } + const fakeDocument = { + body: new FakeElement("body"), + activeElement: null, + createElement(tagName) { + return new FakeElement(tagName); + }, + }; - if (pseudoSectionLabel) { - findings.push( - createFinding( - "skimmability", - "medium", - "Make the section label explicit", - explicitSectionLabel(pseudoSectionLabel.text), - `Line ${pseudoSectionLabel.lineStart}`, - ), - ); - } + global.document = fakeDocument; + global.window = { + setTimeout, + clearTimeout, + getComputedStyle() { + return { lineHeight: "20" }; + }, + }; + global.URL = { + created: [], + revoked: [], + createObjectURL(blob) { + this.created.push(blob); + return "blob:fake-report"; + }, + revokeObjectURL(url) { + this.revoked.push(url); + }, + }; + global.Blob = class FakeBlob { + constructor(parts, options) { + this.parts = parts; + this.options = options; + } + }; + }); - if (bullets.length >= 4) { - const averageBulletWords = bullets.reduce((sum, block) => sum + countWords(block.text), 0) / bullets.length; - if (averageBulletWords >= 11) { - findings.push( - createFinding( - "skimmability", - "medium", - "Bullets are dense", - bulletsAreDense(), - "Bullet list", - ), - ); - } - } + hooks.afterEach(function () { + global.document = this.originalDocument; + global.window = this.originalWindow; + global.URL = this.originalURL; + global.Blob = this.originalBlob; + }); - const score = clampScore( - 100 - - (headings.length === 0 ? 20 : 0) - - (pseudoSectionLabel ? 6 : 0) - - (bullets.length >= 4 ? 10 : 0), - ); + QUnit.test("parseMarkdownBlocks handles ordered lists, setext headings, quotes, and open code fences", function (assert) { + const text = `Overview +--- - return { - result: { - score, - suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), - }, - findings, - pseudoSectionLabel, - }; -} +1. First point +2. Second point + - nested detail -function analyzeEngagement( - blocks: MarkdownBlock[], -): { result: LinterCategoryResult; findings: Finding[]; strengths: string[] } { - const text = blocks.map((block) => block.text).join(" "); - const firstParagraph = blocks.find((block) => block.type === "paragraph"); - const openings = firstParagraph?.text ?? ""; - const findings: Finding[] = []; - const strengths: string[] = []; - const totalWords = countWords(text); +> quoted paragraph +> +> still quoted - const openingSentence = splitSentences(openings)[0] ?? openings; - const openingWords = countWords(openingSentence); - const hasOpeningQuestion = /\?\s*$/.test(openingSentence); - const hasDirectiveLead = /^(remember|consider|notice|start|imagine|picture|look|think)\b/i.test(openingSentence); - const passiveMatches = text.match(/\b(?:was|were|is|are|be|been|being)\s+\w+ed\b/gi) ?? []; - const sentences = splitSentences(text); - const passiveRatio = sentences.length > 0 ? passiveMatches.length / sentences.length : 0; - const uniqueWords = new Set((text.toLowerCase().match(/\b[a-z][a-z'-]+\b/g) ?? []).filter((word) => word.length >= 4)); - const lexicalVariety = countWords(text) > 0 ? uniqueWords.size / countWords(text) : 0; - const directiveMatches = text.match(/\b(?:remember|consider|notice|focus|compare|look|keep|start)\b/gi) ?? []; - const concreteAnchorMatches = text.match(/\b(?:\d{2,4}|[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g) ?? []; - const mnemonicMatches = text.match(/\b(?:remember|key takeaway|simple way to remember|think of|in short)\b/gi) ?? []; - const bulletTexts = getBlocksOfType(blocks, "list_item").map((block) => block.text); - const bulletStarts = bulletTexts.map((bullet) => (bullet.match(/^[A-Za-z]+/)?.[0] ?? "").toLowerCase()).filter(Boolean); - const repeatedBulletStart = bulletStarts.find((start, index) => start && bulletStarts.indexOf(start) !== index); + const value = 1; - if (/^(this|these)\b/i.test(openingSentence) && openingWords >= 8) { - findings.push( - createFinding( - "engagement", - "medium", - "Opening is informative, not directive", - openingIsInformativeNotDirective(), - `Line ${firstParagraph?.lineStart ?? 1}`, - ), - ); - } +\`\`\`js +console.log("open fence") +`; - if (totalWords > 0 && totalWords < 12) { - findings.push( - createFinding( - "engagement", - "high", - "Too little context", - thinDraft(), - `Line ${firstParagraph?.lineStart ?? 1}`, - ), + const blocks = parseMarkdownBlocks(text); + + assert.deepEqual( + blocks.map((block) => block.type), + ["heading", "list_item", "list_item", "list_item", "blockquote", "code", "code"], + "markdown blocks should preserve structure-aware block types", ); - } + assert.strictEqual(blocks[0].text, "Overview", "setext heading should be parsed as a heading"); + assert.strictEqual(blocks[4].text, "quoted paragraph still quoted", "blockquote paragraphs should be merged"); + assert.ok(blocks[6].text.includes("console.log(\"open fence\")"), "unterminated fenced code should still become a code block"); + assert.strictEqual(parseMarkdownBlocks("#NoSpace")[0].type, "paragraph", "headings without a space should stay as paragraph text"); + }); - if (hasOpeningQuestion && totalWords < 18) { - findings.push( - createFinding( - "engagement", - "medium", - "Question needs payoff", - questionNeedsAnswer(), - `Line ${firstParagraph?.lineStart ?? 1}`, - ), + QUnit.test("buildDocumentSections creates a lead section for quote-first documents and detects label sections", function (assert) { + const text = `> Opening quote + +Facts to keep: +- One +- Two + +## Timeline +Paragraph after heading.`; + + const sections = buildDocumentSections(parseMarkdownBlocks(text)); + + assert.deepEqual( + sections.map((section) => ({ title: section.title, kind: section.kind })), + [ + { title: "Lead", kind: "implicit" }, + { title: "Facts to keep", kind: "label" }, + { title: "Timeline", kind: "heading" }, + ], + "sections should preserve implicit lead content before label and heading sections", ); - } + }); - if (hasOpeningQuestion) { - strengths.push(strengthQuestionLead()); - } + QUnit.test("analyzeDocumentText scores and flags long prose while preserving generic strengths", function (assert) { + const text = `This chapter is about how early cities were formed across river valleys, and it keeps layering context, chronology, social change, and settlement details into one long opening sentence that asks the reader to hold too much at once. - if (hasDirectiveLead || directiveMatches.length >= 2) { - strengths.push(strengthDirectiveLead()); - } +Remember this: +- Compare river access with food storage +- Compare crop surplus with trade growth +- Compare dense neighborhoods with social roles`; - if (mnemonicMatches.length > 0) { - strengths.push(strengthMnemonic()); - } + const analysis = analyzeDocumentText(text); - if (concreteAnchorMatches.length >= 2) { - strengths.push(strengthConcreteAnchors()); - } + assert.ok(analysis.scores.readability.score < 100, "readability score should drop for dense prose"); + assert.ok(analysis.overallScore > 0, "analysis should include an aggregate overall score"); + assert.ok( + analysis.scores.readability.suggestions.some((suggestion) => suggestion.includes("Dense sentence")), + "readability suggestions should include the long sentence warning", + ); + assert.ok( + analysis.overview.strengths.some((strength) => strength.includes("memory hook") || strength.includes("structure")), + "generic strengths should be surfaced without topic-specific place-name heuristics", + ); + assert.ok( + analysis.documentSections.some((section) => section.title === "Remember this" && section.needsAttention), + "section analysis should carry an explicit needsAttention flag", + ); + }); - if (repeatedBulletStart && bulletTexts.length >= 3) { - strengths.push(strengthParallelList()); - } + QUnit.test("analyzeDocumentText treats thin repetitive drafts as low-signal", function (assert) { + const text = `Gennaro is bruno always always - if (strengths.length === 0 && (getBlocksOfType(blocks, "heading").length > 0 || bulletTexts.length >= 3)) { - strengths.push(strengthClearStructure()); - } +Why not bannarpo?`; + const analysis = analyzeDocumentText(text); - const score = clampScore( - 48 + - (hasOpeningQuestion ? 10 : 0) + - (hasDirectiveLead ? 10 : 0) + - Math.min(strengths.length, 3) * 8 + - (lexicalVariety >= 0.28 ? 6 : 0) + - (openingWords > 0 && openingWords <= 16 ? 6 : 0) - - (totalWords > 0 && totalWords < 12 ? 18 : 0) - - (passiveRatio >= 0.4 ? 12 : 0) - - findings.length * 8, - ); + assert.ok(analysis.overallScore < 70, "thin repetitive drafts should not receive a flattering overall score"); + assert.ok( + analysis.sections.find((section) => section.title === "Readability")?.lines.some((line) => line.includes("Repeated word")), + "repeated words should produce an explicit finding", + ); + assert.ok( + analysis.sections.find((section) => section.title === "Structure")?.lines.some((line) => line.includes("Draft is too thin")), + "very short drafts should be flagged as too thin to evaluate well", + ); + assert.ok( + analysis.overview.strengths[0].includes("No clear strengths stand out yet"), + "fallback strengths copy should no longer pretend the draft has a factual backbone", + ); + assert.ok( + analysis.documentSections[0].needsAttention, + "thin lead sections should be marked as needing attention instead of balanced by default", + ); + }); + + QUnit.test("buildDocumentLinterReport produces a stable markdown snapshot", function (assert) { + const text = `Why did the first ports grow so quickly? + +## Signals +- Look for ships +- Look for storage jars +- Look for taxes`; + const analysis = analyzeDocumentText(text); + const report = buildDocumentLinterReport(text, analysis); + + assert.strictEqual( + report, + `# Document Linter Review + +## Overall +- Overall score: 95/100 + +## Quick take +- The opening is understandable, but it could be shaped into a sharper lead sentence. +- The "Lead" section deserves attention: this section is too thin to evaluate well; add a clearer claim or supporting detail. +- There are real strengths here: concrete details and structure cues give the document memory and shape. + +## What to fix first +- No major fixes stood out. + +## What is working +- The opening question creates forward pull and gives the reader a reason to keep going. +- The lead uses clear directive language, which gives the document momentum. +- The parallel list structure creates a strong rhythm and makes the sequence easy to scan. + +## Section notes +### Readability +- No notable issues in this category. + +### Skimmability +- No notable issues in this category. + +### Engagement +- No notable issues in this category. + +### Style +- No notable issues in this category. + +### Structure +- No notable issues in this category. + +## Section analysis +### Lead +- Type: implicit +- Lines: 1–1 +- Needs attention: yes +- This section is too thin to evaluate well; add a clearer claim or supporting detail. + +### Signals +- Type: heading +- Lines: 3–6 +- Needs attention: no +- The heading works, but this section is list-only; a short lead sentence could help orient the reader. + +## Snapshot +- Words: 19 +- Sentences: 1 +- Blocks: 5`, + "markdown export should remain stable for the tested document", + ); + }); + + QUnit.test("empty, single-word, and heading-only documents remain analyzable", function (assert) { + const emptyAnalysis = analyzeDocumentText(""); + const singleWordAnalysis = analyzeDocumentText("Hello"); + const headingOnlyAnalysis = analyzeDocumentText("## Title"); + + assert.strictEqual(emptyAnalysis.documentSections[0].title, "Document", "empty documents should produce a fallback section"); + assert.strictEqual(singleWordAnalysis.overview.quickTake.length > 0, true, "single-word documents should still produce overview copy"); + assert.strictEqual(headingOnlyAnalysis.documentSections[0].title, "Title", "heading-only documents should preserve the heading section"); + }); + + QUnit.test("list items are not miscounted as prose sentences in the exported snapshot", function (assert) { + const text = `- First item. +- Second item. +- Third item.`; + const analysis = analyzeDocumentText(text); + const report = buildDocumentLinterReport(text, analysis); + + assert.strictEqual(countSentences(""), 0, "empty text should have zero sentences"); + assert.ok(report.includes("- Sentences: 0"), "list-only documents should not count list items as prose sentences"); + }); + + QUnit.test("createDocumentLinterController renders results, updates status, and guards empty content", async function (assert) { + const els = createControllerDomRefs(); + const toasts = []; + const statuses = []; + const analysisUpdates = []; + let currentText = ""; + + const controller = createDocumentLinterController({ + els, + getEditorText: () => currentText, + onEditorContentReplaced: () => {}, + showToast: (message, options) => { + toasts.push({ message, options }); + }, + setStatus: (message, kind) => { + statuses.push({ message, kind }); + }, + onAnalysisUpdated: (analysis, revision) => { + analysisUpdates.push({ analysis, revision }); + }, + }); + + controller.setActive(true); + + await controller.analyzeDocument(); + + assert.deepEqual( + toasts[0], + { message: "No content to analyze", options: { persist: true } }, + "empty content should surface a persistent toast", + ); + assert.deepEqual(statuses[0], { message: "No content to analyze", kind: "warn" }, "empty content should set warning status"); + + currentText = `Remember this: +- Start with the treaty +- Start with the ships +- Start with the tax records`; + els.editor.value = currentText; + + await controller.analyzeDocument(); + + assert.strictEqual(els.documentLinterAnalyzeBtn.disabled, false, "analyze button should be re-enabled after analysis"); + assert.strictEqual(els.documentLinterStatus.textContent, "Analysis complete", "status copy should update after analysis"); + assert.ok(els.documentLinterResults.children.length >= 3, "results panel should render summary, categories, and section analysis"); + assert.ok( + els.documentLinterResults.children[0].innerHTML.includes("Overall"), + "summary markup should include the overall score block", + ); + assert.deepEqual(statuses.at(-1), { message: "Analysis complete", kind: "ok" }, "successful analysis should report ok status"); + assert.strictEqual(controller.getLatestAnalysis().overallScore > 0, true, "latest analysis should be available to Cogito"); + assert.strictEqual(controller.getAnalysisRevision(), 1, "analysis revision should advance"); + assert.strictEqual(analysisUpdates.length, 1, "analysis updates should be published to the unified panel orchestrator"); + + els.documentLinterAutoRunToggle.checked = true; + els.documentLinterAutoRunToggle.dispatchEvent({ type: "change" }); + currentText = `${currentText}\n- Start with the market tolls`; + els.editor.value = currentText; + const firstScheduledAnalysis = controller.handleEditorChanged(currentText); + currentText = `${currentText}\n- Finish with the harbor records`; + els.editor.value = currentText; + const secondScheduledAnalysis = controller.handleEditorChanged(currentText); + await Promise.all([firstScheduledAnalysis, secondScheduledAnalysis]); + + assert.strictEqual(els.documentLinterStatus.textContent, "Analysis complete", "rerun on change should trigger a fresh analysis"); + assert.strictEqual(controller.getAnalysisRevision(), 2, "rapid edits should coalesce into one new analysis revision"); + + currentText = "## Summary\nUpdated text"; + els.editor.value = currentText; + await controller.exportSuggestions(); + + assert.deepEqual(statuses.at(-2), { message: "Re-analyzing before export", kind: "warn" }, "export should explicitly announce re-analysis when content changed"); + assert.deepEqual(statuses.at(-1), { message: "Exported report", kind: "ok" }, "export should finish with ok status"); + }); +}); + + + +.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 + +line-count: + git ls-files '*.html' '*.ts' '*.ts' '*.js' '*.scss' '*.css' | xargs wc -l + +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", + "include-v-in-tag": true, + "packages": { + ".": { + "release-type": "node" + } + } +} + + + +function createFakeFileHandle(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() {}, + }; + }, + __read() { + return content; + }, + }; +} + +function createFakeDirectoryHandle(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 = 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; + }, + }; +} + +describe("document linter export", () => { + const workspaceName = "linter-workspace"; + const noteName = "linter-note"; + const noteContent = "# Linter export test\n\nThis sentence is intentionally very long so the readability rule has something to report."; + + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); + + win.prompt = (message) => { + if (message.includes("New note name")) { + return noteName; + } + return null; + }; + + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + win.__linterExportBlobs = []; + win.URL.createObjectURL = (blob) => { + win.__linterExportBlobs.push(blob); + return "blob:linter-report"; + }; + win.URL.revokeObjectURL = () => {}; + }, + }); + }); + + it("exports the current linter report as markdown", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(noteContent); + + cy.get("#documentLinterToggleBtn").should("not.exist"); + cy.get("#cogitoToggleBtn").click(); + cy.get("#cogitoPanel").should("be.visible"); + cy.get("#documentLinterAnalyzeBtn").click(); + cy.get("#statusBadge").should("contain", "Analysis complete"); + + cy.get("#documentLinterExportBtn").click(); + cy.get("#statusBadge").should("contain", "Exported report"); + + cy.window().then(async (win) => { + expect(win.__linterExportBlobs).to.have.length(1); + const report = await win.__linterExportBlobs[0].text(); + expect(report).to.contain("# Document Linter Review"); + expect(report).to.contain("## Overall"); + expect(report).to.contain("Overall score:"); + expect(report).to.contain("Readability"); + expect(report).to.contain("Opening is informative, not directive"); + }); + }); +}); + + + +function dispatchShortcut(target, { key, ctrlKey = false, metaKey = false, shiftKey = false, altKey = false }) { + const view = target.ownerDocument.defaultView; + const event = new view.KeyboardEvent("keydown", { + key, + ctrlKey, + metaKey, + shiftKey, + altKey, + bubbles: true, + cancelable: true, + }); + + target.dispatchEvent(event); +} + +function getPlatformModifier(target) { + const view = target.ownerDocument.defaultView; + const isMac = /Mac|iPod|iPhone|iPad/.test(view.navigator.userAgent); + + return isMac ? { metaKey: true } : { ctrlKey: true }; +} + +describe("mobile fallback functionality", () => { + const fileStem = "test-note"; + const fileName = `${fileStem}.md`; + const markdown = "# Test Note\n\nThis is test content."; + + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + delete win.showDirectoryPicker; + delete win.FileSystemHandle; + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; + win.confirm = () => true; + }, + }); + }); + + it("shows temporary session when FS API is unavailable after clicking Open Workspace", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#workspaceName").should("contain", "Temporary Session"); + cy.get("#temporarySessionBadge").should("be.visible"); + cy.get("#temporarySessionBadge").should("contain", "Temporary Session"); + cy.get("#statusBadge").should("contain", "Temporary session"); + cy.get("#tree").should("contain", "Temporary session"); + }); + + it("allows creating and editing notes in temporary session", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#currentFilename").should("contain", fileName); + + cy.get("#editor").clear().type(markdown); + cy.get("#dirtyDot").should("be.visible"); + + cy.get("[data-action=\"save\"]").click({ force: true }); + cy.get("#dirtyDot").should("not.be.visible"); + cy.get("#statusBadge").should("contain", "Saved"); + + cy.get("#tree").should("contain", fileName); + }); + + it("enables export functionality after creating notes", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + + cy.get("[data-action=\"export-json\"]").should("exist"); + cy.get("[data-action=\"export-markdown\"]").should("exist"); + }); + + it("exports current note as markdown", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); + cy.get("[data-action=\"save\"]").click({ force: true }); + + cy.get("[data-action=\"export-markdown\"]").click({ force: true }); + + cy.readFile("cypress/downloads/test-note.md", { timeout: 5000 }).then((content) => { + expect(content).to.include("# Test Note"); + expect(content).to.include("This is test content."); + }); + }); + + it("exports all notes as JSON", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type("# First Note\n\nContent 1"); + cy.get("[data-action=\"save\"]").click({ force: true }); + + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type("# Second Note\n\nContent 2"); + cy.get("[data-action=\"save\"]").click({ force: true }); + + cy.get("[data-action=\"export-json\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Exported JSON"); + }); + + it("exports JSON from the focused editor with Ctrl/Cmd+Shift+S without saving", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type("# Shortcut Export\n\nUnsaved content"); + cy.get("#dirtyDot").should("be.visible"); + + cy.get("#editor") + .focus() + .then(($editor) => { + dispatchShortcut($editor[0], { + key: "s", + shiftKey: true, + ...getPlatformModifier($editor[0]), + }); + }); + + cy.get("#statusBadge").should("contain", "Exported JSON"); + cy.get("#dirtyDot").should("be.visible"); + }); + + it("saves from the focused editor with the platform save shortcut", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); + cy.get("#dirtyDot").should("be.visible"); + + cy.get("#editor") + .focus() + .then(($editor) => { + dispatchShortcut($editor[0], { + key: "s", + ...getPlatformModifier($editor[0]), + }); + }); + + cy.get("#statusBadge").should("contain", "Saved"); + cy.get("#dirtyDot").should("not.be.visible"); + }); + + it("opens existing note from tree in temporary session", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type("# Original Content"); + cy.get("[data-action=\"save\"]").click({ force: true }); + + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type("# Another Note"); + cy.get("[data-action=\"save\"]").click({ force: true }); + + cy.get(".node").first().click(); + + cy.get("#currentFilename").should("contain", ".md"); + }); + + it("shows unsaved changes indicator when editing", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#currentFilename").should("contain", ".md"); + + cy.get("#editor").type(" - added content", { force: true }); + cy.get("#dirtyDot").should("be.visible"); + }); + + it("renders markdown preview in temporary session", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + + cy.get("#editor").clear().type("## Section\n\nSome **bold** text."); + + cy.get("#preview").find("h2").should("contain", "Section"); + cy.get("#preview").find("strong").should("contain", "bold"); + }); + + it("displays tags after creating notes with hashtags", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type("# Note with work tag"); + cy.get("[data-action=\"save\"]").click({ force: true }); + + cy.get(".tagrow").should("exist"); + }); +}); + + + +function createFakeFileHandle(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() {}, + }; + }, + __read() { + return content; + }, + }; +} + +function createFakeDirectoryHandle(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 = 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 dispatchShortcut(win, { key, ctrlKey = false, shiftKey = false, altKey = false }) { + const event = new win.KeyboardEvent("keydown", { + key, + ctrlKey, + shiftKey, + altKey, + bubbles: true, + }); + win.dispatchEvent(event); +} + +describe("workspace actions regression", () => { + const workspaceName = "workspace-actions"; + const noteName = "note-a"; + const saveAsName = "note-b"; + const markdown = "# Ink regression\n\nSaved content."; + + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + Object.defineProperty(win.navigator, "userAgent", { + value: "Windows NT 10.0", + configurable: true, + }); + + const root = createFakeDirectoryHandle(workspaceName); + + win.prompt = (message) => { + if (message.includes("New note name")) { + return noteName; + } + if (message.includes("Save note as")) { + return saveAsName; + } + if (message.includes("Folder name")) { + return "folder-a"; + } + return null; + }; + + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); + + it("save as creates a new file with the saved content", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#workspaceName").should("contain", workspaceName); + + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); + cy.get("[data-action=\"save\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Saved"); + + cy.get("[data-action=\"save-as\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Saved as"); + cy.get("#currentFilename").should("contain", `${saveAsName}.md`); + + cy.window().then(async (win) => { + const handle = await win.__fakeWorkspace.getFileHandle(`${saveAsName}.md`); + expect(handle.__read()).to.eq(markdown); + }); + }); + + it("refresh button rescans workspace and updates status", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#workspaceName").should("contain", workspaceName); + + cy.get("body").click("topLeft"); + cy.get("body").click("bottomRight"); + cy.get("#refreshBtn").click({ force: true }); + cy.get("#statusBadge").should("contain", "Refreshed"); + }); + + it("opens the folder picker immediately and ignores overlapping open requests", () => { + cy.window().then((win) => { + win.__pickerCalls = 0; + win.showDirectoryPicker = () => { + win.__pickerCalls += 1; + return new Promise((resolve) => { + win.__resolveWorkspacePicker = () => resolve(win.__fakeWorkspace); + }); + }; + }); + + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Choose a workspace folder"); + cy.window().its("__pickerCalls").should("eq", 1); + + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.window().its("__pickerCalls").should("eq", 1); + cy.get("#statusBadge").should("contain", "Folder picker already open"); + + cy.window().then((win) => { + win.__resolveWorkspacePicker(); + }); + cy.get("#statusBadge").should("contain", "Workspace ready"); + }); + + it("loads workspace files concurrently and reads each file once", () => { + cy.window().then((win) => { + const tracker = { active: 0, maximum: 0, calls: 0 }; + win.__workspaceReadTracker = tracker; + + for (let index = 0; index < 32; index += 1) { + const name = `note-${index}.md`; + win.__fakeWorkspace.__entries.set(name, { + kind: "file", + name, + async getFile() { + tracker.calls += 1; + tracker.active += 1; + tracker.maximum = Math.max(tracker.maximum, tracker.active); + await new Promise((resolve) => win.setTimeout(resolve, 20)); + tracker.active -= 1; + return new File([`---\ntags: [batch]\n---\n# Note ${index}`], name, { + type: "text/markdown", + lastModified: index, + }); + }, + }); + } + }); + + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Workspace ready"); + cy.get("#countsPill").should("contain", "32 notes"); + cy.window().then((win) => { + expect(win.__workspaceReadTracker.calls).to.eq(32); + expect(win.__workspaceReadTracker.maximum).to.be.greaterThan(1); + expect(win.__workspaceReadTracker.maximum).to.be.at.most(4); + }); + }); + + it("prioritizes opening a note over an in-progress background scan", () => { + cy.window().then((win) => { + win.__fakeWorkspace.__entries.set( + "priority.md", + createFakeFileHandle("priority.md", "# Priority\n\nOpen me immediately."), + ); + }); + + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#workspaceName").should("contain", workspaceName); + cy.get("#tree .node").contains("priority.md").should("be.visible"); + + cy.window().then((win) => { + const tracker = { calls: 0 }; + win.__slowRefreshTracker = tracker; + for (let index = 0; index < 40; index += 1) { + const name = `slow-${index}.md`; + win.__fakeWorkspace.__entries.set(name, { + kind: "file", + name, + async getFile() { + tracker.calls += 1; + await new Promise((resolve) => win.setTimeout(resolve, 250)); + return new File([`# Slow ${index}`], name, { type: "text/markdown" }); + }, + }); + } + }); + + cy.get("#refreshBtn").click({ force: true }); + cy.get("#tree .node").contains("priority.md").click(); + cy.get("#currentFilename").should("contain", "priority.md"); + cy.get("#editor").should("contain.value", "Open me immediately"); + + cy.wait(350); + cy.window().then((win) => { + expect(win.__slowRefreshTracker.calls).to.be.at.most(4); + }); + }); + + it("close workspace resets UI state", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + + cy.get("[data-action=\"close-workspace\"]").click({ force: true }); + cy.get("#workspaceName").should("contain", "No folder selected"); + cy.get("#tree").should("contain", "Open a folder to begin."); + cy.get("#currentFilename").should("contain", "No note open"); + cy.get("#statusBadge").should("contain", "Ready"); + }); + + it("sort toggle updates labels", () => { + cy.get("#sortBtn").should("contain", "Sort: Name"); + cy.get("[data-action=\"sort\"] .menu-label").should("contain", "Sort: Name"); + + cy.get("#sortBtn").click(); + cy.get("#sortBtn").should("contain", "Sort: Last modified"); + cy.get("[data-action=\"sort\"] .menu-label").should("contain", "Sort: Modified"); + }); + + it("keyboard shortcuts trigger key workspace actions", () => { + cy.window().then((win) => { + dispatchShortcut(win, { key: "o", ctrlKey: true, shiftKey: true }); + }); + cy.get("#workspaceName").should("contain", workspaceName); + + cy.window().then((win) => { + dispatchShortcut(win, { key: "e", ctrlKey: true }); + }); + cy.get("#currentFilename").should("contain", `${noteName}.md`); + + cy.get("#editor").clear().type(markdown); + cy.window().then((win) => { + dispatchShortcut(win, { key: "s", ctrlKey: true }); + }); + cy.get("#statusBadge").should("contain", "Saved"); + + cy.window().then((win) => { + dispatchShortcut(win, { key: "l", ctrlKey: true }); + }); + cy.get("#statusBadge").should("contain", "Refreshed"); + }); +}); + + + +import type { DomRefs } from "../types"; +import { + DOCUMENT_LINTER_FALLBACK_STRENGTH, + DOCUMENT_LINTER_NO_MAJOR_FIXES, + DOCUMENT_LINTER_NO_NOTABLE_ISSUES, + DOCUMENT_LINTER_NO_SECTION_STRUCTURE, + balancedSection, + bulletsAreDense, + denseAbstractLanguage, + explicitSectionLabel, + missingHeading, + openingIsInformativeNotDirective, + openingNeedsStrongerLead, + openingNeedsStructure, + openingTooDense, + quickTakeNeedsScaffold, + quickTakeLowSignal, + questionNeedsAnswer, + repeatedWord, + quickTakeStrengthsDetected, + sectionDenseBulletRun, + sectionDenseBullets, + sectionLabelPromotion, + sectionListNeedsLead, + sectionLong, + sectionLongMaterial, + sectionNeedsAttention, + sectionNeedsHeading, + thinDraft, + thinSection, + strengthClearStructure, + strengthConcreteAnchors, + strengthDirectiveLead, + strengthMnemonic, + strengthParallelList, + strengthQuestionLead, + structureBreakMiddle, + structureSimpleOutline, +} from "./messages"; + +type ToastFn = (message: string, options?: { persist?: boolean }) => void; +type SetStatusFn = (message: string, kind?: "ok" | "warn" | "err") => void; + +type LinterCategoryId = "readability" | "skimmability" | "engagement" | "style" | "structure"; +type Severity = "high" | "medium" | "low"; + +type LinterCategoryResult = { + score: number; + suggestions: string[]; +}; + +type LinterResults = Record; + +export type LinterAnalysis = { + scores: LinterResults; + overallScore: number; + overview: { + quickTake: string[]; + priorities: string[]; + strengths: string[]; + }; + sections: Array<{ + title: string; + notes: string[]; + }>; + documentSections: Array<{ + title: string; + kind: "heading" | "label" | "implicit"; + lineStart: number; + lineEnd: number; + notes: string[]; + needsAttention: boolean; + }>; +}; + +type DocumentLinterController = { + setActive: (isActive: boolean) => void; + handleEditorChanged: (textSnapshot?: string) => Promise; + analyzeDocument: () => Promise; + exportSuggestions: () => Promise; + getLatestAnalysis: () => LinterAnalysis | null; + getAnalysisRevision: () => number; +}; + +type MarkdownBlock = + | { type: "heading"; text: string; lineStart: number; lineEnd: number; level: number } + | { type: "paragraph"; text: string; lineStart: number; lineEnd: number } + | { type: "list_item"; text: string; lineStart: number; lineEnd: number } + | { type: "blockquote"; text: string; lineStart: number; lineEnd: number } + | { type: "code"; text: string; lineStart: number; lineEnd: number }; + +type DocumentSection = { + title: string; + kind: "heading" | "label" | "implicit"; + lineStart: number; + lineEnd: number; + blocks: MarkdownBlock[]; +}; + +type Finding = { + category: LinterCategoryId; + severity: Severity; + title: string; + detail: string; + section: string; + isStrength?: boolean; +}; + +const CATEGORY_META: Array<{ id: LinterCategoryId; title: string; color: string }> = [ + { id: "readability", title: "Readability", color: "#4CAF50" }, + { id: "skimmability", title: "Skimmability", color: "#2196F3" }, + { id: "engagement", title: "Engagement", color: "#FF9800" }, + { id: "style", title: "Style", color: "#9C27B0" }, + { id: "structure", title: "Structure", color: "#607D8B" }, +]; + +const AUTO_RUN_DEBOUNCE_MS = 300; + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, score)); +} + +function countWords(text: string): number { + return text.match(/\b\w+\b/g)?.length ?? 0; +} + +// This tokenizer intentionally prefers deterministic sentence boundaries over perfect +// linguistic coverage. Abbreviations like "e.g." may still count as sentence endings. +export function splitSentences(text: string): string[] { + return text + .replaceAll(/\s+/g, " ") + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); +} + +export function countSentences(text: string): number { + return splitSentences(text).length; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function scoreWeight(severity: Severity): number { + if (severity === "high") { + return 3; + } + if (severity === "medium") { + return 2; + } + return 1; +} + +function createLineButtonMarkup(lineStart: number, label = `Line ${lineStart}`): string { + return ``; +} + +function isFencedCodeStart(line: string): boolean { + return /^```/.test(line.trim()); +} + +function isAtxHeading(line: string): boolean { + return /^#{1,6}\s+/.test(line.trim()); +} + +function isOrderedListItem(line: string): boolean { + return /^\s*\d+[.)]\s+/.test(line); +} + +function isUnorderedListItem(line: string): boolean { + return /^\s*[-*+]\s+/.test(line); +} + +function isListItem(line: string): boolean { + return isUnorderedListItem(line) || isOrderedListItem(line); +} + +function isBlockquoteLine(line: string): boolean { + return /^\s*>\s?/.test(line); +} + +function isIndentedCodeLine(line: string): boolean { + return /^(?:\t| {4,})/.test(line); +} + +function getSectionLabelTitle(block: MarkdownBlock, nextBlock?: MarkdownBlock): string | null { + if (block.type === "paragraph" && /:\s*$/.test(block.text) && nextBlock?.type === "list_item") { + return block.text.replace(/:\s*$/, ""); + } + return null; +} + +function listMarkerPrefix(line: string): string { + return line.replace(/^(\s*(?:[-*+]|\d+[.)]))\s+.*$/, "$1"); +} + +function stripListMarker(line: string): string { + return line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim(); +} + +function normalizeBlockText(lines: string[]): string { + return lines.map((line) => line.trim()).join(" ").replaceAll(/\s+/g, " ").trim(); +} + +export function parseMarkdownBlocks(text: string): MarkdownBlock[] { + const lines = text.split("\n"); + const blocks: MarkdownBlock[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + const lineNumber = i + 1; + + if (trimmed === "") { + i += 1; + continue; + } + + if (isFencedCodeStart(line)) { + const startLine = lineNumber; + const codeLines: string[] = [line]; + i += 1; + while (i < lines.length && !isFencedCodeStart(lines[i])) { + codeLines.push(lines[i]); + i += 1; + } + if (i < lines.length) { + codeLines.push(lines[i]); + i += 1; + } + blocks.push({ + type: "code", + text: codeLines.join("\n"), + lineStart: startLine, + lineEnd: Math.max(startLine, i), + }); + continue; + } + + if (isIndentedCodeLine(line)) { + const startLine = lineNumber; + const codeLines = [line.replace(/^(?:\t| {4})/, "")]; + i += 1; + while (i < lines.length && (isIndentedCodeLine(lines[i]) || lines[i].trim() === "")) { + codeLines.push(lines[i].trim() === "" ? "" : lines[i].replace(/^(?:\t| {4})/, "")); + i += 1; + } + blocks.push({ + type: "code", + text: codeLines.join("\n"), + lineStart: startLine, + lineEnd: i, + }); + continue; + } + + if (i + 1 < lines.length && trimmed && /^(=+|-+)\s*$/.test(lines[i + 1].trim())) { + blocks.push({ + type: "heading", + text: trimmed, + lineStart: lineNumber, + lineEnd: lineNumber + 1, + level: lines[i + 1].trim().startsWith("=") ? 1 : 2, + }); + i += 2; + continue; + } + + if (isAtxHeading(line)) { + const match = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (match) { + blocks.push({ + type: "heading", + text: match[2].trim(), + lineStart: lineNumber, + lineEnd: lineNumber, + level: match[1].length, + }); + } + i += 1; + continue; + } + + if (isListItem(line)) { + const marker = listMarkerPrefix(line); + blocks.push({ + type: "list_item", + text: stripListMarker(line), + lineStart: lineNumber, + lineEnd: lineNumber, + }); + i += 1; + while (i < lines.length) { + const nextLine = lines[i]; + if (nextLine.trim() === "") { + break; + } + if (listMarkerPrefix(nextLine) !== marker || !isListItem(nextLine)) { + break; + } + blocks.push({ + type: "list_item", + text: stripListMarker(nextLine), + lineStart: i + 1, + lineEnd: i + 1, + }); + i += 1; + } + continue; + } + + if (isBlockquoteLine(line)) { + const startLine = lineNumber; + const quoteLines: string[] = []; + while (i < lines.length) { + const currentLine = lines[i]; + if (isBlockquoteLine(currentLine)) { + quoteLines.push(currentLine.replace(/^\s*>\s?/, "").trim()); + i += 1; + continue; + } + if (currentLine.trim() === "" && i + 1 < lines.length && isBlockquoteLine(lines[i + 1])) { + quoteLines.push(""); + i += 1; + continue; + } + break; + } + blocks.push({ + type: "blockquote", + text: normalizeBlockText(quoteLines), + lineStart: startLine, + lineEnd: i, + }); + continue; + } + + const startLine = lineNumber; + const paragraphLines: string[] = [trimmed]; + i += 1; + while (i < lines.length) { + const nextLine = lines[i]; + const nextTrimmed = nextLine.trim(); + if ( + nextTrimmed === "" || + isFencedCodeStart(nextLine) || + isAtxHeading(nextLine) || + isListItem(nextLine) || + isBlockquoteLine(nextLine) || + isIndentedCodeLine(nextLine) || + (i + 1 < lines.length && /^(=+|-+)\s*$/.test(lines[i + 1].trim())) + ) { + break; + } + paragraphLines.push(nextTrimmed); + i += 1; + } + + blocks.push({ + type: "paragraph", + text: paragraphLines.join(" ").trim(), + lineStart: startLine, + lineEnd: startLine + paragraphLines.length - 1, + }); + } - return { - result: { - score, - suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), - }, - findings, - strengths, - }; + return blocks; } -function analyzeStyle(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { - const prose = getPlainTextBlocks(blocks).map((block) => block.text).join(" "); - const nominalizations = prose.match(/\b\w+(?:tion|sion|ment|ness|ance|ence)\b/gi) ?? []; - const findings: Finding[] = []; - const repeatedWordMatch = findRepeatedWord(prose); +function getBlocksOfType(blocks: MarkdownBlock[], type: MarkdownBlock["type"]): MarkdownBlock[] { + return blocks.filter((block) => block.type === type); +} - const uniqueNominalizations = [...new Set(nominalizations.map((word) => word.toLowerCase()))]; - if (uniqueNominalizations.length >= 4) { - findings.push( - createFinding( - "style", - "low", - "Dense abstract language", - denseAbstractLanguage(), - "Document", - ), - ); - } +function getPlainTextBlocks(blocks: MarkdownBlock[]): Array> { + return blocks.filter((block): block is Extract => { + return block.type === "paragraph" || block.type === "blockquote"; + }); +} - if (repeatedWordMatch) { - findings.unshift( - createFinding( - "style", - "medium", - "Repeated word", - repeatedWord(), - "Document", - ), - ); +function findRepeatedWord(text: string): { word: string; index: number } | null { + const match = text.match(/\b([a-z][a-z'-]{1,})\b(?:\s+\1\b)/i); + if (!match || typeof match.index !== "number") { + return null; } - - const score = clampScore(96 - Math.min(uniqueNominalizations.length, 4) * 2 - (repeatedWordMatch ? 14 : 0)); - - return { - result: { - score, - suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), - }, - findings, - }; + return { word: match[1], index: match.index }; } -function analyzeStructure(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { - const headings = getBlocksOfType(blocks, "heading"); - const bullets = getBlocksOfType(blocks, "list_item"); - const paragraphs = getBlocksOfType(blocks, "paragraph"); - const findings: Finding[] = []; - const totalWords = countWords(blocks.map((block) => block.text).join(" ")); +function createFinding( + category: LinterCategoryId, + severity: Severity, + title: string, + detail: string, + section: string, + isStrength = false, +): Finding { + return { category, severity, title, detail, section, isStrength }; +} - if (headings.length === 0 && paragraphs.length > 0 && bullets.length > 0) { - findings.push( - createFinding( - "structure", - "high", - "Add a simple outline", - structureSimpleOutline(), - "Document", - ), - ); +function summarizeOpeners(blocks: MarkdownBlock[]): string { + const firstParagraph = blocks.find((block) => block.type === "paragraph"); + if (!firstParagraph) { + return openingNeedsStructure(); } - if (bullets.length >= 5 && headings.length === 0) { - findings.push( - createFinding( - "structure", - "medium", - "Break up the middle", - structureBreakMiddle(), - "Bullet list", - ), - ); + if (countWords(firstParagraph.text) >= 22) { + return openingTooDense(); } - if (totalWords > 0 && totalWords < 12) { - findings.push( - createFinding( - "structure", - "high", - "Draft is too thin", - thinDraft(), - "Document", - ), - ); + if (/^(this|these)\b/i.test(firstParagraph.text)) { + return openingIsInformativeNotDirective(); } - const score = clampScore(86 - (headings.length === 0 ? 12 : 0) - (bullets.length >= 5 ? 8 : 0) - (totalWords > 0 && totalWords < 12 ? 22 : 0)); - - return { - result: { - score, - suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), - }, - findings, - }; + return openingNeedsStrongerLead(); } -function analyzeDocumentSections(sections: DocumentSection[]): { - findings: Finding[]; - sectionNotes: Array<{ - title: string; - kind: DocumentSection["kind"]; - lineStart: number; - lineEnd: number; - notes: string[]; - needsAttention: boolean; - }>; -} { - const findings: Finding[] = []; - const sectionNotes = sections.map((section) => { - const text = section.blocks.map((block) => block.text).join(" "); - const bullets = section.blocks.filter((block) => block.type === "list_item"); - const proseBlocks = section.blocks.filter((block) => block.type === "paragraph" || block.type === "blockquote"); - const words = countWords(text); - const averageBulletWords = - bullets.length > 0 ? bullets.reduce((sum, block) => sum + countWords(block.text), 0) / bullets.length : 0; - const notes: string[] = []; - let needsAttention = false; - - if (section.kind === "label") { - notes.push(sectionLabelPromotion(section.title)); - needsAttention = true; - findings.push( - createFinding( - "structure", - "medium", - "Convert label into heading", - sectionNeedsHeading(section.title), - `Line ${section.lineStart}`, - ), - ); - } - - if (bullets.length >= 4 && averageBulletWords >= 11) { - notes.push(sectionDenseBulletRun()); - needsAttention = true; - findings.push( - createFinding( - "structure", - "medium", - "Dense bullet section", - sectionDenseBullets(section.lineStart, bullets.length), - `Line ${section.lineStart}`, - ), - ); - } - - if (words >= 90 && proseBlocks.length >= 2) { - notes.push(sectionLongMaterial()); - needsAttention = true; - findings.push( - createFinding( - "structure", - "low", - "Long section", - sectionLong(section.lineStart), - `Line ${section.lineStart}`, - ), - ); - } - - if (section.kind === "heading" && bullets.length > 0 && proseBlocks.length === 0) { - notes.push(sectionListNeedsLead()); - } - - if (words > 0 && words < 12 && proseBlocks.length > 0) { - notes.push(thinSection()); - needsAttention = true; - } - - if (notes.length === 0) { - notes.push(balancedSection()); +function detectPseudoSectionLabel(blocks: MarkdownBlock[]): MarkdownBlock | null { + for (let i = 0; i < blocks.length - 1; i += 1) { + const block = blocks[i]; + const nextBlock = blocks[i + 1]; + if (getSectionLabelTitle(block, nextBlock)) { + return block; } - - return { - title: section.title, - kind: section.kind, - lineStart: section.lineStart, - lineEnd: section.lineEnd, - notes, - needsAttention, - }; - }); - - return { findings, sectionNotes }; + } + return null; } -function buildOverview( - findings: Finding[], - strengths: string[], - blocks: MarkdownBlock[], - documentSections: Array<{ - title: string; - kind: DocumentSection["kind"]; - lineStart: number; - lineEnd: number; - notes: string[]; - needsAttention: boolean; - }>, -): LinterAnalysis["overview"] { - const totalWords = countWords(blocks.map((block) => block.text).join(" ")); - const priorities = findings - .filter((finding) => !finding.isStrength) - .slice() - .sort((a, b) => scoreWeight(b.severity) - scoreWeight(a.severity)) - .slice(0, 4) - .map((finding) => `${finding.title} — ${finding.detail}`); +export function buildDocumentSections(blocks: MarkdownBlock[]): DocumentSection[] { + const sections: DocumentSection[] = []; + let currentSection: DocumentSection | null = null; - const quickTake = [summarizeOpeners(blocks)]; - const sectionThatNeedsAttention = documentSections.find((section) => section.needsAttention); - if (sectionThatNeedsAttention) { - quickTake.push(sectionNeedsAttention(sectionThatNeedsAttention.title, sectionThatNeedsAttention.notes[0])); + function startSection(title: string, kind: DocumentSection["kind"], lineStart: number): DocumentSection { + const nextSection: DocumentSection = { + title, + kind, + lineStart, + lineEnd: lineStart, + blocks: [], + }; + sections.push(nextSection); + currentSection = nextSection; + return nextSection; } - if (strengths.length > 0) { - quickTake.push(quickTakeStrengthsDetected()); - } else if (totalWords > 0 && totalWords < 12) { - quickTake.push(quickTakeLowSignal()); - } else { - quickTake.push(quickTakeNeedsScaffold()); + + function ensureLeadSection(block: MarkdownBlock): DocumentSection { + if (currentSection) { + return currentSection; + } + return startSection("Lead", "implicit", block.lineStart); } - return { - quickTake, - priorities, - strengths: strengths.length > 0 ? strengths : [DOCUMENT_LINTER_FALLBACK_STRENGTH], - }; -} + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + const nextBlock = blocks[i + 1]; -function computeOverallScore(scores: LinterResults): number { - const weights: Record = { - readability: 0.25, - skimmability: 0.2, - engagement: 0.2, - style: 0.15, - structure: 0.2, - }; - const total = Object.entries(scores).reduce((sum, [categoryId, result]) => { - return sum + result.score * weights[categoryId as LinterCategoryId]; - }, 0); - return clampScore(Math.round(total)); -} + if (block.type === "heading") { + startSection(block.text, "heading", block.lineStart); + continue; + } -function restoreResultsPanelState(resultsContainer: HTMLElement, scrollTop: number, focusKey: string | null): void { - resultsContainer.scrollTop = scrollTop; - if (!focusKey) { - return; + const labelTitle = getSectionLabelTitle(block, nextBlock); + if (labelTitle) { + startSection(labelTitle, "label", block.lineStart); + continue; + } + + const activeSection = ensureLeadSection(block); + activeSection.blocks.push(block); + activeSection.lineEnd = block.lineEnd; } - if (typeof resultsContainer.querySelector !== "function") { - return; + + if (sections.length === 0) { + return [ + { + title: "Document", + kind: "implicit", + lineStart: 1, + lineEnd: 1, + blocks: [], + }, + ]; } - const nextFocusTarget = resultsContainer.querySelector(`[data-linter-focus-key="${focusKey}"]`); - nextFocusTarget?.focus(); -} -function isElementLike(value: unknown): value is { getAttribute: (name: string) => string | null; closest?: (selector: string) => unknown } { - return typeof value === "object" && value !== null && "getAttribute" in value; + return sections; } -export function analyzeDocumentText(text: string): LinterAnalysis { - const blocks = parseMarkdownBlocks(text); - const documentSections = buildDocumentSections(blocks); - - const readability = analyzeReadability(blocks); - const skimmability = analyzeSkimmability(blocks); - const engagement = analyzeEngagement(blocks); - const style = analyzeStyle(blocks); - const structure = analyzeStructure(blocks); - const sectionAnalysis = analyzeDocumentSections(documentSections); +function analyzeReadability(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { + const proseBlocks = getPlainTextBlocks(blocks); + const findings: Finding[] = []; + let longSentenceCount = 0; + let repeatedWordCount = 0; - const allFindings = [ - ...readability.findings, - ...skimmability.findings, - ...engagement.findings, - ...style.findings, - ...structure.findings, - ...sectionAnalysis.findings, - ]; + for (const block of proseBlocks) { + const sentences = splitSentences(block.text); + const repeatedWordMatch = findRepeatedWord(block.text); + if (repeatedWordMatch) { + repeatedWordCount += 1; + if (!findings.some((finding) => finding.title === "Repeated word")) { + findings.push( + createFinding( + "readability", + "medium", + "Repeated word", + repeatedWord(), + `Line ${block.lineStart}`, + ), + ); + } + } + for (const sentence of sentences) { + const wordCount = countWords(sentence); + const charCount = sentence.length; + if (wordCount >= 24 || charCount >= 140) { + longSentenceCount += 1; + if (findings.length < 3) { + const snippet = sentence.length > 70 ? `${sentence.slice(0, 70)}...` : sentence; + findings.push( + createFinding( + "readability", + "medium", + "Dense sentence", + `This sentence is carrying too much at once: "${snippet}". Split the idea or move the setup into a heading.`, + `Line ${block.lineStart}`, + ), + ); + } + } + } + } - const sectionNotes = [ - { - title: "Readability", - lines: readability.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), - }, - { - title: "Skimmability", - lines: skimmability.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), - }, - { - title: "Engagement", - lines: engagement.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), - }, - { - title: "Style", - lines: style.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), - }, - { - title: "Structure", - lines: structure.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), - }, - ]; + const openingBlock = proseBlocks[0]; + if (openingBlock && countWords(openingBlock.text) >= 22) { + findings.unshift( + createFinding( + "readability", + "high", + "Opening is dense", + openingTooDense(), + `Line ${openingBlock.lineStart}`, + ), + ); + } - const scores = { - readability: readability.result, - skimmability: skimmability.result, - engagement: engagement.result, - style: style.result, - structure: structure.result, - }; + const score = clampScore( + 100 - + longSentenceCount * 14 - + repeatedWordCount * 10 - + (openingBlock && countWords(openingBlock.text) >= 22 ? 8 : 0), + ); return { - scores, - overallScore: computeOverallScore(scores), - overview: buildOverview(allFindings, engagement.strengths, blocks, sectionAnalysis.sectionNotes), - sections: sectionNotes, - documentSections: sectionAnalysis.sectionNotes, + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, }; } -export function buildDocumentLinterReport(text: string, analysis: LinterAnalysis): string { - const blocks = parseMarkdownBlocks(text); - const wordCount = countWords(text); - const sentenceCount = countSentences(getPlainTextBlocks(blocks).map((block) => block.text).join(" ")); - const sections = CATEGORY_META.map((category) => { - const section = analysis.sections.find((entry) => entry.title === category.title); - return { - title: category.title, - lines: section?.lines?.length ? section.lines : [`- ${DOCUMENT_LINTER_NO_NOTABLE_ISSUES}`], - }; - }); - const documentSections = analysis.documentSections.length > 0 - ? analysis.documentSections - : [ - { - title: "Document", - kind: "implicit" as const, - lineStart: 1, - lineEnd: 1, - notes: [DOCUMENT_LINTER_NO_SECTION_STRUCTURE], - needsAttention: false, - }, - ]; +function analyzeSkimmability( + blocks: MarkdownBlock[], +): { result: LinterCategoryResult; findings: Finding[]; pseudoSectionLabel: MarkdownBlock | null } { + const headings = getBlocksOfType(blocks, "heading"); + const bullets = getBlocksOfType(blocks, "list_item"); + const pseudoSectionLabel = detectPseudoSectionLabel(blocks); + const findings: Finding[] = []; + + if (headings.length === 0) { + findings.push( + createFinding( + "skimmability", + "high", + "Missing heading", + missingHeading(), + "Document", + ), + ); + } + + if (pseudoSectionLabel) { + findings.push( + createFinding( + "skimmability", + "medium", + "Make the section label explicit", + explicitSectionLabel(pseudoSectionLabel.text), + `Line ${pseudoSectionLabel.lineStart}`, + ), + ); + } + + if (bullets.length >= 4) { + const averageBulletWords = bullets.reduce((sum, block) => sum + countWords(block.text), 0) / bullets.length; + if (averageBulletWords >= 11) { + findings.push( + createFinding( + "skimmability", + "medium", + "Bullets are dense", + bulletsAreDense(), + "Bullet list", + ), + ); + } + } - const lines = [ - "# Document Linter Review", - "", - "## Overall", - `- Overall score: ${analysis.overallScore}/100`, - "", - "## Quick take", - ...analysis.overview.quickTake.map((line) => `- ${line}`), - "", - "## What to fix first", - ...(analysis.overview.priorities.length > 0 - ? analysis.overview.priorities.map((priority, index) => `${index + 1}. ${priority}`) - : [`- ${DOCUMENT_LINTER_NO_MAJOR_FIXES}`]), - "", - "## What is working", - ...analysis.overview.strengths.map((strength) => `- ${strength}`), - "", - "## Section notes", - ...sections.flatMap((section) => [ - `### ${section.title}`, - ...section.lines, - "", - ]), - "## Section analysis", - ...documentSections.flatMap((section) => [ - `### ${section.title}`, - `- Type: ${section.kind}`, - `- Lines: ${section.lineStart}–${section.lineEnd}`, - `- Needs attention: ${section.needsAttention ? "yes" : "no"}`, - ...section.notes.map((note) => `- ${note}`), - "", - ]), - "## Snapshot", - `- Words: ${wordCount}`, - `- Sentences: ${sentenceCount}`, - `- Blocks: ${blocks.length}`, - ]; + const score = clampScore( + 100 - + (headings.length === 0 ? 20 : 0) - + (pseudoSectionLabel ? 6 : 0) - + (bullets.length >= 4 ? 10 : 0), + ); - return lines.join("\n"); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + pseudoSectionLabel, + }; } -export function createDocumentLinterController({ - els, - getEditorText, - onEditorContentReplaced: _onEditorContentReplaced, - showToast, - setStatus, - onAnalysisUpdated, -}: { - els: DomRefs; - getEditorText: () => string; - onEditorContentReplaced: (text: string) => void; - showToast: ToastFn; - setStatus: SetStatusFn; - onAnalysisUpdated?: (analysis: LinterAnalysis, revision: number) => void; -}): DocumentLinterController { - let isActive = false; - let isAnalyzing = false; - let autoRunEnabled = false; - let lastAnalysis: LinterAnalysis | null = null; - let lastTextSnapshot = ""; - let analysisRevision = 0; - let autoRunTimer: ReturnType | null = null; - let autoRunResolvers: Array<() => void> = []; +function analyzeEngagement( + blocks: MarkdownBlock[], +): { result: LinterCategoryResult; findings: Finding[]; strengths: string[] } { + const text = blocks.map((block) => block.text).join(" "); + const firstParagraph = blocks.find((block) => block.type === "paragraph"); + const openings = firstParagraph?.text ?? ""; + const findings: Finding[] = []; + const strengths: string[] = []; + const totalWords = countWords(text); - async function runAnalysis(textSnapshot: string = getEditorText()): Promise { - return Promise.resolve(analyzeDocumentText(textSnapshot)); + const openingSentence = splitSentences(openings)[0] ?? openings; + const openingWords = countWords(openingSentence); + const hasOpeningQuestion = /\?\s*$/.test(openingSentence); + const hasDirectiveLead = /^(remember|consider|notice|start|imagine|picture|look|think)\b/i.test(openingSentence); + const passiveMatches = text.match(/\b(?:was|were|is|are|be|been|being)\s+\w+ed\b/gi) ?? []; + const sentences = splitSentences(text); + const passiveRatio = sentences.length > 0 ? passiveMatches.length / sentences.length : 0; + const uniqueWords = new Set((text.toLowerCase().match(/\b[a-z][a-z'-]+\b/g) ?? []).filter((word) => word.length >= 4)); + const lexicalVariety = countWords(text) > 0 ? uniqueWords.size / countWords(text) : 0; + const directiveMatches = text.match(/\b(?:remember|consider|notice|focus|compare|look|keep|start)\b/gi) ?? []; + const concreteAnchorMatches = text.match(/\b(?:\d{2,4}|[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g) ?? []; + const mnemonicMatches = text.match(/\b(?:remember|key takeaway|simple way to remember|think of|in short)\b/gi) ?? []; + const bulletTexts = getBlocksOfType(blocks, "list_item").map((block) => block.text); + const bulletStarts = bulletTexts.map((bullet) => (bullet.match(/^[A-Za-z]+/)?.[0] ?? "").toLowerCase()).filter(Boolean); + const repeatedBulletStart = bulletStarts.find((start, index) => start && bulletStarts.indexOf(start) !== index); + + if (/^(this|these)\b/i.test(openingSentence) && openingWords >= 8) { + findings.push( + createFinding( + "engagement", + "medium", + "Opening is informative, not directive", + openingIsInformativeNotDirective(), + `Line ${firstParagraph?.lineStart ?? 1}`, + ), + ); } - function resolveScheduledAutoRun(): void { - const resolvers = autoRunResolvers; - autoRunResolvers = []; - resolvers.forEach((resolve) => resolve()); + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "engagement", + "high", + "Too little context", + thinDraft(), + `Line ${firstParagraph?.lineStart ?? 1}`, + ), + ); } - function cancelScheduledAutoRun(): void { - if (autoRunTimer !== null) { - clearTimeout(autoRunTimer); - autoRunTimer = null; - } - resolveScheduledAutoRun(); + if (hasOpeningQuestion && totalWords < 18) { + findings.push( + createFinding( + "engagement", + "medium", + "Question needs payoff", + questionNeedsAnswer(), + `Line ${firstParagraph?.lineStart ?? 1}`, + ), + ); } - function scrollEditorToLine(lineNumber: number): void { - const lines = els.editor.value.split("\n"); - const clampedLine = Math.max(1, Math.min(lineNumber, lines.length || 1)); - let selectionStart = 0; - for (let lineIndex = 0; lineIndex < clampedLine - 1; lineIndex += 1) { - selectionStart += lines[lineIndex].length + 1; - } - els.editor.focus(); - els.editor.setSelectionRange(selectionStart, selectionStart); - const computedLineHeight = typeof window.getComputedStyle === "function" - ? Number.parseFloat(window.getComputedStyle(els.editor).lineHeight) - : Number.NaN; - const lineHeight = Number.isFinite(computedLineHeight) ? computedLineHeight : 20; - els.editor.scrollTop = Math.max(0, (clampedLine - 1) * lineHeight); + if (hasOpeningQuestion) { + strengths.push(strengthQuestionLead()); } - function updateResultsPanel(analysis: LinterAnalysis): void { - const resultsContainer = els.documentLinterResults; - const previousScrollTop = resultsContainer.scrollTop; - const activeElement = isElementLike(document.activeElement) ? document.activeElement : null; - const focusKey = activeElement?.closest?.("#documentLinterResults") - ? activeElement.getAttribute("data-linter-focus-key") - : null; - resultsContainer.innerHTML = ""; + if (hasDirectiveLead || directiveMatches.length >= 2) { + strengths.push(strengthDirectiveLead()); + } - const summary = document.createElement("section"); - summary.className = "documentLinterSummary"; - summary.innerHTML = ` -
-

Overall

-
${analysis.overallScore}/100
-
-
-

Quick take

-
    - ${analysis.overview.quickTake.map((line) => `
  • ${escapeHtml(line)}
  • `).join("")} -
-
-
-

What to fix first

-
    - ${analysis.overview.priorities.map((line) => `
  1. ${escapeHtml(line)}
  2. `).join("") || `
  3. ${escapeHtml(DOCUMENT_LINTER_NO_MAJOR_FIXES)}
  4. `} -
-
-
-

What is working

-
    - ${analysis.overview.strengths.map((line) => `
  • ${escapeHtml(line)}
  • `).join("")} -
-
- `; - resultsContainer.appendChild(summary); + if (mnemonicMatches.length > 0) { + strengths.push(strengthMnemonic()); + } - CATEGORY_META.forEach((category) => { - const result = analysis.scores[category.id]; - const sectionNotes = analysis.sections.find((section) => section.title === category.title)?.lines ?? []; - const categoryElement = document.createElement("div"); - categoryElement.className = "documentLinterCategory"; - categoryElement.innerHTML = ` -
-

${category.title}

-
${result.score}/100
-
-
- ${ - sectionNotes.length > 0 - ? sectionNotes.map((note) => `
${escapeHtml(note)}
`).join("") - : `
${escapeHtml(DOCUMENT_LINTER_NO_NOTABLE_ISSUES)}
` - } -
- `; - resultsContainer.appendChild(categoryElement); - }); + if (concreteAnchorMatches.length >= 2) { + strengths.push(strengthConcreteAnchors()); + } - const sectionAnalysis = document.createElement("section"); - sectionAnalysis.className = "documentLinterSectionAnalysis"; - sectionAnalysis.innerHTML = ` -

Section analysis

-
- ${ - analysis.documentSections.length > 0 - ? analysis.documentSections - .map( - (section) => ` -
-
-

${escapeHtml(section.title)}

- ${escapeHtml(section.kind)} · ${createLineButtonMarkup(section.lineStart, `Lines ${section.lineStart}–${section.lineEnd}`)} · ${section.needsAttention ? "needs attention" : "stable"} -
-
- ${section.notes.map((note) => `
${escapeHtml(note)}
`).join("")} -
-
- `, - ) - .join("") - : `
${escapeHtml( - DOCUMENT_LINTER_NO_SECTION_STRUCTURE, - )}
` - } -
- `; - resultsContainer.appendChild(sectionAnalysis); - restoreResultsPanelState(resultsContainer, previousScrollTop, focusKey); + if (repeatedBulletStart && bulletTexts.length >= 3) { + strengths.push(strengthParallelList()); + } + + if (strengths.length === 0 && (getBlocksOfType(blocks, "heading").length > 0 || bulletTexts.length >= 3)) { + strengths.push(strengthClearStructure()); } - function downloadMarkdownReport(markdown: string): void { - const blob = new Blob([markdown], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const dateStamp = new Date().toISOString().split("T")[0]; - const fileName = `ink-linter-report-${dateStamp}.md`; + const score = clampScore( + 48 + + (hasOpeningQuestion ? 10 : 0) + + (hasDirectiveLead ? 10 : 0) + + Math.min(strengths.length, 3) * 8 + + (lexicalVariety >= 0.28 ? 6 : 0) + + (openingWords > 0 && openingWords <= 16 ? 6 : 0) - + (totalWords > 0 && totalWords < 12 ? 18 : 0) - + (passiveRatio >= 0.4 ? 12 : 0) - + findings.length * 8, + ); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + strengths, + }; +} + +function analyzeStyle(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { + const prose = getPlainTextBlocks(blocks).map((block) => block.text).join(" "); + const nominalizations = prose.match(/\b\w+(?:tion|sion|ment|ness|ance|ence)\b/gi) ?? []; + const findings: Finding[] = []; + const repeatedWordMatch = findRepeatedWord(prose); + + const uniqueNominalizations = [...new Set(nominalizations.map((word) => word.toLowerCase()))]; + if (uniqueNominalizations.length >= 4) { + findings.push( + createFinding( + "style", + "low", + "Dense abstract language", + denseAbstractLanguage(), + "Document", + ), + ); } - async function analyzeDocumentAction(): Promise { - if (isAnalyzing) { - return; - } + if (repeatedWordMatch) { + findings.unshift( + createFinding( + "style", + "medium", + "Repeated word", + repeatedWord(), + "Document", + ), + ); + } - const text = getEditorText(); - if (!text.trim()) { - showToast("No content to analyze", { persist: true }); - setStatus("No content to analyze", "warn"); - return; - } + const score = clampScore(96 - Math.min(uniqueNominalizations.length, 4) * 2 - (repeatedWordMatch ? 14 : 0)); - isAnalyzing = true; - lastTextSnapshot = text; - els.documentLinterAnalyzeBtn.disabled = true; - els.documentLinterStatus.textContent = "Analyzing document..."; + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + }; +} - try { - const analysis = await runAnalysis(text); - lastAnalysis = analysis; - analysisRevision += 1; - updateResultsPanel(analysis); - els.documentLinterStatus.textContent = "Analysis complete"; - onAnalysisUpdated?.(analysis, analysisRevision); - showToast("Document analysis completed"); - setStatus("Analysis complete", "ok"); - } catch (error) { - els.documentLinterStatus.textContent = "Analysis failed"; - showToast(`Document analysis failed: ${String(error)}`, { persist: true }); - setStatus("Analysis failed", "err"); - } finally { - isAnalyzing = false; - els.documentLinterAnalyzeBtn.disabled = false; - } +function analyzeStructure(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { + const headings = getBlocksOfType(blocks, "heading"); + const bullets = getBlocksOfType(blocks, "list_item"); + const paragraphs = getBlocksOfType(blocks, "paragraph"); + const findings: Finding[] = []; + const totalWords = countWords(blocks.map((block) => block.text).join(" ")); + + if (headings.length === 0 && paragraphs.length > 0 && bullets.length > 0) { + findings.push( + createFinding( + "structure", + "high", + "Add a simple outline", + structureSimpleOutline(), + "Document", + ), + ); } - async function exportSuggestionsAction(): Promise { - const text = getEditorText(); - if (!text.trim()) { - showToast("No content to export", { persist: true }); - setStatus("No content to export", "warn"); - return; - } + if (bullets.length >= 5 && headings.length === 0) { + findings.push( + createFinding( + "structure", + "medium", + "Break up the middle", + structureBreakMiddle(), + "Bullet list", + ), + ); + } - try { - const needsFreshAnalysis = text !== lastTextSnapshot || !lastAnalysis; - if (needsFreshAnalysis) { - els.documentLinterStatus.textContent = "Document changed; re-analyzing before export..."; - setStatus("Re-analyzing before export", "warn"); - } - const analysis = needsFreshAnalysis ? await runAnalysis(text) : lastAnalysis; - lastAnalysis = analysis; - lastTextSnapshot = text; - if (needsFreshAnalysis) { - analysisRevision += 1; - updateResultsPanel(analysis); - onAnalysisUpdated?.(analysis, analysisRevision); - } - const report = buildDocumentLinterReport(text, analysis); - downloadMarkdownReport(report); - showToast("Exported linter review as Markdown."); - setStatus("Exported report", "ok"); - } catch (error) { - showToast(`Failed to export linter report: ${String(error)}`, { persist: true }); - setStatus("Export failed", "err"); - } + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "structure", + "high", + "Draft is too thin", + thinDraft(), + "Document", + ), + ); } - async function handleEditorChanged(textSnapshot: string = getEditorText()): Promise { - if (!isActive || !autoRunEnabled || isAnalyzing || !textSnapshot.trim()) { - cancelScheduledAutoRun(); - if (!textSnapshot.trim() && autoRunEnabled && isActive) { - lastAnalysis = null; - lastTextSnapshot = textSnapshot; - els.documentLinterStatus.textContent = "No content to analyze"; - } - return Promise.resolve(); - } - if (textSnapshot === lastTextSnapshot) { - return Promise.resolve(); + const score = clampScore(86 - (headings.length === 0 ? 12 : 0) - (bullets.length >= 5 ? 8 : 0) - (totalWords > 0 && totalWords < 12 ? 22 : 0)); + + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + }; +} + +function analyzeDocumentSections(sections: DocumentSection[]): { + findings: Finding[]; + sectionNotes: Array<{ + title: string; + kind: DocumentSection["kind"]; + lineStart: number; + lineEnd: number; + notes: string[]; + needsAttention: boolean; + }>; +} { + const findings: Finding[] = []; + const sectionNotes = sections.map((section) => { + const text = section.blocks.map((block) => block.text).join(" "); + const bullets = section.blocks.filter((block) => block.type === "list_item"); + const proseBlocks = section.blocks.filter((block) => block.type === "paragraph" || block.type === "blockquote"); + const words = countWords(text); + const averageBulletWords = + bullets.length > 0 ? bullets.reduce((sum, block) => sum + countWords(block.text), 0) / bullets.length : 0; + const notes: string[] = []; + let needsAttention = false; + + if (section.kind === "label") { + notes.push(sectionLabelPromotion(section.title)); + needsAttention = true; + findings.push( + createFinding( + "structure", + "medium", + "Convert label into heading", + sectionNeedsHeading(section.title), + `Line ${section.lineStart}`, + ), + ); } - if (autoRunTimer !== null) { - clearTimeout(autoRunTimer); + if (bullets.length >= 4 && averageBulletWords >= 11) { + notes.push(sectionDenseBulletRun()); + needsAttention = true; + findings.push( + createFinding( + "structure", + "medium", + "Dense bullet section", + sectionDenseBullets(section.lineStart, bullets.length), + `Line ${section.lineStart}`, + ), + ); } - els.documentLinterStatus.textContent = "Waiting for typing to pause..."; - return new Promise((resolve) => { - autoRunResolvers.push(resolve); - autoRunTimer = setTimeout(() => { - autoRunTimer = null; - const shouldAnalyze = isActive && autoRunEnabled && textSnapshot !== lastTextSnapshot; - const analysisPromise = shouldAnalyze ? analyzeDocumentAction() : Promise.resolve(); - analysisPromise.finally(resolveScheduledAutoRun); - }, AUTO_RUN_DEBOUNCE_MS); - }); - } + if (words >= 90 && proseBlocks.length >= 2) { + notes.push(sectionLongMaterial()); + needsAttention = true; + findings.push( + createFinding( + "structure", + "low", + "Long section", + sectionLong(section.lineStart), + `Line ${section.lineStart}`, + ), + ); + } - els.documentLinterAutoRunToggle.checked = autoRunEnabled; - els.documentLinterAutoRunToggle.addEventListener("change", () => { - autoRunEnabled = els.documentLinterAutoRunToggle.checked; - if (!autoRunEnabled) { - cancelScheduledAutoRun(); + if (section.kind === "heading" && bullets.length > 0 && proseBlocks.length === 0) { + notes.push(sectionListNeedsLead()); } - els.documentLinterStatus.textContent = autoRunEnabled - ? "Rerun on change enabled" - : "Rerun on change disabled"; - }); - els.documentLinterResults.addEventListener("click", (event: Event) => { - const target = event.target as HTMLElement | null; - const lineButton = target?.closest("[data-linter-line]"); - if (!lineButton) { - return; + if (words > 0 && words < 12 && proseBlocks.length > 0) { + notes.push(thinSection()); + needsAttention = true; } - const lineNumber = Number(lineButton.getAttribute("data-linter-line")); - if (Number.isNaN(lineNumber)) { - return; + + if (notes.length === 0) { + notes.push(balancedSection()); } - scrollEditorToLine(lineNumber); + + return { + title: section.title, + kind: section.kind, + lineStart: section.lineStart, + lineEnd: section.lineEnd, + notes, + needsAttention, + }; }); - return { - setActive: (nextIsActive: boolean) => { - isActive = nextIsActive; - if (!nextIsActive) { - cancelScheduledAutoRun(); - } - if (nextIsActive && !lastAnalysis) { - els.documentLinterStatus.textContent = "Ready to analyze document"; - } - }, - handleEditorChanged, - analyzeDocument: analyzeDocumentAction, - exportSuggestions: exportSuggestionsAction, - getLatestAnalysis: () => lastAnalysis, - getAnalysisRevision: () => analysisRevision, - }; + return { findings, sectionNotes }; } -
- -import { marked } from "marked"; -import { escapeHtml } from "./utils"; -import type { AppState, DomRefs } from "./types"; -import type { StatusKind } from "./toast-status"; +function buildOverview( + findings: Finding[], + strengths: string[], + blocks: MarkdownBlock[], + documentSections: Array<{ + title: string; + kind: DocumentSection["kind"]; + lineStart: number; + lineEnd: number; + notes: string[]; + needsAttention: boolean; + }>, +): LinterAnalysis["overview"] { + const totalWords = countWords(blocks.map((block) => block.text).join(" ")); + const priorities = findings + .filter((finding) => !finding.isStrength) + .slice() + .sort((a, b) => scoreWeight(b.severity) - scoreWeight(a.severity)) + .slice(0, 4) + .map((finding) => `${finding.title} — ${finding.detail}`); -export type EditorViewMode = "split" | "source" | "preview"; + const quickTake = [summarizeOpeners(blocks)]; + const sectionThatNeedsAttention = documentSections.find((section) => section.needsAttention); + if (sectionThatNeedsAttention) { + quickTake.push(sectionNeedsAttention(sectionThatNeedsAttention.title, sectionThatNeedsAttention.notes[0])); + } + if (strengths.length > 0) { + quickTake.push(quickTakeStrengthsDetected()); + } else if (totalWords > 0 && totalWords < 12) { + quickTake.push(quickTakeLowSignal()); + } else { + quickTake.push(quickTakeNeedsScaffold()); + } -export const EDITOR_VIEW_MODE_STORAGE_KEY = "ink-editor-view-mode"; -export const VALID_EDITOR_VIEW_MODES: EditorViewMode[] = ["split", "source", "preview"]; + return { + quickTake, + priorities, + strengths: strengths.length > 0 ? strengths : [DOCUMENT_LINTER_FALLBACK_STRENGTH], + }; +} -function normalizeEditorViewMode(value: string | null): EditorViewMode { - if (value === "source" || value === "preview") { - return value; - } - return "split"; +function computeOverallScore(scores: LinterResults): number { + const weights: Record = { + readability: 0.25, + skimmability: 0.2, + engagement: 0.2, + style: 0.15, + structure: 0.2, + }; + const total = Object.entries(scores).reduce((sum, [categoryId, result]) => { + return sum + result.score * weights[categoryId as LinterCategoryId]; + }, 0); + return clampScore(Math.round(total)); } -export function loadEditorViewMode(): EditorViewMode { - try { - return normalizeEditorViewMode(localStorage.getItem(EDITOR_VIEW_MODE_STORAGE_KEY)); - } catch { - return "split"; +function restoreResultsPanelState(resultsContainer: HTMLElement, scrollTop: number, focusKey: string | null): void { + resultsContainer.scrollTop = scrollTop; + if (!focusKey) { + return; + } + if (typeof resultsContainer.querySelector !== "function") { + return; } + const nextFocusTarget = resultsContainer.querySelector(`[data-linter-focus-key="${focusKey}"]`); + nextFocusTarget?.focus(); } -export function applyEditorViewMode(els: DomRefs, mode: EditorViewMode): void { - els.editorSplit.classList.toggle("view-split", mode === "split"); - els.editorSplit.classList.toggle("view-source", mode === "source"); - els.editorSplit.classList.toggle("view-preview", mode === "preview"); +function isElementLike(value: unknown): value is { getAttribute: (name: string) => string | null; closest?: (selector: string) => unknown } { + return typeof value === "object" && value !== null && "getAttribute" in value; +} - els.editorPane.hidden = mode === "preview"; - els.previewPane.hidden = mode === "source"; +export function analyzeDocumentText(text: string): LinterAnalysis { + const blocks = parseMarkdownBlocks(text); + const documentSections = buildDocumentSections(blocks); - const buttons: Array<[HTMLButtonElement, EditorViewMode]> = [ - [els.editorViewSourceBtn, "source"], - [els.editorViewSplitBtn, "split"], - [els.editorViewPreviewBtn, "preview"], + const readability = analyzeReadability(blocks); + const skimmability = analyzeSkimmability(blocks); + const engagement = analyzeEngagement(blocks); + const style = analyzeStyle(blocks); + const structure = analyzeStructure(blocks); + const sectionAnalysis = analyzeDocumentSections(documentSections); + + const allFindings = [ + ...readability.findings, + ...skimmability.findings, + ...engagement.findings, + ...style.findings, + ...structure.findings, + ...sectionAnalysis.findings, + ]; + + const sectionNotes = [ + { + title: "Readability", + lines: readability.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), + }, + { + title: "Skimmability", + lines: skimmability.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), + }, + { + title: "Engagement", + lines: engagement.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), + }, + { + title: "Style", + lines: style.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), + }, + { + title: "Structure", + lines: structure.findings.map((finding) => `- ${finding.title}: ${finding.detail}`), + }, + ]; + + const scores = { + readability: readability.result, + skimmability: skimmability.result, + engagement: engagement.result, + style: style.result, + structure: structure.result, + }; + + return { + scores, + overallScore: computeOverallScore(scores), + overview: buildOverview(allFindings, engagement.strengths, blocks, sectionAnalysis.sectionNotes), + sections: sectionNotes, + documentSections: sectionAnalysis.sectionNotes, + }; +} + +export function buildDocumentLinterReport(text: string, analysis: LinterAnalysis): string { + const blocks = parseMarkdownBlocks(text); + const wordCount = countWords(text); + const sentenceCount = countSentences(getPlainTextBlocks(blocks).map((block) => block.text).join(" ")); + const sections = CATEGORY_META.map((category) => { + const section = analysis.sections.find((entry) => entry.title === category.title); + return { + title: category.title, + lines: section?.lines?.length ? section.lines : [`- ${DOCUMENT_LINTER_NO_NOTABLE_ISSUES}`], + }; + }); + const documentSections = analysis.documentSections.length > 0 + ? analysis.documentSections + : [ + { + title: "Document", + kind: "implicit" as const, + lineStart: 1, + lineEnd: 1, + notes: [DOCUMENT_LINTER_NO_SECTION_STRUCTURE], + needsAttention: false, + }, + ]; + + const lines = [ + "# Document Linter Review", + "", + "## Overall", + `- Overall score: ${analysis.overallScore}/100`, + "", + "## Quick take", + ...analysis.overview.quickTake.map((line) => `- ${line}`), + "", + "## What to fix first", + ...(analysis.overview.priorities.length > 0 + ? analysis.overview.priorities.map((priority, index) => `${index + 1}. ${priority}`) + : [`- ${DOCUMENT_LINTER_NO_MAJOR_FIXES}`]), + "", + "## What is working", + ...analysis.overview.strengths.map((strength) => `- ${strength}`), + "", + "## Section notes", + ...sections.flatMap((section) => [ + `### ${section.title}`, + ...section.lines, + "", + ]), + "## Section analysis", + ...documentSections.flatMap((section) => [ + `### ${section.title}`, + `- Type: ${section.kind}`, + `- Lines: ${section.lineStart}–${section.lineEnd}`, + `- Needs attention: ${section.needsAttention ? "yes" : "no"}`, + ...section.notes.map((note) => `- ${note}`), + "", + ]), + "## Snapshot", + `- Words: ${wordCount}`, + `- Sentences: ${sentenceCount}`, + `- Blocks: ${blocks.length}`, ]; - buttons.forEach(([button, buttonMode]) => { - const isActive = buttonMode === mode; - button.classList.toggle("active", isActive); - button.setAttribute("aria-pressed", String(isActive)); - }); + return lines.join("\n"); } -export function setEditorViewMode(els: DomRefs, state: AppState, mode: EditorViewMode): void { - if (!VALID_EDITOR_VIEW_MODES.includes(mode)) { - return; - } +export function createDocumentLinterController({ + els, + getEditorText, + onEditorContentReplaced: _onEditorContentReplaced, + showToast, + setStatus, + onAnalysisUpdated, +}: { + els: DomRefs; + getEditorText: () => string; + onEditorContentReplaced: (text: string) => void; + showToast: ToastFn; + setStatus: SetStatusFn; + onAnalysisUpdated?: (analysis: LinterAnalysis, revision: number) => void; +}): DocumentLinterController { + let isActive = false; + let isAnalyzing = false; + let autoRunEnabled = false; + let lastAnalysis: LinterAnalysis | null = null; + let lastTextSnapshot = ""; + let analysisRevision = 0; + let autoRunTimer: ReturnType | null = null; + let autoRunResolvers: Array<() => void> = []; - state.editorViewMode = mode; - applyEditorViewMode(els, mode); + async function runAnalysis(textSnapshot: string = getEditorText()): Promise { + return Promise.resolve(analyzeDocumentText(textSnapshot)); + } - try { - localStorage.setItem(EDITOR_VIEW_MODE_STORAGE_KEY, mode); - } catch { - // ignore localStorage errors + function resolveScheduledAutoRun(): void { + const resolvers = autoRunResolvers; + autoRunResolvers = []; + resolvers.forEach((resolve) => resolve()); } -} -export function renderPreview(els: DomRefs, text: string): void { - try { - els.preview.innerHTML = marked.parse(text || "") as string; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - els.preview.innerHTML = `
${escapeHtml(message)}
`; + function cancelScheduledAutoRun(): void { + if (autoRunTimer !== null) { + clearTimeout(autoRunTimer); + autoRunTimer = null; + } + resolveScheduledAutoRun(); } -} -export function updateDirtyUi( - els: DomRefs, - state: AppState, - setStatus: (message: string | null, kind?: StatusKind) => void, -): void { - els.dirtyDot.classList.toggle("show", state.isDirty); + function scrollEditorToLine(lineNumber: number): void { + const lines = els.editor.value.split("\n"); + const clampedLine = Math.max(1, Math.min(lineNumber, lines.length || 1)); + let selectionStart = 0; + for (let lineIndex = 0; lineIndex < clampedLine - 1; lineIndex += 1) { + selectionStart += lines[lineIndex].length + 1; + } + els.editor.focus(); + els.editor.setSelectionRange(selectionStart, selectionStart); + const computedLineHeight = typeof window.getComputedStyle === "function" + ? Number.parseFloat(window.getComputedStyle(els.editor).lineHeight) + : Number.NaN; + const lineHeight = Number.isFinite(computedLineHeight) ? computedLineHeight : 20; + els.editor.scrollTop = Math.max(0, (clampedLine - 1) * lineHeight); + } - const openFileName = state.currentRelPath - ? state.currentRelPath.split("/").pop() - : "No note open"; - els.currentFilename.textContent = `${openFileName}${state.isDirty ? " • Unsaved" : ""}`; + function updateResultsPanel(analysis: LinterAnalysis): void { + const resultsContainer = els.documentLinterResults; + const previousScrollTop = resultsContainer.scrollTop; + const activeElement = isElementLike(document.activeElement) ? document.activeElement : null; + const focusKey = activeElement?.closest?.("#documentLinterResults") + ? activeElement.getAttribute("data-linter-focus-key") + : null; + resultsContainer.innerHTML = ""; - if (state.isDirty) { - setStatus("Unsaved changes", "warn"); - } -} -
+ const summary = document.createElement("section"); + summary.className = "documentLinterSummary"; + summary.innerHTML = ` +
+

Overall

+
${analysis.overallScore}/100
+
+
+

Quick take

+
    + ${analysis.overview.quickTake.map((line) => `
  • ${escapeHtml(line)}
  • `).join("")} +
+
+
+

What to fix first

+
    + ${analysis.overview.priorities.map((line) => `
  1. ${escapeHtml(line)}
  2. `).join("") || `
  3. ${escapeHtml(DOCUMENT_LINTER_NO_MAJOR_FIXES)}
  4. `} +
+
+
+

What is working

+
    + ${analysis.overview.strengths.map((line) => `
  • ${escapeHtml(line)}
  • `).join("")} +
+
+ `; + resultsContainer.appendChild(summary); - -import type { AppState, DomRefs } from "./types"; -import { applyTheme, VALID_THEMES } from "./theme"; + CATEGORY_META.forEach((category) => { + const result = analysis.scores[category.id]; + const sectionNotes = analysis.sections.find((section) => section.title === category.title)?.lines ?? []; + const categoryElement = document.createElement("div"); + categoryElement.className = "documentLinterCategory"; + categoryElement.innerHTML = ` +
+

${category.title}

+
${result.score}/100
+
+
+ ${ + sectionNotes.length > 0 + ? sectionNotes.map((note) => `
${escapeHtml(note)}
`).join("") + : `
${escapeHtml(DOCUMENT_LINTER_NO_NOTABLE_ISSUES)}
` + } +
+ `; + resultsContainer.appendChild(categoryElement); + }); -type ToastFn = (message: string, options?: { persist?: boolean }) => void; -type MenuCallbacks = { - createNewNote: () => Promise; - createNewFolder: () => Promise; - openWorkspace: () => Promise; - closeWorkspace: () => void; - saveCurrentNote: () => Promise; - saveAsNewNote: () => Promise; - handleRefresh: () => void; - exportAsJson: () => void; - exportAsMarkdown: () => void; - setSidebarCollapsed: (isCollapsed: boolean) => void; -}; + const sectionAnalysis = document.createElement("section"); + sectionAnalysis.className = "documentLinterSectionAnalysis"; + sectionAnalysis.innerHTML = ` +

Section analysis

+
+ ${ + analysis.documentSections.length > 0 + ? analysis.documentSections + .map( + (section) => ` +
+
+

${escapeHtml(section.title)}

+ ${escapeHtml(section.kind)} · ${createLineButtonMarkup(section.lineStart, `Lines ${section.lineStart}–${section.lineEnd}`)} · ${section.needsAttention ? "needs attention" : "stable"} +
+
+ ${section.notes.map((note) => `
${escapeHtml(note)}
`).join("")} +
+
+ `, + ) + .join("") + : `
${escapeHtml( + DOCUMENT_LINTER_NO_SECTION_STRUCTURE, + )}
` + } +
+ `; + resultsContainer.appendChild(sectionAnalysis); + restoreResultsPanelState(resultsContainer, previousScrollTop, focusKey); + } -export function updateMenuShortcuts(els: DomRefs, isMac: boolean): void { - const modifier = isMac ? "Cmd" : "Ctrl"; - const shortcuts = els.menuBar.querySelectorAll(".menu-shortcut"); - shortcuts.forEach((el) => { - const text = el.textContent; - if (text) { - if (text.includes("Cmd/Ctrl")) { - el.textContent = text.replace("Cmd/Ctrl", modifier); - } else if (text.includes("Ctrl")) { - el.textContent = text.replace("Ctrl", modifier); - } - } - }); -} + function downloadMarkdownReport(markdown: string): void { + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const dateStamp = new Date().toISOString().split("T")[0]; + const fileName = `ink-linter-report-${dateStamp}.md`; -export function createMenuActions({ - state, - els, - showToast, - renderTree, - callbacks, -}: { - state: AppState; - els: DomRefs; - showToast: ToastFn; - renderTree: () => Promise; - callbacks: MenuCallbacks; -}) { - function toggleSort(): void { - state.sortMode = state.sortMode === "name" ? "modified" : "name"; - els.sortBtn.textContent = `Sort: ${state.sortMode === "name" ? "Name" : "Last modified"}`; + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } - const sortMenuItem = document.querySelector('[data-action="sort"] .menu-label-text'); - if (sortMenuItem) { - sortMenuItem.textContent = `Sort: ${state.sortMode === "name" ? "Name" : "Modified"}`; + async function analyzeDocumentAction(): Promise { + if (isAnalyzing) { + return; } - renderTree().catch((error: unknown) => { - showToast(`Sort render failed: ${String(error)}`, { persist: true }); - }); - } + const text = getEditorText(); + if (!text.trim()) { + showToast("No content to analyze", { persist: true }); + setStatus("No content to analyze", "warn"); + return; + } - function handleExit(): void { - if (state.isDirty) { - const shouldExit = confirm("You have unsaved changes. Are you sure you want to exit?"); - if (!shouldExit) { - return; - } + isAnalyzing = true; + lastTextSnapshot = text; + els.documentLinterAnalyzeBtn.disabled = true; + els.documentLinterStatus.textContent = "Analyzing document..."; + + try { + const analysis = await runAnalysis(text); + lastAnalysis = analysis; + analysisRevision += 1; + updateResultsPanel(analysis); + els.documentLinterStatus.textContent = "Analysis complete"; + onAnalysisUpdated?.(analysis, analysisRevision); + showToast("Document analysis completed"); + setStatus("Analysis complete", "ok"); + } catch (error) { + els.documentLinterStatus.textContent = "Analysis failed"; + showToast(`Document analysis failed: ${String(error)}`, { persist: true }); + setStatus("Analysis failed", "err"); + } finally { + isAnalyzing = false; + els.documentLinterAnalyzeBtn.disabled = false; } - window.close(); } - function handleMenuAction(action: string): void { - switch (action) { - case "new-note": - callbacks.createNewNote().catch((error: unknown) => { - showToast(`Create note failed: ${String(error)}`, { persist: true }); - }); - break; - case "new-folder": - callbacks.createNewFolder().catch((error: unknown) => { - showToast(`Create folder failed: ${String(error)}`, { persist: true }); - }); - break; - case "open-workspace": - callbacks.openWorkspace().catch((error: unknown) => { - showToast(`Failed to open workspace: ${String(error)}`, { persist: true }); - }); - break; - case "close-workspace": - callbacks.closeWorkspace(); - break; - case "exit": - handleExit(); - break; - case "save": - callbacks.saveCurrentNote().catch((error: unknown) => { - showToast(`Save failed: ${String(error)}`, { persist: true }); - }); - break; - case "save-as": - callbacks.saveAsNewNote().catch((error: unknown) => { - showToast(`Save As failed: ${String(error)}`, { persist: true }); - }); - break; - case "refresh": - callbacks.handleRefresh(); - break; - case "sort": - toggleSort(); - break; - case "collapse-sidebar": - callbacks.setSidebarCollapsed(!state.isSidebarCollapsed); - break; - case "export-json": - callbacks.exportAsJson(); - break; - case "export-markdown": - callbacks.exportAsMarkdown(); - break; - case "theme-default": - case "theme-classic": - case "theme-cobalt": - case "theme-monokai": - case "theme-office": - case "theme-twilight": - case "theme-xcode": { - const themeName = action.replace("theme-", ""); - if (VALID_THEMES.includes(themeName)) { - applyTheme(themeName); - } - break; + async function exportSuggestionsAction(): Promise { + const text = getEditorText(); + if (!text.trim()) { + showToast("No content to export", { persist: true }); + setStatus("No content to export", "warn"); + return; + } + + try { + const needsFreshAnalysis = text !== lastTextSnapshot || !lastAnalysis; + if (needsFreshAnalysis) { + els.documentLinterStatus.textContent = "Document changed; re-analyzing before export..."; + setStatus("Re-analyzing before export", "warn"); + } + const analysis = needsFreshAnalysis ? await runAnalysis(text) : lastAnalysis; + lastAnalysis = analysis; + lastTextSnapshot = text; + if (needsFreshAnalysis) { + analysisRevision += 1; + updateResultsPanel(analysis); + onAnalysisUpdated?.(analysis, analysisRevision); } + const report = buildDocumentLinterReport(text, analysis); + downloadMarkdownReport(report); + showToast("Exported linter review as Markdown."); + setStatus("Exported report", "ok"); + } catch (error) { + showToast(`Failed to export linter report: ${String(error)}`, { persist: true }); + setStatus("Export failed", "err"); } } - return { - toggleSort, - handleMenuAction, - handleExit, - }; -} -
- - -import { escapeHtml } from "./utils"; -import { icon } from "./icons"; -import type { - AppState, - DomRefs, - FileHandleLike, - InMemoryNoteRecord, - TreeNode, -} from "./types"; - -export type TreeHandlers = { - openNoteByRelPath: (relPath: string, handleHint: FileHandleLike | null) => Promise; - openInMemoryNote: (relPath: string) => Promise; -}; + async function handleEditorChanged(textSnapshot: string = getEditorText()): Promise { + if (!isActive || !autoRunEnabled || isAnalyzing || !textSnapshot.trim()) { + cancelScheduledAutoRun(); + if (!textSnapshot.trim() && autoRunEnabled && isActive) { + lastAnalysis = null; + lastTextSnapshot = textSnapshot; + els.documentLinterStatus.textContent = "No content to analyze"; + } + return Promise.resolve(); + } + if (textSnapshot === lastTextSnapshot) { + return Promise.resolve(); + } -type ToastFn = (message: string, options?: { persist?: boolean }) => void; + if (autoRunTimer !== null) { + clearTimeout(autoRunTimer); + } + els.documentLinterStatus.textContent = "Waiting for typing to pause..."; -export function createTreeRenderer({ - state, - els, - handlers, - showToast, -}: { - state: AppState; - els: DomRefs; - handlers: TreeHandlers; - showToast: ToastFn; -}) { - function renderTags(): void { - const tagCounts = new Map(); - const notes = state.isTemporarySession ? state.inMemoryNotes : state.notes; + return new Promise((resolve) => { + autoRunResolvers.push(resolve); + autoRunTimer = setTimeout(() => { + autoRunTimer = null; + const shouldAnalyze = isActive && autoRunEnabled && textSnapshot !== lastTextSnapshot; + const analysisPromise = shouldAnalyze ? analyzeDocumentAction() : Promise.resolve(); + analysisPromise.finally(resolveScheduledAutoRun); + }, AUTO_RUN_DEBOUNCE_MS); + }); + } - for (const note of notes) { - for (const tag of note.tags) { - tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); - } + els.documentLinterAutoRunToggle.checked = autoRunEnabled; + els.documentLinterAutoRunToggle.addEventListener("change", () => { + autoRunEnabled = els.documentLinterAutoRunToggle.checked; + if (!autoRunEnabled) { + cancelScheduledAutoRun(); } + els.documentLinterStatus.textContent = autoRunEnabled + ? "Rerun on change enabled" + : "Rerun on change disabled"; + }); - const sorted = [...tagCounts.entries()] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, 50); - - els.tagRow.innerHTML = ""; - if (sorted.length === 0) { + els.documentLinterResults.addEventListener("click", (event: Event) => { + const target = event.target as HTMLElement | null; + const lineButton = target?.closest("[data-linter-line]"); + if (!lineButton) { + return; + } + const lineNumber = Number(lineButton.getAttribute("data-linter-line")); + if (Number.isNaN(lineNumber)) { return; } + scrollEditorToLine(lineNumber); + }); - const allButton = document.createElement("button"); - allButton.className = `tag${state.tagFilter ? "" : " active"}`; - allButton.textContent = "All"; - allButton.title = "Clear tag filter"; - allButton.addEventListener("click", () => { - state.tagFilter = ""; - renderTags(); - if (state.isTemporarySession) { - renderInMemoryTree(); - } else { - renderTree().catch((error: unknown) => { - showToast(`Tag render failed: ${String(error)}`, { persist: true }); - }); + return { + setActive: (nextIsActive: boolean) => { + isActive = nextIsActive; + if (!nextIsActive) { + cancelScheduledAutoRun(); } - }); - els.tagRow.appendChild(allButton); + if (nextIsActive && !lastAnalysis) { + els.documentLinterStatus.textContent = "Ready to analyze document"; + } + }, + handleEditorChanged, + analyzeDocument: analyzeDocumentAction, + exportSuggestions: exportSuggestionsAction, + getLatestAnalysis: () => lastAnalysis, + getAnalysisRevision: () => analysisRevision, + }; +} + + + +import type { DomRefs } from "./types"; +import type { LinterAnalysis } from "./document-linter/document-linter"; - for (const [tag, count] of sorted) { - const button = document.createElement("button"); - button.className = `tag${state.tagFilter === tag ? " active" : ""}`; - button.textContent = `#${tag}`; - button.title = `${count} note${count === 1 ? "" : "s"} tagged #${tag}`; - button.addEventListener("click", () => { - state.tagFilter = state.tagFilter === tag ? "" : tag; - renderTags(); - if (state.isTemporarySession) { - renderInMemoryTree(); - } else { - renderTree().catch((error: unknown) => { - showToast(`Tag render failed: ${String(error)}`, { persist: true }); - }); - } - }); - els.tagRow.appendChild(button); - } - } +export const COGITO_PROMPT = `You are a writing coach. - async function computeMatches(): Promise> { - let notes = [...state.notes]; +Rules: +- Do NOT write prose. +- Do NOT suggest sentences. +- Ask exactly 3 questions. +- Questions must be grounded in the user's last sentence. +- Use document analysis only to focus what the questions explore. +- Output JSON only in this format: +{ + "questions": ["...", "...", "..."] +}`; - if (state.tagFilter) { - notes = notes.filter((note) => note.tags.has(state.tagFilter)); - } +export const LITE_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC"; +export const DEEP_MODEL = "Qwen3-8B-q4f16_1-MLC"; - if (state.sortMode === "modified") { - notes.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); - } else { - notes.sort((a, b) => a.relPath.localeCompare(b.relPath)); - } +export type CogitoModel = "lite" | "deep"; - const query = state.searchQuery.trim().toLowerCase(); - if (!query) { - return new Set(notes.map((note) => note.relPath)); - } +type ChatEngine = { + chat: { + completions: { + create: (payload: { + messages: Array<{ role: "system" | "user"; content: string }>; + temperature?: number; + }) => Promise<{ choices?: Array<{ message?: { content?: unknown } }> }>; + }; + }; +}; - const matches = new Set(); - for (const note of notes) { - if (note.relPath.toLowerCase().includes(query)) { - matches.add(note.relPath); - continue; - } +type WebLlmModule = { + prebuiltAppConfig: { + model_list: unknown[]; + [key: string]: unknown; + }; + deleteModelAllInfoInCache?: ( + modelId: string, + appConfig?: { + model_list: unknown[]; + [key: string]: unknown; + }, + ) => Promise; + CreateMLCEngine: ( + modelId: string, + options?: { + initProgressCallback?: (progress: { text?: string }) => void; + appConfig?: { + model_list: unknown[]; + cacheBackend: "indexeddb"; + [key: string]: unknown; + }; + }, + ) => Promise; +}; - try { - const file = await note.handle.getFile(); - const text = (await file.text()).toLowerCase(); - if (text.includes(query)) { - matches.add(note.relPath); - } - } catch { - // Ignore read errors while searching. - } - } +type ToastFn = (message: string, options?: { persist?: boolean }) => void; - return matches; - } +type SetStatusFn = (message: string, kind?: "ok" | "warn" | "err") => void; - async function renderTree(): Promise { - if (!state.fileTree) { - els.tree.innerHTML = '
Open a folder to begin.
'; - return; - } +type CogitoController = { + togglePanel: () => void; + setPanelOpen: (isOpen: boolean) => void; + isPanelOpen: () => boolean; + closePanel: () => void; + selectModel: (model: CogitoModel) => void; + generateQuestions: () => Promise; + insertQuestionAtIndex: (index: number) => void; + markAnalysisChanged: (revision: number) => void; +}; - const matches = await computeMatches(); - const prunedTree = pruneTree(state.fileTree, matches); +export type CogitoAnalysisContext = { + overallScore: number; + priorities: string[]; + strengths: string[]; +}; - els.tree.innerHTML = ""; - if (!prunedTree || prunedTree.type !== "dir" || prunedTree.children.length === 0) { - els.tree.innerHTML = '
No matching notes.
'; - return; - } +const MAX_LAST_SENTENCE_CONTEXT_LENGTH = 1200; +const MAX_ANALYSIS_LINE_LENGTH = 240; +const MODEL_LOAD_RETRY_DELAY_MS = 750; - for (const child of prunedTree.children) { - renderNode(child, 0); - } +function compactContextLine(value: string, maximumLength: number, keepEnd = false): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maximumLength) { + return normalized; + } + if (keepEnd) { + return `…${normalized.slice(-(maximumLength - 1))}`; } + return `${normalized.slice(0, maximumLength - 1)}…`; +} - function pruneTree(node: TreeNode, matches: Set): TreeNode | null { - if (node.type === "file") { - return matches.has(node.relPath) ? node : null; - } +export function isRecoverableModelLoadError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /cache|network|fetch|failed to execute ['"]?add|load failed|connection/i.test(message); +} - const children: TreeNode[] = []; - for (const child of node.children) { - const kept = pruneTree(child, matches); - if (kept) { - children.push(kept); - } - } +function wait(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} - if (node.relPath === "") { - return { - ...node, - children, - }; - } +export function buildCogitoAnalysisContext(analysis: LinterAnalysis | null): CogitoAnalysisContext | null { + if (!analysis) { + return null; + } - if (children.length === 0) { - return null; - } + return { + overallScore: analysis.overallScore, + priorities: analysis.overview.priorities + .slice(0, 3) + .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)), + strengths: analysis.overview.strengths + .slice(0, 2) + .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)), + }; +} - return { - ...node, - children, - }; +export function buildCogitoUserPrompt( + lastSentence: string, + analysisContext: CogitoAnalysisContext | null, +): string { + const compactLastSentence = compactContextLine( + lastSentence, + MAX_LAST_SENTENCE_CONTEXT_LENGTH, + true, + ); + const lines = [`Last sentence: ${compactLastSentence}`]; + if (!analysisContext) { + lines.push("Document analysis: unavailable. Focus only on the last sentence."); + return lines.join("\n"); } - function renderNode(node: TreeNode, depth: number): void { - const row = document.createElement("div"); - row.className = "node"; - row.style.paddingLeft = `${8 + depth * 12}px`; - - if (node.type === "dir") { - const isCollapsed = state.collapsedDirs.has(node.relPath); - row.innerHTML = ` - ${isCollapsed ? "▶" : "▼"} - ${isCollapsed ? icon.folder() : icon.folderOpen()} - ${escapeHtml(node.name)} - `; + const compactPriorities = analysisContext.priorities + .slice(0, 3) + .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)); + const compactStrengths = analysisContext.strengths + .slice(0, 2) + .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)); + lines.push( + `Document strength: ${analysisContext.overallScore}/100`, + `Highest-priority improvements: ${compactPriorities.join(" | ") || "No major fixes identified."}`, + `Current strengths: ${compactStrengths.join(" | ") || "No clear strengths identified yet."}`, + ); + return lines.join("\n"); +} - row.addEventListener("click", (event: MouseEvent) => { - event.stopPropagation(); - if (isCollapsed) { - state.collapsedDirs.delete(node.relPath); - } else { - state.collapsedDirs.add(node.relPath); - } +export function extractLastSentence(text: string): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) { + return ""; + } - renderTree().catch((error: unknown) => { - showToast(`Tree render failed: ${String(error)}`, { persist: true }); - }); - }); + const fragments = normalized + .split(/(?<=[.!?])\s+/) + .map((fragment) => fragment.trim()) + .filter(Boolean); - els.tree.appendChild(row); - if (!isCollapsed) { - for (const child of node.children) { - renderNode(child, depth + 1); - } - } - return; - } + if (fragments.length === 0) { + return ""; + } - const isActive = node.relPath === state.currentRelPath; - if (isActive) { - row.classList.add("active"); - } + return fragments[fragments.length - 1]; +} - const meta = - state.sortMode === "modified" && node.noteRef.lastModified - ? new Date(node.noteRef.lastModified).toLocaleDateString() - : ""; +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 }; - row.innerHTML = ` - ${icon.fileText()} - ${escapeHtml(node.noteRef.name)} - ${escapeHtml(meta)} - `; + if (!parsed || !Array.isArray(parsed.questions)) { + throw new Error("Cogito response did not include a questions array."); + } - row.addEventListener("click", (event: MouseEvent) => { - event.stopPropagation(); - handlers.openNoteByRelPath(node.relPath, node.handle).catch((error: unknown) => { - showToast(`Open note failed: ${String(error)}`, { persist: true }); - }); - }); + const sanitized = parsed.questions + .filter((q): q is string => typeof q === "string" && q.trim().length > 0) + .map((q) => q.trim()); - els.tree.appendChild(row); + if (sanitized.length === 0) { + throw new Error("Cogito response contained no valid questions."); } - function renderInMemoryTree(): void { - els.tree.innerHTML = ""; + if (sanitized.length !== 3) { + throw new Error("Cogito response must contain exactly 3 questions."); + } - if (state.inMemoryNotes.length === 0) { - els.tree.innerHTML = - '
Temporary session. Create a note to begin.
'; - return; - } + return sanitized; +} - const sortedNotes = [...state.inMemoryNotes].sort((a, b) => a.relPath.localeCompare(b.relPath)); +export function formatCogitoQuestionBlock(question: string): string { + return `> ### AI\n${question.trim()}\n`; +} - for (const note of sortedNotes) { - renderInMemoryRow(note); +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); +} + +export function createCogitoController({ + els, + getEditorText, + onEditorContentReplaced, + showToast, + setStatus, + getDocumentAnalysis, + getAnalysisRevision, +}: { + els: DomRefs; + getEditorText: () => string; + onEditorContentReplaced: (text: string) => void; + showToast: ToastFn; + setStatus: SetStatusFn; + getDocumentAnalysis: () => LinterAnalysis | null; + getAnalysisRevision: () => number; +}): CogitoController { + let isPanelOpen = false; + let generatedQuestions: string[] = []; + let selectedModel: CogitoModel = "lite"; + let generatedAnalysisRevision: number | null = null; + const engineCache: Partial>> = {}; + + async function loadWebLlmModule(): Promise { + const testModule = ( + globalThis as typeof globalThis & { + __INK_TEST_WEBLLM__?: WebLlmModule; + } + ).__INK_TEST_WEBLLM__; + + if (testModule) { + return testModule; } + + return import("https://esm.run/@mlc-ai/web-llm") as Promise; } - function renderInMemoryRow(note: InMemoryNoteRecord): void { - const row = document.createElement("div"); - row.className = "node"; + function selectModel(model: CogitoModel): void { + selectedModel = model; + els.cogitoLiteBtn.classList.toggle("active", model === "lite"); + els.cogitoDeepBtn.classList.toggle("active", model === "deep"); + } - const isActive = note.relPath === state.currentRelPath; - if (isActive) { - row.classList.add("active"); + 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); } + } - const meta = state.sortMode === "modified" ? new Date(note.lastModified).toLocaleDateString() : ""; + function updateQuestionList(questions: string[]): void { + els.cogitoQuestionList.innerHTML = ""; + questions.forEach((question, index) => { + const item = document.createElement("li"); + item.className = "cogitoQuestionItem"; - row.innerHTML = ` - ${icon.fileText()} - ${escapeHtml(note.name)} - ${escapeHtml(meta)} - `; + const text = document.createElement("p"); + text.className = "cogitoQuestionText"; + text.textContent = question; - row.addEventListener("click", () => { - handlers.openInMemoryNote(note.relPath); - }); + 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"; - els.tree.appendChild(row); + item.append(text, button); + els.cogitoQuestionList.appendChild(item); + }); } - function updateCountsPill(): void { - const count = state.inMemoryNotes.length; - els.countsPill.textContent = `${count} note${count === 1 ? "" : "s"}`; + function setCogitoStatus(text: string): void { + els.cogitoStatus.textContent = text; } - return { - computeMatches, - renderTree, - renderTags, - renderInMemoryTree, - updateCountsPill, - }; -} -
- - -import { createRequire } from "node:module"; -import { defineConfig } from "cypress"; - -const require = createRequire(import.meta.url); - -export default defineConfig({ - video: false, - e2e: { - baseUrl: "http://127.0.0.1:4173", - specPattern: "cypress/e2e/**/*.cy.js", - setupNodeEvents(on, config) { - require("@cypress/code-coverage/task")(on, config); - return config; - }, - }, -}); - + function getModelId(model: CogitoModel): string { + return model === "deep" ? DEEP_MODEL : LITE_MODEL; + } - -.PHONY: help lint build watch test test-qunit test-cypress update-repomix + function getModelLabel(model: CogitoModel): string { + return model === "deep" ? "Deep (Qwen3 8B)" : "Lite (Llama 1B)"; + } -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" + async function createEngineWithRecovery(model: CogitoModel): Promise { + const modelId = getModelId(model); + const webllm = await loadWebLlmModule(); + const appConfig = { + ...webllm.prebuiltAppConfig, + cacheBackend: "indexeddb" as const, + }; -lint: - @echo "Running ESLint..." - npm run lint + async function createEngine(): Promise { + return webllm.CreateMLCEngine(modelId, { + appConfig, + initProgressCallback: (progress: { text?: string }) => { + if (progress?.text) { + setCogitoStatus(progress.text); + } + }, + }); + } -build: - @echo "Building the project..." - npm run build + try { + return await createEngine(); + } catch (error: unknown) { + if (!isRecoverableModelLoadError(error)) { + throw error; + } -watch: - @echo "Watching for changes..." - npm run watch + setCogitoStatus(`Repairing ${getModelLabel(model)} cache and retrying...`); + try { + await webllm.deleteModelAllInfoInCache?.(modelId, appConfig); + } catch { + // Cache cleanup is best-effort; the retry may still succeed. + } + await wait(MODEL_LOAD_RETRY_DELAY_MS); + return createEngine(); + } + } -test: test-qunit test-cypress + async function getOrCreateEngine(model: CogitoModel = selectedModel): Promise { + if (engineCache[model]) { + return engineCache[model]!; + } -test-qunit: - @echo "Running QUnit tests..." - npm run test:qunit + setCogitoStatus(`Loading ${getModelLabel(model)} model...`); + engineCache[model] = createEngineWithRecovery(model).catch((error: unknown) => { + delete engineCache[model]; + throw error; + }); -test-cypress: - @echo "Running Cypress tests..." - npm run test:cypress + return engineCache[model]!; + } -repomix: - @echo "Updating repomix to the latest version..." - npx repomix@latest - + async function getEngineWithFallback(): Promise { + try { + return await getOrCreateEngine(selectedModel); + } catch (error: unknown) { + if (selectedModel !== "deep" || !isRecoverableModelLoadError(error)) { + throw error; + } - -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "include-v-in-tag": true, - "packages": { - ".": { - "release-type": "node" + setCogitoStatus("Deep model download failed. Falling back to Lite..."); + selectModel("lite"); + return getOrCreateEngine("lite"); } } -} - - -name: PR Checks + 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; + } -on: - pull_request: - branches: [main] + try { + els.cogitoGenerateBtn.disabled = true; + setCogitoStatus("Generating 3 questions..."); -jobs: - changes: - name: Detect changed files - runs-on: ubuntu-latest - outputs: - docs_only: ${{ steps.filter.outputs.docs_only }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + const engine = await getEngineWithFallback(); + const analysisContext = buildCogitoAnalysisContext(getDocumentAnalysis()); + const analysisRevision = getAnalysisRevision(); + const completion = await engine.chat.completions.create({ + messages: [ + { role: "system", content: COGITO_PROMPT }, + { role: "user", content: buildCogitoUserPrompt(lastSentence, analysisContext) }, + ], + temperature: 0.2, + }); - - name: Classify pull request changes - id: filter - shell: bash - run: | - 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")" + 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() + : ""; - printf '%s\n' "$changed_files" + if (!textContent) { + throw new Error("Cogito returned an empty response."); + } - if [ -z "$changed_files" ]; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + generatedQuestions = parseCogitoQuestionPayload(textContent); + generatedAnalysisRevision = analysisContext ? analysisRevision : null; + updateQuestionList(generatedQuestions); + setCogitoStatus( + analysisContext + ? "Questions ready and focused by the current document analysis." + : "Questions ready. Analyze the document for more focused coaching.", + ); + setStatus("Cogito questions ready", "ok"); + } catch (error: unknown) { + generatedQuestions = []; + updateQuestionList(generatedQuestions); + const message = error instanceof Error ? error.message : String(error); + const friendlyMessage = isRecoverableModelLoadError(error) + ? "The local model could not finish downloading. Check the connection and available browser storage, then retry." + : message; + setCogitoStatus(`Cogito error: ${friendlyMessage}`); + setStatus("Cogito unavailable", "warn"); + showToast(`Cogito failed: ${friendlyMessage}`, { persist: true }); + } finally { + els.cogitoGenerateBtn.disabled = false; + } + } - if echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + function insertQuestionAtIndex(index: number): void { + const question = generatedQuestions[index]; + if (!question) { + showToast("Cogito question not found.", { persist: true }); + return; + } - echo "docs_only=true" >> "$GITHUB_OUTPUT" + const block = formatCogitoQuestionBlock(question); + insertTextAtCursor(els.editor, block); + onEditorContentReplaced(els.editor.value); + setStatus("Inserted AI question", "ok"); + } - test: - name: Build and test - needs: changes - if: needs.changes.outputs.docs_only != 'true' - runs-on: ubuntu-latest + function markAnalysisChanged(revision: number): void { + if (generatedQuestions.length === 0 || generatedAnalysisRevision === revision) { + return; + } + setCogitoStatus("Document analysis changed. Regenerate to focus these questions on the latest findings."); + } - steps: - - name: Checkout code - uses: actions/checkout@v4 + setPanelVisibility(false); - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' + return { + togglePanel: () => { + setPanelVisibility(!isPanelOpen); + if (isPanelOpen) { + setCogitoStatus( + getDocumentAnalysis() + ? "Generate questions focused by the current document analysis." + : "Analyze first for more focused coaching, or generate from your latest sentence now.", + ); + } + }, + setPanelOpen: (nextIsOpen: boolean) => { + setPanelVisibility(nextIsOpen); + if (nextIsOpen) { + setCogitoStatus( + getDocumentAnalysis() + ? "Generate questions focused by the current document analysis." + : "Analyze first for more focused coaching, or generate from your latest sentence now.", + ); + } + }, + isPanelOpen: () => isPanelOpen, + closePanel: () => { + setPanelVisibility(false); + }, + selectModel, + generateQuestions, + insertQuestionAtIndex, + markAnalysisChanged, + }; +} + - - name: Install dependencies - run: npm ci + +import { icon } from "./icons"; +import type { + AppState, + DeclarativeNoteInput, + DeclarativeNoteResult, + DirectoryHandleLike, + DirectoryNode, + DomRefs, + FileHandleLike, + FileNode, + InMemoryNoteRecord, + NoteRecord, + TreeNode, +} from "./types"; - - name: Build - run: npm run build +type ToastFn = (message: string, options?: { persist?: boolean }) => void; +type StatusFn = (message: string | null, kind?: "neutral" | "ok" | "warn" | "err") => void; - - name: Run QUnit tests - run: npm run test:qunit +type RenderPreviewFn = (els: DomRefs, text: string) => void; +type UpdateDirtyFn = (els: DomRefs, state: AppState, setStatus: StatusFn) => void; - - name: Run Cypress tests - uses: cypress-io/github-action@v6 - with: - start: npm run serve:test - wait-on: 'http://127.0.0.1:4173/ink-app.html' - browser: chrome - headless: true - config: video=false +type TreeRenderFns = { + renderTree: () => Promise; + renderInMemoryTree: () => void; + renderTags: () => void; + updateCountsPill: () => void; +}; - - name: Upload Cypress Screenshots - uses: actions/upload-artifact@v4 - if: failure() - with: - name: cypress-screenshots - path: cypress/screenshots - +type FsApi = { + ensurePermission: (handle: DirectoryHandleLike, mode: "read" | "readwrite") => Promise; + isFileSystemApiAvailable: () => boolean; +}; - -function dispatchShortcut(target, { key, ctrlKey = false, metaKey = false, shiftKey = false, altKey = false }) { - const view = target.ownerDocument.defaultView; - const event = new view.KeyboardEvent("keydown", { - key, - ctrlKey, - metaKey, - shiftKey, - altKey, - bubbles: true, - cancelable: true, - }); +type AutoRefreshFns = { + startAutoRefresh: () => void; + stopAutoRefresh: () => void; +}; - target.dispatchEvent(event); -} +type RescanOptions = { + silent?: boolean; + throwOnError?: boolean; + showProgress?: boolean; +}; -function getPlatformModifier(target) { - const view = target.ownerDocument.defaultView; - const isMac = /Mac|iPod|iPhone|iPad/.test(view.navigator.userAgent); +type DiscoveredNote = { + handle: FileHandleLike; + name: string; + relPath: string; + parentNode: DirectoryNode; +}; - return isMac ? { metaKey: true } : { ctrlKey: true }; -} +type TagParser = (text: string) => Set; +type TagNormalizer = (value: string) => string; +const FILE_READ_CONCURRENCY = 4; -describe("mobile fallback functionality", () => { - const fileStem = "test-note"; - const fileName = `${fileStem}.md`; - const markdown = "# Test Note\n\nThis is test content."; +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; +}) { + let isOpeningWorkspace = false; + let activeRescan: Promise | null = null; + let activeRescanHandle: DirectoryHandleLike | null = null; + let scanGeneration = 0; - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - delete win.showDirectoryPicker; - delete win.FileSystemHandle; - win.prompt = (message) => { - if (message.includes("New note name")) { - return fileStem; - } - return null; - }; - win.confirm = () => true; - }, - }); - }); + function recordWorkspaceInteraction(): void { + state.lastWorkspaceInteractionAt = Date.now(); + } - it("shows temporary session when FS API is unavailable after clicking Open Workspace", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("#workspaceName").should("contain", "Temporary Session"); - cy.get("#temporarySessionBadge").should("be.visible"); - cy.get("#temporarySessionBadge").should("contain", "Temporary Session"); - cy.get("#statusBadge").should("contain", "Temporary session"); - cy.get("#tree").should("contain", "Temporary session"); - }); + function cancelActiveScan(): void { + scanGeneration += 1; + activeRescan = null; + activeRescanHandle = null; + } - it("allows creating and editing notes in temporary session", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#currentFilename").should("contain", fileName); + function activateTemporarySession(): void { + const wasTemporarySession = state.isTemporarySession; - cy.get("#editor").clear().type(markdown); - cy.get("#dirtyDot").should("be.visible"); + state.workspaceHandle = null; + state.workspaceName = "Temporary Session"; + state.fileTree = null; + state.notes = []; + state.currentFileHandle = null; - cy.get("[data-action=\"save\"]").click({ force: true }); - cy.get("#dirtyDot").should("not.be.visible"); - cy.get("#statusBadge").should("contain", "Saved"); + if (!wasTemporarySession) { + state.inMemoryNotes = []; + state.currentRelPath = ""; + state.currentContent = ""; + state.isDirty = false; + els.editor.value = ""; + renderPreview(els, ""); + updateDirtyUi(els, state, setStatus); + } - cy.get("#tree").should("contain", fileName); - }); + state.isTemporarySession = true; - it("enables export functionality after creating notes", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); + els.workspaceName.textContent = state.workspaceName; + els.workspaceName.title = "Temporary Session - Data not persisted"; + els.temporarySessionBadge.style.display = "inline"; + els.countsPill.textContent = `${state.inMemoryNotes.length} note${state.inMemoryNotes.length === 1 ? "" : "s"}`; + els.tagRow.innerHTML = ""; + renderInMemoryTree(); + renderTags(); + } - cy.get("[data-action=\"export-json\"]").should("exist"); - cy.get("[data-action=\"export-markdown\"]").should("exist"); - }); + function buildNoteFileName(title: string): string { + const sanitizedTitle = title + .trim() + .replace(/[\\/:*?"<>|]/g, " ") + .replace(/\s+/g, " ") + .trim(); - it("exports current note as markdown", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - cy.get("[data-action=\"save\"]").click({ force: true }); + const baseName = sanitizedTitle || "Untitled"; + return baseName.endsWith(".md") ? baseName : `${baseName}.md`; + } - cy.get("[data-action=\"export-markdown\"]").click({ force: true }); + function buildNoteContent({ title, body, tag }: DeclarativeNoteInput): string { + const normalizedTitle = title.trim() || "Untitled"; + const normalizedBody = body.trim(); + const normalizedTag = normalizeTag(tag); + const sections: string[] = []; - cy.readFile("cypress/downloads/test-note.md", { timeout: 5000 }).then((content) => { - expect(content).to.include("# Test Note"); - expect(content).to.include("This is test content."); - }); - }); + if (normalizedTag) { + sections.push("---", `tags: [${normalizedTag}]`, "---", ""); + } - it("exports all notes as JSON", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type("# First Note\n\nContent 1"); - cy.get("[data-action=\"save\"]").click({ force: true }); + sections.push(`# ${normalizedTitle}`); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type("# Second Note\n\nContent 2"); - cy.get("[data-action=\"save\"]").click({ force: true }); + if (normalizedBody) { + sections.push("", normalizedBody); + } - cy.get("[data-action=\"export-json\"]").click({ force: true }); - cy.get("#statusBadge").should("contain", "Exported JSON"); - }); + return sections.join("\n"); + } - it("exports JSON from the focused editor with Ctrl/Cmd+Shift+S without saving", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type("# Shortcut Export\n\nUnsaved content"); - cy.get("#dirtyDot").should("be.visible"); + async function openWorkspace(): Promise { + if (!fsApi.isFileSystemApiAvailable()) { + activateTemporarySession(); + els.tree.innerHTML = '
Temporary session. Create a note to begin.
'; - cy.get("#editor") - .focus() - .then(($editor) => { - dispatchShortcut($editor[0], { - key: "s", - shiftKey: true, - ...getPlatformModifier($editor[0]), - }); + showToast("Temporary in-memory workspace enabled. Use Export to save your notes.", { + persist: true, }); + setStatus("Temporary session", "warn"); + return; + } - cy.get("#statusBadge").should("contain", "Exported JSON"); - cy.get("#dirtyDot").should("be.visible"); - }); - - it("saves from the focused editor with the platform save shortcut", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - cy.get("#dirtyDot").should("be.visible"); + if (isOpeningWorkspace) { + setStatus("Folder picker already open", "warn"); + return; + } - cy.get("#editor") - .focus() - .then(($editor) => { - dispatchShortcut($editor[0], { - key: "s", - ...getPlatformModifier($editor[0]), - }); - }); + isOpeningWorkspace = true; + cancelActiveScan(); + recordWorkspaceInteraction(); + autoRefresh.stopAutoRefresh(); + setStatus("Choose a workspace folder..."); - cy.get("#statusBadge").should("contain", "Saved"); - cy.get("#dirtyDot").should("not.be.visible"); - }); + try { + if (!window.showDirectoryPicker) { + throw new Error("File System Access API not available"); + } - it("opens existing note from tree in temporary session", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type("# Original Content"); - cy.get("[data-action=\"save\"]").click({ force: true }); + const directory = await window.showDirectoryPicker({ id: "local-md-workspace", mode: "readwrite" }); + setStatus("Checking folder access..."); + const permissionGranted = await fsApi.ensurePermission(directory, "readwrite"); + if (!permissionGranted) { + showToast("Permission denied. Please allow access to the folder.", { persist: true }); + setStatus("Permission denied", "err"); + return; + } - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type("# Another Note"); - cy.get("[data-action=\"save\"]").click({ force: true }); + state.workspaceHandle = directory; + state.workspaceName = directory.name || "Selected folder"; + state.isTemporarySession = false; + state.collapsedDirs.clear(); + state.tagFilter = ""; - cy.get(".node").first().click(); + els.temporarySessionBadge.style.display = "none"; + els.workspaceName.textContent = state.workspaceName; + els.workspaceName.title = state.workspaceName; + els.tagRow.innerHTML = ""; - cy.get("#currentFilename").should("contain", ".md"); - }); + setStatus("Scanning folder..."); + await rescanWorkspace({ silent: true, throwOnError: true, showProgress: true }); + autoRefresh.startAutoRefresh(); - it("shows unsaved changes indicator when editing", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#currentFilename").should("contain", ".md"); + showToast("Workspace opened."); + setStatus("Workspace ready", "ok"); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + setStatus("Open folder cancelled"); + return; + } - cy.get("#editor").type(" - added content", { force: true }); - cy.get("#dirtyDot").should("be.visible"); - }); + const message = error instanceof Error ? error.message : String(error); + showToast(`Failed to open folder: ${message}`, { persist: true }); + setStatus("Failed to open folder", "err"); + } finally { + isOpeningWorkspace = false; + } + } - it("renders markdown preview in temporary session", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); + async function rescanWorkspace(options: RescanOptions = {}): Promise { + if (!state.workspaceHandle) { + return false; + } - cy.get("#editor").clear().type("## Section\n\nSome **bold** text."); + if (activeRescan && activeRescanHandle === state.workspaceHandle) { + return activeRescan; + } - cy.get("#preview").find("h2").should("contain", "Section"); - cy.get("#preview").find("strong").should("contain", "bold"); - }); + const workspaceHandle = state.workspaceHandle; + const workspaceName = state.workspaceName; + const currentScanGeneration = scanGeneration + 1; + scanGeneration = currentScanGeneration; - it("displays tags after creating notes with hashtags", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type("# Note with work tag"); - cy.get("[data-action=\"save\"]").click({ force: true }); + const scanPromise = (async () => { + const permissionGranted = await fsApi.ensurePermission(workspaceHandle, "read"); + if (!permissionGranted) { + throw new Error("Folder permission not granted (read)"); + } - cy.get(".tagrow").should("exist"); - }); -}); -
+ const notes: NoteRecord[] = []; + const discoveredNotes: DiscoveredNote[] = []; + const rootNode: DirectoryNode = { + type: "dir", + name: workspaceName, + relPath: "", + children: [], + }; - -import type { DomRefs } from "./types"; -import type { LinterAnalysis } from "./document-linter/document-linter"; + await walkDirectory(workspaceHandle, rootNode, "", discoveredNotes); + if (scanGeneration !== currentScanGeneration) { + return false; + } + await hydrateDiscoveredNotes( + discoveredNotes, + notes, + options.showProgress === true, + currentScanGeneration, + ); + sortDirectoryTree(rootNode); -export const COGITO_PROMPT = `You are a writing coach. + if ( + state.workspaceHandle !== workspaceHandle + || scanGeneration !== currentScanGeneration + ) { + return false; + } -Rules: -- Do NOT write prose. -- Do NOT suggest sentences. -- Ask exactly 3 questions. -- Questions must be grounded in the user's last sentence. -- Use document analysis only to focus what the questions explore. -- Output JSON only in this format: -{ - "questions": ["...", "...", "..."] -}`; + state.notes = notes; + state.fileTree = rootNode; -export const LITE_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC"; -export const DEEP_MODEL = "Qwen3-8B-q4f16_1-MLC"; + els.countsPill.textContent = `${notes.length} note${notes.length === 1 ? "" : "s"}`; -export type CogitoModel = "lite" | "deep"; + renderTags(); + await renderTree(); -type ChatEngine = { - chat: { - completions: { - create: (payload: { - messages: Array<{ role: "system" | "user"; content: string }>; - temperature?: number; - }) => Promise<{ choices?: Array<{ message?: { content?: unknown } }> }>; - }; - }; -}; + if (!options.silent) { + showToast("Workspace refreshed."); + setStatus("Refreshed", "ok"); + } + return true; + })().catch((error: unknown) => { + if (options.throwOnError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + showToast(`Refresh failed: ${message}`, { persist: true }); + setStatus("Refresh failed", "err"); + return false; + }); -type WebLlmModule = { - prebuiltAppConfig: { - model_list: unknown[]; - [key: string]: unknown; - }; - deleteModelAllInfoInCache?: ( - modelId: string, - appConfig?: { - model_list: unknown[]; - [key: string]: unknown; - }, - ) => Promise; - CreateMLCEngine: ( - modelId: string, - options?: { - initProgressCallback?: (progress: { text?: string }) => void; - appConfig?: { - model_list: unknown[]; - cacheBackend: "indexeddb"; - [key: string]: unknown; - }; - }, - ) => Promise; -}; + activeRescanHandle = workspaceHandle; + const trackedScanPromise = scanPromise.finally(() => { + if (activeRescan === trackedScanPromise) { + activeRescan = null; + activeRescanHandle = null; + } + }); + activeRescan = trackedScanPromise; -type ToastFn = (message: string, options?: { persist?: boolean }) => void; + return activeRescan; + } -type SetStatusFn = (message: string, kind?: "ok" | "warn" | "err") => void; + async function walkDirectory( + dirHandle: DirectoryHandleLike, + parentNode: DirectoryNode, + relPathBase: string, + discoveredNotes: DiscoveredNote[], + ): Promise { + for await (const [name, handle] of dirHandle.entries()) { + if (name.startsWith(".")) { + continue; + } -type CogitoController = { - togglePanel: () => void; - setPanelOpen: (isOpen: boolean) => void; - isPanelOpen: () => boolean; - closePanel: () => void; - selectModel: (model: CogitoModel) => void; - generateQuestions: () => Promise; - insertQuestionAtIndex: (index: number) => void; - markAnalysisChanged: (revision: number) => void; -}; + if (handle.kind === "directory") { + const relPath = relPathBase ? `${relPathBase}/${name}` : name; + const directoryNode: DirectoryNode = { + type: "dir", + name, + relPath, + children: [], + }; -export type CogitoAnalysisContext = { - overallScore: number; - priorities: string[]; - strengths: string[]; -}; + parentNode.children.push(directoryNode); + await walkDirectory(handle, directoryNode, relPath, discoveredNotes); + continue; + } -const MAX_LAST_SENTENCE_CONTEXT_LENGTH = 1200; -const MAX_ANALYSIS_LINE_LENGTH = 240; -const MODEL_LOAD_RETRY_DELAY_MS = 750; + if (!name.toLowerCase().endsWith(".md")) { + continue; + } -function compactContextLine(value: string, maximumLength: number, keepEnd = false): string { - const normalized = value.replace(/\s+/g, " ").trim(); - if (normalized.length <= maximumLength) { - return normalized; - } - if (keepEnd) { - return `…${normalized.slice(-(maximumLength - 1))}`; + const relPath = relPathBase ? `${relPathBase}/${name}` : name; + discoveredNotes.push({ + handle: handle as FileHandleLike, + name, + relPath, + parentNode, + }); + } } - return `${normalized.slice(0, maximumLength - 1)}…`; -} -export function isRecoverableModelLoadError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return /cache|network|fetch|failed to execute ['"]?add|load failed|connection/i.test(message); -} + async function hydrateDiscoveredNotes( + discoveredNotes: DiscoveredNote[], + notes: NoteRecord[], + showProgress: boolean, + currentScanGeneration: number, + ): Promise { + const MAX_BYTES = 256 * 1024; + let nextIndex = 0; + let completedCount = 0; + + async function hydrateNext(): Promise { + const index = nextIndex; + nextIndex += 1; + if (index >= discoveredNotes.length) { + return; + } + if (scanGeneration !== currentScanGeneration) { + return; + } + + const discovered = discoveredNotes[index]; + try { + const file = await discovered.handle.getFile(); + const blob = file.size > MAX_BYTES ? file.slice(0, MAX_BYTES) : file; + const text = await blob.text(); + const note: NoteRecord = { + handle: discovered.handle, + name: discovered.name, + relPath: discovered.relPath, + lastModified: file.lastModified || 0, + size: file.size || 0, + tags: parseTags(text), + }; + notes.push(note); + const fileNode: FileNode = { + type: "file", + name: discovered.name, + relPath: discovered.relPath, + handle: discovered.handle, + noteRef: note, + }; + discovered.parentNode.children.push(fileNode); + } catch { + showToast(`Skipped a file that couldn't be read: ${discovered.relPath}`); + } finally { + completedCount += 1; + if ( + showProgress + && (completedCount === discoveredNotes.length || completedCount % 25 === 0) + ) { + setStatus(`Loading notes ${completedCount}/${discoveredNotes.length}...`); + } + } -function wait(milliseconds: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} + if (scanGeneration === currentScanGeneration) { + await hydrateNext(); + } + } -export function buildCogitoAnalysisContext(analysis: LinterAnalysis | null): CogitoAnalysisContext | null { - if (!analysis) { - return null; + const workerCount = Math.min(FILE_READ_CONCURRENCY, discoveredNotes.length); + await Promise.all(Array.from({ length: workerCount }, () => hydrateNext())); } - return { - overallScore: analysis.overallScore, - priorities: analysis.overview.priorities - .slice(0, 3) - .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)), - strengths: analysis.overview.strengths - .slice(0, 2) - .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)), - }; -} + function sortDirectoryTree(directory: DirectoryNode): void { + for (const child of directory.children) { + if (child.type === "dir") { + sortDirectoryTree(child); + } + } -export function buildCogitoUserPrompt( - lastSentence: string, - analysisContext: CogitoAnalysisContext | null, -): string { - const compactLastSentence = compactContextLine( - lastSentence, - MAX_LAST_SENTENCE_CONTEXT_LENGTH, - true, - ); - const lines = [`Last sentence: ${compactLastSentence}`]; - if (!analysisContext) { - lines.push("Document analysis: unavailable. Focus only on the last sentence."); - return lines.join("\n"); + directory.children.sort((a: TreeNode, b: TreeNode) => { + if (a.type !== b.type) { + return a.type === "dir" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); } - const compactPriorities = analysisContext.priorities - .slice(0, 3) - .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)); - const compactStrengths = analysisContext.strengths - .slice(0, 2) - .map((line) => compactContextLine(line, MAX_ANALYSIS_LINE_LENGTH)); - lines.push( - `Document strength: ${analysisContext.overallScore}/100`, - `Highest-priority improvements: ${compactPriorities.join(" | ") || "No major fixes identified."}`, - `Current strengths: ${compactStrengths.join(" | ") || "No clear strengths identified yet."}`, - ); - return lines.join("\n"); -} + async function openNoteByRelPath(relPath: string, handleHint: FileHandleLike | null = null): Promise { + if (state.isDirty && state.currentFileHandle) { + const shouldDiscard = confirm("You have unsaved changes. Discard them?"); + if (!shouldDiscard) { + return; + } + } -export function extractLastSentence(text: string): string { - const normalized = text.replace(/\s+/g, " ").trim(); - if (!normalized) { - return ""; - } + cancelActiveScan(); + recordWorkspaceInteraction(); - const fragments = normalized - .split(/(?<=[.!?])\s+/) - .map((fragment) => fragment.trim()) - .filter(Boolean); + try { + const handle = handleHint || state.notes.find((note) => note.relPath === relPath)?.handle; + if (!handle) { + throw new Error("File not found"); + } - if (fragments.length === 0) { - return ""; - } + const file = await handle.getFile(); + const text = await file.text(); - return fragments[fragments.length - 1]; -} + state.currentFileHandle = handle; + state.currentRelPath = relPath; + state.currentContent = text; + state.isDirty = false; -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 }; + els.editor.value = text; + renderPreview(els, text); + updateDirtyUi(els, state, setStatus); + setStatus("Opened ✓", "ok"); - if (!parsed || !Array.isArray(parsed.questions)) { - throw new Error("Cogito response did not include a questions array."); + await renderTree(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast(`Failed to open note: ${message}`, { persist: true }); + setStatus("Open failed", "err"); + } } - const sanitized = parsed.questions - .filter((q): q is string => typeof q === "string" && q.trim().length > 0) - .map((q) => q.trim()); + async function saveCurrentNote(): Promise { + if (state.isTemporarySession) { + return saveInMemoryNote(); + } - if (sanitized.length === 0) { - throw new Error("Cogito response contained no valid questions."); - } + if (!state.currentFileHandle) { + return; + } - if (sanitized.length !== 3) { - throw new Error("Cogito response must contain exactly 3 questions."); - } + cancelActiveScan(); + recordWorkspaceInteraction(); - return sanitized; -} + try { + const writable = await state.currentFileHandle.createWritable(); + await writable.write(els.editor.value); + await writable.close(); -export function formatCogitoQuestionBlock(question: string): string { - return `> ### AI\n${question.trim()}\n`; -} + state.currentContent = els.editor.value; + state.isDirty = false; + updateDirtyUi(els, state, setStatus); -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); -} + setStatus("Saved ✓", "ok"); + showToast("Saved ✓"); -export function createCogitoController({ - els, - getEditorText, - onEditorContentReplaced, - showToast, - setStatus, - getDocumentAnalysis, - getAnalysisRevision, -}: { - els: DomRefs; - getEditorText: () => string; - onEditorContentReplaced: (text: string) => void; - showToast: ToastFn; - setStatus: SetStatusFn; - getDocumentAnalysis: () => LinterAnalysis | null; - getAnalysisRevision: () => number; -}): CogitoController { - let isPanelOpen = false; - let generatedQuestions: string[] = []; - let selectedModel: CogitoModel = "lite"; - let generatedAnalysisRevision: number | null = null; - const engineCache: Partial>> = {}; + await rescanWorkspace({ silent: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast(`Save failed: ${message}`, { persist: true }); + setStatus("Save failed", "err"); + } + } - async function loadWebLlmModule(): Promise { - const testModule = ( - globalThis as typeof globalThis & { - __INK_TEST_WEBLLM__?: WebLlmModule; - } - ).__INK_TEST_WEBLLM__; + async function createNewNote(): Promise { + if (!state.workspaceHandle && !state.isTemporarySession) { + showToast("Open a workspace first."); + return; + } - if (testModule) { - return testModule; + if (state.isDirty) { + const shouldContinue = confirm("You have unsaved changes. Continue and discard them?"); + if (!shouldContinue) { + return; + } } - return import("https://esm.run/@mlc-ai/web-llm") as Promise; - } + const name = prompt("New note name (without .md)"); + if (!name) { + return; + } - function selectModel(model: CogitoModel): void { - selectedModel = model; - els.cogitoLiteBtn.classList.toggle("active", model === "lite"); - els.cogitoDeepBtn.classList.toggle("active", model === "deep"); - } + const fileName = name.endsWith(".md") ? name : `${name}.md`; - 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); + if (state.isTemporarySession) { + return createInMemoryNote(fileName, name); } - } - function updateQuestionList(questions: string[]): void { - els.cogitoQuestionList.innerHTML = ""; - questions.forEach((question, index) => { - const item = document.createElement("li"); - item.className = "cogitoQuestionItem"; + try { + if (!state.workspaceHandle) { + throw new Error("Workspace handle is not available"); + } + + const workspaceHandle = state.workspaceHandle; + for await (const [existingName] of workspaceHandle.entries()) { + if (existingName === fileName) { + showToast("A file with that name already exists.", { persist: true }); + return; + } + } + + const fileHandle = await state.workspaceHandle.getFileHandle(fileName, { create: true }); + const initialContent = `# ${name}\n\nCreated ${new Date().toLocaleString()}\n`; - const text = document.createElement("p"); - text.className = "cogitoQuestionText"; - text.textContent = question; + const writable = await fileHandle.createWritable(); + await writable.write(initialContent); + await writable.close(); - 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"; + await rescanWorkspace({ silent: true }); + await openNoteByRelPath(fileName, fileHandle); - item.append(text, button); - els.cogitoQuestionList.appendChild(item); - }); + showToast("New note created ✓"); + setStatus("New note", "ok"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast(`Failed to create note: ${message}`, { persist: true }); + setStatus("Create failed", "err"); + } } - function setCogitoStatus(text: string): void { - els.cogitoStatus.textContent = text; - } + async function saveAsNewNote(): Promise { + if (!state.workspaceHandle && !state.isTemporarySession) { + showToast("Open a workspace first."); + return; + } - function getModelId(model: CogitoModel): string { - return model === "deep" ? DEEP_MODEL : LITE_MODEL; - } + const currentContent = state.isTemporarySession ? els.editor.value : state.currentContent; - function getModelLabel(model: CogitoModel): string { - return model === "deep" ? "Deep (Qwen3 8B)" : "Lite (Llama 1B)"; - } + if (!currentContent) { + showToast("Nothing to save."); + return; + } - async function createEngineWithRecovery(model: CogitoModel): Promise { - const modelId = getModelId(model); - const webllm = await loadWebLlmModule(); - const appConfig = { - ...webllm.prebuiltAppConfig, - cacheBackend: "indexeddb" as const, - }; + const name = prompt("Save note as (filename without .md):"); + if (!name) { + return; + } - async function createEngine(): Promise { - return webllm.CreateMLCEngine(modelId, { - appConfig, - initProgressCallback: (progress: { text?: string }) => { - if (progress?.text) { - setCogitoStatus(progress.text); - } - }, - }); + const fileName = name.endsWith(".md") ? name : `${name}.md`; + + if (state.isTemporarySession) { + return createInMemoryNote(fileName, name); } try { - return await createEngine(); - } catch (error: unknown) { - if (!isRecoverableModelLoadError(error)) { - throw error; + if (!state.workspaceHandle) { + throw new Error("Workspace handle is not available"); } - setCogitoStatus(`Repairing ${getModelLabel(model)} cache and retrying...`); - try { - await webllm.deleteModelAllInfoInCache?.(modelId, appConfig); - } catch { - // Cache cleanup is best-effort; the retry may still succeed. + for await (const [existingName] of state.workspaceHandle.entries()) { + if (existingName === fileName) { + showToast("A file with that name already exists.", { persist: true }); + return; + } } - await wait(MODEL_LOAD_RETRY_DELAY_MS); - return createEngine(); - } - } - async function getOrCreateEngine(model: CogitoModel = selectedModel): Promise { - if (engineCache[model]) { - return engineCache[model]!; - } + const fileHandle = await state.workspaceHandle.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(currentContent); + await writable.close(); - setCogitoStatus(`Loading ${getModelLabel(model)} model...`); - engineCache[model] = createEngineWithRecovery(model).catch((error: unknown) => { - delete engineCache[model]; - throw error; - }); + await rescanWorkspace({ silent: true }); + await openNoteByRelPath(fileName, fileHandle); - return engineCache[model]!; + showToast(`Saved as ${fileName} ✓`); + setStatus("Saved as", "ok"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast(`Failed to save: ${message}`, { persist: true }); + setStatus("Save failed", "err"); + } } - async function getEngineWithFallback(): Promise { - try { - return await getOrCreateEngine(selectedModel); - } catch (error: unknown) { - if (selectedModel !== "deep" || !isRecoverableModelLoadError(error)) { - throw error; + async function createNewFolder(parentHandle: DirectoryHandleLike | null = state.workspaceHandle): Promise { + if (!parentHandle) { + if (state.isTemporarySession) { + showToast("Folders are not supported in temporary session."); + return; } + showToast("Open a workspace first."); + return; + } - setCogitoStatus("Deep model download failed. Falling back to Lite..."); - selectModel("lite"); - return getOrCreateEngine("lite"); + const folderName = prompt("Folder name:"); + if (!folderName) { + return; + } + + try { + await parentHandle.getDirectoryHandle(folderName, { create: true }); + await rescanWorkspace({ silent: true }); + showToast("Folder created ✓"); + setStatus("Folder created", "ok"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast(`Failed to create folder: ${message}`, { persist: true }); + setStatus("Create folder failed", "err"); } } - 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"); + async function createInMemoryNote(fileName: string, name: string): Promise { + const existingNote = state.inMemoryNotes.find((n) => n.relPath === fileName); + if (existingNote) { + showToast("A note with that name already exists.", { persist: true }); return; } - try { - els.cogitoGenerateBtn.disabled = true; - setCogitoStatus("Generating 3 questions..."); + const initialContent = `# ${name}\n\nCreated ${new Date().toLocaleString()}\n`; - const engine = await getEngineWithFallback(); - const analysisContext = buildCogitoAnalysisContext(getDocumentAnalysis()); - const analysisRevision = getAnalysisRevision(); - const completion = await engine.chat.completions.create({ - messages: [ - { role: "system", content: COGITO_PROMPT }, - { role: "user", content: buildCogitoUserPrompt(lastSentence, analysisContext) }, - ], - temperature: 0.2, - }); + const note: InMemoryNoteRecord = { + name: fileName, + relPath: fileName, + content: initialContent, + lastModified: Date.now(), + tags: parseTags(initialContent), + }; - 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() - : ""; + state.inMemoryNotes.push(note); + state.currentRelPath = fileName; + state.currentContent = initialContent; + state.isDirty = false; - if (!textContent) { - throw new Error("Cogito returned an empty response."); - } + els.editor.value = initialContent; + renderPreview(els, initialContent); + updateDirtyUi(els, state, setStatus); + renderInMemoryTree(); + updateCountsPill(); - generatedQuestions = parseCogitoQuestionPayload(textContent); - generatedAnalysisRevision = analysisContext ? analysisRevision : null; - updateQuestionList(generatedQuestions); - setCogitoStatus( - analysisContext - ? "Questions ready and focused by the current document analysis." - : "Questions ready. Analyze the document for more focused coaching.", - ); - setStatus("Cogito questions ready", "ok"); - } catch (error: unknown) { - generatedQuestions = []; - updateQuestionList(generatedQuestions); - const message = error instanceof Error ? error.message : String(error); - const friendlyMessage = isRecoverableModelLoadError(error) - ? "The local model could not finish downloading. Check the connection and available browser storage, then retry." - : message; - setCogitoStatus(`Cogito error: ${friendlyMessage}`); - setStatus("Cogito unavailable", "warn"); - showToast(`Cogito failed: ${friendlyMessage}`, { persist: true }); - } finally { - els.cogitoGenerateBtn.disabled = false; - } + showToast("New note created ✓"); + setStatus("New note", "ok"); } - function insertQuestionAtIndex(index: number): void { - const question = generatedQuestions[index]; - if (!question) { - showToast("Cogito question not found.", { persist: true }); - return; + async function createNoteFromTool(input: DeclarativeNoteInput): Promise { + const title = input.title.trim(); + const body = input.body.trim(); + const tag = input.tag.trim(); + + if (!title || !body) { + const message = "Title and body are required."; + showToast(message, { persist: true }); + setStatus("Create failed", "err"); + return { ok: false, message }; } - const block = formatCogitoQuestionBlock(question); - insertTextAtCursor(els.editor, block); - onEditorContentReplaced(els.editor.value); - setStatus("Inserted AI question", "ok"); - } + if (!state.workspaceHandle && !state.isTemporarySession) { + activateTemporarySession(); + showToast("Temporary in-memory workspace enabled for note creation.", { + persist: true, + }); + setStatus("Temporary session", "warn"); + } + + const fileName = buildNoteFileName(title); + const content = buildNoteContent({ title, body, tag }); + const keptCurrentNote = state.isDirty; + const successMessage = keptCurrentNote + ? `Created ${fileName}. Current unsaved note was left open.` + : `Created ${fileName} ✓`; + + if (state.isTemporarySession) { + const existingNote = state.inMemoryNotes.find((note) => note.relPath === fileName); + if (existingNote) { + const message = "A note with that name already exists."; + showToast(message, { persist: true }); + setStatus("Create failed", "err"); + return { ok: false, message }; + } - function markAnalysisChanged(revision: number): void { - if (generatedQuestions.length === 0 || generatedAnalysisRevision === revision) { - return; - } - setCogitoStatus("Document analysis changed. Regenerate to focus these questions on the latest findings."); - } + const note: InMemoryNoteRecord = { + name: fileName, + relPath: fileName, + content, + lastModified: Date.now(), + tags: parseTags(content), + }; - setPanelVisibility(false); + state.inMemoryNotes.push(note); - return { - togglePanel: () => { - setPanelVisibility(!isPanelOpen); - if (isPanelOpen) { - setCogitoStatus( - getDocumentAnalysis() - ? "Generate questions focused by the current document analysis." - : "Analyze first for more focused coaching, or generate from your latest sentence now.", - ); - } - }, - setPanelOpen: (nextIsOpen: boolean) => { - setPanelVisibility(nextIsOpen); - if (nextIsOpen) { - setCogitoStatus( - getDocumentAnalysis() - ? "Generate questions focused by the current document analysis." - : "Analyze first for more focused coaching, or generate from your latest sentence now.", - ); + if (!keptCurrentNote) { + state.currentRelPath = fileName; + state.currentContent = content; + state.isDirty = false; + els.editor.value = content; + renderPreview(els, content); + updateDirtyUi(els, state, setStatus); } - }, - isPanelOpen: () => isPanelOpen, - closePanel: () => { - setPanelVisibility(false); - }, - selectModel, - generateQuestions, - insertQuestionAtIndex, - markAnalysisChanged, - }; -} - - -import { icon } from "./icons"; -import type { - AppState, - DeclarativeNoteInput, - DeclarativeNoteResult, - DirectoryHandleLike, - DirectoryNode, - DomRefs, - FileHandleLike, - FileNode, - InMemoryNoteRecord, - NoteRecord, - TreeNode, -} from "./types"; + renderInMemoryTree(); + renderTags(); + updateCountsPill(); + showToast(successMessage); + setStatus("New note", "ok"); -type ToastFn = (message: string, options?: { persist?: boolean }) => void; -type StatusFn = (message: string | null, kind?: "neutral" | "ok" | "warn" | "err") => void; + return { + ok: true, + message: successMessage, + notePath: fileName, + sessionType: "temporary", + keptCurrentNote, + }; + } -type RenderPreviewFn = (els: DomRefs, text: string) => void; -type UpdateDirtyFn = (els: DomRefs, state: AppState, setStatus: StatusFn) => void; + try { + if (!state.workspaceHandle) { + throw new Error("Workspace handle is not available"); + } -type TreeRenderFns = { - renderTree: () => Promise; - renderInMemoryTree: () => void; - renderTags: () => void; - updateCountsPill: () => void; -}; + for await (const [existingName] of state.workspaceHandle.entries()) { + if (existingName === fileName) { + const message = "A file with that name already exists."; + showToast(message, { persist: true }); + setStatus("Create failed", "err"); + return { ok: false, message }; + } + } -type FsApi = { - ensurePermission: (handle: DirectoryHandleLike, mode: "read" | "readwrite") => Promise; - isFileSystemApiAvailable: () => boolean; -}; + const fileHandle = await state.workspaceHandle.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(content); + await writable.close(); -type AutoRefreshFns = { - startAutoRefresh: () => void; - stopAutoRefresh: () => void; -}; + await rescanWorkspace({ silent: true }); -type RescanOptions = { - silent?: boolean; - throwOnError?: boolean; - showProgress?: boolean; -}; + if (!keptCurrentNote) { + await openNoteByRelPath(fileName, fileHandle); + } else { + setStatus("New note", "ok"); + } -type DiscoveredNote = { - handle: FileHandleLike; - name: string; - relPath: string; - parentNode: DirectoryNode; -}; + showToast(successMessage); + return { + ok: true, + message: successMessage, + notePath: fileName, + sessionType: "workspace", + keptCurrentNote, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast(`Failed to create note: ${message}`, { persist: true }); + setStatus("Create failed", "err"); + return { ok: false, message }; + } + } -type TagParser = (text: string) => Set; -type TagNormalizer = (value: string) => string; -const FILE_READ_CONCURRENCY = 4; + async function openInMemoryNote(relPath: string): Promise { + if (state.isDirty && state.currentRelPath) { + const shouldDiscard = confirm("You have unsaved changes. Discard them?"); + if (!shouldDiscard) { + return; + } + } -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; -}) { - let isOpeningWorkspace = false; - let activeRescan: Promise | null = null; - let activeRescanHandle: DirectoryHandleLike | null = null; - let scanGeneration = 0; + const note = state.inMemoryNotes.find((n) => n.relPath === relPath); + if (!note) { + showToast("Note not found", { persist: true }); + return; + } - function recordWorkspaceInteraction(): void { - state.lastWorkspaceInteractionAt = Date.now(); - } + state.currentRelPath = relPath; + state.currentContent = note.content; + state.isDirty = false; - function cancelActiveScan(): void { - scanGeneration += 1; - activeRescan = null; - activeRescanHandle = null; - } + els.editor.value = note.content; + renderPreview(els, note.content); + updateDirtyUi(els, state, setStatus); + setStatus("Opened ✓", "ok"); - function activateTemporarySession(): void { - const wasTemporarySession = state.isTemporarySession; + renderInMemoryTree(); + } - state.workspaceHandle = null; - state.workspaceName = "Temporary Session"; - state.fileTree = null; - state.notes = []; - state.currentFileHandle = null; + async function saveInMemoryNote(): Promise { + if (!state.currentRelPath || !state.isTemporarySession) { + return; + } - if (!wasTemporarySession) { - state.inMemoryNotes = []; - state.currentRelPath = ""; - state.currentContent = ""; - state.isDirty = false; - els.editor.value = ""; - renderPreview(els, ""); - updateDirtyUi(els, state, setStatus); + const note = state.inMemoryNotes.find((n) => n.relPath === state.currentRelPath); + if (!note) { + showToast("Note not found", { persist: true }); + return; } - state.isTemporarySession = true; + note.content = els.editor.value; + note.lastModified = Date.now(); + note.tags = parseTags(note.content); + + state.currentContent = note.content; + state.isDirty = false; + updateDirtyUi(els, state, setStatus); + + setStatus("Saved ✓", "ok"); + showToast("Saved ✓"); - els.workspaceName.textContent = state.workspaceName; - els.workspaceName.title = "Temporary Session - Data not persisted"; - els.temporarySessionBadge.style.display = "inline"; - els.countsPill.textContent = `${state.inMemoryNotes.length} note${state.inMemoryNotes.length === 1 ? "" : "s"}`; - els.tagRow.innerHTML = ""; renderInMemoryTree(); renderTags(); } - function buildNoteFileName(title: string): string { - const sanitizedTitle = title - .trim() - .replace(/[\\/:*?"<>|]/g, " ") - .replace(/\s+/g, " ") - .trim(); + function exportAsJson(): void { + if (state.inMemoryNotes.length === 0) { + showToast("No notes to export."); + return; + } - const baseName = sanitizedTitle || "Untitled"; - return baseName.endsWith(".md") ? baseName : `${baseName}.md`; + const exportData = { + exportedAt: new Date().toISOString(), + notes: state.inMemoryNotes.map((note) => ({ + name: note.name, + path: note.relPath, + content: note.content, + lastModified: new Date(note.lastModified).toISOString(), + })), + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const date = new Date().toISOString().split("T")[0]; + const fileName = `ink-export-${date}.json`; + + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showToast(`Exported ${state.inMemoryNotes.length} note(s) as JSON.`); + setStatus("Exported JSON", "ok"); } - function buildNoteContent({ title, body, tag }: DeclarativeNoteInput): string { - const normalizedTitle = title.trim() || "Untitled"; - const normalizedBody = body.trim(); - const normalizedTag = normalizeTag(tag); - const sections: string[] = []; + function exportAsMarkdown(): void { + if (!state.currentRelPath) { + showToast("No note selected to export."); + return; + } - if (normalizedTag) { - sections.push("---", `tags: [${normalizedTag}]`, "---", ""); + const note = state.inMemoryNotes.find((n) => n.relPath === state.currentRelPath); + if (!note) { + showToast("Note not found.", { persist: true }); + return; } - sections.push(`# ${normalizedTitle}`); + const blob = new Blob([note.content], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); - if (normalizedBody) { - sections.push("", normalizedBody); - } + const a = document.createElement("a"); + a.href = url; + a.download = note.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); - return sections.join("\n"); + showToast(`Exported ${note.name} as Markdown.`); + setStatus("Exported MD", "ok"); } - async function openWorkspace(): Promise { - if (!fsApi.isFileSystemApiAvailable()) { - activateTemporarySession(); - els.tree.innerHTML = '
Temporary session. Create a note to begin.
'; - - showToast("Temporary in-memory workspace enabled. Use Export to save your notes.", { - persist: true, - }); - setStatus("Temporary session", "warn"); + function handleRefresh(): void { + if (state.isTemporarySession) { + showToast("Refresh not available in temporary session. Your data is in memory."); return; } - if (isOpeningWorkspace) { - setStatus("Folder picker already open", "warn"); + if (!state.workspaceHandle) { + showToast("No workspace open."); return; } - isOpeningWorkspace = true; cancelActiveScan(); recordWorkspaceInteraction(); + rescanWorkspace().catch((error: unknown) => { + showToast(`Refresh failed: ${String(error)}`, { persist: true }); + setStatus("Refresh failed", "err"); + }); + } + + function closeWorkspace(): void { autoRefresh.stopAutoRefresh(); - setStatus("Choose a workspace folder..."); + cancelActiveScan(); + recordWorkspaceInteraction(); + state.workspaceHandle = null; + state.workspaceName = ""; + state.fileTree = null; + state.notes = []; + state.currentFileHandle = null; + state.currentRelPath = ""; + state.currentContent = ""; + state.isDirty = false; + state.searchQuery = ""; + state.tagFilter = ""; + state.collapsedDirs.clear(); - try { - if (!window.showDirectoryPicker) { - throw new Error("File System Access API not available"); - } + els.workspaceName.textContent = "No folder selected"; + els.workspaceName.title = "No folder selected"; + els.countsPill.textContent = "0 notes"; + els.tagRow.innerHTML = ""; + els.tree.innerHTML = '
Open a folder to begin.
'; + els.editor.value = ""; + els.preview.innerHTML = ""; + els.currentFilename.textContent = "No note open"; + els.dirtyDot.classList.remove("show"); - const directory = await window.showDirectoryPicker({ id: "local-md-workspace", mode: "readwrite" }); - setStatus("Checking folder access..."); - const permissionGranted = await fsApi.ensurePermission(directory, "readwrite"); - if (!permissionGranted) { - showToast("Permission denied. Please allow access to the folder.", { persist: true }); - setStatus("Permission denied", "err"); - return; - } + setStatus("Ready"); + showToast("Workspace closed."); + } - state.workspaceHandle = directory; - state.workspaceName = directory.name || "Selected folder"; - state.isTemporarySession = false; - state.collapsedDirs.clear(); - state.tagFilter = ""; + return { + openWorkspace, + rescanWorkspace, + openNoteByRelPath, + saveCurrentNote, + createNewNote, + saveAsNewNote, + createNoteFromTool, + createNewFolder, + createInMemoryNote, + openInMemoryNote, + saveInMemoryNote, + exportAsJson, + exportAsMarkdown, + handleRefresh, + closeWorkspace, + }; +} +
- els.temporarySessionBadge.style.display = "none"; - els.workspaceName.textContent = state.workspaceName; - els.workspaceName.title = state.workspaceName; - els.tagRow.innerHTML = ""; + +# Contributing to ink - setStatus("Scanning folder..."); - await rescanWorkspace({ silent: true, throwOnError: true, showProgress: true }); - autoRefresh.startAutoRefresh(); +Thank you for your interest in contributing to ink! We appreciate every improvement, whether it is a bug fix, a documentation update, a design refinement, or a new feature. This guide is here to make contributing straightforward and consistent with how the project is built and maintained. - showToast("Workspace opened."); - setStatus("Workspace ready", "ok"); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - setStatus("Open folder cancelled"); - return; - } +## Table of Contents - const message = error instanceof Error ? error.message : String(error); - showToast(`Failed to open folder: ${message}`, { persist: true }); - setStatus("Failed to open folder", "err"); - } finally { - isOpeningWorkspace = false; - } - } +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [How to Contribute](#how-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Features](#suggesting-features) + - [Submitting Changes](#submitting-changes) +- [Coding Guidelines](#coding-guidelines) +- [Commit Messages](#commit-messages) +- [Style Guide](#style-guide) +- [Testing and Build Checks](#testing-and-build-checks) +- [License](#license) - async function rescanWorkspace(options: RescanOptions = {}): Promise { - if (!state.workspaceHandle) { - return false; - } +## Code of Conduct - if (activeRescan && activeRescanHandle === state.workspaceHandle) { - return activeRescan; - } +Please be respectful, constructive, and patient in all project discussions. We want ink to stay welcoming to contributors of all experience levels. - const workspaceHandle = state.workspaceHandle; - const workspaceName = state.workspaceName; - const currentScanGeneration = scanGeneration + 1; - scanGeneration = currentScanGeneration; +## Getting Started - const scanPromise = (async () => { - const permissionGranted = await fsApi.ensurePermission(workspaceHandle, "read"); - if (!permissionGranted) { - throw new Error("Folder permission not granted (read)"); - } +1. **Fork the repository**: Create a fork of the repository to work on your changes. +2. **Clone your fork**: Clone the repository to your local machine using `git clone`. +3. **Install dependencies**: Run `npm install`. +4. **Create a branch**: Create a branch for your work using `git checkout -b your-branch-name`. - const notes: NoteRecord[] = []; - const discoveredNotes: DiscoveredNote[] = []; - const rootNode: DirectoryNode = { - type: "dir", - name: workspaceName, - relPath: "", - children: [], - }; +## How to Contribute - await walkDirectory(workspaceHandle, rootNode, "", discoveredNotes); - if (scanGeneration !== currentScanGeneration) { - return false; - } - await hydrateDiscoveredNotes( - discoveredNotes, - notes, - options.showProgress === true, - currentScanGeneration, - ); - sortDirectoryTree(rootNode); +### Reporting Bugs + +If you find a bug, please open an issue with clear reproduction steps, the expected behavior, the actual behavior, and any screenshots or console output that help explain the problem. + +### Suggesting Features + +Feature ideas are welcome. Open an issue describing the problem you want to solve, how you expect the feature to work, and whether it affects editing, workspace management, export flows, or the single-file build output. + +### Submitting Changes + +1. **Keep changes focused**: Prefer small pull requests that solve one problem well. +2. **Follow the existing structure**: ink favors simple, explicit modules and predictable file organization. +3. **Run the relevant checks**: Build the app and run tests before opening a pull request. +4. **Open a pull request**: Include a clear summary of what changed, why it changed, and how you verified it. + +## Coding Guidelines + +- Follow the current project structure and keep `src/app.ts` as a thin entrypoint. +- Prefer small, explicit modules with flat control flow and minimal indirection. +- Preserve the single-file app output, `ink-app.html`. +- Add comments only when they explain an invariant, assumption, or external requirement. +- Keep changes easy to debug and easy to regenerate. - if ( - state.workspaceHandle !== workspaceHandle - || scanGeneration !== currentScanGeneration - ) { - return false; - } +## Commit Messages - state.notes = notes; - state.fileTree = rootNode; +- Use the present tense, such as `Add export validation`. +- Use the imperative mood, such as `Update workspace refresh flow`. +- Limit the first line to 72 characters or less when possible. +- Reference related issues or pull requests after the first line when relevant. +- Prefer Conventional Commit prefixes so release automation can classify changes: + `fix:` for patch releases, `feat:` for minor releases, and `feat!:` or a `BREAKING CHANGE:` footer for major releases. +- If you need to force a specific next version, add a `Release-As: x.y.z` footer to the merged commit body or release PR description. - els.countsPill.textContent = `${notes.length} note${notes.length === 1 ? "" : "s"}`; +## Release Process - renderTags(); - await renderTree(); +- Releases are prepared with `release-please`, which opens a release PR that updates `package.json`, `package-lock.json`, and the changelog. +- This repository uses plain `v*` Git tags for releases; keep `release-please` configured to match that tag history. +- When a release PR is merged, the release workflow finalizes it by creating the plain `v*` tag, adding a compatibility `ink-v*` tag for release-please state reconciliation, publishing the GitHub release from `v*`, and clearing stale autorelease labels. +- Merge the release PR through the normal protected-branch flow to create the Git tag and publish the GitHub release. +- If you want the release PR to trigger the normal PR checks automatically, configure a `RELEASE_PLEASE_TOKEN` secret with a PAT that can open pull requests in this repository. +- CI validation should happen in PR workflows before merge; the release workflow is intentionally limited to regenerating `ink-app.html` for the tagged release artifact instead of rerunning the full build/test suite after the protected-branch merge. - if (!options.silent) { - showToast("Workspace refreshed."); - setStatus("Refreshed", "ok"); - } - return true; - })().catch((error: unknown) => { - if (options.throwOnError) { - throw error; - } - const message = error instanceof Error ? error.message : String(error); - showToast(`Refresh failed: ${message}`, { persist: true }); - setStatus("Refresh failed", "err"); - return false; - }); +## Style Guide - activeRescanHandle = workspaceHandle; - const trackedScanPromise = scanPromise.finally(() => { - if (activeRescan === trackedScanPromise) { - activeRescan = null; - activeRescanHandle = null; - } - }); - activeRescan = trackedScanPromise; +- **JavaScript and TypeScript**: Prefer explicit, readable code over clever abstractions. +- **SCSS**: Keep styles aligned with the existing structure in `src/styles.scss`. +- **HTML**: Update `ink.template.html` when changing the app shell or document structure. +- **Build scripts**: Keep build logic simple and compatible with the single-file assembly flow in `build/compile-and-assemble.js`. - return activeRescan; - } +## Testing and Build Checks - async function walkDirectory( - dirHandle: DirectoryHandleLike, - parentNode: DirectoryNode, - relPathBase: string, - discoveredNotes: DiscoveredNote[], - ): Promise { - for await (const [name, handle] of dirHandle.entries()) { - if (name.startsWith(".")) { - continue; - } +Before submitting changes, run the checks that match your work: - if (handle.kind === "directory") { - const relPath = relPathBase ? `${relPathBase}/${name}` : name; - const directoryNode: DirectoryNode = { - type: "dir", - name, - relPath, - children: [], - }; +- `npm run build` to regenerate `ink-app.html` +- `npm run test:qunit` for unit and integration coverage +- `npm run test:cypress` for end-to-end coverage +- `npm test` for the full automated suite +- `make build` and `make test` are available as convenience wrappers - parentNode.children.push(directoryNode); - await walkDirectory(handle, directoryNode, relPath, discoveredNotes); - continue; - } +If your change affects favicon or branding assets, also run `npm run build:favicon`. - if (!name.toLowerCase().endsWith(".md")) { - continue; - } +## License - const relPath = relPathBase ? `${relPathBase}/${name}` : name; - discoveredNotes.push({ - handle: handle as FileHandleLike, - name, - relPath, - parentNode, - }); - } - } +By contributing to this project, you agree that your contributions will be licensed under the MIT License. - async function hydrateDiscoveredNotes( - discoveredNotes: DiscoveredNote[], - notes: NoteRecord[], - showProgress: boolean, - currentScanGeneration: number, - ): Promise { - const MAX_BYTES = 256 * 1024; - let nextIndex = 0; - let completedCount = 0; +--- - async function hydrateNext(): Promise { - const index = nextIndex; - nextIndex += 1; - if (index >= discoveredNotes.length) { - return; - } - if (scanGeneration !== currentScanGeneration) { - return; - } +Thank you for contributing to ink! + - const discovered = discoveredNotes[index]; - try { - const file = await discovered.handle.getFile(); - const blob = file.size > MAX_BYTES ? file.slice(0, MAX_BYTES) : file; - const text = await blob.text(); - const note: NoteRecord = { - handle: discovered.handle, - name: discovered.name, - relPath: discovered.relPath, - lastModified: file.lastModified || 0, - size: file.size || 0, - tags: parseTags(text), - }; - notes.push(note); - const fileNode: FileNode = { - type: "file", - name: discovered.name, - relPath: discovered.relPath, - handle: discovered.handle, - noteRef: note, - }; - discovered.parentNode.children.push(fileNode); - } catch { - showToast(`Skipped a file that couldn't be read: ${discovered.relPath}`); - } finally { - completedCount += 1; - if ( - showProgress - && (completedCount === discoveredNotes.length || completedCount % 25 === 0) - ) { - setStatus(`Loading notes ${completedCount}/${discoveredNotes.length}...`); - } - } + +# ink - if (scanGeneration === currentScanGeneration) { - await hydrateNext(); - } - } +

+ Ink logo +

- const workerCount = Math.min(FILE_READ_CONCURRENCY, discoveredNotes.length); - await Promise.all(Array.from({ length: workerCount }, () => hydrateNext())); - } +Ink is a browser-based markdown workspace for writing notes and documents. It opens a local folder of `.md` files, shows a searchable file tree, renders a live preview, and exports temporary notes without needing a backend server. - function sortDirectoryTree(directory: DirectoryNode): void { - for (const child of directory.children) { - if (child.type === "dir") { - sortDirectoryTree(child); - } - } +The project goal is deliberately narrow: keep the writing surface fast, local-first, understandable, and easy to ship as one self-contained HTML file. Ink is not a hosted notes service, not a database-backed knowledge base, and not a replacement for a full IDE. It is a small document editor that can live next to your files. - directory.children.sort((a: TreeNode, b: TreeNode) => { - if (a.type !== b.type) { - return a.type === "dir" ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - } +![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) - async function openNoteByRelPath(relPath: string, handleHint: FileHandleLike | null = null): Promise { - if (state.isDirty && state.currentFileHandle) { - const shouldDiscard = confirm("You have unsaved changes. Discard them?"); - if (!shouldDiscard) { - return; - } - } +## Quick Demo - cancelActiveScan(); - recordWorkspaceInteraction(); +![Ink demo](assets/demo/ink-demo.gif) - try { - const handle = handleHint || state.notes.find((note) => note.relPath === relPath)?.handle; - if (!handle) { - throw new Error("File not found"); - } +## What Ink Does - const file = await handle.getFile(); - const text = await file.text(); +Ink provides the everyday loop for local markdown writing: - state.currentFileHandle = handle; - state.currentRelPath = relPath; - state.currentContent = text; - state.isDirty = false; +- Open a local folder as a workspace. +- Scan nested folders for `.md` files. +- Create markdown notes and folders. +- Edit markdown with source, split, or preview-only modes. +- Save changes back to the selected local file. +- Search across filenames and note contents. +- Filter notes by inline tags and frontmatter tags. +- Sort notes by name or modified date. +- Refresh manually or let idle auto-refresh rescan the workspace. +- Use temporary in-memory notes when the browser cannot provide folder access. +- Export temporary notes as JSON or the selected note as Markdown. +- Analyze document strength and generate local Cogito writing questions. +- Switch between built-in visual themes. - els.editor.value = text; - renderPreview(els, text); - updateDirtyUi(els, state, setStatus); - setStatus("Opened ✓", "ok"); +Ink keeps normal workspace notes in your folder as plain `.md` files. The main app has no server-side storage layer. Browser storage is used only for preferences, temporary model cache, and temporary-session behavior. - await renderTree(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - showToast(`Failed to open note: ${message}`, { persist: true }); - setStatus("Open failed", "err"); - } - } +## Status - async function saveCurrentNote(): Promise { - if (state.isTemporarySession) { - return saveInMemoryNote(); - } +Ink is usable, but still a small evolving application. The current app is strongest for individual markdown folders and personal writing workflows. - if (!state.currentFileHandle) { - return; - } +Known boundaries: - cancelActiveScan(); - recordWorkspaceInteraction(); +- Workspace persistence depends on the browser File System Access API. +- Temporary sessions are in memory and must be exported before leaving the page. +- Cogito loads browser-side language models and may need network access, IndexedDB storage, and a capable browser/device. +- The final distributable is a generated file, `ink-app.html`; edit source files and rebuild rather than hand-editing generated output. - try { - const writable = await state.currentFileHandle.createWritable(); - await writable.write(els.editor.value); - await writable.close(); +## Browser Support - state.currentContent = els.editor.value; - state.isDirty = false; - updateDirtyUi(els, state, setStatus); +For full local folder editing, use a Chromium-based browser with the File System Access API, such as Chrome or Edge. - setStatus("Saved ✓", "ok"); - showToast("Saved ✓"); +When folder access is unavailable, Ink falls back to a temporary in-memory session. This mode is useful for mobile browsers, unsupported desktop browsers, or quick trials, but it cannot persist notes to your filesystem. Use the export commands before closing the tab. - await rescanWorkspace({ silent: true }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - showToast(`Save failed: ${message}`, { persist: true }); - setStatus("Save failed", "err"); - } - } +The editor itself is a client-side web app. A built `ink-app.html` can be opened directly in a browser, hosted from static file hosting, or served locally during development. - async function createNewNote(): Promise { - if (!state.workspaceHandle && !state.isTemporarySession) { - showToast("Open a workspace first."); - return; - } +## Getting Started As A User - if (state.isDirty) { - const shouldContinue = confirm("You have unsaved changes. Continue and discard them?"); - if (!shouldContinue) { - return; - } - } +If you only want to use Ink: + +1. Open `ink-app.html` in a modern browser. +2. Choose `File -> Open Workspace`. +3. Select a folder that contains markdown files, or an empty folder where Ink can create them. +4. Allow read/write folder permission when the browser asks. +5. Choose `File -> New Note`, type a note name, and start writing. +6. Save with `Edit -> Save` or `Ctrl/Cmd + S`. - const name = prompt("New note name (without .md)"); - if (!name) { - return; - } +Ink reads `.md` files recursively. Files with other extensions are ignored by the workspace tree. - const fileName = name.endsWith(".md") ? name : `${name}.md`; +## Workspace Model - if (state.isTemporarySession) { - return createInMemoryNote(fileName, name); - } +A workspace is a local directory handle selected through the browser. Ink stores the handle in memory for the current page session and requests read/write permission before scanning or saving. - try { - if (!state.workspaceHandle) { - throw new Error("Workspace handle is not available"); - } +The scan behavior is intentionally simple: - const workspaceHandle = state.workspaceHandle; - for await (const [existingName] of workspaceHandle.entries()) { - if (existingName === fileName) { - showToast("A file with that name already exists.", { persist: true }); - return; - } - } +- Hidden files and folders whose names start with `.` are skipped. +- Only `.md` files are listed. +- Nested folders are shown as collapsible tree nodes. +- Note metadata is read from the first 256 KB of each file during scans. +- Folder scans read several files concurrently so large folders stay responsive. - const fileHandle = await state.workspaceHandle.getFileHandle(fileName, { create: true }); - const initialContent = `# ${name}\n\nCreated ${new Date().toLocaleString()}\n`; +Ink does not create an app-specific database for workspace notes. The markdown files remain normal files that can be edited with any other editor. - const writable = await fileHandle.createWritable(); - await writable.write(initialContent); - await writable.close(); +## Markdown And Tags - await rescanWorkspace({ silent: true }); - await openNoteByRelPath(fileName, fileHandle); +Markdown rendering uses `marked` and is configured with line breaks enabled. The preview updates as you type. - showToast("New note created ✓"); - setStatus("New note", "ok"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - showToast(`Failed to create note: ${message}`, { persist: true }); - setStatus("Create failed", "err"); - } - } +Tags are discovered from two places: - async function saveAsNewNote(): Promise { - if (!state.workspaceHandle && !state.isTemporarySession) { - showToast("Open a workspace first."); - return; - } +```markdown +--- +tags: [draft, research] +--- - const currentContent = state.isTemporarySession ? els.editor.value : state.currentContent; +# A tagged note - if (!currentContent) { - showToast("Nothing to save."); - return; - } +Inline tags like #idea and #writing are also recognized. +``` - const name = prompt("Save note as (filename without .md):"); - if (!name) { - return; - } +Frontmatter supports inline lists like `tags: [draft, research]` and block lists: - const fileName = name.endsWith(".md") ? name : `${name}.md`; +```yaml +--- +tags: + - draft + - research +--- +``` - if (state.isTemporarySession) { - return createInMemoryNote(fileName, name); - } +Tags are normalized to lowercase and shown as filter buttons in the workspace sidebar. - try { - if (!state.workspaceHandle) { - throw new Error("Workspace handle is not available"); - } +## Editor Views - for await (const [existingName] of state.workspaceHandle.entries()) { - if (existingName === fileName) { - showToast("A file with that name already exists.", { persist: true }); - return; - } - } +Ink has three editor modes: - const fileHandle = await state.workspaceHandle.getFileHandle(fileName, { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(currentContent); - await writable.close(); +- `Source`: markdown editor only. +- `Split`: markdown editor and rendered preview side by side. +- `Preview`: rendered preview only. - await rescanWorkspace({ silent: true }); - await openNoteByRelPath(fileName, fileHandle); +The chosen mode is stored in `localStorage` under `ink-editor-view-mode`. - showToast(`Saved as ${fileName} ✓`); - setStatus("Saved as", "ok"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - showToast(`Failed to save: ${message}`, { persist: true }); - setStatus("Save failed", "err"); - } - } +## Menus And Shortcuts - async function createNewFolder(parentHandle: DirectoryHandleLike | null = state.workspaceHandle): Promise { - if (!parentHandle) { - if (state.isTemporarySession) { - showToast("Folders are not supported in temporary session."); - return; - } - showToast("Open a workspace first."); - return; - } +Ink uses a compact menu bar instead of a large toolbar. On macOS, shortcut hints use `Cmd`; on other platforms they use `Ctrl`. - const folderName = prompt("Folder name:"); - if (!folderName) { - return; - } +| Action | Menu | Shortcut | +| --- | --- | --- | +| New note | `File -> New Note` | `Ctrl/Cmd + E` | +| New folder | `File -> New Folder` | none | +| Open workspace | `File -> Open Workspace` | `Ctrl/Cmd + Shift + O` | +| Close workspace | `File -> Close Workspace` | none | +| Export temporary notes as JSON | `File -> Export -> Export JSON` | `Ctrl/Cmd + Shift + S` | +| Export selected temporary note as Markdown | `File -> Export -> Export Markdown` | `Ctrl/Cmd + Shift + M` | +| Save current note | `Edit -> Save` | `Ctrl/Cmd + S` | +| Save as a new note | `Edit -> Save As...` | none | +| Refresh workspace | `Edit -> Refresh` | `Ctrl/Cmd + L` | +| Toggle sort mode | `Edit -> Sort` or sidebar `Sort` | none | - try { - await parentHandle.getDirectoryHandle(folderName, { create: true }); - await rescanWorkspace({ silent: true }); - showToast("Folder created ✓"); - setStatus("Folder created", "ok"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - showToast(`Failed to create folder: ${message}`, { persist: true }); - setStatus("Create folder failed", "err"); - } - } +The sidebar also includes search, refresh, tag filters, note counts, and a collapse control. - async function createInMemoryNote(fileName: string, name: string): Promise { - const existingNote = state.inMemoryNotes.find((n) => n.relPath === fileName); - if (existingNote) { - showToast("A note with that name already exists.", { persist: true }); - return; - } +## Temporary Sessions - const initialContent = `# ${name}\n\nCreated ${new Date().toLocaleString()}\n`; +Temporary sessions are enabled automatically when `showDirectoryPicker` is unavailable. They can also be triggered by agent-created notes before a workspace is open. - const note: InMemoryNoteRecord = { - name: fileName, - relPath: fileName, - content: initialContent, - lastModified: Date.now(), - tags: parseTags(initialContent), - }; +Temporary-session behavior: - state.inMemoryNotes.push(note); - state.currentRelPath = fileName; - state.currentContent = initialContent; - state.isDirty = false; +- Notes exist only in browser memory. +- Folders are not supported. +- Save updates the in-memory note. +- Refresh is disabled because there is no filesystem to scan. +- `Export JSON` downloads all temporary notes. +- `Export Markdown` downloads the selected temporary note. - els.editor.value = initialContent; - renderPreview(els, initialContent); - updateDirtyUi(els, state, setStatus); - renderInMemoryTree(); - updateCountsPill(); +Use temporary sessions for quick drafting or unsupported browsers, but treat export as mandatory if the work matters. - showToast("New note created ✓"); - setStatus("New note", "ok"); - } +## Cogito Writing Assistance - async function createNoteFromTool(input: DeclarativeNoteInput): Promise { - const title = input.title.trim(); - const body = input.body.trim(); - const tag = input.tag.trim(); +Cogito is the writing-assistance panel opened from the top-right `Cogito` button. It has two parts. - if (!title || !body) { - const message = "Title and body are required."; - showToast(message, { persist: true }); - setStatus("Create failed", "err"); - return { ok: false, message }; - } +`Document strength` is deterministic analysis. It scores and explains clarity, structure, readability, engagement, and section-level priorities without using a language model. You can run it manually, enable rerun-on-change, navigate suggestions, and export a Markdown report. - if (!state.workspaceHandle && !state.isTemporarySession) { - activateTemporarySession(); - showToast("Temporary in-memory workspace enabled for note creation.", { - persist: true, - }); - setStatus("Temporary session", "warn"); - } +`Improve with Cogito` generates exactly three question-only coaching prompts grounded in the latest sentence. If document analysis is available, Cogito uses a compact summary of strengths and priorities to focus those questions. Questions are not inserted automatically; each one has an explicit insert action. - const fileName = buildNoteFileName(title); - const content = buildNoteContent({ title, body, tag }); - const keptCurrentNote = state.isDirty; - const successMessage = keptCurrentNote - ? `Created ${fileName}. Current unsaved note was left open.` - : `Created ${fileName} ✓`; +Cogito model options: - if (state.isTemporarySession) { - const existingNote = state.inMemoryNotes.find((note) => note.relPath === fileName); - if (existingNote) { - const message = "A note with that name already exists."; - showToast(message, { persist: true }); - setStatus("Create failed", "err"); - return { ok: false, message }; - } +- `Lite`: `Llama-3.2-1B-Instruct-q4f32_1-MLC`, the faster default. +- `Deep`: `Qwen3-8B-q4f16_1-MLC`, a larger model with fallback to Lite if model loading fails. - const note: InMemoryNoteRecord = { - name: fileName, - relPath: fileName, - content, - lastModified: Date.now(), - tags: parseTags(content), - }; +Cogito loads WebLLM in the browser from `https://esm.run/@mlc-ai/web-llm` and stores model data in IndexedDB. The core editor can still work without Cogito, but Cogito needs network access for first load and enough browser storage for the selected model. - state.inMemoryNotes.push(note); +## Themes - if (!keptCurrentNote) { - state.currentRelPath = fileName; - state.currentContent = content; - state.isDirty = false; - els.editor.value = content; - renderPreview(els, content); - updateDirtyUi(els, state, setStatus); - } +Ink ships with these themes: - renderInMemoryTree(); - renderTags(); - updateCountsPill(); - showToast(successMessage); - setStatus("New note", "ok"); +- Default (Dark) +- Classic +- Cobalt +- Monokai +- Office +- Twilight +- Xcode - return { - ok: true, - message: successMessage, - notePath: fileName, - sessionType: "temporary", - keptCurrentNote, - }; - } +Theme selection is stored in `localStorage` under `ink-theme`. - try { - if (!state.workspaceHandle) { - throw new Error("Workspace handle is not available"); - } +## Agent Note Tool - for await (const [existingName] of state.workspaceHandle.entries()) { - if (existingName === fileName) { - const message = "A file with that name already exists."; - showToast(message, { persist: true }); - setStatus("Create failed", "err"); - return { ok: false, message }; - } - } +The generated app includes a WebMCP-compatible note creation form. It is normally hidden and appears only when an agent uses the `create_note` tool. The tool accepts a title, body, and optional tag, then creates a markdown note in the active workspace or in a temporary session. - const fileHandle = await state.workspaceHandle.getFileHandle(fileName, { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(content); - await writable.close(); +This keeps agent-created notes inside the same visible workflow as user-created notes. - await rescanWorkspace({ silent: true }); +## Installing For Development - if (!keptCurrentNote) { - await openNoteByRelPath(fileName, fileHandle); - } else { - setStatus("New note", "ok"); - } +Requirements: - showToast(successMessage); - return { - ok: true, - message: successMessage, - notePath: fileName, - sessionType: "workspace", - keptCurrentNote, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - showToast(`Failed to create note: ${message}`, { persist: true }); - setStatus("Create failed", "err"); - return { ok: false, message }; - } - } +- Node.js with npm. +- A modern browser for manual testing. +- Chrome or another Cypress-supported browser for end-to-end tests. - async function openInMemoryNote(relPath: string): Promise { - if (state.isDirty && state.currentRelPath) { - const shouldDiscard = confirm("You have unsaved changes. Discard them?"); - if (!shouldDiscard) { - return; - } - } +Install dependencies: - const note = state.inMemoryNotes.find((n) => n.relPath === relPath); - if (!note) { - showToast("Note not found", { persist: true }); - return; - } +```bash +npm install +``` - state.currentRelPath = relPath; - state.currentContent = note.content; - state.isDirty = false; +Build the app: - els.editor.value = note.content; - renderPreview(els, note.content); - updateDirtyUi(els, state, setStatus); - setStatus("Opened ✓", "ok"); +```bash +npm run build +``` + +The build writes `ink-app.html`, the single-file app with inlined CSS and JavaScript. - renderInMemoryTree(); - } +Watch and rebuild on source changes: - async function saveInMemoryNote(): Promise { - if (!state.currentRelPath || !state.isTemporarySession) { - return; - } +```bash +npm run watch +``` - const note = state.inMemoryNotes.find((n) => n.relPath === state.currentRelPath); - if (!note) { - showToast("Note not found", { persist: true }); - return; - } +Serve locally for browser testing: - note.content = els.editor.value; - note.lastModified = Date.now(); - note.tags = parseTags(note.content); +```bash +npm run serve:test +``` - state.currentContent = note.content; - state.isDirty = false; - updateDirtyUi(els, state, setStatus); +Then open: - setStatus("Saved ✓", "ok"); - showToast("Saved ✓"); +```text +http://127.0.0.1:4173/ink-app.html +``` - renderInMemoryTree(); - renderTags(); - } +## Build System - function exportAsJson(): void { - if (state.inMemoryNotes.length === 0) { - showToast("No notes to export."); - return; - } +Ink does not use a full app framework. The build pipeline is intentionally direct: - const exportData = { - exportedAt: new Date().toISOString(), - notes: state.inMemoryNotes.map((note) => ({ - name: note.name, - path: note.relPath, - content: note.content, - lastModified: new Date(note.lastModified).toISOString(), - })), - }; +- TypeScript source is bundled with `esbuild`. +- SCSS is compiled with `sass`. +- `build/compile-and-assemble.js` assembles the final single-file app. +- `ink.template.html` provides the HTML shell. +- Generated CSS and JavaScript are injected into the template. +- Favicon assets are regenerated during the normal build. - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); +Canonical build command: - const date = new Date().toISOString().split("T")[0]; - const fileName = `ink-export-${date}.json`; +```bash +npm run build +``` - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); +Makefile wrappers are available: - showToast(`Exported ${state.inMemoryNotes.length} note(s) as JSON.`); - setStatus("Exported JSON", "ok"); - } +```bash +make lint +make build +make watch +make test +make test-qunit +make test-cypress +make repomix +``` - function exportAsMarkdown(): void { - if (!state.currentRelPath) { - showToast("No note selected to export."); - return; - } +## Testing - const note = state.inMemoryNotes.find((n) => n.relPath === state.currentRelPath); - if (!note) { - showToast("Note not found.", { persist: true }); - return; - } +Automated tests cover pure utilities, app modules, browser workflows, and the generated build. - const blob = new Blob([note.content], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); +Run QUnit tests: - const a = document.createElement("a"); - a.href = url; - a.download = note.name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); +```bash +npm run test:qunit +``` - showToast(`Exported ${note.name} as Markdown.`); - setStatus("Exported MD", "ok"); - } +Run Cypress end-to-end tests: - function handleRefresh(): void { - if (state.isTemporarySession) { - showToast("Refresh not available in temporary session. Your data is in memory."); - return; - } +```bash +npm run test:cypress +``` - if (!state.workspaceHandle) { - showToast("No workspace open."); - return; - } +Run the full suite: - cancelActiveScan(); - recordWorkspaceInteraction(); - rescanWorkspace().catch((error: unknown) => { - showToast(`Refresh failed: ${String(error)}`, { persist: true }); - setStatus("Refresh failed", "err"); - }); - } +```bash +npm test +``` - function closeWorkspace(): void { - autoRefresh.stopAutoRefresh(); - cancelActiveScan(); - recordWorkspaceInteraction(); - state.workspaceHandle = null; - state.workspaceName = ""; - state.fileTree = null; - state.notes = []; - state.currentFileHandle = null; - state.currentRelPath = ""; - state.currentContent = ""; - state.isDirty = false; - state.searchQuery = ""; - state.tagFilter = ""; - state.collapsedDirs.clear(); +The Cypress suite covers the main editor flow, workspace actions, menu behavior, mobile fallback behavior, editor view modes, document analysis, Cogito mode, and demo capture support. - els.workspaceName.textContent = "No folder selected"; - els.workspaceName.title = "No folder selected"; - els.countsPill.textContent = "0 notes"; - els.tagRow.innerHTML = ""; - els.tree.innerHTML = '
Open a folder to begin.
'; - els.editor.value = ""; - els.preview.innerHTML = ""; - els.currentFilename.textContent = "No note open"; - els.dirtyDot.classList.remove("show"); +## Demo Capture - setStatus("Ready"); - showToast("Workspace closed."); - } +Record the first-use flow: - return { - openWorkspace, - rescanWorkspace, - openNoteByRelPath, - saveCurrentNote, - createNewNote, - saveAsNewNote, - createNoteFromTool, - createNewFolder, - createInMemoryNote, - openInMemoryNote, - saveInMemoryNote, - exportAsJson, - exportAsMarkdown, - handleRefresh, - closeWorkspace, - }; -} -
+```bash +npm run demo:record +``` - -# ink -

- Ink logo -

+This starts the local test server, launches headless Chrome, opens Ink, uses a fake workspace, creates `getting-started.md`, writes markdown, saves, and switches to preview. The recording is written to: -Ink is a functional and minimalistic webapp to write documents in markdown and export them. +```text +assets/demo/ink-demo.webm +``` -![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) +On macOS, when `avconvert` can convert the recording, the script also writes: -## Quick Demo +```text +assets/demo/ink-demo.mp4 +``` -![](assets/demo/ink-demo.gif) +Generate a GIF from the recorded video: -## Repo Structure +```bash +npm run demo:gif +``` -The structure of the repo is as follows: +This writes: +```text +assets/demo/ink-demo.gif ``` + +Keep the MP4 as the canonical demo artifact when possible because it is smaller and clearer than a full-app GIF. + +## Repo Structure + +```text 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) + app.ts App entrypoint + tags.ts Frontmatter and inline tag parsing + app/ + app-controller.ts Main composition root + auto-refresh.ts Idle workspace rescan behavior + cogito.ts WebLLM-backed coaching questions + document-linter/ Deterministic document analysis + dom.ts Required DOM reference collection + editor-preview.ts Markdown preview and editor view modes + fs-api.ts File System Access API helpers + menu-actions.ts Menu actions and shortcut labels + sidebar.ts Sidebar collapse state + toast-status.ts Toast and status badge behavior + tree-render.ts Workspace tree, search, and tags + ui-events.ts Browser event wiring + workspace-io.ts Workspace, file, and temporary-session actions test-support/ - storage-fixture.ts (Test-only storage helpers) - styles.scss (The app styles, in SCSS) + storage-fixture.ts Test-only storage helpers dist/ - app.min.js - styles.min.css -ink.template.html (The HTML template source) -ink-app.html (The final single-page app, with inline