` wrapper for those three icon buttons if it becomes empty.
-- [x] 1.5 Remove the `#saveBtn` button element from the editor header status area in `ink.template.html`.
-- [x] 1.6 Remove the `#exportJsonBtn` button element (`JSON`) from the editor header status area in `ink.template.html`.
-- [x] 1.7 Remove the `#exportMdBtn` button element (`MD`) from the editor header status area in `ink.template.html`.
-
-## 2. TypeScript — Types
-
-- [x] 2.1 Remove `newNoteBtn`, `newFolderBtn`, `openFolderBtn`, `saveBtn`, `exportJsonBtn`, and `exportMdBtn` fields from the `DomRefs` interface in `src/app/types.ts`.
-
-## 3. TypeScript — DOM References
-
-- [x] 3.1 Remove `newNoteBtn`, `newFolderBtn`, `openFolderBtn`, `saveBtn`, `exportJsonBtn`, and `exportMdBtn` from the `getDomRefs()` return object in `src/app/dom.ts`.
-
-## 4. TypeScript — Bootstrap / Event Listeners and State Management
-
-- [x] 4.1 Remove the `this.els.newNoteBtn.addEventListener(...)` block from `attachEventListeners()` in `src/app/bootstrap.ts`.
-- [x] 4.2 Remove the `this.els.newFolderBtn.addEventListener(...)` block from `attachEventListeners()`.
-- [x] 4.3 Remove the `this.els.openFolderBtn.addEventListener(...)` block from `attachEventListeners()`.
-- [x] 4.4 Remove the `this.els.saveBtn.addEventListener(...)` block from `attachEventListeners()`.
-- [x] 4.5 Remove the `this.els.exportJsonBtn.addEventListener(...)` block from `attachEventListeners()`.
-- [x] 4.6 Remove the `this.els.exportMdBtn.addEventListener(...)` block from `attachEventListeners()`.
-- [x] 4.7 Remove all `this.els.saveBtn.disabled = ...` assignments throughout `bootstrap.ts` (including in `updateDirtyUi()`, `openWorkspace()`, and `closeWorkspace()`).
-- [x] 4.8 Remove all `this.els.exportJsonBtn.disabled = ...` assignments throughout `bootstrap.ts`.
-- [x] 4.9 Remove all `this.els.exportMdBtn.disabled = ...` assignments throughout `bootstrap.ts`.
-- [x] 4.10 Remove the `enableExportButtons()` private method and its call site.
-
-## 5. Tests — New QUnit Tests
-
-- [x] 5.1 Add a QUnit test asserting that `#saveBtn` is not present in `ink-app.html`.
-- [x] 5.2 Add a QUnit test asserting that `#exportJsonBtn` is not present in `ink-app.html`.
-- [x] 5.3 Add a QUnit test asserting that `#exportMdBtn` is not present in `ink-app.html`.
-- [x] 5.4 Add a QUnit test asserting that `#newNoteBtn`, `#newFolderBtn`, and `#openFolderBtn` are not present in `ink-app.html`.
-- [x] 5.5 Add a QUnit test asserting that the dirty-dot indicator (`#dirtyDot`) is still present in `ink-app.html` after button removal.
-- [x] 5.6 Add a QUnit test asserting that the status badge (`#statusBadge`) is still present in `ink-app.html` after button removal.
-
-## 6. Tests — New Cypress Tests
-
-- [x] 6.1 Add a Cypress test confirming the Save button is absent from the editor header.
-- [x] 6.2 Add a Cypress test confirming that Cmd/Ctrl+S still saves the current note successfully.
-- [x] 6.3 Add a Cypress test confirming the dirty-dot indicator appears after editing and disappears after saving via keyboard shortcut.
-- [x] 6.4 Add a Cypress test confirming that the File menu New Note and New Folder items still function correctly.
-- [x] 6.5 Add a Cypress test confirming that Import/Export > Export JSON and Export Markdown items still function correctly.
-
-## 7. Build and Verification
-
-- [x] 7.1 Run `npm run build` and confirm zero TypeScript compilation errors.
-- [x] 7.2 Run `npm run test:qunit` and confirm all tests pass (baseline: 32 / 32).
-- [x] 7.3 Run `npm run test:cypress` and confirm all new Cypress tests pass. Note: The existing tests (editor-flow.cy.js and mobile-fallback.cy.js) use the removed buttons and need to be updated separately.
-- [x] 7.4 Verify visually that the sidebar header no longer shows the three icon buttons.
-- [x] 7.5 Verify visually that the editor header status area shows only the dirty-dot indicator and status badge.
-- [x] 7.6 Verify the dirty-dot indicator and "Unsaved changes" status badge correctly reflect unsaved state.
-- [x] 7.7 Verify all remaining menu and keyboard-shortcut paths for the removed buttons continue to work end-to-end.
-
-
-
-import type { DomRefs } from "./types";
-
-export const COGITO_PROMPT = `You are a writing coach.
-
-Rules:
-- Do NOT write prose.
-- Do NOT suggest sentences.
-- Ask exactly 3 questions.
-- Questions must be grounded in the user's last sentence.
-- Output JSON only in this format:
-{
- "questions": ["...", "...", "..."]
-}`;
-
-export const LITE_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC";
-export const DEEP_MODEL = "Qwen3-8B-q4f16_1-MLC";
-
-export type CogitoModel = "lite" | "deep";
-
-type ChatEngine = {
- chat: {
- completions: {
- create: (payload: {
- messages: Array<{ role: "system" | "user"; content: string }>;
- temperature?: number;
- }) => Promise<{ choices?: Array<{ message?: { content?: unknown } }> }>;
- };
- };
-};
+
+function createFakeFileHandle(name, initialContent = "") {
+ let content = initialContent;
+ let lastModified = Date.now();
-type WebLlmModule = {
- CreateMLCEngine: (
- modelId: string,
- options?: {
- initProgressCallback?: (progress: { text?: string }) => void;
+ return {
+ kind: "file",
+ name,
+ async queryPermission() {
+ return "granted";
},
- ) => Promise;
-};
-
-type ToastFn = (message: string, options?: { persist?: boolean }) => void;
-
-type SetStatusFn = (message: string, kind?: "ok" | "warn" | "err") => void;
-
-type CogitoController = {
- togglePanel: () => void;
- setPanelOpen: (isOpen: boolean) => void;
- isPanelOpen: () => boolean;
- closePanel: () => void;
- selectModel: (model: CogitoModel) => void;
- generateQuestions: () => Promise;
- insertQuestionAtIndex: (index: number) => void;
-};
-
-export function extractLastSentence(text: string): string {
- const normalized = text.replace(/\s+/g, " ").trim();
- if (!normalized) {
- return "";
- }
-
- const fragments = normalized
- .split(/(?<=[.!?])\s+/)
- .map((fragment) => fragment.trim())
- .filter(Boolean);
-
- if (fragments.length === 0) {
- return "";
- }
-
- return fragments[fragments.length - 1];
-}
-
-export function parseCogitoQuestionPayload(raw: string): string[] {
- const jsonText = raw
- .replace(/[\s\S]*?<\/think>/gi, "")
- .replace(/^```(?:json)?\s*/i, "")
- .replace(/```\s*$/, "")
- .trim();
- const parsed = JSON.parse(jsonText) as { questions?: unknown };
-
- if (!parsed || !Array.isArray(parsed.questions)) {
- throw new Error("Cogito response did not include a questions array.");
- }
-
- const sanitized = parsed.questions
- .filter((q): q is string => typeof q === "string" && q.trim().length > 0)
- .map((q) => q.trim());
-
- if (sanitized.length === 0) {
- throw new Error("Cogito response contained no valid questions.");
- }
-
- if (sanitized.length !== 3) {
- throw new Error("Cogito response must contain exactly 3 questions.");
- }
-
- return sanitized;
-}
-
-export function formatCogitoQuestionBlock(question: string): string {
- return `> ### AI\n${question.trim()}\n`;
-}
-
-export function insertTextAtCursor(textarea: HTMLTextAreaElement, text: string): void {
- const { selectionStart, selectionEnd, value } = textarea;
- const before = value.slice(0, selectionStart);
- const after = value.slice(selectionEnd);
- textarea.value = `${before}${text}${after}`;
- const nextCursor = before.length + text.length;
- textarea.setSelectionRange(nextCursor, nextCursor);
+ async requestPermission() {
+ return "granted";
+ },
+ async getFile() {
+ return new File([content], name, { type: "text/markdown", lastModified });
+ },
+ async createWritable() {
+ return {
+ async write(nextContent) {
+ content = String(nextContent);
+ lastModified = Date.now();
+ },
+ async close() {},
+ };
+ },
+ __read() {
+ return content;
+ },
+ };
}
-export function createCogitoController({
- els,
- getEditorText,
- onEditorContentReplaced,
- showToast,
- setStatus,
-}: {
- els: DomRefs;
- getEditorText: () => string;
- onEditorContentReplaced: (text: string) => void;
- showToast: ToastFn;
- setStatus: SetStatusFn;
-}): CogitoController {
- let isPanelOpen = false;
- let generatedQuestions: string[] = [];
- let selectedModel: CogitoModel = "lite";
- const engineCache: Partial>> = {};
+function createFakeDirectoryHandle(name) {
+ const entries = new Map();
- async function loadWebLlmModule(): Promise {
- const testModule = (
- globalThis as typeof globalThis & {
- __INK_TEST_WEBLLM__?: WebLlmModule;
+ return {
+ kind: "directory",
+ name,
+ async queryPermission() {
+ return "granted";
+ },
+ async requestPermission() {
+ return "granted";
+ },
+ async *entries() {
+ for (const entry of entries.entries()) {
+ yield entry;
}
- ).__INK_TEST_WEBLLM__;
-
- if (testModule) {
- return testModule;
- }
-
- return import("https://esm.run/@mlc-ai/web-llm") as Promise;
- }
-
- function selectModel(model: CogitoModel): void {
- selectedModel = model;
- els.cogitoLiteBtn.classList.toggle("active", model === "lite");
- els.cogitoDeepBtn.classList.toggle("active", model === "deep");
- }
-
- function setPanelVisibility(isOpen: boolean): void {
- isPanelOpen = isOpen;
- els.cogitoPanel.hidden = !isOpen;
- els.cogitoToggleBtn.setAttribute("aria-expanded", String(isOpen));
- const split = els.cogitoPanel.closest(".split");
- if (split) {
- split.classList.toggle("with-cogito", isOpen);
- }
- }
+ },
+ async getFileHandle(fileName, options = {}) {
+ const existing = entries.get(fileName);
+ if (existing) {
+ return existing;
+ }
+ if (!options.create) {
+ throw new Error(`File not found: ${fileName}`);
+ }
+ const handle = createFakeFileHandle(fileName);
+ entries.set(fileName, handle);
+ return handle;
+ },
+ async getDirectoryHandle(dirName, options = {}) {
+ const existing = entries.get(dirName);
+ if (existing) {
+ return existing;
+ }
+ if (!options.create) {
+ throw new Error(`Directory not found: ${dirName}`);
+ }
+ const handle = createFakeDirectoryHandle(dirName);
+ entries.set(dirName, handle);
+ return handle;
+ },
+ __entries: entries,
+ };
+}
- function updateQuestionList(questions: string[]): void {
- els.cogitoQuestionList.innerHTML = "";
- questions.forEach((question, index) => {
- const item = document.createElement("li");
- item.className = "cogitoQuestionItem";
+describe("ink authoring flow", () => {
+ const workspaceName = "workspace-a";
+ const fileStem = "notes";
+ const fileName = `${fileStem}.md`;
+ const markdown = "# Ink flow\n\nThis is markdown content.";
- const text = document.createElement("p");
- text.className = "cogitoQuestionText";
- text.textContent = question;
+ beforeEach(() => {
+ cy.visit("/ink-app.html", {
+ onBeforeLoad(win) {
+ const root = createFakeDirectoryHandle(workspaceName);
- const button = document.createElement("button");
- button.type = "button";
- button.className = "ghost cogitoInsertBtn";
- button.dataset.questionIndex = String(index);
- button.textContent = "Insert";
- button.title = "Insert question into markdown";
+ win.prompt = (message) => {
+ if (message.includes("New note name")) {
+ return fileStem;
+ }
+ return null;
+ };
- item.append(text, button);
- els.cogitoQuestionList.appendChild(item);
+ win.confirm = () => true;
+ win.FileSystemHandle = function FileSystemHandle() {};
+ win.showDirectoryPicker = async () => root;
+ win.__fakeWorkspace = root;
+ },
});
- }
-
- function setCogitoStatus(text: string): void {
- els.cogitoStatus.textContent = text;
- }
+ });
- async function getOrCreateEngine(): Promise {
- const modelId = selectedModel === "deep" ? DEEP_MODEL : LITE_MODEL;
+ it("selects workspace, creates a new file, edits markdown, and saves", () => {
+ cy.get("[data-action=\"open-workspace\"]").click({ force: true });
+ cy.get("#workspaceName").should("contain", workspaceName);
- if (engineCache[selectedModel]) {
- return engineCache[selectedModel]!;
- }
+ cy.get("[data-action=\"new-note\"]").click({ force: true });
+ cy.get("#currentFilename").should("contain", fileName);
- engineCache[selectedModel] = (async () => {
- setCogitoStatus(`Loading ${selectedModel === "deep" ? "Deep (Qwen3 8B)" : "Lite (Llama 1B)"} model...`);
- const webllm = await loadWebLlmModule();
- const engine = await webllm.CreateMLCEngine(modelId, {
- initProgressCallback: (progress: { text?: string }) => {
- if (progress?.text) {
- setCogitoStatus(progress.text);
- }
- },
- });
- return engine as ChatEngine;
- })().catch((error: unknown) => {
- delete engineCache[selectedModel];
- throw error;
- });
+ cy.get("#editor").clear().type(markdown);
+ cy.get("[data-action=\"save\"]").click({ force: true });
+ cy.get("#statusBadge").should("contain", "Saved");
- return engineCache[selectedModel]!;
- }
+ cy.window().then(async (win) => {
+ const handle = await win.__fakeWorkspace.getFileHandle(fileName);
+ expect(handle.__read()).to.eq(markdown);
+ });
+ });
- async function generateQuestions(): Promise {
- const lastSentence = extractLastSentence(getEditorText());
- if (!lastSentence) {
- setCogitoStatus("Write at least one sentence first, then generate Cogito questions.");
- setStatus("Cogito needs a sentence", "warn");
- return;
- }
+ it("renders markdown preview when editing a note", () => {
+ cy.get("[data-action=\"open-workspace\"]").click({ force: true });
+ cy.get("[data-action=\"new-note\"]").click({ force: true });
- try {
- els.cogitoGenerateBtn.disabled = true;
- setCogitoStatus("Generating 3 questions...");
+ cy.get("#editor").clear().type("# Hello World\n\nThis is a paragraph.");
- const engine = await getOrCreateEngine();
- const completion = await engine.chat.completions.create({
- messages: [
- { role: "system", content: COGITO_PROMPT },
- { role: "user", content: `Last sentence: ${lastSentence}` },
- ],
- temperature: 0.2,
- });
+ cy.get("#preview").find("h1").should("contain", "Hello World");
+ cy.get("#preview").find("p").should("contain", "This is a paragraph.");
+ });
- const rawContent = completion.choices?.[0]?.message?.content;
- const textContent = Array.isArray(rawContent)
- ? rawContent
- .map((chunk) => (typeof chunk === "string" ? chunk : ""))
- .join("")
- .trim()
- : typeof rawContent === "string"
- ? rawContent.trim()
- : "";
+ it("collapses and expands the left menu, updating accessibility state and editor space", () => {
+ let expandedEditorWidth = 0;
+ let collapsedEditorWidth = 0;
- if (!textContent) {
- throw new Error("Cogito returned an empty response.");
- }
+ cy.get("#sidebarToggleBtn")
+ .should("have.attr", "aria-expanded", "true")
+ .and("contain", "▶ Collapse")
+ .and("be.visible");
+ cy.get(".app").should("not.have.class", "sidebar-collapsed");
- generatedQuestions = parseCogitoQuestionPayload(textContent);
- updateQuestionList(generatedQuestions);
- setCogitoStatus("Questions ready. Insert one into your markdown when useful.");
- setStatus("Cogito questions ready", "ok");
- } catch (error: unknown) {
- generatedQuestions = [];
- updateQuestionList(generatedQuestions);
- const message = error instanceof Error ? error.message : String(error);
- setCogitoStatus(`Cogito error: ${message}`);
- setStatus("Cogito unavailable", "warn");
- showToast(`Cogito failed: ${message}`, { persist: true });
- } finally {
- els.cogitoGenerateBtn.disabled = false;
- }
- }
+ cy.get("#editor").then(($editor) => {
+ expandedEditorWidth = $editor[0].getBoundingClientRect().width;
+ });
- function insertQuestionAtIndex(index: number): void {
- const question = generatedQuestions[index];
- if (!question) {
- showToast("Cogito question not found.", { persist: true });
- return;
- }
+ cy.get("#sidebarToggleBtn").click();
+ cy.get(".app").should("have.class", "sidebar-collapsed");
+ cy.get("#sidebarToggleBtn")
+ .should("have.attr", "aria-expanded", "false")
+ .and("contain", "▼ Expand");
- const block = formatCogitoQuestionBlock(question);
- insertTextAtCursor(els.editor, block);
- onEditorContentReplaced(els.editor.value);
- setStatus("Inserted AI question", "ok");
- }
+ cy.get("#editor").then(($editor) => {
+ collapsedEditorWidth = $editor[0].getBoundingClientRect().width;
+ expect(collapsedEditorWidth).to.be.greaterThan(expandedEditorWidth);
+ });
- setPanelVisibility(false);
+ cy.get("#sidebarToggleBtn").focus().type("{enter}");
+ cy.get(".app").should("not.have.class", "sidebar-collapsed");
+ cy.get("#sidebarToggleBtn")
+ .should("have.attr", "aria-expanded", "true")
+ .and("contain", "Collapse");
- return {
- togglePanel: () => {
- setPanelVisibility(!isPanelOpen);
- if (isPanelOpen) {
- setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence.");
- }
- },
- setPanelOpen: (nextIsOpen: boolean) => {
- setPanelVisibility(nextIsOpen);
- if (nextIsOpen) {
- setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence.");
- }
- },
- isPanelOpen: () => isPanelOpen,
- closePanel: () => {
- setPanelVisibility(false);
- },
- selectModel,
- generateQuestions,
- insertQuestionAtIndex,
- };
-}
+ cy.get("#editor").then(($editor) => {
+ const finalEditorWidth = $editor[0].getBoundingClientRect().width;
+ expect(finalEditorWidth).to.be.lessThan(collapsedEditorWidth);
+ });
+ });
+});
-
-import { icon } from "./icons";
-import type {
- AppState,
- DeclarativeNoteInput,
- DeclarativeNoteResult,
- DirectoryHandleLike,
- DirectoryNode,
- DomRefs,
- FileHandleLike,
- FileNode,
- InMemoryNoteRecord,
- NoteRecord,
- TreeNode,
-} from "./types";
+
+# ink
+
+
+
-type ToastFn = (message: string, options?: { persist?: boolean }) => void;
-type StatusFn = (message: string | null, kind?: "neutral" | "ok" | "warn" | "err") => void;
+Ink is a functional and minimalistic webapp to write documents in markdown and export them.
-type RenderPreviewFn = (els: DomRefs, text: string) => void;
-type UpdateDirtyFn = (els: DomRefs, state: AppState, setStatus: StatusFn) => void;
+
+
+
+
+[](https://opensource.org/licenses/MIT)
-type TreeRenderFns = {
- renderTree: () => Promise;
- renderInMemoryTree: () => void;
- renderTags: () => void;
- updateCountsPill: () => void;
-};
+## Quick Demo
-type FsApi = {
- ensurePermission: (handle: DirectoryHandleLike, mode: "read" | "readwrite") => Promise;
- isFileSystemApiAvailable: () => boolean;
-};
+
-type AutoRefreshFns = {
- startAutoRefresh: () => void;
- stopAutoRefresh: () => void;
-};
+## Repo Structure
-type TagParser = (text: string) => Set;
-type TagNormalizer = (value: string) => string;
+The structure of the repo is as follows:
-export function createWorkspaceActions({
- state,
- els,
- showToast,
- setStatus,
- renderPreview,
- updateDirtyUi,
- renderTree,
- renderInMemoryTree,
- renderTags,
- updateCountsPill,
- fsApi,
- parseTags,
- normalizeTag,
- autoRefresh,
-}: {
- state: AppState;
- els: DomRefs;
- showToast: ToastFn;
- setStatus: StatusFn;
- renderPreview: RenderPreviewFn;
- updateDirtyUi: UpdateDirtyFn;
- renderTree: TreeRenderFns["renderTree"];
- renderInMemoryTree: TreeRenderFns["renderInMemoryTree"];
- renderTags: TreeRenderFns["renderTags"];
- updateCountsPill: TreeRenderFns["updateCountsPill"];
- fsApi: FsApi;
- parseTags: TagParser;
- normalizeTag: TagNormalizer;
- autoRefresh: AutoRefreshFns;
-}) {
- function activateTemporarySession(): void {
- const wasTemporarySession = state.isTemporarySession;
+```
+src/
+ app.ts (Thin app entrypoint)
+ app/ (Feature modules: app-controller, ui-events, workspace-io, tree-render, dom, fs-api, types)
+ tags.ts (Tag/frontmatter parsing utilities)
+ test-support/
+ storage-fixture.ts (Test-only storage helpers)
+ styles.scss (The app styles, in SCSS)
+dist/
+ app.min.js
+ styles.min.css
+ink.template.html (The HTML template source)
+ink-app.html (The final single-page app, with inline