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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@
- The WebMCP `create_note` tool is agent-facing only. Keep its form out of the always-visible sidebar UI and surface it as a modal only when the tool is being used.
- The canonical implementation for the WebMCP note popup lives in `ink.template.html`, `src/styles.scss`, and `src/app/ui-events.ts`; rebuild `ink-app.html` after source edits.
- For local debugging, the WebMCP popup can be opened manually with `Cmd/Ctrl+Alt+N` or automatically via `?debugWebMcpNote=1`; keep those hooks available without making the tool permanently visible.
- Document Linter runtime integration lives in `src/app/document-linter/document-linter.ts`. When extending or maintaining the Document Linter, make sure all associated DOM elements (e.g. `documentLinterPanel`, `documentLinterAnalyzeBtn`, etc.) are mapped in `src/app/dom.ts` and defined in `src/app/types.ts` to avoid bootstrapping reference errors.
- The Document Linter now emits a markdown-aware review summary with prioritized fixes and section notes. Preserve the `analyzeDocumentText()` and `buildDocumentLinterReport()` contract when adjusting report output so the UI panel and export stay aligned.
- Document Linter copy now lives in `src/app/document-linter/messages.ts`; keep suggestion/overview strings centralized there, preserve the `overallScore` field, and keep section-analysis `needsAttention` flags aligned between the panel and markdown export.
- Document Linter panel behavior now includes a `Rerun on change` toggle plus clickable section line links back into the editor. Preserve `handleEditorChanged()` in `src/app/document-linter/document-linter.ts` and keep the panel's line-jump buttons wired to the textarea selection logic when changing the panel UI.
- Cogito and the Document Linter are mutually exclusive panels. Opening one should close the other so the editor split layout stays stable and never tries to render both sidebars together.
- Editor view mode lives in `src/app/editor-preview.ts` and is wired through the main editor header in `ink.template.html`. Keep the Source/Split/Preview toggle in sync with `editorSplit`, `editorPane`, and `previewPane`, and preserve the `ink-editor-view-mode` localStorage key when changing that flow.
8 changes: 8 additions & 0 deletions build/build-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const bundles = [
entryPoints: ["src/app/cogito.ts"],
outfile: "dist/test/cogito.js",
},
{
entryPoints: ["src/app/editor-preview.ts"],
outfile: "dist/test/editor-preview.js",
},
{
entryPoints: ["src/app/document-linter/document-linter.ts"],
outfile: "dist/test/document-linter.js",
},
];

function ensureTestDist() {
Expand Down
3 changes: 3 additions & 0 deletions cypress.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createRequire } from "node:module";
import { defineConfig } from "cypress";

const require = createRequire(import.meta.url);

export default defineConfig({
video: false,
e2e: {
Expand Down
129 changes: 129 additions & 0 deletions cypress/e2e/document-linter.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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").click();
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");
});
});
});
121 changes: 121 additions & 0 deletions cypress/e2e/editor-view-mode.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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() {},
};
},
};
}

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("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);

win.prompt = (message) => {
if (message.includes("New note name")) {
return noteName;
}
return null;
};

win.confirm = () => true;
win.FileSystemHandle = function FileSystemHandle() {};
win.showDirectoryPicker = async () => root;
},
});
});

it("switches between split, source, and preview layouts", () => {
cy.get("[data-action=\"open-workspace\"]").click({ force: true });
cy.get("[data-action=\"new-note\"]").click({ force: true });

cy.get("#editorSplit").should("have.class", "view-split");
cy.get("#editorViewSplitBtn").should("have.class", "active");

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");

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");
});
});
Loading
Loading