diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c394cd0..7e90477 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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. diff --git a/build/build-test.js b/build/build-test.js index fe71d0d..ee04495 100644 --- a/build/build-test.js +++ b/build/build-test.js @@ -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() { diff --git a/cypress.config.mjs b/cypress.config.mjs index a7a32eb..bea7c14 100644 --- a/cypress.config.mjs +++ b/cypress.config.mjs @@ -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: { diff --git a/cypress/e2e/document-linter.cy.js b/cypress/e2e/document-linter.cy.js new file mode 100644 index 0000000..bbe23ff --- /dev/null +++ b/cypress/e2e/document-linter.cy.js @@ -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"); + }); + }); +}); diff --git a/cypress/e2e/editor-view-mode.cy.js b/cypress/e2e/editor-view-mode.cy.js new file mode 100644 index 0000000..bb30383 --- /dev/null +++ b/cypress/e2e/editor-view-mode.cy.js @@ -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"); + }); +}); diff --git a/dist/app.min.js b/dist/app.min.js index 4fecaa2..cf85571 100644 --- a/dist/app.min.js +++ b/dist/app.min.js @@ -1,83 +1,83 @@ -(()=>{var kt=Object.create;var he=Object.defineProperty;var wt=Object.getOwnPropertyDescriptor;var bt=Object.getOwnPropertyNames;var xt=Object.getPrototypeOf,yt=Object.prototype.hasOwnProperty;var vt=(t,e,r)=>e in t?he(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r;var Tt=(t=>typeof require!="undefined"?require:typeof Proxy!="undefined"?new Proxy(t,{get:(e,r)=>(typeof require!="undefined"?require:e)[r]}):t)(function(t){if(typeof require!="undefined")return require.apply(this,arguments);throw Error('Dynamic require of "'+t+'" is not supported')});var St=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of bt(e))!yt.call(t,o)&&o!==r&&he(t,o,{get:()=>e[o],enumerable:!(n=wt(e,o))||n.enumerable});return t};var Mt=(t,e,r)=>(r=t!=null?kt(xt(t)):{},St(e||!t||!t.__esModule?he(r,"default",{value:t,enumerable:!0}):r,t));var E=(t,e,r)=>vt(t,typeof e!="symbol"?e+"":e,r);function ke(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var G=ke();function We(t){G=t}var K={exec:()=>null};function M(t,e=""){let r=typeof t=="string"?t:t.source,n={replace:(o,i)=>{let a=typeof i=="string"?i:i.source;return a=a.replace($.caret,"$1"),r=r.replace(o,a),n},getRegex:()=>new RegExp(r,e)};return n}var Rt=(()=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i"),blockquoteBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}>`)},Ct=/^(?:[ \t]*(?:\n|$))+/,Et=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,At=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,X=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Pt=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,we=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,_e=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,qe=M(_e).replace(/bull/g,we).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Nt=M(_e).replace(/bull/g,we).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),be=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Lt=/^[^\n]+/,xe=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,$t=M(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",xe).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Dt=M(/^(bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,we).getRegex(),le="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",ye=/|$))/,It=M("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",ye).replace("tag",le).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Oe=M(be).replace("hr",X).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",le).getRegex(),Bt=M(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Oe).getRegex(),ve={blockquote:Bt,code:Et,def:$t,fences:At,heading:Pt,hr:X,html:It,lheading:qe,list:Dt,newline:Ct,paragraph:Oe,table:K,text:Lt},De=M("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",X).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",le).getRegex(),Ht={...ve,lheading:Nt,table:De,paragraph:M(be).replace("hr",X).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",De).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",le).getRegex()},Ft={...ve,html:M(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",ye).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:K,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:M(be).replace("hr",X).replace("heading",` *#{1,6} *[^ -]`).replace("lheading",qe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},zt=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Wt=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Qe=/^( {2,}|\\)\n(?!\s*$)/,_t=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Rt?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),Ge=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,Gt=M(Ge,"u").replace(/punct/g,ce).getRegex(),Ut=M(Ge,"u").replace(/punct/g,Ke).getRegex(),Ue="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Jt=M(Ue,"gu").replace(/notPunctSpace/g,je).replace(/punctSpace/g,Te).replace(/punct/g,ce).getRegex(),Vt=M(Ue,"gu").replace(/notPunctSpace/g,Qt).replace(/punctSpace/g,Ot).replace(/punct/g,Ke).getRegex(),Yt=M("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,je).replace(/punctSpace/g,Te).replace(/punct/g,ce).getRegex(),Xt=M(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,Ze).getRegex(),er="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",tr=M(er,"gu").replace(/notPunctSpace/g,Kt).replace(/punctSpace/g,jt).replace(/punct/g,Ze).getRegex(),rr=M(/\\(punct)/,"gu").replace(/punct/g,ce).getRegex(),nr=M(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),or=M(ye).replace("(?:-->|$)","-->").getRegex(),ir=M("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",or).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),ie=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,sr=M(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",ie).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Je=M(/^!?\[(label)\]\[(ref)\]/).replace("label",ie).replace("ref",xe).getRegex(),Ve=M(/^!?\[(ref)\](?:\[\])?/).replace("ref",xe).getRegex(),ar=M("reflink|nolink(?!\\()","g").replace("reflink",Je).replace("nolink",Ve).getRegex(),Ie=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,Se={_backpedal:K,anyPunctuation:rr,autolink:nr,blockSkip:Zt,br:Qe,code:Wt,del:K,delLDelim:K,delRDelim:K,emStrongLDelim:Gt,emStrongRDelimAst:Jt,emStrongRDelimUnd:Yt,escape:zt,link:sr,nolink:Ve,punctuation:qt,reflink:Je,reflinkSearch:ar,tag:ir,text:_t,url:K},lr={...Se,link:M(/^!?\[(label)\]\((.*?)\)/).replace("label",ie).getRegex(),reflink:M(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",ie).getRegex()},ge={...Se,emStrongRDelimAst:Vt,emStrongLDelim:Ut,delLDelim:Xt,delRDelim:tr,url:M(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",Ie).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:M(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},Be=t=>pr[t];function _(t,e){if(e){if($.escapeTest.test(t))return t.replace($.escapeReplace,Be)}else if($.escapeTestNoEncode.test(t))return t.replace($.escapeReplaceNoEncode,Be);return t}function He(t){try{t=encodeURI(t).replace($.percentDecode,"%")}catch(e){return null}return t}function Fe(t,e){var i;let r=t.replace($.findPipe,(a,s,u)=>{let l=!1,g=s;for(;--g>=0&&u[g]==="\\";)l=!l;return l?"|":" |"}),n=r.split($.splitPipe),o=0;if(n[0].trim()||n.shift(),n.length>0&&!((i=n.at(-1))!=null&&i.trim())&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function dr(t,e=0){let r=e,n="";for(let o of t)if(o===" "){let i=4-r%4;n+=" ".repeat(i),r+=i}else n+=o,r++;return n}function ze(t,e,r,n,o){let i=e.href,a=e.title||null,s=t[1].replace(o.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:t[0].charAt(0)==="!"?"image":"link",raw:r,href:i,title:a,text:s,tokens:n.inlineTokens(s)};return n.state.inLink=!1,u}function hr(t,e,r){let n=t.match(r.other.indentCodeCompensation);if(n===null)return e;let o=n[1];return e.split(` -`).map(i=>{let a=i.match(r.other.beginningSpace);if(a===null)return i;let[s]=a;return s.length>=o.length?i.slice(o.length):i}).join(` -`)}var se=class{constructor(t){E(this,"options");E(this,"rules");E(this,"lexer");this.options=t||G}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let r=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?r:V(r,` -`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let r=e[0],n=hr(r,e[3]||"",this.rules);return{type:"code",raw:r,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:n}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let r=e[2].trim();if(this.rules.other.endingHash.test(r)){let n=V(r,"#");(this.options.pedantic||!n||this.rules.other.endingSpaceChar.test(n))&&(r=n.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:r,tokens:this.lexer.inline(r)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:V(e[0],` -`)}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let r=V(e[0],` +(()=>{var Ln=Object.create;var Ee=Object.defineProperty;var Tn=Object.getOwnPropertyDescriptor;var Mn=Object.getOwnPropertyNames;var En=Object.getPrototypeOf,Rn=Object.prototype.hasOwnProperty;var Cn=(t,e,n)=>e in t?Ee(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;var An=(t=>typeof require!="undefined"?require:typeof Proxy!="undefined"?new Proxy(t,{get:(e,n)=>(typeof require!="undefined"?require:e)[n]}):t)(function(t){if(typeof require!="undefined")return require.apply(this,arguments);throw Error('Dynamic require of "'+t+'" is not supported')});var $n=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Mn(e))!Rn.call(t,i)&&i!==n&&Ee(t,i,{get:()=>e[i],enumerable:!(r=Tn(e,i))||r.enumerable});return t};var Nn=(t,e,n)=>(n=t!=null?Ln(En(t)):{},$n(e||!t||!t.__esModule?Ee(n,"default",{value:t,enumerable:!0}):n,t));var $=(t,e,n)=>Cn(t,typeof e!="symbol"?e+"":e,n);function $e(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var ae=$e();function dt(t){ae=t}var oe={exec:()=>null};function C(t,e=""){let n=typeof t=="string"?t:t.source,r={replace:(i,o)=>{let a=typeof o=="string"?o:o.source;return a=a.replace(z.caret,"$1"),n=n.replace(i,a),r},getRegex:()=>new RegExp(n,e)};return r}var Pn=(()=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i"),blockquoteBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}>`)},Dn=/^(?:[ \t]*(?:\n|$))+/,Bn=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,In=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,he=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Hn=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Ne=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,pt=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,ht=C(pt).replace(/bull/g,Ne).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Fn=C(pt).replace(/bull/g,Ne).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Pe=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,zn=/^[^\n]+/,De=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,_n=C(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",De).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Wn=C(/^(bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Ne).getRegex(),xe="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Be=/|$))/,On=C("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",Be).replace("tag",xe).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),gt=C(Pe).replace("hr",he).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",xe).getRegex(),qn=C(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",gt).getRegex(),Ie={blockquote:qn,code:Bn,def:_n,fences:In,heading:Hn,hr:he,html:On,lheading:ht,list:Wn,newline:Dn,paragraph:gt,table:oe,text:zn},ot=C("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",he).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",xe).getRegex(),jn={...Ie,lheading:Fn,table:ot,paragraph:C(Pe).replace("hr",he).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ot).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",xe).getRegex()},Qn={...Ie,html:C(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",Be).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:oe,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:C(Pe).replace("hr",he).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",ht).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Vn=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Un=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,mt=/^( {2,}|\\)\n(?!\s*$)/,Gn=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Pn?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),bt=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,tr=C(bt,"u").replace(/punct/g,ve).getRegex(),nr=C(bt,"u").replace(/punct/g,kt).getRegex(),yt="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",rr=C(yt,"gu").replace(/notPunctSpace/g,ft).replace(/punctSpace/g,He).replace(/punct/g,ve).getRegex(),ir=C(yt,"gu").replace(/notPunctSpace/g,Jn).replace(/punctSpace/g,Zn).replace(/punct/g,kt).getRegex(),or=C("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ft).replace(/punctSpace/g,He).replace(/punct/g,ve).getRegex(),sr=C(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,wt).getRegex(),ar="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",lr=C(ar,"gu").replace(/notPunctSpace/g,Xn).replace(/punctSpace/g,Yn).replace(/punct/g,wt).getRegex(),cr=C(/\\(punct)/,"gu").replace(/punct/g,ve).getRegex(),ur=C(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),dr=C(Be).replace("(?:-->|$)","-->").getRegex(),pr=C("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",dr).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),we=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,hr=C(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",we).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),xt=C(/^!?\[(label)\]\[(ref)\]/).replace("label",we).replace("ref",De).getRegex(),vt=C(/^!?\[(ref)\](?:\[\])?/).replace("ref",De).getRegex(),gr=C("reflink|nolink(?!\\()","g").replace("reflink",xt).replace("nolink",vt).getRegex(),st=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,Fe={_backpedal:oe,anyPunctuation:cr,autolink:ur,blockSkip:er,br:mt,code:Un,del:oe,delLDelim:oe,delRDelim:oe,emStrongLDelim:tr,emStrongRDelimAst:rr,emStrongRDelimUnd:or,escape:Vn,link:hr,nolink:vt,punctuation:Kn,reflink:xt,reflinkSearch:gr,tag:pr,text:Gn,url:oe},mr={...Fe,link:C(/^!?\[(label)\]\((.*?)\)/).replace("label",we).getRegex(),reflink:C(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",we).getRegex()},Re={...Fe,emStrongRDelimAst:ir,emStrongLDelim:nr,delLDelim:sr,delRDelim:lr,url:C(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",st).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:C(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},at=t=>kr[t];function U(t,e){if(e){if(z.escapeTest.test(t))return t.replace(z.escapeReplace,at)}else if(z.escapeTestNoEncode.test(t))return t.replace(z.escapeReplaceNoEncode,at);return t}function lt(t){try{t=encodeURI(t).replace(z.percentDecode,"%")}catch(e){return null}return t}function ct(t,e){var o;let n=t.replace(z.findPipe,(a,s,c)=>{let l=!1,d=s;for(;--d>=0&&c[d]==="\\";)l=!l;return l?"|":" |"}),r=n.split(z.splitPipe),i=0;if(r[0].trim()||r.shift(),r.length>0&&!((o=r.at(-1))!=null&&o.trim())&&r.pop(),e)if(r.length>e)r.splice(e);else for(;r.length0?-2:-1}function br(t,e=0){let n=e,r="";for(let i of t)if(i===" "){let o=4-n%4;r+=" ".repeat(o),n+=o}else r+=i,n++;return r}function ut(t,e,n,r,i){let o=e.href,a=e.title||null,s=t[1].replace(i.other.outputLinkReplace,"$1");r.state.inLink=!0;let c={type:t[0].charAt(0)==="!"?"image":"link",raw:n,href:o,title:a,text:s,tokens:r.inlineTokens(s)};return r.state.inLink=!1,c}function yr(t,e,n){let r=t.match(n.other.indentCodeCompensation);if(r===null)return e;let i=r[1];return e.split(` +`).map(o=>{let a=o.match(n.other.beginningSpace);if(a===null)return o;let[s]=a;return s.length>=i.length?o.slice(i.length):o}).join(` +`)}var be=class{constructor(t){$(this,"options");$(this,"rules");$(this,"lexer");this.options=t||ae}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let n=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?n:de(n,` +`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let n=e[0],r=yr(n,e[3]||"",this.rules);return{type:"code",raw:n,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:r}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let n=e[2].trim();if(this.rules.other.endingHash.test(n)){let r=de(n,"#");(this.options.pedantic||!r||this.rules.other.endingSpaceChar.test(r))&&(n=r.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:de(e[0],` +`)}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let n=de(e[0],` `).split(` -`),n="",o="",i=[];for(;r.length>0;){let a=!1,s=[],u;for(u=0;u1,a={type:"list",raw:"",ordered:i,start:i?+o.slice(0,-1):"",loose:!1,items:[]};o=i?`\\d{1,9}\\${o.slice(-1)}`:`\\${o}`,this.options.pedantic&&(o=i?o:"[*+-]");let s=this.rules.other.listItemRegex(o),u=!1;for(;t;){let g=!1,k="",d="";if(!(e=s.exec(t))||this.rules.block.hr.test(t))break;k=e[0],t=t.substring(k.length);let p=dr(e[2].split(` -`,1)[0],e[1].length),c=t.split(` -`,1)[0],m=!p.trim(),h=0;if(this.options.pedantic?(h=2,d=p.trimStart()):m?h=e[1].length+1:(h=p.search(this.rules.other.nonSpaceChar),h=h>4?1:h,d=p.slice(h),h+=e[1].length),m&&this.rules.other.blankLine.test(c)&&(k+=c+` -`,t=t.substring(c.length+1),g=!0),!g){let w=this.rules.other.nextBulletRegex(h),v=this.rules.other.hrRegex(h),P=this.rules.other.fencesBeginRegex(h),N=this.rules.other.headingBeginRegex(h),L=this.rules.other.htmlBeginRegex(h),D=this.rules.other.blockquoteBeginRegex(h);for(;t;){let F=t.split(` -`,1)[0],z;if(c=F,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),z=c):z=c.replace(this.rules.other.tabCharGlobal," "),P.test(c)||N.test(c)||L.test(c)||D.test(c)||w.test(c)||v.test(c))break;if(z.search(this.rules.other.nonSpaceChar)>=h||!c.trim())d+=` -`+z.slice(h);else{if(m||p.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||P.test(p)||N.test(p)||v.test(p))break;d+=` -`+c}m=!c.trim(),k+=F+` -`,t=t.substring(F.length+1),p=z.slice(h)}}a.loose||(u?a.loose=!0:this.rules.other.doubleBlankLine.test(k)&&(u=!0)),a.items.push({type:"list_item",raw:k,task:!!this.options.gfm&&this.rules.other.listIsTask.test(d),loose:!1,text:d,tokens:[]}),a.raw+=k}let l=a.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;a.raw=a.raw.trimEnd();for(let g of a.items){if(this.lexer.state.top=!1,g.tokens=this.lexer.blockTokens(g.text,[]),g.task){if(g.text=g.text.replace(this.rules.other.listReplaceTask,""),((r=g.tokens[0])==null?void 0:r.type)==="text"||((n=g.tokens[0])==null?void 0:n.type)==="paragraph"){g.tokens[0].raw=g.tokens[0].raw.replace(this.rules.other.listReplaceTask,""),g.tokens[0].text=g.tokens[0].text.replace(this.rules.other.listReplaceTask,"");for(let d=this.lexer.inlineQueue.length-1;d>=0;d--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[d].src)){this.lexer.inlineQueue[d].src=this.lexer.inlineQueue[d].src.replace(this.rules.other.listReplaceTask,"");break}}let k=this.rules.other.listTaskCheckbox.exec(g.raw);if(k){let d={type:"checkbox",raw:k[0]+" ",checked:k[0]!=="[ ]"};g.checked=d.checked,a.loose?g.tokens[0]&&["paragraph","text"].includes(g.tokens[0].type)&&"tokens"in g.tokens[0]&&g.tokens[0].tokens?(g.tokens[0].raw=d.raw+g.tokens[0].raw,g.tokens[0].text=d.raw+g.tokens[0].text,g.tokens[0].tokens.unshift(d)):g.tokens.unshift({type:"paragraph",raw:d.raw,text:d.raw,tokens:[d]}):g.tokens.unshift(d)}}if(!a.loose){let k=g.tokens.filter(p=>p.type==="space"),d=k.length>0&&k.some(p=>this.rules.other.anyLine.test(p.raw));a.loose=d}}if(a.loose)for(let g of a.items){g.loose=!0;for(let k of g.tokens)k.type==="text"&&(k.type="paragraph")}return a}}html(t){let e=this.rules.block.html.exec(t);if(e)return{type:"html",block:!0,raw:e[0],pre:e[1]==="pre"||e[1]==="script"||e[1]==="style",text:e[0]}}def(t){let e=this.rules.block.def.exec(t);if(e){let r=e[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),n=e[2]?e[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",o=e[3]?e[3].substring(1,e[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):e[3];return{type:"def",tag:r,raw:e[0],href:n,title:o}}}table(t){var a;let e=this.rules.block.table.exec(t);if(!e||!this.rules.other.tableDelimiter.test(e[2]))return;let r=Fe(e[1]),n=e[2].replace(this.rules.other.tableAlignChars,"").split("|"),o=(a=e[3])!=null&&a.trim()?e[3].replace(this.rules.other.tableRowBlankLine,"").split(` -`):[],i={type:"table",raw:e[0],header:[],align:[],rows:[]};if(r.length===n.length){for(let s of n)this.rules.other.tableAlignRight.test(s)?i.align.push("right"):this.rules.other.tableAlignCenter.test(s)?i.align.push("center"):this.rules.other.tableAlignLeft.test(s)?i.align.push("left"):i.align.push(null);for(let s=0;s({text:u,tokens:this.lexer.inline(u),header:!1,align:i.align[l]})));return i}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let r=e[1].charAt(e[1].length-1)===` -`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:r,tokens:this.lexer.inline(r)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let r=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(r)){if(!this.rules.other.endAngleBracket.test(r))return;let i=V(r.slice(0,-1),"\\");if((r.length-i.length)%2===0)return}else{let i=ur(e[2],"()");if(i===-2)return;if(i>-1){let a=(e[0].indexOf("!")===0?5:4)+e[1].length+i;e[2]=e[2].substring(0,i),e[0]=e[0].substring(0,a).trim(),e[3]=""}}let n=e[2],o="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(n);i&&(n=i[1],o=i[3])}else o=e[3]?e[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(r)?n=n.slice(1):n=n.slice(1,-1)),ze(e,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:o&&o.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let r;if((r=this.rules.inline.reflink.exec(t))||(r=this.rules.inline.nolink.exec(t))){let n=(r[2]||r[1]).replace(this.rules.other.multipleSpaceGlobal," "),o=e[n.toLowerCase()];if(!o){let i=r[0].charAt(0);return{type:"text",raw:i,text:i}}return ze(r,o,r[0],this.lexer,this.rules)}}emStrong(t,e,r=""){let n=this.rules.inline.emStrongLDelim.exec(t);if(!(!n||n[3]&&r.match(this.rules.other.unicodeAlphaNumeric))&&(!(n[1]||n[2])||!r||this.rules.inline.punctuation.exec(r))){let o=[...n[0]].length-1,i,a,s=o,u=0,l=n[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(l.lastIndex=0,e=e.slice(-1*t.length+o);(n=l.exec(e))!=null;){if(i=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!i)continue;if(a=[...i].length,n[3]||n[4]){s+=a;continue}else if((n[5]||n[6])&&o%3&&!((o+a)%3)){u+=a;continue}if(s-=a,s>0)continue;a=Math.min(a,a+s+u);let g=[...n[0]][0].length,k=t.slice(0,o+n.index+g+a);if(Math.min(o,a)%2){let p=k.slice(1,-1);return{type:"em",raw:k,text:p,tokens:this.lexer.inlineTokens(p)}}let d=k.slice(2,-2);return{type:"strong",raw:k,text:d,tokens:this.lexer.inlineTokens(d)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let r=e[2].replace(this.rules.other.newLineCharGlobal," "),n=this.rules.other.nonSpaceChar.test(r),o=this.rules.other.startingSpaceChar.test(r)&&this.rules.other.endingSpaceChar.test(r);return n&&o&&(r=r.substring(1,r.length-1)),{type:"codespan",raw:e[0],text:r}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t,e,r=""){let n=this.rules.inline.delLDelim.exec(t);if(n&&(!n[1]||!r||this.rules.inline.punctuation.exec(r))){let o=[...n[0]].length-1,i,a,s=o,u=this.rules.inline.delRDelim;for(u.lastIndex=0,e=e.slice(-1*t.length+o);(n=u.exec(e))!=null;){if(i=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!i||(a=[...i].length,a!==o))continue;if(n[3]||n[4]){s+=a;continue}if(s-=a,s>0)continue;a=Math.min(a,a+s);let l=[...n[0]][0].length,g=t.slice(0,o+n.index+l+a),k=g.slice(o,-o);return{type:"del",raw:g,text:k,tokens:this.lexer.inlineTokens(k)}}}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let r,n;return e[2]==="@"?(r=e[1],n="mailto:"+r):(r=e[1],n=r),{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}url(t){var r,n;let e;if(e=this.rules.inline.url.exec(t)){let o,i;if(e[2]==="@")o=e[0],i="mailto:"+o;else{let a;do a=e[0],e[0]=(n=(r=this.rules.inline._backpedal.exec(e[0]))==null?void 0:r[0])!=null?n:"";while(a!==e[0]);o=e[0],e[1]==="www."?i="http://"+e[0]:i=e[0]}return{type:"link",raw:e[0],text:o,href:i,tokens:[{type:"text",raw:o,text:o}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let r=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:r}}}},B=class fe{constructor(e){E(this,"tokens");E(this,"options");E(this,"state");E(this,"inlineQueue");E(this,"tokenizer");this.tokens=[],this.tokens.links=Object.create(null),this.options=e||G,this.options.tokenizer=this.options.tokenizer||new se,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let r={other:$,block:ne.normal,inline:J.normal};this.options.pedantic?(r.block=ne.pedantic,r.inline=J.pedantic):this.options.gfm&&(r.block=ne.gfm,this.options.breaks?r.inline=J.breaks:r.inline=J.gfm),this.tokenizer.rules=r}static get rules(){return{block:ne,inline:J}}static lex(e,r){return new fe(r).lex(e)}static lexInline(e,r){return new fe(r).inlineTokens(e)}lex(e){e=e.replace($.carriageReturn,` -`),this.blockTokens(e,this.tokens);for(let r=0;r(s=l.call({lexer:this},e,r))?(e=e.substring(s.raw.length),r.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let l=r.at(-1);s.raw.length===1&&l!==void 0?l.raw+=` -`:r.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let l=r.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` +`),r="",i="",o=[];for(;n.length>0;){let a=!1,s=[],c;for(c=0;c1,a={type:"list",raw:"",ordered:o,start:o?+i.slice(0,-1):"",loose:!1,items:[]};i=o?`\\d{1,9}\\${i.slice(-1)}`:`\\${i}`,this.options.pedantic&&(i=o?i:"[*+-]");let s=this.rules.other.listItemRegex(i),c=!1;for(;t;){let d=!1,f="",u="";if(!(e=s.exec(t))||this.rules.block.hr.test(t))break;f=e[0],t=t.substring(f.length);let h=br(e[2].split(` +`,1)[0],e[1].length),p=t.split(` +`,1)[0],b=!h.trim(),m=0;if(this.options.pedantic?(m=2,u=h.trimStart()):b?m=e[1].length+1:(m=h.search(this.rules.other.nonSpaceChar),m=m>4?1:m,u=h.slice(m),m+=e[1].length),b&&this.rules.other.blankLine.test(p)&&(f+=p+` +`,t=t.substring(p.length+1),d=!0),!d){let w=this.rules.other.nextBulletRegex(m),g=this.rules.other.hrRegex(m),S=this.rules.other.fencesBeginRegex(m),x=this.rules.other.headingBeginRegex(m),y=this.rules.other.htmlBeginRegex(m),N=this.rules.other.blockquoteBeginRegex(m);for(;t;){let P=t.split(` +`,1)[0],I;if(p=P,this.options.pedantic?(p=p.replace(this.rules.other.listReplaceNesting," "),I=p):I=p.replace(this.rules.other.tabCharGlobal," "),S.test(p)||x.test(p)||y.test(p)||N.test(p)||w.test(p)||g.test(p))break;if(I.search(this.rules.other.nonSpaceChar)>=m||!p.trim())u+=` +`+I.slice(m);else{if(b||h.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||S.test(h)||x.test(h)||g.test(h))break;u+=` +`+p}b=!p.trim(),f+=P+` +`,t=t.substring(P.length+1),h=I.slice(m)}}a.loose||(c?a.loose=!0:this.rules.other.doubleBlankLine.test(f)&&(c=!0)),a.items.push({type:"list_item",raw:f,task:!!this.options.gfm&&this.rules.other.listIsTask.test(u),loose:!1,text:u,tokens:[]}),a.raw+=f}let l=a.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;a.raw=a.raw.trimEnd();for(let d of a.items){if(this.lexer.state.top=!1,d.tokens=this.lexer.blockTokens(d.text,[]),d.task){if(d.text=d.text.replace(this.rules.other.listReplaceTask,""),((n=d.tokens[0])==null?void 0:n.type)==="text"||((r=d.tokens[0])==null?void 0:r.type)==="paragraph"){d.tokens[0].raw=d.tokens[0].raw.replace(this.rules.other.listReplaceTask,""),d.tokens[0].text=d.tokens[0].text.replace(this.rules.other.listReplaceTask,"");for(let u=this.lexer.inlineQueue.length-1;u>=0;u--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[u].src)){this.lexer.inlineQueue[u].src=this.lexer.inlineQueue[u].src.replace(this.rules.other.listReplaceTask,"");break}}let f=this.rules.other.listTaskCheckbox.exec(d.raw);if(f){let u={type:"checkbox",raw:f[0]+" ",checked:f[0]!=="[ ]"};d.checked=u.checked,a.loose?d.tokens[0]&&["paragraph","text"].includes(d.tokens[0].type)&&"tokens"in d.tokens[0]&&d.tokens[0].tokens?(d.tokens[0].raw=u.raw+d.tokens[0].raw,d.tokens[0].text=u.raw+d.tokens[0].text,d.tokens[0].tokens.unshift(u)):d.tokens.unshift({type:"paragraph",raw:u.raw,text:u.raw,tokens:[u]}):d.tokens.unshift(u)}}if(!a.loose){let f=d.tokens.filter(h=>h.type==="space"),u=f.length>0&&f.some(h=>this.rules.other.anyLine.test(h.raw));a.loose=u}}if(a.loose)for(let d of a.items){d.loose=!0;for(let f of d.tokens)f.type==="text"&&(f.type="paragraph")}return a}}html(t){let e=this.rules.block.html.exec(t);if(e)return{type:"html",block:!0,raw:e[0],pre:e[1]==="pre"||e[1]==="script"||e[1]==="style",text:e[0]}}def(t){let e=this.rules.block.def.exec(t);if(e){let n=e[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),r=e[2]?e[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=e[3]?e[3].substring(1,e[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):e[3];return{type:"def",tag:n,raw:e[0],href:r,title:i}}}table(t){var a;let e=this.rules.block.table.exec(t);if(!e||!this.rules.other.tableDelimiter.test(e[2]))return;let n=ct(e[1]),r=e[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=(a=e[3])!=null&&a.trim()?e[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],o={type:"table",raw:e[0],header:[],align:[],rows:[]};if(n.length===r.length){for(let s of r)this.rules.other.tableAlignRight.test(s)?o.align.push("right"):this.rules.other.tableAlignCenter.test(s)?o.align.push("center"):this.rules.other.tableAlignLeft.test(s)?o.align.push("left"):o.align.push(null);for(let s=0;s({text:c,tokens:this.lexer.inline(c),header:!1,align:o.align[l]})));return o}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let n=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:n,tokens:this.lexer.inline(n)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let n=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let o=de(n.slice(0,-1),"\\");if((n.length-o.length)%2===0)return}else{let o=wr(e[2],"()");if(o===-2)return;if(o>-1){let a=(e[0].indexOf("!")===0?5:4)+e[1].length+o;e[2]=e[2].substring(0,o),e[0]=e[0].substring(0,a).trim(),e[3]=""}}let r=e[2],i="";if(this.options.pedantic){let o=this.rules.other.pedanticHrefTitle.exec(r);o&&(r=o[1],i=o[3])}else i=e[3]?e[3].slice(1,-1):"";return r=r.trim(),this.rules.other.startAngleBracket.test(r)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?r=r.slice(1):r=r.slice(1,-1)),ut(e,{href:r&&r.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let n;if((n=this.rules.inline.reflink.exec(t))||(n=this.rules.inline.nolink.exec(t))){let r=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=e[r.toLowerCase()];if(!i){let o=n[0].charAt(0);return{type:"text",raw:o,text:o}}return ut(n,i,n[0],this.lexer,this.rules)}}emStrong(t,e,n=""){let r=this.rules.inline.emStrongLDelim.exec(t);if(!(!r||r[3]&&n.match(this.rules.other.unicodeAlphaNumeric))&&(!(r[1]||r[2])||!n||this.rules.inline.punctuation.exec(n))){let i=[...r[0]].length-1,o,a,s=i,c=0,l=r[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(l.lastIndex=0,e=e.slice(-1*t.length+i);(r=l.exec(e))!=null;){if(o=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!o)continue;if(a=[...o].length,r[3]||r[4]){s+=a;continue}else if((r[5]||r[6])&&i%3&&!((i+a)%3)){c+=a;continue}if(s-=a,s>0)continue;a=Math.min(a,a+s+c);let d=[...r[0]][0].length,f=t.slice(0,i+r.index+d+a);if(Math.min(i,a)%2){let h=f.slice(1,-1);return{type:"em",raw:f,text:h,tokens:this.lexer.inlineTokens(h)}}let u=f.slice(2,-2);return{type:"strong",raw:f,text:u,tokens:this.lexer.inlineTokens(u)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let n=e[2].replace(this.rules.other.newLineCharGlobal," "),r=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return r&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:e[0],text:n}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t,e,n=""){let r=this.rules.inline.delLDelim.exec(t);if(r&&(!r[1]||!n||this.rules.inline.punctuation.exec(n))){let i=[...r[0]].length-1,o,a,s=i,c=this.rules.inline.delRDelim;for(c.lastIndex=0,e=e.slice(-1*t.length+i);(r=c.exec(e))!=null;){if(o=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!o||(a=[...o].length,a!==i))continue;if(r[3]||r[4]){s+=a;continue}if(s-=a,s>0)continue;a=Math.min(a,a+s);let l=[...r[0]][0].length,d=t.slice(0,i+r.index+l+a),f=d.slice(i,-i);return{type:"del",raw:d,text:f,tokens:this.lexer.inlineTokens(f)}}}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let n,r;return e[2]==="@"?(n=e[1],r="mailto:"+n):(n=e[1],r=n),{type:"link",raw:e[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}url(t){var n,r;let e;if(e=this.rules.inline.url.exec(t)){let i,o;if(e[2]==="@")i=e[0],o="mailto:"+i;else{let a;do a=e[0],e[0]=(r=(n=this.rules.inline._backpedal.exec(e[0]))==null?void 0:n[0])!=null?r:"";while(a!==e[0]);i=e[0],e[1]==="www."?o="http://"+e[0]:o=e[0]}return{type:"link",raw:e[0],text:i,href:o,tokens:[{type:"text",raw:i,text:i}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let n=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:n}}}},O=class Ce{constructor(e){$(this,"tokens");$(this,"options");$(this,"state");$(this,"inlineQueue");$(this,"tokenizer");this.tokens=[],this.tokens.links=Object.create(null),this.options=e||ae,this.options.tokenizer=this.options.tokenizer||new be,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let n={other:z,block:fe.normal,inline:ue.normal};this.options.pedantic?(n.block=fe.pedantic,n.inline=ue.pedantic):this.options.gfm&&(n.block=fe.gfm,this.options.breaks?n.inline=ue.breaks:n.inline=ue.gfm),this.tokenizer.rules=n}static get rules(){return{block:fe,inline:ue}}static lex(e,n){return new Ce(n).lex(e)}static lexInline(e,n){return new Ce(n).inlineTokens(e)}lex(e){e=e.replace(z.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let n=0;n(s=l.call({lexer:this},e,n))?(e=e.substring(s.raw.length),n.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let l=n.at(-1);s.raw.length===1&&l!==void 0?l.raw+=` +`:n.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let l=n.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` `)?"":` `)+s.raw,l.text+=` -`+s.text,this.inlineQueue.at(-1).src=l.text):r.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let l=r.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` +`+s.text,this.inlineQueue.at(-1).src=l.text):n.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),n.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),n.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),n.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),n.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),n.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),n.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let l=n.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` `)?"":` `)+s.raw,l.text+=` -`+s.raw,this.inlineQueue.at(-1).src=l.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title},r.push(s));continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),r.push(s);continue}let u=e;if((a=this.options.extensions)!=null&&a.startBlock){let l=1/0,g=e.slice(1),k;this.options.extensions.startBlock.forEach(d=>{k=d.call({lexer:this},g),typeof k=="number"&&k>=0&&(l=Math.min(l,k))}),l<1/0&&l>=0&&(u=e.substring(0,l+1))}if(this.state.top&&(s=this.tokenizer.paragraph(u))){let l=r.at(-1);n&&(l==null?void 0:l.type)==="paragraph"?(l.raw+=(l.raw.endsWith(` +`+s.raw,this.inlineQueue.at(-1).src=l.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title},n.push(s));continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),n.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),n.push(s);continue}let c=e;if((a=this.options.extensions)!=null&&a.startBlock){let l=1/0,d=e.slice(1),f;this.options.extensions.startBlock.forEach(u=>{f=u.call({lexer:this},d),typeof f=="number"&&f>=0&&(l=Math.min(l,f))}),l<1/0&&l>=0&&(c=e.substring(0,l+1))}if(this.state.top&&(s=this.tokenizer.paragraph(c))){let l=n.at(-1);r&&(l==null?void 0:l.type)==="paragraph"?(l.raw+=(l.raw.endsWith(` `)?"":` `)+s.raw,l.text+=` -`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):r.push(s),n=u.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let l=r.at(-1);(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):n.push(s),r=c.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let l=n.at(-1);(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` `)?"":` `)+s.raw,l.text+=` -`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):r.push(s);continue}if(e){let l="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(l);break}else throw new Error(l)}}return this.state.top=!0,r}inline(e,r=[]){return this.inlineQueue.push({src:e,tokens:r}),r}inlineTokens(e,r=[]){var u,l,g,k,d,p;let n=e,o=null;if(this.tokens.links){let c=Object.keys(this.tokens.links);if(c.length>0)for(;(o=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)c.includes(o[0].slice(o[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,o.index)+"["+"a".repeat(o[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(o=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,o.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let i;for(;(o=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)i=o[2]?o[2].length:0,n=n.slice(0,o.index+i)+"["+"a".repeat(o[0].length-i-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=(g=(l=(u=this.options.hooks)==null?void 0:u.emStrongMask)==null?void 0:l.call({lexer:this},n))!=null?g:n;let a=!1,s="";for(;e;){a||(s=""),a=!1;let c;if((d=(k=this.options.extensions)==null?void 0:k.inline)!=null&&d.some(h=>(c=h.call({lexer:this},e,r))?(e=e.substring(c.raw.length),r.push(c),!0):!1))continue;if(c=this.tokenizer.escape(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.tag(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.link(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(c.raw.length);let h=r.at(-1);c.type==="text"&&(h==null?void 0:h.type)==="text"?(h.raw+=c.raw,h.text+=c.text):r.push(c);continue}if(c=this.tokenizer.emStrong(e,n,s)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.codespan(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.br(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.del(e,n,s)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.autolink(e)){e=e.substring(c.raw.length),r.push(c);continue}if(!this.state.inLink&&(c=this.tokenizer.url(e))){e=e.substring(c.raw.length),r.push(c);continue}let m=e;if((p=this.options.extensions)!=null&&p.startInline){let h=1/0,w=e.slice(1),v;this.options.extensions.startInline.forEach(P=>{v=P.call({lexer:this},w),typeof v=="number"&&v>=0&&(h=Math.min(h,v))}),h<1/0&&h>=0&&(m=e.substring(0,h+1))}if(c=this.tokenizer.inlineText(m)){e=e.substring(c.raw.length),c.raw.slice(-1)!=="_"&&(s=c.raw.slice(-1)),a=!0;let h=r.at(-1);(h==null?void 0:h.type)==="text"?(h.raw+=c.raw,h.text+=c.text):r.push(c);continue}if(e){let h="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(h);break}else throw new Error(h)}}return r}},ae=class{constructor(t){E(this,"options");E(this,"parser");this.options=t||G}space(t){return""}code({text:t,lang:e,escaped:r}){var i;let n=(i=(e||"").match($.notSpaceStart))==null?void 0:i[0],o=t.replace($.endingNewline,"")+` -`;return n?'
'+(r?o:_(o,!0))+`
-`:"
"+(r?o:_(o,!0))+`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):n.push(s);continue}if(e){let l="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(l);break}else throw new Error(l)}}return this.state.top=!0,n}inline(e,n=[]){return this.inlineQueue.push({src:e,tokens:n}),n}inlineTokens(e,n=[]){var c,l,d,f,u,h;let r=e,i=null;if(this.tokens.links){let p=Object.keys(this.tokens.links);if(p.length>0)for(;(i=this.tokenizer.rules.inline.reflinkSearch.exec(r))!=null;)p.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(r=r.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+r.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(i=this.tokenizer.rules.inline.anyPunctuation.exec(r))!=null;)r=r.slice(0,i.index)+"++"+r.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let o;for(;(i=this.tokenizer.rules.inline.blockSkip.exec(r))!=null;)o=i[2]?i[2].length:0,r=r.slice(0,i.index+o)+"["+"a".repeat(i[0].length-o-2)+"]"+r.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);r=(d=(l=(c=this.options.hooks)==null?void 0:c.emStrongMask)==null?void 0:l.call({lexer:this},r))!=null?d:r;let a=!1,s="";for(;e;){a||(s=""),a=!1;let p;if((u=(f=this.options.extensions)==null?void 0:f.inline)!=null&&u.some(m=>(p=m.call({lexer:this},e,n))?(e=e.substring(p.raw.length),n.push(p),!0):!1))continue;if(p=this.tokenizer.escape(e)){e=e.substring(p.raw.length),n.push(p);continue}if(p=this.tokenizer.tag(e)){e=e.substring(p.raw.length),n.push(p);continue}if(p=this.tokenizer.link(e)){e=e.substring(p.raw.length),n.push(p);continue}if(p=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(p.raw.length);let m=n.at(-1);p.type==="text"&&(m==null?void 0:m.type)==="text"?(m.raw+=p.raw,m.text+=p.text):n.push(p);continue}if(p=this.tokenizer.emStrong(e,r,s)){e=e.substring(p.raw.length),n.push(p);continue}if(p=this.tokenizer.codespan(e)){e=e.substring(p.raw.length),n.push(p);continue}if(p=this.tokenizer.br(e)){e=e.substring(p.raw.length),n.push(p);continue}if(p=this.tokenizer.del(e,r,s)){e=e.substring(p.raw.length),n.push(p);continue}if(p=this.tokenizer.autolink(e)){e=e.substring(p.raw.length),n.push(p);continue}if(!this.state.inLink&&(p=this.tokenizer.url(e))){e=e.substring(p.raw.length),n.push(p);continue}let b=e;if((h=this.options.extensions)!=null&&h.startInline){let m=1/0,w=e.slice(1),g;this.options.extensions.startInline.forEach(S=>{g=S.call({lexer:this},w),typeof g=="number"&&g>=0&&(m=Math.min(m,g))}),m<1/0&&m>=0&&(b=e.substring(0,m+1))}if(p=this.tokenizer.inlineText(b)){e=e.substring(p.raw.length),p.raw.slice(-1)!=="_"&&(s=p.raw.slice(-1)),a=!0;let m=n.at(-1);(m==null?void 0:m.type)==="text"?(m.raw+=p.raw,m.text+=p.text):n.push(p);continue}if(e){let m="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(m);break}else throw new Error(m)}}return n}},ye=class{constructor(t){$(this,"options");$(this,"parser");this.options=t||ae}space(t){return""}code({text:t,lang:e,escaped:n}){var o;let r=(o=(e||"").match(z.notSpaceStart))==null?void 0:o[0],i=t.replace(z.endingNewline,"")+` +`;return r?'
'+(n?i:U(i,!0))+`
+`:"
"+(n?i:U(i,!0))+`
`}blockquote({tokens:t}){return`
${this.parser.parse(t)}
`}html({text:t}){return t}def(t){return""}heading({tokens:t,depth:e}){return`${this.parser.parseInline(t)} `}hr(t){return`
-`}list(t){let e=t.ordered,r=t.start,n="";for(let a=0;a -`+n+" +`}list(t){let e=t.ordered,n=t.start,r="";for(let a=0;a +`+r+" `}listitem(t){return`
  • ${this.parser.parse(t.tokens)}
  • `}checkbox({checked:t}){return" '}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    -`}table(t){let e="",r="";for(let o=0;o${n}`),` +`}table(t){let e="",n="";for(let i=0;i${r}`),`
    `+e+` -`+n+`
    +`+r+` `}tablerow({text:t}){return` ${t} -`}tablecell(t){let e=this.parser.parseInline(t.tokens),r=t.header?"th":"td";return(t.align?`<${r} align="${t.align}">`:`<${r}>`)+e+` -`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${_(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:r}){let n=this.parser.parseInline(r),o=He(t);if(o===null)return n;t=o;let i='
    ",i}image({href:t,title:e,text:r,tokens:n}){n&&(r=this.parser.parseInline(n,this.parser.textRenderer));let o=He(t);if(o===null)return _(r);t=o;let i=`${_(r)}{let u=a[s].flat(1/0);r=r.concat(this.walkTokens(u,e))}):a.tokens&&(r=r.concat(this.walkTokens(a.tokens,e)))}}return r}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(r=>{let n={...r};if(n.async=this.defaults.async||n.async||!1,r.extensions&&(r.extensions.forEach(o=>{if(!o.name)throw new Error("extension name required");if("renderer"in o){let i=e.renderers[o.name];i?e.renderers[o.name]=function(...a){let s=o.renderer.apply(this,a);return s===!1&&(s=i.apply(this,a)),s}:e.renderers[o.name]=o.renderer}if("tokenizer"in o){if(!o.level||o.level!=="block"&&o.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let i=e[o.level];i?i.unshift(o.tokenizer):e[o.level]=[o.tokenizer],o.start&&(o.level==="block"?e.startBlock?e.startBlock.push(o.start):e.startBlock=[o.start]:o.level==="inline"&&(e.startInline?e.startInline.push(o.start):e.startInline=[o.start]))}"childTokens"in o&&o.childTokens&&(e.childTokens[o.name]=o.childTokens)}),n.extensions=e),r.renderer){let o=this.defaults.renderer||new ae(this.defaults);for(let i in r.renderer){if(!(i in o))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;let a=i,s=r.renderer[a],u=o[a];o[a]=(...l)=>{let g=s.apply(o,l);return g===!1&&(g=u.apply(o,l)),g||""}}n.renderer=o}if(r.tokenizer){let o=this.defaults.tokenizer||new se(this.defaults);for(let i in r.tokenizer){if(!(i in o))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;let a=i,s=r.tokenizer[a],u=o[a];o[a]=(...l)=>{let g=s.apply(o,l);return g===!1&&(g=u.apply(o,l)),g}}n.tokenizer=o}if(r.hooks){let o=this.defaults.hooks||new Y;for(let i in r.hooks){if(!(i in o))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;let a=i,s=r.hooks[a],u=o[a];Y.passThroughHooks.has(i)?o[a]=l=>{if(this.defaults.async&&Y.passThroughHooksRespectAsync.has(i))return(async()=>{let k=await s.call(o,l);return u.call(o,k)})();let g=s.call(o,l);return u.call(o,g)}:o[a]=(...l)=>{if(this.defaults.async)return(async()=>{let k=await s.apply(o,l);return k===!1&&(k=await u.apply(o,l)),k})();let g=s.apply(o,l);return g===!1&&(g=u.apply(o,l)),g}}n.hooks=o}if(r.walkTokens){let o=this.defaults.walkTokens,i=r.walkTokens;n.walkTokens=function(a){let s=[];return s.push(i.call(this,a)),o&&(s=s.concat(o.call(this,a))),s}}this.defaults={...this.defaults,...n}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return B.lex(t,e!=null?e:this.defaults)}parser(t,e){return H.parse(t,e!=null?e:this.defaults)}parseMarkdown(t){return(e,r)=>{let n={...r},o={...this.defaults,...n},i=this.onError(!!o.silent,!!o.async);if(this.defaults.async===!0&&n.async===!1)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||e===null)return i(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if(o.hooks&&(o.hooks.options=o,o.hooks.block=t),o.async)return(async()=>{let a=o.hooks?await o.hooks.preprocess(e):e,s=await(o.hooks?await o.hooks.provideLexer():t?B.lex:B.lexInline)(a,o),u=o.hooks?await o.hooks.processAllTokens(s):s;o.walkTokens&&await Promise.all(this.walkTokens(u,o.walkTokens));let l=await(o.hooks?await o.hooks.provideParser():t?H.parse:H.parseInline)(u,o);return o.hooks?await o.hooks.postprocess(l):l})().catch(i);try{o.hooks&&(e=o.hooks.preprocess(e));let a=(o.hooks?o.hooks.provideLexer():t?B.lex:B.lexInline)(e,o);o.hooks&&(a=o.hooks.processAllTokens(a)),o.walkTokens&&this.walkTokens(a,o.walkTokens);let s=(o.hooks?o.hooks.provideParser():t?H.parse:H.parseInline)(a,o);return o.hooks&&(s=o.hooks.postprocess(s)),s}catch(a){return i(a)}}}onError(t,e){return r=>{if(r.message+=` -Please report this to https://github.com/markedjs/marked.`,t){let n="

    An error occurred:

    "+_(r.message+"",!0)+"
    ";return e?Promise.resolve(n):n}if(e)return Promise.reject(r);throw r}}},Z=new gr;function C(t,e){return Z.parse(t,e)}C.options=C.setOptions=function(t){return Z.setOptions(t),C.defaults=Z.defaults,We(C.defaults),C};C.getDefaults=ke;C.defaults=G;C.use=function(...t){return Z.use(...t),C.defaults=Z.defaults,We(C.defaults),C};C.walkTokens=function(t,e){return Z.walkTokens(t,e)};C.parseInline=Z.parseInline;C.Parser=H;C.parser=H.parse;C.Renderer=ae;C.TextRenderer=Me;C.Lexer=B;C.lexer=B.lex;C.Tokenizer=se;C.Hooks=Y;C.parse=C;var Nr=C.options,Lr=C.setOptions,$r=C.use,Dr=C.walkTokens,Ir=C.parseInline;var Br=H.parse,Hr=B.lex;function ee(t){return(t||"").trim().replace(/^#+/,"").replace(/[^\w\-/]+/g,"").toLowerCase()}function fr(t){if(!t.startsWith("---"))return"";let e=t.indexOf(` ----`,3);return e===-1?"":t.slice(3,e).trim()}function mr(t){let e=new Set,r=t.split(` -`);for(let o of r){let i=o.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i);if(!i)continue;let a=i[1].split(",").map(s=>ee(s.replace(/["']/g,"")));for(let s of a)s&&e.add(s)}let n=!1;for(let o of r){if(/^\s*tags\s*:\s*$/i.test(o)){n=!0;continue}if(!n)continue;let i=o.match(/^\s*-\s*(.+)\s*$/);if(i){let a=ee(i[1].replace(/["']/g,""));a&&e.add(a);continue}o.trim()!==""&&!/^\s+/.test(o)&&(n=!1)}return e}function Ye(t){let e=new Set,r=fr(t);if(r){let o=mr(r);for(let i of o)e.add(i)}let n=t.match(/\B#([a-zA-Z0-9][\w\-/]{1,50})\b/g);if(n)for(let o of n){let i=ee(o);i&&e.add(i)}return e}function R(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing required element: #${t}`);return e}function Xe(){return{app:R("app"),menuBar:R("menuBar"),workspaceSidebar:R("workspaceSidebar"),sidebarToggleBtn:R("sidebarToggleBtn"),refreshBtn:R("refreshBtn"),sortBtn:R("sortBtn"),searchInput:R("searchInput"),webmcpNoteModal:R("webmcpNoteModal"),webmcpNoteModalBackdrop:R("webmcpNoteModalBackdrop"),webmcpNoteModalCloseBtn:R("webmcpNoteModalCloseBtn"),webmcpNoteForm:R("webmcpNoteForm"),webmcpTitleInput:R("webmcpTitleInput"),webmcpBodyInput:R("webmcpBodyInput"),webmcpTagInput:R("webmcpTagInput"),tree:R("tree"),tagRow:R("tagRow"),workspaceName:R("workspaceName"),countsPill:R("countsPill"),editor:R("editor"),preview:R("preview"),currentFilename:R("currentFilename"),dirtyDot:R("dirtyDot"),statusBadge:R("statusBadge"),toast:R("toast"),toastMsg:R("toastMsg"),toastCloseBtn:R("toastCloseBtn"),temporarySessionBadge:R("temporarySessionBadge"),cogitoToggleBtn:R("cogitoToggleBtn"),cogitoPanel:R("cogitoPanel"),cogitoLiteBtn:R("cogitoLiteBtn"),cogitoDeepBtn:R("cogitoDeepBtn"),cogitoGenerateBtn:R("cogitoGenerateBtn"),cogitoStatus:R("cogitoStatus"),cogitoQuestionList:R("cogitoQuestionList")}}function et(){return!!(window.showDirectoryPicker&&window.FileSystemHandle)}async function Re(t,e="read"){if(!t)return!1;if(!t.queryPermission||!t.requestPermission)return!0;let r={mode:e};return await t.queryPermission(r)==="granted"?!0:await t.requestPermission(r)==="granted"}function tt({state:t,ensurePermission:e,rescanWorkspace:r,showToast:n,setStatus:o}){function i(){s(),t.autoRefreshTimer=setInterval(()=>{a().catch(u=>{n(`Auto-refresh failed: ${String(u)}`,{persist:!0}),o("Auto-refresh failed","err")})},t.autoRefreshMs)}async function a(){if(t.workspaceHandle){try{if(!await e(t.workspaceHandle,"read"))throw new Error("Folder permission revoked.")}catch(u){s(),n("Auto-refresh stopped: folder permission revoked.",{persist:!0}),o("Permission revoked","err");return}t.isDirty||await r({silent:!0})}}function s(){t.autoRefreshTimer&&(clearInterval(t.autoRefreshTimer),t.autoRefreshTimer=null)}return{startAutoRefresh:i,stopAutoRefresh:s}}function q(t){return String(t).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function Ce(t,e){try{t.preview.innerHTML=C.parse(e||"")}catch(r){let n=r instanceof Error?r.message:String(r);t.preview.innerHTML=`
    ${q(n)}
    `}}function pe(t,e,r){t.dirtyDot.classList.toggle("show",e.isDirty);let n=e.currentRelPath?e.currentRelPath.split("/").pop():"No note open";t.currentFilename.textContent=`${n}${e.isDirty?" \u2022 Unsaved":""}`,e.isDirty&&r("Unsaved changes","warn")}var ue=["default","classic","cobalt","monokai","office","twilight","xcode"];function Ee(t){if(!ue.includes(t))return;t==="default"?document.documentElement.removeAttribute("data-theme"):document.documentElement.setAttribute("data-theme",t);try{localStorage.setItem("ink-theme",t)}catch(r){}document.querySelectorAll(".menu-theme-check").forEach(r=>{r.classList.remove("active")});let e=document.getElementById(`themeCheck-${t}`);e&&e.classList.add("active")}function rt(){var e;let t="default";try{t=(e=localStorage.getItem("ink-theme"))!=null?e:"default"}catch(r){}Ee(ue.includes(t)?t:"default")}function nt(t,e){let r=e?"Cmd":"Ctrl";t.menuBar.querySelectorAll(".menu-shortcut").forEach(o=>{let i=o.textContent;i&&(i.includes("Cmd/Ctrl")?o.textContent=i.replace("Cmd/Ctrl",r):i.includes("Ctrl")&&(o.textContent=i.replace("Ctrl",r)))})}function ot({state:t,els:e,showToast:r,renderTree:n,callbacks:o}){function i(){t.sortMode=t.sortMode==="name"?"modified":"name",e.sortBtn.textContent=`Sort: ${t.sortMode==="name"?"Name":"Last modified"}`;let u=document.querySelector('[data-action="sort"] .menu-label-text');u&&(u.textContent=`Sort: ${t.sortMode==="name"?"Name":"Modified"}`),n().catch(l=>{r(`Sort render failed: ${String(l)}`,{persist:!0})})}function a(){t.isDirty&&!confirm("You have unsaved changes. Are you sure you want to exit?")||window.close()}function s(u){switch(u){case"new-note":o.createNewNote().catch(l=>{r(`Create note failed: ${String(l)}`,{persist:!0})});break;case"new-folder":o.createNewFolder().catch(l=>{r(`Create folder failed: ${String(l)}`,{persist:!0})});break;case"open-workspace":o.openWorkspace().catch(l=>{r(`Failed to open workspace: ${String(l)}`,{persist:!0})});break;case"close-workspace":o.closeWorkspace();break;case"exit":a();break;case"save":o.saveCurrentNote().catch(l=>{r(`Save failed: ${String(l)}`,{persist:!0})});break;case"save-as":o.saveAsNewNote().catch(l=>{r(`Save As failed: ${String(l)}`,{persist:!0})});break;case"refresh":o.handleRefresh();break;case"sort":i();break;case"collapse-sidebar":o.setSidebarCollapsed(!t.isSidebarCollapsed);break;case"export-json":o.exportAsJson();break;case"export-markdown":o.exportAsMarkdown();break;case"theme-default":case"theme-classic":case"theme-cobalt":case"theme-monokai":case"theme-office":case"theme-twilight":case"theme-xcode":{let l=u.replace("theme-","");ue.includes(l)&&Ee(l);break}}}return{toggleSort:i,handleMenuAction:s,handleExit:a}}function Ae(t,e){let r=e.isSidebarCollapsed;t.app.classList.toggle("sidebar-collapsed",r),t.workspaceSidebar.classList.toggle("collapsed",r),t.sidebarToggleBtn.setAttribute("aria-expanded",String(!r)),t.sidebarToggleBtn.setAttribute("aria-label",r?"Expand sidebar":"Collapse sidebar"),t.sidebarToggleBtn.title=r?"Expand sidebar":"Collapse sidebar",t.sidebarToggleBtn.textContent=r?"\u25BC Expand":"\u25B6 Collapse"}function Pe(t,e,r){e.isSidebarCollapsed=r,Ae(t,e)}function U(t,e,r="neutral"){t.statusBadge.textContent=e,t.statusBadge.classList.remove("ok","warn","err"),r!=="neutral"&&t.statusBadge.classList.add(r)}function it(t,e){function r(o,i={}){t.toastMsg.textContent=o,t.toast.classList.add("show"),e.current&&(clearTimeout(e.current),e.current=null),i.persist||(e.current=setTimeout(()=>{t.toast.classList.remove("show")},3500))}function n(){t.toast.classList.remove("show")}return{showToast:r,hideToast:n}}var te={folder:()=>'',folderOpen:()=>'',fileText:()=>'',library:()=>'',check:()=>''};function st({state:t,els:e,handlers:r,showToast:n}){function o(){let d=new Map,p=t.isTemporarySession?t.inMemoryNotes:t.notes;for(let h of p)for(let w of h.tags)d.set(w,(d.get(w)||0)+1);let c=[...d.entries()].sort((h,w)=>w[1]-h[1]||h[0].localeCompare(w[0])).slice(0,50);if(e.tagRow.innerHTML="",c.length===0)return;let m=document.createElement("button");m.className=`tag${t.tagFilter?"":" active"}`,m.textContent="All",m.title="Clear tag filter",m.addEventListener("click",()=>{t.tagFilter="",o(),t.isTemporarySession?l():a().catch(h=>{n(`Tag render failed: ${String(h)}`,{persist:!0})})}),e.tagRow.appendChild(m);for(let[h,w]of c){let v=document.createElement("button");v.className=`tag${t.tagFilter===h?" active":""}`,v.textContent=`#${h}`,v.title=`${w} note${w===1?"":"s"} tagged #${h}`,v.addEventListener("click",()=>{t.tagFilter=t.tagFilter===h?"":h,o(),t.isTemporarySession?l():a().catch(P=>{n(`Tag render failed: ${String(P)}`,{persist:!0})})}),e.tagRow.appendChild(v)}}async function i(){let d=[...t.notes];t.tagFilter&&(d=d.filter(m=>m.tags.has(t.tagFilter))),t.sortMode==="modified"?d.sort((m,h)=>(h.lastModified||0)-(m.lastModified||0)):d.sort((m,h)=>m.relPath.localeCompare(h.relPath));let p=t.searchQuery.trim().toLowerCase();if(!p)return new Set(d.map(m=>m.relPath));let c=new Set;for(let m of d){if(m.relPath.toLowerCase().includes(p)){c.add(m.relPath);continue}try{(await(await m.handle.getFile()).text()).toLowerCase().includes(p)&&c.add(m.relPath)}catch(h){}}return c}async function a(){if(!t.fileTree){e.tree.innerHTML='
    Open a folder to begin.
    ';return}let d=await i(),p=s(t.fileTree,d);if(e.tree.innerHTML="",!p||p.type!=="dir"||p.children.length===0){e.tree.innerHTML='
    No matching notes.
    ';return}for(let c of p.children)u(c,0)}function s(d,p){if(d.type==="file")return p.has(d.relPath)?d:null;let c=[];for(let m of d.children){let h=s(m,p);h&&c.push(h)}return d.relPath===""?{...d,children:c}:c.length===0?null:{...d,children:c}}function u(d,p){let c=document.createElement("div");if(c.className="node",c.style.paddingLeft=`${8+p*12}px`,d.type==="dir"){let w=t.collapsedDirs.has(d.relPath);if(c.innerHTML=` +`}tablecell(t){let e=this.parser.parseInline(t.tokens),n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${U(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:n}){let r=this.parser.parseInline(n),i=lt(t);if(i===null)return r;t=i;let o='
    ",o}image({href:t,title:e,text:n,tokens:r}){r&&(n=this.parser.parseInline(r,this.parser.textRenderer));let i=lt(t);if(i===null)return U(n);t=i;let o=`${U(n)}{let c=a[s].flat(1/0);n=n.concat(this.walkTokens(c,e))}):a.tokens&&(n=n.concat(this.walkTokens(a.tokens,e)))}}return n}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(n=>{let r={...n};if(r.async=this.defaults.async||r.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let o=e.renderers[i.name];o?e.renderers[i.name]=function(...a){let s=i.renderer.apply(this,a);return s===!1&&(s=o.apply(this,a)),s}:e.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let o=e[i.level];o?o.unshift(i.tokenizer):e[i.level]=[i.tokenizer],i.start&&(i.level==="block"?e.startBlock?e.startBlock.push(i.start):e.startBlock=[i.start]:i.level==="inline"&&(e.startInline?e.startInline.push(i.start):e.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(e.childTokens[i.name]=i.childTokens)}),r.extensions=e),n.renderer){let i=this.defaults.renderer||new ye(this.defaults);for(let o in n.renderer){if(!(o in i))throw new Error(`renderer '${o}' does not exist`);if(["options","parser"].includes(o))continue;let a=o,s=n.renderer[a],c=i[a];i[a]=(...l)=>{let d=s.apply(i,l);return d===!1&&(d=c.apply(i,l)),d||""}}r.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new be(this.defaults);for(let o in n.tokenizer){if(!(o in i))throw new Error(`tokenizer '${o}' does not exist`);if(["options","rules","lexer"].includes(o))continue;let a=o,s=n.tokenizer[a],c=i[a];i[a]=(...l)=>{let d=s.apply(i,l);return d===!1&&(d=c.apply(i,l)),d}}r.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new pe;for(let o in n.hooks){if(!(o in i))throw new Error(`hook '${o}' does not exist`);if(["options","block"].includes(o))continue;let a=o,s=n.hooks[a],c=i[a];pe.passThroughHooks.has(o)?i[a]=l=>{if(this.defaults.async&&pe.passThroughHooksRespectAsync.has(o))return(async()=>{let f=await s.call(i,l);return c.call(i,f)})();let d=s.call(i,l);return c.call(i,d)}:i[a]=(...l)=>{if(this.defaults.async)return(async()=>{let f=await s.apply(i,l);return f===!1&&(f=await c.apply(i,l)),f})();let d=s.apply(i,l);return d===!1&&(d=c.apply(i,l)),d}}r.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,o=n.walkTokens;r.walkTokens=function(a){let s=[];return s.push(o.call(this,a)),i&&(s=s.concat(i.call(this,a))),s}}this.defaults={...this.defaults,...r}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return O.lex(t,e!=null?e:this.defaults)}parser(t,e){return q.parse(t,e!=null?e:this.defaults)}parseMarkdown(t){return(e,n)=>{let r={...n},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||e===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if(i.hooks&&(i.hooks.options=i,i.hooks.block=t),i.async)return(async()=>{let a=i.hooks?await i.hooks.preprocess(e):e,s=await(i.hooks?await i.hooks.provideLexer():t?O.lex:O.lexInline)(a,i),c=i.hooks?await i.hooks.processAllTokens(s):s;i.walkTokens&&await Promise.all(this.walkTokens(c,i.walkTokens));let l=await(i.hooks?await i.hooks.provideParser():t?q.parse:q.parseInline)(c,i);return i.hooks?await i.hooks.postprocess(l):l})().catch(o);try{i.hooks&&(e=i.hooks.preprocess(e));let a=(i.hooks?i.hooks.provideLexer():t?O.lex:O.lexInline)(e,i);i.hooks&&(a=i.hooks.processAllTokens(a)),i.walkTokens&&this.walkTokens(a,i.walkTokens);let s=(i.hooks?i.hooks.provideParser():t?q.parse:q.parseInline)(a,i);return i.hooks&&(s=i.hooks.postprocess(s)),s}catch(a){return o(a)}}}onError(t,e){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,t){let r="

    An error occurred:

    "+U(n.message+"",!0)+"
    ";return e?Promise.resolve(r):r}if(e)return Promise.reject(n);throw n}}},se=new xr;function A(t,e){return se.parse(t,e)}A.options=A.setOptions=function(t){return se.setOptions(t),A.defaults=se.defaults,dt(A.defaults),A};A.getDefaults=$e;A.defaults=ae;A.use=function(...t){return se.use(...t),A.defaults=se.defaults,dt(A.defaults),A};A.walkTokens=function(t,e){return se.walkTokens(t,e)};A.parseInline=se.parseInline;A.Parser=q;A.parser=q.parse;A.Renderer=ye;A.TextRenderer=ze;A.Lexer=O;A.lexer=O.lex;A.Tokenizer=be;A.Hooks=pe;A.parse=A;var si=A.options,ai=A.setOptions,li=A.use,ci=A.walkTokens,ui=A.parseInline;var di=q.parse,pi=O.lex;function ge(t){return(t||"").trim().replace(/^#+/,"").replace(/[^\w\-/]+/g,"").toLowerCase()}function vr(t){if(!t.startsWith("---"))return"";let e=t.indexOf(` +---`,3);return e===-1?"":t.slice(3,e).trim()}function Sr(t){let e=new Set,n=t.split(` +`);for(let i of n){let o=i.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i);if(!o)continue;let a=o[1].split(",").map(s=>ge(s.replace(/["']/g,"")));for(let s of a)s&&e.add(s)}let r=!1;for(let i of n){if(/^\s*tags\s*:\s*$/i.test(i)){r=!0;continue}if(!r)continue;let o=i.match(/^\s*-\s*(.+)\s*$/);if(o){let a=ge(o[1].replace(/["']/g,""));a&&e.add(a);continue}i.trim()!==""&&!/^\s+/.test(i)&&(r=!1)}return e}function St(t){let e=new Set,n=vr(t);if(n){let i=Sr(n);for(let o of i)e.add(o)}let r=t.match(/\B#([a-zA-Z0-9][\w\-/]{1,50})\b/g);if(r)for(let i of r){let o=ge(i);o&&e.add(o)}return e}function M(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing required element: #${t}`);return e}function Lt(){return{app:M("app"),menuBar:M("menuBar"),workspaceSidebar:M("workspaceSidebar"),sidebarToggleBtn:M("sidebarToggleBtn"),refreshBtn:M("refreshBtn"),sortBtn:M("sortBtn"),searchInput:M("searchInput"),webmcpNoteModal:M("webmcpNoteModal"),webmcpNoteModalBackdrop:M("webmcpNoteModalBackdrop"),webmcpNoteModalCloseBtn:M("webmcpNoteModalCloseBtn"),webmcpNoteForm:M("webmcpNoteForm"),webmcpTitleInput:M("webmcpTitleInput"),webmcpBodyInput:M("webmcpBodyInput"),webmcpTagInput:M("webmcpTagInput"),tree:M("tree"),tagRow:M("tagRow"),workspaceName:M("workspaceName"),countsPill:M("countsPill"),editor:M("editor"),editorSplit:M("editorSplit"),editorPane:M("editorPane"),previewPane:M("previewPane"),editorViewModeGroup:M("editorViewModeGroup"),editorViewSourceBtn:M("editorViewSourceBtn"),editorViewSplitBtn:M("editorViewSplitBtn"),editorViewPreviewBtn:M("editorViewPreviewBtn"),preview:M("preview"),currentFilename:M("currentFilename"),dirtyDot:M("dirtyDot"),statusBadge:M("statusBadge"),toast:M("toast"),toastMsg:M("toastMsg"),toastCloseBtn:M("toastCloseBtn"),temporarySessionBadge:M("temporarySessionBadge"),cogitoToggleBtn:M("cogitoToggleBtn"),cogitoPanel:M("cogitoPanel"),cogitoLiteBtn:M("cogitoLiteBtn"),cogitoDeepBtn:M("cogitoDeepBtn"),cogitoGenerateBtn:M("cogitoGenerateBtn"),cogitoStatus:M("cogitoStatus"),cogitoQuestionList:M("cogitoQuestionList"),documentLinterToggleBtn:M("documentLinterToggleBtn"),documentLinterPanel:M("documentLinterPanel"),documentLinterAnalyzeBtn:M("documentLinterAnalyzeBtn"),documentLinterExportBtn:M("documentLinterExportBtn"),documentLinterAutoRunToggle:M("documentLinterAutoRunToggle"),documentLinterStatus:M("documentLinterStatus"),documentLinterResults:M("documentLinterResults")}}function Tt(){return!!(window.showDirectoryPicker&&window.FileSystemHandle)}async function _e(t,e="read"){if(!t)return!1;if(!t.queryPermission||!t.requestPermission)return!0;let n={mode:e};return await t.queryPermission(n)==="granted"?!0:await t.requestPermission(n)==="granted"}function Mt({state:t,ensurePermission:e,rescanWorkspace:n,showToast:r,setStatus:i}){function o(){s(),t.autoRefreshTimer=setInterval(()=>{a().catch(c=>{r(`Auto-refresh failed: ${String(c)}`,{persist:!0}),i("Auto-refresh failed","err")})},t.autoRefreshMs)}async function a(){if(t.workspaceHandle){try{if(!await e(t.workspaceHandle,"read"))throw new Error("Folder permission revoked.")}catch(c){s(),r("Auto-refresh stopped: folder permission revoked.",{persist:!0}),i("Permission revoked","err");return}t.isDirty||await n({silent:!0})}}function s(){t.autoRefreshTimer&&(clearInterval(t.autoRefreshTimer),t.autoRefreshTimer=null)}return{startAutoRefresh:o,stopAutoRefresh:s}}function G(t){return String(t).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}var Et="ink-editor-view-mode",Lr=["split","source","preview"];function Tr(t){return t==="source"||t==="preview"?t:"split"}function Rt(){try{return Tr(localStorage.getItem(Et))}catch(t){return"split"}}function We(t,e){t.editorSplit.classList.toggle("view-split",e==="split"),t.editorSplit.classList.toggle("view-source",e==="source"),t.editorSplit.classList.toggle("view-preview",e==="preview"),t.editorPane.hidden=e==="preview",t.previewPane.hidden=e==="source",[[t.editorViewSourceBtn,"source"],[t.editorViewSplitBtn,"split"],[t.editorViewPreviewBtn,"preview"]].forEach(([r,i])=>{let o=i===e;r.classList.toggle("active",o),r.setAttribute("aria-pressed",String(o))})}function Ct(t,e,n){if(Lr.includes(n)){e.editorViewMode=n,We(t,n);try{localStorage.setItem(Et,n)}catch(r){}}}function Oe(t,e){try{t.preview.innerHTML=A.parse(e||"")}catch(n){let r=n instanceof Error?n.message:String(n);t.preview.innerHTML=`
    ${G(r)}
    `}}function Se(t,e,n){t.dirtyDot.classList.toggle("show",e.isDirty);let r=e.currentRelPath?e.currentRelPath.split("/").pop():"No note open";t.currentFilename.textContent=`${r}${e.isDirty?" \u2022 Unsaved":""}`,e.isDirty&&n("Unsaved changes","warn")}var Le=["default","classic","cobalt","monokai","office","twilight","xcode"];function qe(t){if(!Le.includes(t))return;t==="default"?document.documentElement.removeAttribute("data-theme"):document.documentElement.setAttribute("data-theme",t);try{localStorage.setItem("ink-theme",t)}catch(n){}document.querySelectorAll(".menu-theme-check").forEach(n=>{n.classList.remove("active")});let e=document.getElementById(`themeCheck-${t}`);e&&e.classList.add("active")}function At(){var e;let t="default";try{t=(e=localStorage.getItem("ink-theme"))!=null?e:"default"}catch(n){}qe(Le.includes(t)?t:"default")}function $t(t,e){let n=e?"Cmd":"Ctrl";t.menuBar.querySelectorAll(".menu-shortcut").forEach(i=>{let o=i.textContent;o&&(o.includes("Cmd/Ctrl")?i.textContent=o.replace("Cmd/Ctrl",n):o.includes("Ctrl")&&(i.textContent=o.replace("Ctrl",n)))})}function Nt({state:t,els:e,showToast:n,renderTree:r,callbacks:i}){function o(){t.sortMode=t.sortMode==="name"?"modified":"name",e.sortBtn.textContent=`Sort: ${t.sortMode==="name"?"Name":"Last modified"}`;let c=document.querySelector('[data-action="sort"] .menu-label-text');c&&(c.textContent=`Sort: ${t.sortMode==="name"?"Name":"Modified"}`),r().catch(l=>{n(`Sort render failed: ${String(l)}`,{persist:!0})})}function a(){t.isDirty&&!confirm("You have unsaved changes. Are you sure you want to exit?")||window.close()}function s(c){switch(c){case"new-note":i.createNewNote().catch(l=>{n(`Create note failed: ${String(l)}`,{persist:!0})});break;case"new-folder":i.createNewFolder().catch(l=>{n(`Create folder failed: ${String(l)}`,{persist:!0})});break;case"open-workspace":i.openWorkspace().catch(l=>{n(`Failed to open workspace: ${String(l)}`,{persist:!0})});break;case"close-workspace":i.closeWorkspace();break;case"exit":a();break;case"save":i.saveCurrentNote().catch(l=>{n(`Save failed: ${String(l)}`,{persist:!0})});break;case"save-as":i.saveAsNewNote().catch(l=>{n(`Save As failed: ${String(l)}`,{persist:!0})});break;case"refresh":i.handleRefresh();break;case"sort":o();break;case"collapse-sidebar":i.setSidebarCollapsed(!t.isSidebarCollapsed);break;case"export-json":i.exportAsJson();break;case"export-markdown":i.exportAsMarkdown();break;case"theme-default":case"theme-classic":case"theme-cobalt":case"theme-monokai":case"theme-office":case"theme-twilight":case"theme-xcode":{let l=c.replace("theme-","");Le.includes(l)&&qe(l);break}}}return{toggleSort:o,handleMenuAction:s,handleExit:a}}function je(t,e){let n=e.isSidebarCollapsed;t.app.classList.toggle("sidebar-collapsed",n),t.workspaceSidebar.classList.toggle("collapsed",n),t.sidebarToggleBtn.setAttribute("aria-expanded",String(!n)),t.sidebarToggleBtn.setAttribute("aria-label",n?"Expand sidebar":"Collapse sidebar"),t.sidebarToggleBtn.title=n?"Expand sidebar":"Collapse sidebar",t.sidebarToggleBtn.textContent=n?"\u25BC Expand":"\u25B6 Collapse"}function Qe(t,e,n){e.isSidebarCollapsed=n,je(t,e)}function ee(t,e,n="neutral"){t.statusBadge.textContent=e,t.statusBadge.classList.remove("ok","warn","err"),n!=="neutral"&&t.statusBadge.classList.add(n)}function Pt(t,e){function n(i,o={}){t.toastMsg.textContent=i,t.toast.classList.add("show"),e.current&&(clearTimeout(e.current),e.current=null),o.persist||(e.current=setTimeout(()=>{t.toast.classList.remove("show")},3500))}function r(){t.toast.classList.remove("show")}return{showToast:n,hideToast:r}}var me={folder:()=>'',folderOpen:()=>'',fileText:()=>'',library:()=>'',check:()=>''};function Dt({state:t,els:e,handlers:n,showToast:r}){function i(){let u=new Map,h=t.isTemporarySession?t.inMemoryNotes:t.notes;for(let m of h)for(let w of m.tags)u.set(w,(u.get(w)||0)+1);let p=[...u.entries()].sort((m,w)=>w[1]-m[1]||m[0].localeCompare(w[0])).slice(0,50);if(e.tagRow.innerHTML="",p.length===0)return;let b=document.createElement("button");b.className=`tag${t.tagFilter?"":" active"}`,b.textContent="All",b.title="Clear tag filter",b.addEventListener("click",()=>{t.tagFilter="",i(),t.isTemporarySession?l():a().catch(m=>{r(`Tag render failed: ${String(m)}`,{persist:!0})})}),e.tagRow.appendChild(b);for(let[m,w]of p){let g=document.createElement("button");g.className=`tag${t.tagFilter===m?" active":""}`,g.textContent=`#${m}`,g.title=`${w} note${w===1?"":"s"} tagged #${m}`,g.addEventListener("click",()=>{t.tagFilter=t.tagFilter===m?"":m,i(),t.isTemporarySession?l():a().catch(S=>{r(`Tag render failed: ${String(S)}`,{persist:!0})})}),e.tagRow.appendChild(g)}}async function o(){let u=[...t.notes];t.tagFilter&&(u=u.filter(b=>b.tags.has(t.tagFilter))),t.sortMode==="modified"?u.sort((b,m)=>(m.lastModified||0)-(b.lastModified||0)):u.sort((b,m)=>b.relPath.localeCompare(m.relPath));let h=t.searchQuery.trim().toLowerCase();if(!h)return new Set(u.map(b=>b.relPath));let p=new Set;for(let b of u){if(b.relPath.toLowerCase().includes(h)){p.add(b.relPath);continue}try{(await(await b.handle.getFile()).text()).toLowerCase().includes(h)&&p.add(b.relPath)}catch(m){}}return p}async function a(){if(!t.fileTree){e.tree.innerHTML='
    Open a folder to begin.
    ';return}let u=await o(),h=s(t.fileTree,u);if(e.tree.innerHTML="",!h||h.type!=="dir"||h.children.length===0){e.tree.innerHTML='
    No matching notes.
    ';return}for(let p of h.children)c(p,0)}function s(u,h){if(u.type==="file")return h.has(u.relPath)?u:null;let p=[];for(let b of u.children){let m=s(b,h);m&&p.push(m)}return u.relPath===""?{...u,children:p}:p.length===0?null:{...u,children:p}}function c(u,h){let p=document.createElement("div");if(p.className="node",p.style.paddingLeft=`${8+h*12}px`,u.type==="dir"){let w=t.collapsedDirs.has(u.relPath);if(p.innerHTML=` ${w?"\u25B6":"\u25BC"} - ${w?te.folder():te.folderOpen()} - ${q(d.name)} - `,c.addEventListener("click",v=>{v.stopPropagation(),w?t.collapsedDirs.delete(d.relPath):t.collapsedDirs.add(d.relPath),a().catch(P=>{n(`Tree render failed: ${String(P)}`,{persist:!0})})}),e.tree.appendChild(c),!w)for(let v of d.children)u(v,p+1);return}d.relPath===t.currentRelPath&&c.classList.add("active");let h=t.sortMode==="modified"&&d.noteRef.lastModified?new Date(d.noteRef.lastModified).toLocaleDateString():"";c.innerHTML=` - ${te.fileText()} - ${q(d.noteRef.name)} - ${q(h)} - `,c.addEventListener("click",w=>{w.stopPropagation(),r.openNoteByRelPath(d.relPath,d.handle).catch(v=>{n(`Open note failed: ${String(v)}`,{persist:!0})})}),e.tree.appendChild(c)}function l(){if(e.tree.innerHTML="",t.inMemoryNotes.length===0){e.tree.innerHTML='
    Temporary session. Create a note to begin.
    ';return}let d=[...t.inMemoryNotes].sort((p,c)=>p.relPath.localeCompare(c.relPath));for(let p of d)g(p)}function g(d){let p=document.createElement("div");p.className="node",d.relPath===t.currentRelPath&&p.classList.add("active");let m=t.sortMode==="modified"?new Date(d.lastModified).toLocaleDateString():"";p.innerHTML=` - ${te.fileText()} - ${q(d.name)} - ${q(m)} - `,p.addEventListener("click",()=>{r.openInMemoryNote(d.relPath)}),e.tree.appendChild(p)}function k(){let d=t.inMemoryNotes.length;e.countsPill.textContent=`${d} note${d===1?"":"s"}`}return{computeMatches:i,renderTree:a,renderTags:o,renderInMemoryTree:l,updateCountsPill:k}}function at({state:t,els:e,actions:r}){let n=!1,o=Ne(e),i=null;function a(p){e.webmcpNoteModal.setAttribute("aria-hidden","false"),e.webmcpNoteModal.classList.add("show"),p!=null&&p.focusTitle&&queueMicrotask(()=>{e.webmcpTitleInput.focus()})}function s(){e.webmcpNoteModal.classList.remove("show"),e.webmcpNoteModal.setAttribute("aria-hidden","true")}function u(p){n=!0,a(p)}function l(){if(e.webmcpNoteModal.classList.contains("show")){s();return}a({focusTitle:!0})}function g(){let p=Ne(e);p!==o&&(o=p,kr(e)&&u())}function k(){i===null&&(i=window.setInterval(()=>{g()},150))}xr(e,r.handleMenuAction),e.sidebarToggleBtn.addEventListener("click",()=>{r.toggleSidebar()}),e.refreshBtn.addEventListener("click",()=>{r.handleRefresh()}),e.sortBtn.addEventListener("click",()=>{r.toggleSort()}),e.searchInput.addEventListener("input",()=>{r.handleSearchInput(e.searchInput.value)});let d=[e.webmcpTitleInput,e.webmcpBodyInput,e.webmcpTagInput];d.forEach(p=>{p.addEventListener("input",g),p.addEventListener("change",g),p.addEventListener("focus",()=>{e.webmcpNoteModal.classList.contains("show")||u()})}),d.forEach(p=>{wr(p,g)}),e.webmcpNoteModalCloseBtn.addEventListener("click",()=>{s()}),e.webmcpNoteModalBackdrop.addEventListener("click",()=>{s()}),e.webmcpNoteForm.addEventListener("submit",p=>{let c=p,m=typeof c.respondWith=="function";p.preventDefault(),c.agentInvoked&&u();let h={title:e.webmcpTitleInput.value,body:e.webmcpBodyInput.value,tag:e.webmcpTagInput.value},w=r.createNoteFromTool(h).then(v=>(v.ok&&(m||(e.webmcpNoteForm.reset(),o=Ne(e)),n&&(s(),n=!1)),v));if(typeof c.respondWith=="function"){c.respondWith(w);return}w.catch(v=>{r.showToast(`WebMCP note creation failed: ${String(v)}`,{persist:!0})})}),e.editor.addEventListener("input",()=>{t.currentRelPath&&r.handleEditorInput(e.editor.value)}),e.editor.addEventListener("keydown",p=>{if((navigator.platform.toLowerCase().includes("mac")?p.metaKey:p.ctrlKey)&&!p.shiftKey&&p.key.toLowerCase()==="s"){p.preventDefault(),r.saveCurrentNote().catch(h=>{r.showToast(`Save failed: ${String(h)}`,{persist:!0})});return}if(p.key==="Tab"){p.preventDefault();let h=e.editor.selectionStart;e.editor.setRangeText(" ",h,h,"end")}}),e.toastCloseBtn.addEventListener("click",()=>{r.hideToast()}),e.cogitoToggleBtn.addEventListener("click",()=>{r.toggleCogitoPanel()}),e.cogitoLiteBtn.addEventListener("click",()=>{r.selectCogitoModel("lite")}),e.cogitoDeepBtn.addEventListener("click",()=>{r.selectCogitoModel("deep")}),e.cogitoGenerateBtn.addEventListener("click",()=>{r.generateCogitoQuestions().catch(p=>{r.showToast(`Cogito generation failed: ${String(p)}`,{persist:!0})})}),e.cogitoQuestionList.addEventListener("click",p=>{let c=p.target;if(!c)return;let m=c.closest("[data-question-index]");if(!m)return;let h=m.getAttribute("data-question-index"),w=Number(h);Number.isNaN(w)||r.insertCogitoQuestion(w)}),window.addEventListener("beforeunload",p=>{t.isDirty&&(p.preventDefault(),p.returnValue="")}),window.addEventListener("keydown",p=>{p.key==="Escape"&&e.webmcpNoteModal.classList.contains("show")&&s()}),window.addEventListener("keydown",p=>{(/Mac|iPod|iPhone|iPad/.test(navigator.userAgent)?p.metaKey:p.ctrlKey)&&p.altKey&&p.key.toLowerCase()==="n"&&(p.preventDefault(),l())}),br()&&a({focusTitle:!0}),k(),yr(r)}function Ne(t){return[t.webmcpTitleInput.value,t.webmcpBodyInput.value,t.webmcpTagInput.value].join("\u241F")}function kr(t){return t.webmcpTitleInput.value.trim()!==""||t.webmcpBodyInput.value.trim()!==""||t.webmcpTagInput.value.trim()!==""}function wr(t,e){var o;let r=Object.getPrototypeOf(t),n=Object.getOwnPropertyDescriptor(r,"value");!(n!=null&&n.get)||!n.set||Object.defineProperty(t,"value",{configurable:!0,enumerable:(o=n.enumerable)!=null?o:!0,get(){return n.get.call(this)},set(i){n.set.call(this,i),e()}})}function br(){try{return new URLSearchParams(window.location.search).get("debugWebMcpNote")==="1"}catch(t){return!1}}function xr(t,e){let r=t.menuBar.querySelectorAll(".menu-item");r.forEach(i=>{i.addEventListener("click",a=>{a.stopPropagation();let s=i.getAttribute("aria-expanded")==="true";r.forEach(u=>u.setAttribute("aria-expanded","false")),s||i.setAttribute("aria-expanded","true")}),i.addEventListener("keydown",a=>{a.key==="Escape"&&i.setAttribute("aria-expanded","false")})});let n=t.menuBar.querySelectorAll(".submenu-parent");n.forEach(i=>{i.addEventListener("click",a=>{a.stopPropagation();let s=i.getAttribute("aria-expanded")==="true";n.forEach(u=>u.setAttribute("aria-expanded","false")),s||i.setAttribute("aria-expanded","true")}),i.addEventListener("keydown",a=>{a.key==="Escape"&&i.setAttribute("aria-expanded","false")})}),document.addEventListener("click",()=>{r.forEach(i=>i.setAttribute("aria-expanded","false")),n.forEach(i=>i.setAttribute("aria-expanded","false"))}),t.menuBar.querySelectorAll(".dropdown li[data-action]").forEach(i=>{i.addEventListener("click",()=>{let a=i.getAttribute("data-action");a&&e(a),r.forEach(s=>s.setAttribute("aria-expanded","false")),n.forEach(s=>s.setAttribute("aria-expanded","false"))})})}function yr(t){window.addEventListener("keydown",e=>{let n=/Mac|iPod|iPhone|iPad/.test(navigator.userAgent)?e.metaKey:e.ctrlKey,o=e.altKey;if(n&&e.key.toLowerCase()==="l"){e.preventDefault(),t.handleRefresh();return}if(n&&e.key.toLowerCase()==="s"&&!e.shiftKey){e.preventDefault(),t.saveCurrentNote().catch(i=>{t.showToast(`Save failed: ${String(i)}`,{persist:!0})});return}if(n&&e.shiftKey&&e.key.toLowerCase()==="s"){e.preventDefault(),t.exportAsJson();return}if(n&&e.shiftKey&&e.key.toLowerCase()==="m"){e.preventDefault(),t.exportAsMarkdown();return}if(n&&e.key.toLowerCase()==="e"){e.preventDefault(),t.createNewNote().catch(i=>{t.showToast(`Create note failed: ${String(i)}`,{persist:!0})});return}if(n&&e.shiftKey&&e.key.toLowerCase()==="o"){e.preventDefault(),t.openWorkspace().catch(i=>{t.showToast(`Failed to open workspace: ${String(i)}`,{persist:!0})});return}o&&e.shiftKey&&e.key.toLowerCase()==="o"&&(e.preventDefault(),t.openWorkspace().catch(i=>{t.showToast(`Failed to open workspace: ${String(i)}`,{persist:!0})}))})}function lt({state:t,els:e,showToast:r,setStatus:n,renderPreview:o,updateDirtyUi:i,renderTree:a,renderInMemoryTree:s,renderTags:u,updateCountsPill:l,fsApi:g,parseTags:k,normalizeTag:d,autoRefresh:p}){function c(){let f=t.isTemporarySession;t.workspaceHandle=null,t.workspaceName="Temporary Session",t.fileTree=null,t.notes=[],t.currentFileHandle=null,f||(t.inMemoryNotes=[],t.currentRelPath="",t.currentContent="",t.isDirty=!1,e.editor.value="",o(e,""),i(e,t,n)),t.isTemporarySession=!0,e.workspaceName.textContent=t.workspaceName,e.workspaceName.title="Temporary Session - Data not persisted",e.temporarySessionBadge.style.display="inline",e.countsPill.textContent=`${t.inMemoryNotes.length} note${t.inMemoryNotes.length===1?"":"s"}`,e.tagRow.innerHTML="",s(),u()}function m(f){let b=f.trim().replace(/[\\/:*?"<>|]/g," ").replace(/\s+/g," ").trim()||"Untitled";return b.endsWith(".md")?b:`${b}.md`}function h({title:f,body:x,tag:b}){let y=f.trim()||"Untitled",T=x.trim(),S=d(b),A=[];return S&&A.push("---",`tags: [${S}]`,"---",""),A.push(`# ${y}`),T&&A.push("",T),A.join(` -`)}async function w(){if(!g.isFileSystemApiAvailable()){c(),e.tree.innerHTML='
    Temporary session. Create a note to begin.
    ',r("Temporary in-memory workspace enabled. Use Export to save your notes.",{persist:!0}),n("Temporary session","warn");return}try{if(!window.showDirectoryPicker)throw new Error("File System Access API not available");let f=await window.showDirectoryPicker({id:"local-md-workspace",mode:"readwrite"});if(!await g.ensurePermission(f,"readwrite")){r("Permission denied. Please allow access to the folder.",{persist:!0}),n("Permission denied","err");return}t.workspaceHandle=f,t.workspaceName=f.name||"Selected folder",t.isTemporarySession=!1,t.collapsedDirs.clear(),t.tagFilter="",e.temporarySessionBadge.style.display="none",e.workspaceName.textContent=t.workspaceName,e.workspaceName.title=t.workspaceName,e.tagRow.innerHTML="",n("Scanning folder..."),await v(),p.startAutoRefresh(),r("Workspace opened."),n("Workspace ready","ok")}catch(f){if(f instanceof Error&&f.name==="AbortError"){n("Open folder cancelled");return}let x=f instanceof Error?f.message:String(f);r(`Failed to open folder: ${x}`,{persist:!0}),n("Failed to open folder","err")}}async function v(f={}){if(t.workspaceHandle)try{if(!await g.ensurePermission(t.workspaceHandle,"read"))throw new Error("Folder permission not granted (read)");let b=[],y={type:"dir",name:t.workspaceName,relPath:"",children:[]};await P(t.workspaceHandle,y,"",b),await N(b),t.notes=b,t.fileTree=y,e.countsPill.textContent=`${b.length} note${b.length===1?"":"s"}`,u(),await a(),f.silent||(r("Workspace refreshed."),n("Refreshed","ok"))}catch(x){let b=x instanceof Error?x.message:String(x);r(`Refresh failed: ${b}`,{persist:!0}),n("Refresh failed","err")}}async function P(f,x,b,y){for await(let[T,S]of f.entries()){if(T.startsWith("."))continue;if(S.kind==="directory"){let O=b?`${b}/${T}`:T,$e={type:"dir",name:T,relPath:O,children:[]};x.children.push($e),await P(S,$e,O,y);continue}if(!T.toLowerCase().endsWith(".md"))continue;let A=b?`${b}/${T}`:T,j=0,I=0;try{let O=await S.getFile();j=O.lastModified||0,I=O.size||0}catch(O){r(`Skipped a file that couldn't be read: ${A}`);continue}let W={handle:S,name:T,relPath:A,lastModified:j,size:I,tags:new Set};y.push(W);let Q={type:"file",name:T,relPath:A,handle:S,noteRef:W};x.children.push(Q)}x.children.sort((T,S)=>T.type!==S.type?T.type==="dir"?-1:1:T.name.localeCompare(S.name))}async function N(f){for(let b of f)try{let y=await b.handle.getFile(),S=await(y.size>262144?y.slice(0,262144):y).text();b.tags=k(S)}catch(y){b.tags=new Set}}async function L(f,x=null){var b;if(!(t.isDirty&&t.currentFileHandle&&!confirm("You have unsaved changes. Discard them?")))try{let y=x||((b=t.notes.find(A=>A.relPath===f))==null?void 0:b.handle);if(!y)throw new Error("File not found");let S=await(await y.getFile()).text();t.currentFileHandle=y,t.currentRelPath=f,t.currentContent=S,t.isDirty=!1,e.editor.value=S,o(e,S),i(e,t,n),n("Opened \u2713","ok"),await a()}catch(y){let T=y instanceof Error?y.message:String(y);r(`Failed to open note: ${T}`,{persist:!0}),n("Open failed","err")}}async function D(){if(t.isTemporarySession)return Le();if(t.currentFileHandle)try{let f=await t.currentFileHandle.createWritable();await f.write(e.editor.value),await f.close(),t.currentContent=e.editor.value,t.isDirty=!1,i(e,t,n),n("Saved \u2713","ok"),r("Saved \u2713"),await v({silent:!0})}catch(f){let x=f instanceof Error?f.message:String(f);r(`Save failed: ${x}`,{persist:!0}),n("Save failed","err")}}async function F(){if(!t.workspaceHandle&&!t.isTemporarySession){r("Open a workspace first.");return}if(t.isDirty&&!confirm("You have unsaved changes. Continue and discard them?"))return;let f=prompt("New note name (without .md)");if(!f)return;let x=f.endsWith(".md")?f:`${f}.md`;if(t.isTemporarySession)return de(x,f);try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");let b=t.workspaceHandle;for await(let[A]of b.entries())if(A===x){r("A file with that name already exists.",{persist:!0});return}let y=await t.workspaceHandle.getFileHandle(x,{create:!0}),T=`# ${f} + ${w?me.folder():me.folderOpen()} + ${G(u.name)} + `,p.addEventListener("click",g=>{g.stopPropagation(),w?t.collapsedDirs.delete(u.relPath):t.collapsedDirs.add(u.relPath),a().catch(S=>{r(`Tree render failed: ${String(S)}`,{persist:!0})})}),e.tree.appendChild(p),!w)for(let g of u.children)c(g,h+1);return}u.relPath===t.currentRelPath&&p.classList.add("active");let m=t.sortMode==="modified"&&u.noteRef.lastModified?new Date(u.noteRef.lastModified).toLocaleDateString():"";p.innerHTML=` + ${me.fileText()} + ${G(u.noteRef.name)} + ${G(m)} + `,p.addEventListener("click",w=>{w.stopPropagation(),n.openNoteByRelPath(u.relPath,u.handle).catch(g=>{r(`Open note failed: ${String(g)}`,{persist:!0})})}),e.tree.appendChild(p)}function l(){if(e.tree.innerHTML="",t.inMemoryNotes.length===0){e.tree.innerHTML='
    Temporary session. Create a note to begin.
    ';return}let u=[...t.inMemoryNotes].sort((h,p)=>h.relPath.localeCompare(p.relPath));for(let h of u)d(h)}function d(u){let h=document.createElement("div");h.className="node",u.relPath===t.currentRelPath&&h.classList.add("active");let b=t.sortMode==="modified"?new Date(u.lastModified).toLocaleDateString():"";h.innerHTML=` + ${me.fileText()} + ${G(u.name)} + ${G(b)} + `,h.addEventListener("click",()=>{n.openInMemoryNote(u.relPath)}),e.tree.appendChild(h)}function f(){let u=t.inMemoryNotes.length;e.countsPill.textContent=`${u} note${u===1?"":"s"}`}return{computeMatches:o,renderTree:a,renderTags:i,renderInMemoryTree:l,updateCountsPill:f}}function Bt({state:t,els:e,actions:n}){let r=!1,i=Ve(e),o=null;function a(h){e.webmcpNoteModal.setAttribute("aria-hidden","false"),e.webmcpNoteModal.classList.add("show"),h!=null&&h.focusTitle&&queueMicrotask(()=>{e.webmcpTitleInput.focus()})}function s(){e.webmcpNoteModal.classList.remove("show"),e.webmcpNoteModal.setAttribute("aria-hidden","true")}function c(h){r=!0,a(h)}function l(){if(e.webmcpNoteModal.classList.contains("show")){s();return}a({focusTitle:!0})}function d(){let h=Ve(e);h!==i&&(i=h,Mr(e)&&c())}function f(){o===null&&(o=window.setInterval(()=>{d()},150))}Cr(e,n.handleMenuAction),e.sidebarToggleBtn.addEventListener("click",()=>{n.toggleSidebar()}),e.refreshBtn.addEventListener("click",()=>{n.handleRefresh()}),e.sortBtn.addEventListener("click",()=>{n.toggleSort()}),e.searchInput.addEventListener("input",()=>{n.handleSearchInput(e.searchInput.value)});let u=[e.webmcpTitleInput,e.webmcpBodyInput,e.webmcpTagInput];u.forEach(h=>{h.addEventListener("input",d),h.addEventListener("change",d),h.addEventListener("focus",()=>{e.webmcpNoteModal.classList.contains("show")||c()})}),u.forEach(h=>{Er(h,d)}),e.webmcpNoteModalCloseBtn.addEventListener("click",()=>{s()}),e.webmcpNoteModalBackdrop.addEventListener("click",()=>{s()}),e.webmcpNoteForm.addEventListener("submit",h=>{let p=h,b=typeof p.respondWith=="function";h.preventDefault(),p.agentInvoked&&c();let m={title:e.webmcpTitleInput.value,body:e.webmcpBodyInput.value,tag:e.webmcpTagInput.value},w=n.createNoteFromTool(m).then(g=>(g.ok&&(b||(e.webmcpNoteForm.reset(),i=Ve(e)),r&&(s(),r=!1)),g));if(typeof p.respondWith=="function"){p.respondWith(w);return}w.catch(g=>{n.showToast(`WebMCP note creation failed: ${String(g)}`,{persist:!0})})}),e.editor.addEventListener("input",()=>{t.currentRelPath&&n.handleEditorInput(e.editor.value)}),e.editor.addEventListener("keydown",h=>{if((navigator.platform.toLowerCase().includes("mac")?h.metaKey:h.ctrlKey)&&!h.shiftKey&&h.key.toLowerCase()==="s"){h.preventDefault(),n.saveCurrentNote().catch(m=>{n.showToast(`Save failed: ${String(m)}`,{persist:!0})});return}if(h.key==="Tab"){h.preventDefault();let m=e.editor.selectionStart;e.editor.setRangeText(" ",m,m,"end")}}),e.toastCloseBtn.addEventListener("click",()=>{n.hideToast()}),e.cogitoToggleBtn.addEventListener("click",()=>{n.toggleCogitoPanel()}),e.cogitoLiteBtn.addEventListener("click",()=>{n.selectCogitoModel("lite")}),e.cogitoDeepBtn.addEventListener("click",()=>{n.selectCogitoModel("deep")}),e.cogitoGenerateBtn.addEventListener("click",()=>{n.generateCogitoQuestions().catch(h=>{n.showToast(`Cogito generation failed: ${String(h)}`,{persist:!0})})}),e.cogitoQuestionList.addEventListener("click",h=>{let p=h.target;if(!p)return;let b=p.closest("[data-question-index]");if(!b)return;let m=b.getAttribute("data-question-index"),w=Number(m);Number.isNaN(w)||n.insertCogitoQuestion(w)}),e.editorViewModeGroup.addEventListener("click",h=>{let p=h.target;if(!p)return;let b=p.closest("[data-editor-view-mode]");if(!b)return;let m=b.getAttribute("data-editor-view-mode");m&&n.setEditorViewMode(m)}),e.documentLinterToggleBtn.addEventListener("click",()=>{n.toggleDocumentLinterPanel()}),e.documentLinterAnalyzeBtn.addEventListener("click",()=>{n.analyzeDocument().catch(h=>{n.showToast(`Document analysis failed: ${String(h)}`,{persist:!0})})}),e.documentLinterExportBtn.addEventListener("click",()=>{n.exportDocumentLinterSuggestions().catch(h=>{n.showToast(`Document export failed: ${String(h)}`,{persist:!0})})}),window.addEventListener("beforeunload",h=>{t.isDirty&&(h.preventDefault(),h.returnValue="")}),window.addEventListener("keydown",h=>{h.key==="Escape"&&e.webmcpNoteModal.classList.contains("show")&&s()}),window.addEventListener("keydown",h=>{(/Mac|iPod|iPhone|iPad/.test(navigator.userAgent)?h.metaKey:h.ctrlKey)&&h.altKey&&h.key.toLowerCase()==="n"&&(h.preventDefault(),l())}),Rr()&&a({focusTitle:!0}),f(),Ar(n)}function Ve(t){return[t.webmcpTitleInput.value,t.webmcpBodyInput.value,t.webmcpTagInput.value].join("\u241F")}function Mr(t){return t.webmcpTitleInput.value.trim()!==""||t.webmcpBodyInput.value.trim()!==""||t.webmcpTagInput.value.trim()!==""}function Er(t,e){var i;let n=Object.getPrototypeOf(t),r=Object.getOwnPropertyDescriptor(n,"value");!(r!=null&&r.get)||!r.set||Object.defineProperty(t,"value",{configurable:!0,enumerable:(i=r.enumerable)!=null?i:!0,get(){return r.get.call(this)},set(o){r.set.call(this,o),e()}})}function Rr(){try{return new URLSearchParams(window.location.search).get("debugWebMcpNote")==="1"}catch(t){return!1}}function Cr(t,e){let n=t.menuBar.querySelectorAll(".menu-item");n.forEach(o=>{o.addEventListener("click",a=>{a.stopPropagation();let s=o.getAttribute("aria-expanded")==="true";n.forEach(c=>c.setAttribute("aria-expanded","false")),s||o.setAttribute("aria-expanded","true")}),o.addEventListener("keydown",a=>{a.key==="Escape"&&o.setAttribute("aria-expanded","false")})});let r=t.menuBar.querySelectorAll(".submenu-parent");r.forEach(o=>{o.addEventListener("click",a=>{a.stopPropagation();let s=o.getAttribute("aria-expanded")==="true";r.forEach(c=>c.setAttribute("aria-expanded","false")),s||o.setAttribute("aria-expanded","true")}),o.addEventListener("keydown",a=>{a.key==="Escape"&&o.setAttribute("aria-expanded","false")})}),document.addEventListener("click",()=>{n.forEach(o=>o.setAttribute("aria-expanded","false")),r.forEach(o=>o.setAttribute("aria-expanded","false"))}),t.menuBar.querySelectorAll(".dropdown li[data-action]").forEach(o=>{o.addEventListener("click",()=>{let a=o.getAttribute("data-action");a&&e(a),n.forEach(s=>s.setAttribute("aria-expanded","false")),r.forEach(s=>s.setAttribute("aria-expanded","false"))})})}function Ar(t){window.addEventListener("keydown",e=>{let r=/Mac|iPod|iPhone|iPad/.test(navigator.userAgent)?e.metaKey:e.ctrlKey,i=e.altKey;if(r&&e.key.toLowerCase()==="l"){e.preventDefault(),t.handleRefresh();return}if(r&&e.key.toLowerCase()==="s"&&!e.shiftKey){e.preventDefault(),t.saveCurrentNote().catch(o=>{t.showToast(`Save failed: ${String(o)}`,{persist:!0})});return}if(r&&e.shiftKey&&e.key.toLowerCase()==="s"){e.preventDefault(),t.exportAsJson();return}if(r&&e.shiftKey&&e.key.toLowerCase()==="m"){e.preventDefault(),t.exportAsMarkdown();return}if(r&&e.key.toLowerCase()==="e"){e.preventDefault(),t.createNewNote().catch(o=>{t.showToast(`Create note failed: ${String(o)}`,{persist:!0})});return}if(r&&e.shiftKey&&e.key.toLowerCase()==="o"){e.preventDefault(),t.openWorkspace().catch(o=>{t.showToast(`Failed to open workspace: ${String(o)}`,{persist:!0})});return}i&&e.shiftKey&&e.key.toLowerCase()==="o"&&(e.preventDefault(),t.openWorkspace().catch(o=>{t.showToast(`Failed to open workspace: ${String(o)}`,{persist:!0})}))})}function It({state:t,els:e,showToast:n,setStatus:r,renderPreview:i,updateDirtyUi:o,renderTree:a,renderInMemoryTree:s,renderTags:c,updateCountsPill:l,fsApi:d,parseTags:f,normalizeTag:u,autoRefresh:h}){function p(){let k=t.isTemporarySession;t.workspaceHandle=null,t.workspaceName="Temporary Session",t.fileTree=null,t.notes=[],t.currentFileHandle=null,k||(t.inMemoryNotes=[],t.currentRelPath="",t.currentContent="",t.isDirty=!1,e.editor.value="",i(e,""),o(e,t,r)),t.isTemporarySession=!0,e.workspaceName.textContent=t.workspaceName,e.workspaceName.title="Temporary Session - Data not persisted",e.temporarySessionBadge.style.display="inline",e.countsPill.textContent=`${t.inMemoryNotes.length} note${t.inMemoryNotes.length===1?"":"s"}`,e.tagRow.innerHTML="",s(),c()}function b(k){let v=k.trim().replace(/[\\/:*?"<>|]/g," ").replace(/\s+/g," ").trim()||"Untitled";return v.endsWith(".md")?v:`${v}.md`}function m({title:k,body:L,tag:v}){let T=k.trim()||"Untitled",E=L.trim(),R=u(v),B=[];return R&&B.push("---",`tags: [${R}]`,"---",""),B.push(`# ${T}`),E&&B.push("",E),B.join(` +`)}async function w(){if(!d.isFileSystemApiAvailable()){p(),e.tree.innerHTML='
    Temporary session. Create a note to begin.
    ',n("Temporary in-memory workspace enabled. Use Export to save your notes.",{persist:!0}),r("Temporary session","warn");return}try{if(!window.showDirectoryPicker)throw new Error("File System Access API not available");let k=await window.showDirectoryPicker({id:"local-md-workspace",mode:"readwrite"});if(!await d.ensurePermission(k,"readwrite")){n("Permission denied. Please allow access to the folder.",{persist:!0}),r("Permission denied","err");return}t.workspaceHandle=k,t.workspaceName=k.name||"Selected folder",t.isTemporarySession=!1,t.collapsedDirs.clear(),t.tagFilter="",e.temporarySessionBadge.style.display="none",e.workspaceName.textContent=t.workspaceName,e.workspaceName.title=t.workspaceName,e.tagRow.innerHTML="",r("Scanning folder..."),await g(),h.startAutoRefresh(),n("Workspace opened."),r("Workspace ready","ok")}catch(k){if(k instanceof Error&&k.name==="AbortError"){r("Open folder cancelled");return}let L=k instanceof Error?k.message:String(k);n(`Failed to open folder: ${L}`,{persist:!0}),r("Failed to open folder","err")}}async function g(k={}){if(t.workspaceHandle)try{if(!await d.ensurePermission(t.workspaceHandle,"read"))throw new Error("Folder permission not granted (read)");let v=[],T={type:"dir",name:t.workspaceName,relPath:"",children:[]};await S(t.workspaceHandle,T,"",v),await x(v),t.notes=v,t.fileTree=T,e.countsPill.textContent=`${v.length} note${v.length===1?"":"s"}`,c(),await a(),k.silent||(n("Workspace refreshed."),r("Refreshed","ok"))}catch(L){let v=L instanceof Error?L.message:String(L);n(`Refresh failed: ${v}`,{persist:!0}),r("Refresh failed","err")}}async function S(k,L,v,T){for await(let[E,R]of k.entries()){if(E.startsWith("."))continue;if(R.kind==="directory"){let Z=v?`${v}/${E}`:E,it={type:"dir",name:E,relPath:Z,children:[]};L.children.push(it),await S(R,it,Z,T);continue}if(!E.toLowerCase().endsWith(".md"))continue;let B=v?`${v}/${E}`:E,ie=0,W=0;try{let Z=await R.getFile();ie=Z.lastModified||0,W=Z.size||0}catch(Z){n(`Skipped a file that couldn't be read: ${B}`);continue}let V={handle:R,name:E,relPath:B,lastModified:ie,size:W,tags:new Set};T.push(V);let X={type:"file",name:E,relPath:B,handle:R,noteRef:V};L.children.push(X)}L.children.sort((E,R)=>E.type!==R.type?E.type==="dir"?-1:1:E.name.localeCompare(R.name))}async function x(k){for(let v of k)try{let T=await v.handle.getFile(),R=await(T.size>262144?T.slice(0,262144):T).text();v.tags=f(R)}catch(T){v.tags=new Set}}async function y(k,L=null){var v;if(!(t.isDirty&&t.currentFileHandle&&!confirm("You have unsaved changes. Discard them?")))try{let T=L||((v=t.notes.find(B=>B.relPath===k))==null?void 0:v.handle);if(!T)throw new Error("File not found");let R=await(await T.getFile()).text();t.currentFileHandle=T,t.currentRelPath=k,t.currentContent=R,t.isDirty=!1,e.editor.value=R,i(e,R),o(e,t,r),r("Opened \u2713","ok"),await a()}catch(T){let E=T instanceof Error?T.message:String(T);n(`Failed to open note: ${E}`,{persist:!0}),r("Open failed","err")}}async function N(){if(t.isTemporarySession)return K();if(t.currentFileHandle)try{let k=await t.currentFileHandle.createWritable();await k.write(e.editor.value),await k.close(),t.currentContent=e.editor.value,t.isDirty=!1,o(e,t,r),r("Saved \u2713","ok"),n("Saved \u2713"),await g({silent:!0})}catch(k){let L=k instanceof Error?k.message:String(k);n(`Save failed: ${L}`,{persist:!0}),r("Save failed","err")}}async function P(){if(!t.workspaceHandle&&!t.isTemporarySession){n("Open a workspace first.");return}if(t.isDirty&&!confirm("You have unsaved changes. Continue and discard them?"))return;let k=prompt("New note name (without .md)");if(!k)return;let L=k.endsWith(".md")?k:`${k}.md`;if(t.isTemporarySession)return D(L,k);try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");let v=t.workspaceHandle;for await(let[B]of v.entries())if(B===L){n("A file with that name already exists.",{persist:!0});return}let T=await t.workspaceHandle.getFileHandle(L,{create:!0}),E=`# ${k} Created ${new Date().toLocaleString()} -`,S=await y.createWritable();await S.write(T),await S.close(),await v({silent:!0}),await L(x,y),r("New note created \u2713"),n("New note","ok")}catch(b){let y=b instanceof Error?b.message:String(b);r(`Failed to create note: ${y}`,{persist:!0}),n("Create failed","err")}}async function z(){if(!t.workspaceHandle&&!t.isTemporarySession){r("Open a workspace first.");return}let f=t.isTemporarySession?e.editor.value:t.currentContent;if(!f){r("Nothing to save.");return}let x=prompt("Save note as (filename without .md):");if(!x)return;let b=x.endsWith(".md")?x:`${x}.md`;if(t.isTemporarySession)return de(b,x);try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");for await(let[S]of t.workspaceHandle.entries())if(S===b){r("A file with that name already exists.",{persist:!0});return}let y=await t.workspaceHandle.getFileHandle(b,{create:!0}),T=await y.createWritable();await T.write(f),await T.close(),await v({silent:!0}),await L(b,y),r(`Saved as ${b} \u2713`),n("Saved as","ok")}catch(y){let T=y instanceof Error?y.message:String(y);r(`Failed to save: ${T}`,{persist:!0}),n("Save failed","err")}}async function re(f=t.workspaceHandle){if(!f){if(t.isTemporarySession){r("Folders are not supported in temporary session.");return}r("Open a workspace first.");return}let x=prompt("Folder name:");if(x)try{await f.getDirectoryHandle(x,{create:!0}),await v({silent:!0}),r("Folder created \u2713"),n("Folder created","ok")}catch(b){let y=b instanceof Error?b.message:String(b);r(`Failed to create folder: ${y}`,{persist:!0}),n("Create folder failed","err")}}async function de(f,x){if(t.inMemoryNotes.find(S=>S.relPath===f)){r("A note with that name already exists.",{persist:!0});return}let y=`# ${x} +`,R=await T.createWritable();await R.write(E),await R.close(),await g({silent:!0}),await y(L,T),n("New note created \u2713"),r("New note","ok")}catch(v){let T=v instanceof Error?v.message:String(v);n(`Failed to create note: ${T}`,{persist:!0}),r("Create failed","err")}}async function I(){if(!t.workspaceHandle&&!t.isTemporarySession){n("Open a workspace first.");return}let k=t.isTemporarySession?e.editor.value:t.currentContent;if(!k){n("Nothing to save.");return}let L=prompt("Save note as (filename without .md):");if(!L)return;let v=L.endsWith(".md")?L:`${L}.md`;if(t.isTemporarySession)return D(v,L);try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");for await(let[R]of t.workspaceHandle.entries())if(R===v){n("A file with that name already exists.",{persist:!0});return}let T=await t.workspaceHandle.getFileHandle(v,{create:!0}),E=await T.createWritable();await E.write(k),await E.close(),await g({silent:!0}),await y(v,T),n(`Saved as ${v} \u2713`),r("Saved as","ok")}catch(T){let E=T instanceof Error?T.message:String(T);n(`Failed to save: ${E}`,{persist:!0}),r("Save failed","err")}}async function Q(k=t.workspaceHandle){if(!k){if(t.isTemporarySession){n("Folders are not supported in temporary session.");return}n("Open a workspace first.");return}let L=prompt("Folder name:");if(L)try{await k.getDirectoryHandle(L,{create:!0}),await g({silent:!0}),n("Folder created \u2713"),r("Folder created","ok")}catch(v){let T=v instanceof Error?v.message:String(v);n(`Failed to create folder: ${T}`,{persist:!0}),r("Create folder failed","err")}}async function D(k,L){if(t.inMemoryNotes.find(R=>R.relPath===k)){n("A note with that name already exists.",{persist:!0});return}let T=`# ${L} Created ${new Date().toLocaleString()} -`,T={name:f,relPath:f,content:y,lastModified:Date.now(),tags:k(y)};t.inMemoryNotes.push(T),t.currentRelPath=f,t.currentContent=y,t.isDirty=!1,e.editor.value=y,o(e,y),i(e,t,n),s(),l(),r("New note created \u2713"),n("New note","ok")}async function ut(f){let x=f.title.trim(),b=f.body.trim(),y=f.tag.trim();if(!x||!b){let I="Title and body are required.";return r(I,{persist:!0}),n("Create failed","err"),{ok:!1,message:I}}!t.workspaceHandle&&!t.isTemporarySession&&(c(),r("Temporary in-memory workspace enabled for note creation.",{persist:!0}),n("Temporary session","warn"));let T=m(x),S=h({title:x,body:b,tag:y}),A=t.isDirty,j=A?`Created ${T}. Current unsaved note was left open.`:`Created ${T} \u2713`;if(t.isTemporarySession){if(t.inMemoryNotes.find(Q=>Q.relPath===T)){let Q="A note with that name already exists.";return r(Q,{persist:!0}),n("Create failed","err"),{ok:!1,message:Q}}let W={name:T,relPath:T,content:S,lastModified:Date.now(),tags:k(S)};return t.inMemoryNotes.push(W),A||(t.currentRelPath=T,t.currentContent=S,t.isDirty=!1,e.editor.value=S,o(e,S),i(e,t,n)),s(),u(),l(),r(j),n("New note","ok"),{ok:!0,message:j,notePath:T,sessionType:"temporary",keptCurrentNote:A}}try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");for await(let[Q]of t.workspaceHandle.entries())if(Q===T){let O="A file with that name already exists.";return r(O,{persist:!0}),n("Create failed","err"),{ok:!1,message:O}}let I=await t.workspaceHandle.getFileHandle(T,{create:!0}),W=await I.createWritable();return await W.write(S),await W.close(),await v({silent:!0}),A?n("New note","ok"):await L(T,I),r(j),{ok:!0,message:j,notePath:T,sessionType:"workspace",keptCurrentNote:A}}catch(I){let W=I instanceof Error?I.message:String(I);return r(`Failed to create note: ${W}`,{persist:!0}),n("Create failed","err"),{ok:!1,message:W}}}async function dt(f){if(t.isDirty&&t.currentRelPath&&!confirm("You have unsaved changes. Discard them?"))return;let x=t.inMemoryNotes.find(b=>b.relPath===f);if(!x){r("Note not found",{persist:!0});return}t.currentRelPath=f,t.currentContent=x.content,t.isDirty=!1,e.editor.value=x.content,o(e,x.content),i(e,t,n),n("Opened \u2713","ok"),s()}async function Le(){if(!t.currentRelPath||!t.isTemporarySession)return;let f=t.inMemoryNotes.find(x=>x.relPath===t.currentRelPath);if(!f){r("Note not found",{persist:!0});return}f.content=e.editor.value,f.lastModified=Date.now(),f.tags=k(f.content),t.currentContent=f.content,t.isDirty=!1,i(e,t,n),n("Saved \u2713","ok"),r("Saved \u2713"),s(),u()}function ht(){if(t.inMemoryNotes.length===0){r("No notes to export.");return}let f={exportedAt:new Date().toISOString(),notes:t.inMemoryNotes.map(A=>({name:A.name,path:A.relPath,content:A.content,lastModified:new Date(A.lastModified).toISOString()}))},x=new Blob([JSON.stringify(f,null,2)],{type:"application/json"}),b=URL.createObjectURL(x),T=`ink-export-${new Date().toISOString().split("T")[0]}.json`,S=document.createElement("a");S.href=b,S.download=T,document.body.appendChild(S),S.click(),document.body.removeChild(S),URL.revokeObjectURL(b),r(`Exported ${t.inMemoryNotes.length} note(s) as JSON.`),n("Exported JSON","ok")}function gt(){if(!t.currentRelPath){r("No note selected to export.");return}let f=t.inMemoryNotes.find(T=>T.relPath===t.currentRelPath);if(!f){r("Note not found.",{persist:!0});return}let x=new Blob([f.content],{type:"text/markdown"}),b=URL.createObjectURL(x),y=document.createElement("a");y.href=b,y.download=f.name,document.body.appendChild(y),y.click(),document.body.removeChild(y),URL.revokeObjectURL(b),r(`Exported ${f.name} as Markdown.`),n("Exported MD","ok")}function ft(){if(t.isTemporarySession){r("Refresh not available in temporary session. Your data is in memory.");return}if(!t.workspaceHandle){r("No workspace open.");return}v().catch(f=>{r(`Refresh failed: ${String(f)}`,{persist:!0}),n("Refresh failed","err")})}function mt(){p.stopAutoRefresh(),t.workspaceHandle=null,t.workspaceName="",t.fileTree=null,t.notes=[],t.currentFileHandle=null,t.currentRelPath="",t.currentContent="",t.isDirty=!1,t.searchQuery="",t.tagFilter="",t.collapsedDirs.clear(),e.workspaceName.textContent="No folder selected",e.workspaceName.title="No folder selected",e.countsPill.textContent="0 notes",e.tagRow.innerHTML="",e.tree.innerHTML='
    Open a folder to begin.
    ',e.editor.value="",e.preview.innerHTML="",e.currentFilename.textContent="No note open",e.dirtyDot.classList.remove("show"),n("Ready"),r("Workspace closed.")}return{openWorkspace:w,rescanWorkspace:v,openNoteByRelPath:L,saveCurrentNote:D,createNewNote:F,saveAsNewNote:z,createNoteFromTool:ut,createNewFolder:re,createInMemoryNote:de,openInMemoryNote:dt,saveInMemoryNote:Le,exportAsJson:ht,exportAsMarkdown:gt,handleRefresh:ft,closeWorkspace:mt}}var vr=`You are a writing coach. +`,E={name:k,relPath:k,content:T,lastModified:Date.now(),tags:f(T)};t.inMemoryNotes.push(E),t.currentRelPath=k,t.currentContent=T,t.isDirty=!1,e.editor.value=T,i(e,T),o(e,t,r),s(),l(),n("New note created \u2713"),r("New note","ok")}async function J(k){let L=k.title.trim(),v=k.body.trim(),T=k.tag.trim();if(!L||!v){let W="Title and body are required.";return n(W,{persist:!0}),r("Create failed","err"),{ok:!1,message:W}}!t.workspaceHandle&&!t.isTemporarySession&&(p(),n("Temporary in-memory workspace enabled for note creation.",{persist:!0}),r("Temporary session","warn"));let E=b(L),R=m({title:L,body:v,tag:T}),B=t.isDirty,ie=B?`Created ${E}. Current unsaved note was left open.`:`Created ${E} \u2713`;if(t.isTemporarySession){if(t.inMemoryNotes.find(X=>X.relPath===E)){let X="A note with that name already exists.";return n(X,{persist:!0}),r("Create failed","err"),{ok:!1,message:X}}let V={name:E,relPath:E,content:R,lastModified:Date.now(),tags:f(R)};return t.inMemoryNotes.push(V),B||(t.currentRelPath=E,t.currentContent=R,t.isDirty=!1,e.editor.value=R,i(e,R),o(e,t,r)),s(),c(),l(),n(ie),r("New note","ok"),{ok:!0,message:ie,notePath:E,sessionType:"temporary",keptCurrentNote:B}}try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");for await(let[X]of t.workspaceHandle.entries())if(X===E){let Z="A file with that name already exists.";return n(Z,{persist:!0}),r("Create failed","err"),{ok:!1,message:Z}}let W=await t.workspaceHandle.getFileHandle(E,{create:!0}),V=await W.createWritable();return await V.write(R),await V.close(),await g({silent:!0}),B?r("New note","ok"):await y(E,W),n(ie),{ok:!0,message:ie,notePath:E,sessionType:"workspace",keptCurrentNote:B}}catch(W){let V=W instanceof Error?W.message:String(W);return n(`Failed to create note: ${V}`,{persist:!0}),r("Create failed","err"),{ok:!1,message:V}}}async function te(k){if(t.isDirty&&t.currentRelPath&&!confirm("You have unsaved changes. Discard them?"))return;let L=t.inMemoryNotes.find(v=>v.relPath===k);if(!L){n("Note not found",{persist:!0});return}t.currentRelPath=k,t.currentContent=L.content,t.isDirty=!1,e.editor.value=L.content,i(e,L.content),o(e,t,r),r("Opened \u2713","ok"),s()}async function K(){if(!t.currentRelPath||!t.isTemporarySession)return;let k=t.inMemoryNotes.find(L=>L.relPath===t.currentRelPath);if(!k){n("Note not found",{persist:!0});return}k.content=e.editor.value,k.lastModified=Date.now(),k.tags=f(k.content),t.currentContent=k.content,t.isDirty=!1,o(e,t,r),r("Saved \u2713","ok"),n("Saved \u2713"),s(),c()}function ne(){if(t.inMemoryNotes.length===0){n("No notes to export.");return}let k={exportedAt:new Date().toISOString(),notes:t.inMemoryNotes.map(B=>({name:B.name,path:B.relPath,content:B.content,lastModified:new Date(B.lastModified).toISOString()}))},L=new Blob([JSON.stringify(k,null,2)],{type:"application/json"}),v=URL.createObjectURL(L),E=`ink-export-${new Date().toISOString().split("T")[0]}.json`,R=document.createElement("a");R.href=v,R.download=E,document.body.appendChild(R),R.click(),document.body.removeChild(R),URL.revokeObjectURL(v),n(`Exported ${t.inMemoryNotes.length} note(s) as JSON.`),r("Exported JSON","ok")}function re(){if(!t.currentRelPath){n("No note selected to export.");return}let k=t.inMemoryNotes.find(E=>E.relPath===t.currentRelPath);if(!k){n("Note not found.",{persist:!0});return}let L=new Blob([k.content],{type:"text/markdown"}),v=URL.createObjectURL(L),T=document.createElement("a");T.href=v,T.download=k.name,document.body.appendChild(T),T.click(),document.body.removeChild(T),URL.revokeObjectURL(v),n(`Exported ${k.name} as Markdown.`),r("Exported MD","ok")}function Y(){if(t.isTemporarySession){n("Refresh not available in temporary session. Your data is in memory.");return}if(!t.workspaceHandle){n("No workspace open.");return}g().catch(k=>{n(`Refresh failed: ${String(k)}`,{persist:!0}),r("Refresh failed","err")})}function H(){h.stopAutoRefresh(),t.workspaceHandle=null,t.workspaceName="",t.fileTree=null,t.notes=[],t.currentFileHandle=null,t.currentRelPath="",t.currentContent="",t.isDirty=!1,t.searchQuery="",t.tagFilter="",t.collapsedDirs.clear(),e.workspaceName.textContent="No folder selected",e.workspaceName.title="No folder selected",e.countsPill.textContent="0 notes",e.tagRow.innerHTML="",e.tree.innerHTML='
    Open a folder to begin.
    ',e.editor.value="",e.preview.innerHTML="",e.currentFilename.textContent="No note open",e.dirtyDot.classList.remove("show"),r("Ready"),n("Workspace closed.")}return{openWorkspace:w,rescanWorkspace:g,openNoteByRelPath:y,saveCurrentNote:N,createNewNote:P,saveAsNewNote:I,createNoteFromTool:J,createNewFolder:Q,createInMemoryNote:D,openInMemoryNote:te,saveInMemoryNote:K,exportAsJson:ne,exportAsMarkdown:re,handleRefresh:Y,closeWorkspace:H}}var $r=`You are a writing coach. Rules: - Do NOT write prose. @@ -87,6 +87,57 @@ Rules: - Output JSON only in this format: { "questions": ["...", "...", "..."] -}`,Tr="Llama-3.2-1B-Instruct-q4f32_1-MLC",Sr="Qwen3-8B-q4f16_1-MLC";function Mr(t){let e=t.replace(/\s+/g," ").trim();if(!e)return"";let r=e.split(/(?<=[.!?])\s+/).map(n=>n.trim()).filter(Boolean);return r.length===0?"":r[r.length-1]}function Rr(t){let e=t.replace(/[\s\S]*?<\/think>/gi,"").replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"").trim(),r=JSON.parse(e);if(!r||!Array.isArray(r.questions))throw new Error("Cogito response did not include a questions array.");let n=r.questions.filter(o=>typeof o=="string"&&o.trim().length>0).map(o=>o.trim());if(n.length===0)throw new Error("Cogito response contained no valid questions.");if(n.length!==3)throw new Error("Cogito response must contain exactly 3 questions.");return n}function Cr(t){return`> ### AI +}`,Nr="Llama-3.2-1B-Instruct-q4f32_1-MLC",Pr="Qwen3-8B-q4f16_1-MLC";function Dr(t){let e=t.replace(/\s+/g," ").trim();if(!e)return"";let n=e.split(/(?<=[.!?])\s+/).map(r=>r.trim()).filter(Boolean);return n.length===0?"":n[n.length-1]}function Br(t){let e=t.replace(/[\s\S]*?<\/think>/gi,"").replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"").trim(),n=JSON.parse(e);if(!n||!Array.isArray(n.questions))throw new Error("Cogito response did not include a questions array.");let r=n.questions.filter(i=>typeof i=="string"&&i.trim().length>0).map(i=>i.trim());if(r.length===0)throw new Error("Cogito response contained no valid questions.");if(r.length!==3)throw new Error("Cogito response must contain exactly 3 questions.");return r}function Ir(t){return`> ### AI ${t.trim()} -`}function Er(t,e){let{selectionStart:r,selectionEnd:n,value:o}=t,i=o.slice(0,r),a=o.slice(n);t.value=`${i}${e}${a}`;let s=i.length+e.length;t.setSelectionRange(s,s)}function ct({els:t,getEditorText:e,onEditorContentReplaced:r,showToast:n,setStatus:o}){let i=!1,a=[],s="lite",u={};async function l(){let w=globalThis.__INK_TEST_WEBLLM__;return w||import("https://esm.run/@mlc-ai/web-llm")}function g(w){s=w,t.cogitoLiteBtn.classList.toggle("active",w==="lite"),t.cogitoDeepBtn.classList.toggle("active",w==="deep")}function k(w){i=w,t.cogitoPanel.hidden=!w,t.cogitoToggleBtn.setAttribute("aria-expanded",String(w));let v=t.cogitoPanel.closest(".split");v&&v.classList.toggle("with-cogito",w)}function d(w){t.cogitoQuestionList.innerHTML="",w.forEach((v,P)=>{let N=document.createElement("li");N.className="cogitoQuestionItem";let L=document.createElement("p");L.className="cogitoQuestionText",L.textContent=v;let D=document.createElement("button");D.type="button",D.className="ghost cogitoInsertBtn",D.dataset.questionIndex=String(P),D.textContent="Insert",D.title="Insert question into markdown",N.append(L,D),t.cogitoQuestionList.appendChild(N)})}function p(w){t.cogitoStatus.textContent=w}async function c(){let w=s==="deep"?Sr:Tr;return u[s]||(u[s]=(async()=>(p(`Loading ${s==="deep"?"Deep (Qwen3 8B)":"Lite (Llama 1B)"} model...`),await(await l()).CreateMLCEngine(w,{initProgressCallback:N=>{N!=null&&N.text&&p(N.text)}})))().catch(v=>{throw delete u[s],v})),u[s]}async function m(){var v,P,N;let w=Mr(e());if(!w){p("Write at least one sentence first, then generate Cogito questions."),o("Cogito needs a sentence","warn");return}try{t.cogitoGenerateBtn.disabled=!0,p("Generating 3 questions...");let F=(N=(P=(v=(await(await c()).chat.completions.create({messages:[{role:"system",content:vr},{role:"user",content:`Last sentence: ${w}`}],temperature:.2})).choices)==null?void 0:v[0])==null?void 0:P.message)==null?void 0:N.content,z=Array.isArray(F)?F.map(re=>typeof re=="string"?re:"").join("").trim():typeof F=="string"?F.trim():"";if(!z)throw new Error("Cogito returned an empty response.");a=Rr(z),d(a),p("Questions ready. Insert one into your markdown when useful."),o("Cogito questions ready","ok")}catch(L){a=[],d(a);let D=L instanceof Error?L.message:String(L);p(`Cogito error: ${D}`),o("Cogito unavailable","warn"),n(`Cogito failed: ${D}`,{persist:!0})}finally{t.cogitoGenerateBtn.disabled=!1}}function h(w){let v=a[w];if(!v){n("Cogito question not found.",{persist:!0});return}let P=Cr(v);Er(t.editor,P),r(t.editor.value),o("Inserted AI question","ok")}return k(!1),{togglePanel:()=>{k(!i),i&&p("Cogito Mode enabled. Generate questions from your last sentence.")},selectModel:g,generateQuestions:m,insertQuestionAtIndex:h}}function pt(){Ar(Xe()).initialize()}function Ar(t){let e={workspaceHandle:null,workspaceName:"",fileTree:null,notes:[],inMemoryNotes:[],currentFileHandle:null,currentRelPath:"",currentContent:"",isDirty:!1,searchQuery:"",tagFilter:"",sortMode:"name",collapsedDirs:new Set,autoRefreshMs:1e4,autoRefreshTimer:null,isSidebarCollapsed:!1,isTemporarySession:!1,isCogitoModeEnabled:!1},r={current:null},{showToast:n,hideToast:o}=it(t,r),i={openNoteByRelPath:async()=>{},openInMemoryNote:async()=>{}},a=st({state:e,els:t,handlers:i,showToast:n}),s={current:async()=>{}},u=tt({state:e,ensurePermission:Re,rescanWorkspace:m=>s.current(m),showToast:n,setStatus:(m,h)=>U(t,m,h)}),l=lt({state:e,els:t,showToast:n,setStatus:(m,h)=>U(t,m,h),renderPreview:Ce,updateDirtyUi:pe,renderTree:a.renderTree,renderInMemoryTree:a.renderInMemoryTree,renderTags:a.renderTags,updateCountsPill:a.updateCountsPill,fsApi:{ensurePermission:Re,isFileSystemApiAvailable:et},parseTags:Ye,normalizeTag:ee,autoRefresh:u});s.current=l.rescanWorkspace,i.openNoteByRelPath=l.openNoteByRelPath,i.openInMemoryNote=l.openInMemoryNote;let g=ot({state:e,els:t,showToast:n,renderTree:a.renderTree,callbacks:{createNewNote:l.createNewNote,createNewFolder:l.createNewFolder,openWorkspace:l.openWorkspace,closeWorkspace:l.closeWorkspace,saveCurrentNote:l.saveCurrentNote,saveAsNewNote:l.saveAsNewNote,handleRefresh:l.handleRefresh,exportAsJson:l.exportAsJson,exportAsMarkdown:l.exportAsMarkdown,setSidebarCollapsed:m=>Pe(t,e,m)}});function k(m){e.searchQuery=m,a.renderTree().catch(h=>{n(`Search render failed: ${String(h)}`,{persist:!0})})}function d(m){e.isDirty=m!==e.currentContent,pe(t,e,(h,w)=>U(t,h,w)),Ce(t,m)}let p=ct({els:t,getEditorText:()=>t.editor.value,onEditorContentReplaced:m=>d(m),showToast:n,setStatus:(m,h)=>U(t,m,h)});function c(){C.use({breaks:!0});let m=navigator.platform.toLowerCase().includes("mac");nt(t,m),at({state:e,els:t,actions:{handleMenuAction:g.handleMenuAction,toggleSidebar:()=>Pe(t,e,!e.isSidebarCollapsed),handleRefresh:l.handleRefresh,toggleSort:g.toggleSort,handleSearchInput:k,handleEditorInput:d,saveCurrentNote:l.saveCurrentNote,createNewNote:l.createNewNote,createNoteFromTool:h=>l.createNoteFromTool(h),openWorkspace:l.openWorkspace,exportAsJson:l.exportAsJson,exportAsMarkdown:l.exportAsMarkdown,toggleCogitoPanel:()=>{e.isCogitoModeEnabled=!e.isCogitoModeEnabled,p.togglePanel()},selectCogitoModel:h=>p.selectModel(h),generateCogitoQuestions:()=>p.generateQuestions(),insertCogitoQuestion:h=>p.insertQuestionAtIndex(h),hideToast:o,showToast:n}}),rt(),Ae(t,e),pe(t,e,(h,w)=>U(t,h,w)),a.renderTree().catch(h=>{n(`Failed to render tree: ${String(h)}`,{persist:!0}),U(t,"Render failed","err")})}return{initialize:c,state:e}}pt();})(); +`}function Hr(t,e){let{selectionStart:n,selectionEnd:r,value:i}=t,o=i.slice(0,n),a=i.slice(r);t.value=`${o}${e}${a}`;let s=o.length+e.length;t.setSelectionRange(s,s)}function Ht({els:t,getEditorText:e,onEditorContentReplaced:n,showToast:r,setStatus:i}){let o=!1,a=[],s="lite",c={};async function l(){let w=globalThis.__INK_TEST_WEBLLM__;return w||import("https://esm.run/@mlc-ai/web-llm")}function d(w){s=w,t.cogitoLiteBtn.classList.toggle("active",w==="lite"),t.cogitoDeepBtn.classList.toggle("active",w==="deep")}function f(w){o=w,t.cogitoPanel.hidden=!w,t.cogitoToggleBtn.setAttribute("aria-expanded",String(w));let g=t.cogitoPanel.closest(".split");g&&g.classList.toggle("with-cogito",w)}function u(w){t.cogitoQuestionList.innerHTML="",w.forEach((g,S)=>{let x=document.createElement("li");x.className="cogitoQuestionItem";let y=document.createElement("p");y.className="cogitoQuestionText",y.textContent=g;let N=document.createElement("button");N.type="button",N.className="ghost cogitoInsertBtn",N.dataset.questionIndex=String(S),N.textContent="Insert",N.title="Insert question into markdown",x.append(y,N),t.cogitoQuestionList.appendChild(x)})}function h(w){t.cogitoStatus.textContent=w}async function p(){let w=s==="deep"?Pr:Nr;return c[s]||(c[s]=(async()=>(h(`Loading ${s==="deep"?"Deep (Qwen3 8B)":"Lite (Llama 1B)"} model...`),await(await l()).CreateMLCEngine(w,{initProgressCallback:x=>{x!=null&&x.text&&h(x.text)}})))().catch(g=>{throw delete c[s],g})),c[s]}async function b(){var g,S,x;let w=Dr(e());if(!w){h("Write at least one sentence first, then generate Cogito questions."),i("Cogito needs a sentence","warn");return}try{t.cogitoGenerateBtn.disabled=!0,h("Generating 3 questions...");let P=(x=(S=(g=(await(await p()).chat.completions.create({messages:[{role:"system",content:$r},{role:"user",content:`Last sentence: ${w}`}],temperature:.2})).choices)==null?void 0:g[0])==null?void 0:S.message)==null?void 0:x.content,I=Array.isArray(P)?P.map(Q=>typeof Q=="string"?Q:"").join("").trim():typeof P=="string"?P.trim():"";if(!I)throw new Error("Cogito returned an empty response.");a=Br(I),u(a),h("Questions ready. Insert one into your markdown when useful."),i("Cogito questions ready","ok")}catch(y){a=[],u(a);let N=y instanceof Error?y.message:String(y);h(`Cogito error: ${N}`),i("Cogito unavailable","warn"),r(`Cogito failed: ${N}`,{persist:!0})}finally{t.cogitoGenerateBtn.disabled=!1}}function m(w){let g=a[w];if(!g){r("Cogito question not found.",{persist:!0});return}let S=Ir(g);Hr(t.editor,S),n(t.editor.value),i("Inserted AI question","ok")}return f(!1),{togglePanel:()=>{f(!o),o&&h("Cogito Mode enabled. Generate questions from your last sentence.")},setPanelOpen:w=>{f(w),w&&h("Cogito Mode enabled. Generate questions from your last sentence.")},isPanelOpen:()=>o,closePanel:()=>{f(!1)},selectModel:d,generateQuestions:b,insertQuestionAtIndex:m}}var Ft="No clear strengths stand out yet; the draft needs more signal before the linter can praise specific choices.",Ue="No major fixes stood out.",Ge="No notable issues in this category.",Ke="This note did not expose any obvious structural sections.";function zt(){return"The note opens cleanly, but it still needs a visible structure signal."}function Ze(){return"The first sentence does a lot of work. Tighten it and let the rest of the section carry the supporting detail."}function _t(){return"The opening is understandable, but it could be shaped into a sharper lead sentence."}function Je(){return"Lead with the payoff or takeaway first, then use the topic sentence to support it."}function Wt(){return"There are real strengths here: concrete details and structure cues give the document memory and shape."}function Ot(){return"The content is solid, but it needs a clearer scaffold so the reader can move through it faster."}function qt(){return"The draft is too thin or fragmentary to score well yet; add a clearer claim and one or two supporting details."}function jt(){return"Add a heading at the top so the chapter reads as a structured note instead of a raw outline."}function Qt(t){return`Turn "${t}" into a heading so the bullet list has a clean anchor.`}function Vt(){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."}function Ut(){return"This would scan better with three visible beats: a short lead, a grouped facts section, and a closing takeaway."}function Gt(){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."}function Kt(){return"This section leans on a few abstract nouns. It still reads clearly, but a couple of them could be replaced with plainer words."}function Ye(){return"A word is repeated back-to-back. Clean that up before worrying about higher-level style."}function Xe(){return"There is not enough developed prose yet to judge the document fairly. Add a clearer point and at least one supporting sentence."}function Zt(){return"This section is too thin to evaluate well; add a clearer claim or supporting detail."}function Jt(){return"A question can be a good hook, but it needs an immediate answer or payoff."}function Yt(){return"This section is balanced and easy to follow."}function Xt(t){return`The "${t}" block introduces a real section. Promoting it to a heading would make the document easier to scan.`}function en(t,e){return`The section starting at line ${t} has ${e} bullets and several carry more than one idea.`}function tn(t){return`The section starting at line ${t} is carrying a lot of text and would be easier to scan if it were split.`}function nn(t){return`Turn "${t}:" into a heading so the section reads as intentional structure.`}function rn(){return"This bullet run is dense; split the longest items or group them by theme."}function on(){return"This section carries a lot of material; consider splitting it into two smaller beats."}function sn(){return"The heading works, but this section is list-only; a short lead sentence could help orient the reader."}function an(t,e){return`The "${t}" section deserves attention: ${e.toLowerCase()}`}function ln(){return"Concrete anchors such as names, dates, or numbers make the material easier to remember."}function cn(){return"The document gives the reader a memory hook, which makes the takeaway easier to retain."}function un(){return"The parallel list structure creates a strong rhythm and makes the sequence easy to scan."}function dn(){return"The opening question creates forward pull and gives the reader a reason to keep going."}function pn(){return"The lead uses clear directive language, which gives the document momentum."}function hn(){return"The document already has enough visible structure that a reader can scan it quickly."}var wn=[{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"}];function ce(t){return Math.max(0,Math.min(100,t))}function _(t){var e,n;return(n=(e=t.match(/\b\w+\b/g))==null?void 0:e.length)!=null?n:0}function Me(t){return t.replaceAll(/\s+/g," ").split(/(?<=[.!?])\s+/).map(e=>e.trim()).filter(Boolean)}function Fr(t){return Me(t).length}function j(t){return t.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function gn(t){return t==="high"?3:t==="medium"?2:1}function zr(t,e=`Line ${t}`){return``}function et(t){return/^```/.test(t.trim())}function mn(t){return/^#{1,6}\s+/.test(t.trim())}function _r(t){return/^\s*\d+[.)]\s+/.test(t)}function Wr(t){return/^\s*[-*+]\s+/.test(t)}function tt(t){return Wr(t)||_r(t)}function Te(t){return/^\s*>\s?/.test(t)}function nt(t){return/^(?:\t| {4,})/.test(t)}function bn(t,e){return t.type==="paragraph"&&/:\s*$/.test(t.text)&&(e==null?void 0:e.type)==="list_item"?t.text.replace(/:\s*$/,""):null}function fn(t){return t.replace(/^(\s*(?:[-*+]|\d+[.)]))\s+.*$/,"$1")}function kn(t){return t.replace(/^\s*(?:[-*+]|\d+[.)])\s+/,"").trim()}function Or(t){return t.map(e=>e.trim()).join(" ").replaceAll(/\s+/g," ").trim()}function yn(t){let e=t.split(` +`),n=[],r=0;for(;r\s?/,"").trim()),r+=1;continue}if(f.trim()===""&&r+1n.type===e)}function rt(t){return t.filter(e=>e.type==="paragraph"||e.type==="blockquote")}function xn(t){let e=t.match(/\b([a-z][a-z'-]{1,})\b(?:\s+\1\b)/i);return!e||typeof e.index!="number"?null:{word:e[1],index:e.index}}function F(t,e,n,r,i,o=!1){return{category:t,severity:e,title:n,detail:r,section:i,isStrength:o}}function qr(t){let e=t.find(n=>n.type==="paragraph");return e?_(e.text)>=22?Ze():/^(this|these)\b/i.test(e.text)?Je():_t():zt()}function jr(t){for(let e=0;ed.title==="Repeated word")||n.push(F("readability","medium","Repeated word",Ye(),`Line ${s.lineStart}`)));for(let d of c){let f=_(d),u=d.length;if((f>=24||u>=140)&&(r+=1,n.length<3)){let h=d.length>70?`${d.slice(0,70)}...`:d;n.push(F("readability","medium","Dense sentence",`This sentence is carrying too much at once: "${h}". Split the idea or move the setup into a heading.`,`Line ${s.lineStart}`))}}}let o=e[0];return o&&_(o.text)>=22&&n.unshift(F("readability","high","Opening is dense",Ze(),`Line ${o.lineStart}`)),{result:{score:ce(100-r*14-i*10-(o&&_(o.text)>=22?8:0)),suggestions:n.map(s=>`${s.title}: ${s.detail}`)},findings:n}}function Ur(t){let e=le(t,"heading"),n=le(t,"list_item"),r=jr(t),i=[];return e.length===0&&i.push(F("skimmability","high","Missing heading",jt(),"Document")),r&&i.push(F("skimmability","medium","Make the section label explicit",Qt(r.text),`Line ${r.lineStart}`)),n.length>=4&&n.reduce((s,c)=>s+_(c.text),0)/n.length>=11&&i.push(F("skimmability","medium","Bullets are dense",Vt(),"Bullet list")),{result:{score:ce(100-(e.length===0?20:0)-(r?6:0)-(n.length>=4?10:0)),suggestions:i.map(a=>`${a.title}: ${a.detail}`)},findings:i,pseudoSectionLabel:r}}function Gr(t){var P,I,Q,D,J,te,K,ne,re,Y;let e=t.map(H=>H.text).join(" "),n=t.find(H=>H.type==="paragraph"),r=(P=n==null?void 0:n.text)!=null?P:"",i=[],o=[],a=_(e),s=(I=Me(r)[0])!=null?I:r,c=_(s),l=/\?\s*$/.test(s),d=/^(remember|consider|notice|start|imagine|picture|look|think)\b/i.test(s),f=(Q=e.match(/\b(?:was|were|is|are|be|been|being)\s+\w+ed\b/gi))!=null?Q:[],u=Me(e),h=u.length>0?f.length/u.length:0,p=new Set(((D=e.toLowerCase().match(/\b[a-z][a-z'-]+\b/g))!=null?D:[]).filter(H=>H.length>=4)),b=_(e)>0?p.size/_(e):0,m=(J=e.match(/\b(?:remember|consider|notice|focus|compare|look|keep|start)\b/gi))!=null?J:[],w=(te=e.match(/\b(?:\d{2,4}|[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g))!=null?te:[],g=(K=e.match(/\b(?:remember|key takeaway|simple way to remember|think of|in short)\b/gi))!=null?K:[],S=le(t,"list_item").map(H=>H.text),x=S.map(H=>{var k,L;return((L=(k=H.match(/^[A-Za-z]+/))==null?void 0:k[0])!=null?L:"").toLowerCase()}).filter(Boolean),y=x.find((H,k)=>H&&x.indexOf(H)!==k);return/^(this|these)\b/i.test(s)&&c>=8&&i.push(F("engagement","medium","Opening is informative, not directive",Je(),`Line ${(ne=n==null?void 0:n.lineStart)!=null?ne:1}`)),a>0&&a<12&&i.push(F("engagement","high","Too little context",Xe(),`Line ${(re=n==null?void 0:n.lineStart)!=null?re:1}`)),l&&a<18&&i.push(F("engagement","medium","Question needs payoff",Jt(),`Line ${(Y=n==null?void 0:n.lineStart)!=null?Y:1}`)),l&&o.push(dn()),(d||m.length>=2)&&o.push(pn()),g.length>0&&o.push(cn()),w.length>=2&&o.push(ln()),y&&S.length>=3&&o.push(un()),o.length===0&&(le(t,"heading").length>0||S.length>=3)&&o.push(hn()),{result:{score:ce(48+(l?10:0)+(d?10:0)+Math.min(o.length,3)*8+(b>=.28?6:0)+(c>0&&c<=16?6:0)-(a>0&&a<12?18:0)-(h>=.4?12:0)-i.length*8),suggestions:i.map(H=>`${H.title}: ${H.detail}`)},findings:i,strengths:o}}function Kr(t){var s;let e=rt(t).map(c=>c.text).join(" "),n=(s=e.match(/\b\w+(?:tion|sion|ment|ness|ance|ence)\b/gi))!=null?s:[],r=[],i=xn(e),o=[...new Set(n.map(c=>c.toLowerCase()))];return o.length>=4&&r.push(F("style","low","Dense abstract language",Kt(),"Document")),i&&r.unshift(F("style","medium","Repeated word",Ye(),"Document")),{result:{score:ce(96-Math.min(o.length,4)*2-(i?14:0)),suggestions:r.map(c=>`${c.title}: ${c.detail}`)},findings:r}}function Zr(t){let e=le(t,"heading"),n=le(t,"list_item"),r=le(t,"paragraph"),i=[],o=_(t.map(s=>s.text).join(" "));return e.length===0&&r.length>0&&n.length>0&&i.push(F("structure","high","Add a simple outline",Ut(),"Document")),n.length>=5&&e.length===0&&i.push(F("structure","medium","Break up the middle",Gt(),"Bullet list")),o>0&&o<12&&i.push(F("structure","high","Draft is too thin",Xe(),"Document")),{result:{score:ce(86-(e.length===0?12:0)-(n.length>=5?8:0)-(o>0&&o<12?22:0)),suggestions:i.map(s=>`${s.title}: ${s.detail}`)},findings:i}}function Jr(t){let e=[],n=t.map(r=>{let i=r.blocks.map(f=>f.text).join(" "),o=r.blocks.filter(f=>f.type==="list_item"),a=r.blocks.filter(f=>f.type==="paragraph"||f.type==="blockquote"),s=_(i),c=o.length>0?o.reduce((f,u)=>f+_(u.text),0)/o.length:0,l=[],d=!1;return r.kind==="label"&&(l.push(nn(r.title)),d=!0,e.push(F("structure","medium","Convert label into heading",Xt(r.title),`Line ${r.lineStart}`))),o.length>=4&&c>=11&&(l.push(rn()),d=!0,e.push(F("structure","medium","Dense bullet section",en(r.lineStart,o.length),`Line ${r.lineStart}`))),s>=90&&a.length>=2&&(l.push(on()),d=!0,e.push(F("structure","low","Long section",tn(r.lineStart),`Line ${r.lineStart}`))),r.kind==="heading"&&o.length>0&&a.length===0&&l.push(sn()),s>0&&s<12&&a.length>0&&(l.push(Zt()),d=!0),l.length===0&&l.push(Yt()),{title:r.title,kind:r.kind,lineStart:r.lineStart,lineEnd:r.lineEnd,notes:l,needsAttention:d}});return{findings:e,sectionNotes:n}}function Yr(t,e,n,r){let i=_(n.map(c=>c.text).join(" ")),o=t.filter(c=>!c.isStrength).slice().sort((c,l)=>gn(l.severity)-gn(c.severity)).slice(0,4).map(c=>`${c.title} \u2014 ${c.detail}`),a=[qr(n)],s=r.find(c=>c.needsAttention);return s&&a.push(an(s.title,s.notes[0])),e.length>0?a.push(Wt()):i>0&&i<12?a.push(qt()):a.push(Ot()),{quickTake:a,priorities:o,strengths:e.length>0?e:[Ft]}}function Xr(t){let e={readability:.25,skimmability:.2,engagement:.2,style:.15,structure:.2},n=Object.entries(t).reduce((r,[i,o])=>r+o.score*e[i],0);return ce(Math.round(n))}function ei(t,e,n){if(t.scrollTop=e,!n||typeof t.querySelector!="function")return;let r=t.querySelector(`[data-linter-focus-key="${n}"]`);r==null||r.focus()}function ti(t){return typeof t=="object"&&t!==null&&"getAttribute"in t}function ni(t){let e=yn(t),n=Qr(e),r=Vr(e),i=Ur(e),o=Gr(e),a=Kr(e),s=Zr(e),c=Jr(n),l=[...r.findings,...i.findings,...o.findings,...a.findings,...s.findings,...c.findings],d=[{title:"Readability",lines:r.findings.map(u=>`- ${u.title}: ${u.detail}`)},{title:"Skimmability",lines:i.findings.map(u=>`- ${u.title}: ${u.detail}`)},{title:"Engagement",lines:o.findings.map(u=>`- ${u.title}: ${u.detail}`)},{title:"Style",lines:a.findings.map(u=>`- ${u.title}: ${u.detail}`)},{title:"Structure",lines:s.findings.map(u=>`- ${u.title}: ${u.detail}`)}],f={readability:r.result,skimmability:i.result,engagement:o.result,style:a.result,structure:s.result};return{scores:f,overallScore:Xr(f),overview:Yr(l,o.strengths,e,c.sectionNotes),sections:d,documentSections:c.sectionNotes}}function ri(t,e){let n=yn(t),r=_(t),i=Fr(rt(n).map(c=>c.text).join(" ")),o=wn.map(c=>{var d;let l=e.sections.find(f=>f.title===c.title);return{title:c.title,lines:(d=l==null?void 0:l.lines)!=null&&d.length?l.lines:[`- ${Ge}`]}}),a=e.documentSections.length>0?e.documentSections:[{title:"Document",kind:"implicit",lineStart:1,lineEnd:1,notes:[Ke],needsAttention:!1}];return["# Document Linter Review","","## Overall",`- Overall score: ${e.overallScore}/100`,"","## Quick take",...e.overview.quickTake.map(c=>`- ${c}`),"","## What to fix first",...e.overview.priorities.length>0?e.overview.priorities.map((c,l)=>`${l+1}. ${c}`):[`- ${Ue}`],"","## What is working",...e.overview.strengths.map(c=>`- ${c}`),"","## Section notes",...o.flatMap(c=>[`### ${c.title}`,...c.lines,""]),"## Section analysis",...a.flatMap(c=>[`### ${c.title}`,`- Type: ${c.kind}`,`- Lines: ${c.lineStart}\u2013${c.lineEnd}`,`- Needs attention: ${c.needsAttention?"yes":"no"}`,...c.notes.map(l=>`- ${l}`),""]),"## Snapshot",`- Words: ${r}`,`- Sentences: ${i}`,`- Blocks: ${n.length}`].join(` +`)}function vn({els:t,getEditorText:e,onEditorContentReplaced:n,showToast:r,setStatus:i}){let o=!1,a=!1,s=!1,c=null,l="";async function d(g=e()){return Promise.resolve(ni(g))}function f(g){let S=t.editor.value.split(` +`),x=Math.max(1,Math.min(g,S.length||1)),y=0;for(let I=0;I +

    Overall

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

    Quick take

    +
      + ${g.overview.quickTake.map(D=>`
    • ${j(D)}
    • `).join("")} +
    +
    +
    +

    What to fix first

    +
      + ${g.overview.priorities.map(D=>`
    1. ${j(D)}
    2. `).join("")||`
    3. ${j(Ue)}
    4. `} +
    +
    +
    +

    What is working

    +
      + ${g.overview.strengths.map(D=>`
    • ${j(D)}
    • `).join("")} +
    +
    + `,S.appendChild(P),wn.forEach(D=>{var ne,re;let J=g.scores[D.id],te=(re=(ne=g.sections.find(Y=>Y.title===D.title))==null?void 0:ne.lines)!=null?re:[],K=document.createElement("div");K.className="documentLinterCategory",K.innerHTML=` +
    +

    ${D.title}

    +
    ${J.score}/100
    +
    +
    + ${te.length>0?te.map(Y=>`
    ${j(Y)}
    `).join(""):`
    ${j(Ge)}
    `} +
    + `,S.appendChild(K)});let I=document.createElement("section");I.className="documentLinterSectionAnalysis",I.innerHTML=` +

    Section analysis

    +
    + ${g.documentSections.length>0?g.documentSections.map(D=>` +
    +
    +

    ${j(D.title)}

    + ${j(D.kind)} \xB7 ${zr(D.lineStart,`Lines ${D.lineStart}\u2013${D.lineEnd}`)} \xB7 ${D.needsAttention?"needs attention":"stable"} +
    +
    + ${D.notes.map(J=>`
    ${j(J)}
    `).join("")} +
    +
    + `).join(""):`
    ${j(Ke)}
    `} +
    + `,S.appendChild(I),ei(S,x,N)}function p(g){let S=new Blob([g],{type:"text/markdown"}),x=URL.createObjectURL(S),N=`ink-linter-report-${new Date().toISOString().split("T")[0]}.md`,P=document.createElement("a");P.href=x,P.download=N,document.body.appendChild(P),P.click(),document.body.removeChild(P),URL.revokeObjectURL(x)}async function b(){if(a)return;let g=e();if(!g.trim()){r("No content to analyze",{persist:!0}),i("No content to analyze","warn");return}a=!0,l=g,t.documentLinterAnalyzeBtn.disabled=!0,t.documentLinterStatus.textContent="Analyzing document...";try{let S=await d(g);c=S,h(S),t.documentLinterStatus.textContent="Analysis complete",r("Document analysis completed"),i("Analysis complete","ok")}catch(S){t.documentLinterStatus.textContent="Analysis failed",r(`Document analysis failed: ${String(S)}`,{persist:!0}),i("Analysis failed","err")}finally{a=!1,t.documentLinterAnalyzeBtn.disabled=!1}}async function m(){let g=e();if(!g.trim()){r("No content to export",{persist:!0}),i("No content to export","warn");return}try{let S=g!==l||!c;S&&(t.documentLinterStatus.textContent="Document changed; re-analyzing before export...",i("Re-analyzing before export","warn"));let x=S?await d(g):c;c=x,l=g;let y=ri(g,x);p(y),r("Exported linter review as Markdown."),i("Exported report","ok")}catch(S){r(`Failed to export linter report: ${String(S)}`,{persist:!0}),i("Export failed","err")}}async function w(g=e()){return!o||!s||a||!g.trim()?(!g.trim()&&s&&o&&(c=null,l=g,t.documentLinterStatus.textContent="No content to analyze"),Promise.resolve()):g===l?Promise.resolve():b()}return t.documentLinterAutoRunToggle.checked=s,t.documentLinterAutoRunToggle.addEventListener("change",()=>{s=t.documentLinterAutoRunToggle.checked,t.documentLinterStatus.textContent=s?"Rerun on change enabled":"Rerun on change disabled"}),t.documentLinterResults.addEventListener("click",g=>{let S=g.target,x=S==null?void 0:S.closest("[data-linter-line]");if(!x)return;let y=Number(x.getAttribute("data-linter-line"));Number.isNaN(y)||f(y)}),u(!1),{togglePanel:()=>{u(!o),o&&(t.documentLinterStatus.textContent="Ready to analyze document")},setPanelOpen:g=>{u(g),g&&(t.documentLinterStatus.textContent="Ready to analyze document")},isPanelOpen:()=>o,closePanel:()=>{u(!1)},handleEditorChanged:w,analyzeDocument:b,exportSuggestions:m}}function Sn(){ii(Lt()).initialize()}function ii(t){let e={workspaceHandle:null,workspaceName:"",fileTree:null,notes:[],inMemoryNotes:[],currentFileHandle:null,currentRelPath:"",currentContent:"",isDirty:!1,searchQuery:"",tagFilter:"",sortMode:"name",collapsedDirs:new Set,autoRefreshMs:1e4,autoRefreshTimer:null,isSidebarCollapsed:!1,isTemporarySession:!1,isCogitoModeEnabled:!1,editorViewMode:Rt()},n={current:null},{showToast:r,hideToast:i}=Pt(t,n),o={openNoteByRelPath:async()=>{},openInMemoryNote:async()=>{}},a=Dt({state:e,els:t,handlers:o,showToast:r}),s={current:async()=>{}},c=Mt({state:e,ensurePermission:_e,rescanWorkspace:x=>s.current(x),showToast:r,setStatus:(x,y)=>ee(t,x,y)}),l=It({state:e,els:t,showToast:r,setStatus:(x,y)=>ee(t,x,y),renderPreview:Oe,updateDirtyUi:Se,renderTree:a.renderTree,renderInMemoryTree:a.renderInMemoryTree,renderTags:a.renderTags,updateCountsPill:a.updateCountsPill,fsApi:{ensurePermission:_e,isFileSystemApiAvailable:Tt},parseTags:St,normalizeTag:ge,autoRefresh:c});s.current=l.rescanWorkspace,o.openNoteByRelPath=l.openNoteByRelPath,o.openInMemoryNote=l.openInMemoryNote;let d=Nt({state:e,els:t,showToast:r,renderTree:a.renderTree,callbacks:{createNewNote:l.createNewNote,createNewFolder:l.createNewFolder,openWorkspace:l.openWorkspace,closeWorkspace:l.closeWorkspace,saveCurrentNote:l.saveCurrentNote,saveAsNewNote:l.saveAsNewNote,handleRefresh:l.handleRefresh,exportAsJson:l.exportAsJson,exportAsMarkdown:l.exportAsMarkdown,setSidebarCollapsed:x=>Qe(t,e,x)}});function f(x){e.searchQuery=x,a.renderTree().catch(y=>{r(`Search render failed: ${String(y)}`,{persist:!0})})}function u(x){e.isDirty=x!==e.currentContent,Se(t,e,(y,N)=>ee(t,y,N)),Oe(t,x),g.handleEditorChanged(x)}function h(){g.closePanel(),e.isCogitoModeEnabled=!0,w.setPanelOpen(!0)}function p(){e.isCogitoModeEnabled=!1,w.closePanel()}function b(){w.isPanelOpen()&&p(),g.setPanelOpen(!0)}function m(){g.closePanel()}let w=Ht({els:t,getEditorText:()=>t.editor.value,onEditorContentReplaced:x=>u(x),showToast:r,setStatus:(x,y)=>ee(t,x,y)}),g=vn({els:t,getEditorText:()=>t.editor.value,onEditorContentReplaced:x=>u(x),showToast:r,setStatus:(x,y)=>ee(t,x,y)});function S(){A.use({breaks:!0});let x=navigator.platform.toLowerCase().includes("mac");$t(t,x),Bt({state:e,els:t,actions:{handleMenuAction:d.handleMenuAction,toggleSidebar:()=>Qe(t,e,!e.isSidebarCollapsed),handleRefresh:l.handleRefresh,toggleSort:d.toggleSort,handleSearchInput:f,handleEditorInput:u,saveCurrentNote:l.saveCurrentNote,createNewNote:l.createNewNote,createNoteFromTool:y=>l.createNoteFromTool(y),openWorkspace:l.openWorkspace,exportAsJson:l.exportAsJson,exportAsMarkdown:l.exportAsMarkdown,toggleCogitoPanel:()=>{if(w.isPanelOpen()){p();return}h()},selectCogitoModel:y=>w.selectModel(y),generateCogitoQuestions:()=>w.generateQuestions(),insertCogitoQuestion:y=>w.insertQuestionAtIndex(y),setEditorViewMode:y=>Ct(t,e,y),toggleDocumentLinterPanel:()=>{if(g.isPanelOpen()){m();return}b()},analyzeDocument:()=>g.analyzeDocument(),exportDocumentLinterSuggestions:()=>g.exportSuggestions(),hideToast:i,showToast:r}}),At(),je(t,e),We(t,e.editorViewMode),Se(t,e,(y,N)=>ee(t,y,N)),a.renderTree().catch(y=>{r(`Failed to render tree: ${String(y)}`,{persist:!0}),ee(t,"Render failed","err")})}return{initialize:S,state:e}}Sn();})(); diff --git a/dist/styles.min.css b/dist/styles.min.css index 3ffee47..50dc0c9 100644 --- a/dist/styles.min.css +++ b/dist/styles.min.css @@ -1 +1 @@ -:root{--bg: #0b0f14;--panel: #0f1620;--panel2: #0c121a;--border: rgba(255, 255, 255, 0.10);--muted: rgba(255, 255, 255, 0.70);--text: rgba(255, 255, 255, 0.92);--accent: #5eead4;--danger: #fb7185;--ok: #34d399;--warn: #fbbf24;--shadow: 0 10px 30px rgba(0,0,0,.35);--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";--radius: 14px;--body-bg: radial-gradient(900px 600px at 10% 0%, rgba(94, 234, 212, 0.15), transparent 60%), radial-gradient(900px 600px at 90% 0%, rgba(251, 191, 36, 0.10), transparent 60%), var(--bg)}:root[data-theme=classic]{--bg: #f5f5f5;--panel: #ffffff;--panel2: #eeeeee;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.55);--text: rgba(0, 0, 0, 0.88);--accent: #2c6fad;--danger: #d9534f;--ok: #2e7d32;--warn: #e65100;--shadow: 0 10px 30px rgba(0,0,0,.10);--body-bg: var(--bg)}:root[data-theme=cobalt]{--bg: #001f3d;--panel: #002b52;--panel2: #002040;--border: rgba(255, 255, 255, 0.12);--muted: rgba(255, 255, 255, 0.60);--text: #e8f4fd;--accent: #ffd700;--danger: #ff6b6b;--ok: #32d48a;--warn: #ffba08;--shadow: 0 10px 30px rgba(0,0,0,.50);--body-bg: radial-gradient(800px 500px at 20% 0%, rgba(0, 120, 212, 0.20), transparent 60%), radial-gradient(800px 500px at 80% 100%, rgba(255, 215, 0, 0.08), transparent 60%), var(--bg)}:root[data-theme=monokai]{--bg: #272822;--panel: #3e3d32;--panel2: #2d2c27;--border: rgba(255, 255, 255, 0.10);--muted: rgba(248, 248, 242, 0.55);--text: #f8f8f2;--accent: #a6e22e;--danger: #f92672;--ok: #a6e22e;--warn: #e6db74;--shadow: 0 10px 30px rgba(0,0,0,.45);--body-bg: radial-gradient(700px 400px at 0% 0%, rgba(166, 226, 46, 0.08), transparent 60%), radial-gradient(700px 400px at 100% 100%, rgba(249, 38, 114, 0.08), transparent 60%), var(--bg)}:root[data-theme=office]{--bg: #f0f0f0;--panel: #ffffff;--panel2: #f7f7f7;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.50);--text: rgba(0, 0, 0, 0.85);--accent: #0078d4;--danger: #c4314b;--ok: #107c10;--warn: #c19c00;--shadow: 0 10px 30px rgba(0,0,0,.08);--body-bg: var(--bg)}:root[data-theme=twilight]{--bg: #141414;--panel: #1e1e1e;--panel2: #191919;--border: rgba(255, 255, 255, 0.10);--muted: rgba(247, 247, 247, 0.55);--text: #f5f5f5;--accent: #d4a96a;--danger: #cf6679;--ok: #7cbf8e;--warn: #c9a84c;--shadow: 0 10px 30px rgba(0,0,0,.50);--body-bg: radial-gradient(700px 400px at 15% 0%, rgba(212, 169, 106, 0.10), transparent 60%), radial-gradient(700px 400px at 85% 100%, rgba(124, 191, 142, 0.07), transparent 60%), var(--bg)}:root[data-theme=xcode]{--bg: #f9f9f9;--panel: #ffffff;--panel2: #f2f2f7;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.50);--text: rgba(0, 0, 0, 0.88);--accent: #0070c1;--danger: #b22222;--ok: #006400;--warn: #7d4700;--shadow: 0 10px 30px rgba(0,0,0,.08);--body-bg: var(--bg)}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font-family:var(--sans);background:var(--body-bg);color:var(--text)}.app{height:100%;display:grid;grid-template-rows:auto 1fr;grid-template-columns:320px 1fr}.app.sidebar-collapsed{grid-template-columns:64px 1fr}.app>.menubar{grid-column:1/-1}.card{background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;min-height:0}.sidebar{display:flex;flex-direction:column;min-height:0;position:relative;border-radius:0}.sidebarToggle{display:inline-flex;margin:10px;top:10px;right:10px;z-index:2;padding:8px 10px;font-size:12px}.sidebarPanel{display:flex;flex-direction:column;min-height:0;height:100%}.sidebar.collapsed .sidebarPanel{display:none}.sidebar.collapsed{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);box-shadow:none}.sidebar.collapsed .sidebarToggle{display:flex;align-items:center;justify-content:center;writing-mode:vertical-lr;text-orientation:upright;letter-spacing:1px;background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);border:1px solid var(--border);color:var(--text)}.sidebar header{padding:14px 14px 10px;border-bottom:1px solid var(--border);display:block;align-items:center;gap:10px}.row{display:flex;gap:10px;align-items:center;margin-top:7px}.row.space{justify-content:space-between}.row .row{margin-top:0}#searchInput{margin-top:7px}.title{font-size:14px;font-weight:700;letter-spacing:.2px;display:flex;align-items:center;gap:10px}.pill{font-size:12px;padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:hsla(0,0%,100%,.04);color:var(--muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pill.warning{border-color:rgba(251,191,36,.45);background:rgba(251,191,36,.12);color:rgba(251,191,36,.95)}button{appearance:none;border:1px solid var(--border);background:hsla(0,0%,100%,.06);color:var(--text);padding:10px 12px;border-radius:12px;font-weight:650;cursor:pointer;transition:transform .05s ease,background .15s ease,border-color .15s ease;user-select:none;white-space:nowrap}button:hover{background:hsla(0,0%,100%,.1);border-color:hsla(0,0%,100%,.18)}button:active{transform:translateY(1px)}button.primary{border-color:rgba(94,234,212,.45);background:rgba(94,234,212,.14)}button.primary:hover{background:rgba(94,234,212,.2);border-color:rgba(94,234,212,.7)}button.ghost{background:rgba(0,0,0,0)}button:disabled{opacity:.55;cursor:not-allowed}.input{width:100%;border-radius:12px;border:1px solid var(--border);background:hsla(0,0%,100%,.05);color:var(--text);padding:10px 12px;outline:none;font-size:14px}.input::placeholder{color:hsla(0,0%,100%,.55)}@media(prefers-color-scheme: light){.input::placeholder{color:rgba(2,6,23,.4)}}.small{font-size:12px;color:var(--muted)}.treeWrap{padding:10px 10px 14px;overflow:auto;min-height:0}.tree{font-size:13px;line-height:1.35;padding:4px 0;user-select:none}.tree .node{display:flex;align-items:center;gap:6px;padding:6px 8px;border-radius:10px;cursor:pointer;transition:background .12s ease;border:1px solid rgba(0,0,0,0)}.tree .node:hover{background:hsla(0,0%,100%,.06)}.tree .node.active{background:rgba(94,234,212,.14);background:rgba(94,234,212,.4)}.tree .indent{margin-left:14px}.icon{width:16px;height:16px;display:inline-block;vertical-align:middle;flex-shrink:0;color:inherit;pointer-events:none;opacity:.85}.node .name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node .meta{font-size:11px;color:var(--muted);flex:0 0 auto}.tagrow{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px;padding:0 2px 2px}.tag{font-size:11px;padding:3px 7px;border-radius:999px;border:1px solid var(--border);color:var(--muted);background:hsla(0,0%,100%,.04);cursor:pointer}.tag.active{border-color:rgba(94,234,212,.45);color:var(--text);background:rgba(94,234,212,.12)}.main{display:flex;flex-direction:column;min-height:0;border-radius:0}.main header{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-height:54px}.filename{font-weight:800;letter-spacing:.2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55vw}.dirtyDot{width:8px;height:8px;border-radius:50%;background:var(--danger);box-shadow:0 0 0 3px rgba(251,113,113,.18);display:none;flex:0 0 auto}.dirtyDot.show{display:inline-block}.status{margin-left:auto;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px;min-width:220px;justify-content:flex-end}.status .badge{padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:hsla(0,0%,100%,.04);color:var(--muted);max-width:60ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status .badge.ok{border-color:rgba(52,211,153,.35);color:rgba(52,211,153,.95);background:rgba(52,211,153,.1)}.status .badge.warn{border-color:rgba(251,191,36,.35);color:rgba(251,191,36,.95);background:rgba(251,191,36,.1)}.status .badge.err{border-color:rgba(251,113,133,.4);color:rgba(251,113,133,.95);background:rgba(251,113,133,.1)}.split{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:1fr 1fr}.split.with-cogito{grid-template-columns:1fr 1fr 320px}.editorPane,.previewPane{min-height:0;min-width:0;overflow:hidden;display:flex;flex-direction:column}.editorPane{border-right:1px solid var(--border);background:var(--panel2)}textarea{width:100%;height:100%;resize:none;border:none;outline:none;padding:14px 14px 18px;font-family:var(--mono);font-size:14px;line-height:1.5;background:rgba(0,0,0,0);color:var(--text);overflow:auto;white-space:pre-wrap;word-break:break-word;tab-size:2}.preview{padding:14px 16px 18px;min-width:0;min-height:0;overflow-y:auto}.cogitoPanel{min-height:0;border-left:1px solid var(--border);background:var(--panel2);padding:12px;display:flex;flex-direction:column;gap:10px;overflow:auto}.cogitoPanel[hidden]{display:none}.cogitoHeader{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0;padding:0;border:0;min-height:0}.cogitoModelPicker{display:flex;gap:4px}.cogitoModelBtn{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;font-size:11px;font-weight:600;border-radius:8px;opacity:.55}.cogitoModelBtn.active{opacity:1;border-color:rgba(94,234,212,.5);background:rgba(94,234,212,.14)}.cogitoQuestionList{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.cogitoQuestionItem{border:1px solid var(--border);border-radius:12px;padding:10px;background:hsla(0,0%,100%,.03);display:flex;flex-direction:column;gap:8px}.cogitoQuestionText{margin:0;font-size:13px;line-height:1.4}.cogitoInsertBtn{align-self:flex-end;padding:6px 10px;font-size:12px;border-radius:10px}.preview :is(h1,h2,h3){line-height:1.25;margin:1em 0 .5em}.preview h1{font-size:1.6rem}.preview h2{font-size:1.35rem}.preview h3{font-size:1.15rem}.preview p{color:var(--text);opacity:.92}.preview a{color:var(--accent)}.preview code{font-family:var(--mono);font-size:.95em;padding:.15em .35em;border-radius:8px;background:hsla(0,0%,100%,.06);border:1px solid var(--border)}.preview pre{max-width:100%;padding:12px;border-radius:12px;background:rgba(94,234,212,.06);border:1px solid var(--border);white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.preview pre code{padding:0;border:none;background:rgba(0,0,0,0)}.preview blockquote{border-left:4px solid rgba(94,234,212,.45);padding:8px 12px;margin:12px 0;background:rgba(94,234,212,.08);border-radius:10px;color:var(--muted)}.preview table{width:100%;border-collapse:collapse;border:1px solid var(--border);border-radius:12px;overflow:hidden}.preview th,.preview td{border-bottom:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}.preview th{background:hsla(0,0%,100%,.05);font-weight:800}.menubar{display:flex;align-items:center;padding:0 8px;height:38px;border-radius:0;border-bottom:1px solid var(--border);background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);flex-shrink:0;overflow:visible}.menubar-spacer{flex:1 1 auto}.menu-action-btn{display:inline-flex;align-items:center;gap:6px;height:30px;padding:4px 10px;border-radius:10px;font-size:12px}.menu-action-label{font-weight:700}.menu-action-btn[aria-expanded=true]{border-color:rgba(94,234,212,.6);background:rgba(94,234,212,.16)}.glyphicon{display:inline-flex;width:16px;justify-content:center;align-items:center;line-height:1;font-size:14px}.glyphicon-education::before{content:"🧠"}.menu-item{position:relative;display:flex;align-items:center;gap:4px;padding:6px 12px;cursor:pointer;border-radius:8px;transition:background .12s ease;user-select:none}.menu-item:hover,.menu-item:focus,.menu-item[aria-expanded=true]{background:hsla(0,0%,100%,.08);outline:none}.menu-text{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:500;color:var(--text)}.menu-arrow{font-size:8px;color:var(--muted);margin-left:2px}.dropdown{position:absolute;top:100%;left:0;min-width:200px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow);padding:4px 0;display:none;z-index:100;margin-top:2px}.menu-item[aria-expanded=true] .dropdown{display:block}.dropdown li{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;transition:background .08s ease}.dropdown li:hover,.dropdown li:focus{background:rgba(94,234,212,.12);outline:none}.dropdown li[role=separator]{height:1px;padding:0;margin:4px 8px;background:var(--border);cursor:default}.dropdown li[role=separator]:hover,.dropdown li[role=separator]:focus{background:var(--border)}.menu-label{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--text)}.menu-label-text{min-width:0}.menu-shortcut{font-size:11px;color:var(--muted);margin-left:20px}.menu-theme-check{font-size:11px;color:var(--accent);margin-left:20px;opacity:0}.menu-theme-check.active{opacity:1}.submenu-parent{position:relative}.submenu-parent .dropdown.submenu{position:absolute;left:100%;top:0;margin-left:4px;display:none}.submenu-parent:hover>.dropdown.submenu{display:block}.submenu-parent>.menu-arrow{font-size:6px;margin-left:6px}.toast{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#fff;padding:10px 12px;border-radius:12px;border:1px solid hsla(0,0%,100%,.18);max-width:min(760px,100vw - 28px);box-shadow:0 14px 40px rgba(0,0,0,.35);display:none;z-index:1000;font-size:13px;backdrop-filter:blur(10px)}.toast.show{display:block}.toast .trow{display:flex;gap:10px;align-items:flex-start}.toast .trow .tmsg{flex:1 1 auto}.toast .trow button{padding:6px 8px;border-radius:10px;font-size:12px}.webmcpModal{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:20px;z-index:1050;opacity:0;pointer-events:none;visibility:hidden}.webmcpModal.show{opacity:1;pointer-events:auto;visibility:visible}.webmcpModalBackdrop{position:absolute;inset:0;background:rgba(0,0,0,.56);backdrop-filter:blur(8px)}.webmcpModalCard{position:relative;width:min(680px,100vw - 40px);max-height:calc(100vh - 40px);overflow:auto;padding:18px;border-radius:18px;border:1px solid var(--border);background:linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 100%),var(--panel);box-shadow:var(--shadow)}.webmcpModalHeader{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px}#webmcpNoteForm{display:grid;gap:10px}#webmcpNoteForm textarea{min-height:160px;resize:vertical}.webmcpModalActions{justify-content:flex-end;margin-top:4px}@media(max-width: 980px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr;height:100%}.app.sidebar-collapsed{grid-template-columns:1fr}.split{grid-template-columns:1fr}.split.with-cogito{grid-template-columns:1fr}.editorPane{border-right:none;border-bottom:1px solid var(--border);min-height:40vh}.previewPane{min-height:40vh}.cogitoPanel{border-left:none;border-top:1px solid var(--border);min-height:30vh}.filename{max-width:70vw}.sidebarToggle{position:static;margin:10px;width:calc(100% - 20px);height:auto;transform:none;writing-mode:horizontal-tb;text-orientation:mixed;letter-spacing:normal}.sidebar.collapsed .sidebarPanel{display:none}.sidebar.collapsed .sidebarToggle{display:flex;width:calc(100% - 20px);writing-mode:horizontal-tb;text-orientation:mixed;letter-spacing:normal}.sidebar.collapsed{height:auto;min-height:0}.webmcpModal{padding:12px;align-items:flex-end}.webmcpModalCard{width:100%;max-height:min(80vh,720px);border-radius:18px 18px 0 0}} \ No newline at end of file +:root{--bg: #0b0f14;--panel: #0f1620;--panel2: #0c121a;--border: rgba(255, 255, 255, 0.10);--muted: rgba(255, 255, 255, 0.70);--text: rgba(255, 255, 255, 0.92);--accent: #5eead4;--danger: #fb7185;--ok: #34d399;--warn: #fbbf24;--shadow: 0 10px 30px rgba(0,0,0,.35);--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";--radius: 14px;--body-bg: radial-gradient(900px 600px at 10% 0%, rgba(94, 234, 212, 0.15), transparent 60%), radial-gradient(900px 600px at 90% 0%, rgba(251, 191, 36, 0.10), transparent 60%), var(--bg)}:root[data-theme=classic]{--bg: #f5f5f5;--panel: #ffffff;--panel2: #eeeeee;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.55);--text: rgba(0, 0, 0, 0.88);--accent: #2c6fad;--danger: #d9534f;--ok: #2e7d32;--warn: #e65100;--shadow: 0 10px 30px rgba(0,0,0,.10);--body-bg: var(--bg)}:root[data-theme=cobalt]{--bg: #001f3d;--panel: #002b52;--panel2: #002040;--border: rgba(255, 255, 255, 0.12);--muted: rgba(255, 255, 255, 0.60);--text: #e8f4fd;--accent: #ffd700;--danger: #ff6b6b;--ok: #32d48a;--warn: #ffba08;--shadow: 0 10px 30px rgba(0,0,0,.50);--body-bg: radial-gradient(800px 500px at 20% 0%, rgba(0, 120, 212, 0.20), transparent 60%), radial-gradient(800px 500px at 80% 100%, rgba(255, 215, 0, 0.08), transparent 60%), var(--bg)}:root[data-theme=monokai]{--bg: #272822;--panel: #3e3d32;--panel2: #2d2c27;--border: rgba(255, 255, 255, 0.10);--muted: rgba(248, 248, 242, 0.55);--text: #f8f8f2;--accent: #a6e22e;--danger: #f92672;--ok: #a6e22e;--warn: #e6db74;--shadow: 0 10px 30px rgba(0,0,0,.45);--body-bg: radial-gradient(700px 400px at 0% 0%, rgba(166, 226, 46, 0.08), transparent 60%), radial-gradient(700px 400px at 100% 100%, rgba(249, 38, 114, 0.08), transparent 60%), var(--bg)}:root[data-theme=office]{--bg: #f0f0f0;--panel: #ffffff;--panel2: #f7f7f7;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.50);--text: rgba(0, 0, 0, 0.85);--accent: #0078d4;--danger: #c4314b;--ok: #107c10;--warn: #c19c00;--shadow: 0 10px 30px rgba(0,0,0,.08);--body-bg: var(--bg)}:root[data-theme=twilight]{--bg: #141414;--panel: #1e1e1e;--panel2: #191919;--border: rgba(255, 255, 255, 0.10);--muted: rgba(247, 247, 247, 0.55);--text: #f5f5f5;--accent: #d4a96a;--danger: #cf6679;--ok: #7cbf8e;--warn: #c9a84c;--shadow: 0 10px 30px rgba(0,0,0,.50);--body-bg: radial-gradient(700px 400px at 15% 0%, rgba(212, 169, 106, 0.10), transparent 60%), radial-gradient(700px 400px at 85% 100%, rgba(124, 191, 142, 0.07), transparent 60%), var(--bg)}:root[data-theme=xcode]{--bg: #f9f9f9;--panel: #ffffff;--panel2: #f2f2f7;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.50);--text: rgba(0, 0, 0, 0.88);--accent: #0070c1;--danger: #b22222;--ok: #006400;--warn: #7d4700;--shadow: 0 10px 30px rgba(0,0,0,.08);--body-bg: var(--bg)}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font-family:var(--sans);background:var(--body-bg);color:var(--text)}.app{height:100%;display:grid;grid-template-rows:auto 1fr;grid-template-columns:320px 1fr}.app.sidebar-collapsed{grid-template-columns:64px 1fr}.app>.menubar{grid-column:1/-1}.card{background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;min-height:0}.sidebar{display:flex;flex-direction:column;min-height:0;position:relative;border-radius:0}.sidebarToggle{display:inline-flex;margin:10px;top:10px;right:10px;z-index:2;padding:8px 10px;font-size:12px}.sidebarPanel{display:flex;flex-direction:column;min-height:0;height:100%}.sidebar.collapsed .sidebarPanel{display:none}.sidebar.collapsed{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);box-shadow:none}.sidebar.collapsed .sidebarToggle{display:flex;align-items:center;justify-content:center;writing-mode:vertical-lr;text-orientation:upright;letter-spacing:1px;background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);border:1px solid var(--border);color:var(--text)}.sidebar header{padding:14px 14px 10px;border-bottom:1px solid var(--border);display:block;align-items:center;gap:10px}.row{display:flex;gap:10px;align-items:center;margin-top:7px}.row.space{justify-content:space-between}.row .row{margin-top:0}#searchInput{margin-top:7px}.title{font-size:14px;font-weight:700;letter-spacing:.2px;display:flex;align-items:center;gap:10px}.pill{font-size:12px;padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:hsla(0,0%,100%,.04);color:var(--muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pill.warning{border-color:rgba(251,191,36,.45);background:rgba(251,191,36,.12);color:rgba(251,191,36,.95)}button{appearance:none;border:1px solid var(--border);background:hsla(0,0%,100%,.06);color:var(--text);padding:10px 12px;border-radius:12px;font-weight:650;cursor:pointer;transition:transform .05s ease,background .15s ease,border-color .15s ease;user-select:none;white-space:nowrap}button:hover{background:hsla(0,0%,100%,.1);border-color:hsla(0,0%,100%,.18)}button:active{transform:translateY(1px)}button.primary{border-color:rgba(94,234,212,.45);background:rgba(94,234,212,.14)}button.primary:hover{background:rgba(94,234,212,.2);border-color:rgba(94,234,212,.7)}button.ghost{background:rgba(0,0,0,0)}button:disabled{opacity:.55;cursor:not-allowed}.input{width:100%;border-radius:12px;border:1px solid var(--border);background:hsla(0,0%,100%,.05);color:var(--text);padding:10px 12px;outline:none;font-size:14px}.input::placeholder{color:hsla(0,0%,100%,.55)}@media(prefers-color-scheme: light){.input::placeholder{color:rgba(2,6,23,.4)}}.small{font-size:12px;color:var(--muted)}.treeWrap{padding:10px 10px 14px;overflow:auto;min-height:0}.tree{font-size:13px;line-height:1.35;padding:4px 0;user-select:none}.tree .node{display:flex;align-items:center;gap:6px;padding:6px 8px;border-radius:10px;cursor:pointer;transition:background .12s ease;border:1px solid rgba(0,0,0,0)}.tree .node:hover{background:hsla(0,0%,100%,.06)}.tree .node.active{background:rgba(94,234,212,.14);background:rgba(94,234,212,.4)}.tree .indent{margin-left:14px}.icon{width:16px;height:16px;display:inline-block;vertical-align:middle;flex-shrink:0;color:inherit;pointer-events:none;opacity:.85}.node .name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node .meta{font-size:11px;color:var(--muted);flex:0 0 auto}.tagrow{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px;padding:0 2px 2px}.tag{font-size:11px;padding:3px 7px;border-radius:999px;border:1px solid var(--border);color:var(--muted);background:hsla(0,0%,100%,.04);cursor:pointer}.tag.active{border-color:rgba(94,234,212,.45);color:var(--text);background:rgba(94,234,212,.12)}.main{display:flex;flex-direction:column;min-height:0;border-radius:0}.main header{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-height:54px}.viewModePicker{display:inline-flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--border);border-radius:999px;background:hsla(0,0%,100%,.04);flex:0 0 auto}.viewModeBtn{border:0;background:rgba(0,0,0,0);color:var(--muted);border-radius:999px;padding:6px 10px;font-size:12px;line-height:1;font-weight:700;white-space:nowrap}.viewModeBtn:hover{background:hsla(0,0%,100%,.06);color:var(--text)}.viewModeBtn.active{background:rgba(94,234,212,.16);color:var(--text);box-shadow:inset 0 0 0 1px rgba(94,234,212,.35)}.filename{font-weight:800;letter-spacing:.2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55vw}.dirtyDot{width:8px;height:8px;border-radius:50%;background:var(--danger);box-shadow:0 0 0 3px rgba(251,113,113,.18);display:none;flex:0 0 auto}.dirtyDot.show{display:inline-block}.status{margin-left:auto;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px;min-width:220px;justify-content:flex-end}.status .badge{padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:hsla(0,0%,100%,.04);color:var(--muted);max-width:60ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status .badge.ok{border-color:rgba(52,211,153,.35);color:rgba(52,211,153,.95);background:rgba(52,211,153,.1)}.status .badge.warn{border-color:rgba(251,191,36,.35);color:rgba(251,191,36,.95);background:rgba(251,191,36,.1)}.status .badge.err{border-color:rgba(251,113,133,.4);color:rgba(251,113,133,.95);background:rgba(251,113,133,.1)}.split{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:1fr 1fr}.split.view-source,.split.view-preview{grid-template-columns:1fr}.split.view-source.with-cogito,.split.view-source.with-document-linter,.split.view-preview.with-cogito,.split.view-preview.with-document-linter{grid-template-columns:1fr 320px}.split.view-split.with-cogito,.split.view-split.with-document-linter{grid-template-columns:1fr 1fr 320px}.editorPane,.previewPane{min-height:0;min-width:0;overflow:hidden;display:flex;flex-direction:column}.editorPane{border-right:1px solid var(--border);background:var(--panel2)}.split.view-source .editorPane{border-right:0}.split.view-preview .previewPane{border-left:0}.editorPane[hidden],.previewPane[hidden],.cogitoPanel[hidden],.documentLinterPanel[hidden]{display:none !important}textarea{width:100%;height:100%;resize:none;border:none;outline:none;padding:14px 14px 18px;font-family:var(--mono);font-size:14px;line-height:1.5;background:rgba(0,0,0,0);color:var(--text);overflow:auto;white-space:pre-wrap;word-break:break-word;tab-size:2}.preview{padding:14px 16px 18px;min-width:0;min-height:0;overflow-y:auto}.cogitoPanel{min-height:0;border-left:1px solid var(--border);background:var(--panel2);padding:12px;display:flex;flex-direction:column;gap:10px;overflow:auto}.cogitoPanel[hidden]{display:none}.documentLinterPanel{min-height:0;border-left:1px solid var(--border);background:var(--panel2);padding:12px;display:flex;flex-direction:column;gap:10px;overflow:auto}.documentLinterPanel[hidden]{display:none}.documentLinterHeader{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0;padding:0;border:0;min-height:0}.documentLinterAutoRun{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);user-select:none}.documentLinterAutoRun input{margin:0}.documentLinterResults{flex:1 1 auto;overflow-y:auto;display:flex;flex-direction:column;gap:12px;padding-bottom:12px}.documentLinterSummary{display:grid;gap:10px;margin-bottom:10px}.documentLinterSummaryBlock{border:1px solid var(--border);border-radius:12px;padding:10px;background:hsla(0,0%,100%,.03)}.documentLinterSummaryBlock h3{margin:0 0 8px;font-size:13px;font-weight:700}.documentLinterSummaryBlock ul,.documentLinterSummaryBlock ol{margin:0;padding-left:18px;display:grid;gap:6px}.documentLinterCategory{border:1px solid var(--border);border-radius:12px;padding:10px;background:hsla(0,0%,100%,.03);margin:0}.documentLinterCategoryHeader{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--border)}.documentLinterCategoryHeader h3{margin:0;font-size:13px;font-weight:600}.documentLinterScore{font-size:14px;font-weight:700;padding:2px 6px;border-radius:4px}.documentLinterSuggestions{display:flex;flex-direction:column;gap:4px}.documentLinterSuggestion{font-size:12px;line-height:1.4}.documentLinterLineButton{border:0;background:rgba(0,0,0,0);color:var(--accent);padding:0;font:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}.documentLinterLineButton:hover,.documentLinterLineButton:focus-visible{color:var(--text);outline:none}.documentLinterSectionAnalysis{display:grid;gap:10px;margin-top:2px;padding-top:8px}.documentLinterSectionAnalysis h3{margin:0;font-size:13px;font-weight:700}.documentLinterSectionList{display:grid;gap:10px}.documentLinterSectionCard{border:1px solid var(--border);border-radius:12px;padding:10px;background:hsla(0,0%,100%,.03);display:grid;gap:8px}.documentLinterSectionHeader{display:flex;justify-content:space-between;gap:10px;align-items:baseline;padding-bottom:8px;border-bottom:1px solid var(--border)}.documentLinterSectionHeader h4{margin:0;font-size:12px;font-weight:700}.documentLinterSectionHeader span{font-size:11px;color:var(--muted);white-space:nowrap}.documentLinterSectionNotes{display:grid;gap:4px}.cogitoHeader{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0;padding:0;border:0;min-height:0}.cogitoModelPicker{display:flex;gap:4px}.cogitoModelBtn{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;font-size:11px;font-weight:600;border-radius:8px;opacity:.55}.cogitoModelBtn.active{opacity:1;border-color:rgba(94,234,212,.5);background:rgba(94,234,212,.14)}.cogitoQuestionList{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.cogitoQuestionItem{border:1px solid var(--border);border-radius:12px;padding:10px;background:hsla(0,0%,100%,.03);display:flex;flex-direction:column;gap:8px}.cogitoQuestionText{margin:0;font-size:13px;line-height:1.4}.cogitoInsertBtn{align-self:flex-end;padding:6px 10px;font-size:12px;border-radius:10px}.preview :is(h1,h2,h3){line-height:1.25;margin:1em 0 .5em}.preview h1{font-size:1.6rem}.preview h2{font-size:1.35rem}.preview h3{font-size:1.15rem}.preview p{color:var(--text);opacity:.92}.preview a{color:var(--accent)}.preview code{font-family:var(--mono);font-size:.95em;padding:.15em .35em;border-radius:8px;background:hsla(0,0%,100%,.06);border:1px solid var(--border)}.preview pre{max-width:100%;padding:12px;border-radius:12px;background:rgba(94,234,212,.06);border:1px solid var(--border);white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.preview pre code{padding:0;border:none;background:rgba(0,0,0,0)}.preview blockquote{border-left:4px solid rgba(94,234,212,.45);padding:8px 12px;margin:12px 0;background:rgba(94,234,212,.08);border-radius:10px;color:var(--muted)}.preview table{width:100%;border-collapse:collapse;border:1px solid var(--border);border-radius:12px;overflow:hidden}.preview th,.preview td{border-bottom:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}.preview th{background:hsla(0,0%,100%,.05);font-weight:800}.menubar{display:flex;align-items:center;padding:0 8px;height:38px;border-radius:0;border-bottom:1px solid var(--border);background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);flex-shrink:0;overflow:visible}.menubar-spacer{flex:1 1 auto}.menu-action-btn{display:inline-flex;align-items:center;gap:6px;height:30px;padding:4px 10px;border-radius:10px;font-size:12px}.menu-action-btn+.menu-action-btn{margin-left:8px}.menu-action-label{font-weight:700}.menu-action-btn[aria-expanded=true]{border-color:rgba(94,234,212,.6);background:rgba(94,234,212,.16)}.glyphicon{display:inline-flex;width:16px;justify-content:center;align-items:center;line-height:1;font-size:14px}.glyphicon-education::before{content:"🧠"}.menu-item{position:relative;display:flex;align-items:center;gap:4px;padding:6px 12px;cursor:pointer;border-radius:8px;transition:background .12s ease;user-select:none}.menu-item:hover,.menu-item:focus,.menu-item[aria-expanded=true]{background:hsla(0,0%,100%,.08);outline:none}.menu-text{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:500;color:var(--text)}.menu-arrow{font-size:8px;color:var(--muted);margin-left:2px}.dropdown{position:absolute;top:100%;left:0;min-width:200px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow);padding:4px 0;display:none;z-index:100;margin-top:2px}.menu-item[aria-expanded=true] .dropdown{display:block}.dropdown li{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;transition:background .08s ease}.dropdown li:hover,.dropdown li:focus{background:rgba(94,234,212,.12);outline:none}.dropdown li[role=separator]{height:1px;padding:0;margin:4px 8px;background:var(--border);cursor:default}.dropdown li[role=separator]:hover,.dropdown li[role=separator]:focus{background:var(--border)}.menu-label{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--text)}.menu-label-text{min-width:0}.menu-shortcut{font-size:11px;color:var(--muted);margin-left:20px}.menu-theme-check{font-size:11px;color:var(--accent);margin-left:20px;opacity:0}.menu-theme-check.active{opacity:1}.submenu-parent{position:relative}.submenu-parent .dropdown.submenu{position:absolute;left:100%;top:0;margin-left:4px;display:none}.submenu-parent:hover>.dropdown.submenu{display:block}.submenu-parent>.menu-arrow{font-size:6px;margin-left:6px}.toast{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#fff;padding:10px 12px;border-radius:12px;border:1px solid hsla(0,0%,100%,.18);max-width:min(760px,100vw - 28px);box-shadow:0 14px 40px rgba(0,0,0,.35);display:none;z-index:1000;font-size:13px;backdrop-filter:blur(10px)}.toast.show{display:block}.toast .trow{display:flex;gap:10px;align-items:flex-start}.toast .trow .tmsg{flex:1 1 auto}.toast .trow button{padding:6px 8px;border-radius:10px;font-size:12px}.webmcpModal{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:20px;z-index:1050;opacity:0;pointer-events:none;visibility:hidden}.webmcpModal.show{opacity:1;pointer-events:auto;visibility:visible}.webmcpModalBackdrop{position:absolute;inset:0;background:rgba(0,0,0,.56);backdrop-filter:blur(8px)}.webmcpModalCard{position:relative;width:min(680px,100vw - 40px);max-height:calc(100vh - 40px);overflow:auto;padding:18px;border-radius:18px;border:1px solid var(--border);background:linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 100%),var(--panel);box-shadow:var(--shadow)}.webmcpModalHeader{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px}#webmcpNoteForm{display:grid;gap:10px}#webmcpNoteForm textarea{min-height:160px;resize:vertical}.webmcpModalActions{justify-content:flex-end;margin-top:4px}@media(max-width: 980px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr;height:100%}.app.sidebar-collapsed{grid-template-columns:1fr}.split,.split.view-source,.split.view-preview,.split.view-split.with-cogito,.split.view-split.with-document-linter,.split.view-source.with-cogito,.split.view-source.with-document-linter,.split.view-preview.with-cogito,.split.view-preview.with-document-linter{grid-template-columns:1fr}.editorPane{border-right:none;border-bottom:1px solid var(--border);min-height:40vh}.previewPane{min-height:40vh}.cogitoPanel{border-left:none;border-top:1px solid var(--border);min-height:30vh}.documentLinterPanel{border-left:none;border-top:1px solid var(--border);min-height:30vh}.filename{max-width:70vw}.sidebarToggle{position:static;margin:10px;width:calc(100% - 20px);height:auto;transform:none;writing-mode:horizontal-tb;text-orientation:mixed;letter-spacing:normal}.sidebar.collapsed .sidebarPanel{display:none}.sidebar.collapsed .sidebarToggle{display:flex;width:calc(100% - 20px);writing-mode:horizontal-tb;text-orientation:mixed;letter-spacing:normal}.sidebar.collapsed{height:auto;min-height:0}.webmcpModal{padding:12px;align-items:flex-end}.webmcpModalCard{width:100%;max-height:min(80vh,720px);border-radius:18px 18px 0 0}} \ No newline at end of file diff --git a/dist/test/cogito.js b/dist/test/cogito.js index b2d7672..0f7e0dc 100644 --- a/dist/test/cogito.js +++ b/dist/test/cogito.js @@ -183,6 +183,16 @@ function createCogitoController({ setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence."); } }, + setPanelOpen: (nextIsOpen) => { + setPanelVisibility(nextIsOpen); + if (nextIsOpen) { + setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence."); + } + }, + isPanelOpen: () => isPanelOpen, + closePanel: () => { + setPanelVisibility(false); + }, selectModel, generateQuestions, insertQuestionAtIndex diff --git a/dist/test/document-linter.js b/dist/test/document-linter.js new file mode 100644 index 0000000..8d5ea58 --- /dev/null +++ b/dist/test/document-linter.js @@ -0,0 +1,1170 @@ +// src/app/document-linter/messages.ts +var DOCUMENT_LINTER_FALLBACK_STRENGTH = "No clear strengths stand out yet; the draft needs more signal before the linter can praise specific choices."; +var DOCUMENT_LINTER_NO_MAJOR_FIXES = "No major fixes stood out."; +var DOCUMENT_LINTER_NO_NOTABLE_ISSUES = "No notable issues in this category."; +var DOCUMENT_LINTER_NO_SECTION_STRUCTURE = "This note did not expose any obvious structural sections."; +function openingNeedsStructure() { + return "The note opens cleanly, but it still needs a visible structure signal."; +} +function openingTooDense() { + return "The first sentence does a lot of work. Tighten it and let the rest of the section carry the supporting detail."; +} +function openingNeedsStrongerLead() { + return "The opening is understandable, but it could be shaped into a sharper lead sentence."; +} +function openingIsInformativeNotDirective() { + return "Lead with the payoff or takeaway first, then use the topic sentence to support it."; +} +function quickTakeStrengthsDetected() { + return "There are real strengths here: concrete details and structure cues give the document memory and shape."; +} +function quickTakeNeedsScaffold() { + return "The content is solid, but it needs a clearer scaffold so the reader can move through it faster."; +} +function quickTakeLowSignal() { + return "The draft is too thin or fragmentary to score well yet; add a clearer claim and one or two supporting details."; +} +function missingHeading() { + return "Add a heading at the top so the chapter reads as a structured note instead of a raw outline."; +} +function explicitSectionLabel(label) { + return `Turn "${label}" into a heading so the bullet list has a clean anchor.`; +} +function bulletsAreDense() { + 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."; +} +function structureSimpleOutline() { + return "This would scan better with three visible beats: a short lead, a grouped facts section, and a closing takeaway."; +} +function structureBreakMiddle() { + 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."; +} +function denseAbstractLanguage() { + return "This section leans on a few abstract nouns. It still reads clearly, but a couple of them could be replaced with plainer words."; +} +function repeatedWord() { + return "A word is repeated back-to-back. Clean that up before worrying about higher-level style."; +} +function thinDraft() { + return "There is not enough developed prose yet to judge the document fairly. Add a clearer point and at least one supporting sentence."; +} +function thinSection() { + return "This section is too thin to evaluate well; add a clearer claim or supporting detail."; +} +function questionNeedsAnswer() { + return "A question can be a good hook, but it needs an immediate answer or payoff."; +} +function balancedSection() { + return "This section is balanced and easy to follow."; +} +function sectionNeedsHeading(title) { + return `The "${title}" block introduces a real section. Promoting it to a heading would make the document easier to scan.`; +} +function sectionDenseBullets(lineStart, bulletCount) { + return `The section starting at line ${lineStart} has ${bulletCount} bullets and several carry more than one idea.`; +} +function sectionLong(lineStart) { + return `The section starting at line ${lineStart} is carrying a lot of text and would be easier to scan if it were split.`; +} +function sectionLabelPromotion(title) { + return `Turn "${title}:" into a heading so the section reads as intentional structure.`; +} +function sectionDenseBulletRun() { + return "This bullet run is dense; split the longest items or group them by theme."; +} +function sectionLongMaterial() { + return "This section carries a lot of material; consider splitting it into two smaller beats."; +} +function sectionListNeedsLead() { + return "The heading works, but this section is list-only; a short lead sentence could help orient the reader."; +} +function sectionNeedsAttention(title, note) { + return `The "${title}" section deserves attention: ${note.toLowerCase()}`; +} +function strengthConcreteAnchors() { + return "Concrete anchors such as names, dates, or numbers make the material easier to remember."; +} +function strengthMnemonic() { + return "The document gives the reader a memory hook, which makes the takeaway easier to retain."; +} +function strengthParallelList() { + return "The parallel list structure creates a strong rhythm and makes the sequence easy to scan."; +} +function strengthQuestionLead() { + return "The opening question creates forward pull and gives the reader a reason to keep going."; +} +function strengthDirectiveLead() { + return "The lead uses clear directive language, which gives the document momentum."; +} +function strengthClearStructure() { + return "The document already has enough visible structure that a reader can scan it quickly."; +} + +// src/app/document-linter/document-linter.ts +var CATEGORY_META = [ + { 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" } +]; +function clampScore(score) { + return Math.max(0, Math.min(100, score)); +} +function countWords(text) { + return text.match(/\b\w+\b/g)?.length ?? 0; +} +function splitSentences(text) { + return text.replaceAll(/\s+/g, " ").split(/(?<=[.!?])\s+/).map((sentence) => sentence.trim()).filter(Boolean); +} +function countSentences(text) { + return splitSentences(text).length; +} +function escapeHtml(value) { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} +function scoreWeight(severity) { + if (severity === "high") { + return 3; + } + if (severity === "medium") { + return 2; + } + return 1; +} +function createLineButtonMarkup(lineStart, label = `Line ${lineStart}`) { + return ``; +} +function isFencedCodeStart(line) { + return /^```/.test(line.trim()); +} +function isAtxHeading(line) { + return /^#{1,6}\s+/.test(line.trim()); +} +function isOrderedListItem(line) { + return /^\s*\d+[.)]\s+/.test(line); +} +function isUnorderedListItem(line) { + return /^\s*[-*+]\s+/.test(line); +} +function isListItem(line) { + return isUnorderedListItem(line) || isOrderedListItem(line); +} +function isBlockquoteLine(line) { + return /^\s*>\s?/.test(line); +} +function isIndentedCodeLine(line) { + return /^(?:\t| {4,})/.test(line); +} +function getSectionLabelTitle(block, nextBlock) { + if (block.type === "paragraph" && /:\s*$/.test(block.text) && nextBlock?.type === "list_item") { + return block.text.replace(/:\s*$/, ""); + } + return null; +} +function listMarkerPrefix(line) { + return line.replace(/^(\s*(?:[-*+]|\d+[.)]))\s+.*$/, "$1"); +} +function stripListMarker(line) { + return line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim(); +} +function normalizeBlockText(lines) { + return lines.map((line) => line.trim()).join(" ").replaceAll(/\s+/g, " ").trim(); +} +function parseMarkdownBlocks(text) { + const lines = text.split("\n"); + const blocks = []; + 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 startLine2 = lineNumber; + const codeLines = [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: startLine2, + lineEnd: Math.max(startLine2, i) + }); + continue; + } + if (isIndentedCodeLine(line)) { + const startLine2 = 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: startLine2, + 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 startLine2 = lineNumber; + const quoteLines = []; + 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: startLine2, + lineEnd: i + }); + continue; + } + const startLine = lineNumber; + const paragraphLines = [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 blocks; +} +function getBlocksOfType(blocks, type) { + return blocks.filter((block) => block.type === type); +} +function getPlainTextBlocks(blocks) { + return blocks.filter((block) => { + return block.type === "paragraph" || block.type === "blockquote"; + }); +} +function findRepeatedWord(text) { + 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 }; +} +function createFinding(category, severity, title, detail, section, isStrength = false) { + return { category, severity, title, detail, section, isStrength }; +} +function summarizeOpeners(blocks) { + const firstParagraph = blocks.find((block) => block.type === "paragraph"); + if (!firstParagraph) { + return openingNeedsStructure(); + } + if (countWords(firstParagraph.text) >= 22) { + return openingTooDense(); + } + if (/^(this|these)\b/i.test(firstParagraph.text)) { + return openingIsInformativeNotDirective(); + } + return openingNeedsStrongerLead(); +} +function detectPseudoSectionLabel(blocks) { + 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; +} +function buildDocumentSections(blocks) { + const sections = []; + let currentSection = null; + function startSection(title, kind, lineStart) { + const nextSection = { + title, + kind, + lineStart, + lineEnd: lineStart, + blocks: [] + }; + sections.push(nextSection); + currentSection = nextSection; + return nextSection; + } + function ensureLeadSection(block) { + if (currentSection) { + return currentSection; + } + return startSection("Lead", "implicit", block.lineStart); + } + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + const nextBlock = blocks[i + 1]; + if (block.type === "heading") { + startSection(block.text, "heading", block.lineStart); + continue; + } + 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 (sections.length === 0) { + return [ + { + title: "Document", + kind: "implicit", + lineStart: 1, + lineEnd: 1, + blocks: [] + } + ]; + } + return sections; +} +function analyzeReadability(blocks) { + const proseBlocks = getPlainTextBlocks(blocks); + const findings = []; + let longSentenceCount = 0; + let repeatedWordCount = 0; + 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 openingBlock = proseBlocks[0]; + if (openingBlock && countWords(openingBlock.text) >= 22) { + findings.unshift( + createFinding( + "readability", + "high", + "Opening is dense", + openingTooDense(), + `Line ${openingBlock.lineStart}` + ) + ); + } + const score = clampScore( + 100 - longSentenceCount * 14 - repeatedWordCount * 10 - (openingBlock && countWords(openingBlock.text) >= 22 ? 8 : 0) + ); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`) + }, + findings + }; +} +function analyzeSkimmability(blocks) { + const headings = getBlocksOfType(blocks, "heading"); + const bullets = getBlocksOfType(blocks, "list_item"); + const pseudoSectionLabel = detectPseudoSectionLabel(blocks); + const findings = []; + 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 score = clampScore( + 100 - (headings.length === 0 ? 20 : 0) - (pseudoSectionLabel ? 6 : 0) - (bullets.length >= 4 ? 10 : 0) + ); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`) + }, + findings, + pseudoSectionLabel + }; +} +function analyzeEngagement(blocks) { + const text = blocks.map((block) => block.text).join(" "); + const firstParagraph = blocks.find((block) => block.type === "paragraph"); + const openings = firstParagraph?.text ?? ""; + const findings = []; + const strengths = []; + const totalWords = countWords(text); + 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}` + ) + ); + } + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "engagement", + "high", + "Too little context", + thinDraft(), + `Line ${firstParagraph?.lineStart ?? 1}` + ) + ); + } + if (hasOpeningQuestion && totalWords < 18) { + findings.push( + createFinding( + "engagement", + "medium", + "Question needs payoff", + questionNeedsAnswer(), + `Line ${firstParagraph?.lineStart ?? 1}` + ) + ); + } + if (hasOpeningQuestion) { + strengths.push(strengthQuestionLead()); + } + if (hasDirectiveLead || directiveMatches.length >= 2) { + strengths.push(strengthDirectiveLead()); + } + if (mnemonicMatches.length > 0) { + strengths.push(strengthMnemonic()); + } + if (concreteAnchorMatches.length >= 2) { + strengths.push(strengthConcreteAnchors()); + } + if (repeatedBulletStart && bulletTexts.length >= 3) { + strengths.push(strengthParallelList()); + } + if (strengths.length === 0 && (getBlocksOfType(blocks, "heading").length > 0 || bulletTexts.length >= 3)) { + strengths.push(strengthClearStructure()); + } + 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 + ); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`) + }, + findings, + strengths + }; +} +function analyzeStyle(blocks) { + 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 = []; + 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" + ) + ); + } + if (repeatedWordMatch) { + findings.unshift( + createFinding( + "style", + "medium", + "Repeated word", + repeatedWord(), + "Document" + ) + ); + } + 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 + }; +} +function analyzeStructure(blocks) { + const headings = getBlocksOfType(blocks, "heading"); + const bullets = getBlocksOfType(blocks, "list_item"); + const paragraphs = getBlocksOfType(blocks, "paragraph"); + const findings = []; + 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" + ) + ); + } + if (bullets.length >= 5 && headings.length === 0) { + findings.push( + createFinding( + "structure", + "medium", + "Break up the middle", + structureBreakMiddle(), + "Bullet list" + ) + ); + } + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "structure", + "high", + "Draft is too thin", + thinDraft(), + "Document" + ) + ); + } + 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) { + const findings = []; + 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 = []; + 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()); + } + return { + title: section.title, + kind: section.kind, + lineStart: section.lineStart, + lineEnd: section.lineEnd, + notes, + needsAttention + }; + }); + return { findings, sectionNotes }; +} +function buildOverview(findings, strengths, blocks, documentSections) { + 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} \u2014 ${finding.detail}`); + 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()); + } + return { + quickTake, + priorities, + strengths: strengths.length > 0 ? strengths : [DOCUMENT_LINTER_FALLBACK_STRENGTH] + }; +} +function computeOverallScore(scores) { + const weights = { + 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]; + }, 0); + return clampScore(Math.round(total)); +} +function restoreResultsPanelState(resultsContainer, scrollTop, focusKey) { + resultsContainer.scrollTop = scrollTop; + if (!focusKey) { + return; + } + if (typeof resultsContainer.querySelector !== "function") { + return; + } + const nextFocusTarget = resultsContainer.querySelector(`[data-linter-focus-key="${focusKey}"]`); + nextFocusTarget?.focus(); +} +function isElementLike(value) { + return typeof value === "object" && value !== null && "getAttribute" in value; +} +function analyzeDocumentText(text) { + 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); + 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 + }; +} +function buildDocumentLinterReport(text, analysis) { + 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", + 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}\u2013${section.lineEnd}`, + `- Needs attention: ${section.needsAttention ? "yes" : "no"}`, + ...section.notes.map((note) => `- ${note}`), + "" + ]), + "## Snapshot", + `- Words: ${wordCount}`, + `- Sentences: ${sentenceCount}`, + `- Blocks: ${blocks.length}` + ]; + return lines.join("\n"); +} +function createDocumentLinterController({ + els, + getEditorText, + onEditorContentReplaced: _onEditorContentReplaced, + showToast, + setStatus +}) { + let isPanelOpen = false; + let isAnalyzing = false; + let autoRunEnabled = false; + let lastAnalysis = null; + let lastTextSnapshot = ""; + async function runAnalysis(textSnapshot = getEditorText()) { + return Promise.resolve(analyzeDocumentText(textSnapshot)); + } + function scrollEditorToLine(lineNumber) { + 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); + } + function setPanelVisibility(isOpen) { + isPanelOpen = isOpen; + els.documentLinterPanel.hidden = !isOpen; + els.documentLinterToggleBtn.setAttribute("aria-expanded", String(isOpen)); + const split = els.documentLinterPanel.closest(".split"); + if (split) { + split.classList.toggle("with-document-linter", isOpen); + } + } + function updateResultsPanel(analysis) { + 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 = ""; + 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); + 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); + }); + 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)} \xB7 ${createLineButtonMarkup(section.lineStart, `Lines ${section.lineStart}\u2013${section.lineEnd}`)} \xB7 ${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); + } + function downloadMarkdownReport(markdown) { + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const dateStamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0]; + const fileName = `ink-linter-report-${dateStamp}.md`; + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + async function analyzeDocumentAction() { + if (isAnalyzing) { + return; + } + const text = getEditorText(); + if (!text.trim()) { + showToast("No content to analyze", { persist: true }); + setStatus("No content to analyze", "warn"); + return; + } + isAnalyzing = true; + lastTextSnapshot = text; + els.documentLinterAnalyzeBtn.disabled = true; + els.documentLinterStatus.textContent = "Analyzing document..."; + try { + const analysis = await runAnalysis(text); + lastAnalysis = analysis; + updateResultsPanel(analysis); + els.documentLinterStatus.textContent = "Analysis complete"; + 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; + } + } + async function exportSuggestionsAction() { + 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; + 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"); + } + } + async function handleEditorChanged(textSnapshot = getEditorText()) { + if (!isPanelOpen || !autoRunEnabled || isAnalyzing || !textSnapshot.trim()) { + if (!textSnapshot.trim() && autoRunEnabled && isPanelOpen) { + lastAnalysis = null; + lastTextSnapshot = textSnapshot; + els.documentLinterStatus.textContent = "No content to analyze"; + } + return Promise.resolve(); + } + if (textSnapshot === lastTextSnapshot) { + return Promise.resolve(); + } + return analyzeDocumentAction(); + } + els.documentLinterAutoRunToggle.checked = autoRunEnabled; + els.documentLinterAutoRunToggle.addEventListener("change", () => { + autoRunEnabled = els.documentLinterAutoRunToggle.checked; + els.documentLinterStatus.textContent = autoRunEnabled ? "Rerun on change enabled" : "Rerun on change disabled"; + }); + els.documentLinterResults.addEventListener("click", (event) => { + const target = event.target; + 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); + }); + setPanelVisibility(false); + return { + togglePanel: () => { + setPanelVisibility(!isPanelOpen); + if (isPanelOpen) { + els.documentLinterStatus.textContent = "Ready to analyze document"; + } + }, + setPanelOpen: (nextIsOpen) => { + setPanelVisibility(nextIsOpen); + if (nextIsOpen) { + els.documentLinterStatus.textContent = "Ready to analyze document"; + } + }, + isPanelOpen: () => isPanelOpen, + closePanel: () => { + setPanelVisibility(false); + }, + handleEditorChanged, + analyzeDocument: analyzeDocumentAction, + exportSuggestions: exportSuggestionsAction + }; +} +export { + analyzeDocumentText, + buildDocumentLinterReport, + buildDocumentSections, + countSentences, + createDocumentLinterController, + parseMarkdownBlocks, + splitSentences +}; diff --git a/dist/test/github.js b/dist/test/github.js new file mode 100644 index 0000000..569a871 --- /dev/null +++ b/dist/test/github.js @@ -0,0 +1,369 @@ +// src/auth/github.ts +var GITHUB_CLIENT_ID = "Ov23liedFdRiJdiifvVX"; +var GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; +var GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; +var STORAGE_KEY = "ink_github_token"; +var CODE_VERIFIER_KEY = "ink_github_code_verifier"; +var DEFAULT_POLL_INTERVAL_MS = 5e3; +var MAX_POLL_INTERVAL_MS = 6e4; +var BACKOFF_MULTIPLIER = 2; +var AuthErrorType = /* @__PURE__ */ ((AuthErrorType2) => { + AuthErrorType2["NetworkError"] = "network_error"; + AuthErrorType2["AccessDenied"] = "access_denied"; + AuthErrorType2["InvalidRequest"] = "invalid_request"; + AuthErrorType2["ServerError"] = "server_error"; + AuthErrorType2["InvalidState"] = "invalid_state"; + AuthErrorType2["Timeout"] = "timeout"; + AuthErrorType2["Unknown"] = "unknown"; + return AuthErrorType2; +})(AuthErrorType || {}); +var AuthError = class extends Error { + constructor(type, message, retryAfterMs) { + super(message); + this.type = type; + this.retryAfterMs = retryAfterMs; + this.name = "AuthError"; + } +}; +function generateCodeVerifier() { + const array = new Uint8Array(64); + crypto.getRandomValues(array); + const base64 = btoa(String.fromCharCode(...array)); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "").slice(0, 128); +} +async function generateCodeChallenge(verifier) { + 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, ""); +} +function calculateBackoff(attempt, baseIntervalMs = DEFAULT_POLL_INTERVAL_MS) { + const exponentialDelay = baseIntervalMs * Math.pow(BACKOFF_MULTIPLIER, attempt); + const jitter = Math.random() * 1e3; + return Math.min(exponentialDelay + jitter, MAX_POLL_INTERVAL_MS); +} +async function parseJsonResponse(response) { + const json = await response.json(); + return json; +} +function createAuthManager() { + const listeners = { + onStateChange: null, + onError: null, + onAuthStep: null + }; + let state = { + isAuthenticated: false, + isLoading: false, + error: null, + authStep: null + }; + let currentToken = null; + function emitStateChange() { + if (listeners.onStateChange) { + listeners.onStateChange({ ...state }); + } + } + function emitError(error) { + if (listeners.onError) { + listeners.onError(error); + } + } + function emitAuthStep(message) { + state.authStep = message; + if (listeners.onAuthStep) { + listeners.onAuthStep(message); + } + emitStateChange(); + } + function setState(updates) { + state = { ...state, ...updates }; + emitStateChange(); + } + function getStoredToken() { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + } + function storeToken(token) { + try { + localStorage.setItem(STORAGE_KEY, token); + currentToken = token; + } catch { + throw new AuthError( + "unknown" /* Unknown */, + "Failed to store token securely." + ); + } + } + function clearStoredToken() { + try { + localStorage.removeItem(STORAGE_KEY); + currentToken = null; + } catch { + } + } + function storeCodeVerifier(verifier) { + try { + sessionStorage.setItem(CODE_VERIFIER_KEY, verifier); + } catch { + throw new AuthError( + "unknown" /* Unknown */, + "Failed to store verification code." + ); + } + } + function getAndClearCodeVerifier() { + try { + const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY); + sessionStorage.removeItem(CODE_VERIFIER_KEY); + return verifier; + } catch { + return null; + } + } + async function buildAuthorizationUrl(codeChallenge) { + 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()}`); + } + function getRedirectUri() { + return window.location.origin + window.location.pathname; + } + function generateState() { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return Array.from(array).map((b) => b.toString(16).padStart(2, "0")).join(""); + } + async function startAuthFlow() { + setState({ isLoading: true, error: null }); + emitAuthStep("Preparing authentication..."); + try { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + storeCodeVerifier(codeVerifier); + const authUrl = await buildAuthorizationUrl(codeChallenge); + emitAuthStep("Redirecting to GitHub..."); + window.location.href = authUrl.toString(); + } catch (e) { + setState({ isLoading: false }); + if (e instanceof AuthError) { + emitError(e); + throw e; + } + const error = new AuthError( + "network_error" /* NetworkError */, + `Failed to start authentication: ${String(e)}` + ); + emitError(error); + throw error; + } + } + async function handleCallback() { + const url = new URL(window.location.href); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + if (error) { + if (error === "access_denied") { + const authError2 = new AuthError( + "access_denied" /* AccessDenied */, + "Authorization denied. Please try again." + ); + setState({ isLoading: false, error: authError2.message }); + emitError(authError2); + clearUrlParams(); + return false; + } + const authError = new AuthError( + "invalid_request" /* InvalidRequest */, + errorDescription || `Authentication error: ${error}` + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + const code = url.searchParams.get("code"); + if (!code) { + const authError = new AuthError( + "invalid_state" /* InvalidState */, + "Missing authorization code in callback." + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + const codeVerifier = getAndClearCodeVerifier(); + if (!codeVerifier) { + const authError = new AuthError( + "invalid_state" /* InvalidState */, + "Verification code expired. Please try again." + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + setState({ isLoading: true }); + emitAuthStep("Exchanging code for token..."); + try { + const token = await exchangeCodeForToken(code, codeVerifier); + 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( + "network_error" /* NetworkError */, + `Token exchange failed: ${String(e)}` + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + } + function clearUrlParams() { + const cleanUrl = window.location.pathname + window.location.hash; + window.history.replaceState({}, document.title, cleanUrl); + } + async function exchangeCodeForToken(code, codeVerifier) { + 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_verifier: codeVerifier, + grant_type: "authorization_code" + }) + }); + if (!response.ok) { + const authError = new AuthError( + "server_error" /* ServerError */, + `Token request failed: ${response.status}` + ); + throw authError; + } + const data = await parseJsonResponse(response); + if (data.error) { + if (data.error === "access_denied") { + throw new AuthError( + "access_denied" /* AccessDenied */, + "Authorization denied. Please try again." + ); + } + if (data.error === "expired_token") { + throw new AuthError( + "timeout" /* Timeout */, + "Authorization expired. Please try again." + ); + } + throw new AuthError( + "invalid_request" /* InvalidRequest */, + data.error_description || `Authentication error: ${data.error}` + ); + } + if (!data.access_token) { + throw new AuthError( + "server_error" /* ServerError */, + "No access token in response." + ); + } + return data.access_token; + } + function restoreSession() { + const token = getStoredToken(); + if (token) { + currentToken = token; + setState({ + isAuthenticated: true, + isLoading: false, + error: null, + authStep: null + }); + return true; + } + return false; + } + function logout() { + clearStoredToken(); + try { + sessionStorage.removeItem(CODE_VERIFIER_KEY); + } catch { + } + setState({ + isAuthenticated: false, + isLoading: false, + error: null, + authStep: null + }); + } + function getToken() { + return currentToken; + } + function isAuthenticated() { + return state.isAuthenticated; + } + function getState() { + return { ...state }; + } + function subscribe(listenersMap) { + if (listenersMap.onStateChange) { + listeners.onStateChange = listenersMap.onStateChange; + } + if (listenersMap.onError) { + listeners.onError = listenersMap.onError; + } + if (listenersMap.onAuthStep) { + listeners.onAuthStep = listenersMap.onAuthStep; + } + 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 { + AuthError, + AuthErrorType, + calculateBackoff, + createAuthManager, + generateCodeChallenge, + generateCodeVerifier +}; diff --git a/dist/test/user.js b/dist/test/user.js new file mode 100644 index 0000000..a51ffc0 --- /dev/null +++ b/dist/test/user.js @@ -0,0 +1,42 @@ +// src/auth/user.ts +var GITHUB_API_USER_URL = "https://api.github.com/user"; +function createUserManager() { + let cachedUser = null; + async function fetchUser(token) { + if (cachedUser) { + return cachedUser; + } + 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 = await response.json(); + cachedUser = { + login: data.login, + id: data.id, + avatarUrl: data.avatar_url, + name: data.name + }; + return cachedUser; + } + function clearCache() { + cachedUser = null; + } + function getCachedUser() { + return cachedUser; + } + return { + fetchUser, + clearCache, + getCachedUser + }; +} +export { + createUserManager +}; diff --git a/ink-app.html b/ink-app.html index 8eefa72..f8266e2 100644 --- a/ink-app.html +++ b/ink-app.html @@ -8,7 +8,7 @@ @@ -180,19 +180,31 @@ - - + + + @@ -251,16 +263,28 @@ No note open +
    + + + +
    +
    Ready
    -
    -
    +
    +
    -
    +
    -
    - + + +
    +
    @@ -354,86 +391,86 @@
    diff --git a/ink.template.html b/ink.template.html index 8d27bae..41c3703 100644 --- a/ink.template.html +++ b/ink.template.html @@ -180,19 +180,31 @@ - - + + + @@ -251,16 +263,28 @@ No note open +
    + + + +
    +
    Ready
    -
    -
    +
    +
    -
    +
    -
    - + + +
    +
    diff --git a/openspec/changes/add-document-linter/proposal.md b/openspec/changes/add-document-linter/proposal.md new file mode 100644 index 0000000..dc51cca --- /dev/null +++ b/openspec/changes/add-document-linter/proposal.md @@ -0,0 +1,24 @@ +# Change: Add Document Linter + +## 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. + +## 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 + +## 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) \ No newline at end of file diff --git a/openspec/changes/add-document-linter/specs/document-linter/spec.md b/openspec/changes/add-document-linter/specs/document-linter/spec.md new file mode 100644 index 0000000..b362036 --- /dev/null +++ b/openspec/changes/add-document-linter/specs/document-linter/spec.md @@ -0,0 +1,35 @@ +## 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 + +#### 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 + +#### 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 + +#### 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 \ No newline at end of file diff --git a/openspec/changes/add-document-linter/specs/quarto-linter/spec.md b/openspec/changes/add-document-linter/specs/quarto-linter/spec.md new file mode 100644 index 0000000..b362036 --- /dev/null +++ b/openspec/changes/add-document-linter/specs/quarto-linter/spec.md @@ -0,0 +1,35 @@ +## 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 + +#### 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 + +#### 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 + +#### 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 \ No newline at end of file diff --git a/openspec/changes/add-document-linter/tasks.md b/openspec/changes/add-document-linter/tasks.md new file mode 100644 index 0000000..e2c5fbd --- /dev/null +++ b/openspec/changes/add-document-linter/tasks.md @@ -0,0 +1,11 @@ +## 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 \ No newline at end of file diff --git a/openspec/changes/harden-document-linter/proposal.md b/openspec/changes/harden-document-linter/proposal.md new file mode 100644 index 0000000..791b364 --- /dev/null +++ b/openspec/changes/harden-document-linter/proposal.md @@ -0,0 +1,29 @@ +# Change: Harden Document Linter + +## 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: + +- 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. + +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. + +## 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. + +## 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 diff --git a/openspec/changes/harden-document-linter/tasks.md b/openspec/changes/harden-document-linter/tasks.md new file mode 100644 index 0000000..a1883da --- /dev/null +++ b/openspec/changes/harden-document-linter/tasks.md @@ -0,0 +1,33 @@ +## 1. Implementation + +## 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 + +## 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) + +## 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 diff --git a/openspec/changes/improve-document-linter-output/proposal.md b/openspec/changes/improve-document-linter-output/proposal.md new file mode 100644 index 0000000..cc56f00 --- /dev/null +++ b/openspec/changes/improve-document-linter-output/proposal.md @@ -0,0 +1,18 @@ +# Change: Improve Document Linter Output Quality + +## 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. + +## 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 + +## Impact +- Affected specs: document-linter +- Affected code: Document Linter analysis pipeline, markdown report builder, export output +- Runtime dependency: None +- Breaking changes: none diff --git a/openspec/changes/improve-document-linter-output/specs/document-linter/spec.md b/openspec/changes/improve-document-linter-output/specs/document-linter/spec.md new file mode 100644 index 0000000..477e450 --- /dev/null +++ b/openspec/changes/improve-document-linter-output/specs/document-linter/spec.md @@ -0,0 +1,39 @@ +## 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 + +#### 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 + +#### 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 + +#### 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 diff --git a/openspec/changes/improve-document-linter-output/tasks.md b/openspec/changes/improve-document-linter-output/tasks.md new file mode 100644 index 0000000..3c41e22 --- /dev/null +++ b/openspec/changes/improve-document-linter-output/tasks.md @@ -0,0 +1,6 @@ +## 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 diff --git a/package-lock.json b/package-lock.json index 67d1602..350371f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "cypress": "^15.12.0", "esbuild": "^0.27.3", "eslint": "^10.1.0", - "globals": "^17.4.0", + "globals": "^17.6.0", "http-server": "^14.1.1", "nyc": "^18.0.0", "qunit": "^2.24.2", @@ -5407,11 +5407,10 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, diff --git a/package.json b/package.json index 1584988..dbd455b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "cypress": "^15.12.0", "esbuild": "^0.27.3", "eslint": "^10.1.0", - "globals": "^17.4.0", + "globals": "^17.6.0", "http-server": "^14.1.1", "nyc": "^18.0.0", "qunit": "^2.24.2", diff --git a/repomix-output.txt b/repomix-output.txt new file mode 100644 index 0000000..1196685 --- /dev/null +++ b/repomix-output.txt @@ -0,0 +1,10574 @@ +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. + + + +The content is organized as follows: +1. This summary section +2. Repository information +3. Directory structure +4. Repository files (if enabled) +5. Multiple file entries, each consisting of: + - File path as an attribute + - Full contents of the file + + + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + + + +- Some files may have been excluded based on .gitignore rules and Repomix's configuration +- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files +- Files matching patterns in .gitignore are excluded +- Files matching default ignore patterns are excluded +- Files are sorted by Git change count (files with more changes are at the bottom) + + + + + +.claude/ + settings.local.json +.github/ + workflows/ + codeql.yml + pr-checks.yml + release.yml + copilot-instructions.md +assets/ + branding/ + favicon.svg + logo.svg +cypress/ + e2e/ + editor-flow.cy.js + menu-bar.cy.js + mobile-fallback.cy.js + removed-toolbar-buttons.cy.js + workspace-actions.cy.js + support/ + commands.ts + e2e.ts + tsconfig.json +openspec/ + changes/ + add-collapsible-left-menu/ + specs/ + editor-layout/ + spec.md + proposal.md + tasks.md + add-color-style-themes/ + specs/ + theming/ + spec.md + view-menu/ + spec.md + proposal.md + tasks.md + add-componentized-build-and-test-automation/ + specs/ + build-pipeline/ + spec.md + testing/ + spec.md + design.md + proposal.md + tasks.md + add-logo-readme-and-favicon/ + specs/ + branding-assets/ + spec.md + proposal.md + tasks.md + add-mobile-fallback/ + specs/ + mobile-support/ + spec.md + proposal.md + tasks.md + add-office-style-menu/ + specs/ + accessibility/ + spec.md + keyboard-shortcuts/ + spec.md + menu-bar/ + spec.md + design.md + proposal.md + tasks.md + fix-export-json-shortcut-precedence/ + specs/ + keyboard-shortcuts/ + spec.md + proposal.md + tasks.md + fix-mobile-sidebar-and-layout/ + specs/ + mobile-layout/ + spec.md + proposal.md + tasks.md + migrate-marked-to-npm-dependency/ + proposal.md + tasks.md + refactor-align-llm-coding-principles/ + specs/ + codebase-maintainability/ + spec.md + design.md + proposal.md + tasks.md + refactor-app-controller-modules/ + specs/ + codebase-maintainability/ + spec.md + design.md + proposal.md + tasks.md + refactor-simplification-plan/ + specs/ + codebase-maintainability/ + spec.md + plan.md + proposal.md + tasks.md + remove-redundant-toolbar-buttons/ + specs/ + ui-cleanup/ + spec.md + proposal.md + tasks.md + update-menu-shortcuts-platform-aware/ + specs/ + keyboard-shortcuts/ + spec.md + menu-bar/ + spec.md + proposal.md + tasks.md + docs/ + owasp-top10-2025/ + A01_2025-Broken_Access_Control.md + A02_2025-Security_Misconfiguration.md + A03_2025-Software_Supply_Chain_Failures.md + A04_2025-Cryptographic_Failures.md + A05_2025-Injection.md + A06_2025-Insecure_Design.md + A07_2025-Authentication_Failures.md + A08_2025-Software_or_Data_Integrity_Failures.md + A09_2025-Security_Logging_and_Alerting_Failures.md + A10_2025-Mishandling_of_Exceptional_Conditions.md + AGENTS.md + project.md +src/ + app/ + app-controller.ts + auto-refresh.ts + dom.ts + editor-preview.ts + fs-api.ts + menu-actions.ts + sidebar.ts + theme.ts + toast-status.ts + tree-render.ts + types.ts + ui-events.ts + utils.ts + workspace-io.ts + test-support/ + storage-fixture.ts + app.ts + styles.scss + tags.ts +tests/ + qunit/ + fs-api.test.js + mobile-fallback.test.js + storage.test.js + tags.test.js + utils.test.js +.gitignore +.nycrc +.replit +AGENTS.md +babel.config.json +CONTRIBUTING.md +cypress.config.mjs +FUNDING.yml +ink-app.html +ink.code-workspace +ink.template.html +LICENSE +Makefile +opencode.json +package.json +README.md +replit.md + + + +This section contains the contents of the repository's files. + + +{ + "permissions": { + "allow": [ + "Bash(npm install marked)", + "Bash(npm run build)", + "Bash(npm run test:qunit)", + "Bash(npm run test)", + "Bash(git stash)", + "Bash(npm run test:cypress)", + "Bash(git stash pop)", + "Bash(npm install --save-dev typescript)", + "Bash(git push origin feat/add-marked-as-library)", + "Bash(gh pr:*)" + ] + } +} + + + + + + + + + + + + + ink + + + + + + + + + + + + + + + ink + + + + + +describe("menu bar functionality", () => { + const workspaceName = "test-workspace"; + + 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, + }; + } + + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); + + win.prompt = (message) => { + if (message.includes("New note name")) { + return "test-note"; + } + if (message.includes("Folder name")) { + return "test-folder"; + } + return null; + }; + + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); + + 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"); + }); + + it("does not contain Import/Export menu", () => { + cy.get("#menuBar").should("not.contain", "Import/Export"); + }); + + 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"); + }); + }); + + describe("File menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("File").click(); + }); + + 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"); + }); + + it("New Folder menu item exists", () => { + cy.get('[data-action="new-folder"]').should("exist"); + }); + + it("Open Workspace menu item exists", () => { + cy.get('[data-action="open-workspace"]').should("exist"); + }); + + it("Close Workspace menu item exists", () => { + cy.get('[data-action="close-workspace"]').should("exist"); + }); + + it("Exit menu item exists", () => { + cy.get('[data-action="exit"]').should("exist"); + }); + }); + + describe("Edit menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("Edit").click(); + }); + + it("Save menu item exists", () => { + cy.get('[data-action="save"]').should("exist"); + }); + + it("Save As menu item exists", () => { + cy.get('[data-action="save-as"]').should("exist"); + }); + + it("Refresh menu item exists", () => { + cy.get('[data-action="refresh"]').should("exist"); + }); + + it("Sort menu item exists", () => { + cy.get('[data-action="sort"]').should("exist"); + }); + }); + + describe("View menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("View").click(); + }); + + 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(); + }); + + 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"); + }); + }); + + 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"); + }); + }); + }); +}); + + + +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 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; + }, + }; +} + +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); + }); + }); + + 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); + }); + }); + + 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); + }); + }); + + it("5.5 #dirtyDot IS present in ink-app.html after button removal", () => { + cy.get("#dirtyDot").should("exist"); + }); + + it("5.6 #statusBadge IS present in ink-app.html after button removal", () => { + cy.get("#statusBadge").should("exist"); + }); + }); + + 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"); + }); + + 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"); + }); + }); + }); +}); + + + +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("#refreshBtn").click(); + cy.get("#statusBadge").should("contain", "Refreshed"); + }); + + 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"); + }); +}); + + + +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts", "**/*.tsx"] +} + + + +## ADDED Requirements +### Requirement: User-Controlled Left Menu Visibility +The application SHALL provide a user-facing control to collapse and expand the left menu during an editing session. + +#### Scenario: User collapses left menu +- **WHEN** the user activates the menu toggle while the menu is expanded +- **THEN** the left menu transitions to a collapsed state +- **AND** the main editor area gains additional horizontal space + +#### Scenario: User expands left menu +- **WHEN** the user activates the menu toggle while the menu is collapsed +- **THEN** the left menu returns to an expanded state +- **AND** primary menu content becomes visible again + +### Requirement: Left Menu Toggle Accessibility +The left menu toggle SHALL be operable via keyboard and SHALL expose its current expanded/collapsed state to assistive technologies. + +#### Scenario: Keyboard operation +- **WHEN** a keyboard-only user focuses the menu toggle and activates it +- **THEN** the menu state changes between expanded and collapsed +- **AND** focus remains in a predictable location for continued interaction + +#### Scenario: Assistive state exposure +- **WHEN** the menu state changes through the toggle control +- **THEN** the control's accessibility state reflects whether the menu is expanded or collapsed + +### Requirement: Deterministic Initial Menu State +The application SHALL define a deterministic initial left menu state when a new session loads. + +#### Scenario: Initial layout state +- **WHEN** a user opens the application in a new session +- **THEN** the left menu starts in the documented default state +- **AND** the editor layout reflects that state without requiring user interaction + + + +# Change: Add User-Controlled Collapsible Left Menu + +## Why +The current left menu is fixed, which reduces usable editor space on smaller screens and during focused writing. Allowing users to collapse and expand the menu improves layout flexibility without removing existing navigation. + +## What Changes +- Add support for collapsing and expanding the left menu from within the UI. +- Add a persistent toggle control so users can switch menu state on demand. +- Update layout behavior so editor content reflows to use freed horizontal space when the menu is collapsed. +- Preserve keyboard and pointer accessibility for the menu toggle interaction. +- Define expected default menu behavior at app start. + +## Impact +- Affected specs: `editor-layout` +- Affected code: + - `ink.template.html` + - `src/app.ts` + - `src/styles.scss` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + + + +## 1. UI Controls and State +- [x] 1.1 Add a left menu toggle control that supports collapse and expand actions. +- [x] 1.2 Implement menu state management in the app so toggle actions update layout consistently. +- [x] 1.3 Define and implement the default menu state on initial load. + +## 2. Layout and Styling +- [x] 2.1 Update layout styles so collapsing the menu increases main editor horizontal space. +- [x] 2.2 Ensure expanded state preserves existing navigation usability and visual hierarchy. +- [x] 2.3 Ensure collapsed state keeps the toggle discoverable and usable. + +## 3. Accessibility and Interaction +- [x] 3.1 Ensure the toggle is keyboard-focusable and operable. +- [x] 3.2 Ensure toggle semantics expose menu state changes to assistive technologies. + +## 4. Verification +- [x] 4.1 Verify users can collapse and re-expand the menu in a modern browser. +- [x] 4.2 Verify editor content area width increases when the menu is collapsed. +- [x] 4.3 Verify keyboard-only users can operate the toggle and retain navigation control. +- [x] 4.4 Add a Cypress end-to-end test that validates left menu collapse/expand behavior and resulting layout changes. + + + +## ADDED Requirements + +### Requirement: CSS Custom Property Theme System +The application SHALL implement color themes using CSS custom properties, with each theme defined as a set of variable overrides on `:root[data-theme=""]`. + +#### Scenario: Default theme at initial load +- **WHEN** the application loads with no saved theme preference +- **THEN** no `data-theme` attribute is present on `` +- **AND** the default dark teal palette is applied via `:root` base variables + +#### Scenario: Named theme activation +- **WHEN** a named theme (e.g. `monokai`) is applied +- **THEN** `data-theme="monokai"` is set on `` +- **AND** the corresponding CSS variable overrides take effect immediately across all elements +- **AND** no page reload is required + +#### Scenario: Returning to default +- **WHEN** the user selects Default (Dark) after a named theme is active +- **THEN** the `data-theme` attribute is removed from `` +- **AND** the base `:root` variable definitions are restored + +### Requirement: Theme Persistence +The application SHALL persist the user's theme choice across sessions using `localStorage`. + +#### Scenario: Theme saved on selection +- **WHEN** the user selects a theme from the View menu +- **THEN** the theme identifier is written to `localStorage` under the key `ink-theme` + +#### Scenario: Theme restored on load +- **WHEN** the application initialises and a valid theme identifier exists in `localStorage` +- **THEN** that theme is applied before the first render +- **AND** the user sees their previously chosen colours immediately, without a flash of the default theme + +#### Scenario: Invalid or missing stored value +- **WHEN** the application initialises and `localStorage` contains no `ink-theme` key or an unrecognised value +- **THEN** the default dark theme is applied +- **AND** no error is shown to the user + +### Requirement: Available Colour Styles +The application SHALL provide the following seven colour styles, modeled on RStudio's built-in themes. + +| Theme | Character | +|-------|-----------| +| Default (Dark) | Dark teal, original ink palette | +| Classic | Light neutral white/grey, blue accent | +| Cobalt | Dark ocean blue, gold accent | +| Monokai | Dark charcoal, green/pink highlights | +| Office | Clean professional light, Microsoft blue | +| Twilight | Warm dark grey, amber accent | +| Xcode | Crisp Apple-style light, Xcode blue | + +#### Scenario: Each theme covers all required variables +- **WHEN** any named theme is active +- **THEN** all CSS custom properties (`--bg`, `--panel`, `--panel2`, `--border`, `--muted`, `--text`, `--accent`, `--danger`, `--ok`, `--warn`, `--shadow`, `--body-bg`) are defined +- **AND** no variable falls back to an unintended value from another theme + + + +## ADDED Requirements + +### Requirement: View Top-Level Menu +The application menu bar SHALL include a View top-level menu positioned between the Edit menu and the Import/Export menu. + +#### Scenario: View menu is visible in the menu bar +- **WHEN** the application loads +- **THEN** a "View" menu item is present in the menu bar +- **AND** it appears between "Edit" and "Import/Export" + +#### Scenario: View menu opens a dropdown +- **WHEN** the user clicks the View menu item +- **THEN** a dropdown appears listing all available colour styles +- **AND** the dropdown follows the same interaction model as File and Edit menus + +### Requirement: Colour Style Selection +The View menu dropdown SHALL list all available colour styles and allow the user to switch between them with a single click. + +#### Scenario: All themes listed +- **WHEN** the View dropdown is open +- **THEN** the following items are present in order: Default (Dark), [separator], Classic, Cobalt, Monokai, Office, Twilight, Xcode + +#### Scenario: Selecting a theme +- **WHEN** the user clicks a theme name in the View dropdown +- **THEN** the corresponding theme is applied immediately +- **AND** the dropdown closes +- **AND** the application retains full functionality under the new colour scheme + +### Requirement: Active Theme Indicator +The View menu SHALL display a checkmark (✓) next to the currently active theme. + +#### Scenario: Checkmark on active theme +- **WHEN** the View dropdown is open +- **THEN** a ✓ indicator is visible next to the currently active theme name +- **AND** all other theme entries show no checkmark + +#### Scenario: Checkmark updates on selection +- **WHEN** the user selects a different theme +- **THEN** the checkmark moves to the newly selected theme +- **AND** the previous theme entry no longer shows a checkmark + +### Requirement: Backward Compatibility +Adding the View menu SHALL not affect any existing menu, button, or keyboard shortcut behaviour. + +#### Scenario: Existing menus unaffected +- **WHEN** the View menu is present +- **THEN** File, Edit, and Import/Export menus continue to operate identically to their prior behaviour + +#### Scenario: Existing buttons unaffected +- **WHEN** a colour theme is active +- **THEN** all sidebar buttons, Save, JSON, and MD buttons continue to function correctly +- **AND** their visual style adapts to the active theme via CSS custom properties + + + +# Change: Add Color Style Themes + +## Why + +Ink currently uses a single fixed dark color scheme. Users have different preferences and work in different lighting environments, so a fixed style limits comfort and accessibility. Providing a set of well-known color themes — modeled on those available in RStudio — gives users immediate visual choices without requiring any configuration beyond a single menu click. + +## What Changes + +- Add a **View** top-level menu to the existing office-style menu bar, placed between Edit and Import/Export. +- The View menu lists seven color styles: Default (Dark), Classic, Cobalt, Monokai, Office, Twilight, and Xcode. +- Selecting a style applies it immediately by setting a `data-theme` attribute on the `` element. +- Each theme is defined as a set of CSS custom property overrides in `src/styles.scss` using `:root[data-theme=""]` selectors. +- The active theme is indicated by a checkmark (✓) next to the selected item in the menu. +- The selected theme is persisted to `localStorage` under the key `ink-theme` and restored on next load. +- The body background gradient is theme-aware via a `--body-bg` CSS custom property. + +## Impact + +- Affected specs: `theming`, `view-menu` +- Affected code: + - `ink.template.html` — View menu added + - `src/styles.scss` — theme CSS custom properties added, `--body-bg` variable introduced + - `src/app/bootstrap.ts` — `applyTheme()` and `loadTheme()` methods added; new `theme-*` action cases in `handleMenuAction()` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + - `ink-app.html` (rebuilt) + + + +## 1. CSS Theme System + +- [x] 1.1 Introduce `--body-bg` CSS custom property to allow per-theme control of the body background gradient. +- [x] 1.2 Define `Default (Dark)` base theme variables in `:root` (existing dark teal palette, refactored to use `--body-bg`). +- [x] 1.3 Define `Classic` theme — light neutral white/grey, blue accent (`#2c6fad`). +- [x] 1.4 Define `Cobalt` theme — dark ocean blue (`#001f3d`) with gold accent (`#ffd700`) and a blue radial gradient. +- [x] 1.5 Define `Monokai` theme — dark (`#272822`) with green accent (`#a6e22e`) and pink/green radial gradients. +- [x] 1.6 Define `Office` theme — clean professional light, Microsoft blue accent (`#0078d4`). +- [x] 1.7 Define `Twilight` theme — warm dark grey (`#141414`), amber accent (`#d4a96a`). +- [x] 1.8 Define `Xcode` theme — crisp light (`#f9f9f9`), Apple blue accent (`#0070c1`). +- [x] 1.9 Add `.menu-theme-check` CSS class for the active-theme checkmark indicator in the dropdown. + +## 2. View Menu (HTML Template) + +- [x] 2.1 Add a `View` menu item to `ink.template.html` between Edit and Import/Export. +- [x] 2.2 Add dropdown list items for all seven themes, each with `data-action="theme-"`. +- [x] 2.3 Add `` to each theme item for active-state indication. +- [x] 2.4 Include a separator between Default (Dark) and the six named themes. + +## 3. Theme Logic (TypeScript) + +- [x] 3.1 Add `VALID_THEMES` constant array listing all accepted theme identifiers. +- [x] 3.2 Implement `applyTheme(theme: string)` method that sets/removes the `data-theme` attribute on `document.documentElement`, saves to `localStorage`, and updates the checkmark indicator. +- [x] 3.3 Implement `loadTheme()` method that reads `localStorage` on startup and calls `applyTheme()` with the saved value (defaulting to `"default"` if absent or invalid). +- [x] 3.4 Call `loadTheme()` from `initialize()` before other UI setup. +- [x] 3.5 Add `theme-default`, `theme-classic`, `theme-cobalt`, `theme-monokai`, `theme-office`, `theme-twilight`, and `theme-xcode` cases to `handleMenuAction()`. + +## 4. Build and Verification + +- [x] 4.1 Run `npm run build` to recompile TypeScript, SCSS, and assemble `ink-app.html`. +- [x] 4.2 Verify the View menu appears in the menu bar between Edit and Import/Export. +- [x] 4.3 Verify each theme applies immediately on selection with correct colours. +- [x] 4.4 Verify the checkmark moves to the newly selected theme. +- [x] 4.5 Verify the selected theme persists across page reloads via `localStorage`. + +## 5. Documentation + +- [x] 5.1 Update `replit.md` with a Color Themes section describing each theme and the persistence mechanism. +- [x] 5.2 Create this openspec change request. + + + +## ADDED Requirements +### Requirement: Componentized Source Inputs +The project SHALL maintain separate source files for interaction logic, styling, and template markup. + +#### Scenario: Source layout follows project template +- **WHEN** a developer inspects the repository source layout +- **THEN** TypeScript interaction logic is located in `src/app.ts` +- **AND** SASS styling is located in `src/styles.scss` +- **AND** HTML template markup is maintained in a template source file used by the build pipeline + +### Requirement: Deterministic Build Outputs +The build pipeline SHALL compile TypeScript and SASS sources into deterministic distributable artifacts. + +#### Scenario: One-shot build compiles assets +- **WHEN** a developer runs the documented one-shot build command +- **THEN** the pipeline generates a JavaScript bundle at `dist/app.min.js` +- **AND** the pipeline generates compiled CSS at `dist/styles.min.css` + +### Requirement: Single-File Distribution Assembly +The build pipeline SHALL inject compiled JS and CSS artifacts into the HTML template to produce a single-file app output. + +#### Scenario: Build injects compiled assets into final HTML +- **WHEN** the injection step runs after asset compilation +- **THEN** the final HTML output contains inline CSS derived from `dist/styles.min.css` +- **AND** the final HTML output contains inline JavaScript derived from `dist/app.min.js` +- **AND** the resulting file is executable in a browser without network dependencies + +### Requirement: Incremental Developer Build Workflow +The project SHALL provide a watch-mode build workflow that rebuilds artifacts when source files change. + +#### Scenario: Watch mode rebuilds on source edits +- **WHEN** a developer runs the documented watch command and edits TypeScript, SASS, or template source files +- **THEN** relevant compiled artifacts are regenerated without requiring manual command re-entry + + + +## ADDED Requirements +### Requirement: QUnit Test Harness +The project SHALL provide a QUnit-based test harness and a documented command to run QUnit tests locally. + +#### Scenario: QUnit tests are executable from project scripts +- **WHEN** a developer runs the documented QUnit test command +- **THEN** the QUnit suite executes without requiring manual browser setup +- **AND** the command exits non-zero when any QUnit assertion fails + +### Requirement: Cypress End-to-End Test Harness +The project SHALL provide Cypress configuration and a documented command to run Cypress tests locally. + +#### Scenario: Cypress tests are executable from project scripts +- **WHEN** a developer runs the documented Cypress test command +- **THEN** Cypress launches against the application under test +- **AND** the command exits non-zero when any Cypress assertion fails + +### Requirement: Critical Authoring Flow Coverage +Automated tests SHALL validate the user workflow of selecting a workspace, creating a new file, entering markdown, and saving. + +#### Scenario: End-to-end flow is validated +- **WHEN** the Cypress workflow test runs +- **THEN** it selects a workspace through supported UI/test seam interactions +- **AND** it creates a new file +- **AND** it enters markdown content into the editor +- **AND** it triggers save +- **AND** it verifies that the saved content matches the entered markdown + +### Requirement: Fast Regression Coverage for Editor Logic +Automated tests SHALL include QUnit coverage for core editor interactions required by the authoring flow. + +#### Scenario: Editor behavior is validated by QUnit +- **WHEN** the QUnit suite executes editor interaction tests +- **THEN** tests verify at least initialization and markdown content mutation behavior used by the save flow + + + +## Context +Ink targets a single-file HTML distribution while development should remain modular and maintainable. The desired workflow requires clear source separation (TypeScript, SASS, HTML template), predictable build outputs, and automated tests that guard core document editing behavior. + +## Goals / Non-Goals +- Goals: + - Keep source-of-truth files separated by concern: logic, presentation, and template markup. + - Preserve single-file distributable output for runtime usage. + - Provide reliable local build and test commands. + - Cover the critical authoring flow with both fast in-browser tests (QUnit) and browser-driven tests (Cypress). +- Non-Goals: + - Migrating to a framework (React/Vue/etc.). + - Introducing a backend service. + - Redesigning editor UX beyond what is required for testability and flow support. + +## Decisions +- Decision: Use TypeScript compilation via existing `esbuild` and SASS compilation via `sass`, with a final injection step into a template file. + - Rationale: aligns with current lightweight toolchain and single-file output constraint. +- Decision: Expose build commands through `npm scripts` and keep `Makefile` as optional convenience wrapper. + - Rationale: `npm scripts` are portable and integrate well with QUnit/Cypress commands. +- Decision: Add QUnit tests for app-level behaviors that do not require full browser orchestration. + - Rationale: fast feedback for logic and DOM interactions. +- Decision: Add Cypress tests for full user workflow and persistence interactions. + - Rationale: validates user-visible behavior across realistic browser execution. + +## Risks / Trade-offs +- Tooling overhead increases with dual test frameworks. + - Mitigation: keep QUnit focused on fast logic checks; reserve Cypress for key end-to-end flows. +- Workspace/file APIs can be difficult to test directly in browsers. + - Mitigation: define test seams and controlled fixtures/stubs for deterministic execution. + +## Migration Plan +1. Introduce source file structure and build scripts aligned with README conventions. +2. Ensure one-shot and watch build commands generate expected dist artifacts and single-file output. +3. Add QUnit harness and baseline test suite. +4. Add Cypress project config and e2e scenario for workspace→new file→edit→save. +5. Update documentation with developer commands and required prerequisites. + +## Open Questions +- Should Cypress execute against a local static server command defined in `package.json`, or an externally started server? Local static server +- What test seam should be canonical for workspace selection in Cypress (UI picker vs injected test fixture API)? UI picker + + + +# Change: Componentized Build Pipeline and Automated Test Coverage + +## Why +The current project state does not provide a stable, repeatable workflow for maintaining separate HTML, TypeScript, and SASS sources, and it lacks automated regression tests for critical editor behavior. This makes changes risky and manual verification time-consuming. + +## What Changes +- Define a componentized source layout that keeps interaction logic in TypeScript, styles in SASS, and UI structure in an HTML template. +- Define a deterministic build pipeline that compiles TypeScript and SASS into distributable assets and injects them into the single-file app output. +- Define watch-mode and one-shot build commands so developers can rebuild the app whenever files change. +- Add QUnit unit/integration coverage for core application behavior. +- Add Cypress end-to-end coverage for the user flow: selecting a workspace, creating a new file, adding markdown content, and saving. +- Define minimum acceptance checks for local validation before release. + +## Impact +- Affected specs: `build-pipeline`, `testing` +- Affected code: + - `package.json` + - `Makefile` + - `src/app.ts` + - `src/styles.scss` + - `ink.template.html` (or current HTML template source) + - `build/inject.js` + - `dist/` build outputs + - `tests/qunit/` (new) + - `cypress/` (new) + + + +## 1. Implementation +- [x] 1.1 Align source structure with README template: `src/app.ts`, `src/styles.scss`, and HTML template input. +- [x] 1.2 Implement build scripts to compile TypeScript and SASS into `dist/app.min.js` and `dist/styles.min.css`. +- [x] 1.3 Implement template injection so compiled CSS/JS are embedded into the final single-file app output. +- [x] 1.4 Add build commands for one-shot build and watch mode to support rebuilding after file changes. +- [x] 1.5 Update developer documentation for build and run workflows. + +## 2. Testing +- [x] 2.1 Add QUnit test harness and configure command(s) to run tests locally. +- [x] 2.2 Add QUnit tests for core interaction logic and markdown editing behavior. +- [x] 2.3 Add Cypress configuration and command(s) to run end-to-end tests locally. +- [x] 2.4 Add Cypress test that covers selecting a workspace, creating a new file, entering markdown content, and saving. +- [x] 2.5 Add test data/setup hooks needed to execute the flow deterministically. + +## 3. Validation +- [x] 3.1 Verify build output artifacts are generated as specified. +- [x] 3.2 Verify QUnit and Cypress suites pass from documented commands. +- [x] 3.3 Confirm final app output remains a single self-contained HTML file. + + + +## ADDED Requirements +### Requirement: Canonical Project Logo Asset +The repository SHALL store a canonical project logo source file that is used for documentation and favicon generation. + +#### Scenario: Canonical logo source is available +- **WHEN** a maintainer inspects branding files in the repository +- **THEN** a single canonical logo source exists in a stable project path +- **AND** the source is suitable for deriving README and favicon assets + +### Requirement: README Logo Rendering +The project documentation SHALL render the project logo in `README.md` using repository-relative asset references. + +#### Scenario: README displays logo +- **WHEN** `README.md` is rendered by a GitHub-compatible markdown renderer +- **THEN** the logo is visible near the document header +- **AND** the logo reference resolves without external network dependencies + +### Requirement: Favicon Assets Derived from Logo +The project SHALL provide favicon assets derived from the canonical logo source. + +#### Scenario: Favicon assets are generated and available +- **WHEN** the favicon generation workflow is run +- **THEN** favicon files are produced in the documented output location +- **AND** generated filenames match those referenced by the application template + +### Requirement: Application Template Favicon References +The application HTML template SHALL reference project favicon assets so browser tabs display the project icon. + +#### Scenario: Browser tab uses project favicon +- **WHEN** a user opens the built application in a modern browser +- **THEN** the browser resolves favicon references from the application output +- **AND** the tab icon reflects the project logo branding + + + +# Change: Add Project Logo to README and Favicon Assets + +## Why +The repository currently lacks consistent branding assets in project documentation and browser metadata. Adding the provided logo to `README.md` and defining favicon outputs improves recognizability and presentation. + +## What Changes +- Add a canonical logo asset in the repository based on `~/Downloads/ink_logo.svg`. +- Update `README.md` to render the project logo near the top of the document. +- Define and implement favicon outputs derived from the same logo source. +- Wire favicon references into the app HTML template so browser tabs use project branding. +- Document the asset location and favicon generation/update workflow. + +## Impact +- Affected specs: `branding-assets` +- Affected code: + - `README.md` + - `ink.template.html` + - `build/` scripts (if favicon generation is automated in build) + - `dist/` favicon artifacts + - `assets/` branding source files (new) + + + +## 1. Asset Setup +- [x] 1.1 Add the provided logo SVG to a canonical repository path for shared branding usage. +- [x] 1.2 Define favicon output formats and filenames derived from the canonical logo source. + +## 2. Documentation and Template Integration +- [x] 2.1 Update `README.md` to display the project logo with stable relative linking. +- [x] 2.2 Update the HTML template to include favicon references that resolve in the built output. + +## 3. Build and Distribution +- [x] 3.1 Implement or document the process to generate favicon artifacts from the SVG source. +- [x] 3.2 Ensure generated favicon assets are available in expected output locations. + +## 4. Validation +- [x] 4.1 Verify `README.md` renders the logo correctly on GitHub-compatible markdown viewers. +- [x] 4.2 Verify the built app/tab displays the new favicon in a modern browser. +- [x] 4.3 Verify documentation describes how to refresh logo-derived assets when the SVG changes. + + + +## ADDED Requirements + +### Requirement: Mobile Browser Detection +The system SHALL detect when the browser does not support the File System Access API. + +#### Scenario: Browser lacks File System Access API +- **WHEN** the user opens ink on a browser without `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL detect this and enable in-memory workspace mode +- **AND** the system SHALL NOT throw an error blocking app usage + +#### Scenario: Browser supports File System Access API +- **WHEN** the user opens ink on a browser with `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL allow opening a real folder as usual +- **AND** no in-memory mode SHALL be activated + +### Requirement: In-Memory Workspace Mode +The system SHALL provide a temporary in-memory workspace when FS API is unavailable. + +#### Scenario: User creates note in temporary session +- **WHEN** the user clicks "New Note" in temporary session mode +- **AND** enters a note name +- **THEN** a new note SHALL be created in memory +- **AND** the user CAN edit the note content +- **AND** the user CAN save (persist to memory only) + +#### Scenario: User edits note in temporary session +- **WHEN** the user modifies the editor content +- **AND** the note has unsaved changes +- **THEN** the dirty indicator SHALL show unsaved status +- **AND** the user CAN click Save to persist changes to memory + +### Requirement: Export as JSON +The system SHALL provide a way to download all notes as a JSON file. + +#### Scenario: User exports all notes as JSON +- **WHEN** the user clicks "Export JSON" button +- **THEN** the browser SHALL download a file named `ink-export-YYYY-MM-DD.json` +- **AND** the file SHALL contain a JSON object with notes array +- **AND** each note SHALL have `name`, `path`, and `content` fields + +#### Scenario: JSON export contains multiple notes +- **WHEN** the user has created multiple notes in temporary session +- **AND** clicks Export JSON +- **THEN** the exported JSON SHALL include all notes +- **AND** the order SHALL be preserved + +### Requirement: Export as Markdown +The system SHALL provide a way to download individual notes as .md files. + +#### Scenario: User exports current note as Markdown +- **WHEN** the user clicks "Export Markdown" button while a note is open +- **THEN** the browser SHALL download a file with the note's filename +- **AND** the file SHALL contain the note's raw markdown content +- **AND** the file SHALL have `.md` extension + +### Requirement: Temporary Session UI Indicator +The system SHALL display a clear indicator when operating in temporary session mode. + +#### Scenario: Temporary session is active +- **WHEN** the app is running in temporary session mode +- **THEN** a visual indicator SHALL be displayed showing "Temporary Session" +- **AND** the indicator SHALL inform users their data is not persisted +- **AND** export buttons SHALL be prominently visible + +#### Scenario: No indicator shown in normal mode +- **WHEN** the app is running with real file system access +- **THEN** no temporary session indicator SHALL be displayed +- **AND** export buttons MAY be hidden or disabled + + + +# Change: Add Mobile Fallback Support for Browsers Without File System Access API + +## Why +Mobile browsers (e.g., Safari on iOS) do not support the File System Access API. Currently, ink shows an error message and prevents users from using the app. This excludes mobile users entirely. Instead, we should allow temporary in-memory work and provide export capabilities so users can download their notes. + +## What Changes +- Detect when File System Access API is unavailable +- Enable in-memory workspace mode for temporary editing +- Add "Export as JSON" button to download all notes as a JSON file +- Add "Export as Markdown" button to download individual notes as .md files +- Show informative UI about temporary nature of the session +- Preserve existing functionality for desktop browsers with FS API support + +## Impact +- Affected specs: mobile-support (new capability) +- Affected code: src/app/bootstrap.ts, src/app/fs-api.ts, ink-app.html, styles +- No breaking changes to existing desktop functionality + + + +## ADDED Requirements + +### Requirement: ARIA Support for Menu Bar +The menu bar SHALL implement proper ARIA attributes to ensure accessibility for screen readers and assistive technologies. + +#### Scenario: Menu bar has proper ARIA role +- **WHEN** a screen reader encounters the menu bar +- **THEN** the menu bar has role="menubar" attribute +- **AND** screen readers announce it as a menu bar + +#### Scenario: Menu items have proper ARIA attributes +- **WHEN** a screen reader encounters menu items +- **THEN** each menu item has role="menuitem" attribute +- **AND** menu items with dropdowns have aria-haspopup="true" +- **AND** aria-expanded indicates dropdown state + +#### Scenario: Dropdown menus have proper ARIA attributes +- **WHEN** a dropdown menu is opened +- **THEN** the dropdown has role="menu" attribute +- **AND** each dropdown item has role="menuitem" attribute +- **AND** aria-expanded="true" on the parent menu item + +### Requirement: Keyboard Accessibility +The menu bar SHALL support full keyboard navigation for users who cannot use a mouse. + +#### Scenario: Tab navigation support +- **WHEN** a user navigates using Tab key +- **THEN** focus moves to menu bar items in logical order +- **AND** focused items are visually indicated + +#### Scenario: Arrow key navigation +- **WHEN** a user navigates using arrow keys +- **THEN** Left/Right arrows move between top-level menu items +- **AND** Up/Down arrows move between dropdown menu items +- **AND** Home/End keys move to first/last items + +#### Scenario: Enter and Space key activation +- **WHEN** a user presses Enter or Space on a focused menu item +- **THEN** the menu item is activated +- **AND** dropdowns open or actions are executed as appropriate + +#### Scenario: Escape key handling +- **WHEN** a user presses Escape key +- **THEN** open dropdowns are closed +- **AND** focus returns to the parent menu item +- **AND** no other application functionality is affected + +### Requirement: Visual Focus Indicators +The menu bar SHALL provide clear visual indicators for keyboard focus and active states. + +#### Scenario: Focus indicators for menu items +- **WHEN** a menu item receives keyboard focus +- **THEN** a clear visual focus indicator is displayed +- **AND** the focus indicator meets WCAG contrast requirements + +#### Scenario: Active state indicators +- **WHEN** a menu item is activated or a dropdown is open +- **THEN** clear visual indicators show the active state +- **AND** the indicators are distinguishable from focus indicators + +#### Scenario: Hover state indicators +- **WHEN** a user hovers over menu items with a mouse +- **THEN** visual indicators show hover state +- **AND** hover indicators are consistent with focus indicators + +### Requirement: Screen Reader Announcements +The menu bar SHALL provide appropriate announcements for screen reader users. + +#### Scenario: Menu item descriptions +- **WHEN** a screen reader focuses on a menu item +- **THEN** the item's purpose is clearly announced +- **AND** any associated keyboard shortcuts are announced + +#### Scenario: Dropdown state announcements +- **WHEN** a dropdown menu state changes +- **THEN** screen readers announce the state change +- **AND** aria-expanded attribute is updated appropriately + +#### Scenario: Menu navigation announcements +- **WHEN** a user navigates between menu items +- **THEN** screen readers announce the current position +- **AND** the total number of items is announced when appropriate + +### Requirement: High Contrast and Zoom Support +The menu bar SHALL support high contrast modes and browser zoom functionality. + +#### Scenario: High contrast mode support +- **WHEN** high contrast mode is enabled +- **THEN** menu bar maintains proper contrast ratios +- **AND** all interactive elements remain visible and usable + +#### Scenario: Browser zoom support +- **WHEN** browser zoom is applied +- **THEN** menu bar layout adjusts appropriately +- **AND** no content is clipped or overlapping +- **AND** all functionality remains accessible + +### Requirement: Error Handling and Feedback +The menu bar SHALL provide appropriate feedback for accessibility-related errors. + +#### Scenario: Disabled menu item feedback +- **WHEN** a user attempts to activate a disabled menu item +- **THEN** appropriate feedback is provided +- **AND** screen readers announce the item as disabled + +#### Scenario: Invalid keyboard input handling +- **WHEN** a user provides invalid keyboard input +- **THEN** the application handles it gracefully +- **AND** no unexpected behavior occurs + + + +## ADDED Requirements + +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. + +#### Scenario: New Note shortcut +- **WHEN** a user presses Ctrl+E (Windows/Linux) or Cmd+E (Mac) +- **THEN** the createNewNote functionality is triggered +- **AND** the same behavior as clicking "New Note" menu item occurs + +#### Scenario: Open Workspace shortcut +- **WHEN** a user presses Ctrl+Shift+O (Windows/Linux) or Cmd+Shift+O (Mac) +- **THEN** the openWorkspace functionality is triggered +- **AND** the same behavior as clicking "Open Workspace" menu item occurs + +#### Scenario: Save shortcut +- **WHEN** a user presses Ctrl+S (Windows/Linux) or Cmd+S (Mac) +- **THEN** the saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking "Save" menu item occurs + +#### Scenario: Refresh shortcut +- **WHEN** a user presses Ctrl+L (Windows/Linux) or Cmd+L (Mac) +- **THEN** the rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking "Refresh" menu item occurs + +#### Scenario: Export JSON shortcut +- **WHEN** a user presses Ctrl+Shift+S (Windows/Linux) or Cmd+Shift+S (Mac) +- **THEN** the exportAsJson functionality is triggered +- **AND** the same behavior as clicking "Export JSON" menu item occurs + +#### Scenario: Export Markdown shortcut +- **WHEN** a user presses Ctrl+Shift+M (Windows/Linux) or Cmd+Shift+M (Mac) +- **THEN** the exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking "Export Markdown" menu item occurs + +### Requirement: Keyboard Navigation of Menu Bar +The menu bar SHALL support full keyboard navigation using standard accessibility patterns. + +#### Scenario: Tab navigation to menu bar +- **WHEN** a user presses Tab to navigate to the menu bar +- **THEN** focus moves to the first menu item +- **AND** the menu item is visually indicated as focused + +#### Scenario: Arrow key navigation between menus +- **WHEN** a user presses Left/Right arrow keys while focused on menu bar +- **THEN** focus moves between menu items (File, Edit, Import/Export) +- **AND** the focused menu item is visually indicated + +#### Scenario: Enter key to open dropdown +- **WHEN** a user presses Enter while focused on a menu item +- **THEN** the dropdown menu opens +- **AND** focus moves to the first menu item in the dropdown + +#### Scenario: Escape key to close dropdowns +- **WHEN** a user presses Escape while a dropdown is open +- **THEN** the dropdown closes +- **AND** focus returns to the parent menu item + +#### Scenario: Up/Down arrow navigation in dropdowns +- **WHEN** a user presses Up/Down arrow keys while focused on a dropdown menu +- **THEN** focus moves between menu items in the dropdown +- **AND** the focused item is visually indicated + +### Requirement: Shortcut Display in Menu Items +Menu items with keyboard shortcuts SHALL display the shortcut keys for discoverability. + +#### Scenario: Shortcut key display +- **WHEN** a user views the menu bar +- **THEN** menu items that have keyboard shortcuts display the shortcut keys +- **AND** shortcut keys are displayed in a consistent format (e.g., "Ctrl+N") + +#### Scenario: Platform-appropriate shortcut display +- **WHEN** the application runs on different platforms +- **THEN** shortcut keys are displayed using platform-appropriate modifiers +- **AND** Windows/Linux shows "Ctrl", Mac shows "Cmd" + +### Requirement: Shortcut Key Conflict Resolution +The application SHALL handle keyboard shortcut conflicts appropriately. + +#### Scenario: Browser shortcut precedence +- **WHEN** a user presses a keyboard shortcut that conflicts with browser functionality +- **THEN** the application prevents the default browser behavior +- **AND** the application's shortcut functionality is executed instead + +#### Scenario: Focus-based shortcut activation +- **WHEN** keyboard shortcuts are pressed +- **THEN** shortcuts are only active when the application has focus +- **AND** shortcuts do not interfere with other applications + + + +## ADDED Requirements + +### Requirement: Menu Bar Structure +The application SHALL provide a horizontal menu bar at the top of the application window containing File, Edit, and Import/Export menu items. + +#### Scenario: Menu bar is visible and accessible +- **WHEN** the application loads +- **THEN** a horizontal menu bar is displayed at the top of the application window +- **AND** the menu bar contains File, Edit, and Import/Export menu items + +#### Scenario: 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 + +### Requirement: File Menu Functionality +The File menu SHALL provide access to workspace and file management 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 + +#### 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 + +### Requirement: Edit Menu Functionality +The Edit menu SHALL provide access to document editing and view operations. + +#### Scenario: Save menu item +- **WHEN** a user clicks "Save" in the Edit menu +- **THEN** the existing saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking the "Save" button occurs + +#### Scenario: Refresh menu item +- **WHEN** a user clicks "Refresh" in the Edit menu +- **THEN** the existing rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking the "Refresh" button occurs + +#### Scenario: Sort toggle menu item +- **WHEN** a user clicks "Sort: Name/Modified" in the Edit menu +- **THEN** the existing sort functionality is triggered +- **AND** the same behavior as clicking the "Sort" button occurs + +#### Scenario: Collapse Sidebar menu item +- **WHEN** a user clicks "Collapse Sidebar" in the Edit menu +- **THEN** the existing setSidebarCollapsed functionality is triggered +- **AND** the same behavior as clicking the sidebar toggle button occurs + +### Requirement: Import/Export Menu Functionality +The Import/Export menu SHALL provide access to data export operations. + +#### Scenario: Export JSON menu item +- **WHEN** a user clicks "Export JSON" in the Import/Export menu +- **THEN** the existing exportAsJson functionality is triggered +- **AND** the same behavior as clicking the "Export JSON" button occurs + +#### Scenario: Export Markdown menu item +- **WHEN** a user clicks "Export Markdown" in the Import/Export menu +- **THEN** the existing exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking the "Export MD" button occurs + +### Requirement: Menu Dropdown Behavior +Menu dropdowns SHALL open and close appropriately with proper keyboard and mouse interaction. + +#### Scenario: Mouse interaction with dropdowns +- **WHEN** a user hovers over or clicks a menu item +- **THEN** the dropdown menu opens +- **AND** clicking outside the menu closes it + +#### Scenario: Keyboard navigation of menus +- **WHEN** a user navigates using arrow keys +- **THEN** focus moves between menu items +- **AND** pressing Enter activates the focused menu item +- **AND** pressing Escape closes open dropdowns + +### Requirement: Backward Compatibility +The menu bar SHALL not interfere with existing button functionality. + +#### Scenario: Buttons continue to work +- **WHEN** a user clicks existing buttons +- **THEN** the same functionality is triggered as before +- **AND** menu items provide alternative access to the same functions + +#### Scenario: Menu and button state synchronization +- **WHEN** a menu item is clicked +- **THEN** any related button states are updated appropriately +- **AND** the application maintains consistent state across all interfaces + + + +# Design: Office-Style Menu Bar Implementation + +## Context + +The ink markdown note-taking application needs a proper office-style menu bar to improve user experience and provide familiar desktop application patterns. The application currently has a functional but button-heavy interface. The menu bar should integrate seamlessly with existing functionality while adding discoverability and keyboard shortcuts. + +## Goals / Non-Goals + +### Goals +- Provide familiar File, Edit, and Import/Export menu structure +- Add keyboard shortcuts for common operations +- Maintain full backward compatibility with existing buttons +- Ensure accessibility with proper ARIA attributes +- Keep implementation simple and maintainable +- Follow existing code patterns and architecture + +### Non-Goals +- Replace existing button interface (menus complement, don't replace) +- Add complex menu features like toolbars or ribbons +- Implement undo/redo functionality (not currently in scope) +- Add theming or customization options +- Support for nested submenus beyond basic dropdowns + +## Decisions + +### Menu Structure Decision +**Decision**: Use a horizontal menu bar with dropdown menus for File, Edit, and Import/Export sections. + +**Rationale**: This follows standard desktop application patterns and provides clear organization of functionality. The horizontal layout works well with the existing sidebar layout. + +**Alternatives considered**: +- Contextual menus only: Rejected because it reduces discoverability +- Vertical sidebar menu: Rejected because it conflicts with existing workspace sidebar +- Hybrid approach: Rejected for complexity + +### Keyboard Shortcuts Decision +**Decision**: Implement standard desktop application shortcuts: +- Ctrl/Cmd+N: New Note +- Ctrl/Cmd+O: Open Workspace +- Ctrl/Cmd+S: Save +- F5: Refresh +- Ctrl/Cmd+Shift+S: Export JSON +- Ctrl/Cmd+Shift+M: Export Markdown + +**Rationale**: These follow established conventions from applications like VS Code, Notepad++, and other desktop editors. + +**Alternatives considered**: +- Custom shortcuts: Rejected for discoverability +- No shortcuts: Rejected because power users expect them + +### Integration Approach Decision +**Decision**: Extend existing InkApp class methods rather than creating new menu-specific functions. + +**Rationale**: This maintains code reuse and ensures consistent behavior between button clicks and menu selections. The existing methods already handle edge cases and error conditions properly. + +**Alternatives considered**: +- Separate menu handlers: Rejected because it would duplicate logic +- Event delegation: Rejected because direct method calls are simpler + +### Accessibility Decision +**Decision**: Implement full ARIA support with proper roles, labels, and keyboard navigation. + +**Rationale**: Essential for screen reader users and keyboard-only navigation. Follows WCAG guidelines for menu widgets. + +**Alternatives considered**: +- Basic accessibility: Rejected because it doesn't meet modern standards +- No accessibility: Rejected as unacceptable + +## Risks / Trade-offs + +### Risk: Layout Complexity +**Risk**: Adding menu bar could complicate the existing layout and cause responsive design issues. + +**Mitigation**: +- Use CSS Grid/Flexbox for flexible layout +- Test thoroughly on different screen sizes +- Maintain existing responsive behavior + +### Risk: Keyboard Shortcut Conflicts +**Risk**: New shortcuts might conflict with browser or OS shortcuts. + +**Mitigation**: +- Use standard shortcuts that don't conflict +- Add proper event.preventDefault() handling +- Test on different platforms + +### Risk: Performance Impact +**Risk**: Additional DOM elements and event listeners could impact performance. + +**Mitigation**: +- Use event delegation where possible +- Minimize DOM manipulation +- Follow existing performance patterns + +## Migration Plan + +### Phase 1: Core Menu Structure +1. Add HTML structure and basic CSS +2. Implement menu toggle functionality +3. Add basic keyboard navigation + +### Phase 2: Menu Item Implementation +1. Implement File menu items +2. Implement Edit menu items +3. Implement Import/Export menu items + +### Phase 3: Polish and Testing +1. Add comprehensive tests +2. Implement accessibility features +3. Add keyboard shortcuts +4. Performance optimization + +### Rollback Plan +If issues arise, the menu bar can be easily disabled by: +1. Removing menu HTML structure +2. Removing menu-specific CSS +3. Removing menu event handlers +4. All existing functionality remains intact + +## Open Questions + +1. **Should we add a Help menu?** - Currently not planned, but could be added later +2. **Should menu items be disabled when not applicable?** - Yes, following existing button patterns +3. **Should we support right-click context menus?** - Not in initial implementation, could be added later +4. **Should we add menu animations?** - No, keeping it simple and fast + + + +# Change: Add Office-Style Menu Bar + +## Why + +The ink markdown note-taking application currently lacks a proper office-style menu bar (File, Edit, Import/Export) that users expect from desktop applications. This makes common operations like opening workspaces, creating files/folders, and exporting content less discoverable and accessible. Adding a menu bar will improve user experience by providing familiar navigation patterns and keyboard shortcuts. + +## What Changes + +- **BREAKING**: Add a new menu bar component with File, Edit, and Import/Export sections +- Integrate existing functionality (open workspace, create files/folders, export) into menu items +- Add keyboard shortcuts for common operations (Ctrl/Cmd+N, Ctrl/Cmd+O, Ctrl/Cmd+S, etc.) +- Maintain existing button functionality while adding menu alternatives +- Add accessibility attributes and proper ARIA labeling +- Update UI layout to accommodate menu bar + +## Impact + +- Affected specs: `menu-bar`, `keyboard-shortcuts`, `accessibility` +- Affected code: `src/app/bootstrap.ts`, `src/app/dom.ts`, `src/app/types.ts`, `ink-app.html` +- New files: Menu component implementation, CSS styles, comprehensive tests +- User experience: Improved discoverability of features, familiar desktop application patterns +- Backward compatibility: All existing functionality preserved, menu adds new access points + + + +# 1. Implementation + +## 1.1 Menu Bar Structure +- [x] 1.1.1 Add menu bar HTML structure to ink-app.html +- [x] 1.1.2 Create CSS styles for menu bar layout and dropdowns +- [x] 1.1.3 Add ARIA attributes for accessibility (role="menubar", aria-haspopup, etc.) + +## 1.2 File Menu Implementation +- [x] 1.2.1 Implement "New Note" menu item (Ctrl/Cmd+N) +- [x] 1.2.2 Implement "New Folder" menu item +- [x] 1.2.3 Implement "Open Workspace" menu item (Ctrl/Cmd+O) +- [x] 1.2.4 Implement "Close Workspace" menu item +- [x] 1.2.5 Implement "Exit" menu item + +## 1.3 Edit Menu Implementation +- [x] 1.3.1 Implement "Save" menu item (Ctrl/Cmd+S) +- [x] 1.3.2 Implement "Save As" menu item +- [x] 1.3.3 Implement "Refresh" menu item (F5) +- [x] 1.3.4 Implement "Sort" toggle menu item +- [x] 1.3.5 Implement "Collapse Sidebar" menu item + +## 1.4 Import/Export Menu Implementation +- [x] 1.4.1 Implement "Export JSON" menu item +- [x] 1.4.2 Implement "Export Markdown" menu item +- [ ] 1.4.3 Implement "Import JSON" menu item (if needed) + +## 1.5 Keyboard Shortcuts +- [x] 1.5.1 Add global keyboard event listener for menu shortcuts +- [x] 1.5.2 Implement Ctrl/Cmd+E for new note +- [x] 1.5.3 Implement Ctrl/Cmd+Shift+O for open workspace +- [x] 1.5.4 Implement Ctrl/Cmd+S for save +- [x] 1.5.5 Implement Ctrl/Cmd+L for refresh +- [x] 1.5.6 Add visual indicators for keyboard shortcuts in menu items + +## 1.6 Integration with Existing Code +- [x] 1.6.1 Update InkApp class to handle menu events +- [x] 1.6.2 Update DOM references in dom.ts to include menu elements +- [x] 1.6.3 Update types.ts with new menu-related types +- [x] 1.6.4 Ensure menu items call existing InkApp methods +- [x] 1.6.5 Maintain backward compatibility with existing buttons + +## 1.7 Testing +- [ ] 1.7.1 Add Cypress tests for menu bar functionality +- [ ] 1.7.2 Add QUnit tests for menu component logic +- [ ] 1.7.3 Test keyboard shortcuts with Cypress +- [ ] 1.7.4 Test accessibility with screen reader simulation +- [ ] 1.7.5 Test menu dropdown behavior and state management + +## 1.8 Documentation and Polish +- [ ] 1.8.1 Update README.md with new menu features +- [ ] 1.8.2 Add keyboard shortcut documentation +- [ ] 1.8.3 Test responsive design for mobile/tablet +- [x] 1.8.4 Ensure proper error handling and user feedback +- [x] 1.8.5 Code review and cleanup + + + +## MODIFIED Requirements + +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. + +#### 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 + +### 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 + +#### 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 + + + +# Change: Fix Export JSON Shortcut Precedence + +## Why + +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 + +- 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 + +## Non-Regression Guarantees + +- 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 + +## 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 + +## Impact + +- 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 + + + +## 1. Implementation + +- [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 + +## 2. Testing + +- [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 + +## 3. Validation + +- [x] 3.1 Run `openspec validate fix-export-json-shortcut-precedence --strict` +- [x] 3.2 Run the relevant automated shortcut regression tests after implementation + + + +# Spec: Mobile Layout + +## Sidebar Toggle Always Visible on Small Screens + +The sidebar toggle button (`#sidebarToggleBtn`) SHALL be visible and interactive at all viewport widths, including those ≤ 980 px. + +#### Scenario: App loads on a mobile device — sidebar expanded + +- **GIVEN** a viewport width of 390 px +- **WHEN** the app finishes loading +- **THEN** the sidebar toggle button is rendered as a full-width horizontal button inside the sidebar +- **AND** the button label reads "▶ Collapse" with `aria-label="Collapse sidebar"` + +#### Scenario: App loads on a mobile device — sidebar collapsed + +- **GIVEN** a viewport width of 390 px and the sidebar state persisted as collapsed +- **WHEN** the app finishes loading +- **THEN** the sidebar is shown as a thin horizontal strip +- **AND** the toggle button is visible within that strip, reading "▼ Expand" with `aria-label="Expand sidebar"` + +--- + +## Sidebar Collapses and Expands Vertically on Mobile + +On small screens the sidebar SHALL collapse and expand vertically (row height), not horizontally (column width). + +#### Scenario: User collapses sidebar on mobile + +- **GIVEN** the sidebar is expanded on a ≤ 980 px viewport +- **WHEN** the user taps the sidebar toggle button +- **THEN** the sidebar shrinks to a horizontal strip showing only the toggle button +- **AND** the editor and preview area expand vertically to occupy the freed space +- **AND** the layout remains a single full-width column (no narrow sidebar column appears alongside the editor) + +#### Scenario: User expands sidebar on mobile + +- **GIVEN** the sidebar is collapsed on a ≤ 980 px viewport +- **WHEN** the user taps the toggle strip +- **THEN** the sidebar expands to show the full workspace panel (workspace name, search, file tree, tags) +- **AND** the editor area returns to its position below the sidebar + +--- + +## Editor and Preview Fill Available Vertical Space + +The editor (` +
    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    + + + + + + + +{ + "name": "ink", + "version": "1.0.0", + "description": "Ink is a functional and minimalistic webapp to write documents in markdown and export them", + "homepage": "https://github.com/feddernico/ink#readme", + "bugs": { + "url": "https://github.com/feddernico/ink/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/feddernico/ink.git" + }, + "license": "MIT", + "author": "Federico Viscioletti", + "type": "module", + "main": "index.js", + "scripts": { + "build": "node build/compile-and-assemble.js", + "watch": "node build/watch.js", + "build:favicon": "node build/generate-favicons.js", + "build:test": "node build/build-test.js", + "test:qunit": "npm run build:test && qunit \"tests/qunit/**/*.test.js\"", + "serve:test": "http-server . -p 4173 -s", + "test:cypress": "start-server-and-test \"npm run serve:test\" http://127.0.0.1:4173/ink-app.html \"unset ELECTRON_RUN_AS_NODE; cypress run\"", + "test:cypress:open": "unset ELECTRON_RUN_AS_NODE; cypress open", + "test": "npm run test:qunit && npm run test:cypress" + }, + "devDependencies": { + "@babel/standalone": "^7.29.2", + "@cypress/code-coverage": "^4.0.2", + "babel-plugin-istanbul": "^7.0.1", + "chokidar": "^4.0.3", + "cypress": "^15.12.0", + "esbuild": "^0.27.3", + "http-server": "^14.1.1", + "nyc": "^18.0.0", + "qunit": "^2.24.2", + "sass": "^1.97.3", + "start-server-and-test": "^2.0.12", + "typescript": "^5.9.3" + }, + "dependencies": { + "marked": "^17.0.4" + }, + "overrides": { + "immutable": "^5.1.5" + } +} + + + +# ink +

    + Ink logo +

    + +Ink is a functional and minimalistic webapp to write documents in markdown and export them. + +![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) + +## Repo Structure + +The structure of the repo is as follows: + +``` +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 + + + +
    + + + + + + + +
    +
    + + No note open + +
    + Ready +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + + + + +
    + + diff --git a/repomix-output.xml b/repomix-output.xml index 124bea6..b8b34e6 100644 --- a/repomix-output.xml +++ b/repomix-output.xml @@ -40,6 +40,246 @@ The content is organized as follows: +.claude/ + worktrees/ + crystalline-plotting-blanket/ + .claude/ + settings.local.json + .githooks/ + pre-commit + .github/ + workflows/ + codeql.yml + pr-checks.yml + release.yml + copilot-instructions.md + assets/ + branding/ + favicon.svg + logo.svg + build/ + assemble-single-file.js + build-test.js + build.js + compile-and-assemble.js + generate-favicons.js + inject.js + watch.js + cypress/ + e2e/ + cogito-mode.cy.js + editor-flow.cy.js + menu-bar.cy.js + mobile-fallback.cy.js + removed-toolbar-buttons.cy.js + workspace-actions.cy.js + support/ + commands.ts + e2e.ts + tsconfig.json + dist/ + test/ + cogito.js + fs-api.js + storage.js + tags.js + utils.js + app.min.js + styles.min.css + openspec/ + changes/ + add-cogito-mode/ + specs/ + cogito-mode/ + spec.md + design.md + proposal.md + tasks.md + add-collapsible-left-menu/ + specs/ + editor-layout/ + spec.md + proposal.md + tasks.md + add-color-style-themes/ + specs/ + theming/ + spec.md + view-menu/ + spec.md + proposal.md + tasks.md + add-componentized-build-and-test-automation/ + specs/ + build-pipeline/ + spec.md + testing/ + spec.md + design.md + proposal.md + tasks.md + add-eslint/ + specs/ + code-quality/ + spec.md + proposal.md + tasks.md + add-logo-readme-and-favicon/ + specs/ + branding-assets/ + spec.md + proposal.md + tasks.md + add-mobile-fallback/ + specs/ + mobile-support/ + spec.md + proposal.md + tasks.md + add-office-style-menu/ + specs/ + accessibility/ + spec.md + keyboard-shortcuts/ + spec.md + menu-bar/ + spec.md + design.md + proposal.md + tasks.md + add-webmcp-notes/ + proposal.md + spec.md + tasks.md + fix-export-json-shortcut-precedence/ + specs/ + keyboard-shortcuts/ + spec.md + proposal.md + tasks.md + fix-mobile-sidebar-and-layout/ + specs/ + mobile-layout/ + spec.md + proposal.md + tasks.md + migrate-marked-to-npm-dependency/ + proposal.md + tasks.md + refactor-align-llm-coding-principles/ + specs/ + codebase-maintainability/ + spec.md + design.md + proposal.md + tasks.md + refactor-app-controller-modules/ + specs/ + codebase-maintainability/ + spec.md + design.md + proposal.md + tasks.md + refactor-cleanup-and-consistency/ + specs/ + codebase-maintainability/ + spec.md + proposal.md + tasks.md + refactor-simplification-plan/ + specs/ + codebase-maintainability/ + spec.md + plan.md + proposal.md + tasks.md + remove-redundant-toolbar-buttons/ + specs/ + ui-cleanup/ + spec.md + proposal.md + tasks.md + replace-emojis-with-svg-icons/ + design.md + proposal.md + tasks.md + update-menu-shortcuts-platform-aware/ + specs/ + keyboard-shortcuts/ + spec.md + menu-bar/ + spec.md + proposal.md + tasks.md + docs/ + owasp-top10-2025/ + A01_2025-Broken_Access_Control.md + A02_2025-Security_Misconfiguration.md + A03_2025-Software_Supply_Chain_Failures.md + A04_2025-Cryptographic_Failures.md + A05_2025-Injection.md + A06_2025-Insecure_Design.md + A07_2025-Authentication_Failures.md + A08_2025-Software_or_Data_Integrity_Failures.md + A09_2025-Security_Logging_and_Alerting_Failures.md + A10_2025-Mishandling_of_Exceptional_Conditions.md + AGENTS.md + project.md + src/ + app/ + app-controller.ts + auto-refresh.ts + cogito.ts + dom.ts + editor-preview.ts + fs-api.ts + icons.ts + menu-actions.ts + sidebar.ts + theme.ts + toast-status.ts + tree-render.ts + types.ts + ui-events.ts + utils.ts + workspace-io.ts + test-support/ + storage-fixture.ts + app.ts + styles.scss + tags.ts + tests/ + qunit/ + cogito.test.js + fs-api.test.js + mobile-fallback.test.js + storage.test.js + tags.test.js + utils.test.js + .git + .gitignore + .nycrc + .release-please-manifest.json + .replit + AGENTS.md + babel.config.json + CHANGELOG.md + CONTRIBUTING.md + cypress.config.mjs + eslint.config.js + FUNDING.yml + ink-app.html + ink.code-workspace + ink.template.html + LICENSE + Makefile + opencode.json + package.json + README.md + release-please-config.json + replit.md + repomix-output.xml + settings.local.json .githooks/ pre-commit .github/ @@ -48,14 +288,19 @@ The content is organized as follows: pr-checks.yml release.yml copilot-instructions.md +.ocx/ + receipt.jsonc assets/ branding/ favicon.svg logo.svg + white-bg-logo.svg cypress/ e2e/ cogito-mode.cy.js + document-linter.cy.js editor-flow.cy.js + editor-view-mode.cy.js menu-bar.cy.js mobile-fallback.cy.js removed-toolbar-buttons.cy.js @@ -96,12 +341,27 @@ openspec/ design.md proposal.md tasks.md + add-document-linter/ + specs/ + document-linter/ + spec.md + quarto-linter/ + spec.md + proposal.md + tasks.md add-eslint/ specs/ code-quality/ spec.md proposal.md tasks.md + add-github-login/ + specs/ + auth/ + spec.md + design.md + proposal.md + tasks.md add-logo-readme-and-favicon/ specs/ branding-assets/ @@ -141,6 +401,15 @@ openspec/ spec.md proposal.md tasks.md + harden-document-linter/ + proposal.md + tasks.md + improve-document-linter-output/ + specs/ + document-linter/ + spec.md + proposal.md + tasks.md migrate-marked-to-npm-dependency/ proposal.md tasks.md @@ -199,7 +468,11 @@ openspec/ project.md src/ app/ + document-linter/ + document-linter.ts + messages.ts app-controller.ts + auth-controller.ts auto-refresh.ts cogito.ts dom.ts @@ -215,6 +488,9 @@ src/ ui-events.ts utils.ts workspace-io.ts + auth/ + github.ts + user.ts test-support/ storage-fixture.ts app.ts @@ -222,11 +498,15 @@ src/ tags.ts tests/ qunit/ + auth.test.js cogito.test.js + document-linter.test.js + editor-preview.test.js fs-api.test.js mobile-fallback.test.js storage.test.js tags.test.js + user.test.js utils.test.js .gitignore .nycrc @@ -249,12 +529,34 @@ package.json README.md release-please-config.json replit.md +repomix-output.txt +test-document.md +test-webmcp.html This section contains the contents of the repository's files. - + +{ + "permissions": { + "allow": [ + "Bash(npm install marked)", + "Bash(npm run build)", + "Bash(npm run test:qunit)", + "Bash(npm run test)", + "Bash(git stash)", + "Bash(npm run test:cypress)", + "Bash(git stash pop)", + "Bash(npm install --save-dev typescript)", + "Bash(git push origin feat/add-marked-as-library)", + "Bash(gh pr:*)" + ] + } +} + + + #!/bin/sh # Pre-commit hook to run lint and update repomix output @@ -268,1407 +570,37389 @@ npx repomix@latest git add repomix-output.xml - - - - - - - + +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" - - - ink - - - +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '30 16 * * 2' - - - - +jobs: + changes: + name: Detect changed files + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.filter.outputs.docs_only }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 - - + - name: Classify event changes + id: filter + shell: bash + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi - - - ink - - - + 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")" - -function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); + printf '%s\n' "$changed_files" - return { - kind: "file", - name, - async queryPermission() { - return "granted"; - }, - async requestPermission() { - return "granted"; - }, - async getFile() { - return new File([content], name, { type: "text/markdown", lastModified }); - }, - async createWritable() { - return { - async write(nextContent) { - content = String(nextContent); - lastModified = Date.now(); - }, - async close() {}, - }; - }, - __read() { - return content; - }, - }; -} + if [ -z "$changed_files" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi -function createFakeDirectoryHandle(name) { - const entries = new Map(); + if echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi - 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, - }; -} + echo "docs_only=true" >> "$GITHUB_OUTPUT" -describe("cogito mode", () => { - const workspaceName = "workspace-cogito"; - const fileStem = "cogito-note"; - const markdown = "The project should prioritize local-first writing workflows."; + analyze: + name: Analyze (${{ matrix.language }}) + needs: changes + if: needs.changes.outputs.docs_only != 'true' + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - const root = createFakeDirectoryHandle(workspaceName); + # required to fetch internal or private CodeQL packs + packages: read - win.prompt = (message) => { - if (message.includes("New note name")) { - return fileStem; - } - return null; - }; + # only required for workflows in private repositories + actions: read + contents: read - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - win.__fakeWorkspace = root; - win.__cogitoCreateEngineCalls = []; - win.__cogitoCompletions = []; - win.__INK_TEST_WEBLLM__ = { - async CreateMLCEngine(modelId, options = {}) { - win.__cogitoCreateEngineCalls.push(modelId); - options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 - 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?", - ], - }), - }, - }, - ], - }; - }, - }, - }, - }; - }, - }; - }, - }); - }); - - it("opens the panel, switches model, generates questions, and inserts one into the editor", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); - - 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("#cogitoDeepBtn").click().should("have.class", "active"); - cy.get("#cogitoLiteBtn").should("not.have.class", "active"); + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 - cy.get("#cogitoGenerateBtn").click(); + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - 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?"); + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - 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); - }); + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - 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`); - }); -}); + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" - -{ - "compilerOptions": { - "target": "es5", - "lib": ["es5", "dom"], - "types": ["cypress", "node"] - }, - "include": ["**/*.ts", "**/*.tsx"] -} - + +name: PR Checks - -## ADDED Requirements +on: + pull_request: + branches: [main] -### Requirement: Cogito Mode Menu Bar Button -The system SHALL provide a Cogito Mode button in the menu bar's top-right action area. +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 -#### 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 + - 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")" -#### 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 + printf '%s\n' "$changed_files" -### 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. + if [ -z "$changed_files" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi -#### 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 + if echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi -### Requirement: Fixed Coaching Prompt Contract -The system SHALL generate questions using a fixed writing-coach instruction that outputs JSON only with exactly three questions. + echo "docs_only=true" >> "$GITHUB_OUTPUT" -#### 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 + test: + name: Build and test + needs: changes + if: needs.changes.outputs.docs_only != 'true' + runs-on: ubuntu-latest -### Requirement: Last-Sentence Grounding -The system SHALL ground generated questions in the user's most recent sentence from the active markdown content. + steps: + - name: Checkout code + uses: actions/checkout@v4 -#### 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 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' -### Requirement: Exactly Three Rendered Questions -The system SHALL display exactly three generated questions in the Cogito Mode side panel. + - name: Install dependencies + run: npm ci -#### 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 + - name: Build + run: npm run build -#### 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 + - name: Run QUnit tests + run: npm run test:qunit -### Requirement: AI Question Markdown Insertion Format -The system SHALL insert selected questions into the markdown document using a standardized AI block format. + - 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 -#### 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 + - name: Upload Cypress Screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots + -### Requirement: Web-LLM Runtime Integration -The system SHALL use `https://esm.run/@mlc-ai/web-llm` for in-browser question generation. + +name: release-please -#### Scenario: Runtime is available -- **WHEN** Cogito Mode initializes successfully -- **THEN** question generation SHALL execute through the web-llm runtime in the browser +on: + push: + branches: [main] -#### 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 - +permissions: + contents: write + issues: write + pull-requests: write - -## 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. +jobs: + release-please: + runs-on: ubuntu-latest -## 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. + steps: + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + # A PAT is preferred here so bot-created release PRs trigger normal PR checks. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true -## 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. + - name: Checkout code + if: ${{ steps.release.outputs.release_created }} + uses: actions/checkout@v4 + with: + fetch-depth: 0 -## 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. + - name: Setup Node.js + if: ${{ steps.release.outputs.release_created }} + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" -## 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. + - name: Install dependencies + if: ${{ steps.release.outputs.release_created }} + run: npm ci -## 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? - + - name: Build + if: ${{ steps.release.outputs.release_created }} + run: npm run build - -# Change: Add Cogito Mode Question Assistant + - name: Run tests + if: ${{ steps.release.outputs.release_created }} + run: npm test -## 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. + - name: Publish GitHub release + if: ${{ steps.release.outputs.release_created }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.release.outputs.tag_name }} + target_commitish: ${{ steps.release.outputs.sha }} + name: Release ${{ steps.release.outputs.tag_name }} + body: ${{ steps.release.outputs.body }} + files: ink-app.html + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + -## 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. + +# Copilot Instructions -## 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) +- Maintainability work should follow the refactor plan in `openspec/changes/refactor-simplification-plan/plan.md`. +- The refactor implementation proposal lives in `openspec/changes/refactor-app-controller-modules/` and should be followed before touching app controller or build scripts. +- Prefer small, explicit modules with flat control flow and minimal indirection. +- Keep `src/app.ts` as a tiny entrypoint and preserve single-file build output (`ink-app.html`). +- Local git hooks are tracked under `.githooks/`; keep lint-plus-repomix pre-commit automation there and point setup docs at `git config core.hooksPath .githooks` instead of ad hoc hook creation snippets. +- Regression coverage for refactor-sensitive flows lives in `cypress/e2e/workspace-actions.cy.js` and `tests/qunit/fs-api.test.js`. +- The app controller is in `src/app/app-controller.ts`, with UI wiring in `src/app/ui-events.ts` and workspace flows in `src/app/workspace-io.ts`. +- Top menu visuals are authored directly in `ink.template.html`; when adding icons to dropdown rows, keep the text in a nested `.menu-label-text` span so dynamic updates like the Sort label do not remove the icon. +- Keyboard shortcut precedence lives in `src/app/ui-events.ts`; editor-scoped handlers must not swallow longer chords like Cmd/Ctrl+Shift+S that are reserved for export. +- Test bundles are built via `build/build-test.js`. +- Dependency security overrides live in `package.json` under `overrides` (immutable pinned to 5.1.5+). +- GitHub workflows should stay triggerable for PRs and use job-level docs-only gating rather than workflow-level path filters so required checks do not remain pending. +- Release automation is handled by `release-please`; keep `release-please-config.json` and `.release-please-manifest.json` aligned with the latest published tag and prefer Conventional Commit subjects (`fix:`, `feat:`, `deps:`) so release PRs are generated correctly. +- Protected-branch repos should prefer a `RELEASE_PLEASE_TOKEN` PAT for the release workflow so bot-created release PRs trigger the normal PR checks. +- Contributor-facing workflow and validation steps now live in `CONTRIBUTING.md`; keep build, test, and single-file output guidance aligned with that document when project workflows change. +- Declarative WebMCP note creation lives in the `#webmcpNoteForm` form in `ink.template.html`; keep its submit path wired to `createNoteFromTool()` in `src/app/workspace-io.ts` so agent-invoked note creation stays in the single-page app and does not navigate away. +- Cogito Mode work is tracked in `openspec/changes/add-cogito-mode/`; preserve the strict three-question JSON contract and the markdown insertion block format (`> ### AI` then question text) when implementing it. +- Cogito Mode entrypoint must be a top-right menu bar button using a thinking-man glyphicon with accessible Cogito labeling. +- Cogito runtime integration lives in `src/app/cogito.ts`; keep the prompt contract and JSON parsing helpers (`extractLastSentence`, `parseCogitoQuestionPayload`, `formatCogitoQuestionBlock`) stable and covered by QUnit tests. +- Cypress end-to-end coverage for Cogito uses a test-only `globalThis.__INK_TEST_WEBLLM__` override; preserve that seam so the full panel/generate/insert flow remains deterministic under test. +- 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. - -## 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. + + + + -## 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. + + + + + + ink + + - -## ADDED Requirements -### Requirement: User-Controlled Left Menu Visibility -The application SHALL provide a user-facing control to collapse and expand the left menu during an editing session. + + + + -#### Scenario: User collapses left menu -- **WHEN** the user activates the menu toggle while the menu is expanded -- **THEN** the left menu transitions to a collapsed state -- **AND** the main editor area gains additional horizontal space + + -#### Scenario: User expands left menu -- **WHEN** the user activates the menu toggle while the menu is collapsed -- **THEN** the left menu returns to an expanded state -- **AND** primary menu content becomes visible again + + + ink + + + -### Requirement: Left Menu Toggle Accessibility -The left menu toggle SHALL be operable via keyboard and SHALL expose its current expanded/collapsed state to assistive technologies. + +import fs from "node:fs"; -#### Scenario: Keyboard operation -- **WHEN** a keyboard-only user focuses the menu toggle and activates it -- **THEN** the menu state changes between expanded and collapsed -- **AND** focus remains in a predictable location for continued interaction +export function assembleSingleFile({ + templatePath = "ink.template.html", + javascriptPath = "dist/app.min.js", + cssPath = "dist/styles.min.css", + outputPath = "ink-app.html", +} = {}) { + const html = fs.readFileSync(templatePath, "utf8"); + const js = fs.readFileSync(javascriptPath, "utf8"); + const css = fs.readFileSync(cssPath, "utf8"); -#### Scenario: Assistive state exposure -- **WHEN** the menu state changes through the toggle control -- **THEN** the control's accessibility state reflects whether the menu is expanded or collapsed + const output = html + .replace("/*__INLINE_CSS__*/", css) + .replace("//__INLINE_JS__", js); -### Requirement: Deterministic Initial Menu State -The application SHALL define a deterministic initial left menu state when a new session loads. + fs.writeFileSync(outputPath, output); +} -#### Scenario: Initial layout state -- **WHEN** a user opens the application in a new session -- **THEN** the left menu starts in the documented default state -- **AND** the editor layout reflects that state without requiring user interaction +if (import.meta.url === `file://${process.argv[1]}`) { + assembleSingleFile(); +} - -# Change: Add User-Controlled Collapsible Left Menu + +import * as esbuild from "esbuild"; +import fs from "node:fs"; -## Why -The current left menu is fixed, which reduces usable editor space on smaller screens and during focused writing. Allowing users to collapse and expand the menu improves layout flexibility without removing existing navigation. +const bundles = [ + { + entryPoints: ["src/tags.ts"], + outfile: "dist/test/tags.js", + }, + { + entryPoints: ["src/test-support/storage-fixture.ts"], + outfile: "dist/test/storage.js", + }, + { + entryPoints: ["src/app/fs-api.ts"], + outfile: "dist/test/fs-api.js", + }, + { + entryPoints: ["src/app/utils.ts"], + outfile: "dist/test/utils.js", + }, + { + entryPoints: ["src/app/cogito.ts"], + outfile: "dist/test/cogito.js", + }, +]; -## What Changes -- Add support for collapsing and expanding the left menu from within the UI. -- Add a persistent toggle control so users can switch menu state on demand. -- Update layout behavior so editor content reflows to use freed horizontal space when the menu is collapsed. -- Preserve keyboard and pointer accessibility for the menu toggle interaction. -- Define expected default menu behavior at app start. +function ensureTestDist() { + fs.mkdirSync("dist/test", { recursive: true }); +} -## Impact -- Affected specs: `editor-layout` -- Affected code: - - `ink.template.html` - - `src/app.ts` - - `src/styles.scss` - - `dist/app.min.js` (rebuilt) - - `dist/styles.min.css` (rebuilt) +export async function buildTestBundles() { + ensureTestDist(); + for (const bundle of bundles) { + await esbuild.build({ + ...bundle, + bundle: true, + platform: "node", + format: "esm", + }); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + buildTestBundles().catch((error) => { + console.error(error); + process.exit(1); + }); +} - -## 1. UI Controls and State -- [x] 1.1 Add a left menu toggle control that supports collapse and expand actions. -- [x] 1.2 Implement menu state management in the app so toggle actions update layout consistently. -- [x] 1.3 Define and implement the default menu state on initial load. + +export { buildAll } from "./compile-and-assemble.js"; + -## 2. Layout and Styling -- [x] 2.1 Update layout styles so collapsing the menu increases main editor horizontal space. -- [x] 2.2 Ensure expanded state preserves existing navigation usability and visual hierarchy. -- [x] 2.3 Ensure collapsed state keeps the toggle discoverable and usable. + +import * as esbuild from "esbuild"; +import * as sass from "sass"; +import fs from "node:fs"; +import path from "node:path"; +import { assembleSingleFile } from "./assemble-single-file.js"; +import { generateFavicons } from "./generate-favicons.js"; -## 3. Accessibility and Interaction -- [x] 3.1 Ensure the toggle is keyboard-focusable and operable. -- [x] 3.2 Ensure toggle semantics expose menu state changes to assistive technologies. +function ensureDist() { + fs.mkdirSync("dist", { recursive: true }); +} -## 4. Verification -- [x] 4.1 Verify users can collapse and re-expand the menu in a modern browser. -- [x] 4.2 Verify editor content area width increases when the menu is collapsed. -- [x] 4.3 Verify keyboard-only users can operate the toggle and retain navigation control. -- [x] 4.4 Add a Cypress end-to-end test that validates left menu collapse/expand behavior and resulting layout changes. - - - -## ADDED Requirements - -### Requirement: CSS Custom Property Theme System -The application SHALL implement color themes using CSS custom properties, with each theme defined as a set of variable overrides on `:root[data-theme=""]`. +async function buildJavaScript() { + await esbuild.build({ + entryPoints: ["src/app.ts"], + bundle: true, + format: "iife", + minify: true, + target: ["es2018"], + outfile: "dist/app.min.js", + }); +} -#### Scenario: Default theme at initial load -- **WHEN** the application loads with no saved theme preference -- **THEN** no `data-theme` attribute is present on `` -- **AND** the default dark teal palette is applied via `:root` base variables +function buildStyles() { + const result = sass.compile("src/styles.scss", { style: "compressed", charset: false }); + const outputPath = path.join("dist", "styles.min.css"); + fs.writeFileSync(outputPath, result.css); +} -#### Scenario: Named theme activation -- **WHEN** a named theme (e.g. `monokai`) is applied -- **THEN** `data-theme="monokai"` is set on `` -- **AND** the corresponding CSS variable overrides take effect immediately across all elements -- **AND** no page reload is required +export async function buildAll() { + ensureDist(); + generateFavicons(); + await buildJavaScript(); + buildStyles(); + assembleSingleFile(); +} -#### Scenario: Returning to default -- **WHEN** the user selects Default (Dark) after a named theme is active -- **THEN** the `data-theme` attribute is removed from `` -- **AND** the base `:root` variable definitions are restored +if (import.meta.url === `file://${process.argv[1]}`) { + buildAll().catch((error) => { + console.error(error); + process.exit(1); + }); +} + -### Requirement: Theme Persistence -The application SHALL persist the user's theme choice across sessions using `localStorage`. + +import fs from "node:fs"; +import path from "node:path"; -#### Scenario: Theme saved on selection -- **WHEN** the user selects a theme from the View menu -- **THEN** the theme identifier is written to `localStorage` under the key `ink-theme` +const SOURCE_LOGO_PATH = path.join("assets", "branding", "logo.svg"); +const FAVICON_OUTPUT_PATH = path.join("assets", "branding", "favicon.svg"); -#### Scenario: Theme restored on load -- **WHEN** the application initialises and a valid theme identifier exists in `localStorage` -- **THEN** that theme is applied before the first render -- **AND** the user sees their previously chosen colours immediately, without a flash of the default theme +export function generateFavicons() { + const logoSvg = fs.readFileSync(SOURCE_LOGO_PATH, "utf8"); + fs.writeFileSync(FAVICON_OUTPUT_PATH, logoSvg); +} -#### Scenario: Invalid or missing stored value -- **WHEN** the application initialises and `localStorage` contains no `ink-theme` key or an unrecognised value -- **THEN** the default dark theme is applied -- **AND** no error is shown to the user +if (import.meta.url === `file://${process.argv[1]}`) { + generateFavicons(); +} + -### Requirement: Available Colour Styles -The application SHALL provide the following seven colour styles, modeled on RStudio's built-in themes. + +import { assembleSingleFile } from "./assemble-single-file.js"; -| Theme | Character | -|-------|-----------| -| Default (Dark) | Dark teal, original ink palette | -| Classic | Light neutral white/grey, blue accent | -| Cobalt | Dark ocean blue, gold accent | -| Monokai | Dark charcoal, green/pink highlights | -| Office | Clean professional light, Microsoft blue | -| Twilight | Warm dark grey, amber accent | -| Xcode | Crisp Apple-style light, Xcode blue | +export function injectTemplate(options = {}) { + assembleSingleFile(options); +} -#### Scenario: Each theme covers all required variables -- **WHEN** any named theme is active -- **THEN** all CSS custom properties (`--bg`, `--panel`, `--panel2`, `--border`, `--muted`, `--text`, `--accent`, `--danger`, `--ok`, `--warn`, `--shadow`, `--body-bg`) are defined -- **AND** no variable falls back to an unintended value from another theme +if (import.meta.url === `file://${process.argv[1]}`) { + injectTemplate(); +} - -## ADDED Requirements + +import chokidar from "chokidar"; +import { buildAll } from "./compile-and-assemble.js"; -### Requirement: View Top-Level Menu -The application menu bar SHALL include a View top-level menu positioned between the Edit menu and the Import/Export menu. +let inProgress = false; +let queued = false; -#### Scenario: View menu is visible in the menu bar -- **WHEN** the application loads -- **THEN** a "View" menu item is present in the menu bar -- **AND** it appears between "Edit" and "Import/Export" +async function runBuild() { + if (inProgress) { + queued = true; + return; + } -#### Scenario: View menu opens a dropdown -- **WHEN** the user clicks the View menu item -- **THEN** a dropdown appears listing all available colour styles -- **AND** the dropdown follows the same interaction model as File and Edit menus + inProgress = true; -### Requirement: Colour Style Selection -The View menu dropdown SHALL list all available colour styles and allow the user to switch between them with a single click. + try { + await buildAll(); + console.log(`[watch] build completed at ${new Date().toISOString()}`); + } catch (error) { + console.error("[watch] build failed", error); + } finally { + inProgress = false; + if (queued) { + queued = false; + await runBuild(); + } + } +} -#### Scenario: All themes listed -- **WHEN** the View dropdown is open -- **THEN** the following items are present in order: Default (Dark), [separator], Classic, Cobalt, Monokai, Office, Twilight, Xcode +await runBuild(); -#### Scenario: Selecting a theme -- **WHEN** the user clicks a theme name in the View dropdown -- **THEN** the corresponding theme is applied immediately -- **AND** the dropdown closes -- **AND** the application retains full functionality under the new colour scheme +const watcher = chokidar.watch(["src/**/*.ts", "src/**/*.scss", "ink.template.html", "assets/branding/logo.svg"], { + ignoreInitial: true, +}); -### Requirement: Active Theme Indicator -The View menu SHALL display a checkmark (✓) next to the currently active theme. +watcher.on("all", async (event, changedPath) => { + console.log(`[watch] ${event}: ${changedPath}`); + await runBuild(); +}); + -#### Scenario: Checkmark on active theme -- **WHEN** the View dropdown is open -- **THEN** a ✓ indicator is visible next to the currently active theme name -- **AND** all other theme entries show no checkmark + +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -#### Scenario: Checkmark updates on selection -- **WHEN** the user selects a different theme -- **THEN** the checkmark moves to the newly selected theme -- **AND** the previous theme entry no longer shows a checkmark + 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; + }, + }; +} -### Requirement: Backward Compatibility -Adding the View menu SHALL not affect any existing menu, button, or keyboard shortcut behaviour. +function createFakeDirectoryHandle(name) { + const entries = new Map(); -#### Scenario: Existing menus unaffected -- **WHEN** the View menu is present -- **THEN** File, Edit, and Import/Export menus continue to operate identically to their prior behaviour + return { + kind: "directory", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async *entries() { + for (const entry of entries.entries()) { + yield entry; + } + }, + async getFileHandle(fileName, options = {}) { + const existing = entries.get(fileName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`File not found: ${fileName}`); + } + const handle = createFakeFileHandle(fileName); + entries.set(fileName, handle); + return handle; + }, + async getDirectoryHandle(dirName, options = {}) { + const existing = entries.get(dirName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`Directory not found: ${dirName}`); + } + const handle = createFakeDirectoryHandle(dirName); + entries.set(dirName, handle); + return handle; + }, + __entries: entries, + }; +} -#### Scenario: Existing buttons unaffected -- **WHEN** a colour theme is active -- **THEN** all sidebar buttons, Save, JSON, and MD buttons continue to function correctly -- **AND** their visual style adapts to the active theme via CSS custom properties - +describe("cogito mode", () => { + const workspaceName = "workspace-cogito"; + const fileStem = "cogito-note"; + const markdown = "The project should prioritize local-first writing workflows."; - -# Change: Add Color Style Themes + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); -## Why + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; -Ink currently uses a single fixed dark color scheme. Users have different preferences and work in different lighting environments, so a fixed style limits comfort and accessibility. Providing a set of well-known color themes — modeled on those available in RStudio — gives users immediate visual choices without requiring any configuration beyond a single menu click. + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + win.__cogitoCreateEngineCalls = []; + win.__cogitoCompletions = []; + win.__INK_TEST_WEBLLM__ = { + async CreateMLCEngine(modelId, options = {}) { + win.__cogitoCreateEngineCalls.push(modelId); + options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); -## What Changes + 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?", + ], + }), + }, + }, + ], + }; + }, + }, + }, + }; + }, + }; + }, + }); + }); -- Add a **View** top-level menu to the existing office-style menu bar, placed between Edit and Import/Export. -- The View menu lists seven color styles: Default (Dark), Classic, Cobalt, Monokai, Office, Twilight, and Xcode. -- Selecting a style applies it immediately by setting a `data-theme` attribute on the `` element. -- Each theme is defined as a set of CSS custom property overrides in `src/styles.scss` using `:root[data-theme=""]` selectors. -- The active theme is indicated by a checkmark (✓) next to the selected item in the menu. -- The selected theme is persisted to `localStorage` under the key `ink-theme` and restored on next load. -- The body background gradient is theme-aware via a `--body-bg` CSS custom property. + it("opens the panel, switches model, generates questions, and inserts one into the editor", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); -## Impact - -- Affected specs: `theming`, `view-menu` -- Affected code: - - `ink.template.html` — View menu added - - `src/styles.scss` — theme CSS custom properties added, `--body-bg` variable introduced - - `src/app/bootstrap.ts` — `applyTheme()` and `loadTheme()` methods added; new `theme-*` action cases in `handleMenuAction()` - - `dist/app.min.js` (rebuilt) - - `dist/styles.min.css` (rebuilt) - - `ink-app.html` (rebuilt) - + 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"); - -## 1. CSS Theme System + cy.get("#cogitoDeepBtn").click().should("have.class", "active"); + cy.get("#cogitoLiteBtn").should("not.have.class", "active"); -- [x] 1.1 Introduce `--body-bg` CSS custom property to allow per-theme control of the body background gradient. -- [x] 1.2 Define `Default (Dark)` base theme variables in `:root` (existing dark teal palette, refactored to use `--body-bg`). -- [x] 1.3 Define `Classic` theme — light neutral white/grey, blue accent (`#2c6fad`). -- [x] 1.4 Define `Cobalt` theme — dark ocean blue (`#001f3d`) with gold accent (`#ffd700`) and a blue radial gradient. -- [x] 1.5 Define `Monokai` theme — dark (`#272822`) with green accent (`#a6e22e`) and pink/green radial gradients. -- [x] 1.6 Define `Office` theme — clean professional light, Microsoft blue accent (`#0078d4`). -- [x] 1.7 Define `Twilight` theme — warm dark grey (`#141414`), amber accent (`#d4a96a`). -- [x] 1.8 Define `Xcode` theme — crisp light (`#f9f9f9`), Apple blue accent (`#0070c1`). -- [x] 1.9 Add `.menu-theme-check` CSS class for the active-theme checkmark indicator in the dropdown. + cy.get("#cogitoGenerateBtn").click(); -## 2. View Menu (HTML Template) + 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?"); -- [x] 2.1 Add a `View` menu item to `ink.template.html` between Edit and Import/Export. -- [x] 2.2 Add dropdown list items for all seven themes, each with `data-action="theme-"`. -- [x] 2.3 Add `` to each theme item for active-state indication. -- [x] 2.4 Include a separator between Default (Dark) and the six named themes. + 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); + }); -## 3. Theme Logic (TypeScript) + 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`); + }); +}); + -- [x] 3.1 Add `VALID_THEMES` constant array listing all accepted theme identifiers. -- [x] 3.2 Implement `applyTheme(theme: string)` method that sets/removes the `data-theme` attribute on `document.documentElement`, saves to `localStorage`, and updates the checkmark indicator. -- [x] 3.3 Implement `loadTheme()` method that reads `localStorage` on startup and calls `applyTheme()` with the saved value (defaulting to `"default"` if absent or invalid). -- [x] 3.4 Call `loadTheme()` from `initialize()` before other UI setup. -- [x] 3.5 Add `theme-default`, `theme-classic`, `theme-cobalt`, `theme-monokai`, `theme-office`, `theme-twilight`, and `theme-xcode` cases to `handleMenuAction()`. + +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -## 4. Build and Verification + 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; + }, + }; +} -- [x] 4.1 Run `npm run build` to recompile TypeScript, SCSS, and assemble `ink-app.html`. -- [x] 4.2 Verify the View menu appears in the menu bar between Edit and Import/Export. -- [x] 4.3 Verify each theme applies immediately on selection with correct colours. -- [x] 4.4 Verify the checkmark moves to the newly selected theme. -- [x] 4.5 Verify the selected theme persists across page reloads via `localStorage`. +function createFakeDirectoryHandle(name) { + const entries = new Map(); -## 5. Documentation + 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, + }; +} -- [x] 5.1 Update `replit.md` with a Color Themes section describing each theme and the persistence mechanism. -- [x] 5.2 Create this openspec change request. - +describe("ink authoring flow", () => { + const workspaceName = "workspace-a"; + const fileStem = "notes"; + const fileName = `${fileStem}.md`; + const markdown = "# Ink flow\n\nThis is markdown content."; - -## ADDED Requirements -### Requirement: Componentized Source Inputs -The project SHALL maintain separate source files for interaction logic, styling, and template markup. + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); -#### Scenario: Source layout follows project template -- **WHEN** a developer inspects the repository source layout -- **THEN** TypeScript interaction logic is located in `src/app.ts` -- **AND** SASS styling is located in `src/styles.scss` -- **AND** HTML template markup is maintained in a template source file used by the build pipeline + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; -### Requirement: Deterministic Build Outputs -The build pipeline SHALL compile TypeScript and SASS sources into deterministic distributable artifacts. + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); -#### Scenario: One-shot build compiles assets -- **WHEN** a developer runs the documented one-shot build command -- **THEN** the pipeline generates a JavaScript bundle at `dist/app.min.js` -- **AND** the pipeline generates compiled CSS at `dist/styles.min.css` + 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); -### Requirement: Single-File Distribution Assembly -The build pipeline SHALL inject compiled JS and CSS artifacts into the HTML template to produce a single-file app output. + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#currentFilename").should("contain", fileName); -#### Scenario: Build injects compiled assets into final HTML -- **WHEN** the injection step runs after asset compilation -- **THEN** the final HTML output contains inline CSS derived from `dist/styles.min.css` -- **AND** the final HTML output contains inline JavaScript derived from `dist/app.min.js` -- **AND** the resulting file is executable in a browser without network dependencies + cy.get("#editor").clear().type(markdown); + cy.get("[data-action=\"save\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Saved"); -### Requirement: Incremental Developer Build Workflow -The project SHALL provide a watch-mode build workflow that rebuilds artifacts when source files change. + cy.window().then(async (win) => { + const handle = await win.__fakeWorkspace.getFileHandle(fileName); + expect(handle.__read()).to.eq(markdown); + }); + }); -#### Scenario: Watch mode rebuilds on source edits -- **WHEN** a developer runs the documented watch command and edits TypeScript, SASS, or template source files -- **THEN** relevant compiled artifacts are regenerated without requiring manual command re-entry - + 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 }); - -## ADDED Requirements -### Requirement: QUnit Test Harness -The project SHALL provide a QUnit-based test harness and a documented command to run QUnit tests locally. + cy.get("#editor").clear().type("# Hello World\n\nThis is a paragraph."); -#### Scenario: QUnit tests are executable from project scripts -- **WHEN** a developer runs the documented QUnit test command -- **THEN** the QUnit suite executes without requiring manual browser setup -- **AND** the command exits non-zero when any QUnit assertion fails + cy.get("#preview").find("h1").should("contain", "Hello World"); + cy.get("#preview").find("p").should("contain", "This is a paragraph."); + }); -### Requirement: Cypress End-to-End Test Harness -The project SHALL provide Cypress configuration and a documented command to run Cypress tests locally. + it("collapses and expands the left menu, updating accessibility state and editor space", () => { + let expandedEditorWidth = 0; + let collapsedEditorWidth = 0; -#### Scenario: Cypress tests are executable from project scripts -- **WHEN** a developer runs the documented Cypress test command -- **THEN** Cypress launches against the application under test -- **AND** the command exits non-zero when any Cypress assertion fails + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "true") + .and("contain", "▶ Collapse") + .and("be.visible"); + cy.get(".app").should("not.have.class", "sidebar-collapsed"); -### Requirement: Critical Authoring Flow Coverage -Automated tests SHALL validate the user workflow of selecting a workspace, creating a new file, entering markdown, and saving. + cy.get("#editor").then(($editor) => { + expandedEditorWidth = $editor[0].getBoundingClientRect().width; + }); -#### Scenario: End-to-end flow is validated -- **WHEN** the Cypress workflow test runs -- **THEN** it selects a workspace through supported UI/test seam interactions -- **AND** it creates a new file -- **AND** it enters markdown content into the editor -- **AND** it triggers save -- **AND** it verifies that the saved content matches the entered markdown + cy.get("#sidebarToggleBtn").click(); + cy.get(".app").should("have.class", "sidebar-collapsed"); + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "false") + .and("contain", "▼ Expand"); -### Requirement: Fast Regression Coverage for Editor Logic -Automated tests SHALL include QUnit coverage for core editor interactions required by the authoring flow. + cy.get("#editor").then(($editor) => { + collapsedEditorWidth = $editor[0].getBoundingClientRect().width; + expect(collapsedEditorWidth).to.be.greaterThan(expandedEditorWidth); + }); -#### Scenario: Editor behavior is validated by QUnit -- **WHEN** the QUnit suite executes editor interaction tests -- **THEN** tests verify at least initialization and markdown content mutation behavior used by the save flow + 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); + }); + }); +}); - -## Context -Ink targets a single-file HTML distribution while development should remain modular and maintainable. The desired workflow requires clear source separation (TypeScript, SASS, HTML template), predictable build outputs, and automated tests that guard core document editing behavior. + +describe("menu bar functionality", () => { + const workspaceName = "test-workspace"; -## Goals / Non-Goals -- Goals: - - Keep source-of-truth files separated by concern: logic, presentation, and template markup. - - Preserve single-file distributable output for runtime usage. - - Provide reliable local build and test commands. - - Cover the critical authoring flow with both fast in-browser tests (QUnit) and browser-driven tests (Cypress). -- Non-Goals: - - Migrating to a framework (React/Vue/etc.). - - Introducing a backend service. - - Redesigning editor UX beyond what is required for testability and flow support. + function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -## Decisions -- Decision: Use TypeScript compilation via existing `esbuild` and SASS compilation via `sass`, with a final injection step into a template file. - - Rationale: aligns with current lightweight toolchain and single-file output constraint. -- Decision: Expose build commands through `npm scripts` and keep `Makefile` as optional convenience wrapper. - - Rationale: `npm scripts` are portable and integrate well with QUnit/Cypress commands. -- Decision: Add QUnit tests for app-level behaviors that do not require full browser orchestration. - - Rationale: fast feedback for logic and DOM interactions. -- Decision: Add Cypress tests for full user workflow and persistence interactions. - - Rationale: validates user-visible behavior across realistic browser execution. + 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; + }, + }; + } -## Risks / Trade-offs -- Tooling overhead increases with dual test frameworks. - - Mitigation: keep QUnit focused on fast logic checks; reserve Cypress for key end-to-end flows. -- Workspace/file APIs can be difficult to test directly in browsers. - - Mitigation: define test seams and controlled fixtures/stubs for deterministic execution. + function createFakeDirectoryHandle(name) { + const entries = new Map(); -## Migration Plan -1. Introduce source file structure and build scripts aligned with README conventions. -2. Ensure one-shot and watch build commands generate expected dist artifacts and single-file output. -3. Add QUnit harness and baseline test suite. -4. Add Cypress project config and e2e scenario for workspace→new file→edit→save. -5. Update documentation with developer commands and required prerequisites. + 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, + }; + } -## Open Questions -- Should Cypress execute against a local static server command defined in `package.json`, or an externally started server? Local static server -- What test seam should be canonical for workspace selection in Cypress (UI picker vs injected test fixture API)? UI picker - + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); - -# Change: Componentized Build Pipeline and Automated Test Coverage + win.prompt = (message) => { + if (message.includes("New note name")) { + return "test-note"; + } + if (message.includes("Folder name")) { + return "test-folder"; + } + return null; + }; -## Why -The current project state does not provide a stable, repeatable workflow for maintaining separate HTML, TypeScript, and SASS sources, and it lacks automated regression tests for critical editor behavior. This makes changes risky and manual verification time-consuming. + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); -## What Changes -- Define a componentized source layout that keeps interaction logic in TypeScript, styles in SASS, and UI structure in an HTML template. -- Define a deterministic build pipeline that compiles TypeScript and SASS into distributable assets and injects them into the single-file app output. -- Define watch-mode and one-shot build commands so developers can rebuild the app whenever files change. -- Add QUnit unit/integration coverage for core application behavior. -- Add Cypress end-to-end coverage for the user flow: selecting a workspace, creating a new file, adding markdown content, and saving. -- Define minimum acceptance checks for local validation before release. + 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"); + }); -## Impact -- Affected specs: `build-pipeline`, `testing` -- Affected code: - - `package.json` - - `Makefile` - - `src/app.ts` - - `src/styles.scss` - - `ink.template.html` (or current HTML template source) - - `build/inject.js` - - `dist/` build outputs - - `tests/qunit/` (new) - - `cypress/` (new) - + it("does not contain Import/Export menu", () => { + cy.get("#menuBar").should("not.contain", "Import/Export"); + }); - -## 1. Implementation -- [x] 1.1 Align source structure with README template: `src/app.ts`, `src/styles.scss`, and HTML template input. -- [x] 1.2 Implement build scripts to compile TypeScript and SASS into `dist/app.min.js` and `dist/styles.min.css`. -- [x] 1.3 Implement template injection so compiled CSS/JS are embedded into the final single-file app output. -- [x] 1.4 Add build commands for one-shot build and watch mode to support rebuilding after file changes. -- [x] 1.5 Update developer documentation for build and run workflows. + 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"); + }); + }); -## 2. Testing -- [x] 2.1 Add QUnit test harness and configure command(s) to run tests locally. -- [x] 2.2 Add QUnit tests for core interaction logic and markdown editing behavior. -- [x] 2.3 Add Cypress configuration and command(s) to run end-to-end tests locally. -- [x] 2.4 Add Cypress test that covers selecting a workspace, creating a new file, entering markdown content, and saving. -- [x] 2.5 Add test data/setup hooks needed to execute the flow deterministically. + describe("File menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("File").click(); + }); -## 3. Validation -- [x] 3.1 Verify build output artifacts are generated as specified. -- [x] 3.2 Verify QUnit and Cypress suites pass from documented commands. -- [x] 3.3 Confirm final app output remains a single self-contained HTML file. - + 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"); + }); - -## ADDED Requirements -### Requirement: ESLint Code Quality -The project MUST use ESLint to enforce code quality standards on JavaScript files. + it("New Folder menu item exists", () => { + cy.get("[data-action=\"new-folder\"]").should("exist"); + }); -#### Scenario: ESLint runs successfully -- **WHEN** `npm run lint` is executed -- **THEN** ESLint analyzes all JavaScript files and reports any violations + it("Open Workspace menu item exists", () => { + cy.get("[data-action=\"open-workspace\"]").should("exist"); + }); -#### Scenario: Build includes lint check -- **WHEN** the build process runs -- **THEN** lint check is performed and build fails if errors exist - + it("Close Workspace menu item exists", () => { + cy.get("[data-action=\"close-workspace\"]").should("exist"); + }); - -# Change: Add ESLint to the project + it("Exit menu item exists", () => { + cy.get("[data-action=\"exit\"]").should("exist"); + }); + }); -## 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. + describe("Edit menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("Edit").click(); + }); -## 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 + it("Save menu item exists", () => { + cy.get("[data-action=\"save\"]").should("exist"); + }); -## 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 - + it("Save As menu item exists", () => { + cy.get("[data-action=\"save-as\"]").should("exist"); + }); - -## 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 - + it("Refresh menu item exists", () => { + cy.get("[data-action=\"refresh\"]").should("exist"); + }); - -## ADDED Requirements -### Requirement: Canonical Project Logo Asset -The repository SHALL store a canonical project logo source file that is used for documentation and favicon generation. + it("Sort menu item exists", () => { + cy.get("[data-action=\"sort\"]").should("exist"); + }); + }); -#### Scenario: Canonical logo source is available -- **WHEN** a maintainer inspects branding files in the repository -- **THEN** a single canonical logo source exists in a stable project path -- **AND** the source is suitable for deriving README and favicon assets + describe("View menu regression tests", () => { + beforeEach(() => { + cy.get(".menu-text").contains("View").click(); + }); -### Requirement: README Logo Rendering -The project documentation SHALL render the project logo in `README.md` using repository-relative asset references. + 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"); + }); + }); -#### Scenario: README displays logo -- **WHEN** `README.md` is rendered by a GitHub-compatible markdown renderer -- **THEN** the logo is visible near the document header -- **AND** the logo reference resolves without external network dependencies + describe("Export submenu functionality", () => { + beforeEach(() => { + cy.get(".menu-text").contains("File").click(); + cy.get(".submenu-parent").contains("Export").click(); + }); -### Requirement: Favicon Assets Derived from Logo -The project SHALL provide favicon assets derived from the canonical logo source. + it("Export JSON menu item exists in submenu", () => { + cy.get("[data-action=\"export-json\"]").should("exist"); + }); -#### Scenario: Favicon assets are generated and available -- **WHEN** the favicon generation workflow is run -- **THEN** favicon files are produced in the documented output location -- **AND** generated filenames match those referenced by the application template + it("Export Markdown menu item exists in submenu", () => { + cy.get("[data-action=\"export-markdown\"]").should("exist"); + }); + }); -### Requirement: Application Template Favicon References -The application HTML template SHALL reference project favicon assets so browser tabs display the project icon. + 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"); + }); + }); -#### Scenario: Browser tab uses project favicon -- **WHEN** a user opens the built application in a modern browser -- **THEN** the browser resolves favicon references from the application output -- **AND** the tab icon reflects the project logo branding + 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"); + }); + }); + }); +}); - -# Change: Add Project Logo to README and Favicon Assets + +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, + }); -## Why -The repository currently lacks consistent branding assets in project documentation and browser metadata. Adding the provided logo to `README.md` and defining favicon outputs improves recognizability and presentation. + target.dispatchEvent(event); +} -## What Changes -- Add a canonical logo asset in the repository based on `~/Downloads/ink_logo.svg`. -- Update `README.md` to render the project logo near the top of the document. -- Define and implement favicon outputs derived from the same logo source. -- Wire favicon references into the app HTML template so browser tabs use project branding. -- Document the asset location and favicon generation/update workflow. +function getPlatformModifier(target) { + const view = target.ownerDocument.defaultView; + const isMac = /Mac|iPod|iPhone|iPad/.test(view.navigator.userAgent); -## Impact -- Affected specs: `branding-assets` -- Affected code: - - `README.md` - - `ink.template.html` - - `build/` scripts (if favicon generation is automated in build) - - `dist/` favicon artifacts - - `assets/` branding source files (new) - + return isMac ? { metaKey: true } : { ctrlKey: true }; +} - -## 1. Asset Setup -- [x] 1.1 Add the provided logo SVG to a canonical repository path for shared branding usage. -- [x] 1.2 Define favicon output formats and filenames derived from the canonical logo source. +describe("mobile fallback functionality", () => { + const fileStem = "test-note"; + const fileName = `${fileStem}.md`; + const markdown = "# Test Note\n\nThis is test content."; -## 2. Documentation and Template Integration -- [x] 2.1 Update `README.md` to display the project logo with stable relative linking. -- [x] 2.2 Update the HTML template to include favicon references that resolve in the built output. + 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; + }, + }); + }); -## 3. Build and Distribution -- [x] 3.1 Implement or document the process to generate favicon artifacts from the SVG source. -- [x] 3.2 Ensure generated favicon assets are available in expected output locations. + 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"); + }); -## 4. Validation -- [x] 4.1 Verify `README.md` renders the logo correctly on GitHub-compatible markdown viewers. -- [x] 4.2 Verify the built app/tab displays the new favicon in a modern browser. -- [x] 4.3 Verify documentation describes how to refresh logo-derived assets when the SVG changes. - + 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); - -## ADDED Requirements + cy.get("#editor").clear().type(markdown); + cy.get("#dirtyDot").should("be.visible"); -### Requirement: Mobile Browser Detection -The system SHALL detect when the browser does not support the File System Access API. + cy.get("[data-action=\"save\"]").click({ force: true }); + cy.get("#dirtyDot").should("not.be.visible"); + cy.get("#statusBadge").should("contain", "Saved"); -#### Scenario: Browser lacks File System Access API -- **WHEN** the user opens ink on a browser without `showDirectoryPicker` and `FileSystemHandle` -- **THEN** the system SHALL detect this and enable in-memory workspace mode -- **AND** the system SHALL NOT throw an error blocking app usage + cy.get("#tree").should("contain", fileName); + }); -#### Scenario: Browser supports File System Access API -- **WHEN** the user opens ink on a browser with `showDirectoryPicker` and `FileSystemHandle` -- **THEN** the system SHALL allow opening a real folder as usual -- **AND** no in-memory mode SHALL be activated + 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 }); -### Requirement: In-Memory Workspace Mode -The system SHALL provide a temporary in-memory workspace when FS API is unavailable. + cy.get("[data-action=\"export-json\"]").should("exist"); + cy.get("[data-action=\"export-markdown\"]").should("exist"); + }); -#### Scenario: User creates note in temporary session -- **WHEN** the user clicks "New Note" in temporary session mode -- **AND** enters a note name -- **THEN** a new note SHALL be created in memory -- **AND** the user CAN edit the note content -- **AND** the user CAN save (persist to memory only) + 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 }); -#### Scenario: User edits note in temporary session -- **WHEN** the user modifies the editor content -- **AND** the note has unsaved changes -- **THEN** the dirty indicator SHALL show unsaved status -- **AND** the user CAN click Save to persist changes to memory + cy.get("[data-action=\"export-markdown\"]").click({ force: true }); -### Requirement: Export as JSON -The system SHALL provide a way to download all notes as a JSON file. + 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."); + }); + }); -#### Scenario: User exports all notes as JSON -- **WHEN** the user clicks "Export JSON" button -- **THEN** the browser SHALL download a file named `ink-export-YYYY-MM-DD.json` -- **AND** the file SHALL contain a JSON object with notes array -- **AND** each note SHALL have `name`, `path`, and `content` fields + 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 }); -#### Scenario: JSON export contains multiple notes -- **WHEN** the user has created multiple notes in temporary session -- **AND** clicks Export JSON -- **THEN** the exported JSON SHALL include all notes -- **AND** the order SHALL be preserved + 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 }); -### Requirement: Export as Markdown -The system SHALL provide a way to download individual notes as .md files. + cy.get("[data-action=\"export-json\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Exported JSON"); + }); -#### Scenario: User exports current note as Markdown -- **WHEN** the user clicks "Export Markdown" button while a note is open -- **THEN** the browser SHALL download a file with the note's filename -- **AND** the file SHALL contain the note's raw markdown content -- **AND** the file SHALL have `.md` extension + 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"); -### Requirement: Temporary Session UI Indicator -The system SHALL display a clear indicator when operating in temporary session mode. + cy.get("#editor") + .focus() + .then(($editor) => { + dispatchShortcut($editor[0], { + key: "s", + shiftKey: true, + ...getPlatformModifier($editor[0]), + }); + }); -#### Scenario: Temporary session is active -- **WHEN** the app is running in temporary session mode -- **THEN** a visual indicator SHALL be displayed showing "Temporary Session" -- **AND** the indicator SHALL inform users their data is not persisted -- **AND** export buttons SHALL be prominently visible + cy.get("#statusBadge").should("contain", "Exported JSON"); + cy.get("#dirtyDot").should("be.visible"); + }); -#### Scenario: No indicator shown in normal mode -- **WHEN** the app is running with real file system access -- **THEN** no temporary session indicator SHALL be displayed -- **AND** export buttons MAY be hidden or disabled - + 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"); - -# Change: Add Mobile Fallback Support for Browsers Without File System Access API + cy.get("#editor") + .focus() + .then(($editor) => { + dispatchShortcut($editor[0], { + key: "s", + ...getPlatformModifier($editor[0]), + }); + }); -## Why -Mobile browsers (e.g., Safari on iOS) do not support the File System Access API. Currently, ink shows an error message and prevents users from using the app. This excludes mobile users entirely. Instead, we should allow temporary in-memory work and provide export capabilities so users can download their notes. + cy.get("#statusBadge").should("contain", "Saved"); + cy.get("#dirtyDot").should("not.be.visible"); + }); -## What Changes -- Detect when File System Access API is unavailable -- Enable in-memory workspace mode for temporary editing -- Add "Export as JSON" button to download all notes as a JSON file -- Add "Export as Markdown" button to download individual notes as .md files -- Show informative UI about temporary nature of the session -- Preserve existing functionality for desktop browsers with FS API support + 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 }); -## Impact -- Affected specs: mobile-support (new capability) -- Affected code: src/app/bootstrap.ts, src/app/fs-api.ts, ink-app.html, styles -- No breaking changes to existing desktop functionality - + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type("# Another Note"); + cy.get("[data-action=\"save\"]").click({ force: true }); - -## ADDED Requirements + cy.get(".node").first().click(); -### Requirement: ARIA Support for Menu Bar -The menu bar SHALL implement proper ARIA attributes to ensure accessibility for screen readers and assistive technologies. + cy.get("#currentFilename").should("contain", ".md"); + }); -#### Scenario: Menu bar has proper ARIA role -- **WHEN** a screen reader encounters the menu bar -- **THEN** the menu bar has role="menubar" attribute -- **AND** screen readers announce it as a menu bar + 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"); -#### Scenario: Menu items have proper ARIA attributes -- **WHEN** a screen reader encounters menu items -- **THEN** each menu item has role="menuitem" attribute -- **AND** menu items with dropdowns have aria-haspopup="true" -- **AND** aria-expanded indicates dropdown state + cy.get("#editor").type(" - added content", { force: true }); + cy.get("#dirtyDot").should("be.visible"); + }); -#### Scenario: Dropdown menus have proper ARIA attributes -- **WHEN** a dropdown menu is opened -- **THEN** the dropdown has role="menu" attribute -- **AND** each dropdown item has role="menuitem" attribute -- **AND** aria-expanded="true" on the parent menu item + 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 }); -### Requirement: Keyboard Accessibility -The menu bar SHALL support full keyboard navigation for users who cannot use a mouse. + cy.get("#editor").clear().type("## Section\n\nSome **bold** text."); -#### Scenario: Tab navigation support -- **WHEN** a user navigates using Tab key -- **THEN** focus moves to menu bar items in logical order -- **AND** focused items are visually indicated + cy.get("#preview").find("h2").should("contain", "Section"); + cy.get("#preview").find("strong").should("contain", "bold"); + }); -#### Scenario: Arrow key navigation -- **WHEN** a user navigates using arrow keys -- **THEN** Left/Right arrows move between top-level menu items -- **AND** Up/Down arrows move between dropdown menu items -- **AND** Home/End keys move to first/last items + 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 }); -#### Scenario: Enter and Space key activation -- **WHEN** a user presses Enter or Space on a focused menu item -- **THEN** the menu item is activated -- **AND** dropdowns open or actions are executed as appropriate + cy.get(".tagrow").should("exist"); + }); +}); + -#### Scenario: Escape key handling -- **WHEN** a user presses Escape key -- **THEN** open dropdowns are closed -- **AND** focus returns to the parent menu item -- **AND** no other application functionality is affected + +function createFakeDirectoryHandle(name) { + const entries = new Map(); -### Requirement: Visual Focus Indicators -The menu bar SHALL provide clear visual indicators for keyboard focus and active states. + return { + kind: "directory", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async *entries() { + for (const entry of entries.entries()) { + yield entry; + } + }, + async getFileHandle(fileName, options = {}) { + const existing = entries.get(fileName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`File not found: ${fileName}`); + } + const handle = createFakeFileHandle(fileName); + entries.set(fileName, handle); + return handle; + }, + async getDirectoryHandle(dirName, options = {}) { + const existing = entries.get(dirName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`Directory not found: ${dirName}`); + } + const handle = createFakeDirectoryHandle(dirName); + entries.set(dirName, handle); + return handle; + }, + __entries: entries, + }; +} -#### Scenario: Focus indicators for menu items -- **WHEN** a menu item receives keyboard focus -- **THEN** a clear visual focus indicator is displayed -- **AND** the focus indicator meets WCAG contrast requirements +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -#### Scenario: Active state indicators -- **WHEN** a menu item is activated or a dropdown is open -- **THEN** clear visual indicators show the active state -- **AND** the indicators are distinguishable from focus indicators + return { + kind: "file", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async getFile() { + return new File([content], name, { type: "text/markdown", lastModified }); + }, + async createWritable() { + return { + async write(nextContent) { + content = String(nextContent); + lastModified = Date.now(); + }, + async close() {}, + }; + }, + __read() { + return content; + }, + }; +} -#### Scenario: Hover state indicators -- **WHEN** a user hovers over menu items with a mouse -- **THEN** visual indicators show hover state -- **AND** hover indicators are consistent with focus indicators +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; + }, + }); + }); -### Requirement: Screen Reader Announcements -The menu bar SHALL provide appropriate announcements for screen reader users. + 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); + }); + }); -#### Scenario: Menu item descriptions -- **WHEN** a screen reader focuses on a menu item -- **THEN** the item's purpose is clearly announced -- **AND** any associated keyboard shortcuts are announced + 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); + }); + }); -#### Scenario: Dropdown state announcements -- **WHEN** a dropdown menu state changes -- **THEN** screen readers announce the state change -- **AND** aria-expanded attribute is updated appropriately + 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); + }); + }); -#### Scenario: Menu navigation announcements -- **WHEN** a user navigates between menu items -- **THEN** screen readers announce the current position -- **AND** the total number of items is announced when appropriate + 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); + }); + }); -### Requirement: High Contrast and Zoom Support -The menu bar SHALL support high contrast modes and browser zoom functionality. + it("5.5 #dirtyDot IS present in ink-app.html after button removal", () => { + cy.get("#dirtyDot").should("exist"); + }); -#### Scenario: High contrast mode support -- **WHEN** high contrast mode is enabled -- **THEN** menu bar maintains proper contrast ratios -- **AND** all interactive elements remain visible and usable + it("5.6 #statusBadge IS present in ink-app.html after button removal", () => { + cy.get("#statusBadge").should("exist"); + }); + }); -#### Scenario: Browser zoom support -- **WHEN** browser zoom is applied -- **THEN** menu bar layout adjusts appropriately -- **AND** no content is clipped or overlapping -- **AND** all functionality remains accessible + 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"); + }); -### Requirement: Error Handling and Feedback -The menu bar SHALL provide appropriate feedback for accessibility-related errors. + 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"); + }); -#### Scenario: Disabled menu item feedback -- **WHEN** a user attempts to activate a disabled menu item -- **THEN** appropriate feedback is provided -- **AND** screen readers announce the item as disabled + 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"); + }); -#### Scenario: Invalid keyboard input handling -- **WHEN** a user provides invalid keyboard input -- **THEN** the application handles it gracefully -- **AND** no unexpected behavior occurs - + 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"); + }); - -## ADDED Requirements + 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"); -### Requirement: Keyboard Shortcuts for Common Operations -The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. + cy.get("#dirtyDot").parent().within(() => { + cy.get("#statusBadge").should("exist"); + }); + }); + }); +}); + -#### Scenario: New Note shortcut -- **WHEN** a user presses Ctrl+E (Windows/Linux) or Cmd+E (Mac) -- **THEN** the createNewNote functionality is triggered -- **AND** the same behavior as clicking "New Note" menu item occurs + +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -#### Scenario: Open Workspace shortcut -- **WHEN** a user presses Ctrl+Shift+O (Windows/Linux) or Cmd+Shift+O (Mac) -- **THEN** the openWorkspace functionality is triggered -- **AND** the same behavior as clicking "Open Workspace" menu item occurs + return { + kind: "file", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async getFile() { + return new File([content], name, { type: "text/markdown", lastModified }); + }, + async createWritable() { + return { + async write(nextContent) { + content = String(nextContent); + lastModified = Date.now(); + }, + async close() {}, + }; + }, + __read() { + return content; + }, + }; +} -#### Scenario: Save shortcut -- **WHEN** a user presses Ctrl+S (Windows/Linux) or Cmd+S (Mac) -- **THEN** the saveCurrentNote functionality is triggered -- **AND** the same behavior as clicking "Save" menu item occurs +function createFakeDirectoryHandle(name) { + const entries = new Map(); -#### Scenario: Refresh shortcut -- **WHEN** a user presses Ctrl+L (Windows/Linux) or Cmd+L (Mac) -- **THEN** the rescanWorkspace functionality is triggered -- **AND** the same behavior as clicking "Refresh" menu item occurs + return { + kind: "directory", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async *entries() { + for (const entry of entries.entries()) { + yield entry; + } + }, + async getFileHandle(fileName, options = {}) { + const existing = entries.get(fileName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`File not found: ${fileName}`); + } + const handle = createFakeFileHandle(fileName); + entries.set(fileName, handle); + return handle; + }, + async getDirectoryHandle(dirName, options = {}) { + const existing = entries.get(dirName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`Directory not found: ${dirName}`); + } + const handle = createFakeDirectoryHandle(dirName); + entries.set(dirName, handle); + return handle; + }, + __entries: entries, + }; +} -#### Scenario: Export JSON shortcut -- **WHEN** a user presses Ctrl+Shift+S (Windows/Linux) or Cmd+Shift+S (Mac) -- **THEN** the exportAsJson functionality is triggered -- **AND** the same behavior as clicking "Export JSON" menu item occurs +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); +} -#### Scenario: Export Markdown shortcut -- **WHEN** a user presses Ctrl+Shift+M (Windows/Linux) or Cmd+Shift+M (Mac) -- **THEN** the exportAsMarkdown functionality is triggered -- **AND** the same behavior as clicking "Export Markdown" menu item occurs +describe("workspace actions regression", () => { + const workspaceName = "workspace-actions"; + const noteName = "note-a"; + const saveAsName = "note-b"; + const markdown = "# Ink regression\n\nSaved content."; -### Requirement: Keyboard Navigation of Menu Bar -The menu bar SHALL support full keyboard navigation using standard accessibility patterns. + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + Object.defineProperty(win.navigator, "userAgent", { + value: "Windows NT 10.0", + configurable: true, + }); -#### Scenario: Tab navigation to menu bar -- **WHEN** a user presses Tab to navigate to the menu bar -- **THEN** focus moves to the first menu item -- **AND** the menu item is visually indicated as focused + const root = createFakeDirectoryHandle(workspaceName); -#### Scenario: Arrow key navigation between menus -- **WHEN** a user presses Left/Right arrow keys while focused on menu bar -- **THEN** focus moves between menu items (File, Edit, Import/Export) -- **AND** the focused menu item is visually indicated - -#### Scenario: Enter key to open dropdown -- **WHEN** a user presses Enter while focused on a menu item -- **THEN** the dropdown menu opens -- **AND** focus moves to the first menu item in the dropdown - -#### Scenario: Escape key to close dropdowns -- **WHEN** a user presses Escape while a dropdown is open -- **THEN** the dropdown closes -- **AND** focus returns to the parent menu item - -#### Scenario: Up/Down arrow navigation in dropdowns -- **WHEN** a user presses Up/Down arrow keys while focused on a dropdown menu -- **THEN** focus moves between menu items in the dropdown -- **AND** the focused item is visually indicated - -### Requirement: Shortcut Display in Menu Items -Menu items with keyboard shortcuts SHALL display the shortcut keys for discoverability. - -#### Scenario: Shortcut key display -- **WHEN** a user views the menu bar -- **THEN** menu items that have keyboard shortcuts display the shortcut keys -- **AND** shortcut keys are displayed in a consistent format (e.g., "Ctrl+N") - -#### Scenario: Platform-appropriate shortcut display -- **WHEN** the application runs on different platforms -- **THEN** shortcut keys are displayed using platform-appropriate modifiers -- **AND** Windows/Linux shows "Ctrl", Mac shows "Cmd" - -### Requirement: Shortcut Key Conflict Resolution -The application SHALL handle keyboard shortcut conflicts appropriately. - -#### Scenario: Browser shortcut precedence -- **WHEN** a user presses a keyboard shortcut that conflicts with browser functionality -- **THEN** the application prevents the default browser behavior -- **AND** the application's shortcut functionality is executed instead - -#### Scenario: Focus-based shortcut activation -- **WHEN** keyboard shortcuts are pressed -- **THEN** shortcuts are only active when the application has focus -- **AND** shortcuts do not interfere with other applications - - - -## ADDED Requirements - -### Requirement: Menu Bar Structure -The application SHALL provide a horizontal menu bar at the top of the application window containing File, Edit, and Import/Export menu items. - -#### Scenario: Menu bar is visible and accessible -- **WHEN** the application loads -- **THEN** a horizontal menu bar is displayed at the top of the application window -- **AND** the menu bar contains File, Edit, and Import/Export menu items - -#### Scenario: 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 - -### Requirement: File Menu Functionality -The File menu SHALL provide access to workspace and file management 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 - -#### 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 + 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; + }; -#### 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 + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, + }); + }); -### Requirement: Edit Menu Functionality -The Edit menu SHALL provide access to document editing and view operations. + 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); -#### Scenario: Save menu item -- **WHEN** a user clicks "Save" in the Edit menu -- **THEN** the existing saveCurrentNote functionality is triggered -- **AND** the same behavior as clicking the "Save" button occurs + 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"); -#### Scenario: Refresh menu item -- **WHEN** a user clicks "Refresh" in the Edit menu -- **THEN** the existing rescanWorkspace functionality is triggered -- **AND** the same behavior as clicking the "Refresh" button occurs + cy.get("[data-action=\"save-as\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Saved as"); + cy.get("#currentFilename").should("contain", `${saveAsName}.md`); -#### Scenario: Sort toggle menu item -- **WHEN** a user clicks "Sort: Name/Modified" in the Edit menu -- **THEN** the existing sort functionality is triggered -- **AND** the same behavior as clicking the "Sort" button occurs + cy.window().then(async (win) => { + const handle = await win.__fakeWorkspace.getFileHandle(`${saveAsName}.md`); + expect(handle.__read()).to.eq(markdown); + }); + }); -#### Scenario: Collapse Sidebar menu item -- **WHEN** a user clicks "Collapse Sidebar" in the Edit menu -- **THEN** the existing setSidebarCollapsed functionality is triggered -- **AND** the same behavior as clicking the sidebar toggle button occurs + it("refresh button rescans workspace and updates status", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#workspaceName").should("contain", workspaceName); -### Requirement: Import/Export Menu Functionality -The Import/Export menu SHALL provide access to data export operations. + cy.get("body").click("topLeft"); + cy.get("#refreshBtn").click(); + cy.get("#statusBadge").should("contain", "Refreshed"); + }); -#### Scenario: Export JSON menu item -- **WHEN** a user clicks "Export JSON" in the Import/Export menu -- **THEN** the existing exportAsJson functionality is triggered -- **AND** the same behavior as clicking the "Export JSON" button occurs + it("close workspace resets UI state", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); -#### Scenario: Export Markdown menu item -- **WHEN** a user clicks "Export Markdown" in the Import/Export menu -- **THEN** the existing exportAsMarkdown functionality is triggered -- **AND** the same behavior as clicking the "Export MD" button occurs + 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"); + }); -### Requirement: Menu Dropdown Behavior -Menu dropdowns SHALL open and close appropriately with proper keyboard and mouse interaction. + it("sort toggle updates labels", () => { + cy.get("#sortBtn").should("contain", "Sort: Name"); + cy.get("[data-action=\"sort\"] .menu-label").should("contain", "Sort: Name"); -#### Scenario: Mouse interaction with dropdowns -- **WHEN** a user hovers over or clicks a menu item -- **THEN** the dropdown menu opens -- **AND** clicking outside the menu closes it + cy.get("#sortBtn").click(); + cy.get("#sortBtn").should("contain", "Sort: Last modified"); + cy.get("[data-action=\"sort\"] .menu-label").should("contain", "Sort: Modified"); + }); -#### Scenario: Keyboard navigation of menus -- **WHEN** a user navigates using arrow keys -- **THEN** focus moves between menu items -- **AND** pressing Enter activates the focused menu item -- **AND** pressing Escape closes open dropdowns + 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); -### Requirement: Backward Compatibility -The menu bar SHALL not interfere with existing button functionality. + cy.window().then((win) => { + dispatchShortcut(win, { key: "e", ctrlKey: true }); + }); + cy.get("#currentFilename").should("contain", `${noteName}.md`); -#### Scenario: Buttons continue to work -- **WHEN** a user clicks existing buttons -- **THEN** the same functionality is triggered as before -- **AND** menu items provide alternative access to the same functions + cy.get("#editor").clear().type(markdown); + cy.window().then((win) => { + dispatchShortcut(win, { key: "s", ctrlKey: true }); + }); + cy.get("#statusBadge").should("contain", "Saved"); -#### Scenario: Menu and button state synchronization -- **WHEN** a menu item is clicked -- **THEN** any related button states are updated appropriately -- **AND** the application maintains consistent state across all interfaces + cy.window().then((win) => { + dispatchShortcut(win, { key: "l", ctrlKey: true }); + }); + cy.get("#statusBadge").should("contain", "Refreshed"); + }); +}); - -# Design: Office-Style Menu Bar Implementation - -## Context - -The ink markdown note-taking application needs a proper office-style menu bar to improve user experience and provide familiar desktop application patterns. The application currently has a functional but button-heavy interface. The menu bar should integrate seamlessly with existing functionality while adding discoverability and keyboard shortcuts. - -## Goals / Non-Goals - -### Goals -- Provide familiar File, Edit, and Import/Export menu structure -- Add keyboard shortcuts for common operations -- Maintain full backward compatibility with existing buttons -- Ensure accessibility with proper ARIA attributes -- Keep implementation simple and maintainable -- Follow existing code patterns and architecture - -### Non-Goals -- Replace existing button interface (menus complement, don't replace) -- Add complex menu features like toolbars or ribbons -- Implement undo/redo functionality (not currently in scope) -- Add theming or customization options -- Support for nested submenus beyond basic dropdowns + +export {}; + -## Decisions + +import "./commands"; +import "@cypress/code-coverage/support"; -### Menu Structure Decision -**Decision**: Use a horizontal menu bar with dropdown menus for File, Edit, and Import/Export sections. +Cypress.on("uncaught:exception", () => false); + -**Rationale**: This follows standard desktop application patterns and provides clear organization of functionality. The horizontal layout works well with the existing sidebar layout. + +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts", "**/*.tsx"] +} + -**Alternatives considered**: -- Contextual menus only: Rejected because it reduces discoverability -- Vertical sidebar menu: Rejected because it conflicts with existing workspace sidebar -- Hybrid approach: Rejected for complexity + +// src/app/cogito.ts +var COGITO_PROMPT = `You are a writing coach. -### Keyboard Shortcuts Decision -**Decision**: Implement standard desktop application shortcuts: +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": ["...", "...", "..."] +}`; +var LITE_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC"; +var DEEP_MODEL = "Qwen3-8B-q4f16_1-MLC"; +function extractLastSentence(text) { + 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]; +} +function parseCogitoQuestionPayload(raw) { + const jsonText = raw.replace(/[\s\S]*?<\/think>/gi, "").replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim(); + const parsed = JSON.parse(jsonText); + if (!parsed || !Array.isArray(parsed.questions)) { + throw new Error("Cogito response did not include a questions array."); + } + const sanitized = parsed.questions.filter((q) => 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; +} +function formatCogitoQuestionBlock(question) { + return `> ### AI +${question.trim()} +`; +} +function insertTextAtCursor(textarea, text) { + 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); +} +function createCogitoController({ + els, + getEditorText, + onEditorContentReplaced, + showToast, + setStatus +}) { + let isPanelOpen = false; + let generatedQuestions = []; + let selectedModel = "lite"; + const engineCache = {}; + async function loadWebLlmModule() { + const testModule = globalThis.__INK_TEST_WEBLLM__; + if (testModule) { + return testModule; + } + return import("https://esm.run/@mlc-ai/web-llm"); + } + function selectModel(model) { + selectedModel = model; + els.cogitoLiteBtn.classList.toggle("active", model === "lite"); + els.cogitoDeepBtn.classList.toggle("active", model === "deep"); + } + function setPanelVisibility(isOpen) { + 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); + } + } + function updateQuestionList(questions) { + els.cogitoQuestionList.innerHTML = ""; + questions.forEach((question, index) => { + const item = document.createElement("li"); + item.className = "cogitoQuestionItem"; + const text = document.createElement("p"); + text.className = "cogitoQuestionText"; + text.textContent = question; + 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"; + item.append(text, button); + els.cogitoQuestionList.appendChild(item); + }); + } + function setCogitoStatus(text) { + els.cogitoStatus.textContent = text; + } + async function getOrCreateEngine() { + const modelId = selectedModel === "deep" ? DEEP_MODEL : LITE_MODEL; + if (engineCache[selectedModel]) { + return engineCache[selectedModel]; + } + 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) => { + if (progress?.text) { + setCogitoStatus(progress.text); + } + } + }); + return engine; + })().catch((error) => { + delete engineCache[selectedModel]; + throw error; + }); + return engineCache[selectedModel]; + } + async function generateQuestions() { + const lastSentence = extractLastSentence(getEditorText()); + if (!lastSentence) { + setCogitoStatus("Write at least one sentence first, then generate Cogito questions."); + setStatus("Cogito needs a sentence", "warn"); + return; + } + try { + els.cogitoGenerateBtn.disabled = true; + setCogitoStatus("Generating 3 questions..."); + 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 + }); + 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() : ""; + if (!textContent) { + throw new Error("Cogito returned an empty response."); + } + generatedQuestions = parseCogitoQuestionPayload(textContent); + updateQuestionList(generatedQuestions); + setCogitoStatus("Questions ready. Insert one into your markdown when useful."); + setStatus("Cogito questions ready", "ok"); + } catch (error) { + 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; + } + } + function insertQuestionAtIndex(index) { + const question = generatedQuestions[index]; + if (!question) { + showToast("Cogito question not found.", { persist: true }); + return; + } + const block = formatCogitoQuestionBlock(question); + insertTextAtCursor(els.editor, block); + onEditorContentReplaced(els.editor.value); + setStatus("Inserted AI question", "ok"); + } + setPanelVisibility(false); + return { + togglePanel: () => { + setPanelVisibility(!isPanelOpen); + if (isPanelOpen) { + setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence."); + } + }, + selectModel, + generateQuestions, + insertQuestionAtIndex + }; +} +export { + COGITO_PROMPT, + DEEP_MODEL, + LITE_MODEL, + createCogitoController, + extractLastSentence, + formatCogitoQuestionBlock, + insertTextAtCursor, + parseCogitoQuestionPayload +}; + + + +// src/app/fs-api.ts +function isFileSystemApiAvailable() { + return Boolean(window.showDirectoryPicker && window.FileSystemHandle); +} +function isInMemoryMode() { + return !isFileSystemApiAvailable(); +} +async function ensurePermission(handle, mode = "read") { + if (!handle) { + return false; + } + if (!handle.queryPermission || !handle.requestPermission) { + return true; + } + const descriptor = { mode }; + const current = await handle.queryPermission(descriptor); + if (current === "granted") { + return true; + } + const requested = await handle.requestPermission(descriptor); + return requested === "granted"; +} +export { + ensurePermission, + isFileSystemApiAvailable, + isInMemoryMode +}; + + + +// src/test-support/storage-fixture.ts +var STORAGE_PREFIX = "ink.workspace."; +function workspaceKey(name) { + return `${STORAGE_PREFIX}${name}`; +} +function normalizeWorkspaceName(name) { + return name.trim(); +} +function parseWorkspace(value) { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object") { + return {}; + } + const files = {}; + for (const [key, content] of Object.entries(parsed)) { + if (typeof key === "string" && typeof content === "string") { + files[key] = content; + } + } + return files; + } catch { + return {}; + } +} +function writeWorkspace(storage, workspace, files) { + storage.setItem(workspaceKey(workspace), JSON.stringify(files)); +} +function listWorkspaces(storage) { + const workspaces = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key || !key.startsWith(STORAGE_PREFIX)) { + continue; + } + workspaces.push(key.slice(STORAGE_PREFIX.length)); + } + return workspaces.sort((a, b) => a.localeCompare(b)); +} +function ensureWorkspace(storage, workspaceName) { + const normalizedName = normalizeWorkspaceName(workspaceName); + if (!normalizedName) { + throw new Error("Workspace name cannot be empty."); + } + const key = workspaceKey(normalizedName); + if (storage.getItem(key) === null) { + writeWorkspace(storage, normalizedName, {}); + } + return normalizedName; +} +function listFiles(storage, workspaceName) { + const files = parseWorkspace(storage.getItem(workspaceKey(workspaceName))); + return Object.keys(files).sort((a, b) => a.localeCompare(b)); +} +function createFile(storage, workspaceName, fileName) { + const normalizedFileName = fileName.trim(); + if (!normalizedFileName) { + throw new Error("File name cannot be empty."); + } + const workspace = ensureWorkspace(storage, workspaceName); + const files = parseWorkspace(storage.getItem(workspaceKey(workspace))); + if (!(normalizedFileName in files)) { + files[normalizedFileName] = ""; + writeWorkspace(storage, workspace, files); + } + return normalizedFileName; +} +function readFile(storage, workspaceName, fileName) { + const files = parseWorkspace(storage.getItem(workspaceKey(workspaceName))); + return files[fileName] ?? ""; +} +function saveFile(storage, workspaceName, fileName, content) { + const workspace = ensureWorkspace(storage, workspaceName); + const files = parseWorkspace(storage.getItem(workspaceKey(workspace))); + files[fileName] = content; + writeWorkspace(storage, workspace, files); +} +export { + createFile, + ensureWorkspace, + listFiles, + listWorkspaces, + readFile, + saveFile +}; + + + +// src/tags.ts +function normalizeTag(value) { + return (value || "").trim().replace(/^#+/, "").replace(/[^\w\-/]+/g, "").toLowerCase(); +} +function extractFrontMatter(text) { + if (!text.startsWith("---")) { + return ""; + } + const end = text.indexOf("\n---", 3); + if (end === -1) { + return ""; + } + return text.slice(3, end).trim(); +} +function parseFrontmatterTags(frontMatter) { + const tags = /* @__PURE__ */ new Set(); + const lines = frontMatter.split("\n"); + for (const line of lines) { + const inlineListMatch = line.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i); + if (!inlineListMatch) { + continue; + } + const parts = inlineListMatch[1].split(",").map((part) => normalizeTag(part.replace(/["']/g, ""))); + for (const part of parts) { + if (part) { + tags.add(part); + } + } + } + let inTagsBlock = false; + for (const line of lines) { + if (/^\s*tags\s*:\s*$/i.test(line)) { + inTagsBlock = true; + continue; + } + if (!inTagsBlock) { + continue; + } + const item = line.match(/^\s*-\s*(.+)\s*$/); + if (item) { + const tag = normalizeTag(item[1].replace(/["']/g, "")); + if (tag) { + tags.add(tag); + } + continue; + } + if (line.trim() !== "" && !/^\s+/.test(line)) { + inTagsBlock = false; + } + } + return tags; +} +function parseTags(text) { + const tags = /* @__PURE__ */ 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); + } + } + } + return tags; +} +export { + extractFrontMatter, + normalizeTag, + parseFrontmatterTags, + parseTags +}; + + + +// src/app/utils.ts +function escapeHtml(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} +export { + escapeHtml +}; + + + +(()=>{var kt=Object.create;var he=Object.defineProperty;var wt=Object.getOwnPropertyDescriptor;var bt=Object.getOwnPropertyNames;var xt=Object.getPrototypeOf,yt=Object.prototype.hasOwnProperty;var vt=(t,e,r)=>e in t?he(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r;var Tt=(t=>typeof require!="undefined"?require:typeof Proxy!="undefined"?new Proxy(t,{get:(e,r)=>(typeof require!="undefined"?require:e)[r]}):t)(function(t){if(typeof require!="undefined")return require.apply(this,arguments);throw Error('Dynamic require of "'+t+'" is not supported')});var St=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of bt(e))!yt.call(t,o)&&o!==r&&he(t,o,{get:()=>e[o],enumerable:!(n=wt(e,o))||n.enumerable});return t};var Mt=(t,e,r)=>(r=t!=null?kt(xt(t)):{},St(e||!t||!t.__esModule?he(r,"default",{value:t,enumerable:!0}):r,t));var E=(t,e,r)=>vt(t,typeof e!="symbol"?e+"":e,r);function ke(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var G=ke();function We(t){G=t}var K={exec:()=>null};function M(t,e=""){let r=typeof t=="string"?t:t.source,n={replace:(o,i)=>{let a=typeof i=="string"?i:i.source;return a=a.replace($.caret,"$1"),r=r.replace(o,a),n},getRegex:()=>new RegExp(r,e)};return n}var Rt=(()=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^
    /i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i"),blockquoteBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}>`)},Ct=/^(?:[ \t]*(?:\n|$))+/,Et=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,At=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,X=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Pt=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,we=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,_e=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,qe=M(_e).replace(/bull/g,we).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Nt=M(_e).replace(/bull/g,we).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),be=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Lt=/^[^\n]+/,xe=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,$t=M(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",xe).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Dt=M(/^(bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,we).getRegex(),le="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",ye=/|$))/,It=M("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",ye).replace("tag",le).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Oe=M(be).replace("hr",X).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",le).getRegex(),Bt=M(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Oe).getRegex(),ve={blockquote:Bt,code:Et,def:$t,fences:At,heading:Pt,hr:X,html:It,lheading:qe,list:Dt,newline:Ct,paragraph:Oe,table:K,text:Lt},De=M("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",X).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",le).getRegex(),Ht={...ve,lheading:Nt,table:De,paragraph:M(be).replace("hr",X).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",De).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",le).getRegex()},Ft={...ve,html:M(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",ye).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:K,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:M(be).replace("hr",X).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",qe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},zt=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Wt=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Qe=/^( {2,}|\\)\n(?!\s*$)/,_t=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Rt?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),Ge=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,Gt=M(Ge,"u").replace(/punct/g,ce).getRegex(),Ut=M(Ge,"u").replace(/punct/g,Ke).getRegex(),Ue="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Jt=M(Ue,"gu").replace(/notPunctSpace/g,je).replace(/punctSpace/g,Te).replace(/punct/g,ce).getRegex(),Vt=M(Ue,"gu").replace(/notPunctSpace/g,Qt).replace(/punctSpace/g,Ot).replace(/punct/g,Ke).getRegex(),Yt=M("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,je).replace(/punctSpace/g,Te).replace(/punct/g,ce).getRegex(),Xt=M(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,Ze).getRegex(),er="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",tr=M(er,"gu").replace(/notPunctSpace/g,Kt).replace(/punctSpace/g,jt).replace(/punct/g,Ze).getRegex(),rr=M(/\\(punct)/,"gu").replace(/punct/g,ce).getRegex(),nr=M(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),or=M(ye).replace("(?:-->|$)","-->").getRegex(),ir=M("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",or).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),ie=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,sr=M(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",ie).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Je=M(/^!?\[(label)\]\[(ref)\]/).replace("label",ie).replace("ref",xe).getRegex(),Ve=M(/^!?\[(ref)\](?:\[\])?/).replace("ref",xe).getRegex(),ar=M("reflink|nolink(?!\\()","g").replace("reflink",Je).replace("nolink",Ve).getRegex(),Ie=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,Se={_backpedal:K,anyPunctuation:rr,autolink:nr,blockSkip:Zt,br:Qe,code:Wt,del:K,delLDelim:K,delRDelim:K,emStrongLDelim:Gt,emStrongRDelimAst:Jt,emStrongRDelimUnd:Yt,escape:zt,link:sr,nolink:Ve,punctuation:qt,reflink:Je,reflinkSearch:ar,tag:ir,text:_t,url:K},lr={...Se,link:M(/^!?\[(label)\]\((.*?)\)/).replace("label",ie).getRegex(),reflink:M(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",ie).getRegex()},ge={...Se,emStrongRDelimAst:Vt,emStrongLDelim:Ut,delLDelim:Xt,delRDelim:tr,url:M(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",Ie).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:M(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},Be=t=>pr[t];function _(t,e){if(e){if($.escapeTest.test(t))return t.replace($.escapeReplace,Be)}else if($.escapeTestNoEncode.test(t))return t.replace($.escapeReplaceNoEncode,Be);return t}function He(t){try{t=encodeURI(t).replace($.percentDecode,"%")}catch(e){return null}return t}function Fe(t,e){var i;let r=t.replace($.findPipe,(a,s,u)=>{let l=!1,g=s;for(;--g>=0&&u[g]==="\\";)l=!l;return l?"|":" |"}),n=r.split($.splitPipe),o=0;if(n[0].trim()||n.shift(),n.length>0&&!((i=n.at(-1))!=null&&i.trim())&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function dr(t,e=0){let r=e,n="";for(let o of t)if(o===" "){let i=4-r%4;n+=" ".repeat(i),r+=i}else n+=o,r++;return n}function ze(t,e,r,n,o){let i=e.href,a=e.title||null,s=t[1].replace(o.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:t[0].charAt(0)==="!"?"image":"link",raw:r,href:i,title:a,text:s,tokens:n.inlineTokens(s)};return n.state.inLink=!1,u}function hr(t,e,r){let n=t.match(r.other.indentCodeCompensation);if(n===null)return e;let o=n[1];return e.split(` +`).map(i=>{let a=i.match(r.other.beginningSpace);if(a===null)return i;let[s]=a;return s.length>=o.length?i.slice(o.length):i}).join(` +`)}var se=class{constructor(t){E(this,"options");E(this,"rules");E(this,"lexer");this.options=t||G}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let r=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?r:V(r,` +`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let r=e[0],n=hr(r,e[3]||"",this.rules);return{type:"code",raw:r,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:n}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let r=e[2].trim();if(this.rules.other.endingHash.test(r)){let n=V(r,"#");(this.options.pedantic||!n||this.rules.other.endingSpaceChar.test(n))&&(r=n.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:r,tokens:this.lexer.inline(r)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:V(e[0],` +`)}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let r=V(e[0],` +`).split(` +`),n="",o="",i=[];for(;r.length>0;){let a=!1,s=[],u;for(u=0;u1,a={type:"list",raw:"",ordered:i,start:i?+o.slice(0,-1):"",loose:!1,items:[]};o=i?`\\d{1,9}\\${o.slice(-1)}`:`\\${o}`,this.options.pedantic&&(o=i?o:"[*+-]");let s=this.rules.other.listItemRegex(o),u=!1;for(;t;){let g=!1,k="",d="";if(!(e=s.exec(t))||this.rules.block.hr.test(t))break;k=e[0],t=t.substring(k.length);let p=dr(e[2].split(` +`,1)[0],e[1].length),c=t.split(` +`,1)[0],m=!p.trim(),h=0;if(this.options.pedantic?(h=2,d=p.trimStart()):m?h=e[1].length+1:(h=p.search(this.rules.other.nonSpaceChar),h=h>4?1:h,d=p.slice(h),h+=e[1].length),m&&this.rules.other.blankLine.test(c)&&(k+=c+` +`,t=t.substring(c.length+1),g=!0),!g){let w=this.rules.other.nextBulletRegex(h),v=this.rules.other.hrRegex(h),P=this.rules.other.fencesBeginRegex(h),N=this.rules.other.headingBeginRegex(h),L=this.rules.other.htmlBeginRegex(h),D=this.rules.other.blockquoteBeginRegex(h);for(;t;){let F=t.split(` +`,1)[0],z;if(c=F,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),z=c):z=c.replace(this.rules.other.tabCharGlobal," "),P.test(c)||N.test(c)||L.test(c)||D.test(c)||w.test(c)||v.test(c))break;if(z.search(this.rules.other.nonSpaceChar)>=h||!c.trim())d+=` +`+z.slice(h);else{if(m||p.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||P.test(p)||N.test(p)||v.test(p))break;d+=` +`+c}m=!c.trim(),k+=F+` +`,t=t.substring(F.length+1),p=z.slice(h)}}a.loose||(u?a.loose=!0:this.rules.other.doubleBlankLine.test(k)&&(u=!0)),a.items.push({type:"list_item",raw:k,task:!!this.options.gfm&&this.rules.other.listIsTask.test(d),loose:!1,text:d,tokens:[]}),a.raw+=k}let l=a.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;a.raw=a.raw.trimEnd();for(let g of a.items){if(this.lexer.state.top=!1,g.tokens=this.lexer.blockTokens(g.text,[]),g.task){if(g.text=g.text.replace(this.rules.other.listReplaceTask,""),((r=g.tokens[0])==null?void 0:r.type)==="text"||((n=g.tokens[0])==null?void 0:n.type)==="paragraph"){g.tokens[0].raw=g.tokens[0].raw.replace(this.rules.other.listReplaceTask,""),g.tokens[0].text=g.tokens[0].text.replace(this.rules.other.listReplaceTask,"");for(let d=this.lexer.inlineQueue.length-1;d>=0;d--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[d].src)){this.lexer.inlineQueue[d].src=this.lexer.inlineQueue[d].src.replace(this.rules.other.listReplaceTask,"");break}}let k=this.rules.other.listTaskCheckbox.exec(g.raw);if(k){let d={type:"checkbox",raw:k[0]+" ",checked:k[0]!=="[ ]"};g.checked=d.checked,a.loose?g.tokens[0]&&["paragraph","text"].includes(g.tokens[0].type)&&"tokens"in g.tokens[0]&&g.tokens[0].tokens?(g.tokens[0].raw=d.raw+g.tokens[0].raw,g.tokens[0].text=d.raw+g.tokens[0].text,g.tokens[0].tokens.unshift(d)):g.tokens.unshift({type:"paragraph",raw:d.raw,text:d.raw,tokens:[d]}):g.tokens.unshift(d)}}if(!a.loose){let k=g.tokens.filter(p=>p.type==="space"),d=k.length>0&&k.some(p=>this.rules.other.anyLine.test(p.raw));a.loose=d}}if(a.loose)for(let g of a.items){g.loose=!0;for(let k of g.tokens)k.type==="text"&&(k.type="paragraph")}return a}}html(t){let e=this.rules.block.html.exec(t);if(e)return{type:"html",block:!0,raw:e[0],pre:e[1]==="pre"||e[1]==="script"||e[1]==="style",text:e[0]}}def(t){let e=this.rules.block.def.exec(t);if(e){let r=e[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),n=e[2]?e[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",o=e[3]?e[3].substring(1,e[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):e[3];return{type:"def",tag:r,raw:e[0],href:n,title:o}}}table(t){var a;let e=this.rules.block.table.exec(t);if(!e||!this.rules.other.tableDelimiter.test(e[2]))return;let r=Fe(e[1]),n=e[2].replace(this.rules.other.tableAlignChars,"").split("|"),o=(a=e[3])!=null&&a.trim()?e[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],i={type:"table",raw:e[0],header:[],align:[],rows:[]};if(r.length===n.length){for(let s of n)this.rules.other.tableAlignRight.test(s)?i.align.push("right"):this.rules.other.tableAlignCenter.test(s)?i.align.push("center"):this.rules.other.tableAlignLeft.test(s)?i.align.push("left"):i.align.push(null);for(let s=0;s({text:u,tokens:this.lexer.inline(u),header:!1,align:i.align[l]})));return i}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let r=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:r,tokens:this.lexer.inline(r)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let r=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(r)){if(!this.rules.other.endAngleBracket.test(r))return;let i=V(r.slice(0,-1),"\\");if((r.length-i.length)%2===0)return}else{let i=ur(e[2],"()");if(i===-2)return;if(i>-1){let a=(e[0].indexOf("!")===0?5:4)+e[1].length+i;e[2]=e[2].substring(0,i),e[0]=e[0].substring(0,a).trim(),e[3]=""}}let n=e[2],o="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(n);i&&(n=i[1],o=i[3])}else o=e[3]?e[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(r)?n=n.slice(1):n=n.slice(1,-1)),ze(e,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:o&&o.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let r;if((r=this.rules.inline.reflink.exec(t))||(r=this.rules.inline.nolink.exec(t))){let n=(r[2]||r[1]).replace(this.rules.other.multipleSpaceGlobal," "),o=e[n.toLowerCase()];if(!o){let i=r[0].charAt(0);return{type:"text",raw:i,text:i}}return ze(r,o,r[0],this.lexer,this.rules)}}emStrong(t,e,r=""){let n=this.rules.inline.emStrongLDelim.exec(t);if(!(!n||n[3]&&r.match(this.rules.other.unicodeAlphaNumeric))&&(!(n[1]||n[2])||!r||this.rules.inline.punctuation.exec(r))){let o=[...n[0]].length-1,i,a,s=o,u=0,l=n[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(l.lastIndex=0,e=e.slice(-1*t.length+o);(n=l.exec(e))!=null;){if(i=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!i)continue;if(a=[...i].length,n[3]||n[4]){s+=a;continue}else if((n[5]||n[6])&&o%3&&!((o+a)%3)){u+=a;continue}if(s-=a,s>0)continue;a=Math.min(a,a+s+u);let g=[...n[0]][0].length,k=t.slice(0,o+n.index+g+a);if(Math.min(o,a)%2){let p=k.slice(1,-1);return{type:"em",raw:k,text:p,tokens:this.lexer.inlineTokens(p)}}let d=k.slice(2,-2);return{type:"strong",raw:k,text:d,tokens:this.lexer.inlineTokens(d)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let r=e[2].replace(this.rules.other.newLineCharGlobal," "),n=this.rules.other.nonSpaceChar.test(r),o=this.rules.other.startingSpaceChar.test(r)&&this.rules.other.endingSpaceChar.test(r);return n&&o&&(r=r.substring(1,r.length-1)),{type:"codespan",raw:e[0],text:r}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t,e,r=""){let n=this.rules.inline.delLDelim.exec(t);if(n&&(!n[1]||!r||this.rules.inline.punctuation.exec(r))){let o=[...n[0]].length-1,i,a,s=o,u=this.rules.inline.delRDelim;for(u.lastIndex=0,e=e.slice(-1*t.length+o);(n=u.exec(e))!=null;){if(i=n[1]||n[2]||n[3]||n[4]||n[5]||n[6],!i||(a=[...i].length,a!==o))continue;if(n[3]||n[4]){s+=a;continue}if(s-=a,s>0)continue;a=Math.min(a,a+s);let l=[...n[0]][0].length,g=t.slice(0,o+n.index+l+a),k=g.slice(o,-o);return{type:"del",raw:g,text:k,tokens:this.lexer.inlineTokens(k)}}}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let r,n;return e[2]==="@"?(r=e[1],n="mailto:"+r):(r=e[1],n=r),{type:"link",raw:e[0],text:r,href:n,tokens:[{type:"text",raw:r,text:r}]}}}url(t){var r,n;let e;if(e=this.rules.inline.url.exec(t)){let o,i;if(e[2]==="@")o=e[0],i="mailto:"+o;else{let a;do a=e[0],e[0]=(n=(r=this.rules.inline._backpedal.exec(e[0]))==null?void 0:r[0])!=null?n:"";while(a!==e[0]);o=e[0],e[1]==="www."?i="http://"+e[0]:i=e[0]}return{type:"link",raw:e[0],text:o,href:i,tokens:[{type:"text",raw:o,text:o}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let r=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:r}}}},B=class fe{constructor(e){E(this,"tokens");E(this,"options");E(this,"state");E(this,"inlineQueue");E(this,"tokenizer");this.tokens=[],this.tokens.links=Object.create(null),this.options=e||G,this.options.tokenizer=this.options.tokenizer||new se,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let r={other:$,block:ne.normal,inline:J.normal};this.options.pedantic?(r.block=ne.pedantic,r.inline=J.pedantic):this.options.gfm&&(r.block=ne.gfm,this.options.breaks?r.inline=J.breaks:r.inline=J.gfm),this.tokenizer.rules=r}static get rules(){return{block:ne,inline:J}}static lex(e,r){return new fe(r).lex(e)}static lexInline(e,r){return new fe(r).inlineTokens(e)}lex(e){e=e.replace($.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let r=0;r(s=l.call({lexer:this},e,r))?(e=e.substring(s.raw.length),r.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let l=r.at(-1);s.raw.length===1&&l!==void 0?l.raw+=` +`:r.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let l=r.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` +`)?"":` +`)+s.raw,l.text+=` +`+s.text,this.inlineQueue.at(-1).src=l.text):r.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let l=r.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` +`)?"":` +`)+s.raw,l.text+=` +`+s.raw,this.inlineQueue.at(-1).src=l.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title},r.push(s));continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),r.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),r.push(s);continue}let u=e;if((a=this.options.extensions)!=null&&a.startBlock){let l=1/0,g=e.slice(1),k;this.options.extensions.startBlock.forEach(d=>{k=d.call({lexer:this},g),typeof k=="number"&&k>=0&&(l=Math.min(l,k))}),l<1/0&&l>=0&&(u=e.substring(0,l+1))}if(this.state.top&&(s=this.tokenizer.paragraph(u))){let l=r.at(-1);n&&(l==null?void 0:l.type)==="paragraph"?(l.raw+=(l.raw.endsWith(` +`)?"":` +`)+s.raw,l.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):r.push(s),n=u.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let l=r.at(-1);(l==null?void 0:l.type)==="text"?(l.raw+=(l.raw.endsWith(` +`)?"":` +`)+s.raw,l.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):r.push(s);continue}if(e){let l="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(l);break}else throw new Error(l)}}return this.state.top=!0,r}inline(e,r=[]){return this.inlineQueue.push({src:e,tokens:r}),r}inlineTokens(e,r=[]){var u,l,g,k,d,p;let n=e,o=null;if(this.tokens.links){let c=Object.keys(this.tokens.links);if(c.length>0)for(;(o=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)c.includes(o[0].slice(o[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,o.index)+"["+"a".repeat(o[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(o=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,o.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let i;for(;(o=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)i=o[2]?o[2].length:0,n=n.slice(0,o.index+i)+"["+"a".repeat(o[0].length-i-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=(g=(l=(u=this.options.hooks)==null?void 0:u.emStrongMask)==null?void 0:l.call({lexer:this},n))!=null?g:n;let a=!1,s="";for(;e;){a||(s=""),a=!1;let c;if((d=(k=this.options.extensions)==null?void 0:k.inline)!=null&&d.some(h=>(c=h.call({lexer:this},e,r))?(e=e.substring(c.raw.length),r.push(c),!0):!1))continue;if(c=this.tokenizer.escape(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.tag(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.link(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(c.raw.length);let h=r.at(-1);c.type==="text"&&(h==null?void 0:h.type)==="text"?(h.raw+=c.raw,h.text+=c.text):r.push(c);continue}if(c=this.tokenizer.emStrong(e,n,s)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.codespan(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.br(e)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.del(e,n,s)){e=e.substring(c.raw.length),r.push(c);continue}if(c=this.tokenizer.autolink(e)){e=e.substring(c.raw.length),r.push(c);continue}if(!this.state.inLink&&(c=this.tokenizer.url(e))){e=e.substring(c.raw.length),r.push(c);continue}let m=e;if((p=this.options.extensions)!=null&&p.startInline){let h=1/0,w=e.slice(1),v;this.options.extensions.startInline.forEach(P=>{v=P.call({lexer:this},w),typeof v=="number"&&v>=0&&(h=Math.min(h,v))}),h<1/0&&h>=0&&(m=e.substring(0,h+1))}if(c=this.tokenizer.inlineText(m)){e=e.substring(c.raw.length),c.raw.slice(-1)!=="_"&&(s=c.raw.slice(-1)),a=!0;let h=r.at(-1);(h==null?void 0:h.type)==="text"?(h.raw+=c.raw,h.text+=c.text):r.push(c);continue}if(e){let h="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(h);break}else throw new Error(h)}}return r}},ae=class{constructor(t){E(this,"options");E(this,"parser");this.options=t||G}space(t){return""}code({text:t,lang:e,escaped:r}){var i;let n=(i=(e||"").match($.notSpaceStart))==null?void 0:i[0],o=t.replace($.endingNewline,"")+` +`;return n?'
    '+(r?o:_(o,!0))+`
    +`:"
    "+(r?o:_(o,!0))+`
    +`}blockquote({tokens:t}){return`
    +${this.parser.parse(t)}
    +`}html({text:t}){return t}def(t){return""}heading({tokens:t,depth:e}){return`${this.parser.parseInline(t)} +`}hr(t){return`
    +`}list(t){let e=t.ordered,r=t.start,n="";for(let a=0;a +`+n+" +`}listitem(t){return`
  • ${this.parser.parse(t.tokens)}
  • +`}checkbox({checked:t}){return" '}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    +`}table(t){let e="",r="";for(let o=0;o${n}`),` + +`+e+` +`+n+`
    +`}tablerow({text:t}){return` +${t} +`}tablecell(t){let e=this.parser.parseInline(t.tokens),r=t.header?"th":"td";return(t.align?`<${r} align="${t.align}">`:`<${r}>`)+e+` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${_(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:r}){let n=this.parser.parseInline(r),o=He(t);if(o===null)return n;t=o;let i='
    ",i}image({href:t,title:e,text:r,tokens:n}){n&&(r=this.parser.parseInline(n,this.parser.textRenderer));let o=He(t);if(o===null)return _(r);t=o;let i=`${_(r)}{let u=a[s].flat(1/0);r=r.concat(this.walkTokens(u,e))}):a.tokens&&(r=r.concat(this.walkTokens(a.tokens,e)))}}return r}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(r=>{let n={...r};if(n.async=this.defaults.async||n.async||!1,r.extensions&&(r.extensions.forEach(o=>{if(!o.name)throw new Error("extension name required");if("renderer"in o){let i=e.renderers[o.name];i?e.renderers[o.name]=function(...a){let s=o.renderer.apply(this,a);return s===!1&&(s=i.apply(this,a)),s}:e.renderers[o.name]=o.renderer}if("tokenizer"in o){if(!o.level||o.level!=="block"&&o.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let i=e[o.level];i?i.unshift(o.tokenizer):e[o.level]=[o.tokenizer],o.start&&(o.level==="block"?e.startBlock?e.startBlock.push(o.start):e.startBlock=[o.start]:o.level==="inline"&&(e.startInline?e.startInline.push(o.start):e.startInline=[o.start]))}"childTokens"in o&&o.childTokens&&(e.childTokens[o.name]=o.childTokens)}),n.extensions=e),r.renderer){let o=this.defaults.renderer||new ae(this.defaults);for(let i in r.renderer){if(!(i in o))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;let a=i,s=r.renderer[a],u=o[a];o[a]=(...l)=>{let g=s.apply(o,l);return g===!1&&(g=u.apply(o,l)),g||""}}n.renderer=o}if(r.tokenizer){let o=this.defaults.tokenizer||new se(this.defaults);for(let i in r.tokenizer){if(!(i in o))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;let a=i,s=r.tokenizer[a],u=o[a];o[a]=(...l)=>{let g=s.apply(o,l);return g===!1&&(g=u.apply(o,l)),g}}n.tokenizer=o}if(r.hooks){let o=this.defaults.hooks||new Y;for(let i in r.hooks){if(!(i in o))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;let a=i,s=r.hooks[a],u=o[a];Y.passThroughHooks.has(i)?o[a]=l=>{if(this.defaults.async&&Y.passThroughHooksRespectAsync.has(i))return(async()=>{let k=await s.call(o,l);return u.call(o,k)})();let g=s.call(o,l);return u.call(o,g)}:o[a]=(...l)=>{if(this.defaults.async)return(async()=>{let k=await s.apply(o,l);return k===!1&&(k=await u.apply(o,l)),k})();let g=s.apply(o,l);return g===!1&&(g=u.apply(o,l)),g}}n.hooks=o}if(r.walkTokens){let o=this.defaults.walkTokens,i=r.walkTokens;n.walkTokens=function(a){let s=[];return s.push(i.call(this,a)),o&&(s=s.concat(o.call(this,a))),s}}this.defaults={...this.defaults,...n}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return B.lex(t,e!=null?e:this.defaults)}parser(t,e){return H.parse(t,e!=null?e:this.defaults)}parseMarkdown(t){return(e,r)=>{let n={...r},o={...this.defaults,...n},i=this.onError(!!o.silent,!!o.async);if(this.defaults.async===!0&&n.async===!1)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||e===null)return i(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if(o.hooks&&(o.hooks.options=o,o.hooks.block=t),o.async)return(async()=>{let a=o.hooks?await o.hooks.preprocess(e):e,s=await(o.hooks?await o.hooks.provideLexer():t?B.lex:B.lexInline)(a,o),u=o.hooks?await o.hooks.processAllTokens(s):s;o.walkTokens&&await Promise.all(this.walkTokens(u,o.walkTokens));let l=await(o.hooks?await o.hooks.provideParser():t?H.parse:H.parseInline)(u,o);return o.hooks?await o.hooks.postprocess(l):l})().catch(i);try{o.hooks&&(e=o.hooks.preprocess(e));let a=(o.hooks?o.hooks.provideLexer():t?B.lex:B.lexInline)(e,o);o.hooks&&(a=o.hooks.processAllTokens(a)),o.walkTokens&&this.walkTokens(a,o.walkTokens);let s=(o.hooks?o.hooks.provideParser():t?H.parse:H.parseInline)(a,o);return o.hooks&&(s=o.hooks.postprocess(s)),s}catch(a){return i(a)}}}onError(t,e){return r=>{if(r.message+=` +Please report this to https://github.com/markedjs/marked.`,t){let n="

    An error occurred:

    "+_(r.message+"",!0)+"
    ";return e?Promise.resolve(n):n}if(e)return Promise.reject(r);throw r}}},Z=new gr;function C(t,e){return Z.parse(t,e)}C.options=C.setOptions=function(t){return Z.setOptions(t),C.defaults=Z.defaults,We(C.defaults),C};C.getDefaults=ke;C.defaults=G;C.use=function(...t){return Z.use(...t),C.defaults=Z.defaults,We(C.defaults),C};C.walkTokens=function(t,e){return Z.walkTokens(t,e)};C.parseInline=Z.parseInline;C.Parser=H;C.parser=H.parse;C.Renderer=ae;C.TextRenderer=Me;C.Lexer=B;C.lexer=B.lex;C.Tokenizer=se;C.Hooks=Y;C.parse=C;var Nr=C.options,Lr=C.setOptions,$r=C.use,Dr=C.walkTokens,Ir=C.parseInline;var Br=H.parse,Hr=B.lex;function ee(t){return(t||"").trim().replace(/^#+/,"").replace(/[^\w\-/]+/g,"").toLowerCase()}function fr(t){if(!t.startsWith("---"))return"";let e=t.indexOf(` +---`,3);return e===-1?"":t.slice(3,e).trim()}function mr(t){let e=new Set,r=t.split(` +`);for(let o of r){let i=o.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i);if(!i)continue;let a=i[1].split(",").map(s=>ee(s.replace(/["']/g,"")));for(let s of a)s&&e.add(s)}let n=!1;for(let o of r){if(/^\s*tags\s*:\s*$/i.test(o)){n=!0;continue}if(!n)continue;let i=o.match(/^\s*-\s*(.+)\s*$/);if(i){let a=ee(i[1].replace(/["']/g,""));a&&e.add(a);continue}o.trim()!==""&&!/^\s+/.test(o)&&(n=!1)}return e}function Ye(t){let e=new Set,r=fr(t);if(r){let o=mr(r);for(let i of o)e.add(i)}let n=t.match(/\B#([a-zA-Z0-9][\w\-/]{1,50})\b/g);if(n)for(let o of n){let i=ee(o);i&&e.add(i)}return e}function R(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing required element: #${t}`);return e}function Xe(){return{app:R("app"),menuBar:R("menuBar"),workspaceSidebar:R("workspaceSidebar"),sidebarToggleBtn:R("sidebarToggleBtn"),refreshBtn:R("refreshBtn"),sortBtn:R("sortBtn"),searchInput:R("searchInput"),webmcpNoteModal:R("webmcpNoteModal"),webmcpNoteModalBackdrop:R("webmcpNoteModalBackdrop"),webmcpNoteModalCloseBtn:R("webmcpNoteModalCloseBtn"),webmcpNoteForm:R("webmcpNoteForm"),webmcpTitleInput:R("webmcpTitleInput"),webmcpBodyInput:R("webmcpBodyInput"),webmcpTagInput:R("webmcpTagInput"),tree:R("tree"),tagRow:R("tagRow"),workspaceName:R("workspaceName"),countsPill:R("countsPill"),editor:R("editor"),preview:R("preview"),currentFilename:R("currentFilename"),dirtyDot:R("dirtyDot"),statusBadge:R("statusBadge"),toast:R("toast"),toastMsg:R("toastMsg"),toastCloseBtn:R("toastCloseBtn"),temporarySessionBadge:R("temporarySessionBadge"),cogitoToggleBtn:R("cogitoToggleBtn"),cogitoPanel:R("cogitoPanel"),cogitoLiteBtn:R("cogitoLiteBtn"),cogitoDeepBtn:R("cogitoDeepBtn"),cogitoGenerateBtn:R("cogitoGenerateBtn"),cogitoStatus:R("cogitoStatus"),cogitoQuestionList:R("cogitoQuestionList")}}function et(){return!!(window.showDirectoryPicker&&window.FileSystemHandle)}async function Re(t,e="read"){if(!t)return!1;if(!t.queryPermission||!t.requestPermission)return!0;let r={mode:e};return await t.queryPermission(r)==="granted"?!0:await t.requestPermission(r)==="granted"}function tt({state:t,ensurePermission:e,rescanWorkspace:r,showToast:n,setStatus:o}){function i(){s(),t.autoRefreshTimer=setInterval(()=>{a().catch(u=>{n(`Auto-refresh failed: ${String(u)}`,{persist:!0}),o("Auto-refresh failed","err")})},t.autoRefreshMs)}async function a(){if(t.workspaceHandle){try{if(!await e(t.workspaceHandle,"read"))throw new Error("Folder permission revoked.")}catch(u){s(),n("Auto-refresh stopped: folder permission revoked.",{persist:!0}),o("Permission revoked","err");return}t.isDirty||await r({silent:!0})}}function s(){t.autoRefreshTimer&&(clearInterval(t.autoRefreshTimer),t.autoRefreshTimer=null)}return{startAutoRefresh:i,stopAutoRefresh:s}}function q(t){return String(t).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function Ce(t,e){try{t.preview.innerHTML=C.parse(e||"")}catch(r){let n=r instanceof Error?r.message:String(r);t.preview.innerHTML=`
    ${q(n)}
    `}}function pe(t,e,r){t.dirtyDot.classList.toggle("show",e.isDirty);let n=e.currentRelPath?e.currentRelPath.split("/").pop():"No note open";t.currentFilename.textContent=`${n}${e.isDirty?" \u2022 Unsaved":""}`,e.isDirty&&r("Unsaved changes","warn")}var ue=["default","classic","cobalt","monokai","office","twilight","xcode"];function Ee(t){if(!ue.includes(t))return;t==="default"?document.documentElement.removeAttribute("data-theme"):document.documentElement.setAttribute("data-theme",t);try{localStorage.setItem("ink-theme",t)}catch(r){}document.querySelectorAll(".menu-theme-check").forEach(r=>{r.classList.remove("active")});let e=document.getElementById(`themeCheck-${t}`);e&&e.classList.add("active")}function rt(){var e;let t="default";try{t=(e=localStorage.getItem("ink-theme"))!=null?e:"default"}catch(r){}Ee(ue.includes(t)?t:"default")}function nt(t,e){let r=e?"Cmd":"Ctrl";t.menuBar.querySelectorAll(".menu-shortcut").forEach(o=>{let i=o.textContent;i&&(i.includes("Cmd/Ctrl")?o.textContent=i.replace("Cmd/Ctrl",r):i.includes("Ctrl")&&(o.textContent=i.replace("Ctrl",r)))})}function ot({state:t,els:e,showToast:r,renderTree:n,callbacks:o}){function i(){t.sortMode=t.sortMode==="name"?"modified":"name",e.sortBtn.textContent=`Sort: ${t.sortMode==="name"?"Name":"Last modified"}`;let u=document.querySelector('[data-action="sort"] .menu-label-text');u&&(u.textContent=`Sort: ${t.sortMode==="name"?"Name":"Modified"}`),n().catch(l=>{r(`Sort render failed: ${String(l)}`,{persist:!0})})}function a(){t.isDirty&&!confirm("You have unsaved changes. Are you sure you want to exit?")||window.close()}function s(u){switch(u){case"new-note":o.createNewNote().catch(l=>{r(`Create note failed: ${String(l)}`,{persist:!0})});break;case"new-folder":o.createNewFolder().catch(l=>{r(`Create folder failed: ${String(l)}`,{persist:!0})});break;case"open-workspace":o.openWorkspace().catch(l=>{r(`Failed to open workspace: ${String(l)}`,{persist:!0})});break;case"close-workspace":o.closeWorkspace();break;case"exit":a();break;case"save":o.saveCurrentNote().catch(l=>{r(`Save failed: ${String(l)}`,{persist:!0})});break;case"save-as":o.saveAsNewNote().catch(l=>{r(`Save As failed: ${String(l)}`,{persist:!0})});break;case"refresh":o.handleRefresh();break;case"sort":i();break;case"collapse-sidebar":o.setSidebarCollapsed(!t.isSidebarCollapsed);break;case"export-json":o.exportAsJson();break;case"export-markdown":o.exportAsMarkdown();break;case"theme-default":case"theme-classic":case"theme-cobalt":case"theme-monokai":case"theme-office":case"theme-twilight":case"theme-xcode":{let l=u.replace("theme-","");ue.includes(l)&&Ee(l);break}}}return{toggleSort:i,handleMenuAction:s,handleExit:a}}function Ae(t,e){let r=e.isSidebarCollapsed;t.app.classList.toggle("sidebar-collapsed",r),t.workspaceSidebar.classList.toggle("collapsed",r),t.sidebarToggleBtn.setAttribute("aria-expanded",String(!r)),t.sidebarToggleBtn.setAttribute("aria-label",r?"Expand sidebar":"Collapse sidebar"),t.sidebarToggleBtn.title=r?"Expand sidebar":"Collapse sidebar",t.sidebarToggleBtn.textContent=r?"\u25BC Expand":"\u25B6 Collapse"}function Pe(t,e,r){e.isSidebarCollapsed=r,Ae(t,e)}function U(t,e,r="neutral"){t.statusBadge.textContent=e,t.statusBadge.classList.remove("ok","warn","err"),r!=="neutral"&&t.statusBadge.classList.add(r)}function it(t,e){function r(o,i={}){t.toastMsg.textContent=o,t.toast.classList.add("show"),e.current&&(clearTimeout(e.current),e.current=null),i.persist||(e.current=setTimeout(()=>{t.toast.classList.remove("show")},3500))}function n(){t.toast.classList.remove("show")}return{showToast:r,hideToast:n}}var te={folder:()=>'',folderOpen:()=>'',fileText:()=>'',library:()=>'',check:()=>''};function st({state:t,els:e,handlers:r,showToast:n}){function o(){let d=new Map,p=t.isTemporarySession?t.inMemoryNotes:t.notes;for(let h of p)for(let w of h.tags)d.set(w,(d.get(w)||0)+1);let c=[...d.entries()].sort((h,w)=>w[1]-h[1]||h[0].localeCompare(w[0])).slice(0,50);if(e.tagRow.innerHTML="",c.length===0)return;let m=document.createElement("button");m.className=`tag${t.tagFilter?"":" active"}`,m.textContent="All",m.title="Clear tag filter",m.addEventListener("click",()=>{t.tagFilter="",o(),t.isTemporarySession?l():a().catch(h=>{n(`Tag render failed: ${String(h)}`,{persist:!0})})}),e.tagRow.appendChild(m);for(let[h,w]of c){let v=document.createElement("button");v.className=`tag${t.tagFilter===h?" active":""}`,v.textContent=`#${h}`,v.title=`${w} note${w===1?"":"s"} tagged #${h}`,v.addEventListener("click",()=>{t.tagFilter=t.tagFilter===h?"":h,o(),t.isTemporarySession?l():a().catch(P=>{n(`Tag render failed: ${String(P)}`,{persist:!0})})}),e.tagRow.appendChild(v)}}async function i(){let d=[...t.notes];t.tagFilter&&(d=d.filter(m=>m.tags.has(t.tagFilter))),t.sortMode==="modified"?d.sort((m,h)=>(h.lastModified||0)-(m.lastModified||0)):d.sort((m,h)=>m.relPath.localeCompare(h.relPath));let p=t.searchQuery.trim().toLowerCase();if(!p)return new Set(d.map(m=>m.relPath));let c=new Set;for(let m of d){if(m.relPath.toLowerCase().includes(p)){c.add(m.relPath);continue}try{(await(await m.handle.getFile()).text()).toLowerCase().includes(p)&&c.add(m.relPath)}catch(h){}}return c}async function a(){if(!t.fileTree){e.tree.innerHTML='
    Open a folder to begin.
    ';return}let d=await i(),p=s(t.fileTree,d);if(e.tree.innerHTML="",!p||p.type!=="dir"||p.children.length===0){e.tree.innerHTML='
    No matching notes.
    ';return}for(let c of p.children)u(c,0)}function s(d,p){if(d.type==="file")return p.has(d.relPath)?d:null;let c=[];for(let m of d.children){let h=s(m,p);h&&c.push(h)}return d.relPath===""?{...d,children:c}:c.length===0?null:{...d,children:c}}function u(d,p){let c=document.createElement("div");if(c.className="node",c.style.paddingLeft=`${8+p*12}px`,d.type==="dir"){let w=t.collapsedDirs.has(d.relPath);if(c.innerHTML=` + ${w?"\u25B6":"\u25BC"} + ${w?te.folder():te.folderOpen()} + ${q(d.name)} + `,c.addEventListener("click",v=>{v.stopPropagation(),w?t.collapsedDirs.delete(d.relPath):t.collapsedDirs.add(d.relPath),a().catch(P=>{n(`Tree render failed: ${String(P)}`,{persist:!0})})}),e.tree.appendChild(c),!w)for(let v of d.children)u(v,p+1);return}d.relPath===t.currentRelPath&&c.classList.add("active");let h=t.sortMode==="modified"&&d.noteRef.lastModified?new Date(d.noteRef.lastModified).toLocaleDateString():"";c.innerHTML=` + ${te.fileText()} + ${q(d.noteRef.name)} + ${q(h)} + `,c.addEventListener("click",w=>{w.stopPropagation(),r.openNoteByRelPath(d.relPath,d.handle).catch(v=>{n(`Open note failed: ${String(v)}`,{persist:!0})})}),e.tree.appendChild(c)}function l(){if(e.tree.innerHTML="",t.inMemoryNotes.length===0){e.tree.innerHTML='
    Temporary session. Create a note to begin.
    ';return}let d=[...t.inMemoryNotes].sort((p,c)=>p.relPath.localeCompare(c.relPath));for(let p of d)g(p)}function g(d){let p=document.createElement("div");p.className="node",d.relPath===t.currentRelPath&&p.classList.add("active");let m=t.sortMode==="modified"?new Date(d.lastModified).toLocaleDateString():"";p.innerHTML=` + ${te.fileText()} + ${q(d.name)} + ${q(m)} + `,p.addEventListener("click",()=>{r.openInMemoryNote(d.relPath)}),e.tree.appendChild(p)}function k(){let d=t.inMemoryNotes.length;e.countsPill.textContent=`${d} note${d===1?"":"s"}`}return{computeMatches:i,renderTree:a,renderTags:o,renderInMemoryTree:l,updateCountsPill:k}}function at({state:t,els:e,actions:r}){let n=!1,o=Ne(e),i=null;function a(p){e.webmcpNoteModal.setAttribute("aria-hidden","false"),e.webmcpNoteModal.classList.add("show"),p!=null&&p.focusTitle&&queueMicrotask(()=>{e.webmcpTitleInput.focus()})}function s(){e.webmcpNoteModal.classList.remove("show"),e.webmcpNoteModal.setAttribute("aria-hidden","true")}function u(p){n=!0,a(p)}function l(){if(e.webmcpNoteModal.classList.contains("show")){s();return}a({focusTitle:!0})}function g(){let p=Ne(e);p!==o&&(o=p,kr(e)&&u())}function k(){i===null&&(i=window.setInterval(()=>{g()},150))}xr(e,r.handleMenuAction),e.sidebarToggleBtn.addEventListener("click",()=>{r.toggleSidebar()}),e.refreshBtn.addEventListener("click",()=>{r.handleRefresh()}),e.sortBtn.addEventListener("click",()=>{r.toggleSort()}),e.searchInput.addEventListener("input",()=>{r.handleSearchInput(e.searchInput.value)});let d=[e.webmcpTitleInput,e.webmcpBodyInput,e.webmcpTagInput];d.forEach(p=>{p.addEventListener("input",g),p.addEventListener("change",g),p.addEventListener("focus",()=>{e.webmcpNoteModal.classList.contains("show")||u()})}),d.forEach(p=>{wr(p,g)}),e.webmcpNoteModalCloseBtn.addEventListener("click",()=>{s()}),e.webmcpNoteModalBackdrop.addEventListener("click",()=>{s()}),e.webmcpNoteForm.addEventListener("submit",p=>{let c=p,m=typeof c.respondWith=="function";p.preventDefault(),c.agentInvoked&&u();let h={title:e.webmcpTitleInput.value,body:e.webmcpBodyInput.value,tag:e.webmcpTagInput.value},w=r.createNoteFromTool(h).then(v=>(v.ok&&(m||(e.webmcpNoteForm.reset(),o=Ne(e)),n&&(s(),n=!1)),v));if(typeof c.respondWith=="function"){c.respondWith(w);return}w.catch(v=>{r.showToast(`WebMCP note creation failed: ${String(v)}`,{persist:!0})})}),e.editor.addEventListener("input",()=>{t.currentRelPath&&r.handleEditorInput(e.editor.value)}),e.editor.addEventListener("keydown",p=>{if((navigator.platform.toLowerCase().includes("mac")?p.metaKey:p.ctrlKey)&&!p.shiftKey&&p.key.toLowerCase()==="s"){p.preventDefault(),r.saveCurrentNote().catch(h=>{r.showToast(`Save failed: ${String(h)}`,{persist:!0})});return}if(p.key==="Tab"){p.preventDefault();let h=e.editor.selectionStart;e.editor.setRangeText(" ",h,h,"end")}}),e.toastCloseBtn.addEventListener("click",()=>{r.hideToast()}),e.cogitoToggleBtn.addEventListener("click",()=>{r.toggleCogitoPanel()}),e.cogitoLiteBtn.addEventListener("click",()=>{r.selectCogitoModel("lite")}),e.cogitoDeepBtn.addEventListener("click",()=>{r.selectCogitoModel("deep")}),e.cogitoGenerateBtn.addEventListener("click",()=>{r.generateCogitoQuestions().catch(p=>{r.showToast(`Cogito generation failed: ${String(p)}`,{persist:!0})})}),e.cogitoQuestionList.addEventListener("click",p=>{let c=p.target;if(!c)return;let m=c.closest("[data-question-index]");if(!m)return;let h=m.getAttribute("data-question-index"),w=Number(h);Number.isNaN(w)||r.insertCogitoQuestion(w)}),window.addEventListener("beforeunload",p=>{t.isDirty&&(p.preventDefault(),p.returnValue="")}),window.addEventListener("keydown",p=>{p.key==="Escape"&&e.webmcpNoteModal.classList.contains("show")&&s()}),window.addEventListener("keydown",p=>{(/Mac|iPod|iPhone|iPad/.test(navigator.userAgent)?p.metaKey:p.ctrlKey)&&p.altKey&&p.key.toLowerCase()==="n"&&(p.preventDefault(),l())}),br()&&a({focusTitle:!0}),k(),yr(r)}function Ne(t){return[t.webmcpTitleInput.value,t.webmcpBodyInput.value,t.webmcpTagInput.value].join("\u241F")}function kr(t){return t.webmcpTitleInput.value.trim()!==""||t.webmcpBodyInput.value.trim()!==""||t.webmcpTagInput.value.trim()!==""}function wr(t,e){var o;let r=Object.getPrototypeOf(t),n=Object.getOwnPropertyDescriptor(r,"value");!(n!=null&&n.get)||!n.set||Object.defineProperty(t,"value",{configurable:!0,enumerable:(o=n.enumerable)!=null?o:!0,get(){return n.get.call(this)},set(i){n.set.call(this,i),e()}})}function br(){try{return new URLSearchParams(window.location.search).get("debugWebMcpNote")==="1"}catch(t){return!1}}function xr(t,e){let r=t.menuBar.querySelectorAll(".menu-item");r.forEach(i=>{i.addEventListener("click",a=>{a.stopPropagation();let s=i.getAttribute("aria-expanded")==="true";r.forEach(u=>u.setAttribute("aria-expanded","false")),s||i.setAttribute("aria-expanded","true")}),i.addEventListener("keydown",a=>{a.key==="Escape"&&i.setAttribute("aria-expanded","false")})});let n=t.menuBar.querySelectorAll(".submenu-parent");n.forEach(i=>{i.addEventListener("click",a=>{a.stopPropagation();let s=i.getAttribute("aria-expanded")==="true";n.forEach(u=>u.setAttribute("aria-expanded","false")),s||i.setAttribute("aria-expanded","true")}),i.addEventListener("keydown",a=>{a.key==="Escape"&&i.setAttribute("aria-expanded","false")})}),document.addEventListener("click",()=>{r.forEach(i=>i.setAttribute("aria-expanded","false")),n.forEach(i=>i.setAttribute("aria-expanded","false"))}),t.menuBar.querySelectorAll(".dropdown li[data-action]").forEach(i=>{i.addEventListener("click",()=>{let a=i.getAttribute("data-action");a&&e(a),r.forEach(s=>s.setAttribute("aria-expanded","false")),n.forEach(s=>s.setAttribute("aria-expanded","false"))})})}function yr(t){window.addEventListener("keydown",e=>{let n=/Mac|iPod|iPhone|iPad/.test(navigator.userAgent)?e.metaKey:e.ctrlKey,o=e.altKey;if(n&&e.key.toLowerCase()==="l"){e.preventDefault(),t.handleRefresh();return}if(n&&e.key.toLowerCase()==="s"&&!e.shiftKey){e.preventDefault(),t.saveCurrentNote().catch(i=>{t.showToast(`Save failed: ${String(i)}`,{persist:!0})});return}if(n&&e.shiftKey&&e.key.toLowerCase()==="s"){e.preventDefault(),t.exportAsJson();return}if(n&&e.shiftKey&&e.key.toLowerCase()==="m"){e.preventDefault(),t.exportAsMarkdown();return}if(n&&e.key.toLowerCase()==="e"){e.preventDefault(),t.createNewNote().catch(i=>{t.showToast(`Create note failed: ${String(i)}`,{persist:!0})});return}if(n&&e.shiftKey&&e.key.toLowerCase()==="o"){e.preventDefault(),t.openWorkspace().catch(i=>{t.showToast(`Failed to open workspace: ${String(i)}`,{persist:!0})});return}o&&e.shiftKey&&e.key.toLowerCase()==="o"&&(e.preventDefault(),t.openWorkspace().catch(i=>{t.showToast(`Failed to open workspace: ${String(i)}`,{persist:!0})}))})}function lt({state:t,els:e,showToast:r,setStatus:n,renderPreview:o,updateDirtyUi:i,renderTree:a,renderInMemoryTree:s,renderTags:u,updateCountsPill:l,fsApi:g,parseTags:k,normalizeTag:d,autoRefresh:p}){function c(){let f=t.isTemporarySession;t.workspaceHandle=null,t.workspaceName="Temporary Session",t.fileTree=null,t.notes=[],t.currentFileHandle=null,f||(t.inMemoryNotes=[],t.currentRelPath="",t.currentContent="",t.isDirty=!1,e.editor.value="",o(e,""),i(e,t,n)),t.isTemporarySession=!0,e.workspaceName.textContent=t.workspaceName,e.workspaceName.title="Temporary Session - Data not persisted",e.temporarySessionBadge.style.display="inline",e.countsPill.textContent=`${t.inMemoryNotes.length} note${t.inMemoryNotes.length===1?"":"s"}`,e.tagRow.innerHTML="",s(),u()}function m(f){let b=f.trim().replace(/[\\/:*?"<>|]/g," ").replace(/\s+/g," ").trim()||"Untitled";return b.endsWith(".md")?b:`${b}.md`}function h({title:f,body:x,tag:b}){let y=f.trim()||"Untitled",T=x.trim(),S=d(b),A=[];return S&&A.push("---",`tags: [${S}]`,"---",""),A.push(`# ${y}`),T&&A.push("",T),A.join(` +`)}async function w(){if(!g.isFileSystemApiAvailable()){c(),e.tree.innerHTML='
    Temporary session. Create a note to begin.
    ',r("Temporary in-memory workspace enabled. Use Export to save your notes.",{persist:!0}),n("Temporary session","warn");return}try{if(!window.showDirectoryPicker)throw new Error("File System Access API not available");let f=await window.showDirectoryPicker({id:"local-md-workspace",mode:"readwrite"});if(!await g.ensurePermission(f,"readwrite")){r("Permission denied. Please allow access to the folder.",{persist:!0}),n("Permission denied","err");return}t.workspaceHandle=f,t.workspaceName=f.name||"Selected folder",t.isTemporarySession=!1,t.collapsedDirs.clear(),t.tagFilter="",e.temporarySessionBadge.style.display="none",e.workspaceName.textContent=t.workspaceName,e.workspaceName.title=t.workspaceName,e.tagRow.innerHTML="",n("Scanning folder..."),await v(),p.startAutoRefresh(),r("Workspace opened."),n("Workspace ready","ok")}catch(f){if(f instanceof Error&&f.name==="AbortError"){n("Open folder cancelled");return}let x=f instanceof Error?f.message:String(f);r(`Failed to open folder: ${x}`,{persist:!0}),n("Failed to open folder","err")}}async function v(f={}){if(t.workspaceHandle)try{if(!await g.ensurePermission(t.workspaceHandle,"read"))throw new Error("Folder permission not granted (read)");let b=[],y={type:"dir",name:t.workspaceName,relPath:"",children:[]};await P(t.workspaceHandle,y,"",b),await N(b),t.notes=b,t.fileTree=y,e.countsPill.textContent=`${b.length} note${b.length===1?"":"s"}`,u(),await a(),f.silent||(r("Workspace refreshed."),n("Refreshed","ok"))}catch(x){let b=x instanceof Error?x.message:String(x);r(`Refresh failed: ${b}`,{persist:!0}),n("Refresh failed","err")}}async function P(f,x,b,y){for await(let[T,S]of f.entries()){if(T.startsWith("."))continue;if(S.kind==="directory"){let O=b?`${b}/${T}`:T,$e={type:"dir",name:T,relPath:O,children:[]};x.children.push($e),await P(S,$e,O,y);continue}if(!T.toLowerCase().endsWith(".md"))continue;let A=b?`${b}/${T}`:T,j=0,I=0;try{let O=await S.getFile();j=O.lastModified||0,I=O.size||0}catch(O){r(`Skipped a file that couldn't be read: ${A}`);continue}let W={handle:S,name:T,relPath:A,lastModified:j,size:I,tags:new Set};y.push(W);let Q={type:"file",name:T,relPath:A,handle:S,noteRef:W};x.children.push(Q)}x.children.sort((T,S)=>T.type!==S.type?T.type==="dir"?-1:1:T.name.localeCompare(S.name))}async function N(f){for(let b of f)try{let y=await b.handle.getFile(),S=await(y.size>262144?y.slice(0,262144):y).text();b.tags=k(S)}catch(y){b.tags=new Set}}async function L(f,x=null){var b;if(!(t.isDirty&&t.currentFileHandle&&!confirm("You have unsaved changes. Discard them?")))try{let y=x||((b=t.notes.find(A=>A.relPath===f))==null?void 0:b.handle);if(!y)throw new Error("File not found");let S=await(await y.getFile()).text();t.currentFileHandle=y,t.currentRelPath=f,t.currentContent=S,t.isDirty=!1,e.editor.value=S,o(e,S),i(e,t,n),n("Opened \u2713","ok"),await a()}catch(y){let T=y instanceof Error?y.message:String(y);r(`Failed to open note: ${T}`,{persist:!0}),n("Open failed","err")}}async function D(){if(t.isTemporarySession)return Le();if(t.currentFileHandle)try{let f=await t.currentFileHandle.createWritable();await f.write(e.editor.value),await f.close(),t.currentContent=e.editor.value,t.isDirty=!1,i(e,t,n),n("Saved \u2713","ok"),r("Saved \u2713"),await v({silent:!0})}catch(f){let x=f instanceof Error?f.message:String(f);r(`Save failed: ${x}`,{persist:!0}),n("Save failed","err")}}async function F(){if(!t.workspaceHandle&&!t.isTemporarySession){r("Open a workspace first.");return}if(t.isDirty&&!confirm("You have unsaved changes. Continue and discard them?"))return;let f=prompt("New note name (without .md)");if(!f)return;let x=f.endsWith(".md")?f:`${f}.md`;if(t.isTemporarySession)return de(x,f);try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");let b=t.workspaceHandle;for await(let[A]of b.entries())if(A===x){r("A file with that name already exists.",{persist:!0});return}let y=await t.workspaceHandle.getFileHandle(x,{create:!0}),T=`# ${f} + +Created ${new Date().toLocaleString()} +`,S=await y.createWritable();await S.write(T),await S.close(),await v({silent:!0}),await L(x,y),r("New note created \u2713"),n("New note","ok")}catch(b){let y=b instanceof Error?b.message:String(b);r(`Failed to create note: ${y}`,{persist:!0}),n("Create failed","err")}}async function z(){if(!t.workspaceHandle&&!t.isTemporarySession){r("Open a workspace first.");return}let f=t.isTemporarySession?e.editor.value:t.currentContent;if(!f){r("Nothing to save.");return}let x=prompt("Save note as (filename without .md):");if(!x)return;let b=x.endsWith(".md")?x:`${x}.md`;if(t.isTemporarySession)return de(b,x);try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");for await(let[S]of t.workspaceHandle.entries())if(S===b){r("A file with that name already exists.",{persist:!0});return}let y=await t.workspaceHandle.getFileHandle(b,{create:!0}),T=await y.createWritable();await T.write(f),await T.close(),await v({silent:!0}),await L(b,y),r(`Saved as ${b} \u2713`),n("Saved as","ok")}catch(y){let T=y instanceof Error?y.message:String(y);r(`Failed to save: ${T}`,{persist:!0}),n("Save failed","err")}}async function re(f=t.workspaceHandle){if(!f){if(t.isTemporarySession){r("Folders are not supported in temporary session.");return}r("Open a workspace first.");return}let x=prompt("Folder name:");if(x)try{await f.getDirectoryHandle(x,{create:!0}),await v({silent:!0}),r("Folder created \u2713"),n("Folder created","ok")}catch(b){let y=b instanceof Error?b.message:String(b);r(`Failed to create folder: ${y}`,{persist:!0}),n("Create folder failed","err")}}async function de(f,x){if(t.inMemoryNotes.find(S=>S.relPath===f)){r("A note with that name already exists.",{persist:!0});return}let y=`# ${x} + +Created ${new Date().toLocaleString()} +`,T={name:f,relPath:f,content:y,lastModified:Date.now(),tags:k(y)};t.inMemoryNotes.push(T),t.currentRelPath=f,t.currentContent=y,t.isDirty=!1,e.editor.value=y,o(e,y),i(e,t,n),s(),l(),r("New note created \u2713"),n("New note","ok")}async function ut(f){let x=f.title.trim(),b=f.body.trim(),y=f.tag.trim();if(!x||!b){let I="Title and body are required.";return r(I,{persist:!0}),n("Create failed","err"),{ok:!1,message:I}}!t.workspaceHandle&&!t.isTemporarySession&&(c(),r("Temporary in-memory workspace enabled for note creation.",{persist:!0}),n("Temporary session","warn"));let T=m(x),S=h({title:x,body:b,tag:y}),A=t.isDirty,j=A?`Created ${T}. Current unsaved note was left open.`:`Created ${T} \u2713`;if(t.isTemporarySession){if(t.inMemoryNotes.find(Q=>Q.relPath===T)){let Q="A note with that name already exists.";return r(Q,{persist:!0}),n("Create failed","err"),{ok:!1,message:Q}}let W={name:T,relPath:T,content:S,lastModified:Date.now(),tags:k(S)};return t.inMemoryNotes.push(W),A||(t.currentRelPath=T,t.currentContent=S,t.isDirty=!1,e.editor.value=S,o(e,S),i(e,t,n)),s(),u(),l(),r(j),n("New note","ok"),{ok:!0,message:j,notePath:T,sessionType:"temporary",keptCurrentNote:A}}try{if(!t.workspaceHandle)throw new Error("Workspace handle is not available");for await(let[Q]of t.workspaceHandle.entries())if(Q===T){let O="A file with that name already exists.";return r(O,{persist:!0}),n("Create failed","err"),{ok:!1,message:O}}let I=await t.workspaceHandle.getFileHandle(T,{create:!0}),W=await I.createWritable();return await W.write(S),await W.close(),await v({silent:!0}),A?n("New note","ok"):await L(T,I),r(j),{ok:!0,message:j,notePath:T,sessionType:"workspace",keptCurrentNote:A}}catch(I){let W=I instanceof Error?I.message:String(I);return r(`Failed to create note: ${W}`,{persist:!0}),n("Create failed","err"),{ok:!1,message:W}}}async function dt(f){if(t.isDirty&&t.currentRelPath&&!confirm("You have unsaved changes. Discard them?"))return;let x=t.inMemoryNotes.find(b=>b.relPath===f);if(!x){r("Note not found",{persist:!0});return}t.currentRelPath=f,t.currentContent=x.content,t.isDirty=!1,e.editor.value=x.content,o(e,x.content),i(e,t,n),n("Opened \u2713","ok"),s()}async function Le(){if(!t.currentRelPath||!t.isTemporarySession)return;let f=t.inMemoryNotes.find(x=>x.relPath===t.currentRelPath);if(!f){r("Note not found",{persist:!0});return}f.content=e.editor.value,f.lastModified=Date.now(),f.tags=k(f.content),t.currentContent=f.content,t.isDirty=!1,i(e,t,n),n("Saved \u2713","ok"),r("Saved \u2713"),s(),u()}function ht(){if(t.inMemoryNotes.length===0){r("No notes to export.");return}let f={exportedAt:new Date().toISOString(),notes:t.inMemoryNotes.map(A=>({name:A.name,path:A.relPath,content:A.content,lastModified:new Date(A.lastModified).toISOString()}))},x=new Blob([JSON.stringify(f,null,2)],{type:"application/json"}),b=URL.createObjectURL(x),T=`ink-export-${new Date().toISOString().split("T")[0]}.json`,S=document.createElement("a");S.href=b,S.download=T,document.body.appendChild(S),S.click(),document.body.removeChild(S),URL.revokeObjectURL(b),r(`Exported ${t.inMemoryNotes.length} note(s) as JSON.`),n("Exported JSON","ok")}function gt(){if(!t.currentRelPath){r("No note selected to export.");return}let f=t.inMemoryNotes.find(T=>T.relPath===t.currentRelPath);if(!f){r("Note not found.",{persist:!0});return}let x=new Blob([f.content],{type:"text/markdown"}),b=URL.createObjectURL(x),y=document.createElement("a");y.href=b,y.download=f.name,document.body.appendChild(y),y.click(),document.body.removeChild(y),URL.revokeObjectURL(b),r(`Exported ${f.name} as Markdown.`),n("Exported MD","ok")}function ft(){if(t.isTemporarySession){r("Refresh not available in temporary session. Your data is in memory.");return}if(!t.workspaceHandle){r("No workspace open.");return}v().catch(f=>{r(`Refresh failed: ${String(f)}`,{persist:!0}),n("Refresh failed","err")})}function mt(){p.stopAutoRefresh(),t.workspaceHandle=null,t.workspaceName="",t.fileTree=null,t.notes=[],t.currentFileHandle=null,t.currentRelPath="",t.currentContent="",t.isDirty=!1,t.searchQuery="",t.tagFilter="",t.collapsedDirs.clear(),e.workspaceName.textContent="No folder selected",e.workspaceName.title="No folder selected",e.countsPill.textContent="0 notes",e.tagRow.innerHTML="",e.tree.innerHTML='
    Open a folder to begin.
    ',e.editor.value="",e.preview.innerHTML="",e.currentFilename.textContent="No note open",e.dirtyDot.classList.remove("show"),n("Ready"),r("Workspace closed.")}return{openWorkspace:w,rescanWorkspace:v,openNoteByRelPath:L,saveCurrentNote:D,createNewNote:F,saveAsNewNote:z,createNoteFromTool:ut,createNewFolder:re,createInMemoryNote:de,openInMemoryNote:dt,saveInMemoryNote:Le,exportAsJson:ht,exportAsMarkdown:gt,handleRefresh:ft,closeWorkspace:mt}}var vr=`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": ["...", "...", "..."] +}`,Tr="Llama-3.2-1B-Instruct-q4f32_1-MLC",Sr="Qwen3-8B-q4f16_1-MLC";function Mr(t){let e=t.replace(/\s+/g," ").trim();if(!e)return"";let r=e.split(/(?<=[.!?])\s+/).map(n=>n.trim()).filter(Boolean);return r.length===0?"":r[r.length-1]}function Rr(t){let e=t.replace(/[\s\S]*?<\/think>/gi,"").replace(/^```(?:json)?\s*/i,"").replace(/```\s*$/,"").trim(),r=JSON.parse(e);if(!r||!Array.isArray(r.questions))throw new Error("Cogito response did not include a questions array.");let n=r.questions.filter(o=>typeof o=="string"&&o.trim().length>0).map(o=>o.trim());if(n.length===0)throw new Error("Cogito response contained no valid questions.");if(n.length!==3)throw new Error("Cogito response must contain exactly 3 questions.");return n}function Cr(t){return`> ### AI +${t.trim()} +`}function Er(t,e){let{selectionStart:r,selectionEnd:n,value:o}=t,i=o.slice(0,r),a=o.slice(n);t.value=`${i}${e}${a}`;let s=i.length+e.length;t.setSelectionRange(s,s)}function ct({els:t,getEditorText:e,onEditorContentReplaced:r,showToast:n,setStatus:o}){let i=!1,a=[],s="lite",u={};async function l(){let w=globalThis.__INK_TEST_WEBLLM__;return w||import("https://esm.run/@mlc-ai/web-llm")}function g(w){s=w,t.cogitoLiteBtn.classList.toggle("active",w==="lite"),t.cogitoDeepBtn.classList.toggle("active",w==="deep")}function k(w){i=w,t.cogitoPanel.hidden=!w,t.cogitoToggleBtn.setAttribute("aria-expanded",String(w));let v=t.cogitoPanel.closest(".split");v&&v.classList.toggle("with-cogito",w)}function d(w){t.cogitoQuestionList.innerHTML="",w.forEach((v,P)=>{let N=document.createElement("li");N.className="cogitoQuestionItem";let L=document.createElement("p");L.className="cogitoQuestionText",L.textContent=v;let D=document.createElement("button");D.type="button",D.className="ghost cogitoInsertBtn",D.dataset.questionIndex=String(P),D.textContent="Insert",D.title="Insert question into markdown",N.append(L,D),t.cogitoQuestionList.appendChild(N)})}function p(w){t.cogitoStatus.textContent=w}async function c(){let w=s==="deep"?Sr:Tr;return u[s]||(u[s]=(async()=>(p(`Loading ${s==="deep"?"Deep (Qwen3 8B)":"Lite (Llama 1B)"} model...`),await(await l()).CreateMLCEngine(w,{initProgressCallback:N=>{N!=null&&N.text&&p(N.text)}})))().catch(v=>{throw delete u[s],v})),u[s]}async function m(){var v,P,N;let w=Mr(e());if(!w){p("Write at least one sentence first, then generate Cogito questions."),o("Cogito needs a sentence","warn");return}try{t.cogitoGenerateBtn.disabled=!0,p("Generating 3 questions...");let F=(N=(P=(v=(await(await c()).chat.completions.create({messages:[{role:"system",content:vr},{role:"user",content:`Last sentence: ${w}`}],temperature:.2})).choices)==null?void 0:v[0])==null?void 0:P.message)==null?void 0:N.content,z=Array.isArray(F)?F.map(re=>typeof re=="string"?re:"").join("").trim():typeof F=="string"?F.trim():"";if(!z)throw new Error("Cogito returned an empty response.");a=Rr(z),d(a),p("Questions ready. Insert one into your markdown when useful."),o("Cogito questions ready","ok")}catch(L){a=[],d(a);let D=L instanceof Error?L.message:String(L);p(`Cogito error: ${D}`),o("Cogito unavailable","warn"),n(`Cogito failed: ${D}`,{persist:!0})}finally{t.cogitoGenerateBtn.disabled=!1}}function h(w){let v=a[w];if(!v){n("Cogito question not found.",{persist:!0});return}let P=Cr(v);Er(t.editor,P),r(t.editor.value),o("Inserted AI question","ok")}return k(!1),{togglePanel:()=>{k(!i),i&&p("Cogito Mode enabled. Generate questions from your last sentence.")},selectModel:g,generateQuestions:m,insertQuestionAtIndex:h}}function pt(){Ar(Xe()).initialize()}function Ar(t){let e={workspaceHandle:null,workspaceName:"",fileTree:null,notes:[],inMemoryNotes:[],currentFileHandle:null,currentRelPath:"",currentContent:"",isDirty:!1,searchQuery:"",tagFilter:"",sortMode:"name",collapsedDirs:new Set,autoRefreshMs:1e4,autoRefreshTimer:null,isSidebarCollapsed:!1,isTemporarySession:!1,isCogitoModeEnabled:!1},r={current:null},{showToast:n,hideToast:o}=it(t,r),i={openNoteByRelPath:async()=>{},openInMemoryNote:async()=>{}},a=st({state:e,els:t,handlers:i,showToast:n}),s={current:async()=>{}},u=tt({state:e,ensurePermission:Re,rescanWorkspace:m=>s.current(m),showToast:n,setStatus:(m,h)=>U(t,m,h)}),l=lt({state:e,els:t,showToast:n,setStatus:(m,h)=>U(t,m,h),renderPreview:Ce,updateDirtyUi:pe,renderTree:a.renderTree,renderInMemoryTree:a.renderInMemoryTree,renderTags:a.renderTags,updateCountsPill:a.updateCountsPill,fsApi:{ensurePermission:Re,isFileSystemApiAvailable:et},parseTags:Ye,normalizeTag:ee,autoRefresh:u});s.current=l.rescanWorkspace,i.openNoteByRelPath=l.openNoteByRelPath,i.openInMemoryNote=l.openInMemoryNote;let g=ot({state:e,els:t,showToast:n,renderTree:a.renderTree,callbacks:{createNewNote:l.createNewNote,createNewFolder:l.createNewFolder,openWorkspace:l.openWorkspace,closeWorkspace:l.closeWorkspace,saveCurrentNote:l.saveCurrentNote,saveAsNewNote:l.saveAsNewNote,handleRefresh:l.handleRefresh,exportAsJson:l.exportAsJson,exportAsMarkdown:l.exportAsMarkdown,setSidebarCollapsed:m=>Pe(t,e,m)}});function k(m){e.searchQuery=m,a.renderTree().catch(h=>{n(`Search render failed: ${String(h)}`,{persist:!0})})}function d(m){e.isDirty=m!==e.currentContent,pe(t,e,(h,w)=>U(t,h,w)),Ce(t,m)}let p=ct({els:t,getEditorText:()=>t.editor.value,onEditorContentReplaced:m=>d(m),showToast:n,setStatus:(m,h)=>U(t,m,h)});function c(){C.use({breaks:!0});let m=navigator.platform.toLowerCase().includes("mac");nt(t,m),at({state:e,els:t,actions:{handleMenuAction:g.handleMenuAction,toggleSidebar:()=>Pe(t,e,!e.isSidebarCollapsed),handleRefresh:l.handleRefresh,toggleSort:g.toggleSort,handleSearchInput:k,handleEditorInput:d,saveCurrentNote:l.saveCurrentNote,createNewNote:l.createNewNote,createNoteFromTool:h=>l.createNoteFromTool(h),openWorkspace:l.openWorkspace,exportAsJson:l.exportAsJson,exportAsMarkdown:l.exportAsMarkdown,toggleCogitoPanel:()=>{e.isCogitoModeEnabled=!e.isCogitoModeEnabled,p.togglePanel()},selectCogitoModel:h=>p.selectModel(h),generateCogitoQuestions:()=>p.generateQuestions(),insertCogitoQuestion:h=>p.insertQuestionAtIndex(h),hideToast:o,showToast:n}}),rt(),Ae(t,e),pe(t,e,(h,w)=>U(t,h,w)),a.renderTree().catch(h=>{n(`Failed to render tree: ${String(h)}`,{persist:!0}),U(t,"Render failed","err")})}return{initialize:c,state:e}}pt();})(); + + + +:root{--bg: #0b0f14;--panel: #0f1620;--panel2: #0c121a;--border: rgba(255, 255, 255, 0.10);--muted: rgba(255, 255, 255, 0.70);--text: rgba(255, 255, 255, 0.92);--accent: #5eead4;--danger: #fb7185;--ok: #34d399;--warn: #fbbf24;--shadow: 0 10px 30px rgba(0,0,0,.35);--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";--radius: 14px;--body-bg: radial-gradient(900px 600px at 10% 0%, rgba(94, 234, 212, 0.15), transparent 60%), radial-gradient(900px 600px at 90% 0%, rgba(251, 191, 36, 0.10), transparent 60%), var(--bg)}:root[data-theme=classic]{--bg: #f5f5f5;--panel: #ffffff;--panel2: #eeeeee;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.55);--text: rgba(0, 0, 0, 0.88);--accent: #2c6fad;--danger: #d9534f;--ok: #2e7d32;--warn: #e65100;--shadow: 0 10px 30px rgba(0,0,0,.10);--body-bg: var(--bg)}:root[data-theme=cobalt]{--bg: #001f3d;--panel: #002b52;--panel2: #002040;--border: rgba(255, 255, 255, 0.12);--muted: rgba(255, 255, 255, 0.60);--text: #e8f4fd;--accent: #ffd700;--danger: #ff6b6b;--ok: #32d48a;--warn: #ffba08;--shadow: 0 10px 30px rgba(0,0,0,.50);--body-bg: radial-gradient(800px 500px at 20% 0%, rgba(0, 120, 212, 0.20), transparent 60%), radial-gradient(800px 500px at 80% 100%, rgba(255, 215, 0, 0.08), transparent 60%), var(--bg)}:root[data-theme=monokai]{--bg: #272822;--panel: #3e3d32;--panel2: #2d2c27;--border: rgba(255, 255, 255, 0.10);--muted: rgba(248, 248, 242, 0.55);--text: #f8f8f2;--accent: #a6e22e;--danger: #f92672;--ok: #a6e22e;--warn: #e6db74;--shadow: 0 10px 30px rgba(0,0,0,.45);--body-bg: radial-gradient(700px 400px at 0% 0%, rgba(166, 226, 46, 0.08), transparent 60%), radial-gradient(700px 400px at 100% 100%, rgba(249, 38, 114, 0.08), transparent 60%), var(--bg)}:root[data-theme=office]{--bg: #f0f0f0;--panel: #ffffff;--panel2: #f7f7f7;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.50);--text: rgba(0, 0, 0, 0.85);--accent: #0078d4;--danger: #c4314b;--ok: #107c10;--warn: #c19c00;--shadow: 0 10px 30px rgba(0,0,0,.08);--body-bg: var(--bg)}:root[data-theme=twilight]{--bg: #141414;--panel: #1e1e1e;--panel2: #191919;--border: rgba(255, 255, 255, 0.10);--muted: rgba(247, 247, 247, 0.55);--text: #f5f5f5;--accent: #d4a96a;--danger: #cf6679;--ok: #7cbf8e;--warn: #c9a84c;--shadow: 0 10px 30px rgba(0,0,0,.50);--body-bg: radial-gradient(700px 400px at 15% 0%, rgba(212, 169, 106, 0.10), transparent 60%), radial-gradient(700px 400px at 85% 100%, rgba(124, 191, 142, 0.07), transparent 60%), var(--bg)}:root[data-theme=xcode]{--bg: #f9f9f9;--panel: #ffffff;--panel2: #f2f2f7;--border: rgba(0, 0, 0, 0.12);--muted: rgba(0, 0, 0, 0.50);--text: rgba(0, 0, 0, 0.88);--accent: #0070c1;--danger: #b22222;--ok: #006400;--warn: #7d4700;--shadow: 0 10px 30px rgba(0,0,0,.08);--body-bg: var(--bg)}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font-family:var(--sans);background:var(--body-bg);color:var(--text)}.app{height:100%;display:grid;grid-template-rows:auto 1fr;grid-template-columns:320px 1fr}.app.sidebar-collapsed{grid-template-columns:64px 1fr}.app>.menubar{grid-column:1/-1}.card{background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;min-height:0}.sidebar{display:flex;flex-direction:column;min-height:0;position:relative;border-radius:0}.sidebarToggle{display:inline-flex;margin:10px;top:10px;right:10px;z-index:2;padding:8px 10px;font-size:12px}.sidebarPanel{display:flex;flex-direction:column;min-height:0;height:100%}.sidebar.collapsed .sidebarPanel{display:none}.sidebar.collapsed{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);box-shadow:none}.sidebar.collapsed .sidebarToggle{display:flex;align-items:center;justify-content:center;writing-mode:vertical-lr;text-orientation:upright;letter-spacing:1px;background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);border:1px solid var(--border);color:var(--text)}.sidebar header{padding:14px 14px 10px;border-bottom:1px solid var(--border);display:block;align-items:center;gap:10px}.row{display:flex;gap:10px;align-items:center;margin-top:7px}.row.space{justify-content:space-between}.row .row{margin-top:0}#searchInput{margin-top:7px}.title{font-size:14px;font-weight:700;letter-spacing:.2px;display:flex;align-items:center;gap:10px}.pill{font-size:12px;padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:hsla(0,0%,100%,.04);color:var(--muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pill.warning{border-color:rgba(251,191,36,.45);background:rgba(251,191,36,.12);color:rgba(251,191,36,.95)}button{appearance:none;border:1px solid var(--border);background:hsla(0,0%,100%,.06);color:var(--text);padding:10px 12px;border-radius:12px;font-weight:650;cursor:pointer;transition:transform .05s ease,background .15s ease,border-color .15s ease;user-select:none;white-space:nowrap}button:hover{background:hsla(0,0%,100%,.1);border-color:hsla(0,0%,100%,.18)}button:active{transform:translateY(1px)}button.primary{border-color:rgba(94,234,212,.45);background:rgba(94,234,212,.14)}button.primary:hover{background:rgba(94,234,212,.2);border-color:rgba(94,234,212,.7)}button.ghost{background:rgba(0,0,0,0)}button:disabled{opacity:.55;cursor:not-allowed}.input{width:100%;border-radius:12px;border:1px solid var(--border);background:hsla(0,0%,100%,.05);color:var(--text);padding:10px 12px;outline:none;font-size:14px}.input::placeholder{color:hsla(0,0%,100%,.55)}@media(prefers-color-scheme: light){.input::placeholder{color:rgba(2,6,23,.4)}}.small{font-size:12px;color:var(--muted)}.treeWrap{padding:10px 10px 14px;overflow:auto;min-height:0}.tree{font-size:13px;line-height:1.35;padding:4px 0;user-select:none}.tree .node{display:flex;align-items:center;gap:6px;padding:6px 8px;border-radius:10px;cursor:pointer;transition:background .12s ease;border:1px solid rgba(0,0,0,0)}.tree .node:hover{background:hsla(0,0%,100%,.06)}.tree .node.active{background:rgba(94,234,212,.14);background:rgba(94,234,212,.4)}.tree .indent{margin-left:14px}.icon{width:16px;height:16px;display:inline-block;vertical-align:middle;flex-shrink:0;color:inherit;pointer-events:none;opacity:.85}.node .name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node .meta{font-size:11px;color:var(--muted);flex:0 0 auto}.tagrow{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px;padding:0 2px 2px}.tag{font-size:11px;padding:3px 7px;border-radius:999px;border:1px solid var(--border);color:var(--muted);background:hsla(0,0%,100%,.04);cursor:pointer}.tag.active{border-color:rgba(94,234,212,.45);color:var(--text);background:rgba(94,234,212,.12)}.main{display:flex;flex-direction:column;min-height:0;border-radius:0}.main header{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-height:54px}.filename{font-weight:800;letter-spacing:.2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55vw}.dirtyDot{width:8px;height:8px;border-radius:50%;background:var(--danger);box-shadow:0 0 0 3px rgba(251,113,113,.18);display:none;flex:0 0 auto}.dirtyDot.show{display:inline-block}.status{margin-left:auto;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px;min-width:220px;justify-content:flex-end}.status .badge{padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:hsla(0,0%,100%,.04);color:var(--muted);max-width:60ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status .badge.ok{border-color:rgba(52,211,153,.35);color:rgba(52,211,153,.95);background:rgba(52,211,153,.1)}.status .badge.warn{border-color:rgba(251,191,36,.35);color:rgba(251,191,36,.95);background:rgba(251,191,36,.1)}.status .badge.err{border-color:rgba(251,113,133,.4);color:rgba(251,113,133,.95);background:rgba(251,113,133,.1)}.split{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:1fr 1fr}.split.with-cogito{grid-template-columns:1fr 1fr 320px}.editorPane,.previewPane{min-height:0;min-width:0;overflow:hidden;display:flex;flex-direction:column}.editorPane{border-right:1px solid var(--border);background:var(--panel2)}textarea{width:100%;height:100%;resize:none;border:none;outline:none;padding:14px 14px 18px;font-family:var(--mono);font-size:14px;line-height:1.5;background:rgba(0,0,0,0);color:var(--text);overflow:auto;white-space:pre-wrap;word-break:break-word;tab-size:2}.preview{padding:14px 16px 18px;min-width:0;min-height:0;overflow-y:auto}.cogitoPanel{min-height:0;border-left:1px solid var(--border);background:var(--panel2);padding:12px;display:flex;flex-direction:column;gap:10px;overflow:auto}.cogitoPanel[hidden]{display:none}.cogitoHeader{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0;padding:0;border:0;min-height:0}.cogitoModelPicker{display:flex;gap:4px}.cogitoModelBtn{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;font-size:11px;font-weight:600;border-radius:8px;opacity:.55}.cogitoModelBtn.active{opacity:1;border-color:rgba(94,234,212,.5);background:rgba(94,234,212,.14)}.cogitoQuestionList{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.cogitoQuestionItem{border:1px solid var(--border);border-radius:12px;padding:10px;background:hsla(0,0%,100%,.03);display:flex;flex-direction:column;gap:8px}.cogitoQuestionText{margin:0;font-size:13px;line-height:1.4}.cogitoInsertBtn{align-self:flex-end;padding:6px 10px;font-size:12px;border-radius:10px}.preview :is(h1,h2,h3){line-height:1.25;margin:1em 0 .5em}.preview h1{font-size:1.6rem}.preview h2{font-size:1.35rem}.preview h3{font-size:1.15rem}.preview p{color:var(--text);opacity:.92}.preview a{color:var(--accent)}.preview code{font-family:var(--mono);font-size:.95em;padding:.15em .35em;border-radius:8px;background:hsla(0,0%,100%,.06);border:1px solid var(--border)}.preview pre{max-width:100%;padding:12px;border-radius:12px;background:rgba(94,234,212,.06);border:1px solid var(--border);white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.preview pre code{padding:0;border:none;background:rgba(0,0,0,0)}.preview blockquote{border-left:4px solid rgba(94,234,212,.45);padding:8px 12px;margin:12px 0;background:rgba(94,234,212,.08);border-radius:10px;color:var(--muted)}.preview table{width:100%;border-collapse:collapse;border:1px solid var(--border);border-radius:12px;overflow:hidden}.preview th,.preview td{border-bottom:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}.preview th{background:hsla(0,0%,100%,.05);font-weight:800}.menubar{display:flex;align-items:center;padding:0 8px;height:38px;border-radius:0;border-bottom:1px solid var(--border);background:linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 80%),var(--panel);flex-shrink:0;overflow:visible}.menubar-spacer{flex:1 1 auto}.menu-action-btn{display:inline-flex;align-items:center;gap:6px;height:30px;padding:4px 10px;border-radius:10px;font-size:12px}.menu-action-label{font-weight:700}.menu-action-btn[aria-expanded=true]{border-color:rgba(94,234,212,.6);background:rgba(94,234,212,.16)}.glyphicon{display:inline-flex;width:16px;justify-content:center;align-items:center;line-height:1;font-size:14px}.glyphicon-education::before{content:"🧠"}.menu-item{position:relative;display:flex;align-items:center;gap:4px;padding:6px 12px;cursor:pointer;border-radius:8px;transition:background .12s ease;user-select:none}.menu-item:hover,.menu-item:focus,.menu-item[aria-expanded=true]{background:hsla(0,0%,100%,.08);outline:none}.menu-text{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:500;color:var(--text)}.menu-arrow{font-size:8px;color:var(--muted);margin-left:2px}.dropdown{position:absolute;top:100%;left:0;min-width:200px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow);padding:4px 0;display:none;z-index:100;margin-top:2px}.menu-item[aria-expanded=true] .dropdown{display:block}.dropdown li{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;transition:background .08s ease}.dropdown li:hover,.dropdown li:focus{background:rgba(94,234,212,.12);outline:none}.dropdown li[role=separator]{height:1px;padding:0;margin:4px 8px;background:var(--border);cursor:default}.dropdown li[role=separator]:hover,.dropdown li[role=separator]:focus{background:var(--border)}.menu-label{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--text)}.menu-label-text{min-width:0}.menu-shortcut{font-size:11px;color:var(--muted);margin-left:20px}.menu-theme-check{font-size:11px;color:var(--accent);margin-left:20px;opacity:0}.menu-theme-check.active{opacity:1}.submenu-parent{position:relative}.submenu-parent .dropdown.submenu{position:absolute;left:100%;top:0;margin-left:4px;display:none}.submenu-parent:hover>.dropdown.submenu{display:block}.submenu-parent>.menu-arrow{font-size:6px;margin-left:6px}.toast{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#fff;padding:10px 12px;border-radius:12px;border:1px solid hsla(0,0%,100%,.18);max-width:min(760px,100vw - 28px);box-shadow:0 14px 40px rgba(0,0,0,.35);display:none;z-index:1000;font-size:13px;backdrop-filter:blur(10px)}.toast.show{display:block}.toast .trow{display:flex;gap:10px;align-items:flex-start}.toast .trow .tmsg{flex:1 1 auto}.toast .trow button{padding:6px 8px;border-radius:10px;font-size:12px}.webmcpModal{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:20px;z-index:1050;opacity:0;pointer-events:none;visibility:hidden}.webmcpModal.show{opacity:1;pointer-events:auto;visibility:visible}.webmcpModalBackdrop{position:absolute;inset:0;background:rgba(0,0,0,.56);backdrop-filter:blur(8px)}.webmcpModalCard{position:relative;width:min(680px,100vw - 40px);max-height:calc(100vh - 40px);overflow:auto;padding:18px;border-radius:18px;border:1px solid var(--border);background:linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 100%),var(--panel);box-shadow:var(--shadow)}.webmcpModalHeader{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px}#webmcpNoteForm{display:grid;gap:10px}#webmcpNoteForm textarea{min-height:160px;resize:vertical}.webmcpModalActions{justify-content:flex-end;margin-top:4px}@media(max-width: 980px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr;height:100%}.app.sidebar-collapsed{grid-template-columns:1fr}.split{grid-template-columns:1fr}.split.with-cogito{grid-template-columns:1fr}.editorPane{border-right:none;border-bottom:1px solid var(--border);min-height:40vh}.previewPane{min-height:40vh}.cogitoPanel{border-left:none;border-top:1px solid var(--border);min-height:30vh}.filename{max-width:70vw}.sidebarToggle{position:static;margin:10px;width:calc(100% - 20px);height:auto;transform:none;writing-mode:horizontal-tb;text-orientation:mixed;letter-spacing:normal}.sidebar.collapsed .sidebarPanel{display:none}.sidebar.collapsed .sidebarToggle{display:flex;width:calc(100% - 20px);writing-mode:horizontal-tb;text-orientation:mixed;letter-spacing:normal}.sidebar.collapsed{height:auto;min-height:0}.webmcpModal{padding:12px;align-items:flex-end}.webmcpModalCard{width:100%;max-height:min(80vh,720px);border-radius:18px 18px 0 0}} + + + +## ADDED Requirements + +### Requirement: Cogito Mode Menu Bar Button +The system SHALL provide a Cogito Mode button in the menu bar's top-right action area. + +#### Scenario: User sees Cogito entrypoint in the menu bar +- **WHEN** the editor UI is visible +- **THEN** a Cogito Mode button SHALL appear in the top-right menu bar actions +- **AND** the button SHALL use a thinking-man icon from the project's glyphicon library +- **AND** the button SHALL expose an accessible text label or tooltip identifying it as Cogito Mode + +#### Scenario: User toggles Cogito Mode from the button +- **WHEN** the user clicks the Cogito Mode top-right button +- **THEN** the system SHALL open or close the Cogito Mode side panel +- **AND** the toggle action SHALL not interrupt normal editing flow + +### Requirement: Cogito Mode Side Panel +The system SHALL provide a Cogito Mode side panel where users can generate and view writing-coach questions while editing markdown. + +#### Scenario: 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 + +### Requirement: Fixed Coaching Prompt Contract +The system SHALL generate questions using a fixed writing-coach instruction that outputs JSON only with exactly three questions. + +#### 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 + +### Requirement: Last-Sentence Grounding +The system SHALL ground generated questions in the user's most recent sentence from the active markdown content. + +#### 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 + +### Requirement: Exactly Three Rendered Questions +The system SHALL display exactly three generated questions in the Cogito Mode side panel. + +#### Scenario: Valid model output is returned +- **WHEN** the model returns a valid JSON payload containing three questions +- **THEN** the panel SHALL render exactly three question entries +- **AND** each entry SHALL expose an insert action for the user + +#### Scenario: 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 + +### 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 + + + +## 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. + +## 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. + +## 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. + +## 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 + +## 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) + + + +## 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. + +## 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: User-Controlled Left Menu Visibility +The application SHALL provide a user-facing control to collapse and expand the left menu during an editing session. + +#### Scenario: User collapses left menu +- **WHEN** the user activates the menu toggle while the menu is expanded +- **THEN** the left menu transitions to a collapsed state +- **AND** the main editor area gains additional horizontal space + +#### Scenario: User expands left menu +- **WHEN** the user activates the menu toggle while the menu is collapsed +- **THEN** the left menu returns to an expanded state +- **AND** primary menu content becomes visible again + +### Requirement: Left Menu Toggle Accessibility +The left menu toggle SHALL be operable via keyboard and SHALL expose its current expanded/collapsed state to assistive technologies. + +#### Scenario: Keyboard operation +- **WHEN** a keyboard-only user focuses the menu toggle and activates it +- **THEN** the menu state changes between expanded and collapsed +- **AND** focus remains in a predictable location for continued interaction + +#### Scenario: Assistive state exposure +- **WHEN** the menu state changes through the toggle control +- **THEN** the control's accessibility state reflects whether the menu is expanded or collapsed + +### Requirement: Deterministic Initial Menu State +The application SHALL define a deterministic initial left menu state when a new session loads. + +#### Scenario: Initial layout state +- **WHEN** a user opens the application in a new session +- **THEN** the left menu starts in the documented default state +- **AND** the editor layout reflects that state without requiring user interaction + + + +# Change: Add User-Controlled Collapsible Left Menu + +## Why +The current left menu is fixed, which reduces usable editor space on smaller screens and during focused writing. Allowing users to collapse and expand the menu improves layout flexibility without removing existing navigation. + +## What Changes +- Add support for collapsing and expanding the left menu from within the UI. +- Add a persistent toggle control so users can switch menu state on demand. +- Update layout behavior so editor content reflows to use freed horizontal space when the menu is collapsed. +- Preserve keyboard and pointer accessibility for the menu toggle interaction. +- Define expected default menu behavior at app start. + +## Impact +- Affected specs: `editor-layout` +- Affected code: + - `ink.template.html` + - `src/app.ts` + - `src/styles.scss` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + + + +## 1. UI Controls and State +- [x] 1.1 Add a left menu toggle control that supports collapse and expand actions. +- [x] 1.2 Implement menu state management in the app so toggle actions update layout consistently. +- [x] 1.3 Define and implement the default menu state on initial load. + +## 2. Layout and Styling +- [x] 2.1 Update layout styles so collapsing the menu increases main editor horizontal space. +- [x] 2.2 Ensure expanded state preserves existing navigation usability and visual hierarchy. +- [x] 2.3 Ensure collapsed state keeps the toggle discoverable and usable. + +## 3. Accessibility and Interaction +- [x] 3.1 Ensure the toggle is keyboard-focusable and operable. +- [x] 3.2 Ensure toggle semantics expose menu state changes to assistive technologies. + +## 4. Verification +- [x] 4.1 Verify users can collapse and re-expand the menu in a modern browser. +- [x] 4.2 Verify editor content area width increases when the menu is collapsed. +- [x] 4.3 Verify keyboard-only users can operate the toggle and retain navigation control. +- [x] 4.4 Add a Cypress end-to-end test that validates left menu collapse/expand behavior and resulting layout changes. + + + +## ADDED Requirements + +### Requirement: CSS Custom Property Theme System +The application SHALL implement color themes using CSS custom properties, with each theme defined as a set of variable overrides on `:root[data-theme=""]`. + +#### Scenario: Default theme at initial load +- **WHEN** the application loads with no saved theme preference +- **THEN** no `data-theme` attribute is present on `` +- **AND** the default dark teal palette is applied via `:root` base variables + +#### Scenario: Named theme activation +- **WHEN** a named theme (e.g. `monokai`) is applied +- **THEN** `data-theme="monokai"` is set on `` +- **AND** the corresponding CSS variable overrides take effect immediately across all elements +- **AND** no page reload is required + +#### Scenario: Returning to default +- **WHEN** the user selects Default (Dark) after a named theme is active +- **THEN** the `data-theme` attribute is removed from `` +- **AND** the base `:root` variable definitions are restored + +### Requirement: Theme Persistence +The application SHALL persist the user's theme choice across sessions using `localStorage`. + +#### Scenario: Theme saved on selection +- **WHEN** the user selects a theme from the View menu +- **THEN** the theme identifier is written to `localStorage` under the key `ink-theme` + +#### Scenario: Theme restored on load +- **WHEN** the application initialises and a valid theme identifier exists in `localStorage` +- **THEN** that theme is applied before the first render +- **AND** the user sees their previously chosen colours immediately, without a flash of the default theme + +#### Scenario: Invalid or missing stored value +- **WHEN** the application initialises and `localStorage` contains no `ink-theme` key or an unrecognised value +- **THEN** the default dark theme is applied +- **AND** no error is shown to the user + +### Requirement: Available Colour Styles +The application SHALL provide the following seven colour styles, modeled on RStudio's built-in themes. + +| Theme | Character | +|-------|-----------| +| Default (Dark) | Dark teal, original ink palette | +| Classic | Light neutral white/grey, blue accent | +| Cobalt | Dark ocean blue, gold accent | +| Monokai | Dark charcoal, green/pink highlights | +| Office | Clean professional light, Microsoft blue | +| Twilight | Warm dark grey, amber accent | +| Xcode | Crisp Apple-style light, Xcode blue | + +#### Scenario: Each theme covers all required variables +- **WHEN** any named theme is active +- **THEN** all CSS custom properties (`--bg`, `--panel`, `--panel2`, `--border`, `--muted`, `--text`, `--accent`, `--danger`, `--ok`, `--warn`, `--shadow`, `--body-bg`) are defined +- **AND** no variable falls back to an unintended value from another theme + + + +## ADDED Requirements + +### Requirement: View Top-Level Menu +The application menu bar SHALL include a View top-level menu positioned between the Edit menu and the Import/Export menu. + +#### Scenario: View menu is visible in the menu bar +- **WHEN** the application loads +- **THEN** a "View" menu item is present in the menu bar +- **AND** it appears between "Edit" and "Import/Export" + +#### Scenario: View menu opens a dropdown +- **WHEN** the user clicks the View menu item +- **THEN** a dropdown appears listing all available colour styles +- **AND** the dropdown follows the same interaction model as File and Edit menus + +### Requirement: Colour Style Selection +The View menu dropdown SHALL list all available colour styles and allow the user to switch between them with a single click. + +#### Scenario: All themes listed +- **WHEN** the View dropdown is open +- **THEN** the following items are present in order: Default (Dark), [separator], Classic, Cobalt, Monokai, Office, Twilight, Xcode + +#### Scenario: Selecting a theme +- **WHEN** the user clicks a theme name in the View dropdown +- **THEN** the corresponding theme is applied immediately +- **AND** the dropdown closes +- **AND** the application retains full functionality under the new colour scheme + +### Requirement: Active Theme Indicator +The View menu SHALL display a checkmark (✓) next to the currently active theme. + +#### Scenario: Checkmark on active theme +- **WHEN** the View dropdown is open +- **THEN** a ✓ indicator is visible next to the currently active theme name +- **AND** all other theme entries show no checkmark + +#### Scenario: Checkmark updates on selection +- **WHEN** the user selects a different theme +- **THEN** the checkmark moves to the newly selected theme +- **AND** the previous theme entry no longer shows a checkmark + +### Requirement: Backward Compatibility +Adding the View menu SHALL not affect any existing menu, button, or keyboard shortcut behaviour. + +#### Scenario: Existing menus unaffected +- **WHEN** the View menu is present +- **THEN** File, Edit, and Import/Export menus continue to operate identically to their prior behaviour + +#### Scenario: Existing buttons unaffected +- **WHEN** a colour theme is active +- **THEN** all sidebar buttons, Save, JSON, and MD buttons continue to function correctly +- **AND** their visual style adapts to the active theme via CSS custom properties + + + +# Change: Add Color Style Themes + +## Why + +Ink currently uses a single fixed dark color scheme. Users have different preferences and work in different lighting environments, so a fixed style limits comfort and accessibility. Providing a set of well-known color themes — modeled on those available in RStudio — gives users immediate visual choices without requiring any configuration beyond a single menu click. + +## What Changes + +- Add a **View** top-level menu to the existing office-style menu bar, placed between Edit and Import/Export. +- The View menu lists seven color styles: Default (Dark), Classic, Cobalt, Monokai, Office, Twilight, and Xcode. +- Selecting a style applies it immediately by setting a `data-theme` attribute on the `` element. +- Each theme is defined as a set of CSS custom property overrides in `src/styles.scss` using `:root[data-theme=""]` selectors. +- The active theme is indicated by a checkmark (✓) next to the selected item in the menu. +- The selected theme is persisted to `localStorage` under the key `ink-theme` and restored on next load. +- The body background gradient is theme-aware via a `--body-bg` CSS custom property. + +## Impact + +- Affected specs: `theming`, `view-menu` +- Affected code: + - `ink.template.html` — View menu added + - `src/styles.scss` — theme CSS custom properties added, `--body-bg` variable introduced + - `src/app/bootstrap.ts` — `applyTheme()` and `loadTheme()` methods added; new `theme-*` action cases in `handleMenuAction()` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + - `ink-app.html` (rebuilt) + + + +## 1. CSS Theme System + +- [x] 1.1 Introduce `--body-bg` CSS custom property to allow per-theme control of the body background gradient. +- [x] 1.2 Define `Default (Dark)` base theme variables in `:root` (existing dark teal palette, refactored to use `--body-bg`). +- [x] 1.3 Define `Classic` theme — light neutral white/grey, blue accent (`#2c6fad`). +- [x] 1.4 Define `Cobalt` theme — dark ocean blue (`#001f3d`) with gold accent (`#ffd700`) and a blue radial gradient. +- [x] 1.5 Define `Monokai` theme — dark (`#272822`) with green accent (`#a6e22e`) and pink/green radial gradients. +- [x] 1.6 Define `Office` theme — clean professional light, Microsoft blue accent (`#0078d4`). +- [x] 1.7 Define `Twilight` theme — warm dark grey (`#141414`), amber accent (`#d4a96a`). +- [x] 1.8 Define `Xcode` theme — crisp light (`#f9f9f9`), Apple blue accent (`#0070c1`). +- [x] 1.9 Add `.menu-theme-check` CSS class for the active-theme checkmark indicator in the dropdown. + +## 2. View Menu (HTML Template) + +- [x] 2.1 Add a `View` menu item to `ink.template.html` between Edit and Import/Export. +- [x] 2.2 Add dropdown list items for all seven themes, each with `data-action="theme-"`. +- [x] 2.3 Add `` to each theme item for active-state indication. +- [x] 2.4 Include a separator between Default (Dark) and the six named themes. + +## 3. Theme Logic (TypeScript) + +- [x] 3.1 Add `VALID_THEMES` constant array listing all accepted theme identifiers. +- [x] 3.2 Implement `applyTheme(theme: string)` method that sets/removes the `data-theme` attribute on `document.documentElement`, saves to `localStorage`, and updates the checkmark indicator. +- [x] 3.3 Implement `loadTheme()` method that reads `localStorage` on startup and calls `applyTheme()` with the saved value (defaulting to `"default"` if absent or invalid). +- [x] 3.4 Call `loadTheme()` from `initialize()` before other UI setup. +- [x] 3.5 Add `theme-default`, `theme-classic`, `theme-cobalt`, `theme-monokai`, `theme-office`, `theme-twilight`, and `theme-xcode` cases to `handleMenuAction()`. + +## 4. Build and Verification + +- [x] 4.1 Run `npm run build` to recompile TypeScript, SCSS, and assemble `ink-app.html`. +- [x] 4.2 Verify the View menu appears in the menu bar between Edit and Import/Export. +- [x] 4.3 Verify each theme applies immediately on selection with correct colours. +- [x] 4.4 Verify the checkmark moves to the newly selected theme. +- [x] 4.5 Verify the selected theme persists across page reloads via `localStorage`. + +## 5. Documentation + +- [x] 5.1 Update `replit.md` with a Color Themes section describing each theme and the persistence mechanism. +- [x] 5.2 Create this openspec change request. + + + +## ADDED Requirements +### Requirement: Componentized Source Inputs +The project SHALL maintain separate source files for interaction logic, styling, and template markup. + +#### Scenario: Source layout follows project template +- **WHEN** a developer inspects the repository source layout +- **THEN** TypeScript interaction logic is located in `src/app.ts` +- **AND** SASS styling is located in `src/styles.scss` +- **AND** HTML template markup is maintained in a template source file used by the build pipeline + +### Requirement: Deterministic Build Outputs +The build pipeline SHALL compile TypeScript and SASS sources into deterministic distributable artifacts. + +#### Scenario: One-shot build compiles assets +- **WHEN** a developer runs the documented one-shot build command +- **THEN** the pipeline generates a JavaScript bundle at `dist/app.min.js` +- **AND** the pipeline generates compiled CSS at `dist/styles.min.css` + +### Requirement: Single-File Distribution Assembly +The build pipeline SHALL inject compiled JS and CSS artifacts into the HTML template to produce a single-file app output. + +#### Scenario: Build injects compiled assets into final HTML +- **WHEN** the injection step runs after asset compilation +- **THEN** the final HTML output contains inline CSS derived from `dist/styles.min.css` +- **AND** the final HTML output contains inline JavaScript derived from `dist/app.min.js` +- **AND** the resulting file is executable in a browser without network dependencies + +### Requirement: Incremental Developer Build Workflow +The project SHALL provide a watch-mode build workflow that rebuilds artifacts when source files change. + +#### Scenario: Watch mode rebuilds on source edits +- **WHEN** a developer runs the documented watch command and edits TypeScript, SASS, or template source files +- **THEN** relevant compiled artifacts are regenerated without requiring manual command re-entry + + + +## ADDED Requirements +### Requirement: QUnit Test Harness +The project SHALL provide a QUnit-based test harness and a documented command to run QUnit tests locally. + +#### Scenario: QUnit tests are executable from project scripts +- **WHEN** a developer runs the documented QUnit test command +- **THEN** the QUnit suite executes without requiring manual browser setup +- **AND** the command exits non-zero when any QUnit assertion fails + +### Requirement: Cypress End-to-End Test Harness +The project SHALL provide Cypress configuration and a documented command to run Cypress tests locally. + +#### Scenario: Cypress tests are executable from project scripts +- **WHEN** a developer runs the documented Cypress test command +- **THEN** Cypress launches against the application under test +- **AND** the command exits non-zero when any Cypress assertion fails + +### Requirement: Critical Authoring Flow Coverage +Automated tests SHALL validate the user workflow of selecting a workspace, creating a new file, entering markdown, and saving. + +#### Scenario: End-to-end flow is validated +- **WHEN** the Cypress workflow test runs +- **THEN** it selects a workspace through supported UI/test seam interactions +- **AND** it creates a new file +- **AND** it enters markdown content into the editor +- **AND** it triggers save +- **AND** it verifies that the saved content matches the entered markdown + +### Requirement: Fast Regression Coverage for Editor Logic +Automated tests SHALL include QUnit coverage for core editor interactions required by the authoring flow. + +#### Scenario: Editor behavior is validated by QUnit +- **WHEN** the QUnit suite executes editor interaction tests +- **THEN** tests verify at least initialization and markdown content mutation behavior used by the save flow + + + +## Context +Ink targets a single-file HTML distribution while development should remain modular and maintainable. The desired workflow requires clear source separation (TypeScript, SASS, HTML template), predictable build outputs, and automated tests that guard core document editing behavior. + +## Goals / Non-Goals +- Goals: + - Keep source-of-truth files separated by concern: logic, presentation, and template markup. + - Preserve single-file distributable output for runtime usage. + - Provide reliable local build and test commands. + - Cover the critical authoring flow with both fast in-browser tests (QUnit) and browser-driven tests (Cypress). +- Non-Goals: + - Migrating to a framework (React/Vue/etc.). + - Introducing a backend service. + - Redesigning editor UX beyond what is required for testability and flow support. + +## Decisions +- Decision: Use TypeScript compilation via existing `esbuild` and SASS compilation via `sass`, with a final injection step into a template file. + - Rationale: aligns with current lightweight toolchain and single-file output constraint. +- Decision: Expose build commands through `npm scripts` and keep `Makefile` as optional convenience wrapper. + - Rationale: `npm scripts` are portable and integrate well with QUnit/Cypress commands. +- Decision: Add QUnit tests for app-level behaviors that do not require full browser orchestration. + - Rationale: fast feedback for logic and DOM interactions. +- Decision: Add Cypress tests for full user workflow and persistence interactions. + - Rationale: validates user-visible behavior across realistic browser execution. + +## Risks / Trade-offs +- Tooling overhead increases with dual test frameworks. + - Mitigation: keep QUnit focused on fast logic checks; reserve Cypress for key end-to-end flows. +- Workspace/file APIs can be difficult to test directly in browsers. + - Mitigation: define test seams and controlled fixtures/stubs for deterministic execution. + +## Migration Plan +1. Introduce source file structure and build scripts aligned with README conventions. +2. Ensure one-shot and watch build commands generate expected dist artifacts and single-file output. +3. Add QUnit harness and baseline test suite. +4. Add Cypress project config and e2e scenario for workspace→new file→edit→save. +5. Update documentation with developer commands and required prerequisites. + +## Open Questions +- Should Cypress execute against a local static server command defined in `package.json`, or an externally started server? Local static server +- What test seam should be canonical for workspace selection in Cypress (UI picker vs injected test fixture API)? UI picker + + + +# Change: Componentized Build Pipeline and Automated Test Coverage + +## Why +The current project state does not provide a stable, repeatable workflow for maintaining separate HTML, TypeScript, and SASS sources, and it lacks automated regression tests for critical editor behavior. This makes changes risky and manual verification time-consuming. + +## What Changes +- Define a componentized source layout that keeps interaction logic in TypeScript, styles in SASS, and UI structure in an HTML template. +- Define a deterministic build pipeline that compiles TypeScript and SASS into distributable assets and injects them into the single-file app output. +- Define watch-mode and one-shot build commands so developers can rebuild the app whenever files change. +- Add QUnit unit/integration coverage for core application behavior. +- Add Cypress end-to-end coverage for the user flow: selecting a workspace, creating a new file, adding markdown content, and saving. +- Define minimum acceptance checks for local validation before release. + +## Impact +- Affected specs: `build-pipeline`, `testing` +- Affected code: + - `package.json` + - `Makefile` + - `src/app.ts` + - `src/styles.scss` + - `ink.template.html` (or current HTML template source) + - `build/inject.js` + - `dist/` build outputs + - `tests/qunit/` (new) + - `cypress/` (new) + + + +## 1. Implementation +- [x] 1.1 Align source structure with README template: `src/app.ts`, `src/styles.scss`, and HTML template input. +- [x] 1.2 Implement build scripts to compile TypeScript and SASS into `dist/app.min.js` and `dist/styles.min.css`. +- [x] 1.3 Implement template injection so compiled CSS/JS are embedded into the final single-file app output. +- [x] 1.4 Add build commands for one-shot build and watch mode to support rebuilding after file changes. +- [x] 1.5 Update developer documentation for build and run workflows. + +## 2. Testing +- [x] 2.1 Add QUnit test harness and configure command(s) to run tests locally. +- [x] 2.2 Add QUnit tests for core interaction logic and markdown editing behavior. +- [x] 2.3 Add Cypress configuration and command(s) to run end-to-end tests locally. +- [x] 2.4 Add Cypress test that covers selecting a workspace, creating a new file, entering markdown content, and saving. +- [x] 2.5 Add test data/setup hooks needed to execute the flow deterministically. + +## 3. Validation +- [x] 3.1 Verify build output artifacts are generated as specified. +- [x] 3.2 Verify QUnit and Cypress suites pass from documented commands. +- [x] 3.3 Confirm final app output remains a single self-contained HTML file. + + + +## ADDED Requirements +### Requirement: ESLint Code Quality +The project MUST use ESLint to enforce code quality standards on JavaScript files. + +#### Scenario: ESLint runs successfully +- **WHEN** `npm run lint` is executed +- **THEN** ESLint analyzes all JavaScript files and reports any violations + +#### Scenario: Build includes lint check +- **WHEN** the build process runs +- **THEN** lint check is performed and build fails if errors exist + + + +# Change: Add ESLint to the project + +## 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. + +## What Changes +- Add ESLint as a dev dependency +- Configure ESLint with appropriate rules for vanilla ES6+ JavaScript +- Integrate lint check into the build process +- Add npm script for running lint + +## Impact +- Affected specs: code-quality (new capability) +- Affected code: All JavaScript files in `src/`, `build/`, `tests/` +- Build system: Add lint step to build/compile-and-assemble.js + + + +## 1. Implementation +- [x] 1.1 Install ESLint and initialize config +- [x] 1.2 Configure ESLint for ES6+ vanilla JavaScript +- [x] 1.3 Add npm lint script to package.json +- [x] 1.4 Run ESLint and fix any errors +- [x] 1.5 Integrate lint check into build process + + + +## ADDED Requirements +### Requirement: Canonical Project Logo Asset +The repository SHALL store a canonical project logo source file that is used for documentation and favicon generation. + +#### Scenario: Canonical logo source is available +- **WHEN** a maintainer inspects branding files in the repository +- **THEN** a single canonical logo source exists in a stable project path +- **AND** the source is suitable for deriving README and favicon assets + +### Requirement: README Logo Rendering +The project documentation SHALL render the project logo in `README.md` using repository-relative asset references. + +#### Scenario: README displays logo +- **WHEN** `README.md` is rendered by a GitHub-compatible markdown renderer +- **THEN** the logo is visible near the document header +- **AND** the logo reference resolves without external network dependencies + +### Requirement: Favicon Assets Derived from Logo +The project SHALL provide favicon assets derived from the canonical logo source. + +#### Scenario: Favicon assets are generated and available +- **WHEN** the favicon generation workflow is run +- **THEN** favicon files are produced in the documented output location +- **AND** generated filenames match those referenced by the application template + +### Requirement: Application Template Favicon References +The application HTML template SHALL reference project favicon assets so browser tabs display the project icon. + +#### Scenario: Browser tab uses project favicon +- **WHEN** a user opens the built application in a modern browser +- **THEN** the browser resolves favicon references from the application output +- **AND** the tab icon reflects the project logo branding + + + +# Change: Add Project Logo to README and Favicon Assets + +## Why +The repository currently lacks consistent branding assets in project documentation and browser metadata. Adding the provided logo to `README.md` and defining favicon outputs improves recognizability and presentation. + +## What Changes +- Add a canonical logo asset in the repository based on `~/Downloads/ink_logo.svg`. +- Update `README.md` to render the project logo near the top of the document. +- Define and implement favicon outputs derived from the same logo source. +- Wire favicon references into the app HTML template so browser tabs use project branding. +- Document the asset location and favicon generation/update workflow. + +## Impact +- Affected specs: `branding-assets` +- Affected code: + - `README.md` + - `ink.template.html` + - `build/` scripts (if favicon generation is automated in build) + - `dist/` favicon artifacts + - `assets/` branding source files (new) + + + +## 1. Asset Setup +- [x] 1.1 Add the provided logo SVG to a canonical repository path for shared branding usage. +- [x] 1.2 Define favicon output formats and filenames derived from the canonical logo source. + +## 2. Documentation and Template Integration +- [x] 2.1 Update `README.md` to display the project logo with stable relative linking. +- [x] 2.2 Update the HTML template to include favicon references that resolve in the built output. + +## 3. Build and Distribution +- [x] 3.1 Implement or document the process to generate favicon artifacts from the SVG source. +- [x] 3.2 Ensure generated favicon assets are available in expected output locations. + +## 4. Validation +- [x] 4.1 Verify `README.md` renders the logo correctly on GitHub-compatible markdown viewers. +- [x] 4.2 Verify the built app/tab displays the new favicon in a modern browser. +- [x] 4.3 Verify documentation describes how to refresh logo-derived assets when the SVG changes. + + + +## ADDED Requirements + +### Requirement: Mobile Browser Detection +The system SHALL detect when the browser does not support the File System Access API. + +#### Scenario: Browser lacks File System Access API +- **WHEN** the user opens ink on a browser without `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL detect this and enable in-memory workspace mode +- **AND** the system SHALL NOT throw an error blocking app usage + +#### Scenario: Browser supports File System Access API +- **WHEN** the user opens ink on a browser with `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL allow opening a real folder as usual +- **AND** no in-memory mode SHALL be activated + +### Requirement: In-Memory Workspace Mode +The system SHALL provide a temporary in-memory workspace when FS API is unavailable. + +#### Scenario: User creates note in temporary session +- **WHEN** the user clicks "New Note" in temporary session mode +- **AND** enters a note name +- **THEN** a new note SHALL be created in memory +- **AND** the user CAN edit the note content +- **AND** the user CAN save (persist to memory only) + +#### Scenario: User edits note in temporary session +- **WHEN** the user modifies the editor content +- **AND** the note has unsaved changes +- **THEN** the dirty indicator SHALL show unsaved status +- **AND** the user CAN click Save to persist changes to memory + +### Requirement: Export as JSON +The system SHALL provide a way to download all notes as a JSON file. + +#### Scenario: User exports all notes as JSON +- **WHEN** the user clicks "Export JSON" button +- **THEN** the browser SHALL download a file named `ink-export-YYYY-MM-DD.json` +- **AND** the file SHALL contain a JSON object with notes array +- **AND** each note SHALL have `name`, `path`, and `content` fields + +#### Scenario: JSON export contains multiple notes +- **WHEN** the user has created multiple notes in temporary session +- **AND** clicks Export JSON +- **THEN** the exported JSON SHALL include all notes +- **AND** the order SHALL be preserved + +### Requirement: Export as Markdown +The system SHALL provide a way to download individual notes as .md files. + +#### Scenario: User exports current note as Markdown +- **WHEN** the user clicks "Export Markdown" button while a note is open +- **THEN** the browser SHALL download a file with the note's filename +- **AND** the file SHALL contain the note's raw markdown content +- **AND** the file SHALL have `.md` extension + +### Requirement: Temporary Session UI Indicator +The system SHALL display a clear indicator when operating in temporary session mode. + +#### Scenario: Temporary session is active +- **WHEN** the app is running in temporary session mode +- **THEN** a visual indicator SHALL be displayed showing "Temporary Session" +- **AND** the indicator SHALL inform users their data is not persisted +- **AND** export buttons SHALL be prominently visible + +#### Scenario: No indicator shown in normal mode +- **WHEN** the app is running with real file system access +- **THEN** no temporary session indicator SHALL be displayed +- **AND** export buttons MAY be hidden or disabled + + + +# Change: Add Mobile Fallback Support for Browsers Without File System Access API + +## Why +Mobile browsers (e.g., Safari on iOS) do not support the File System Access API. Currently, ink shows an error message and prevents users from using the app. This excludes mobile users entirely. Instead, we should allow temporary in-memory work and provide export capabilities so users can download their notes. + +## What Changes +- Detect when File System Access API is unavailable +- Enable in-memory workspace mode for temporary editing +- Add "Export as JSON" button to download all notes as a JSON file +- Add "Export as Markdown" button to download individual notes as .md files +- Show informative UI about temporary nature of the session +- Preserve existing functionality for desktop browsers with FS API support + +## Impact +- Affected specs: mobile-support (new capability) +- Affected code: src/app/bootstrap.ts, src/app/fs-api.ts, ink-app.html, styles +- No breaking changes to existing desktop functionality + + + +## 1. Implementation +- [x] 1.1 Update `src/app/fs-api.ts` - Add function to check if FS API is available +- [x] 1.2 Update `src/app/bootstrap.ts` - Modify `openWorkspace()` to enter in-memory mode when FS API unavailable +- [x] 1.3 Add in-memory workspace state management (notes stored in memory, not on disk) +- [x] 1.4 Add "Export as JSON" button to UI (download all notes) +- [x] 1.5 Add "Export as Markdown" button to UI (download single note) +- [x] 1.6 Add UI indicator showing "Temporary Session" mode +- [x] 1.7 Update `ink-app.html` with new export buttons +- [x] 1.8 Update `src/styles.scss` with temporary session styles +- [x] 1.9 Test in-memory mode works without errors +- [x] 1.10 Test export JSON downloads correctly +- [x] 1.11 Test export Markdown downloads correctly + +## 2. Cypress Tests +- [x] 2.1 Create `cypress/e2e/mobile-fallback.cy.js` +- [x] 2.2 Test: When FS API unavailable, app shows temporary session mode +- [x] 2.3 Test: User can create and edit notes in memory +- [x] 2.4 Test: Export JSON button downloads valid JSON file +- [x] 2.5 Test: Export Markdown button downloads valid .md file + +## 3. QUnit Tests +- [x] 3.1 Create `tests/qunit/mobile-fallback.test.js` +- [x] 3.2 Test: isFileSystemApiAvailable() returns correct values based on browser +- [x] 3.3 Test: In-memory notes can be created and retrieved +- [x] 3.4 Test: JSON export formats notes correctly +- [x] 3.5 Test: Markdown export includes frontmatter and content + + + +## ADDED Requirements + +### Requirement: ARIA Support for Menu Bar +The menu bar SHALL implement proper ARIA attributes to ensure accessibility for screen readers and assistive technologies. + +#### Scenario: Menu bar has proper ARIA role +- **WHEN** a screen reader encounters the menu bar +- **THEN** the menu bar has role="menubar" attribute +- **AND** screen readers announce it as a menu bar + +#### Scenario: Menu items have proper ARIA attributes +- **WHEN** a screen reader encounters menu items +- **THEN** each menu item has role="menuitem" attribute +- **AND** menu items with dropdowns have aria-haspopup="true" +- **AND** aria-expanded indicates dropdown state + +#### Scenario: Dropdown menus have proper ARIA attributes +- **WHEN** a dropdown menu is opened +- **THEN** the dropdown has role="menu" attribute +- **AND** each dropdown item has role="menuitem" attribute +- **AND** aria-expanded="true" on the parent menu item + +### Requirement: Keyboard Accessibility +The menu bar SHALL support full keyboard navigation for users who cannot use a mouse. + +#### Scenario: Tab navigation support +- **WHEN** a user navigates using Tab key +- **THEN** focus moves to menu bar items in logical order +- **AND** focused items are visually indicated + +#### Scenario: Arrow key navigation +- **WHEN** a user navigates using arrow keys +- **THEN** Left/Right arrows move between top-level menu items +- **AND** Up/Down arrows move between dropdown menu items +- **AND** Home/End keys move to first/last items + +#### Scenario: Enter and Space key activation +- **WHEN** a user presses Enter or Space on a focused menu item +- **THEN** the menu item is activated +- **AND** dropdowns open or actions are executed as appropriate + +#### Scenario: Escape key handling +- **WHEN** a user presses Escape key +- **THEN** open dropdowns are closed +- **AND** focus returns to the parent menu item +- **AND** no other application functionality is affected + +### Requirement: Visual Focus Indicators +The menu bar SHALL provide clear visual indicators for keyboard focus and active states. + +#### Scenario: Focus indicators for menu items +- **WHEN** a menu item receives keyboard focus +- **THEN** a clear visual focus indicator is displayed +- **AND** the focus indicator meets WCAG contrast requirements + +#### Scenario: Active state indicators +- **WHEN** a menu item is activated or a dropdown is open +- **THEN** clear visual indicators show the active state +- **AND** the indicators are distinguishable from focus indicators + +#### Scenario: Hover state indicators +- **WHEN** a user hovers over menu items with a mouse +- **THEN** visual indicators show hover state +- **AND** hover indicators are consistent with focus indicators + +### Requirement: Screen Reader Announcements +The menu bar SHALL provide appropriate announcements for screen reader users. + +#### Scenario: Menu item descriptions +- **WHEN** a screen reader focuses on a menu item +- **THEN** the item's purpose is clearly announced +- **AND** any associated keyboard shortcuts are announced + +#### Scenario: Dropdown state announcements +- **WHEN** a dropdown menu state changes +- **THEN** screen readers announce the state change +- **AND** aria-expanded attribute is updated appropriately + +#### Scenario: Menu navigation announcements +- **WHEN** a user navigates between menu items +- **THEN** screen readers announce the current position +- **AND** the total number of items is announced when appropriate + +### Requirement: High Contrast and Zoom Support +The menu bar SHALL support high contrast modes and browser zoom functionality. + +#### Scenario: High contrast mode support +- **WHEN** high contrast mode is enabled +- **THEN** menu bar maintains proper contrast ratios +- **AND** all interactive elements remain visible and usable + +#### Scenario: Browser zoom support +- **WHEN** browser zoom is applied +- **THEN** menu bar layout adjusts appropriately +- **AND** no content is clipped or overlapping +- **AND** all functionality remains accessible + +### Requirement: Error Handling and Feedback +The menu bar SHALL provide appropriate feedback for accessibility-related errors. + +#### Scenario: Disabled menu item feedback +- **WHEN** a user attempts to activate a disabled menu item +- **THEN** appropriate feedback is provided +- **AND** screen readers announce the item as disabled + +#### Scenario: Invalid keyboard input handling +- **WHEN** a user provides invalid keyboard input +- **THEN** the application handles it gracefully +- **AND** no unexpected behavior occurs + + + +## ADDED Requirements + +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. + +#### Scenario: New Note shortcut +- **WHEN** a user presses Ctrl+E (Windows/Linux) or Cmd+E (Mac) +- **THEN** the createNewNote functionality is triggered +- **AND** the same behavior as clicking "New Note" menu item occurs + +#### Scenario: Open Workspace shortcut +- **WHEN** a user presses Ctrl+Shift+O (Windows/Linux) or Cmd+Shift+O (Mac) +- **THEN** the openWorkspace functionality is triggered +- **AND** the same behavior as clicking "Open Workspace" menu item occurs + +#### Scenario: Save shortcut +- **WHEN** a user presses Ctrl+S (Windows/Linux) or Cmd+S (Mac) +- **THEN** the saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking "Save" menu item occurs + +#### Scenario: Refresh shortcut +- **WHEN** a user presses Ctrl+L (Windows/Linux) or Cmd+L (Mac) +- **THEN** the rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking "Refresh" menu item occurs + +#### Scenario: Export JSON shortcut +- **WHEN** a user presses Ctrl+Shift+S (Windows/Linux) or Cmd+Shift+S (Mac) +- **THEN** the exportAsJson functionality is triggered +- **AND** the same behavior as clicking "Export JSON" menu item occurs + +#### Scenario: Export Markdown shortcut +- **WHEN** a user presses Ctrl+Shift+M (Windows/Linux) or Cmd+Shift+M (Mac) +- **THEN** the exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking "Export Markdown" menu item occurs + +### Requirement: Keyboard Navigation of Menu Bar +The menu bar SHALL support full keyboard navigation using standard accessibility patterns. + +#### Scenario: Tab navigation to menu bar +- **WHEN** a user presses Tab to navigate to the menu bar +- **THEN** focus moves to the first menu item +- **AND** the menu item is visually indicated as focused + +#### Scenario: Arrow key navigation between menus +- **WHEN** a user presses Left/Right arrow keys while focused on menu bar +- **THEN** focus moves between menu items (File, Edit, Import/Export) +- **AND** the focused menu item is visually indicated + +#### Scenario: Enter key to open dropdown +- **WHEN** a user presses Enter while focused on a menu item +- **THEN** the dropdown menu opens +- **AND** focus moves to the first menu item in the dropdown + +#### Scenario: Escape key to close dropdowns +- **WHEN** a user presses Escape while a dropdown is open +- **THEN** the dropdown closes +- **AND** focus returns to the parent menu item + +#### Scenario: Up/Down arrow navigation in dropdowns +- **WHEN** a user presses Up/Down arrow keys while focused on a dropdown menu +- **THEN** focus moves between menu items in the dropdown +- **AND** the focused item is visually indicated + +### Requirement: Shortcut Display in Menu Items +Menu items with keyboard shortcuts SHALL display the shortcut keys for discoverability. + +#### Scenario: Shortcut key display +- **WHEN** a user views the menu bar +- **THEN** menu items that have keyboard shortcuts display the shortcut keys +- **AND** shortcut keys are displayed in a consistent format (e.g., "Ctrl+N") + +#### Scenario: Platform-appropriate shortcut display +- **WHEN** the application runs on different platforms +- **THEN** shortcut keys are displayed using platform-appropriate modifiers +- **AND** Windows/Linux shows "Ctrl", Mac shows "Cmd" + +### Requirement: Shortcut Key Conflict Resolution +The application SHALL handle keyboard shortcut conflicts appropriately. + +#### Scenario: Browser shortcut precedence +- **WHEN** a user presses a keyboard shortcut that conflicts with browser functionality +- **THEN** the application prevents the default browser behavior +- **AND** the application's shortcut functionality is executed instead + +#### Scenario: Focus-based shortcut activation +- **WHEN** keyboard shortcuts are pressed +- **THEN** shortcuts are only active when the application has focus +- **AND** shortcuts do not interfere with other applications + + + +## ADDED Requirements + +### Requirement: Menu Bar Structure +The application SHALL provide a horizontal menu bar at the top of the application window containing File, Edit, and Import/Export menu items. + +#### Scenario: Menu bar is visible and accessible +- **WHEN** the application loads +- **THEN** a horizontal menu bar is displayed at the top of the application window +- **AND** the menu bar contains File, Edit, and Import/Export menu items + +#### Scenario: 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 + +### Requirement: File Menu Functionality +The File menu SHALL provide access to workspace and file management 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 + +#### 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 + +### Requirement: Edit Menu Functionality +The Edit menu SHALL provide access to document editing and view operations. + +#### Scenario: Save menu item +- **WHEN** a user clicks "Save" in the Edit menu +- **THEN** the existing saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking the "Save" button occurs + +#### Scenario: Refresh menu item +- **WHEN** a user clicks "Refresh" in the Edit menu +- **THEN** the existing rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking the "Refresh" button occurs + +#### Scenario: Sort toggle menu item +- **WHEN** a user clicks "Sort: Name/Modified" in the Edit menu +- **THEN** the existing sort functionality is triggered +- **AND** the same behavior as clicking the "Sort" button occurs + +#### Scenario: Collapse Sidebar menu item +- **WHEN** a user clicks "Collapse Sidebar" in the Edit menu +- **THEN** the existing setSidebarCollapsed functionality is triggered +- **AND** the same behavior as clicking the sidebar toggle button occurs + +### Requirement: Import/Export Menu Functionality +The Import/Export menu SHALL provide access to data export operations. + +#### Scenario: Export JSON menu item +- **WHEN** a user clicks "Export JSON" in the Import/Export menu +- **THEN** the existing exportAsJson functionality is triggered +- **AND** the same behavior as clicking the "Export JSON" button occurs + +#### Scenario: Export Markdown menu item +- **WHEN** a user clicks "Export Markdown" in the Import/Export menu +- **THEN** the existing exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking the "Export MD" button occurs + +### Requirement: Menu Dropdown Behavior +Menu dropdowns SHALL open and close appropriately with proper keyboard and mouse interaction. + +#### Scenario: Mouse interaction with dropdowns +- **WHEN** a user hovers over or clicks a menu item +- **THEN** the dropdown menu opens +- **AND** clicking outside the menu closes it + +#### Scenario: Keyboard navigation of menus +- **WHEN** a user navigates using arrow keys +- **THEN** focus moves between menu items +- **AND** pressing Enter activates the focused menu item +- **AND** pressing Escape closes open dropdowns + +### Requirement: Backward Compatibility +The menu bar SHALL not interfere with existing button functionality. + +#### Scenario: Buttons continue to work +- **WHEN** a user clicks existing buttons +- **THEN** the same functionality is triggered as before +- **AND** menu items provide alternative access to the same functions + +#### Scenario: Menu and button state synchronization +- **WHEN** a menu item is clicked +- **THEN** any related button states are updated appropriately +- **AND** the application maintains consistent state across all interfaces + + + +# Design: Office-Style Menu Bar Implementation + +## Context + +The ink markdown note-taking application needs a proper office-style menu bar to improve user experience and provide familiar desktop application patterns. The application currently has a functional but button-heavy interface. The menu bar should integrate seamlessly with existing functionality while adding discoverability and keyboard shortcuts. + +## Goals / Non-Goals + +### Goals +- Provide familiar File, Edit, and Import/Export menu structure +- Add keyboard shortcuts for common operations +- Maintain full backward compatibility with existing buttons +- Ensure accessibility with proper ARIA attributes +- Keep implementation simple and maintainable +- Follow existing code patterns and architecture + +### Non-Goals +- Replace existing button interface (menus complement, don't replace) +- Add complex menu features like toolbars or ribbons +- Implement undo/redo functionality (not currently in scope) +- Add theming or customization options +- Support for nested submenus beyond basic dropdowns + +## Decisions + +### Menu Structure Decision +**Decision**: Use a horizontal menu bar with dropdown menus for File, Edit, and Import/Export sections. + +**Rationale**: This follows standard desktop application patterns and provides clear organization of functionality. The horizontal layout works well with the existing sidebar layout. + +**Alternatives considered**: +- Contextual menus only: Rejected because it reduces discoverability +- Vertical sidebar menu: Rejected because it conflicts with existing workspace sidebar +- Hybrid approach: Rejected for complexity + +### Keyboard Shortcuts Decision +**Decision**: Implement standard desktop application shortcuts: +- Ctrl/Cmd+N: New Note +- Ctrl/Cmd+O: Open Workspace +- Ctrl/Cmd+S: Save +- F5: Refresh +- Ctrl/Cmd+Shift+S: Export JSON +- Ctrl/Cmd+Shift+M: Export Markdown + +**Rationale**: These follow established conventions from applications like VS Code, Notepad++, and other desktop editors. + +**Alternatives considered**: +- Custom shortcuts: Rejected for discoverability +- No shortcuts: Rejected because power users expect them + +### Integration Approach Decision +**Decision**: Extend existing InkApp class methods rather than creating new menu-specific functions. + +**Rationale**: This maintains code reuse and ensures consistent behavior between button clicks and menu selections. The existing methods already handle edge cases and error conditions properly. + +**Alternatives considered**: +- Separate menu handlers: Rejected because it would duplicate logic +- Event delegation: Rejected because direct method calls are simpler + +### Accessibility Decision +**Decision**: Implement full ARIA support with proper roles, labels, and keyboard navigation. + +**Rationale**: Essential for screen reader users and keyboard-only navigation. Follows WCAG guidelines for menu widgets. + +**Alternatives considered**: +- Basic accessibility: Rejected because it doesn't meet modern standards +- No accessibility: Rejected as unacceptable + +## Risks / Trade-offs + +### Risk: Layout Complexity +**Risk**: Adding menu bar could complicate the existing layout and cause responsive design issues. + +**Mitigation**: +- Use CSS Grid/Flexbox for flexible layout +- Test thoroughly on different screen sizes +- Maintain existing responsive behavior + +### Risk: Keyboard Shortcut Conflicts +**Risk**: New shortcuts might conflict with browser or OS shortcuts. + +**Mitigation**: +- Use standard shortcuts that don't conflict +- Add proper event.preventDefault() handling +- Test on different platforms + +### Risk: Performance Impact +**Risk**: Additional DOM elements and event listeners could impact performance. + +**Mitigation**: +- Use event delegation where possible +- Minimize DOM manipulation +- Follow existing performance patterns + +## Migration Plan + +### Phase 1: Core Menu Structure +1. Add HTML structure and basic CSS +2. Implement menu toggle functionality +3. Add basic keyboard navigation + +### Phase 2: Menu Item Implementation +1. Implement File menu items +2. Implement Edit menu items +3. Implement Import/Export menu items + +### Phase 3: Polish and Testing +1. Add comprehensive tests +2. Implement accessibility features +3. Add keyboard shortcuts +4. Performance optimization + +### Rollback Plan +If issues arise, the menu bar can be easily disabled by: +1. Removing menu HTML structure +2. Removing menu-specific CSS +3. Removing menu event handlers +4. All existing functionality remains intact + +## Open Questions + +1. **Should we add a Help menu?** - Currently not planned, but could be added later +2. **Should menu items be disabled when not applicable?** - Yes, following existing button patterns +3. **Should we support right-click context menus?** - Not in initial implementation, could be added later +4. **Should we add menu animations?** - No, keeping it simple and fast + + + +# Change: Add Office-Style Menu Bar + +## Why + +The ink markdown note-taking application currently lacks a proper office-style menu bar (File, Edit, Import/Export) that users expect from desktop applications. This makes common operations like opening workspaces, creating files/folders, and exporting content less discoverable and accessible. Adding a menu bar will improve user experience by providing familiar navigation patterns and keyboard shortcuts. + +## What Changes + +- **BREAKING**: Add a new menu bar component with File, Edit, and Import/Export sections +- Integrate existing functionality (open workspace, create files/folders, export) into menu items +- Add keyboard shortcuts for common operations (Ctrl/Cmd+N, Ctrl/Cmd+O, Ctrl/Cmd+S, etc.) +- Maintain existing button functionality while adding menu alternatives +- Add accessibility attributes and proper ARIA labeling +- Update UI layout to accommodate menu bar + +## Impact + +- Affected specs: `menu-bar`, `keyboard-shortcuts`, `accessibility` +- Affected code: `src/app/bootstrap.ts`, `src/app/dom.ts`, `src/app/types.ts`, `ink-app.html` +- New files: Menu component implementation, CSS styles, comprehensive tests +- User experience: Improved discoverability of features, familiar desktop application patterns +- Backward compatibility: All existing functionality preserved, menu adds new access points + + + +# 1. Implementation + +## 1.1 Menu Bar Structure +- [x] 1.1.1 Add menu bar HTML structure to ink-app.html +- [x] 1.1.2 Create CSS styles for menu bar layout and dropdowns +- [x] 1.1.3 Add ARIA attributes for accessibility (role="menubar", aria-haspopup, etc.) + +## 1.2 File Menu Implementation +- [x] 1.2.1 Implement "New Note" menu item (Ctrl/Cmd+N) +- [x] 1.2.2 Implement "New Folder" menu item +- [x] 1.2.3 Implement "Open Workspace" menu item (Ctrl/Cmd+O) +- [x] 1.2.4 Implement "Close Workspace" menu item +- [x] 1.2.5 Implement "Exit" menu item + +## 1.3 Edit Menu Implementation +- [x] 1.3.1 Implement "Save" menu item (Ctrl/Cmd+S) +- [x] 1.3.2 Implement "Save As" menu item +- [x] 1.3.3 Implement "Refresh" menu item (F5) +- [x] 1.3.4 Implement "Sort" toggle menu item +- [x] 1.3.5 Implement "Collapse Sidebar" menu item + +## 1.4 Import/Export Menu Implementation +- [x] 1.4.1 Implement "Export JSON" menu item +- [x] 1.4.2 Implement "Export Markdown" menu item +- [ ] 1.4.3 Implement "Import JSON" menu item (if needed) + +## 1.5 Keyboard Shortcuts +- [x] 1.5.1 Add global keyboard event listener for menu shortcuts +- [x] 1.5.2 Implement Ctrl/Cmd+E for new note +- [x] 1.5.3 Implement Ctrl/Cmd+Shift+O for open workspace +- [x] 1.5.4 Implement Ctrl/Cmd+S for save +- [x] 1.5.5 Implement Ctrl/Cmd+L for refresh +- [x] 1.5.6 Add visual indicators for keyboard shortcuts in menu items + +## 1.6 Integration with Existing Code +- [x] 1.6.1 Update InkApp class to handle menu events +- [x] 1.6.2 Update DOM references in dom.ts to include menu elements +- [x] 1.6.3 Update types.ts with new menu-related types +- [x] 1.6.4 Ensure menu items call existing InkApp methods +- [x] 1.6.5 Maintain backward compatibility with existing buttons + +## 1.7 Testing +- [ ] 1.7.1 Add Cypress tests for menu bar functionality +- [ ] 1.7.2 Add QUnit tests for menu component logic +- [ ] 1.7.3 Test keyboard shortcuts with Cypress +- [ ] 1.7.4 Test accessibility with screen reader simulation +- [ ] 1.7.5 Test menu dropdown behavior and state management + +## 1.8 Documentation and Polish +- [ ] 1.8.1 Update README.md with new menu features +- [ ] 1.8.2 Add keyboard shortcut documentation +- [ ] 1.8.3 Test responsive design for mobile/tablet +- [x] 1.8.4 Ensure proper error handling and user feedback +- [x] 1.8.5 Code review and cleanup + + + +# Change: Add a Declarative WebMCP Note Creation Tool + +## 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. + +## 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 + +## 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 + +### 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 + +### 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. + +#### 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 + +### 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 + + + +## 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 + +## 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 + +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. + +#### 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 + +### 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 + +#### 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 + + + +# Change: Fix Export JSON Shortcut Precedence + +## Why + +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 + +- 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 + +## Non-Regression Guarantees + +- 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 + +## 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 + +## Impact + +- 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 + + + +## 1. Implementation + +- [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 + +## 2. Testing + +- [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 + +## 3. Validation + +- [x] 3.1 Run `openspec validate fix-export-json-shortcut-precedence --strict` +- [x] 3.2 Run the relevant automated shortcut regression tests after implementation + + + +# Spec: Mobile Layout + +## Sidebar Toggle Always Visible on Small Screens + +The sidebar toggle button (`#sidebarToggleBtn`) SHALL be visible and interactive at all viewport widths, including those ≤ 980 px. + +#### Scenario: App loads on a mobile device — sidebar expanded + +- **GIVEN** a viewport width of 390 px +- **WHEN** the app finishes loading +- **THEN** the sidebar toggle button is rendered as a full-width horizontal button inside the sidebar +- **AND** the button label reads "▶ Collapse" with `aria-label="Collapse sidebar"` + +#### Scenario: App loads on a mobile device — sidebar collapsed + +- **GIVEN** a viewport width of 390 px and the sidebar state persisted as collapsed +- **WHEN** the app finishes loading +- **THEN** the sidebar is shown as a thin horizontal strip +- **AND** the toggle button is visible within that strip, reading "▼ Expand" with `aria-label="Expand sidebar"` + +--- + +## Sidebar Collapses and Expands Vertically on Mobile + +On small screens the sidebar SHALL collapse and expand vertically (row height), not horizontally (column width). + +#### Scenario: User collapses sidebar on mobile + +- **GIVEN** the sidebar is expanded on a ≤ 980 px viewport +- **WHEN** the user taps the sidebar toggle button +- **THEN** the sidebar shrinks to a horizontal strip showing only the toggle button +- **AND** the editor and preview area expand vertically to occupy the freed space +- **AND** the layout remains a single full-width column (no narrow sidebar column appears alongside the editor) + +#### Scenario: User expands sidebar on mobile + +- **GIVEN** the sidebar is collapsed on a ≤ 980 px viewport +- **WHEN** the user taps the toggle strip +- **THEN** the sidebar expands to show the full workspace panel (workspace name, search, file tree, tags) +- **AND** the editor area returns to its position below the sidebar + +--- + +## Editor and Preview Fill Available Vertical Space + +The editor (` + +
    +
    +
    + + + + + +
    +
    +
    + +
    +
    + + + + + + +
    + + +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} + + + + + + + + + ink - Markdown Notes & Documents + + + + + + + +
    + + + + + + + +
    +
    + + No note open + +
    + Ready +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + + + + + + +
    + + +MIT License + +Copyright (c) 2026 Federico Viscioletti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +.PHONY: help lint build watch test test-qunit test-cypress update-repomix + +help: + @echo "Available targets:" + @echo " lint - Run ESLint" + @echo " build - Build the project" + @echo " watch - Watch for changes and rebuild" + @echo " test - Run all tests (QUnit and Cypress)" + @echo " test-qunit - Run QUnit tests" + @echo " test-cypress - Run Cypress tests" + @echo " repomix - Update repomix to the latest version" + +lint: + @echo "Running ESLint..." + npm run lint + +build: + @echo "Building the project..." + npm run build + +watch: + @echo "Watching for changes..." + npm run watch + +test: test-qunit test-cypress + +test-qunit: + @echo "Running QUnit tests..." + npm run test:qunit + +test-cypress: + @echo "Running Cypress tests..." + npm run test:cypress + +repomix: + @echo "Updating repomix to the latest version..." + npx repomix@latest + + + +{ + "$schema": "https://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}" + } + } + } +} + + + +{ + "name": "ink", + "version": "1.1.5", + "description": "Ink is a functional and minimalistic webapp to write documents in markdown and export them", + "homepage": "https://github.com/feddernico/ink#readme", + "bugs": { + "url": "https://github.com/feddernico/ink/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/feddernico/ink.git" + }, + "license": "MIT", + "author": "Federico Viscioletti", + "type": "module", + "main": "index.js", + "scripts": { + "lint": "eslint .", + "build": "npm run lint && node build/compile-and-assemble.js", + "watch": "node build/watch.js", + "build:favicon": "node build/generate-favicons.js", + "build:test": "node build/build-test.js", + "test:qunit": "npm run build:test && qunit \"tests/qunit/**/*.test.js\"", + "serve:test": "http-server . -p 4173 -s", + "test:cypress": "start-server-and-test \"npm run serve:test\" http://127.0.0.1:4173/ink-app.html \"unset ELECTRON_RUN_AS_NODE; cypress run\"", + "test:cypress:open": "unset ELECTRON_RUN_AS_NODE; cypress open", + "test": "npm run test:qunit && npm run test:cypress" + }, + "devDependencies": { + "@babel/standalone": "^7.29.2", + "@cypress/code-coverage": "^4.0.2", + "babel-plugin-istanbul": "^7.0.1", + "chokidar": "^4.0.3", + "cypress": "^15.12.0", + "esbuild": "^0.27.3", + "eslint": "^10.1.0", + "globals": "^17.4.0", + "http-server": "^14.1.1", + "nyc": "^18.0.0", + "qunit": "^2.24.2", + "sass": "^1.97.3", + "start-server-and-test": "^2.0.12", + "typescript": "^5.9.3" + }, + "dependencies": { + "marked": "^17.0.4" + }, + "overrides": { + "immutable": "^5.1.5" + } +} + + + +# ink +

    + Ink logo +

    + +Ink is a functional and minimalistic webapp to write documents in markdown and export them. + +![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) + +## Repo Structure + +The structure of the repo is as follows: + +``` +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 + + + +
    + + + + + + + +
    +
    + + No note open + +
    + Ready +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + + + + + + +
    + + + + + + + + ink - Markdown Notes & Documents + + + + + + + +
    + + + + + + + +
    +
    + + No note open + +
    + Ready +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + + + + + + +
    + + + + + +{ + "permissions": { + "allow": [ + "Bash(npm install marked)", + "Bash(npm run build)", + "Bash(npm run test:qunit)", + "Bash(npm run test)", + "Bash(git stash)", + "Bash(npm run test:cypress)", + "Bash(git stash pop)", + "Bash(npm install --save-dev typescript)", + "Bash(git push origin feat/add-marked-as-library)", + "Bash(gh pr:*)" + ] + } +} + + + +{ + "version": 1, + "root": "/Users/federicoviscioletti/Dev/ink", + "installed": { + "https://registry.kdco.dev::kdco/kdco-primitives@sha256:688d2d5f536e8db0b44fdff5d588000a5a9ed7028ec715828cc458d27f34dd8d": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "kdco-primitives", + "revision": "sha256:688d2d5f536e8db0b44fdff5d588000a5a9ed7028ec715828cc458d27f34dd8d", + "hash": "688d2d5f536e8db0b44fdff5d588000a5a9ed7028ec715828cc458d27f34dd8d", + "files": [ + { + "path": ".opencode/plugins/kdco-primitives/get-project-id.ts", + "hash": "2bdcb8e839171e8a9ddff36f7b9c28c90fae972cb395cec38f8d3e85cfd66ad9" + }, + { + "path": ".opencode/plugins/kdco-primitives/with-timeout.ts", + "hash": "3c582e3e89f5d0de8230122ef0fea2066b3ebff3d77579515cef251628439527" + }, + { + "path": ".opencode/plugins/kdco-primitives/log-warn.ts", + "hash": "7ed7eb195fd4d67cbee8057ae35593b92693e1e5e84012fcf5402a14df071311" + }, + { + "path": ".opencode/plugins/kdco-primitives/types.ts", + "hash": "4471c8d97b16e06906f3446e89e01c4a436130a42e5fa6c31f107e63f3b42666" + }, + { + "path": ".opencode/plugins/kdco-primitives/mutex.ts", + "hash": "4caacfd38bb4b971c356ed9dbfb4dcc10634a83a39c51e724852325d84be941e" + }, + { + "path": ".opencode/plugins/kdco-primitives/shell.ts", + "hash": "9941f4f642c10f04795304a7657111b2cd10ec066b08f0e0866ae87de5160669" + }, + { + "path": ".opencode/plugins/kdco-primitives/temp.ts", + "hash": "b66983f2a747d87fb84dcdaa3ed88e5da2e8e46c8151e98f74e76755148f7e08" + }, + { + "path": ".opencode/plugins/kdco-primitives/terminal-detect.ts", + "hash": "523435085e8fc31883a6a702e70fd9ba82c5d799026d0f04be890c84dbab6b41" + }, + { + "path": ".opencode/plugins/kdco-primitives/index.ts", + "hash": "012301f644639e930b30ea09f43b0a3808d8dfed3970c242dd60ab1c530fc5e6" + } + ], + "installedAt": "2026-03-27T10:01:48.970Z" + }, + "https://registry.kdco.dev::kdco/worktree@sha256:d034940e3b6f54ffbe74bc3126ad762fb93ba013715fa4ee4b6599e19d75131d": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "worktree", + "revision": "sha256:d034940e3b6f54ffbe74bc3126ad762fb93ba013715fa4ee4b6599e19d75131d", + "hash": "d034940e3b6f54ffbe74bc3126ad762fb93ba013715fa4ee4b6599e19d75131d", + "files": [ + { + "path": ".opencode/plugins/worktree.ts", + "hash": "a1ee8d25da1a6e66eade962ea96769803d862e89c8c41a939a9fc1e911547d01" + }, + { + "path": ".opencode/plugins/worktree/state.ts", + "hash": "4bcf9b53a8389938f59ae43e15387e9f90ca970c2ccfb4d7fd78513aebcd2eb7" + }, + { + "path": ".opencode/plugins/worktree/terminal.ts", + "hash": "30644e13f6c2da047b78bd9010433d8ddf5a1d1ffe62288a54c28a155f6a5263" + }, + { + "path": ".opencode/plugins/worktree/launch-context.ts", + "hash": "48c7151fd7ea0bd141e40c7a0e8a685d804bd8ab8aaba1dfed00ec2ebae13b48" + } + ], + "installedAt": "2026-03-27T10:01:48.979Z" + }, + "https://registry.kdco.dev::kdco/background-agents@sha256:48f0844d9959996a41458acc2c35e8729683255142bce09f12888c4ab0c4d71d": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "background-agents", + "revision": "sha256:48f0844d9959996a41458acc2c35e8729683255142bce09f12888c4ab0c4d71d", + "hash": "48f0844d9959996a41458acc2c35e8729683255142bce09f12888c4ab0c4d71d", + "files": [ + { + "path": ".opencode/plugins/background-agents.ts", + "hash": "58888250845d02e2d3d305a8004659d5938e8f49eb34c0d02dc37af7de341b26" + } + ], + "installedAt": "2026-03-27T10:01:48.971Z", + "opencode": { + "permission": { + "task": "deny" + } + } + }, + "https://registry.kdco.dev::kdco/workspace-plugin@sha256:d70f924c872dc4541f90d417d1c90545faf01ddc19a90ebc4408509dd073c890": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "workspace-plugin", + "revision": "sha256:d70f924c872dc4541f90d417d1c90545faf01ddc19a90ebc4408509dd073c890", + "hash": "d70f924c872dc4541f90d417d1c90545faf01ddc19a90ebc4408509dd073c890", + "files": [ + { + "path": ".opencode/plugins/workspace-plugin.ts", + "hash": "aae8ff0b6bcf7994c81dccea4a92a352ffb9d772edd83e886992d4a70adb36e5" + } + ], + "installedAt": "2026-03-27T10:01:48.972Z" + }, + "https://registry.kdco.dev::kdco/plan-protocol@sha256:50dae3e515ac7d89b2081ec501cc05f7e00c6a706635abc2b6a6e9a52347bdc0": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "plan-protocol", + "revision": "sha256:50dae3e515ac7d89b2081ec501cc05f7e00c6a706635abc2b6a6e9a52347bdc0", + "hash": "50dae3e515ac7d89b2081ec501cc05f7e00c6a706635abc2b6a6e9a52347bdc0", + "files": [ + { + "path": ".opencode/skills/plan-protocol/SKILL.md", + "hash": "3e2708c8c62e34c84eea8d0b09976ccfb7557493c250555939aaf75cf3b82177" + } + ], + "installedAt": "2026-03-27T10:01:48.973Z" + }, + "https://registry.kdco.dev::kdco/researcher@sha256:123019a6e34f563ce0cf16cc17ab780c0316e097cf4577cf6feb5c3d47948f4c": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "researcher", + "revision": "sha256:123019a6e34f563ce0cf16cc17ab780c0316e097cf4577cf6feb5c3d47948f4c", + "hash": "123019a6e34f563ce0cf16cc17ab780c0316e097cf4577cf6feb5c3d47948f4c", + "files": [ + { + "path": ".opencode/agents/researcher.md", + "hash": "2b5138f97cada5d5048d1c015d8c11a96c3d72f5e2705ca268d4a1858dcfd614" + } + ], + "installedAt": "2026-03-27T10:01:48.973Z", + "opencode": { + "agent": { + "researcher": { + "permission": { + "context7_*": "allow", + "exa_*": "allow", + "gh_grep_*": "allow", + "kagi_*": "deny", + "webfetch": "allow", + "write": "deny", + "edit": "deny", + "plan_read": "deny", + "todoread": "deny", + "bash": { + "*": "deny", + "gh repo view*": "allow", + "gh pr view*": "allow", + "gh pr list*": "allow", + "gh issue view*": "allow", + "gh issue list*": "allow", + "gh release view*": "allow", + "gh release list*": "allow", + "gh run view*": "allow", + "gh run list*": "allow", + "gh workflow list*": "allow", + "gh search *": "allow", + "gh api *": "allow", + "npm view*": "allow", + "npm info*": "allow", + "npm show*": "allow", + "pip show*": "allow", + "pip index*": "allow", + "cargo search*": "allow", + "cargo info*": "allow", + "man *": "allow", + "tldr *": "allow", + "dig *": "allow", + "nslookup *": "allow", + "whois *": "allow", + "host *": "allow", + "jq *": "allow", + "head *": "allow", + "tail *": "allow", + "base64 *": "allow", + "grep *": "allow", + "rg *": "allow", + "wc *": "allow", + "sort *": "allow", + "uniq *": "allow", + "cut *": "allow", + "awk *": "allow", + "tr *": "allow" + } + } + } + }, + "permission": { + "context7_*": "deny", + "exa_*": "deny", + "gh_grep_*": "deny", + "kagi_*": "deny" + }, + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "enabled": true + }, + "exa": { + "type": "remote", + "url": "https://mcp.exa.ai/mcp", + "enabled": true + }, + "gh_grep": { + "type": "remote", + "url": "https://mcp.grep.app", + "enabled": true + } + } + } + }, + "https://registry.kdco.dev::kdco/scribe@sha256:866002e39e09902b08de096b8b254226ad27c8fb05af4d66ca729f50b0d436ee": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "scribe", + "revision": "sha256:866002e39e09902b08de096b8b254226ad27c8fb05af4d66ca729f50b0d436ee", + "hash": "866002e39e09902b08de096b8b254226ad27c8fb05af4d66ca729f50b0d436ee", + "files": [ + { + "path": ".opencode/agents/scribe.md", + "hash": "2d42f3e18b4017006b84f8f462c6736c66626ad01b820b2e367e080482d1728c" + } + ], + "installedAt": "2026-03-27T10:01:48.974Z", + "opencode": { + "agent": { + "scribe": { + "permission": { + "bash": { + "*": "deny" + }, + "edit": "allow", + "glob": "allow", + "read": "allow", + "write": "allow", + "plan_read": "deny", + "todoread": "deny" + } + } + } + } + }, + "https://registry.kdco.dev::kdco/coder@sha256:ba4339cc89f34fcf95716e9ee7232c704853dbb924461ea582578e12b2175021": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "coder", + "revision": "sha256:ba4339cc89f34fcf95716e9ee7232c704853dbb924461ea582578e12b2175021", + "hash": "ba4339cc89f34fcf95716e9ee7232c704853dbb924461ea582578e12b2175021", + "files": [ + { + "path": ".opencode/agents/coder.md", + "hash": "0d2437ac14530fb3d21675a8e1a90b578577e3f205abfffc390d516e3439f7e5" + } + ], + "installedAt": "2026-03-27T10:01:48.974Z", + "opencode": { + "agent": { + "coder": { + "permission": { + "context7_*": "deny", + "exa_*": "deny", + "gh_grep_*": "deny", + "read": "allow", + "write": "allow", + "edit": "allow", + "glob": "allow", + "grep": "allow", + "bash": "allow", + "plan_read": "deny", + "todoread": "deny" + } + } + } + } + }, + "https://registry.kdco.dev::kdco/code-review@sha256:5b5d505d4fb9b1623940937d6d4773ae2679995dcc08be6153a390965c7c256f": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "code-review", + "revision": "sha256:5b5d505d4fb9b1623940937d6d4773ae2679995dcc08be6153a390965c7c256f", + "hash": "5b5d505d4fb9b1623940937d6d4773ae2679995dcc08be6153a390965c7c256f", + "files": [ + { + "path": ".opencode/skills/code-review/SKILL.md", + "hash": "98200cc8992cdebd921e33cd426db5d9a9a80bb82bed19520c947289d55215ad" + } + ], + "installedAt": "2026-03-27T10:01:48.974Z" + }, + "https://registry.kdco.dev::kdco/plan-review@sha256:cb99ef1ef4518ecbbc0083d892327f1819d045c50c8765733dd9de1470e4b8b6": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "plan-review", + "revision": "sha256:cb99ef1ef4518ecbbc0083d892327f1819d045c50c8765733dd9de1470e4b8b6", + "hash": "cb99ef1ef4518ecbbc0083d892327f1819d045c50c8765733dd9de1470e4b8b6", + "files": [ + { + "path": ".opencode/skills/plan-review/SKILL.md", + "hash": "73a2c9310024940ee6e87f24c34bea60a6c27c5f2776c8e41c59184cfa6ac0e7" + } + ], + "installedAt": "2026-03-27T10:01:48.975Z" + }, + "https://registry.kdco.dev::kdco/reviewer@sha256:a15138b10262d8481e24a848f1da5501cce2025f3b2f5bd12eed0a5f03fc0375": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "reviewer", + "revision": "sha256:a15138b10262d8481e24a848f1da5501cce2025f3b2f5bd12eed0a5f03fc0375", + "hash": "a15138b10262d8481e24a848f1da5501cce2025f3b2f5bd12eed0a5f03fc0375", + "files": [ + { + "path": ".opencode/agents/reviewer.md", + "hash": "b18c61724c795de926d2c5d0b060da92cb470a8072a0d55d63cc7640be493854" + } + ], + "installedAt": "2026-03-27T10:01:48.976Z", + "opencode": { + "agent": { + "reviewer": { + "temperature": 0.1, + "permission": { + "edit": "deny", + "write": "deny", + "bash": { + "*": "deny", + "git diff*": "allow", + "git log*": "allow", + "git show*": "allow", + "git blame*": "allow", + "rg *": "allow" + }, + "plan_read": "allow", + "delegation_read": "allow", + "delegation_list": "allow" + } + } + } + } + }, + "https://registry.kdco.dev::kdco/review@sha256:b772e609bf063350f529bdd94449b690dd5c374c33cdcd39dc8004f5d600224a": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "review", + "revision": "sha256:b772e609bf063350f529bdd94449b690dd5c374c33cdcd39dc8004f5d600224a", + "hash": "b772e609bf063350f529bdd94449b690dd5c374c33cdcd39dc8004f5d600224a", + "files": [ + { + "path": ".opencode/command/review.md", + "hash": "4dd8c9ab0b80f7b97a9ce662b5270557d7cb134e4739ed96b8ac707cf9e1efaf" + } + ], + "installedAt": "2026-03-27T10:01:48.977Z" + }, + "https://registry.kdco.dev::kdco/notify@sha256:2d51712c24f6ab7430f22fd429bf97cc665ed64c97ae1f751e83f3857623e0a5": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "notify", + "revision": "sha256:2d51712c24f6ab7430f22fd429bf97cc665ed64c97ae1f751e83f3857623e0a5", + "hash": "2d51712c24f6ab7430f22fd429bf97cc665ed64c97ae1f751e83f3857623e0a5", + "files": [ + { + "path": ".opencode/plugins/notify.ts", + "hash": "b922f7f95d20ccf429d7966b125928b9546aa16147aca80a3c9772726830eb84" + }, + { + "path": ".opencode/plugins/notify/backend.ts", + "hash": "d8398dedaba3a14bd85221df0da4415aba85158a33f16899f3010f8746e9c636" + }, + { + "path": ".opencode/plugins/notify/cmux.ts", + "hash": "ced0bf06554b6b4af506e07848928fbe34deaf715fb871c9abedf5620fb2bb80" + } + ], + "installedAt": "2026-03-27T10:01:48.978Z" + }, + "https://registry.kdco.dev::kdco/code-philosophy@sha256:c41fffa9bb00b6e3b7df547158a0ba29d47a783d4b3299af5f0c8ed3dfcd621c": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "code-philosophy", + "revision": "sha256:c41fffa9bb00b6e3b7df547158a0ba29d47a783d4b3299af5f0c8ed3dfcd621c", + "hash": "c41fffa9bb00b6e3b7df547158a0ba29d47a783d4b3299af5f0c8ed3dfcd621c", + "files": [ + { + "path": ".opencode/skills/code-philosophy/SKILL.md", + "hash": "7f15b42b52e5f47c44c6d3cf93613f1918d69b74b3ae70d4115100368e1eb7c9" + } + ], + "installedAt": "2026-03-27T10:01:48.980Z" + }, + "https://registry.kdco.dev::kdco/frontend-philosophy@sha256:b90812b8ffb040eef0d31a87222eaa9471e168cc1792425e2767f2235268febe": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "frontend-philosophy", + "revision": "sha256:b90812b8ffb040eef0d31a87222eaa9471e168cc1792425e2767f2235268febe", + "hash": "b90812b8ffb040eef0d31a87222eaa9471e168cc1792425e2767f2235268febe", + "files": [ + { + "path": ".opencode/skills/frontend-philosophy/SKILL.md", + "hash": "42d577992ad9fe5349a522870b14d69ba60da6d00442b1d744d15d155666bf8b" + } + ], + "installedAt": "2026-03-27T10:01:48.980Z" + }, + "https://registry.kdco.dev::kdco/philosophy@sha256:f0b040317909a32d044167a65f37bf96710713f27860960405eec15003c4b88d": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "philosophy", + "revision": "sha256:f0b040317909a32d044167a65f37bf96710713f27860960405eec15003c4b88d", + "hash": "f0b040317909a32d044167a65f37bf96710713f27860960405eec15003c4b88d", + "files": [ + { + "path": ".opencode/tools/philosophy.md", + "hash": "910f20e1b2d2a68238d68b3d4090302a0b41acadb7b8e6a9f0fbde4405db25c9" + } + ], + "installedAt": "2026-03-27T10:01:48.981Z", + "opencode": { + "instructions": [ + "./tools/philosophy.md" + ] + } + }, + "https://registry.kdco.dev::kdco/workspace@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": { + "registryUrl": "https://registry.kdco.dev", + "registryName": "kdco", + "name": "workspace", + "revision": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "files": [], + "installedAt": "2026-03-27T10:01:48.981Z", + "opencode": { + "plugin": [ + "@tarquinen/opencode-dcp@3.1.3", + "@franlol/opencode-md-table-formatter@0.0.6" + ], + "agent": { + "plan": { + "permission": { + "edit": "deny", + "write": "deny", + "bash": { + "*": "deny" + }, + "task": "allow", + "worktree_*": "allow" + } + }, + "build": { + "prompt": "You are a **build orchestrator**. You coordinate implementation through delegation - you do NOT implement directly.\\\\\\\\n\\\\\\\\n## Your Role\\\\\\\\n- Delegate implementation to `coder`\\\\\\\\n- Delegate documentation to `scribe`\\\\\\\\n- Delegate codebase analysis to `explore`\\\\\\\\n- Delegate external research to `researcher`\\\\\\\\n- Interpret results and decide next steps\\\\\\\\n\\\\\\\\n## Critical Constraint\\\\\\\\nYou CANNOT edit files or run commands directly. For ALL implementation and verification, delegate to `coder`.", + "permission": { + "edit": "deny", + "write": "deny", + "bash": { + "*": "deny" + }, + "task": "allow", + "worktree_*": "allow" + } + }, + "explore": { + "permission": { + "edit": "deny", + "write": "deny", + "plan_read": "deny", + "todoread": "deny", + "bash": { + "*": "deny", + "ls *": "allow", + "tree *": "allow", + "pwd": "allow", + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "wc *": "allow", + "file *": "allow", + "stat *": "allow", + "grep *": "allow", + "rg *": "allow", + "find *": "allow", + "git status*": "allow", + "git log*": "allow", + "git diff*": "allow", + "git show*": "allow", + "git blame*": "allow", + "git branch*": "allow", + "git ls-files*": "allow", + "uname*": "allow", + "hostname": "allow", + "whoami": "allow", + "which *": "allow", + "realpath *": "allow" + } + } + } + }, + "permission": { + "webfetch": "deny", + "worktree_*": "deny" + } + } + } + } +} + + + + + + + + + + + + + + + + ink + + + + + +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts", "**/*.tsx"] +} + + + +## ADDED Requirements +### Requirement: Componentized Source Inputs +The project SHALL maintain separate source files for interaction logic, styling, and template markup. + +#### Scenario: Source layout follows project template +- **WHEN** a developer inspects the repository source layout +- **THEN** TypeScript interaction logic is located in `src/app.ts` +- **AND** SASS styling is located in `src/styles.scss` +- **AND** HTML template markup is maintained in a template source file used by the build pipeline + +### Requirement: Deterministic Build Outputs +The build pipeline SHALL compile TypeScript and SASS sources into deterministic distributable artifacts. + +#### Scenario: One-shot build compiles assets +- **WHEN** a developer runs the documented one-shot build command +- **THEN** the pipeline generates a JavaScript bundle at `dist/app.min.js` +- **AND** the pipeline generates compiled CSS at `dist/styles.min.css` + +### Requirement: Single-File Distribution Assembly +The build pipeline SHALL inject compiled JS and CSS artifacts into the HTML template to produce a single-file app output. + +#### Scenario: Build injects compiled assets into final HTML +- **WHEN** the injection step runs after asset compilation +- **THEN** the final HTML output contains inline CSS derived from `dist/styles.min.css` +- **AND** the final HTML output contains inline JavaScript derived from `dist/app.min.js` +- **AND** the resulting file is executable in a browser without network dependencies + +### Requirement: Incremental Developer Build Workflow +The project SHALL provide a watch-mode build workflow that rebuilds artifacts when source files change. + +#### Scenario: Watch mode rebuilds on source edits +- **WHEN** a developer runs the documented watch command and edits TypeScript, SASS, or template source files +- **THEN** relevant compiled artifacts are regenerated without requiring manual command re-entry + + + +## ADDED Requirements +### Requirement: QUnit Test Harness +The project SHALL provide a QUnit-based test harness and a documented command to run QUnit tests locally. + +#### Scenario: QUnit tests are executable from project scripts +- **WHEN** a developer runs the documented QUnit test command +- **THEN** the QUnit suite executes without requiring manual browser setup +- **AND** the command exits non-zero when any QUnit assertion fails + +### Requirement: Cypress End-to-End Test Harness +The project SHALL provide Cypress configuration and a documented command to run Cypress tests locally. + +#### Scenario: Cypress tests are executable from project scripts +- **WHEN** a developer runs the documented Cypress test command +- **THEN** Cypress launches against the application under test +- **AND** the command exits non-zero when any Cypress assertion fails + +### Requirement: Critical Authoring Flow Coverage +Automated tests SHALL validate the user workflow of selecting a workspace, creating a new file, entering markdown, and saving. + +#### Scenario: End-to-end flow is validated +- **WHEN** the Cypress workflow test runs +- **THEN** it selects a workspace through supported UI/test seam interactions +- **AND** it creates a new file +- **AND** it enters markdown content into the editor +- **AND** it triggers save +- **AND** it verifies that the saved content matches the entered markdown + +### Requirement: Fast Regression Coverage for Editor Logic +Automated tests SHALL include QUnit coverage for core editor interactions required by the authoring flow. + +#### Scenario: Editor behavior is validated by QUnit +- **WHEN** the QUnit suite executes editor interaction tests +- **THEN** tests verify at least initialization and markdown content mutation behavior used by the save flow + + + +## Context +Ink targets a single-file HTML distribution while development should remain modular and maintainable. The desired workflow requires clear source separation (TypeScript, SASS, HTML template), predictable build outputs, and automated tests that guard core document editing behavior. + +## Goals / Non-Goals +- Goals: + - Keep source-of-truth files separated by concern: logic, presentation, and template markup. + - Preserve single-file distributable output for runtime usage. + - Provide reliable local build and test commands. + - Cover the critical authoring flow with both fast in-browser tests (QUnit) and browser-driven tests (Cypress). +- Non-Goals: + - Migrating to a framework (React/Vue/etc.). + - Introducing a backend service. + - Redesigning editor UX beyond what is required for testability and flow support. + +## Decisions +- Decision: Use TypeScript compilation via existing `esbuild` and SASS compilation via `sass`, with a final injection step into a template file. + - Rationale: aligns with current lightweight toolchain and single-file output constraint. +- Decision: Expose build commands through `npm scripts` and keep `Makefile` as optional convenience wrapper. + - Rationale: `npm scripts` are portable and integrate well with QUnit/Cypress commands. +- Decision: Add QUnit tests for app-level behaviors that do not require full browser orchestration. + - Rationale: fast feedback for logic and DOM interactions. +- Decision: Add Cypress tests for full user workflow and persistence interactions. + - Rationale: validates user-visible behavior across realistic browser execution. + +## Risks / Trade-offs +- Tooling overhead increases with dual test frameworks. + - Mitigation: keep QUnit focused on fast logic checks; reserve Cypress for key end-to-end flows. +- Workspace/file APIs can be difficult to test directly in browsers. + - Mitigation: define test seams and controlled fixtures/stubs for deterministic execution. + +## Migration Plan +1. Introduce source file structure and build scripts aligned with README conventions. +2. Ensure one-shot and watch build commands generate expected dist artifacts and single-file output. +3. Add QUnit harness and baseline test suite. +4. Add Cypress project config and e2e scenario for workspace→new file→edit→save. +5. Update documentation with developer commands and required prerequisites. + +## Open Questions +- Should Cypress execute against a local static server command defined in `package.json`, or an externally started server? Local static server +- What test seam should be canonical for workspace selection in Cypress (UI picker vs injected test fixture API)? UI picker + + + +# Change: Componentized Build Pipeline and Automated Test Coverage + +## Why +The current project state does not provide a stable, repeatable workflow for maintaining separate HTML, TypeScript, and SASS sources, and it lacks automated regression tests for critical editor behavior. This makes changes risky and manual verification time-consuming. + +## What Changes +- Define a componentized source layout that keeps interaction logic in TypeScript, styles in SASS, and UI structure in an HTML template. +- Define a deterministic build pipeline that compiles TypeScript and SASS into distributable assets and injects them into the single-file app output. +- Define watch-mode and one-shot build commands so developers can rebuild the app whenever files change. +- Add QUnit unit/integration coverage for core application behavior. +- Add Cypress end-to-end coverage for the user flow: selecting a workspace, creating a new file, adding markdown content, and saving. +- Define minimum acceptance checks for local validation before release. + +## Impact +- Affected specs: `build-pipeline`, `testing` +- Affected code: + - `package.json` + - `Makefile` + - `src/app.ts` + - `src/styles.scss` + - `ink.template.html` (or current HTML template source) + - `build/inject.js` + - `dist/` build outputs + - `tests/qunit/` (new) + - `cypress/` (new) + + + +## 1. Implementation +- [x] 1.1 Align source structure with README template: `src/app.ts`, `src/styles.scss`, and HTML template input. +- [x] 1.2 Implement build scripts to compile TypeScript and SASS into `dist/app.min.js` and `dist/styles.min.css`. +- [x] 1.3 Implement template injection so compiled CSS/JS are embedded into the final single-file app output. +- [x] 1.4 Add build commands for one-shot build and watch mode to support rebuilding after file changes. +- [x] 1.5 Update developer documentation for build and run workflows. + +## 2. Testing +- [x] 2.1 Add QUnit test harness and configure command(s) to run tests locally. +- [x] 2.2 Add QUnit tests for core interaction logic and markdown editing behavior. +- [x] 2.3 Add Cypress configuration and command(s) to run end-to-end tests locally. +- [x] 2.4 Add Cypress test that covers selecting a workspace, creating a new file, entering markdown content, and saving. +- [x] 2.5 Add test data/setup hooks needed to execute the flow deterministically. + +## 3. Validation +- [x] 3.1 Verify build output artifacts are generated as specified. +- [x] 3.2 Verify QUnit and Cypress suites pass from documented commands. +- [x] 3.3 Confirm final app output remains a single self-contained HTML file. + + + +## ADDED Requirements + +### Requirement: GitHub OAuth Web Flow with PKCE Authentication +The system SHALL provide GitHub authentication using OAuth Web Flow with PKCE (RFC 7636), allowing users to log in with their GitHub account without requiring a server-side redirect. + +#### Scenario: Successful authentication +- **WHEN** user clicks "Login with GitHub" +- **THEN** the browser redirects to GitHub's authorization page with PKCE parameters +- **AND** the user authenticates with GitHub +- **AND** the browser redirects back with an authorization code +- **AND** the system exchanges the code for an access token +- **AND** the access token is stored in localStorage +- **AND** the user's GitHub avatar and username are displayed + +#### Scenario: User denies authorization +- **WHEN** user denies GitHub authorization +- **THEN** an error message is displayed +- **AND** no token is stored + +#### Scenario: Callback with missing code +- **WHEN** user returns from GitHub without authorization code +- **THEN** an error is displayed explaining the issue + +#### Scenario: Callback with expired verifier +- **WHEN** user returns but code_verifier is not in sessionStorage +- **THEN** an error is displayed about expired verification + +#### Scenario: Session persistence +- **WHEN** user reloads the page after successful login +- **THEN** the authentication state is restored +- **AND** the user remains logged in + +### Requirement: PKCE Security +The system SHALL use PKCE (Proof Key for Code Exchange) as specified in RFC 7636 for enhanced security. + +#### Scenario: Code verifier generation +- **WHEN** authentication flow starts +- **THEN** a cryptographically random code_verifier (43-128 URL-safe characters) is generated +- **AND** stored in sessionStorage (not localStorage) + +#### Scenario: Code challenge generation +- **WHEN** authentication flow starts +- **THEN** a code_challenge is derived using S256 method: BASE64URL(SHA256(code_verifier)) +- **AND** included in authorization request + +#### Scenario: Token exchange with verifier +- **WHEN** exchanging authorization code for token +- **THEN** the code_verifier is included in the token request +- **AND** verified against the original code_challenge + +### Requirement: Logout +The system SHALL allow authenticated users to log out, clearing all stored GitHub credentials and returning to the logged-out state. + +#### Scenario: User logs out +- **WHEN** authenticated user clicks logout +- **THEN** the stored access token is cleared from localStorage +- **AND** the code_verifier is cleared from sessionStorage +- **AND** the user avatar/username are hidden +- **AND** the login button is restored + +### Requirement: Token Storage +The system SHALL store the GitHub access token securely in localStorage for session persistence. + +#### Scenario: Token storage format +- **WHEN** authentication succeeds +- **THEN** the access token is stored under key `ink_github_token` +- **AND** the token is retrieved on page load to restore session + + + +## Context +GitHub's Device Flow endpoints don't support CORS for browser-based requests. This makes Device Flow unsuitable for web applications that need to make requests directly from the browser. GitHub's OAuth Web Flow with PKCE (RFC 7636) is the standard approach for browser-based OAuth apps and supports CORS. + +## Goals / Non-Goals + +- Goals: + - Allow users to authenticate using their GitHub account + - Use browser-compatible OAuth (no backend required) + - Store tokens securely in localStorage + - Display authenticated user info + - Use PKCE for enhanced security + +- Non-Goals: + - Server-side token validation + - Refresh token rotation + - Integration with GitHub API for data operations (future scope) + +## Decisions + +- Decision: Use GitHub OAuth Web Flow with PKCE instead of Device Flow + - Rationale: Web Flow supports CORS, works directly in browser + - Trade-off: Requires redirect flow (standard OAuth UX), but more seamless than Device Flow + - Security: PKCE prevents authorization code interception attacks + +- Decision: Store token in localStorage + - Rationale: Simple persistence without server + - Trade-off: Less secure than httpOnly cookies, but standard for client-side apps + +- Decision: Store code_verifier in sessionStorage (not localStorage) + - Rationale: sessionStorage is cleared when tab closes, providing extra security + - Trade-off: User must complete auth in same browser session + +- Decision: Use localhost:8000/callback as redirect_uri for development + - Rationale: Simpler setup for local development + - Trade-off: Production deployment on GitHub Pages will need a dedicated callback page + +## Risks / Trade-offs + +- Risk: localStorage XSS attacks + - Mitigation: Keep minimal data, clear on logout, consider session-only storage + +- Risk: User closes browser before completing auth + - Mitigation: code_verifier expires naturally when sessionStorage is cleared + +- Risk: GitHub rate limits on token exchange + - Mitigation: Keep exponential backoff utility for potential future token refresh + +## Migration Plan + +- Existing Device Flow implementation: Replace with PKCE Web Flow +- Token storage key: Keep `ink_github_token` for backward compatibility +- Auth state: Replace `isPolling`/`pollingMessage` with `isLoading`/`authStep` + +## Open Questions + +- Should we use GitHub's scoped tokens or request specific permissions? +- Do we need to handle token expiration/retry automatically? +- For production on GitHub Pages: What should the redirect_uri be? + + + +# Change: Add GitHub Login + +## Why +Users want to authenticate with their GitHub account to save their preferences and potentially sync data across devices. GitHub provides a secure, widely-used identity provider that doesn't require managing separate credentials. + +## What Changes +- Add GitHub authentication using GitHub Device Flow (browser-compatible OAuth) +- Store GitHub token securely in localStorage +- Add login/logout UI controls +- Display user avatar and username when authenticated +- Persist authentication state across sessions + +## Impact +- Affected specs: New `auth` capability +- Affected code: New `src/auth/github.js` module, UI components, build configuration + + + +## 1. Implementation + +### 1.1 GitHub OAuth App Setup +- [x] 1.1.1 Create GitHub OAuth App in developer settings +- [x] 1.1.2 Document client ID for web flow with PKCE + +### 1.2 Authentication Module +- [x] 1.2.1 Create `src/auth/github.ts` module +- [x] 1.2.2 Implement PKCE flow (RFC 7636) +- [x] 1.2.3 Generate code_verifier (43-128 chars) +- [x] 1.2.4 Generate code_challenge (BASE64URL-SHA256) +- [x] 1.2.5 Implement token exchange (/access_token) +- [x] 1.2.6 Add token storage (localStorage under ink_github_token) +- [x] 1.2.7 Store code_verifier in sessionStorage +- [x] 1.2.8 Implement logout (clear token) + +### 1.2.9 Security Mitigations +- [x] 1.2.9.1 Store minimal data in localStorage (only token, no user data) +- [x] 1.2.9.2 Clear localStorage on explicit logout +- [x] 1.2.9.3 Store code_verifier in sessionStorage (not localStorage) for security +- [x] 1.2.9.4 Show user-friendly error messages on auth failures + +### 1.3 Callback Handling +- [x] 1.3.1 Handle redirect from GitHub with ?code= parameter +- [x] 1.3.2 Exchange code for token using code_verifier +- [x] 1.3.3 Clear code from URL (history.replaceState) +- [x] 1.3.4 Handle error parameter (user denied access) + +### 1.4 User Info Module +- [x] 1.4.1 Fetch user profile from GitHub API +- [x] 1.4.2 Cache user info in memory + +### 1.5 UI Components +- [x] 1.5.1 Add login button to header +- [x] 1.5.2 Add user avatar/username display when logged in +- [x] 1.5.3 Add logout button in user menu + +### 1.6 State Management +- [x] 1.6.1 Add auth state to global app state +- [x] 1.6.2 Persist auth state on page reload +- [x] 1.6.3 Emit events on auth state changes +- [x] 1.6.4 Replace isPolling with isLoading for PKCE flow +- [x] 1.6.5 Replace pollingMessage with authStep for status updates + +### 1.7 Build Integration +- [x] 1.7.1 Add auth module to build script +- [x] 1.7.2 Update index.html template with auth UI + +### 1.8 Error Types Update +- [x] 1.8.1 Remove RateLimited (device flow specific) +- [x] 1.8.2 Add ServerError (for web flow errors) +- [x] 1.8.3 Add InvalidState (for callback errors) + +## 2. Testing +- [ ] 2.1 Manual test PKCE flow in browser +- [ ] 2.2 Verify token persistence across page reloads +- [ ] 2.3 Verify logout clears all stored data +- [ ] 2.4 Verify XSS mitigation - only token stored, no sensitive user data +- [ ] 2.5 Verify logout clears sessionStorage code_verifier +- [ ] 2.6 Verify error UI displays on user denial +- [ ] 2.7 Test callback URL with code parameter +- [ ] 2.8 Verify code is cleared from URL after exchange +- [x] 2.9 Run existing test suite to ensure no regressions + +## 3. Production Setup +- [ ] 3.1 Configure redirect_uri for GitHub Pages production +- [ ] 3.2 Create callback page for production deployment + + + +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## 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 + +### 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 + +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 + +**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 + +### 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 + +## 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 + +### 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` + +## Quick Start + +### CLI Commands + +```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) + +# 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 + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict +``` + +### Command Flags + +- `--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 + +``` +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 + +### Decision Tree + +``` +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 + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] + +## Why +[1-2 sentences on problem/opportunity] + +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +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 + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] + +## 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 +``` + +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 + +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] + +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] + +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs +- [Risk] → Mitigation + +## Migration Plan +[Steps, rollback] + +## Open Questions +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### 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. + +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. + +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:`. + +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```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 + +# 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 + +# 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. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` + +## 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 + +### 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 + +### 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" + +### 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 + +| 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 + +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## 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 + +### 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) +``` + +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 + */ + +import type { DomRefs, GitHubUser } from "./types"; +import type { GitHubAuthManager } from "../auth/github"; +import type { GitHubUserManager } from "../auth/user"; + +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 = ""; + } + } + + 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 = ""; + } + + function handleAuthStep(message: string | null): void { + if (message) { + els.authStatus.textContent = message; + els.authError.style.display = "none"; + } else { + els.authStatus.textContent = ""; + } + } + + async function startLogin(): Promise { + try { + els.authError.style.display = "none"; + + // Start the OAuth flow - this will redirect to GitHub + await authManager.startAuthFlow(); + + // 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))); + } + } + } + + 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(); + + if (success) { + const token = authManager.getToken(); + if (token) { + const user = await userManager.fetchUser(token); + updateUserUI(user); + showToast("Successfully logged in with GitHub!", { persist: false }); + } + } + + return success; + } catch (error) { + if (error instanceof Error) { + handleAuthError(error); + } else { + handleAuthError(new Error(String(error))); + } + return false; + } + } + + function handleModalClose(): void { + els.authModal.style.display = "none"; + } + + 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"); + + if (isCallback) { + // Handle the OAuth callback + handleCallback().then(() => { + // Clean up URL after handling + }).catch(() => { + // Error already handled in handleCallback + }); + } + + authManager.subscribe({ + onStateChange: (state) => { + updateAuthUI(state.isAuthenticated); + }, + onError: handleAuthError, + onAuthStep: handleAuthStep, + }); + + els.loginBtn.addEventListener("click", () => { + startLogin(); + }); + + els.logoutBtn.addEventListener("click", () => { + handleLogout(); + }); + + els.authModalCloseBtn.addEventListener("click", () => { + handleModalClose(); + }); + + els.authModal.addEventListener("click", (event) => { + if (event.target === els.authModal) { + handleModalClose(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && els.authModal.style.display !== "none") { + handleModalClose(); + } + }); + + if (authManager.restoreSession()) { + const token = authManager.getToken(); + if (token) { + userManager.fetchUser(token).then((user) => { + updateUserUI(user); + }).catch(() => { + authManager.logout(); + userManager.clearCache(); + }); + } + } + } + + return { + initialize, + getCurrentUser: () => currentUser, + isAuthenticated: () => authManager.isAuthenticated(), + getToken: () => authManager.getToken(), + }; +} + + + +/** + * GitHub OAuth Authentication Module + * Implements RFC 7636 PKCE OAuth Web Flow for browser-based apps + * @module auth/github + */ + +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"; + +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"; + } +} + +interface TokenResponse { + access_token?: string; + token_type?: string; + scope?: string; + error?: string; + error_description?: string; +} + +/** + * 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); +} + +/** + * 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, ""); +} + +/** + * 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); +} + +/** + * 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, + }; + + let state: AuthState = { + isAuthenticated: false, + isLoading: false, + error: null, + authStep: null, + }; + + let currentToken: string | null = null; + + function emitStateChange(): void { + if (listeners.onStateChange) { + listeners.onStateChange({ ...state }); + } + } + + function emitError(error: AuthError): void { + if (listeners.onError) { + listeners.onError(error); + } + } + + function emitAuthStep(message: string | null): void { + state.authStep = message; + if (listeners.onAuthStep) { + listeners.onAuthStep(message); + } + emitStateChange(); + } + + function setState(updates: Partial): void { + state = { ...state, ...updates }; + emitStateChange(); + } + + /** + * Retrieves token from localStorage + */ + function getStoredToken(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + } + + /** + * Stores token in localStorage + */ + function storeToken(token: string): void { + try { + localStorage.setItem(STORAGE_KEY, token); + currentToken = token; + } catch { + throw new AuthError( + AuthErrorType.Unknown, + "Failed to store token securely.", + ); + } + } + + /** + * Clears stored token from localStorage + */ + function clearStoredToken(): void { + try { + localStorage.removeItem(STORAGE_KEY); + currentToken = null; + } catch { + // Ignore storage errors on logout + } + } + + /** + * 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.", + ); + } + } + + /** + * 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; + } + + /** + * 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(""); + } + + /** + * 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); + + // Build authorization URL + const authUrl = await buildAuthorizationUrl(codeChallenge); + + emitAuthStep("Redirecting to GitHub..."); + + // 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; + } + } + + /** + * 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"); + + 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; + } + + const authError = new AuthError( + AuthErrorType.InvalidRequest, + errorDescription || `Authentication error: ${error}`, + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + + // Get authorization code + const code = url.searchParams.get("code"); + if (!code) { + const authError = new AuthError( + AuthErrorType.InvalidState, + "Missing authorization code in callback.", + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + + // Get code_verifier from sessionStorage + const codeVerifier = getAndClearCodeVerifier(); + if (!codeVerifier) { + const authError = new AuthError( + AuthErrorType.InvalidState, + "Verification code expired. Please try again.", + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + + setState({ isLoading: true }); + emitAuthStep("Exchanging code for token..."); + + try { + const token = await exchangeCodeForToken(code, codeVerifier); + + 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; + } + } + + /** + * 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); + } + + /** + * 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); + + 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.", + ); + } + + 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; + } + + /** + * 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; + } + + /** + * 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, + }); + } + + /** + * 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 }; + } + + /** + * 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; + } + + 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; + + + +/** + * 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"; + +const GITHUB_API_USER_URL = "https://api.github.com/user"; + +interface GitHubApiUserResponse { + login: string; + id: number; + avatar_url: string; + name: string | null; +} + +/** + * 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; + } + + 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(); + + cachedUser = { + login: data.login, + id: data.id, + avatarUrl: data.avatar_url, + name: data.name, + }; + + return cachedUser; + } + + /** + * Clears cached user data + * Called on logout + */ + function clearCache(): void { + cachedUser = null; + } + + /** + * Gets cached user without fetching + */ + function getCachedUser(): GitHubUser | null { + return cachedUser; + } + + return { + fetchUser, + clearCache, + getCachedUser, + }; +} + +export type GitHubUserManager = ReturnType; + + + +export function normalizeTag(value: string): string { + return (value || "") + .trim() + .replace(/^#+/, "") + .replace(/[^\w\-/]+/g, "") + .toLowerCase(); +} + +export function extractFrontMatter(text: string): string { + if (!text.startsWith("---")) { + return ""; + } + + const end = text.indexOf("\n---", 3); + if (end === -1) { + return ""; + } + + return text.slice(3, end).trim(); +} + +export function parseFrontmatterTags(frontMatter: string): Set { + const tags = new Set(); + const lines = frontMatter.split("\n"); + + for (const line of lines) { + const inlineListMatch = line.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i); + if (!inlineListMatch) { + continue; + } + + const parts = inlineListMatch[1] + .split(",") + .map((part) => normalizeTag(part.replace(/["']/g, ""))); + + for (const part of parts) { + if (part) { + tags.add(part); + } + } + } + + let inTagsBlock = false; + for (const line of lines) { + if (/^\s*tags\s*:\s*$/i.test(line)) { + inTagsBlock = true; + continue; + } + + if (!inTagsBlock) { + continue; + } + + const item = line.match(/^\s*-\s*(.+)\s*$/); + if (item) { + const tag = normalizeTag(item[1].replace(/["']/g, "")); + if (tag) { + tags.add(tag); + } + continue; + } + + if (line.trim() !== "" && !/^\s+/.test(line)) { + inTagsBlock = false; + } + } + + return tags; +} + +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); + } + } + } + + return tags; +} + + + +import QUnit from "qunit"; +import { + createFile, + ensureWorkspace, + listFiles, + listWorkspaces, + readFile, + saveFile, +} from "../../dist/test/storage.js"; + +class MemoryStorage { + constructor() { + this.values = new Map(); + } + + 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; + } + + setItem(key, value) { + this.values.set(key, value); + } + + removeItem(key) { + this.values.delete(key); + } +} + +QUnit.module("storage"); + +QUnit.test("ensureWorkspace trims name and rejects empty names", (assert) => { + const storage = new MemoryStorage(); + + assert.throws( + () => ensureWorkspace(storage, " "), + /Workspace name cannot be empty\./, + ); + + const name = ensureWorkspace(storage, " project-a "); + assert.strictEqual(name, "project-a"); + assert.deepEqual(listWorkspaces(storage), ["project-a"]); +}); + +QUnit.test("createFile rejects empty names and creates file once", (assert) => { + const storage = new MemoryStorage(); + + ensureWorkspace(storage, "notes"); + + assert.throws( + () => createFile(storage, "notes", " \n "), + /File name cannot be empty\./, + ); + + createFile(storage, "notes", "daily.md"); + createFile(storage, "notes", "daily.md"); + + assert.deepEqual(listFiles(storage, "notes"), ["daily.md"]); + assert.strictEqual(readFile(storage, "notes", "daily.md"), ""); +}); + +QUnit.test("listWorkspaces and listFiles are sorted", (assert) => { + const storage = new MemoryStorage(); + + ensureWorkspace(storage, "zeta"); + ensureWorkspace(storage, "alpha"); + ensureWorkspace(storage, "beta"); + + createFile(storage, "alpha", "z.md"); + createFile(storage, "alpha", "a.md"); + createFile(storage, "alpha", "m.md"); + + assert.deepEqual(listWorkspaces(storage), ["alpha", "beta", "zeta"]); + assert.deepEqual(listFiles(storage, "alpha"), ["a.md", "m.md", "z.md"]); +}); + +QUnit.test("readFile returns empty string when file does not exist", (assert) => { + const storage = new MemoryStorage(); + ensureWorkspace(storage, "empty"); + + assert.strictEqual(readFile(storage, "empty", "missing.md"), ""); + assert.strictEqual(readFile(storage, "missing-workspace", "missing.md"), ""); +}); + +QUnit.test("saveFile creates missing workspace and persists content", (assert) => { + const storage = new MemoryStorage(); + + saveFile(storage, "new-workspace", "created.md", "# Hello"); + + assert.deepEqual(listWorkspaces(storage), ["new-workspace"]); + assert.deepEqual(listFiles(storage, "new-workspace"), ["created.md"]); + assert.strictEqual(readFile(storage, "new-workspace", "created.md"), "# Hello"); +}); + +QUnit.test("integration flow: workspace -> create file -> save -> read", (assert) => { + const storage = new MemoryStorage(); + + ensureWorkspace(storage, "workspace-a"); + createFile(storage, "workspace-a", "notes.md"); + saveFile(storage, "workspace-a", "notes.md", "# Ink\n\nSaved content"); + + assert.strictEqual( + readFile(storage, "workspace-a", "notes.md"), + "# Ink\n\nSaved content", + ); +}); + +QUnit.test("integration flow: data is isolated between workspaces", (assert) => { + const storage = new MemoryStorage(); + + createFile(storage, "workspace-a", "shared.md"); + createFile(storage, "workspace-b", "shared.md"); + + saveFile(storage, "workspace-a", "shared.md", "A"); + saveFile(storage, "workspace-b", "shared.md", "B"); + + assert.strictEqual(readFile(storage, "workspace-a", "shared.md"), "A"); + assert.strictEqual(readFile(storage, "workspace-b", "shared.md"), "B"); +}); + + + +import QUnit from "qunit"; +import { createUserManager } from "../../dist/test/user.js"; + +QUnit.module("auth/user", (hooks) => { + let mockFetch; + let originalFetch; + + 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; + }); + + hooks.afterEach(function () { + globalThis.fetch = originalFetch; + }); + + QUnit.test("fetchUser returns user data", async function (assert) { + const userManager = createUserManager(); + const user = await userManager.fetchUser("test_token"); + + 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"); + }); + + QUnit.test("fetchUser caches result", async function (assert) { + const userManager = createUserManager(); + let fetchCalls = 0; + + 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", + }; + }, + }; + }; + + await userManager.fetchUser("test_token"); + await userManager.fetchUser("test_token"); + + assert.strictEqual(fetchCalls, 1, "Should only make one fetch call due to caching"); + }); + + QUnit.test("clearCache removes cached user", async function (assert) { + const userManager = createUserManager(); + await userManager.fetchUser("test_token"); + + assert.ok(userManager.getCachedUser() !== null); + + userManager.clearCache(); + + assert.strictEqual(userManager.getCachedUser(), null); + }); + + 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(); + + 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")); + } + }); + + 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"); + + assert.strictEqual(capturedHeaders?.get("Authorization"), "Bearer test_token_xyz"); + assert.strictEqual(capturedHeaders?.get("Accept"), "application/vnd.github+json"); + }); +}); + + + +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} + + + +MIT License + +Copyright (c) 2026 Federico Viscioletti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + + + + + + + Declarative WebMCP Demo + + + +

    Declarative WebMCP Demo

    +

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

    + +
    + + + + + + + +
    + + +
    + + +#!/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 + + + + + + + + + + + + + ink + + + + + + + + + + + + + + + ink + + + + + +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, + }; +} + +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", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); + + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; + + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + win.__cogitoCreateEngineCalls = []; + win.__cogitoCompletions = []; + win.__INK_TEST_WEBLLM__ = { + async CreateMLCEngine(modelId, options = {}) { + win.__cogitoCreateEngineCalls.push(modelId); + options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); + + 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?", + ], + }), + }, + }, + ], + }; + }, + }, + }, + }; + }, + }; + }, + }); + }); + + it("opens the panel, switches model, generates questions, and inserts one into the editor", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); + + 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("#cogitoDeepBtn").click().should("have.class", "active"); + cy.get("#cogitoLiteBtn").should("not.have.class", "active"); + + cy.get("#cogitoGenerateBtn").click(); + + 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); + }); + + 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`); + }); +}); + + + +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"); + }); + }); +}); + + + +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"); + }); +}); + + + +export {}; + + + +## ADDED Requirements + +### Requirement: Cogito Mode Menu Bar Button +The system SHALL provide a Cogito Mode button in the menu bar's top-right action area. + +#### Scenario: User sees Cogito entrypoint in the menu bar +- **WHEN** the editor UI is visible +- **THEN** a Cogito Mode button SHALL appear in the top-right menu bar actions +- **AND** the button SHALL use a thinking-man icon from the project's glyphicon library +- **AND** the button SHALL expose an accessible text label or tooltip identifying it as Cogito Mode + +#### Scenario: User toggles Cogito Mode from the button +- **WHEN** the user clicks the Cogito Mode top-right button +- **THEN** the system SHALL open or close the Cogito Mode side panel +- **AND** the toggle action SHALL not interrupt normal editing flow + +### Requirement: Cogito Mode Side Panel +The system SHALL provide a Cogito Mode side panel where users can generate and view writing-coach questions while editing markdown. + +#### Scenario: 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 + +### Requirement: Fixed Coaching Prompt Contract +The system SHALL generate questions using a fixed writing-coach instruction that outputs JSON only with exactly three questions. + +#### 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 + +### Requirement: Last-Sentence Grounding +The system SHALL ground generated questions in the user's most recent sentence from the active markdown content. + +#### 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 + +### Requirement: Exactly Three Rendered Questions +The system SHALL display exactly three generated questions in the Cogito Mode side panel. + +#### Scenario: Valid model output is returned +- **WHEN** the model returns a valid JSON payload containing three questions +- **THEN** the panel SHALL render exactly three question entries +- **AND** each entry SHALL expose an insert action for the user + +#### Scenario: 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 + +### 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 + + + +## 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. + +## 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. + +## 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. + +## 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 + +## 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) + + + +## 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. + +## 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: User-Controlled Left Menu Visibility +The application SHALL provide a user-facing control to collapse and expand the left menu during an editing session. + +#### Scenario: User collapses left menu +- **WHEN** the user activates the menu toggle while the menu is expanded +- **THEN** the left menu transitions to a collapsed state +- **AND** the main editor area gains additional horizontal space + +#### Scenario: User expands left menu +- **WHEN** the user activates the menu toggle while the menu is collapsed +- **THEN** the left menu returns to an expanded state +- **AND** primary menu content becomes visible again + +### Requirement: Left Menu Toggle Accessibility +The left menu toggle SHALL be operable via keyboard and SHALL expose its current expanded/collapsed state to assistive technologies. + +#### Scenario: Keyboard operation +- **WHEN** a keyboard-only user focuses the menu toggle and activates it +- **THEN** the menu state changes between expanded and collapsed +- **AND** focus remains in a predictable location for continued interaction + +#### Scenario: Assistive state exposure +- **WHEN** the menu state changes through the toggle control +- **THEN** the control's accessibility state reflects whether the menu is expanded or collapsed + +### Requirement: Deterministic Initial Menu State +The application SHALL define a deterministic initial left menu state when a new session loads. + +#### Scenario: Initial layout state +- **WHEN** a user opens the application in a new session +- **THEN** the left menu starts in the documented default state +- **AND** the editor layout reflects that state without requiring user interaction + + + +# Change: Add User-Controlled Collapsible Left Menu + +## Why +The current left menu is fixed, which reduces usable editor space on smaller screens and during focused writing. Allowing users to collapse and expand the menu improves layout flexibility without removing existing navigation. + +## What Changes +- Add support for collapsing and expanding the left menu from within the UI. +- Add a persistent toggle control so users can switch menu state on demand. +- Update layout behavior so editor content reflows to use freed horizontal space when the menu is collapsed. +- Preserve keyboard and pointer accessibility for the menu toggle interaction. +- Define expected default menu behavior at app start. + +## Impact +- Affected specs: `editor-layout` +- Affected code: + - `ink.template.html` + - `src/app.ts` + - `src/styles.scss` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + + + +## 1. UI Controls and State +- [x] 1.1 Add a left menu toggle control that supports collapse and expand actions. +- [x] 1.2 Implement menu state management in the app so toggle actions update layout consistently. +- [x] 1.3 Define and implement the default menu state on initial load. + +## 2. Layout and Styling +- [x] 2.1 Update layout styles so collapsing the menu increases main editor horizontal space. +- [x] 2.2 Ensure expanded state preserves existing navigation usability and visual hierarchy. +- [x] 2.3 Ensure collapsed state keeps the toggle discoverable and usable. + +## 3. Accessibility and Interaction +- [x] 3.1 Ensure the toggle is keyboard-focusable and operable. +- [x] 3.2 Ensure toggle semantics expose menu state changes to assistive technologies. + +## 4. Verification +- [x] 4.1 Verify users can collapse and re-expand the menu in a modern browser. +- [x] 4.2 Verify editor content area width increases when the menu is collapsed. +- [x] 4.3 Verify keyboard-only users can operate the toggle and retain navigation control. +- [x] 4.4 Add a Cypress end-to-end test that validates left menu collapse/expand behavior and resulting layout changes. + + + +## ADDED Requirements + +### Requirement: CSS Custom Property Theme System +The application SHALL implement color themes using CSS custom properties, with each theme defined as a set of variable overrides on `:root[data-theme=""]`. + +#### Scenario: Default theme at initial load +- **WHEN** the application loads with no saved theme preference +- **THEN** no `data-theme` attribute is present on `` +- **AND** the default dark teal palette is applied via `:root` base variables + +#### Scenario: Named theme activation +- **WHEN** a named theme (e.g. `monokai`) is applied +- **THEN** `data-theme="monokai"` is set on `` +- **AND** the corresponding CSS variable overrides take effect immediately across all elements +- **AND** no page reload is required + +#### Scenario: Returning to default +- **WHEN** the user selects Default (Dark) after a named theme is active +- **THEN** the `data-theme` attribute is removed from `` +- **AND** the base `:root` variable definitions are restored + +### Requirement: Theme Persistence +The application SHALL persist the user's theme choice across sessions using `localStorage`. + +#### Scenario: Theme saved on selection +- **WHEN** the user selects a theme from the View menu +- **THEN** the theme identifier is written to `localStorage` under the key `ink-theme` + +#### Scenario: Theme restored on load +- **WHEN** the application initialises and a valid theme identifier exists in `localStorage` +- **THEN** that theme is applied before the first render +- **AND** the user sees their previously chosen colours immediately, without a flash of the default theme + +#### Scenario: Invalid or missing stored value +- **WHEN** the application initialises and `localStorage` contains no `ink-theme` key or an unrecognised value +- **THEN** the default dark theme is applied +- **AND** no error is shown to the user + +### Requirement: Available Colour Styles +The application SHALL provide the following seven colour styles, modeled on RStudio's built-in themes. + +| Theme | Character | +|-------|-----------| +| Default (Dark) | Dark teal, original ink palette | +| Classic | Light neutral white/grey, blue accent | +| Cobalt | Dark ocean blue, gold accent | +| Monokai | Dark charcoal, green/pink highlights | +| Office | Clean professional light, Microsoft blue | +| Twilight | Warm dark grey, amber accent | +| Xcode | Crisp Apple-style light, Xcode blue | + +#### Scenario: Each theme covers all required variables +- **WHEN** any named theme is active +- **THEN** all CSS custom properties (`--bg`, `--panel`, `--panel2`, `--border`, `--muted`, `--text`, `--accent`, `--danger`, `--ok`, `--warn`, `--shadow`, `--body-bg`) are defined +- **AND** no variable falls back to an unintended value from another theme + + + +## ADDED Requirements + +### Requirement: View Top-Level Menu +The application menu bar SHALL include a View top-level menu positioned between the Edit menu and the Import/Export menu. + +#### Scenario: View menu is visible in the menu bar +- **WHEN** the application loads +- **THEN** a "View" menu item is present in the menu bar +- **AND** it appears between "Edit" and "Import/Export" + +#### Scenario: View menu opens a dropdown +- **WHEN** the user clicks the View menu item +- **THEN** a dropdown appears listing all available colour styles +- **AND** the dropdown follows the same interaction model as File and Edit menus + +### Requirement: Colour Style Selection +The View menu dropdown SHALL list all available colour styles and allow the user to switch between them with a single click. + +#### Scenario: All themes listed +- **WHEN** the View dropdown is open +- **THEN** the following items are present in order: Default (Dark), [separator], Classic, Cobalt, Monokai, Office, Twilight, Xcode + +#### Scenario: Selecting a theme +- **WHEN** the user clicks a theme name in the View dropdown +- **THEN** the corresponding theme is applied immediately +- **AND** the dropdown closes +- **AND** the application retains full functionality under the new colour scheme + +### Requirement: Active Theme Indicator +The View menu SHALL display a checkmark (✓) next to the currently active theme. + +#### Scenario: Checkmark on active theme +- **WHEN** the View dropdown is open +- **THEN** a ✓ indicator is visible next to the currently active theme name +- **AND** all other theme entries show no checkmark + +#### Scenario: Checkmark updates on selection +- **WHEN** the user selects a different theme +- **THEN** the checkmark moves to the newly selected theme +- **AND** the previous theme entry no longer shows a checkmark + +### Requirement: Backward Compatibility +Adding the View menu SHALL not affect any existing menu, button, or keyboard shortcut behaviour. + +#### Scenario: Existing menus unaffected +- **WHEN** the View menu is present +- **THEN** File, Edit, and Import/Export menus continue to operate identically to their prior behaviour + +#### Scenario: Existing buttons unaffected +- **WHEN** a colour theme is active +- **THEN** all sidebar buttons, Save, JSON, and MD buttons continue to function correctly +- **AND** their visual style adapts to the active theme via CSS custom properties + + + +# Change: Add Color Style Themes + +## Why + +Ink currently uses a single fixed dark color scheme. Users have different preferences and work in different lighting environments, so a fixed style limits comfort and accessibility. Providing a set of well-known color themes — modeled on those available in RStudio — gives users immediate visual choices without requiring any configuration beyond a single menu click. + +## What Changes + +- Add a **View** top-level menu to the existing office-style menu bar, placed between Edit and Import/Export. +- The View menu lists seven color styles: Default (Dark), Classic, Cobalt, Monokai, Office, Twilight, and Xcode. +- Selecting a style applies it immediately by setting a `data-theme` attribute on the `` element. +- Each theme is defined as a set of CSS custom property overrides in `src/styles.scss` using `:root[data-theme=""]` selectors. +- The active theme is indicated by a checkmark (✓) next to the selected item in the menu. +- The selected theme is persisted to `localStorage` under the key `ink-theme` and restored on next load. +- The body background gradient is theme-aware via a `--body-bg` CSS custom property. + +## Impact + +- Affected specs: `theming`, `view-menu` +- Affected code: + - `ink.template.html` — View menu added + - `src/styles.scss` — theme CSS custom properties added, `--body-bg` variable introduced + - `src/app/bootstrap.ts` — `applyTheme()` and `loadTheme()` methods added; new `theme-*` action cases in `handleMenuAction()` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + - `ink-app.html` (rebuilt) + + + +## 1. CSS Theme System + +- [x] 1.1 Introduce `--body-bg` CSS custom property to allow per-theme control of the body background gradient. +- [x] 1.2 Define `Default (Dark)` base theme variables in `:root` (existing dark teal palette, refactored to use `--body-bg`). +- [x] 1.3 Define `Classic` theme — light neutral white/grey, blue accent (`#2c6fad`). +- [x] 1.4 Define `Cobalt` theme — dark ocean blue (`#001f3d`) with gold accent (`#ffd700`) and a blue radial gradient. +- [x] 1.5 Define `Monokai` theme — dark (`#272822`) with green accent (`#a6e22e`) and pink/green radial gradients. +- [x] 1.6 Define `Office` theme — clean professional light, Microsoft blue accent (`#0078d4`). +- [x] 1.7 Define `Twilight` theme — warm dark grey (`#141414`), amber accent (`#d4a96a`). +- [x] 1.8 Define `Xcode` theme — crisp light (`#f9f9f9`), Apple blue accent (`#0070c1`). +- [x] 1.9 Add `.menu-theme-check` CSS class for the active-theme checkmark indicator in the dropdown. + +## 2. View Menu (HTML Template) + +- [x] 2.1 Add a `View` menu item to `ink.template.html` between Edit and Import/Export. +- [x] 2.2 Add dropdown list items for all seven themes, each with `data-action="theme-"`. +- [x] 2.3 Add `` to each theme item for active-state indication. +- [x] 2.4 Include a separator between Default (Dark) and the six named themes. + +## 3. Theme Logic (TypeScript) + +- [x] 3.1 Add `VALID_THEMES` constant array listing all accepted theme identifiers. +- [x] 3.2 Implement `applyTheme(theme: string)` method that sets/removes the `data-theme` attribute on `document.documentElement`, saves to `localStorage`, and updates the checkmark indicator. +- [x] 3.3 Implement `loadTheme()` method that reads `localStorage` on startup and calls `applyTheme()` with the saved value (defaulting to `"default"` if absent or invalid). +- [x] 3.4 Call `loadTheme()` from `initialize()` before other UI setup. +- [x] 3.5 Add `theme-default`, `theme-classic`, `theme-cobalt`, `theme-monokai`, `theme-office`, `theme-twilight`, and `theme-xcode` cases to `handleMenuAction()`. + +## 4. Build and Verification + +- [x] 4.1 Run `npm run build` to recompile TypeScript, SCSS, and assemble `ink-app.html`. +- [x] 4.2 Verify the View menu appears in the menu bar between Edit and Import/Export. +- [x] 4.3 Verify each theme applies immediately on selection with correct colours. +- [x] 4.4 Verify the checkmark moves to the newly selected theme. +- [x] 4.5 Verify the selected theme persists across page reloads via `localStorage`. + +## 5. Documentation + +- [x] 5.1 Update `replit.md` with a Color Themes section describing each theme and the persistence mechanism. +- [x] 5.2 Create this openspec change request. + + + +## 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 + +#### 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 + +#### 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 + +#### 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 + + + +## 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 + +#### 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 + +#### 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 + +#### 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 + + + +# Change: Add Document Linter + +## 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. + +## 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 + +## 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) + + + +## 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. + +#### Scenario: ESLint runs successfully +- **WHEN** `npm run lint` is executed +- **THEN** ESLint analyzes all JavaScript files and reports any violations + +#### Scenario: Build includes lint check +- **WHEN** the build process runs +- **THEN** lint check is performed and build fails if errors exist + + + +# Change: Add ESLint to the project + +## 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. + +## What Changes +- Add ESLint as a dev dependency +- Configure ESLint with appropriate rules for vanilla ES6+ JavaScript +- Integrate lint check into the build process +- Add npm script for running lint + +## Impact +- Affected specs: code-quality (new capability) +- Affected code: All JavaScript files in `src/`, `build/`, `tests/` +- Build system: Add lint step to build/compile-and-assemble.js + + + +## 1. Implementation +- [x] 1.1 Install ESLint and initialize config +- [x] 1.2 Configure ESLint for ES6+ vanilla JavaScript +- [x] 1.3 Add npm lint script to package.json +- [x] 1.4 Run ESLint and fix any errors +- [x] 1.5 Integrate lint check into build process + + + +## ADDED Requirements +### Requirement: Canonical Project Logo Asset +The repository SHALL store a canonical project logo source file that is used for documentation and favicon generation. + +#### Scenario: Canonical logo source is available +- **WHEN** a maintainer inspects branding files in the repository +- **THEN** a single canonical logo source exists in a stable project path +- **AND** the source is suitable for deriving README and favicon assets + +### Requirement: README Logo Rendering +The project documentation SHALL render the project logo in `README.md` using repository-relative asset references. + +#### Scenario: README displays logo +- **WHEN** `README.md` is rendered by a GitHub-compatible markdown renderer +- **THEN** the logo is visible near the document header +- **AND** the logo reference resolves without external network dependencies + +### Requirement: Favicon Assets Derived from Logo +The project SHALL provide favicon assets derived from the canonical logo source. + +#### Scenario: Favicon assets are generated and available +- **WHEN** the favicon generation workflow is run +- **THEN** favicon files are produced in the documented output location +- **AND** generated filenames match those referenced by the application template + +### Requirement: Application Template Favicon References +The application HTML template SHALL reference project favicon assets so browser tabs display the project icon. + +#### Scenario: Browser tab uses project favicon +- **WHEN** a user opens the built application in a modern browser +- **THEN** the browser resolves favicon references from the application output +- **AND** the tab icon reflects the project logo branding + + + +# Change: Add Project Logo to README and Favicon Assets + +## Why +The repository currently lacks consistent branding assets in project documentation and browser metadata. Adding the provided logo to `README.md` and defining favicon outputs improves recognizability and presentation. + +## What Changes +- Add a canonical logo asset in the repository based on `~/Downloads/ink_logo.svg`. +- Update `README.md` to render the project logo near the top of the document. +- Define and implement favicon outputs derived from the same logo source. +- Wire favicon references into the app HTML template so browser tabs use project branding. +- Document the asset location and favicon generation/update workflow. + +## Impact +- Affected specs: `branding-assets` +- Affected code: + - `README.md` + - `ink.template.html` + - `build/` scripts (if favicon generation is automated in build) + - `dist/` favicon artifacts + - `assets/` branding source files (new) + + + +## 1. Asset Setup +- [x] 1.1 Add the provided logo SVG to a canonical repository path for shared branding usage. +- [x] 1.2 Define favicon output formats and filenames derived from the canonical logo source. + +## 2. Documentation and Template Integration +- [x] 2.1 Update `README.md` to display the project logo with stable relative linking. +- [x] 2.2 Update the HTML template to include favicon references that resolve in the built output. + +## 3. Build and Distribution +- [x] 3.1 Implement or document the process to generate favicon artifacts from the SVG source. +- [x] 3.2 Ensure generated favicon assets are available in expected output locations. + +## 4. Validation +- [x] 4.1 Verify `README.md` renders the logo correctly on GitHub-compatible markdown viewers. +- [x] 4.2 Verify the built app/tab displays the new favicon in a modern browser. +- [x] 4.3 Verify documentation describes how to refresh logo-derived assets when the SVG changes. + + + +## ADDED Requirements + +### Requirement: Mobile Browser Detection +The system SHALL detect when the browser does not support the File System Access API. + +#### Scenario: Browser lacks File System Access API +- **WHEN** the user opens ink on a browser without `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL detect this and enable in-memory workspace mode +- **AND** the system SHALL NOT throw an error blocking app usage + +#### Scenario: Browser supports File System Access API +- **WHEN** the user opens ink on a browser with `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL allow opening a real folder as usual +- **AND** no in-memory mode SHALL be activated + +### Requirement: In-Memory Workspace Mode +The system SHALL provide a temporary in-memory workspace when FS API is unavailable. + +#### Scenario: User creates note in temporary session +- **WHEN** the user clicks "New Note" in temporary session mode +- **AND** enters a note name +- **THEN** a new note SHALL be created in memory +- **AND** the user CAN edit the note content +- **AND** the user CAN save (persist to memory only) + +#### Scenario: User edits note in temporary session +- **WHEN** the user modifies the editor content +- **AND** the note has unsaved changes +- **THEN** the dirty indicator SHALL show unsaved status +- **AND** the user CAN click Save to persist changes to memory + +### Requirement: Export as JSON +The system SHALL provide a way to download all notes as a JSON file. + +#### Scenario: User exports all notes as JSON +- **WHEN** the user clicks "Export JSON" button +- **THEN** the browser SHALL download a file named `ink-export-YYYY-MM-DD.json` +- **AND** the file SHALL contain a JSON object with notes array +- **AND** each note SHALL have `name`, `path`, and `content` fields + +#### Scenario: JSON export contains multiple notes +- **WHEN** the user has created multiple notes in temporary session +- **AND** clicks Export JSON +- **THEN** the exported JSON SHALL include all notes +- **AND** the order SHALL be preserved + +### Requirement: Export as Markdown +The system SHALL provide a way to download individual notes as .md files. + +#### Scenario: User exports current note as Markdown +- **WHEN** the user clicks "Export Markdown" button while a note is open +- **THEN** the browser SHALL download a file with the note's filename +- **AND** the file SHALL contain the note's raw markdown content +- **AND** the file SHALL have `.md` extension + +### Requirement: Temporary Session UI Indicator +The system SHALL display a clear indicator when operating in temporary session mode. + +#### Scenario: Temporary session is active +- **WHEN** the app is running in temporary session mode +- **THEN** a visual indicator SHALL be displayed showing "Temporary Session" +- **AND** the indicator SHALL inform users their data is not persisted +- **AND** export buttons SHALL be prominently visible + +#### Scenario: No indicator shown in normal mode +- **WHEN** the app is running with real file system access +- **THEN** no temporary session indicator SHALL be displayed +- **AND** export buttons MAY be hidden or disabled + + + +# Change: Add Mobile Fallback Support for Browsers Without File System Access API + +## Why +Mobile browsers (e.g., Safari on iOS) do not support the File System Access API. Currently, ink shows an error message and prevents users from using the app. This excludes mobile users entirely. Instead, we should allow temporary in-memory work and provide export capabilities so users can download their notes. + +## What Changes +- Detect when File System Access API is unavailable +- Enable in-memory workspace mode for temporary editing +- Add "Export as JSON" button to download all notes as a JSON file +- Add "Export as Markdown" button to download individual notes as .md files +- Show informative UI about temporary nature of the session +- Preserve existing functionality for desktop browsers with FS API support + +## Impact +- Affected specs: mobile-support (new capability) +- Affected code: src/app/bootstrap.ts, src/app/fs-api.ts, ink-app.html, styles +- No breaking changes to existing desktop functionality + + + +## ADDED Requirements + +### Requirement: ARIA Support for Menu Bar +The menu bar SHALL implement proper ARIA attributes to ensure accessibility for screen readers and assistive technologies. + +#### Scenario: Menu bar has proper ARIA role +- **WHEN** a screen reader encounters the menu bar +- **THEN** the menu bar has role="menubar" attribute +- **AND** screen readers announce it as a menu bar + +#### Scenario: Menu items have proper ARIA attributes +- **WHEN** a screen reader encounters menu items +- **THEN** each menu item has role="menuitem" attribute +- **AND** menu items with dropdowns have aria-haspopup="true" +- **AND** aria-expanded indicates dropdown state + +#### Scenario: Dropdown menus have proper ARIA attributes +- **WHEN** a dropdown menu is opened +- **THEN** the dropdown has role="menu" attribute +- **AND** each dropdown item has role="menuitem" attribute +- **AND** aria-expanded="true" on the parent menu item + +### Requirement: Keyboard Accessibility +The menu bar SHALL support full keyboard navigation for users who cannot use a mouse. + +#### Scenario: Tab navigation support +- **WHEN** a user navigates using Tab key +- **THEN** focus moves to menu bar items in logical order +- **AND** focused items are visually indicated + +#### Scenario: Arrow key navigation +- **WHEN** a user navigates using arrow keys +- **THEN** Left/Right arrows move between top-level menu items +- **AND** Up/Down arrows move between dropdown menu items +- **AND** Home/End keys move to first/last items + +#### Scenario: Enter and Space key activation +- **WHEN** a user presses Enter or Space on a focused menu item +- **THEN** the menu item is activated +- **AND** dropdowns open or actions are executed as appropriate + +#### Scenario: Escape key handling +- **WHEN** a user presses Escape key +- **THEN** open dropdowns are closed +- **AND** focus returns to the parent menu item +- **AND** no other application functionality is affected + +### Requirement: Visual Focus Indicators +The menu bar SHALL provide clear visual indicators for keyboard focus and active states. + +#### Scenario: Focus indicators for menu items +- **WHEN** a menu item receives keyboard focus +- **THEN** a clear visual focus indicator is displayed +- **AND** the focus indicator meets WCAG contrast requirements + +#### Scenario: Active state indicators +- **WHEN** a menu item is activated or a dropdown is open +- **THEN** clear visual indicators show the active state +- **AND** the indicators are distinguishable from focus indicators + +#### Scenario: Hover state indicators +- **WHEN** a user hovers over menu items with a mouse +- **THEN** visual indicators show hover state +- **AND** hover indicators are consistent with focus indicators + +### Requirement: Screen Reader Announcements +The menu bar SHALL provide appropriate announcements for screen reader users. + +#### Scenario: Menu item descriptions +- **WHEN** a screen reader focuses on a menu item +- **THEN** the item's purpose is clearly announced +- **AND** any associated keyboard shortcuts are announced + +#### Scenario: Dropdown state announcements +- **WHEN** a dropdown menu state changes +- **THEN** screen readers announce the state change +- **AND** aria-expanded attribute is updated appropriately + +#### Scenario: Menu navigation announcements +- **WHEN** a user navigates between menu items +- **THEN** screen readers announce the current position +- **AND** the total number of items is announced when appropriate + +### Requirement: High Contrast and Zoom Support +The menu bar SHALL support high contrast modes and browser zoom functionality. + +#### Scenario: High contrast mode support +- **WHEN** high contrast mode is enabled +- **THEN** menu bar maintains proper contrast ratios +- **AND** all interactive elements remain visible and usable + +#### Scenario: Browser zoom support +- **WHEN** browser zoom is applied +- **THEN** menu bar layout adjusts appropriately +- **AND** no content is clipped or overlapping +- **AND** all functionality remains accessible + +### Requirement: Error Handling and Feedback +The menu bar SHALL provide appropriate feedback for accessibility-related errors. + +#### Scenario: Disabled menu item feedback +- **WHEN** a user attempts to activate a disabled menu item +- **THEN** appropriate feedback is provided +- **AND** screen readers announce the item as disabled + +#### Scenario: Invalid keyboard input handling +- **WHEN** a user provides invalid keyboard input +- **THEN** the application handles it gracefully +- **AND** no unexpected behavior occurs + + + +## ADDED Requirements + +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. + +#### Scenario: New Note shortcut +- **WHEN** a user presses Ctrl+E (Windows/Linux) or Cmd+E (Mac) +- **THEN** the createNewNote functionality is triggered +- **AND** the same behavior as clicking "New Note" menu item occurs + +#### Scenario: Open Workspace shortcut +- **WHEN** a user presses Ctrl+Shift+O (Windows/Linux) or Cmd+Shift+O (Mac) +- **THEN** the openWorkspace functionality is triggered +- **AND** the same behavior as clicking "Open Workspace" menu item occurs + +#### Scenario: Save shortcut +- **WHEN** a user presses Ctrl+S (Windows/Linux) or Cmd+S (Mac) +- **THEN** the saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking "Save" menu item occurs + +#### Scenario: Refresh shortcut +- **WHEN** a user presses Ctrl+L (Windows/Linux) or Cmd+L (Mac) +- **THEN** the rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking "Refresh" menu item occurs + +#### Scenario: Export JSON shortcut +- **WHEN** a user presses Ctrl+Shift+S (Windows/Linux) or Cmd+Shift+S (Mac) +- **THEN** the exportAsJson functionality is triggered +- **AND** the same behavior as clicking "Export JSON" menu item occurs + +#### Scenario: Export Markdown shortcut +- **WHEN** a user presses Ctrl+Shift+M (Windows/Linux) or Cmd+Shift+M (Mac) +- **THEN** the exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking "Export Markdown" menu item occurs + +### Requirement: Keyboard Navigation of Menu Bar +The menu bar SHALL support full keyboard navigation using standard accessibility patterns. + +#### Scenario: Tab navigation to menu bar +- **WHEN** a user presses Tab to navigate to the menu bar +- **THEN** focus moves to the first menu item +- **AND** the menu item is visually indicated as focused + +#### Scenario: Arrow key navigation between menus +- **WHEN** a user presses Left/Right arrow keys while focused on menu bar +- **THEN** focus moves between menu items (File, Edit, Import/Export) +- **AND** the focused menu item is visually indicated + +#### Scenario: Enter key to open dropdown +- **WHEN** a user presses Enter while focused on a menu item +- **THEN** the dropdown menu opens +- **AND** focus moves to the first menu item in the dropdown + +#### Scenario: Escape key to close dropdowns +- **WHEN** a user presses Escape while a dropdown is open +- **THEN** the dropdown closes +- **AND** focus returns to the parent menu item + +#### Scenario: Up/Down arrow navigation in dropdowns +- **WHEN** a user presses Up/Down arrow keys while focused on a dropdown menu +- **THEN** focus moves between menu items in the dropdown +- **AND** the focused item is visually indicated + +### Requirement: Shortcut Display in Menu Items +Menu items with keyboard shortcuts SHALL display the shortcut keys for discoverability. + +#### Scenario: Shortcut key display +- **WHEN** a user views the menu bar +- **THEN** menu items that have keyboard shortcuts display the shortcut keys +- **AND** shortcut keys are displayed in a consistent format (e.g., "Ctrl+N") + +#### Scenario: Platform-appropriate shortcut display +- **WHEN** the application runs on different platforms +- **THEN** shortcut keys are displayed using platform-appropriate modifiers +- **AND** Windows/Linux shows "Ctrl", Mac shows "Cmd" + +### Requirement: Shortcut Key Conflict Resolution +The application SHALL handle keyboard shortcut conflicts appropriately. + +#### Scenario: Browser shortcut precedence +- **WHEN** a user presses a keyboard shortcut that conflicts with browser functionality +- **THEN** the application prevents the default browser behavior +- **AND** the application's shortcut functionality is executed instead + +#### Scenario: Focus-based shortcut activation +- **WHEN** keyboard shortcuts are pressed +- **THEN** shortcuts are only active when the application has focus +- **AND** shortcuts do not interfere with other applications + + + +## ADDED Requirements + +### Requirement: Menu Bar Structure +The application SHALL provide a horizontal menu bar at the top of the application window containing File, Edit, and Import/Export menu items. + +#### Scenario: Menu bar is visible and accessible +- **WHEN** the application loads +- **THEN** a horizontal menu bar is displayed at the top of the application window +- **AND** the menu bar contains File, Edit, and Import/Export menu items + +#### Scenario: 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 + +### Requirement: File Menu Functionality +The File menu SHALL provide access to workspace and file management 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 + +#### 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 + +### Requirement: Edit Menu Functionality +The Edit menu SHALL provide access to document editing and view operations. + +#### Scenario: Save menu item +- **WHEN** a user clicks "Save" in the Edit menu +- **THEN** the existing saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking the "Save" button occurs + +#### Scenario: Refresh menu item +- **WHEN** a user clicks "Refresh" in the Edit menu +- **THEN** the existing rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking the "Refresh" button occurs + +#### Scenario: Sort toggle menu item +- **WHEN** a user clicks "Sort: Name/Modified" in the Edit menu +- **THEN** the existing sort functionality is triggered +- **AND** the same behavior as clicking the "Sort" button occurs + +#### Scenario: Collapse Sidebar menu item +- **WHEN** a user clicks "Collapse Sidebar" in the Edit menu +- **THEN** the existing setSidebarCollapsed functionality is triggered +- **AND** the same behavior as clicking the sidebar toggle button occurs + +### Requirement: Import/Export Menu Functionality +The Import/Export menu SHALL provide access to data export operations. + +#### Scenario: Export JSON menu item +- **WHEN** a user clicks "Export JSON" in the Import/Export menu +- **THEN** the existing exportAsJson functionality is triggered +- **AND** the same behavior as clicking the "Export JSON" button occurs + +#### Scenario: Export Markdown menu item +- **WHEN** a user clicks "Export Markdown" in the Import/Export menu +- **THEN** the existing exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking the "Export MD" button occurs + +### Requirement: Menu Dropdown Behavior +Menu dropdowns SHALL open and close appropriately with proper keyboard and mouse interaction. + +#### Scenario: Mouse interaction with dropdowns +- **WHEN** a user hovers over or clicks a menu item +- **THEN** the dropdown menu opens +- **AND** clicking outside the menu closes it + +#### Scenario: Keyboard navigation of menus +- **WHEN** a user navigates using arrow keys +- **THEN** focus moves between menu items +- **AND** pressing Enter activates the focused menu item +- **AND** pressing Escape closes open dropdowns + +### Requirement: Backward Compatibility +The menu bar SHALL not interfere with existing button functionality. + +#### Scenario: Buttons continue to work +- **WHEN** a user clicks existing buttons +- **THEN** the same functionality is triggered as before +- **AND** menu items provide alternative access to the same functions + +#### Scenario: Menu and button state synchronization +- **WHEN** a menu item is clicked +- **THEN** any related button states are updated appropriately +- **AND** the application maintains consistent state across all interfaces + + + +# Design: Office-Style Menu Bar Implementation + +## Context + +The ink markdown note-taking application needs a proper office-style menu bar to improve user experience and provide familiar desktop application patterns. The application currently has a functional but button-heavy interface. The menu bar should integrate seamlessly with existing functionality while adding discoverability and keyboard shortcuts. + +## Goals / Non-Goals + +### Goals +- Provide familiar File, Edit, and Import/Export menu structure +- Add keyboard shortcuts for common operations +- Maintain full backward compatibility with existing buttons +- Ensure accessibility with proper ARIA attributes +- Keep implementation simple and maintainable +- Follow existing code patterns and architecture + +### Non-Goals +- Replace existing button interface (menus complement, don't replace) +- Add complex menu features like toolbars or ribbons +- Implement undo/redo functionality (not currently in scope) +- Add theming or customization options +- Support for nested submenus beyond basic dropdowns + +## Decisions + +### Menu Structure Decision +**Decision**: Use a horizontal menu bar with dropdown menus for File, Edit, and Import/Export sections. + +**Rationale**: This follows standard desktop application patterns and provides clear organization of functionality. The horizontal layout works well with the existing sidebar layout. + +**Alternatives considered**: +- Contextual menus only: Rejected because it reduces discoverability +- Vertical sidebar menu: Rejected because it conflicts with existing workspace sidebar +- Hybrid approach: Rejected for complexity + +### Keyboard Shortcuts Decision +**Decision**: Implement standard desktop application shortcuts: +- Ctrl/Cmd+N: New Note +- Ctrl/Cmd+O: Open Workspace +- Ctrl/Cmd+S: Save +- F5: Refresh +- Ctrl/Cmd+Shift+S: Export JSON +- Ctrl/Cmd+Shift+M: Export Markdown + +**Rationale**: These follow established conventions from applications like VS Code, Notepad++, and other desktop editors. + +**Alternatives considered**: +- Custom shortcuts: Rejected for discoverability +- No shortcuts: Rejected because power users expect them + +### Integration Approach Decision +**Decision**: Extend existing InkApp class methods rather than creating new menu-specific functions. + +**Rationale**: This maintains code reuse and ensures consistent behavior between button clicks and menu selections. The existing methods already handle edge cases and error conditions properly. + +**Alternatives considered**: +- Separate menu handlers: Rejected because it would duplicate logic +- Event delegation: Rejected because direct method calls are simpler + +### Accessibility Decision +**Decision**: Implement full ARIA support with proper roles, labels, and keyboard navigation. + +**Rationale**: Essential for screen reader users and keyboard-only navigation. Follows WCAG guidelines for menu widgets. + +**Alternatives considered**: +- Basic accessibility: Rejected because it doesn't meet modern standards +- No accessibility: Rejected as unacceptable + +## Risks / Trade-offs + +### Risk: Layout Complexity +**Risk**: Adding menu bar could complicate the existing layout and cause responsive design issues. + +**Mitigation**: +- Use CSS Grid/Flexbox for flexible layout +- Test thoroughly on different screen sizes +- Maintain existing responsive behavior + +### Risk: Keyboard Shortcut Conflicts +**Risk**: New shortcuts might conflict with browser or OS shortcuts. + +**Mitigation**: +- Use standard shortcuts that don't conflict +- Add proper event.preventDefault() handling +- Test on different platforms + +### Risk: Performance Impact +**Risk**: Additional DOM elements and event listeners could impact performance. + +**Mitigation**: +- Use event delegation where possible +- Minimize DOM manipulation +- Follow existing performance patterns + +## Migration Plan + +### Phase 1: Core Menu Structure +1. Add HTML structure and basic CSS +2. Implement menu toggle functionality +3. Add basic keyboard navigation + +### Phase 2: Menu Item Implementation +1. Implement File menu items +2. Implement Edit menu items +3. Implement Import/Export menu items + +### Phase 3: Polish and Testing +1. Add comprehensive tests +2. Implement accessibility features +3. Add keyboard shortcuts +4. Performance optimization + +### Rollback Plan +If issues arise, the menu bar can be easily disabled by: +1. Removing menu HTML structure +2. Removing menu-specific CSS +3. Removing menu event handlers +4. All existing functionality remains intact + +## Open Questions + +1. **Should we add a Help menu?** - Currently not planned, but could be added later +2. **Should menu items be disabled when not applicable?** - Yes, following existing button patterns +3. **Should we support right-click context menus?** - Not in initial implementation, could be added later +4. **Should we add menu animations?** - No, keeping it simple and fast + + + +# Change: Add Office-Style Menu Bar + +## Why + +The ink markdown note-taking application currently lacks a proper office-style menu bar (File, Edit, Import/Export) that users expect from desktop applications. This makes common operations like opening workspaces, creating files/folders, and exporting content less discoverable and accessible. Adding a menu bar will improve user experience by providing familiar navigation patterns and keyboard shortcuts. + +## What Changes + +- **BREAKING**: Add a new menu bar component with File, Edit, and Import/Export sections +- Integrate existing functionality (open workspace, create files/folders, export) into menu items +- Add keyboard shortcuts for common operations (Ctrl/Cmd+N, Ctrl/Cmd+O, Ctrl/Cmd+S, etc.) +- Maintain existing button functionality while adding menu alternatives +- Add accessibility attributes and proper ARIA labeling +- Update UI layout to accommodate menu bar + +## Impact + +- Affected specs: `menu-bar`, `keyboard-shortcuts`, `accessibility` +- Affected code: `src/app/bootstrap.ts`, `src/app/dom.ts`, `src/app/types.ts`, `ink-app.html` +- New files: Menu component implementation, CSS styles, comprehensive tests +- User experience: Improved discoverability of features, familiar desktop application patterns +- Backward compatibility: All existing functionality preserved, menu adds new access points + + + +# 1. Implementation + +## 1.1 Menu Bar Structure +- [x] 1.1.1 Add menu bar HTML structure to ink-app.html +- [x] 1.1.2 Create CSS styles for menu bar layout and dropdowns +- [x] 1.1.3 Add ARIA attributes for accessibility (role="menubar", aria-haspopup, etc.) + +## 1.2 File Menu Implementation +- [x] 1.2.1 Implement "New Note" menu item (Ctrl/Cmd+N) +- [x] 1.2.2 Implement "New Folder" menu item +- [x] 1.2.3 Implement "Open Workspace" menu item (Ctrl/Cmd+O) +- [x] 1.2.4 Implement "Close Workspace" menu item +- [x] 1.2.5 Implement "Exit" menu item + +## 1.3 Edit Menu Implementation +- [x] 1.3.1 Implement "Save" menu item (Ctrl/Cmd+S) +- [x] 1.3.2 Implement "Save As" menu item +- [x] 1.3.3 Implement "Refresh" menu item (F5) +- [x] 1.3.4 Implement "Sort" toggle menu item +- [x] 1.3.5 Implement "Collapse Sidebar" menu item + +## 1.4 Import/Export Menu Implementation +- [x] 1.4.1 Implement "Export JSON" menu item +- [x] 1.4.2 Implement "Export Markdown" menu item +- [ ] 1.4.3 Implement "Import JSON" menu item (if needed) + +## 1.5 Keyboard Shortcuts +- [x] 1.5.1 Add global keyboard event listener for menu shortcuts +- [x] 1.5.2 Implement Ctrl/Cmd+E for new note +- [x] 1.5.3 Implement Ctrl/Cmd+Shift+O for open workspace +- [x] 1.5.4 Implement Ctrl/Cmd+S for save +- [x] 1.5.5 Implement Ctrl/Cmd+L for refresh +- [x] 1.5.6 Add visual indicators for keyboard shortcuts in menu items + +## 1.6 Integration with Existing Code +- [x] 1.6.1 Update InkApp class to handle menu events +- [x] 1.6.2 Update DOM references in dom.ts to include menu elements +- [x] 1.6.3 Update types.ts with new menu-related types +- [x] 1.6.4 Ensure menu items call existing InkApp methods +- [x] 1.6.5 Maintain backward compatibility with existing buttons + +## 1.7 Testing +- [ ] 1.7.1 Add Cypress tests for menu bar functionality +- [ ] 1.7.2 Add QUnit tests for menu component logic +- [ ] 1.7.3 Test keyboard shortcuts with Cypress +- [ ] 1.7.4 Test accessibility with screen reader simulation +- [ ] 1.7.5 Test menu dropdown behavior and state management + +## 1.8 Documentation and Polish +- [ ] 1.8.1 Update README.md with new menu features +- [ ] 1.8.2 Add keyboard shortcut documentation +- [ ] 1.8.3 Test responsive design for mobile/tablet +- [x] 1.8.4 Ensure proper error handling and user feedback +- [x] 1.8.5 Code review and cleanup + + + +# Change: Add a Declarative WebMCP Note Creation Tool + +## 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. + +## 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 + +## 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 + +### 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 + +### 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. + +#### 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 + +### 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 + + + +## 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 + +## 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 + +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. + +#### 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 + +### 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 + +#### 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 + + + +# Change: Fix Export JSON Shortcut Precedence + +## Why + +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 + +- 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 + +## Non-Regression Guarantees + +- 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 + +## 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 + +## Impact + +- 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 + + + +## 1. Implementation + +- [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 + +## 2. Testing + +- [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 + +## 3. Validation + +- [x] 3.1 Run `openspec validate fix-export-json-shortcut-precedence --strict` +- [x] 3.2 Run the relevant automated shortcut regression tests after implementation + + + +# Spec: Mobile Layout + +## Sidebar Toggle Always Visible on Small Screens + +The sidebar toggle button (`#sidebarToggleBtn`) SHALL be visible and interactive at all viewport widths, including those ≤ 980 px. + +#### Scenario: App loads on a mobile device — sidebar expanded + +- **GIVEN** a viewport width of 390 px +- **WHEN** the app finishes loading +- **THEN** the sidebar toggle button is rendered as a full-width horizontal button inside the sidebar +- **AND** the button label reads "▶ Collapse" with `aria-label="Collapse sidebar"` + +#### Scenario: App loads on a mobile device — sidebar collapsed + +- **GIVEN** a viewport width of 390 px and the sidebar state persisted as collapsed +- **WHEN** the app finishes loading +- **THEN** the sidebar is shown as a thin horizontal strip +- **AND** the toggle button is visible within that strip, reading "▼ Expand" with `aria-label="Expand sidebar"` + +--- + +## Sidebar Collapses and Expands Vertically on Mobile + +On small screens the sidebar SHALL collapse and expand vertically (row height), not horizontally (column width). + +#### Scenario: User collapses sidebar on mobile + +- **GIVEN** the sidebar is expanded on a ≤ 980 px viewport +- **WHEN** the user taps the sidebar toggle button +- **THEN** the sidebar shrinks to a horizontal strip showing only the toggle button +- **AND** the editor and preview area expand vertically to occupy the freed space +- **AND** the layout remains a single full-width column (no narrow sidebar column appears alongside the editor) + +#### Scenario: User expands sidebar on mobile + +- **GIVEN** the sidebar is collapsed on a ≤ 980 px viewport +- **WHEN** the user taps the toggle strip +- **THEN** the sidebar expands to show the full workspace panel (workspace name, search, file tree, tags) +- **AND** the editor area returns to its position below the sidebar + +--- + +## Editor and Preview Fill Available Vertical Space + +The editor (` + +
    +
    +
    + + + - const found = inMemoryNotes.find((n) => n.relPath === "note1.md"); - assert.ok(found, "Should find note by relPath"); - assert.strictEqual(found?.name, "note1.md", "Found note should have correct name"); -}); +
    +
    +
    + +
    +
    -QUnit.test("in-memory note content can be updated", (assert) => { - const note = { - name: "note.md", - relPath: "note.md", - content: "# Original", - lastModified: Date.now(), - tags: new Set(), - }; + + + +
    - note.content = "# Updated"; - note.lastModified = Date.now(); + +{ + "name": "ink", + "version": "1.0.0", + "description": "Ink is a functional and minimalistic webapp to write documents in markdown and export them", + "homepage": "https://github.com/feddernico/ink#readme", + "bugs": { + "url": "https://github.com/feddernico/ink/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/feddernico/ink.git" + }, + "license": "MIT", + "author": "Federico Viscioletti", + "type": "module", + "main": "index.js", + "scripts": { + "build": "node build/compile-and-assemble.js", + "watch": "node build/watch.js", + "build:favicon": "node build/generate-favicons.js", + "build:test": "node build/build-test.js", + "test:qunit": "npm run build:test && qunit \"tests/qunit/**/*.test.js\"", + "serve:test": "http-server . -p 4173 -s", + "test:cypress": "start-server-and-test \"npm run serve:test\" http://127.0.0.1:4173/ink-app.html \"unset ELECTRON_RUN_AS_NODE; cypress run\"", + "test:cypress:open": "unset ELECTRON_RUN_AS_NODE; cypress open", + "test": "npm run test:qunit && npm run test:cypress" + }, + "devDependencies": { + "@babel/standalone": "^7.29.2", + "@cypress/code-coverage": "^4.0.2", + "babel-plugin-istanbul": "^7.0.1", + "chokidar": "^4.0.3", + "cypress": "^15.12.0", + "esbuild": "^0.27.3", + "http-server": "^14.1.1", + "nyc": "^18.0.0", + "qunit": "^2.24.2", + "sass": "^1.97.3", + "start-server-and-test": "^2.0.12", + "typescript": "^5.9.3" + }, + "dependencies": { + "marked": "^17.0.4" + }, + "overrides": { + "immutable": "^5.1.5" + } +} + - assert.strictEqual(note.content, "# Updated", "Content should be updated"); - assert.ok(note.lastModified > 0, "Last modified should be updated"); -}); + +# ink +

    + Ink logo +

    -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() }, - ]; +Ink is a functional and minimalistic webapp to write documents in markdown and export them. - const count = inMemoryNotes.length; - assert.strictEqual(count, 3, "Should have 3 notes"); -}); +![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) -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"]) }, - ]; +## Repo Structure - const workNotes = inMemoryNotes.filter((n) => n.tags.has("work")); - assert.strictEqual(workNotes.length, 2, "Should have 2 work notes"); -}); +The structure of the repo is as follows: -QUnit.test("markdown filename extraction from full path", (assert) => { - const relPath = "folder/subfolder/note.md"; - const fileName = relPath.split("/").pop(); +``` +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 + - createFile(storage, "alpha", "z.md"); - createFile(storage, "alpha", "a.md"); - createFile(storage, "alpha", "m.md"); + +
    + + - assert.deepEqual(listWorkspaces(storage), ["alpha", "beta", "zeta"]); - assert.deepEqual(listFiles(storage, "alpha"), ["a.md", "m.md", "z.md"]); -}); + + - ensureWorkspace(storage, "workspace-a"); - createFile(storage, "workspace-a", "notes.md"); - saveFile(storage, "workspace-a", "notes.md", "# Ink\n\nSaved content"); + +
    +
    + + No note open - assert.strictEqual( - readFile(storage, "workspace-a", "notes.md"), - "# Ink\n\nSaved content", - ); -}); +
    + Ready +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    -QUnit.test("integration flow: data is isolated between workspaces", (assert) => { - const storage = new MemoryStorage(); +
    +
    +
    + +
    +
    - createFile(storage, "workspace-a", "shared.md"); - createFile(storage, "workspace-b", "shared.md"); + + +
    - -{ - "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", - }, - }, - }, -]; - + +# Test Document for Linter - -github: feddernico -ko_fi: feddernico - +This is a test document to verify the document linter functionality. - -{ - "folders": [ - { - "path": "." - } - ], - "settings": {} -} - +## Introduction - -MIT License +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. -Copyright (c) 2026 Federico Viscioletti +## Features -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: +- Feature one: This is a very long sentence that goes on and on and on and might exceed our readability threshold for testing purposes +- Feature two: Another feature with some code `example()` +- Feature three: And another feature -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +## Conclusion -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. - +In conclusion, this document tests various aspects of the linter including readability, structure, and engagement factors. - -{ - "$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}" - } - } - } -} +Some might say that the passive voice was used in this sentence, which could be flagged by the style checker. @@ -6755,8 +47438,11 @@ describe("workspace actions regression", () => { }); - -export {}; + +import "./commands"; +import "@cypress/code-coverage/support"; + +Cypress.on("uncaught:exception", () => false); @@ -7159,1123 +47845,1954 @@ No data or logic is affected. - [ ] 5.7 Verify no layout regressions in sidebar, file tree, or status bar on a standard viewport - -import type { PermissionCapableHandle } from "./types"; + +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"; -export function isFileSystemApiAvailable(): boolean { - return Boolean(window.showDirectoryPicker && window.FileSystemHandle); +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; + +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 = { + togglePanel: () => void; + setPanelOpen: (isOpen: boolean) => void; + isPanelOpen: () => boolean; + closePanel: () => void; + handleEditorChanged: (textSnapshot?: string) => Promise; + analyzeDocument: () => Promise; + exportSuggestions: () => Promise; +}; + +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" }, +]; + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, score)); } -export function isInMemoryMode(): boolean { - return !isFileSystemApiAvailable(); +function countWords(text: string): number { + return text.match(/\b\w+\b/g)?.length ?? 0; } -export async function ensurePermission( - handle: PermissionCapableHandle | null, - mode: "read" | "readwrite" = "read", -): Promise { - if (!handle) { - return false; - } +// 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); +} - if (!handle.queryPermission || !handle.requestPermission) { - return true; - } +export function countSentences(text: string): number { + return splitSentences(text).length; +} - const descriptor = { mode }; - const current = await handle.queryPermission(descriptor); - if (current === "granted") { - return true; +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; +} - const requested = await handle.requestPermission(descriptor); - return requested === "granted"; +function createLineButtonMarkup(lineStart: number, label = `Line ${lineStart}`): string { + return ``; } - - -import type { AppState, DomRefs } from "./types"; -import { applyTheme, VALID_THEMES } from "./theme"; +function isFencedCodeStart(line: string): boolean { + return /^```/.test(line.trim()); +} -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; -}; +function isAtxHeading(line: string): boolean { + return /^#{1,6}\s+/.test(line.trim()); +} -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 isOrderedListItem(line: string): boolean { + return /^\s*\d+[.)]\s+/.test(line); } -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"}`; +function isUnorderedListItem(line: string): boolean { + return /^\s*[-*+]\s+/.test(line); +} - const sortMenuItem = document.querySelector('[data-action="sort"] .menu-label-text'); - if (sortMenuItem) { - sortMenuItem.textContent = `Sort: ${state.sortMode === "name" ? "Name" : "Modified"}`; - } +function isListItem(line: string): boolean { + return isUnorderedListItem(line) || isOrderedListItem(line); +} - renderTree().catch((error: unknown) => { - showToast(`Sort render failed: ${String(error)}`, { persist: true }); - }); - } +function isBlockquoteLine(line: string): boolean { + return /^\s*>\s?/.test(line); +} - 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(); - } +function isIndentedCodeLine(line: string): boolean { + return /^(?:\t| {4,})/.test(line); +} - 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; - } - } +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; +} - return { - toggleSort, - handleMenuAction, - handleExit, - }; +function listMarkerPrefix(line: string): string { + return line.replace(/^(\s*(?:[-*+]|\d+[.)]))\s+.*$/, "$1"); } - - -import { escapeHtml } from "./utils"; -import { icon } from "./icons"; -import type { - AppState, - DomRefs, - FileHandleLike, - InMemoryNoteRecord, - TreeNode, -} from "./types"; +function stripListMarker(line: string): string { + return line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim(); +} -export type TreeHandlers = { - openNoteByRelPath: (relPath: string, handleHint: FileHandleLike | null) => Promise; - openInMemoryNote: (relPath: string) => Promise; -}; +function normalizeBlockText(lines: string[]): string { + return lines.map((line) => line.trim()).join(" ").replaceAll(/\s+/g, " ").trim(); +} -type ToastFn = (message: string, options?: { persist?: boolean }) => void; +export function parseMarkdownBlocks(text: string): MarkdownBlock[] { + const lines = text.split("\n"); + const blocks: MarkdownBlock[] = []; -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; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + const lineNumber = i + 1; - for (const note of notes) { - for (const tag of note.tags) { - tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); - } + if (trimmed === "") { + i += 1; + continue; } - 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) { - return; + 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; } - 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 }); - }); + 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; } - }); - els.tagRow.appendChild(allButton); - - 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 }); - }); - } + blocks.push({ + type: "code", + text: codeLines.join("\n"), + lineStart: startLine, + lineEnd: i, }); - els.tagRow.appendChild(button); + continue; } - } - - async function computeMatches(): Promise> { - let notes = [...state.notes]; - if (state.tagFilter) { - notes = notes.filter((note) => note.tags.has(state.tagFilter)); + 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 (state.sortMode === "modified") { - notes.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); - } else { - notes.sort((a, b) => a.relPath.localeCompare(b.relPath)); + 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 query = state.searchQuery.trim().toLowerCase(); - if (!query) { - return new Set(notes.map((note) => note.relPath)); + 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; } - const matches = new Set(); - for (const note of notes) { - if (note.relPath.toLowerCase().includes(query)) { - matches.add(note.relPath); - 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; + } - 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. + 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; } - return matches; + blocks.push({ + type: "paragraph", + text: paragraphLines.join(" ").trim(), + lineStart: startLine, + lineEnd: startLine + paragraphLines.length - 1, + }); + } + + return blocks; +} + +function getBlocksOfType(blocks: MarkdownBlock[], type: MarkdownBlock["type"]): MarkdownBlock[] { + return blocks.filter((block) => block.type === type); +} + +function getPlainTextBlocks(blocks: MarkdownBlock[]): Array> { + return blocks.filter((block): block is Extract => { + return block.type === "paragraph" || block.type === "blockquote"; + }); +} + +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 }; +} + +function createFinding( + category: LinterCategoryId, + severity: Severity, + title: string, + detail: string, + section: string, + isStrength = false, +): Finding { + return { category, severity, title, detail, section, isStrength }; +} + +function summarizeOpeners(blocks: MarkdownBlock[]): string { + const firstParagraph = blocks.find((block) => block.type === "paragraph"); + if (!firstParagraph) { + return openingNeedsStructure(); } - async function renderTree(): Promise { - if (!state.fileTree) { - els.tree.innerHTML = '
    Open a folder to begin.
    '; - return; - } + if (countWords(firstParagraph.text) >= 22) { + return openingTooDense(); + } - const matches = await computeMatches(); - const prunedTree = pruneTree(state.fileTree, matches); + if (/^(this|these)\b/i.test(firstParagraph.text)) { + return openingIsInformativeNotDirective(); + } - els.tree.innerHTML = ""; - if (!prunedTree || prunedTree.type !== "dir" || prunedTree.children.length === 0) { - els.tree.innerHTML = '
    No matching notes.
    '; - return; - } + return openingNeedsStrongerLead(); +} - for (const child of prunedTree.children) { - renderNode(child, 0); +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; +} - function pruneTree(node: TreeNode, matches: Set): TreeNode | null { - if (node.type === "file") { - return matches.has(node.relPath) ? node : null; - } +export function buildDocumentSections(blocks: MarkdownBlock[]): DocumentSection[] { + const sections: DocumentSection[] = []; + let currentSection: DocumentSection | null = null; + + 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; + } - const children: TreeNode[] = []; - for (const child of node.children) { - const kept = pruneTree(child, matches); - if (kept) { - children.push(kept); - } + function ensureLeadSection(block: MarkdownBlock): DocumentSection { + if (currentSection) { + return currentSection; } + return startSection("Lead", "implicit", block.lineStart); + } - if (node.relPath === "") { - return { - ...node, - children, - }; + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + const nextBlock = blocks[i + 1]; + + if (block.type === "heading") { + startSection(block.text, "heading", block.lineStart); + continue; } - if (children.length === 0) { - return null; + const labelTitle = getSectionLabelTitle(block, nextBlock); + if (labelTitle) { + startSection(labelTitle, "label", block.lineStart); + continue; } - return { - ...node, - children, - }; + const activeSection = ensureLeadSection(block); + activeSection.blocks.push(block); + activeSection.lineEnd = block.lineEnd; } - 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)} - `; - - row.addEventListener("click", (event: MouseEvent) => { - event.stopPropagation(); - if (isCollapsed) { - state.collapsedDirs.delete(node.relPath); - } else { - state.collapsedDirs.add(node.relPath); - } + if (sections.length === 0) { + return [ + { + title: "Document", + kind: "implicit", + lineStart: 1, + lineEnd: 1, + blocks: [], + }, + ]; + } - renderTree().catch((error: unknown) => { - showToast(`Tree render failed: ${String(error)}`, { persist: true }); - }); - }); + return sections; +} - els.tree.appendChild(row); - if (!isCollapsed) { - for (const child of node.children) { - renderNode(child, depth + 1); - } +function analyzeReadability(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { + const proseBlocks = getPlainTextBlocks(blocks); + const findings: Finding[] = []; + let longSentenceCount = 0; + let repeatedWordCount = 0; + + 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}`, + ), + ); } - return; } - - const isActive = node.relPath === state.currentRelPath; - if (isActive) { - row.classList.add("active"); + 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 meta = - state.sortMode === "modified" && node.noteRef.lastModified - ? new Date(node.noteRef.lastModified).toLocaleDateString() - : ""; + const openingBlock = proseBlocks[0]; + if (openingBlock && countWords(openingBlock.text) >= 22) { + findings.unshift( + createFinding( + "readability", + "high", + "Opening is dense", + openingTooDense(), + `Line ${openingBlock.lineStart}`, + ), + ); + } - row.innerHTML = ` - ${icon.fileText()} - ${escapeHtml(node.noteRef.name)} - ${escapeHtml(meta)} - `; + const score = clampScore( + 100 - + longSentenceCount * 14 - + repeatedWordCount * 10 - + (openingBlock && countWords(openingBlock.text) >= 22 ? 8 : 0), + ); - row.addEventListener("click", (event: MouseEvent) => { - event.stopPropagation(); - handlers.openNoteByRelPath(node.relPath, node.handle).catch((error: unknown) => { - showToast(`Open note failed: ${String(error)}`, { persist: true }); - }); - }); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + }; +} - els.tree.appendChild(row); +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", + ), + ); } - function renderInMemoryTree(): void { - els.tree.innerHTML = ""; + if (pseudoSectionLabel) { + findings.push( + createFinding( + "skimmability", + "medium", + "Make the section label explicit", + explicitSectionLabel(pseudoSectionLabel.text), + `Line ${pseudoSectionLabel.lineStart}`, + ), + ); + } - if (state.inMemoryNotes.length === 0) { - els.tree.innerHTML = - '
    Temporary session. Create a note to begin.
    '; - return; + 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 sortedNotes = [...state.inMemoryNotes].sort((a, b) => a.relPath.localeCompare(b.relPath)); + const score = clampScore( + 100 - + (headings.length === 0 ? 20 : 0) - + (pseudoSectionLabel ? 6 : 0) - + (bullets.length >= 4 ? 10 : 0), + ); - for (const note of sortedNotes) { - renderInMemoryRow(note); - } + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + pseudoSectionLabel, + }; +} + +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); + + 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 renderInMemoryRow(note: InMemoryNoteRecord): void { - const row = document.createElement("div"); - row.className = "node"; + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "engagement", + "high", + "Too little context", + thinDraft(), + `Line ${firstParagraph?.lineStart ?? 1}`, + ), + ); + } - const isActive = note.relPath === state.currentRelPath; - if (isActive) { - row.classList.add("active"); - } + if (hasOpeningQuestion && totalWords < 18) { + findings.push( + createFinding( + "engagement", + "medium", + "Question needs payoff", + questionNeedsAnswer(), + `Line ${firstParagraph?.lineStart ?? 1}`, + ), + ); + } - const meta = state.sortMode === "modified" ? new Date(note.lastModified).toLocaleDateString() : ""; + if (hasOpeningQuestion) { + strengths.push(strengthQuestionLead()); + } - row.innerHTML = ` - ${icon.fileText()} - ${escapeHtml(note.name)} - ${escapeHtml(meta)} - `; + if (hasDirectiveLead || directiveMatches.length >= 2) { + strengths.push(strengthDirectiveLead()); + } - row.addEventListener("click", () => { - handlers.openInMemoryNote(note.relPath); - }); + if (mnemonicMatches.length > 0) { + strengths.push(strengthMnemonic()); + } - els.tree.appendChild(row); + if (concreteAnchorMatches.length >= 2) { + strengths.push(strengthConcreteAnchors()); } - function updateCountsPill(): void { - const count = state.inMemoryNotes.length; - els.countsPill.textContent = `${count} note${count === 1 ? "" : "s"}`; + if (repeatedBulletStart && bulletTexts.length >= 3) { + strengths.push(strengthParallelList()); + } + + if (strengths.length === 0 && (getBlocksOfType(blocks, "heading").length > 0 || bulletTexts.length >= 3)) { + strengths.push(strengthClearStructure()); } + 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, + ); + return { - computeMatches, - renderTree, - renderTags, - renderInMemoryTree, - updateCountsPill, + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + strengths, }; } -
    - - -import QUnit from "qunit"; -import { - extractFrontMatter, - normalizeTag, - parseFrontmatterTags, - parseTags, -} from "../../dist/test/tags.js"; - -QUnit.module("tags parsing"); -QUnit.test("extracts YAML frontmatter", (assert) => { - const text = "---\ntags: [work, docs]\n---\n\n# Note"; - assert.strictEqual(extractFrontMatter(text), "tags: [work, docs]"); -}); - -QUnit.test("returns empty frontmatter when closing delimiter is missing", (assert) => { - const text = "---\ntags: [work, docs]\n\n# Note"; - assert.strictEqual(extractFrontMatter(text), ""); -}); +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", + ), + ); + } -QUnit.test("normalizes hashtag input and strips invalid characters", (assert) => { - assert.strictEqual(normalizeTag(" #Work/Area!! "), "work/area"); - assert.strictEqual(normalizeTag("###"), ""); - assert.strictEqual(normalizeTag(""), ""); -}); + if (repeatedWordMatch) { + findings.unshift( + createFinding( + "style", + "medium", + "Repeated word", + repeatedWord(), + "Document", + ), + ); + } -QUnit.test("parses frontmatter tags from inline and block syntaxes", (assert) => { - const fm = [ - "tags: [work, docs]", - "topic: markdown", - "tags:", - " - notes", - " - personal", - ].join("\n"); + const score = clampScore(96 - Math.min(uniqueNominalizations.length, 4) * 2 - (repeatedWordMatch ? 14 : 0)); - const tags = parseFrontmatterTags(fm); - assert.deepEqual([...tags].sort(), ["docs", "notes", "personal", "work"]); -}); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + }; +} -QUnit.test("ignores empty and invalid frontmatter tag entries", (assert) => { - const fm = [ - "tags: [\"\", !!!, work]", - "tags:", - " - \"\"", - " - $$$", - " - docs", - ].join("\n"); +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", + ), + ); + } - const tags = parseFrontmatterTags(fm); - assert.deepEqual([...tags].sort(), ["docs", "work"]); -}); + if (bullets.length >= 5 && headings.length === 0) { + findings.push( + createFinding( + "structure", + "medium", + "Break up the middle", + structureBreakMiddle(), + "Bullet list", + ), + ); + } -QUnit.test("parses combined frontmatter and inline hashtag tags", (assert) => { - const text = [ - "---", - "tags: [work]", - "---", - "", - "# Weekly notes", - "Track #project-alpha updates and #work planning", - ].join("\n"); + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "structure", + "high", + "Draft is too thin", + thinDraft(), + "Document", + ), + ); + } - const tags = parseTags(text); - assert.deepEqual([...tags].sort(), ["project-alpha", "work"]); -}); + const score = clampScore(86 - (headings.length === 0 ? 12 : 0) - (bullets.length >= 5 ? 8 : 0) - (totalWords > 0 && totalWords < 12 ? 22 : 0)); -QUnit.test("deduplicates tags across frontmatter and inline content", (assert) => { - const text = [ - "---", - "tags: [work, docs]", - "---", - "", - "Update #work and #docs this week", - ].join("\n"); + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + }; +} - const tags = parseTags(text); - assert.deepEqual([...tags].sort(), ["docs", "work"]); -}); +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}`, + ), + ); + } -QUnit.test("parses hashtags with punctuation and slash correctly", (assert) => { - const text = "Check (#release/v1), #alpha, and trailing #beta."; - const tags = parseTags(text); + 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}`, + ), + ); + } - assert.deepEqual([...tags].sort(), ["alpha", "beta", "release/v1"]); -}); - + if (words >= 90 && proseBlocks.length >= 2) { + notes.push(sectionLongMaterial()); + needsAttention = true; + findings.push( + createFinding( + "structure", + "low", + "Long section", + sectionLong(section.lineStart), + `Line ${section.lineStart}`, + ), + ); + } - -import QUnit from "qunit"; -import { escapeHtml } from "../../dist/test/utils.js"; + if (section.kind === "heading" && bullets.length > 0 && proseBlocks.length === 0) { + notes.push(sectionListNeedsLead()); + } -QUnit.module("escapeHtml"); + if (words > 0 && words < 12 && proseBlocks.length > 0) { + notes.push(thinSection()); + needsAttention = true; + } -QUnit.test("escapes ampersand", (assert) => { - assert.strictEqual(escapeHtml("a & b"), "a & b"); -}); + if (notes.length === 0) { + notes.push(balancedSection()); + } -QUnit.test("escapes multiple ampersands", (assert) => { - assert.strictEqual(escapeHtml("a & b & c"), "a & b & c"); -}); + return { + title: section.title, + kind: section.kind, + lineStart: section.lineStart, + lineEnd: section.lineEnd, + notes, + needsAttention, + }; + }); -QUnit.test("escapes less-than", (assert) => { - assert.strictEqual(escapeHtml("a < b"), "a < b"); -}); + return { findings, sectionNotes }; +} -QUnit.test("escapes multiple less-than signs", (assert) => { - assert.strictEqual(escapeHtml("a < b < c"), "a < b < c"); -}); +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}`); + + 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()); + } -QUnit.test("escapes greater-than", (assert) => { - assert.strictEqual(escapeHtml("a > b"), "a > b"); -}); + return { + quickTake, + priorities, + strengths: strengths.length > 0 ? strengths : [DOCUMENT_LINTER_FALLBACK_STRENGTH], + }; +} -QUnit.test("escapes double quotes", (assert) => { - assert.strictEqual(escapeHtml("say \"hello\""), "say "hello""); -}); +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)); +} -QUnit.test("escapes single quotes", (assert) => { - assert.strictEqual(escapeHtml("it's"), "it's"); -}); +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(); +} -QUnit.test("escapes all special characters together", (assert) => { - assert.strictEqual( - escapeHtml(""), - "<script>alert("xss & 'fun'");</script>" - ); -}); +function isElementLike(value: unknown): value is { getAttribute: (name: string) => string | null; closest?: (selector: string) => unknown } { + return typeof value === "object" && value !== null && "getAttribute" in value; +} -QUnit.test("converts non-string input to string", (assert) => { - assert.strictEqual(escapeHtml(42), "42"); -}); +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); + + const allFindings = [ + ...readability.findings, + ...skimmability.findings, + ...engagement.findings, + ...style.findings, + ...structure.findings, + ...sectionAnalysis.findings, + ]; -QUnit.test("returns empty string unchanged", (assert) => { - assert.strictEqual(escapeHtml(""), ""); -}); + 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}`), + }, + ]; -QUnit.test("leaves plain text unchanged", (assert) => { - assert.strictEqual(escapeHtml("hello world"), "hello world"); -}); - + const scores = { + readability: readability.result, + skimmability: skimmability.result, + engagement: engagement.result, + style: style.result, + structure: structure.result, + }; - -# Dependencies -node_modules/ + return { + scores, + overallScore: computeOverallScore(scores), + overview: buildOverview(allFindings, engagement.strengths, blocks, sectionAnalysis.sectionNotes), + sections: sectionNotes, + documentSections: sectionAnalysis.sectionNotes, + }; +} -# OS/editor noise -.DS_Store -*.log +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, + }, + ]; -# Local tool state -.opencode/ + 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}`, + ]; -# Cypress artifacts -cypress/screenshots/ -cypress/videos/ -cypress/downloads/ -coverage/ -.nyc_output/ - + return lines.join("\n"); +} - -# Changelog +export function createDocumentLinterController({ + els, + getEditorText, + onEditorContentReplaced: _onEditorContentReplaced, + showToast, + setStatus, +}: { + els: DomRefs; + getEditorText: () => string; + onEditorContentReplaced: (text: string) => void; + showToast: ToastFn; + setStatus: SetStatusFn; +}): DocumentLinterController { + let isPanelOpen = false; + let isAnalyzing = false; + let autoRunEnabled = false; + let lastAnalysis: LinterAnalysis | null = null; + let lastTextSnapshot = ""; -## [1.2.0](https://github.com/feddernico/ink/compare/ink-v1.1.5...ink-v1.2.0) (2026-04-07) + async function runAnalysis(textSnapshot: string = getEditorText()): Promise { + return Promise.resolve(analyzeDocumentText(textSnapshot)); + } + 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); + } -### Features + function setPanelVisibility(isOpen: boolean): void { + isPanelOpen = isOpen; + els.documentLinterPanel.hidden = !isOpen; + els.documentLinterToggleBtn.setAttribute("aria-expanded", String(isOpen)); -* hide the WebMCP note tool behind an agent popup ([bed3cca](https://github.com/feddernico/ink/commit/bed3cca71b72be81af1159c307e6b5c1ea0abd82)) + const split = els.documentLinterPanel.closest(".split"); + if (split) { + split.classList.toggle("with-document-linter", isOpen); + } + } + 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 = ""; + + 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); + + 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); + }); -### Bug Fixes + 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); + } -* align release-please tags with v-tag history ([a58f478](https://github.com/feddernico/ink/commit/a58f4786594f70a87d650becbe7b249914101397)) -* align release-please tags with v-tag history ([36d04fa](https://github.com/feddernico/ink/commit/36d04fae0bcf942733db027c5ed95ab4ef0fa39f)) + 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 link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } -## [1.1.5](https://github.com/feddernico/ink/compare/ink-v1.1.4...ink-v1.1.5) (2026-04-02) + async function analyzeDocumentAction(): Promise { + if (isAnalyzing) { + return; + } + const text = getEditorText(); + if (!text.trim()) { + showToast("No content to analyze", { persist: true }); + setStatus("No content to analyze", "warn"); + return; + } -### Bug Fixes + isAnalyzing = true; + lastTextSnapshot = text; + els.documentLinterAnalyzeBtn.disabled = true; + els.documentLinterStatus.textContent = "Analyzing document..."; -* added a way to bump node package verion using release-please ([6c62f7c](https://github.com/feddernico/ink/commit/6c62f7c6d7f67b41b46e61359bfa6f0380182817)) -* added a way to bump node package verion using release-please ([dc85d9e](https://github.com/feddernico/ink/commit/dc85d9ebb8ac44595c3a48be5a4113f59bf9fd99)) -* only create release if ink-app.html changed ([2f31aac](https://github.com/feddernico/ink/commit/2f31aaccf443daa2486dbff805f90256ecc2a146)) -* only create release if ink-app.html changed ([a9f4f65](https://github.com/feddernico/ink/commit/a9f4f656715bfb61ae394e4e8320e78ef57c9742)) -
    + try { + const analysis = await runAnalysis(text); + lastAnalysis = analysis; + updateResultsPanel(analysis); + els.documentLinterStatus.textContent = "Analysis complete"; + 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; + } + } - -import { defineConfig } from "cypress"; + async function exportSuggestionsAction(): Promise { + const text = getEditorText(); + if (!text.trim()) { + showToast("No content to export", { persist: true }); + setStatus("No content to export", "warn"); + return; + } -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; - }, - }, -}); - + 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; + 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"); + } + } - -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "include-v-in-tag": true, - "packages": { - ".": { - "release-type": "node" + async function handleEditorChanged(textSnapshot: string = getEditorText()): Promise { + if (!isPanelOpen || !autoRunEnabled || isAnalyzing || !textSnapshot.trim()) { + if (!textSnapshot.trim() && autoRunEnabled && isPanelOpen) { + lastAnalysis = null; + lastTextSnapshot = textSnapshot; + els.documentLinterStatus.textContent = "No content to analyze"; + } + return Promise.resolve(); + } + if (textSnapshot === lastTextSnapshot) { + return Promise.resolve(); } + return analyzeDocumentAction(); } + + els.documentLinterAutoRunToggle.checked = autoRunEnabled; + els.documentLinterAutoRunToggle.addEventListener("change", () => { + autoRunEnabled = els.documentLinterAutoRunToggle.checked; + 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; + } + const lineNumber = Number(lineButton.getAttribute("data-linter-line")); + if (Number.isNaN(lineNumber)) { + return; + } + scrollEditorToLine(lineNumber); + }); + + setPanelVisibility(false); + + return { + togglePanel: () => { + setPanelVisibility(!isPanelOpen); + if (isPanelOpen) { + els.documentLinterStatus.textContent = "Ready to analyze document"; + } + }, + setPanelOpen: (nextIsOpen: boolean) => { + setPanelVisibility(nextIsOpen); + if (nextIsOpen) { + els.documentLinterStatus.textContent = "Ready to analyze document"; + } + }, + isPanelOpen: () => isPanelOpen, + closePanel: () => { + setPanelVisibility(false); + }, + handleEditorChanged, + analyzeDocument: analyzeDocumentAction, + exportSuggestions: exportSuggestionsAction, + }; } - -# Ink - Markdown Editor Web App + +import { marked } from "marked"; +import { escapeHtml } from "./utils"; +import type { AppState, DomRefs } from "./types"; +import type { StatusKind } from "./toast-status"; -## Overview -Ink is a functional and minimalistic single-page web application for writing documents in markdown and exporting them. +export type EditorViewMode = "split" | "source" | "preview"; -## 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 +export const EDITOR_VIEW_MODE_STORAGE_KEY = "ink-editor-view-mode"; +export const VALID_EDITOR_VIEW_MODES: EditorViewMode[] = ["split", "source", "preview"]; -## 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 +function normalizeEditorViewMode(value: string | null): EditorViewMode { + if (value === "source" || value === "preview") { + return value; + } + return "split"; +} -## Build -```bash -npm install -npm run build # One-time build -npm run watch # Auto-rebuild on changes -``` +export function loadEditorViewMode(): EditorViewMode { + try { + return normalizeEditorViewMode(localStorage.getItem(EDITOR_VIEW_MODE_STORAGE_KEY)); + } catch { + return "split"; + } +} -## Serving (Development) -Workflow "Start application" runs: -``` -npx http-server . -p 5000 -s -``` -App is accessible at: `http://localhost:5000/ink-app.html` +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"); -## Deployment -- Target: Static site -- Build command: `npm run build` -- Public directory: `.` (root, serves `ink-app.html`) + els.editorPane.hidden = mode === "preview"; + els.previewPane.hidden = mode === "source"; -## 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 + const buttons: Array<[HTMLButtonElement, EditorViewMode]> = [ + [els.editorViewSourceBtn, "source"], + [els.editorViewSplitBtn, "split"], + [els.editorViewPreviewBtn, "preview"], + ]; -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`. + buttons.forEach(([button, buttonMode]) => { + const isActive = buttonMode === mode; + button.classList.toggle("active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); +} -## Testing -- QUnit unit tests: `npm run test:qunit` -- Cypress e2e tests: `npm run test:cypress` -- Full suite: `npm test` - +export function setEditorViewMode(els: DomRefs, state: AppState, mode: EditorViewMode): void { + if (!VALID_EDITOR_VIEW_MODES.includes(mode)) { + return; + } - -name: PR Checks + state.editorViewMode = mode; + applyEditorViewMode(els, mode); -on: - pull_request: - branches: [main] + try { + localStorage.setItem(EDITOR_VIEW_MODE_STORAGE_KEY, mode); + } catch { + // ignore localStorage errors + } +} -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 +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)}
    `; + } +} - - 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")" +export function updateDirtyUi( + els: DomRefs, + state: AppState, + setStatus: (message: string | null, kind?: StatusKind) => void, +): void { + els.dirtyDot.classList.toggle("show", state.isDirty); - printf '%s\n' "$changed_files" + const openFileName = state.currentRelPath + ? state.currentRelPath.split("/").pop() + : "No note open"; + els.currentFilename.textContent = `${openFileName}${state.isDirty ? " • Unsaved" : ""}`; - if [ -z "$changed_files" ]; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + if (state.isDirty) { + setStatus("Unsaved changes", "warn"); + } +} +
    - if echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + +import type { PermissionCapableHandle } from "./types"; - echo "docs_only=true" >> "$GITHUB_OUTPUT" +export function isFileSystemApiAvailable(): boolean { + return Boolean(window.showDirectoryPicker && window.FileSystemHandle); +} - test: - name: Build and test - needs: changes - if: needs.changes.outputs.docs_only != 'true' - runs-on: ubuntu-latest +export function isInMemoryMode(): boolean { + return !isFileSystemApiAvailable(); +} - steps: - - name: Checkout code - uses: actions/checkout@v4 +export async function ensurePermission( + handle: PermissionCapableHandle | null, + mode: "read" | "readwrite" = "read", +): Promise { + if (!handle) { + return false; + } - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' + if (!handle.queryPermission || !handle.requestPermission) { + return true; + } - - name: Install dependencies - run: npm ci + const descriptor = { mode }; + const current = await handle.queryPermission(descriptor); + if (current === "granted") { + return true; + } - - name: Build - run: npm run build + const requested = await handle.requestPermission(descriptor); + return requested === "granted"; +} + - - name: Run QUnit tests - run: npm run test:qunit + +import type { AppState, DomRefs } from "./types"; +import { applyTheme, VALID_THEMES } from "./theme"; - - 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 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; +}; - - name: Upload Cypress Screenshots - uses: actions/upload-artifact@v4 - if: failure() - with: - name: cypress-screenshots - path: cypress/screenshots - +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); + } + } + }); +} - -import "./commands"; -import "@cypress/code-coverage/support"; +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 sortMenuItem = document.querySelector('[data-action="sort"] .menu-label-text'); + if (sortMenuItem) { + sortMenuItem.textContent = `Sort: ${state.sortMode === "name" ? "Name" : "Modified"}`; + } + + renderTree().catch((error: unknown) => { + showToast(`Sort render failed: ${String(error)}`, { persist: true }); + }); + } + + 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(); + } + + 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; + } + } + } -Cypress.on("uncaught:exception", () => false); + return { + toggleSort, + handleMenuAction, + handleExit, + }; +} - -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 } }> }>; - }; - }; -}; + +import { escapeHtml } from "./utils"; +import { icon } from "./icons"; +import type { + AppState, + DomRefs, + FileHandleLike, + InMemoryNoteRecord, + TreeNode, +} from "./types"; -type WebLlmModule = { - CreateMLCEngine: ( - modelId: string, - options?: { - initProgressCallback?: (progress: { text?: string }) => void; - }, - ) => Promise; +export type TreeHandlers = { + openNoteByRelPath: (relPath: string, handleHint: FileHandleLike | null) => Promise; + openInMemoryNote: (relPath: string) => Promise; }; type ToastFn = (message: string, options?: { persist?: boolean }) => void; -type SetStatusFn = (message: string, kind?: "ok" | "warn" | "err") => void; - -type CogitoController = { - togglePanel: () => 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 ""; - } +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 fragments[fragments.length - 1]; -} + for (const note of notes) { + for (const tag of note.tags) { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 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 }; + const sorted = [...tagCounts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 50); - if (!parsed || !Array.isArray(parsed.questions)) { - throw new Error("Cogito response did not include a questions array."); - } + els.tagRow.innerHTML = ""; + if (sorted.length === 0) { + return; + } - const sanitized = parsed.questions - .filter((q): q is string => typeof q === "string" && q.trim().length > 0) - .map((q) => q.trim()); + 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); - if (sanitized.length === 0) { - throw new Error("Cogito response contained no valid questions."); + 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); + } } - if (sanitized.length !== 3) { - throw new Error("Cogito response must contain exactly 3 questions."); - } + async function computeMatches(): Promise> { + let notes = [...state.notes]; - return sanitized; -} + if (state.tagFilter) { + notes = notes.filter((note) => note.tags.has(state.tagFilter)); + } -export function formatCogitoQuestionBlock(question: string): string { - return `> ### AI\n${question.trim()}\n`; -} + 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 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); -} + const query = state.searchQuery.trim().toLowerCase(); + if (!query) { + return new Set(notes.map((note) => note.relPath)); + } -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>> = {}; + const matches = new Set(); + for (const note of notes) { + if (note.relPath.toLowerCase().includes(query)) { + matches.add(note.relPath); + continue; + } - async function loadWebLlmModule(): Promise { - const testModule = ( - globalThis as typeof globalThis & { - __INK_TEST_WEBLLM__?: WebLlmModule; + 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. } - ).__INK_TEST_WEBLLM__; + } + + return matches; + } - if (testModule) { - return testModule; + async function renderTree(): Promise { + if (!state.fileTree) { + els.tree.innerHTML = '
    Open a folder to begin.
    '; + return; } - return import("https://esm.run/@mlc-ai/web-llm") as Promise; - } + const matches = await computeMatches(); + const prunedTree = pruneTree(state.fileTree, matches); - function selectModel(model: CogitoModel): void { - selectedModel = model; - els.cogitoLiteBtn.classList.toggle("active", model === "lite"); - els.cogitoDeepBtn.classList.toggle("active", model === "deep"); - } + els.tree.innerHTML = ""; + if (!prunedTree || prunedTree.type !== "dir" || prunedTree.children.length === 0) { + els.tree.innerHTML = '
    No matching notes.
    '; + return; + } - 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); + for (const child of prunedTree.children) { + renderNode(child, 0); } } - function updateQuestionList(questions: string[]): void { - els.cogitoQuestionList.innerHTML = ""; - questions.forEach((question, index) => { - const item = document.createElement("li"); - item.className = "cogitoQuestionItem"; + function pruneTree(node: TreeNode, matches: Set): TreeNode | null { + if (node.type === "file") { + return matches.has(node.relPath) ? node : null; + } - const text = document.createElement("p"); - text.className = "cogitoQuestionText"; - text.textContent = question; + const children: TreeNode[] = []; + for (const child of node.children) { + const kept = pruneTree(child, matches); + if (kept) { + children.push(kept); + } + } - 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"; + if (node.relPath === "") { + return { + ...node, + children, + }; + } - item.append(text, button); - els.cogitoQuestionList.appendChild(item); - }); - } + if (children.length === 0) { + return null; + } - function setCogitoStatus(text: string): void { - els.cogitoStatus.textContent = text; + return { + ...node, + children, + }; } - async function getOrCreateEngine(): Promise { - const modelId = selectedModel === "deep" ? DEEP_MODEL : LITE_MODEL; + function renderNode(node: TreeNode, depth: number): void { + const row = document.createElement("div"); + row.className = "node"; + row.style.paddingLeft = `${8 + depth * 12}px`; - if (engineCache[selectedModel]) { - return engineCache[selectedModel]!; - } + if (node.type === "dir") { + const isCollapsed = state.collapsedDirs.has(node.relPath); + row.innerHTML = ` + ${isCollapsed ? "▶" : "▼"} + ${isCollapsed ? icon.folder() : icon.folderOpen()} + ${escapeHtml(node.name)} + `; - 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; - }); + row.addEventListener("click", (event: MouseEvent) => { + event.stopPropagation(); + if (isCollapsed) { + state.collapsedDirs.delete(node.relPath); + } else { + state.collapsedDirs.add(node.relPath); + } - return engineCache[selectedModel]!; - } + renderTree().catch((error: unknown) => { + showToast(`Tree render failed: ${String(error)}`, { persist: true }); + }); + }); - 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"); + els.tree.appendChild(row); + if (!isCollapsed) { + for (const child of node.children) { + renderNode(child, depth + 1); + } + } return; } - try { - els.cogitoGenerateBtn.disabled = true; - setCogitoStatus("Generating 3 questions..."); + const isActive = node.relPath === state.currentRelPath; + if (isActive) { + row.classList.add("active"); + } - 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, + const meta = + state.sortMode === "modified" && node.noteRef.lastModified + ? new Date(node.noteRef.lastModified).toLocaleDateString() + : ""; + + row.innerHTML = ` + ${icon.fileText()} + ${escapeHtml(node.noteRef.name)} + ${escapeHtml(meta)} + `; + + 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 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() - : ""; + els.tree.appendChild(row); + } - if (!textContent) { - throw new Error("Cogito returned an empty response."); - } + function renderInMemoryTree(): void { + els.tree.innerHTML = ""; - 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; + if (state.inMemoryNotes.length === 0) { + els.tree.innerHTML = + '
    Temporary session. Create a note to begin.
    '; + return; + } + + const sortedNotes = [...state.inMemoryNotes].sort((a, b) => a.relPath.localeCompare(b.relPath)); + + for (const note of sortedNotes) { + renderInMemoryRow(note); } } - function insertQuestionAtIndex(index: number): void { - const question = generatedQuestions[index]; - if (!question) { - showToast("Cogito question not found.", { persist: true }); - return; + function renderInMemoryRow(note: InMemoryNoteRecord): void { + const row = document.createElement("div"); + row.className = "node"; + + const isActive = note.relPath === state.currentRelPath; + if (isActive) { + row.classList.add("active"); } - const block = formatCogitoQuestionBlock(question); - insertTextAtCursor(els.editor, block); - onEditorContentReplaced(els.editor.value); - setStatus("Inserted AI question", "ok"); + const meta = state.sortMode === "modified" ? new Date(note.lastModified).toLocaleDateString() : ""; + + row.innerHTML = ` + ${icon.fileText()} + ${escapeHtml(note.name)} + ${escapeHtml(meta)} + `; + + row.addEventListener("click", () => { + handlers.openInMemoryNote(note.relPath); + }); + + els.tree.appendChild(row); } - setPanelVisibility(false); + function updateCountsPill(): void { + const count = state.inMemoryNotes.length; + els.countsPill.textContent = `${count} note${count === 1 ? "" : "s"}`; + } return { - togglePanel: () => { - setPanelVisibility(!isPanelOpen); - if (isPanelOpen) { - setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence."); - } - }, - selectModel, - generateQuestions, - insertQuestionAtIndex, + computeMatches, + renderTree, + renderTags, + renderInMemoryTree, + updateCountsPill, }; }
    @@ -8286,53 +49803,58 @@ import { bootstrapInkApp } from "./app/app-controller"; bootstrapInkApp();
    - -{ - ".": "1.2.0" -} - + +import QUnit from "qunit"; +import { escapeHtml } from "../../dist/test/utils.js"; + +QUnit.module("escapeHtml"); + +QUnit.test("escapes ampersand", (assert) => { + assert.strictEqual(escapeHtml("a & b"), "a & b"); +}); - -modules = ["web", "nodejs-20"] -[agent] -expertMode = true +QUnit.test("escapes multiple ampersands", (assert) => { + assert.strictEqual(escapeHtml("a & b & c"), "a & b & c"); +}); -[nix] -channel = "stable-25_05" -packages = ["gh"] +QUnit.test("escapes less-than", (assert) => { + assert.strictEqual(escapeHtml("a < b"), "a < b"); +}); -[workflows] -runButton = "Project" +QUnit.test("escapes multiple less-than signs", (assert) => { + assert.strictEqual(escapeHtml("a < b < c"), "a < b < c"); +}); -[[workflows.workflow]] -name = "Project" -mode = "parallel" -author = "agent" +QUnit.test("escapes greater-than", (assert) => { + assert.strictEqual(escapeHtml("a > b"), "a > b"); +}); -[[workflows.workflow.tasks]] -task = "workflow.run" -args = "Start application" +QUnit.test("escapes double quotes", (assert) => { + assert.strictEqual(escapeHtml("say \"hello\""), "say "hello""); +}); -[[workflows.workflow]] -name = "Start application" -author = "agent" +QUnit.test("escapes single quotes", (assert) => { + assert.strictEqual(escapeHtml("it's"), "it's"); +}); -[[workflows.workflow.tasks]] -task = "shell.exec" -args = "npx http-server . -p 5000 --proxy http://localhost:5000? -s" -waitForPort = 5000 +QUnit.test("escapes all special characters together", (assert) => { + assert.strictEqual( + escapeHtml(""), + "<script>alert("xss & 'fun'");</script>" + ); +}); -[workflows.workflow.metadata] -outputType = "webview" +QUnit.test("converts non-string input to string", (assert) => { + assert.strictEqual(escapeHtml(42), "42"); +}); -[deployment] -deploymentTarget = "static" -build = ["npm", "run", "build"] -publicDir = "." +QUnit.test("returns empty string unchanged", (assert) => { + assert.strictEqual(escapeHtml(""), ""); +}); -[[ports]] -localPort = 5000 -externalPort = 80 +QUnit.test("leaves plain text unchanged", (assert) => { + assert.strictEqual(escapeHtml("hello world"), "hello world"); +}); @@ -8432,105 +49954,256 @@ chmod +x .git/hooks/pre-commit ``` - -# Contributing to ink + +import { createRequire } from "node:module"; +import { defineConfig } from "cypress"; -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. +const require = createRequire(import.meta.url); -## Table of Contents +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; + }, + }, +}); + -- [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) + +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "packages": { + ".": { + "release-type": "node" + } + } +} + -## Code of Conduct + +# Ink - Markdown Editor Web App -Please be respectful, constructive, and patient in all project discussions. We want ink to stay welcoming to contributors of all experience levels. +## Overview +Ink is a functional and minimalistic single-page web application for writing documents in markdown and exporting them. -## Getting Started +## Architecture +- **Type**: Static frontend-only SPA (no backend) +- **Language**: TypeScript (compiled via esbuild) +- **Styles**: SCSS (compiled via sass) +- **Output**: Single HTML file (`ink-app.html`) with all JS and CSS inlined + +## Project Structure +- `src/app.ts` - App entrypoint +- `src/app/` - Feature modules (bootstrap, dom, fs-api, types) +- `src/tags.ts` - Tag/frontmatter parsing utilities +- `src/styles.scss` - SCSS styles +- `ink.template.html` - HTML template source +- `ink-app.html` - Final built single-page app (served directly) +- `build/` - Build scripts (esbuild + sass) +- `dist/` - Intermediate compiled output +- `assets/branding/` - Logo and favicon SVGs + +## Build +```bash +npm install +npm run build # One-time build +npm run watch # Auto-rebuild on changes +``` + +## Serving (Development) +Workflow "Start application" runs: +``` +npx http-server . -p 5000 -s +``` +App is accessible at: `http://localhost:5000/ink-app.html` + +## Deployment +- Target: Static site +- Build command: `npm run build` +- Public directory: `.` (root, serves `ink-app.html`) + +## Color Themes +Six RStudio-style color themes available under the **View** menu: +- **Default (Dark)** — original dark teal theme +- **Classic** — light, neutral grey/white +- **Cobalt** — dark ocean blue with gold accents +- **Monokai** — iconic dark theme with green/pink highlights +- **Office** — clean professional light theme (Microsoft Office palette) +- **Twilight** — warm dark grey with amber accents +- **Xcode** — light theme inspired by Apple Xcode + +The selected theme is persisted in `localStorage` under the key `ink-theme`. Themes are applied via a `data-theme` attribute on ``, driven by CSS custom property overrides in `src/styles.scss`. + +## Testing +- QUnit unit tests: `npm run test:qunit` +- Cypress e2e tests: `npm run test:cypress` +- Full suite: `npm test` + + + +name: PR Checks + +on: + pull_request: + branches: [main] + +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 + + - 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" + + if [ -z "$changed_files" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "docs_only=true" >> "$GITHUB_OUTPUT" + + test: + name: Build and test + needs: changes + if: needs.changes.outputs.docs_only != 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Run QUnit tests + run: npm run test:qunit + + - 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 + + - name: Upload Cypress Screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots + + + +modules = ["web", "nodejs-20"] +[agent] +expertMode = true + +[nix] +channel = "stable-25_05" +packages = ["gh"] -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`. +[workflows] +runButton = "Project" -## How to Contribute +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" -### Reporting Bugs +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" -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. +[[workflows.workflow]] +name = "Start application" +author = "agent" -### Suggesting Features +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npx http-server . -p 5000 --proxy http://localhost:5000? -s" +waitForPort = 5000 -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. +[workflows.workflow.metadata] +outputType = "webview" -### Submitting Changes +[deployment] +deploymentTarget = "static" +build = ["npm", "run", "build"] +publicDir = "." -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. +[[ports]] +localPort = 5000 +externalPort = 80 + -## Coding Guidelines + +# Changelog -- 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. +## [1.2.1](https://github.com/feddernico/ink/compare/ink-v1.2.0...ink-v1.2.1) (2026-04-07) -## Commit Messages -- 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. +### Bug Fixes -## Release Process +* finalize merged release PRs with plain tags ([98a6bea](https://github.com/feddernico/ink/commit/98a6bea8da0c32d8d419000e9a0d023838230c99)) +* finalize merged release PRs with plain tags ([b02c526](https://github.com/feddernico/ink/commit/b02c526c7d5da3789d5e6f80273537437c7ea183)) -- 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. +## [1.2.0](https://github.com/feddernico/ink/compare/ink-v1.1.5...ink-v1.2.0) (2026-04-07) -## Style Guide -- **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`. +### Features -## Testing and Build Checks +* hide the WebMCP note tool behind an agent popup ([bed3cca](https://github.com/feddernico/ink/commit/bed3cca71b72be81af1159c307e6b5c1ea0abd82)) -Before submitting changes, run the checks that match your work: -- `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 +### Bug Fixes -If your change affects favicon or branding assets, also run `npm run build:favicon`. +* align release-please tags with v-tag history ([a58f478](https://github.com/feddernico/ink/commit/a58f4786594f70a87d650becbe7b249914101397)) +* align release-please tags with v-tag history ([36d04fa](https://github.com/feddernico/ink/commit/36d04fae0bcf942733db027c5ed95ab4ef0fa39f)) -## License +## [1.1.5](https://github.com/feddernico/ink/compare/ink-v1.1.4...ink-v1.1.5) (2026-04-02) -By contributing to this project, you agree that your contributions will be licensed under the MIT License. ---- +### Bug Fixes -Thank you for contributing to ink! +* added a way to bump node package verion using release-please ([6c62f7c](https://github.com/feddernico/ink/commit/6c62f7c6d7f67b41b46e61359bfa6f0380182817)) +* added a way to bump node package verion using release-please ([dc85d9e](https://github.com/feddernico/ink/commit/dc85d9ebb8ac44595c3a48be5a4113f59bf9fd99)) +* only create release if ink-app.html changed ([2f31aac](https://github.com/feddernico/ink/commit/2f31aaccf443daa2486dbff805f90256ecc2a146)) +* only create release if ink-app.html changed ([a9f4f65](https://github.com/feddernico/ink/commit/a9f4f656715bfb61ae394e4e8320e78ef57c9742)) @@ -8615,209 +50288,397 @@ Thank you for contributing to ink! - [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 +## 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. + + + +# Project Context + +## Purpose + +Ink is a web application for writing and editing markdown documents with export capabilities. It provides a clean, focused interface for markdown editing and document generation. + +## Tech Stack + +- **Frontend**: Vanilla JavaScript (ES6+) +- **Markdown Processing**: Marked.js v15.0.12 (embedded) +- **Build System**: Custom Node.js build script (build/compile-and-assemble.js) +- **HTML Template**: Custom template system with inlined CSS/JS +- **Target**: Single-page web application (ink.html) + +## Project Conventions + +### Code Style + +- ES6+ JavaScript modules +- Minimal dependencies - prefers vanilla JavaScript +- Single-file build output (ink.html) with inlined assets +- Functional programming patterns preferred + +### Architecture Patterns + +- **Single File Architecture**: Final build is a self-contained HTML file +- **Template Injection**: CSS and JS are injected into HTML template during build +- **No Build Framework**: Custom build process without webpack/rollup/etc. +- **Client-side Only**: Pure frontend application with no server requirements + +### Testing Strategy + +- No formal testing framework currently implemented +- Manual testing via browser +- Build verification through file generation + +### Git Workflow + +- Simple trunk-based development +- Commits should be descriptive of changes +- Main branch contains production-ready code + +## Domain Context + +- **Markdown Editing**: Core focus on markdown document creation and editing +- **Document Export**: Ability to export markdown to various formats +- **Offline Usage**: Application should work without internet connectivity +- **Single User**: Designed for individual document creation + +## Important Constraints + +- **Single File Output**: Must build to a single HTML file (ink.html) +- **No External Dependencies**: Runtime should work without CDN/internet +- **Cross-browser**: Must work in modern browsers +- **Lightweight**: Keep file size minimal for fast loading +- **No Server**: Pure client-side application + +## Keyboard Shortcuts + +- **Ctrl/Cmd + E**: Create a new note +- **Ctrl/Cmd + Shift + O**: Open a workspace +- **Ctrl/Cmd + S**: Save the current note +- **Ctrl/Cmd + L**: Refresh the workspace +- **Ctrl/Cmd + Shift + S**: Export all notes as JSON +- **Ctrl/Cmd + Shift + M**: Export the current note as Markdown + +## External Dependencies + +- **Marked.js**: Markdown parser library (embedded in build) +- **No External APIs**: Application works completely offline +- **No Backend**: No server-side components required + + + +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 } }> }>; + }; + }; +}; + +type WebLlmModule = { + CreateMLCEngine: ( + modelId: string, + options?: { + initProgressCallback?: (progress: { text?: string }) => void; + }, + ) => 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 }; -- [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. + if (!parsed || !Array.isArray(parsed.questions)) { + throw new Error("Cogito response did not include a questions array."); + } -## 7. Build and Verification + const sanitized = parsed.questions + .filter((q): q is string => typeof q === "string" && q.trim().length > 0) + .map((q) => q.trim()); -- [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. - + if (sanitized.length === 0) { + throw new Error("Cogito response contained no valid questions."); + } - -import { marked } from "marked"; -import { normalizeTag, parseTags } from "../tags"; -import { getDomRefs } from "./dom"; -import { ensurePermission, isFileSystemApiAvailable } from "./fs-api"; -import { createAutoRefresh } from "./auto-refresh"; -import { renderPreview, updateDirtyUi } from "./editor-preview"; -import { createMenuActions, updateMenuShortcuts } from "./menu-actions"; -import { applySidebarState, setSidebarCollapsed } from "./sidebar"; -import { loadTheme } from "./theme"; -import { createToastController, setStatus } from "./toast-status"; -import { createTreeRenderer } from "./tree-render"; -import { attachUiEvents } from "./ui-events"; -import { createWorkspaceActions } from "./workspace-io"; -import { createCogitoController } from "./cogito"; -import type { AppState, DeclarativeNoteInput, DeclarativeNoteResult, DomRefs } from "./types"; + if (sanitized.length !== 3) { + throw new Error("Cogito response must contain exactly 3 questions."); + } -type RescanWorkspaceFn = (options?: { silent?: boolean }) => Promise; + return sanitized; +} -export function bootstrapInkApp(): void { - const app = createAppController(getDomRefs()); - app.initialize(); +export function formatCogitoQuestionBlock(question: string): string { + return `> ### AI\n${question.trim()}\n`; } -export function createAppController(els: DomRefs) { - const state: AppState = { - workspaceHandle: null, - workspaceName: "", - fileTree: null, - notes: [], - inMemoryNotes: [], - currentFileHandle: null, - currentRelPath: "", - currentContent: "", - isDirty: false, - searchQuery: "", - tagFilter: "", - sortMode: "name", - collapsedDirs: new Set(), - autoRefreshMs: 10000, - autoRefreshTimer: null, - isSidebarCollapsed: false, - isTemporarySession: false, - isCogitoModeEnabled: false, - }; +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); +} - const toastTimerRef = { current: null as ReturnType | null }; - const { showToast, hideToast } = createToastController(els, toastTimerRef); +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>> = {}; - const treeHandlers = { - openNoteByRelPath: async () => {}, - openInMemoryNote: async () => {}, - }; + async function loadWebLlmModule(): Promise { + const testModule = ( + globalThis as typeof globalThis & { + __INK_TEST_WEBLLM__?: WebLlmModule; + } + ).__INK_TEST_WEBLLM__; - const treeRenderer = createTreeRenderer({ - state, - els, - handlers: treeHandlers, - showToast, - }); + if (testModule) { + return testModule; + } - const rescanWorkspaceRef: { current: RescanWorkspaceFn } = { - current: async () => {}, - }; + return import("https://esm.run/@mlc-ai/web-llm") as Promise; + } - const autoRefresh = createAutoRefresh({ - state, - ensurePermission, - rescanWorkspace: (options) => rescanWorkspaceRef.current(options), - showToast, - setStatus: (message, kind) => setStatus(els, message, kind), - }); + function selectModel(model: CogitoModel): void { + selectedModel = model; + els.cogitoLiteBtn.classList.toggle("active", model === "lite"); + els.cogitoDeepBtn.classList.toggle("active", model === "deep"); + } - const workspaceActions = createWorkspaceActions({ - state, - els, - showToast, - setStatus: (message, kind) => setStatus(els, message, kind), - renderPreview, - updateDirtyUi, - renderTree: treeRenderer.renderTree, - renderInMemoryTree: treeRenderer.renderInMemoryTree, - renderTags: treeRenderer.renderTags, - updateCountsPill: treeRenderer.updateCountsPill, - fsApi: { - ensurePermission, - isFileSystemApiAvailable, - }, - parseTags, - normalizeTag, - autoRefresh, - }); + 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); + } + } - rescanWorkspaceRef.current = workspaceActions.rescanWorkspace; - treeHandlers.openNoteByRelPath = workspaceActions.openNoteByRelPath; - treeHandlers.openInMemoryNote = workspaceActions.openInMemoryNote; + function updateQuestionList(questions: string[]): void { + els.cogitoQuestionList.innerHTML = ""; + questions.forEach((question, index) => { + const item = document.createElement("li"); + item.className = "cogitoQuestionItem"; - const menuActions = createMenuActions({ - state, - els, - showToast, - renderTree: treeRenderer.renderTree, - callbacks: { - createNewNote: workspaceActions.createNewNote, - createNewFolder: workspaceActions.createNewFolder, - openWorkspace: workspaceActions.openWorkspace, - closeWorkspace: workspaceActions.closeWorkspace, - saveCurrentNote: workspaceActions.saveCurrentNote, - saveAsNewNote: workspaceActions.saveAsNewNote, - handleRefresh: workspaceActions.handleRefresh, - exportAsJson: workspaceActions.exportAsJson, - exportAsMarkdown: workspaceActions.exportAsMarkdown, - setSidebarCollapsed: (isCollapsed) => setSidebarCollapsed(els, state, isCollapsed), - }, - }); + const text = document.createElement("p"); + text.className = "cogitoQuestionText"; + text.textContent = question; - function handleSearchInput(value: string): void { - state.searchQuery = value; - treeRenderer.renderTree().catch((error: unknown) => { - showToast(`Search render failed: ${String(error)}`, { persist: true }); + 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"; + + item.append(text, button); + els.cogitoQuestionList.appendChild(item); }); } - function handleEditorInput(value: string): void { - state.isDirty = value !== state.currentContent; - updateDirtyUi(els, state, (message, kind) => setStatus(els, message, kind)); - renderPreview(els, value); + function setCogitoStatus(text: string): void { + els.cogitoStatus.textContent = text; + } + + async function getOrCreateEngine(): Promise { + const modelId = selectedModel === "deep" ? DEEP_MODEL : LITE_MODEL; + + if (engineCache[selectedModel]) { + return engineCache[selectedModel]!; + } + + 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; + }); + + return engineCache[selectedModel]!; } - const cogitoController = createCogitoController({ - els, - getEditorText: () => els.editor.value, - onEditorContentReplaced: (text) => handleEditorInput(text), - showToast, - setStatus: (message, kind) => setStatus(els, message, kind), - }); + 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; + } + + try { + els.cogitoGenerateBtn.disabled = true; + setCogitoStatus("Generating 3 questions..."); + + 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, + }); - function initialize(): void { - marked.use({ breaks: true }); + 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() + : ""; - const isMac = navigator.platform.toLowerCase().includes("mac"); - updateMenuShortcuts(els, isMac); + if (!textContent) { + throw new Error("Cogito returned an empty response."); + } - attachUiEvents({ - state, - els, - actions: { - handleMenuAction: menuActions.handleMenuAction, - toggleSidebar: () => setSidebarCollapsed(els, state, !state.isSidebarCollapsed), - handleRefresh: workspaceActions.handleRefresh, - toggleSort: menuActions.toggleSort, - handleSearchInput, - handleEditorInput, - saveCurrentNote: workspaceActions.saveCurrentNote, - createNewNote: workspaceActions.createNewNote, - createNoteFromTool: (input: DeclarativeNoteInput): Promise => - workspaceActions.createNoteFromTool(input), - openWorkspace: workspaceActions.openWorkspace, - exportAsJson: workspaceActions.exportAsJson, - exportAsMarkdown: workspaceActions.exportAsMarkdown, - toggleCogitoPanel: () => { - state.isCogitoModeEnabled = !state.isCogitoModeEnabled; - cogitoController.togglePanel(); - }, - selectCogitoModel: (model) => cogitoController.selectModel(model), - generateCogitoQuestions: () => cogitoController.generateQuestions(), - insertCogitoQuestion: (index) => cogitoController.insertQuestionAtIndex(index), - hideToast, - showToast, - }, - }); + 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; + } + } - loadTheme(); - applySidebarState(els, state); - updateDirtyUi(els, state, (message, kind) => setStatus(els, message, kind)); - treeRenderer.renderTree().catch((error: unknown) => { - showToast(`Failed to render tree: ${String(error)}`, { persist: true }); - setStatus(els, "Render failed", "err"); - }); + function insertQuestionAtIndex(index: number): void { + const question = generatedQuestions[index]; + if (!question) { + showToast("Cogito question not found.", { persist: true }); + return; + } + + const block = formatCogitoQuestionBlock(question); + insertTextAtCursor(els.editor, block); + onEditorContentReplaced(els.editor.value); + setStatus("Inserted AI question", "ok"); } + setPanelVisibility(false); + return { - initialize, - state, + 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, }; } @@ -9659,169 +51520,151 @@ export function createWorkspaceActions({ } - -name: release-please + +{ + ".": "1.2.1" +} + -on: - push: - branches: [main] - workflow_dispatch: + +# Contributing to ink -permissions: - contents: write - issues: write - pull-requests: write +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. -jobs: - release-please: - runs-on: ubuntu-latest +## Table of Contents - steps: - - name: Run release-please - id: release - uses: googleapis/release-please-action@v4 - with: - # A PAT is preferred here so bot-created release PRs trigger normal PR checks. - token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} - config-file: release-please-config.json - manifest-file: .release-please-manifest.json - skip-github-release: true +- [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) - - name: Detect merged release PR - id: merged_release - if: ${{ github.event_name == 'push' }} - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const {owner, repo} = context.repo; - const commitSha = context.sha; - const response = await github.request( - 'GET /repos/{owner}/{repo}/commits/{commit_sha}/pulls', - { - owner, - repo, - commit_sha: commitSha, - mediaType: { previews: ['groot'] }, - }, - ); +## Code of Conduct - const releasePr = response.data.find((pull) => - /^chore\(main\): release ink \d+\.\d+\.\d+$/.test(pull.title), - ); +Please be respectful, constructive, and patient in all project discussions. We want ink to stay welcoming to contributors of all experience levels. - if (!releasePr) { - core.setOutput('is_release_merge', 'false'); - return; - } +## Getting Started - const versionMatch = releasePr.title.match(/(\d+\.\d+\.\d+)$/); - if (!versionMatch) { - core.setFailed(`Could not determine release version from PR title: ${releasePr.title}`); - return; - } +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`. - core.setOutput('is_release_merge', 'true'); - core.setOutput('pr_number', String(releasePr.number)); - core.setOutput('version', versionMatch[1]); - core.setOutput('canonical_tag', `v${versionMatch[1]}`); - core.setOutput('compatibility_tag', `ink-v${versionMatch[1]}`); - core.setOutput('release_title', `Release v${versionMatch[1]}`); - const normalizedBody = (releasePr.body || `Release ${versionMatch[1]}`).replaceAll('ink-v', 'v'); - core.setOutput('release_body', normalizedBody); +## How to Contribute + +### 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. + +## Commit Messages + +- 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. + +## Release Process + +- 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. + +## Style Guide + +- **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`. + +## Testing and Build Checks + +Before submitting changes, run the checks that match your work: + +- `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 - - name: Checkout code - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - uses: actions/checkout@v4 - with: - fetch-depth: 0 +If your change affects favicon or branding assets, also run `npm run build:favicon`. - - name: Setup Node.js - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" +## License - - name: Install dependencies - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - run: npm ci +By contributing to this project, you agree that your contributions will be licensed under the MIT License. - - name: Build - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - run: npm run build +--- - - name: Run tests - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - run: npm test +Thank you for contributing to ink! + - - name: Create release tags - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - env: - CANONICAL_TAG: ${{ steps.merged_release.outputs.canonical_tag }} - COMPATIBILITY_TAG: ${{ steps.merged_release.outputs.compatibility_tag }} - TARGET_SHA: ${{ github.sha }} - run: | - git fetch --tags origin + +.PHONY: help lint build watch test test-qunit test-cypress update-repomix - if ! git rev-parse "$CANONICAL_TAG" >/dev/null 2>&1; then - git tag "$CANONICAL_TAG" "$TARGET_SHA" - fi +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" - if ! git rev-parse "$COMPATIBILITY_TAG" >/dev/null 2>&1; then - git tag "$COMPATIBILITY_TAG" "$TARGET_SHA" - fi +lint: + @echo "Running ESLint..." + npm run lint - if ! git ls-remote --exit-code --tags origin "refs/tags/$CANONICAL_TAG" >/dev/null 2>&1; then - git push origin "refs/tags/$CANONICAL_TAG" - fi +build: + @echo "Building the project..." + npm run build - if ! git ls-remote --exit-code --tags origin "refs/tags/$COMPATIBILITY_TAG" >/dev/null 2>&1; then - git push origin "refs/tags/$COMPATIBILITY_TAG" - fi +watch: + @echo "Watching for changes..." + npm run watch - - name: Publish GitHub release - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CANONICAL_TAG: ${{ steps.merged_release.outputs.canonical_tag }} - RELEASE_TITLE: ${{ steps.merged_release.outputs.release_title }} - RELEASE_BODY: ${{ steps.merged_release.outputs.release_body }} - run: | - printf '%s' "$RELEASE_BODY" > release-notes.md +test: test-qunit test-cypress - if gh release view "$CANONICAL_TAG" >/dev/null 2>&1; then - gh release edit "$CANONICAL_TAG" --title "$RELEASE_TITLE" --notes-file release-notes.md - gh release upload "$CANONICAL_TAG" ink-app.html --clobber - else - gh release create "$CANONICAL_TAG" \ - --target "${GITHUB_SHA}" \ - --title "$RELEASE_TITLE" \ - --notes-file release-notes.md \ - ink-app.html - fi +test-qunit: + @echo "Running QUnit tests..." + npm run test:qunit - - name: Clear autorelease labels from merged release PR - if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const {owner, repo} = context.repo; - const issue_number = Number(process.env.PR_NUMBER); - const labelsToRemove = ['autorelease: pending', 'autorelease: triggered']; +test-cypress: + @echo "Running Cypress tests..." + npm run test:cypress - for (const name of labelsToRemove) { - try { - await github.rest.issues.removeLabel({ owner, repo, issue_number, name }); - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - } - env: - PR_NUMBER: ${{ steps.merged_release.outputs.pr_number }} +repomix: + @echo "Updating repomix to the latest version..." + npx repomix@latest @@ -9938,59 +51781,224 @@ describe("ink authoring flow", () => { cy.get("[data-action=\"save\"]").click({ force: true }); cy.get("#statusBadge").should("contain", "Saved"); - cy.window().then(async (win) => { - const handle = await win.__fakeWorkspace.getFileHandle(fileName); - expect(handle.__read()).to.eq(markdown); - }); - }); + cy.window().then(async (win) => { + const handle = await win.__fakeWorkspace.getFileHandle(fileName); + expect(handle.__read()).to.eq(markdown); + }); + }); + + 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 }); + + 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."); + }); + + it("collapses and expands the left menu, updating accessibility state and editor space", () => { + let expandedEditorWidth = 0; + let collapsedEditorWidth = 0; + + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "true") + .and("contain", "▶ Collapse") + .and("be.visible"); + cy.get(".app").should("not.have.class", "sidebar-collapsed"); + + cy.get("#editor").then(($editor) => { + expandedEditorWidth = $editor[0].getBoundingClientRect().width; + }); + + cy.get("#sidebarToggleBtn").click(); + cy.get(".app").should("have.class", "sidebar-collapsed"); + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "false") + .and("contain", "▼ Expand"); + + 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); + }); + }); +}); + + + +name: release-please + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + + steps: + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + # A PAT is preferred here so bot-created release PRs trigger normal PR checks. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true + + - name: Detect merged release PR + id: merged_release + if: ${{ github.event_name == 'push' }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const {owner, repo} = context.repo; + const commitSha = context.sha; + const response = await github.request( + 'GET /repos/{owner}/{repo}/commits/{commit_sha}/pulls', + { + owner, + repo, + commit_sha: commitSha, + mediaType: { previews: ['groot'] }, + }, + ); + + const releasePr = response.data.find((pull) => + /^chore\(main\): release ink \d+\.\d+\.\d+$/.test(pull.title), + ); + + if (!releasePr) { + core.setOutput('is_release_merge', 'false'); + return; + } + + const versionMatch = releasePr.title.match(/(\d+\.\d+\.\d+)$/); + if (!versionMatch) { + core.setFailed(`Could not determine release version from PR title: ${releasePr.title}`); + return; + } + + core.setOutput('is_release_merge', 'true'); + core.setOutput('pr_number', String(releasePr.number)); + core.setOutput('version', versionMatch[1]); + core.setOutput('canonical_tag', `v${versionMatch[1]}`); + core.setOutput('compatibility_tag', `ink-v${versionMatch[1]}`); + core.setOutput('release_title', `Release v${versionMatch[1]}`); + const normalizedBody = (releasePr.body || `Release ${versionMatch[1]}`).replaceAll('ink-v', 'v'); + core.setOutput('release_body', normalizedBody); + + - name: Checkout code + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + run: npm ci + + - name: Build + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + run: npm run build - 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 }); + - name: Run tests + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + run: npm test - cy.get("#editor").clear().type("# Hello World\n\nThis is a paragraph."); + - name: Create release tags + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + env: + CANONICAL_TAG: ${{ steps.merged_release.outputs.canonical_tag }} + COMPATIBILITY_TAG: ${{ steps.merged_release.outputs.compatibility_tag }} + TARGET_SHA: ${{ github.sha }} + run: | + git fetch --tags origin - cy.get("#preview").find("h1").should("contain", "Hello World"); - cy.get("#preview").find("p").should("contain", "This is a paragraph."); - }); + if ! git rev-parse "$CANONICAL_TAG" >/dev/null 2>&1; then + git tag "$CANONICAL_TAG" "$TARGET_SHA" + fi - it("collapses and expands the left menu, updating accessibility state and editor space", () => { - let expandedEditorWidth = 0; - let collapsedEditorWidth = 0; + if ! git rev-parse "$COMPATIBILITY_TAG" >/dev/null 2>&1; then + git tag "$COMPATIBILITY_TAG" "$TARGET_SHA" + fi - cy.get("#sidebarToggleBtn") - .should("have.attr", "aria-expanded", "true") - .and("contain", "▶ Collapse") - .and("be.visible"); - cy.get(".app").should("not.have.class", "sidebar-collapsed"); + if ! git ls-remote --exit-code --tags origin "refs/tags/$CANONICAL_TAG" >/dev/null 2>&1; then + git push origin "refs/tags/$CANONICAL_TAG" + fi - cy.get("#editor").then(($editor) => { - expandedEditorWidth = $editor[0].getBoundingClientRect().width; - }); + if ! git ls-remote --exit-code --tags origin "refs/tags/$COMPATIBILITY_TAG" >/dev/null 2>&1; then + git push origin "refs/tags/$COMPATIBILITY_TAG" + fi - cy.get("#sidebarToggleBtn").click(); - cy.get(".app").should("have.class", "sidebar-collapsed"); - cy.get("#sidebarToggleBtn") - .should("have.attr", "aria-expanded", "false") - .and("contain", "▼ Expand"); + - name: Publish GitHub release + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CANONICAL_TAG: ${{ steps.merged_release.outputs.canonical_tag }} + RELEASE_TITLE: ${{ steps.merged_release.outputs.release_title }} + RELEASE_BODY: ${{ steps.merged_release.outputs.release_body }} + run: | + printf '%s' "$RELEASE_BODY" > release-notes.md - cy.get("#editor").then(($editor) => { - collapsedEditorWidth = $editor[0].getBoundingClientRect().width; - expect(collapsedEditorWidth).to.be.greaterThan(expandedEditorWidth); - }); + if gh release view "$CANONICAL_TAG" >/dev/null 2>&1; then + gh release edit "$CANONICAL_TAG" --title "$RELEASE_TITLE" --notes-file release-notes.md + gh release upload "$CANONICAL_TAG" ink-app.html --clobber + else + gh release create "$CANONICAL_TAG" \ + --target "${GITHUB_SHA}" \ + --title "$RELEASE_TITLE" \ + --notes-file release-notes.md \ + ink-app.html + fi - 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"); + - name: Clear autorelease labels from merged release PR + if: ${{ steps.merged_release.outputs.is_release_merge == 'true' }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const {owner, repo} = context.repo; + const issue_number = Number(process.env.PR_NUMBER); + const labelsToRemove = ['autorelease: pending', 'autorelease: triggered']; - cy.get("#editor").then(($editor) => { - const finalEditorWidth = $editor[0].getBoundingClientRect().width; - expect(finalEditorWidth).to.be.lessThan(collapsedEditorWidth); - }); - }); -}); + for (const name of labelsToRemove) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + env: + PR_NUMBER: ${{ steps.merged_release.outputs.pr_number }} @@ -10181,82 +52189,244 @@ describe("mobile fallback functionality", () => { }); - -# Project Context + +import { marked } from "marked"; +import { normalizeTag, parseTags } from "../tags"; +import { getDomRefs } from "./dom"; +import { ensurePermission, isFileSystemApiAvailable } from "./fs-api"; +import { createAutoRefresh } from "./auto-refresh"; +import { applyEditorViewMode, loadEditorViewMode, renderPreview, setEditorViewMode, updateDirtyUi } from "./editor-preview"; +import { createMenuActions, updateMenuShortcuts } from "./menu-actions"; +import { applySidebarState, setSidebarCollapsed } from "./sidebar"; +import { loadTheme } from "./theme"; +import { createToastController, setStatus } from "./toast-status"; +import { createTreeRenderer } from "./tree-render"; +import { attachUiEvents } from "./ui-events"; +import { createWorkspaceActions } from "./workspace-io"; +import { createCogitoController } from "./cogito"; +import { createDocumentLinterController } from "./document-linter/document-linter"; +import type { AppState, DeclarativeNoteInput, DeclarativeNoteResult, DomRefs } from "./types"; -## Purpose +type RescanWorkspaceFn = (options?: { silent?: boolean }) => Promise; -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. +export function bootstrapInkApp(): void { + const app = createAppController(getDomRefs()); + app.initialize(); +} -## Tech Stack +export function createAppController(els: DomRefs) { + const state: AppState = { + workspaceHandle: null, + workspaceName: "", + fileTree: null, + notes: [], + inMemoryNotes: [], + currentFileHandle: null, + currentRelPath: "", + currentContent: "", + isDirty: false, + searchQuery: "", + tagFilter: "", + sortMode: "name", + collapsedDirs: new Set(), + autoRefreshMs: 10000, + autoRefreshTimer: null, + isSidebarCollapsed: false, + isTemporarySession: false, + isCogitoModeEnabled: false, + editorViewMode: loadEditorViewMode(), + }; -- **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) + const toastTimerRef = { current: null as ReturnType | null }; + const { showToast, hideToast } = createToastController(els, toastTimerRef); -## Project Conventions + const treeHandlers = { + openNoteByRelPath: async () => {}, + openInMemoryNote: async () => {}, + }; -### Code Style + const treeRenderer = createTreeRenderer({ + state, + els, + handlers: treeHandlers, + showToast, + }); -- ES6+ JavaScript modules -- Minimal dependencies - prefers vanilla JavaScript -- Single-file build output (ink.html) with inlined assets -- Functional programming patterns preferred + const rescanWorkspaceRef: { current: RescanWorkspaceFn } = { + current: async () => {}, + }; -### Architecture Patterns + const autoRefresh = createAutoRefresh({ + state, + ensurePermission, + rescanWorkspace: (options) => rescanWorkspaceRef.current(options), + showToast, + setStatus: (message, kind) => setStatus(els, message, kind), + }); -- **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 + const workspaceActions = createWorkspaceActions({ + state, + els, + showToast, + setStatus: (message, kind) => setStatus(els, message, kind), + renderPreview, + updateDirtyUi, + renderTree: treeRenderer.renderTree, + renderInMemoryTree: treeRenderer.renderInMemoryTree, + renderTags: treeRenderer.renderTags, + updateCountsPill: treeRenderer.updateCountsPill, + fsApi: { + ensurePermission, + isFileSystemApiAvailable, + }, + parseTags, + normalizeTag, + autoRefresh, + }); -### Testing Strategy + rescanWorkspaceRef.current = workspaceActions.rescanWorkspace; + treeHandlers.openNoteByRelPath = workspaceActions.openNoteByRelPath; + treeHandlers.openInMemoryNote = workspaceActions.openInMemoryNote; -- No formal testing framework currently implemented -- Manual testing via browser -- Build verification through file generation + const menuActions = createMenuActions({ + state, + els, + showToast, + renderTree: treeRenderer.renderTree, + callbacks: { + createNewNote: workspaceActions.createNewNote, + createNewFolder: workspaceActions.createNewFolder, + openWorkspace: workspaceActions.openWorkspace, + closeWorkspace: workspaceActions.closeWorkspace, + saveCurrentNote: workspaceActions.saveCurrentNote, + saveAsNewNote: workspaceActions.saveAsNewNote, + handleRefresh: workspaceActions.handleRefresh, + exportAsJson: workspaceActions.exportAsJson, + exportAsMarkdown: workspaceActions.exportAsMarkdown, + setSidebarCollapsed: (isCollapsed) => setSidebarCollapsed(els, state, isCollapsed), + }, + }); -### Git Workflow + function handleSearchInput(value: string): void { + state.searchQuery = value; + treeRenderer.renderTree().catch((error: unknown) => { + showToast(`Search render failed: ${String(error)}`, { persist: true }); + }); + } -- Simple trunk-based development -- Commits should be descriptive of changes -- Main branch contains production-ready code + function handleEditorInput(value: string): void { + state.isDirty = value !== state.currentContent; + updateDirtyUi(els, state, (message, kind) => setStatus(els, message, kind)); + renderPreview(els, value); + documentLinterController.handleEditorChanged(value); + } + + function openCogitoPanel(): void { + documentLinterController.closePanel(); + state.isCogitoModeEnabled = true; + cogitoController.setPanelOpen(true); + } + + function closeCogitoPanel(): void { + state.isCogitoModeEnabled = false; + cogitoController.closePanel(); + } -## Domain Context + function openDocumentLinterPanel(): void { + if (cogitoController.isPanelOpen()) { + closeCogitoPanel(); + } + documentLinterController.setPanelOpen(true); + } -- **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 + function closeDocumentLinterPanel(): void { + documentLinterController.closePanel(); + } -## Important Constraints + const cogitoController = createCogitoController({ + els, + getEditorText: () => els.editor.value, + onEditorContentReplaced: (text) => handleEditorInput(text), + showToast, + setStatus: (message, kind) => setStatus(els, message, kind), + }); + + const documentLinterController = createDocumentLinterController({ + els, + getEditorText: () => els.editor.value, + onEditorContentReplaced: (text) => handleEditorInput(text), + showToast, + setStatus: (message, kind) => setStatus(els, message, kind), + }); -- **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 + function initialize(): void { + marked.use({ breaks: true }); -## Keyboard Shortcuts + const isMac = navigator.platform.toLowerCase().includes("mac"); + updateMenuShortcuts(els, isMac); -- **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 + attachUiEvents({ + state, + els, + actions: { + handleMenuAction: menuActions.handleMenuAction, + toggleSidebar: () => setSidebarCollapsed(els, state, !state.isSidebarCollapsed), + handleRefresh: workspaceActions.handleRefresh, + toggleSort: menuActions.toggleSort, + handleSearchInput, + handleEditorInput, + saveCurrentNote: workspaceActions.saveCurrentNote, + createNewNote: workspaceActions.createNewNote, + createNoteFromTool: (input: DeclarativeNoteInput): Promise => + workspaceActions.createNoteFromTool(input), + openWorkspace: workspaceActions.openWorkspace, + exportAsJson: workspaceActions.exportAsJson, + exportAsMarkdown: workspaceActions.exportAsMarkdown, + toggleCogitoPanel: () => { + if (cogitoController.isPanelOpen()) { + closeCogitoPanel(); + return; + } + openCogitoPanel(); + }, + selectCogitoModel: (model) => cogitoController.selectModel(model), + generateCogitoQuestions: () => cogitoController.generateQuestions(), + insertCogitoQuestion: (index) => cogitoController.insertQuestionAtIndex(index), + setEditorViewMode: (mode) => setEditorViewMode(els, state, mode), + toggleDocumentLinterPanel: () => { + if (documentLinterController.isPanelOpen()) { + closeDocumentLinterPanel(); + return; + } + openDocumentLinterPanel(); + }, + analyzeDocument: () => documentLinterController.analyzeDocument(), + exportDocumentLinterSuggestions: () => documentLinterController.exportSuggestions(), + hideToast, + showToast, + }, + }); -## External Dependencies + loadTheme(); + applySidebarState(els, state); + applyEditorViewMode(els, state.editorViewMode); + updateDirtyUi(els, state, (message, kind) => setStatus(els, message, kind)); + treeRenderer.renderTree().catch((error: unknown) => { + showToast(`Failed to render tree: ${String(error)}`, { persist: true }); + setStatus(els, "Render failed", "err"); + }); + } -- **Marked.js**: Markdown parser library (embedded in build) -- **No External APIs**: Application works completely offline -- **No Backend**: No server-side components required + return { + initialize, + state, + }; +} import type { AppState, DeclarativeNoteInput, DeclarativeNoteResult, DomRefs } from "./types"; +import type { EditorViewMode } from "./editor-preview"; type ToastFn = (message: string, options?: { persist?: boolean }) => void; @@ -10277,6 +52447,10 @@ export type UiActions = { selectCogitoModel: (model: "lite" | "deep") => void; generateCogitoQuestions: () => Promise; insertCogitoQuestion: (index: number) => void; + setEditorViewMode: (mode: EditorViewMode) => void; + toggleDocumentLinterPanel: () => void; + analyzeDocument: () => Promise; + exportDocumentLinterSuggestions: () => Promise; hideToast: () => void; showToast: ToastFn; }; @@ -10504,6 +52678,41 @@ export function attachUiEvents({ actions.insertCogitoQuestion(index); }); + els.editorViewModeGroup.addEventListener("click", (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + const button = target.closest("[data-editor-view-mode]"); + if (!button) { + return; + } + + const mode = button.getAttribute("data-editor-view-mode") as EditorViewMode | null; + if (!mode) { + return; + } + + actions.setEditorViewMode(mode); + }); + + els.documentLinterToggleBtn.addEventListener("click", () => { + actions.toggleDocumentLinterPanel(); + }); + + els.documentLinterAnalyzeBtn.addEventListener("click", () => { + actions.analyzeDocument().catch((error: unknown) => { + actions.showToast(`Document analysis failed: ${String(error)}`, { persist: true }); + }); + }); + + els.documentLinterExportBtn.addEventListener("click", () => { + actions.exportDocumentLinterSuggestions().catch((error: unknown) => { + actions.showToast(`Document export failed: ${String(error)}`, { persist: true }); + }); + }); + window.addEventListener("beforeunload", (event: BeforeUnloadEvent) => { if (state.isDirty) { event.preventDefault(); @@ -10705,97 +52914,6 @@ function attachGlobalKeyboardShortcuts(actions: UiActions): void { } - -.PHONY: help lint build watch test test-qunit test-cypress update-repomix - -help: - @echo "Available targets:" - @echo " lint - Run ESLint" - @echo " build - Build the project" - @echo " watch - Watch for changes and rebuild" - @echo " test - Run all tests (QUnit and Cypress)" - @echo " test-qunit - Run QUnit tests" - @echo " test-cypress - Run Cypress tests" - @echo " repomix - Update repomix to the latest version" - -lint: - @echo "Running ESLint..." - npm run lint - -build: - @echo "Building the project..." - npm run build - -watch: - @echo "Watching for changes..." - npm run watch - -test: test-qunit test-cypress - -test-qunit: - @echo "Running QUnit tests..." - npm run test:qunit - -test-cypress: - @echo "Running Cypress tests..." - npm run test:cypress - -repomix: - @echo "Updating repomix to the latest version..." - npx repomix@latest - - - -import type { DomRefs } from "./types"; - -function requiredElement(id: string): T { - const element = document.getElementById(id); - if (!element) { - throw new Error(`Missing required element: #${id}`); - } - return element as unknown as T; -} - -export function getDomRefs(): DomRefs { - return { - app: requiredElement("app"), - menuBar: requiredElement("menuBar"), - workspaceSidebar: requiredElement("workspaceSidebar"), - sidebarToggleBtn: requiredElement("sidebarToggleBtn"), - refreshBtn: requiredElement("refreshBtn"), - sortBtn: requiredElement("sortBtn"), - searchInput: requiredElement("searchInput"), - webmcpNoteModal: requiredElement("webmcpNoteModal"), - webmcpNoteModalBackdrop: requiredElement("webmcpNoteModalBackdrop"), - webmcpNoteModalCloseBtn: requiredElement("webmcpNoteModalCloseBtn"), - webmcpNoteForm: requiredElement("webmcpNoteForm"), - webmcpTitleInput: requiredElement("webmcpTitleInput"), - webmcpBodyInput: requiredElement("webmcpBodyInput"), - webmcpTagInput: requiredElement("webmcpTagInput"), - tree: requiredElement("tree"), - tagRow: requiredElement("tagRow"), - workspaceName: requiredElement("workspaceName"), - countsPill: requiredElement("countsPill"), - editor: requiredElement("editor"), - preview: requiredElement("preview"), - currentFilename: requiredElement("currentFilename"), - dirtyDot: requiredElement("dirtyDot"), - statusBadge: requiredElement("statusBadge"), - toast: requiredElement("toast"), - toastMsg: requiredElement("toastMsg"), - toastCloseBtn: requiredElement("toastCloseBtn"), - temporarySessionBadge: requiredElement("temporarySessionBadge"), - cogitoToggleBtn: requiredElement("cogitoToggleBtn"), - cogitoPanel: requiredElement("cogitoPanel"), - cogitoLiteBtn: requiredElement("cogitoLiteBtn"), - cogitoDeepBtn: requiredElement("cogitoDeepBtn"), - cogitoGenerateBtn: requiredElement("cogitoGenerateBtn"), - cogitoStatus: requiredElement("cogitoStatus"), - cogitoQuestionList: requiredElement("cogitoQuestionList"), - }; -} - - # ink

    @@ -10912,6 +53030,71 @@ The Cypress flow test verifies: - **Ctrl/Cmd + Shift + M**: Export the current note as Markdown + +import type { DomRefs } from "./types"; + +function requiredElement(id: string): T { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Missing required element: #${id}`); + } + return element as unknown as T; +} + +export function getDomRefs(): DomRefs { + return { + app: requiredElement("app"), + menuBar: requiredElement("menuBar"), + workspaceSidebar: requiredElement("workspaceSidebar"), + sidebarToggleBtn: requiredElement("sidebarToggleBtn"), + refreshBtn: requiredElement("refreshBtn"), + sortBtn: requiredElement("sortBtn"), + searchInput: requiredElement("searchInput"), + webmcpNoteModal: requiredElement("webmcpNoteModal"), + webmcpNoteModalBackdrop: requiredElement("webmcpNoteModalBackdrop"), + webmcpNoteModalCloseBtn: requiredElement("webmcpNoteModalCloseBtn"), + webmcpNoteForm: requiredElement("webmcpNoteForm"), + webmcpTitleInput: requiredElement("webmcpTitleInput"), + webmcpBodyInput: requiredElement("webmcpBodyInput"), + webmcpTagInput: requiredElement("webmcpTagInput"), + tree: requiredElement("tree"), + tagRow: requiredElement("tagRow"), + workspaceName: requiredElement("workspaceName"), + countsPill: requiredElement("countsPill"), + editor: requiredElement("editor"), + editorSplit: requiredElement("editorSplit"), + editorPane: requiredElement("editorPane"), + previewPane: requiredElement("previewPane"), + editorViewModeGroup: requiredElement("editorViewModeGroup"), + editorViewSourceBtn: requiredElement("editorViewSourceBtn"), + editorViewSplitBtn: requiredElement("editorViewSplitBtn"), + editorViewPreviewBtn: requiredElement("editorViewPreviewBtn"), + preview: requiredElement("preview"), + currentFilename: requiredElement("currentFilename"), + dirtyDot: requiredElement("dirtyDot"), + statusBadge: requiredElement("statusBadge"), + toast: requiredElement("toast"), + toastMsg: requiredElement("toastMsg"), + toastCloseBtn: requiredElement("toastCloseBtn"), + temporarySessionBadge: requiredElement("temporarySessionBadge"), + cogitoToggleBtn: requiredElement("cogitoToggleBtn"), + cogitoPanel: requiredElement("cogitoPanel"), + cogitoLiteBtn: requiredElement("cogitoLiteBtn"), + cogitoDeepBtn: requiredElement("cogitoDeepBtn"), + cogitoGenerateBtn: requiredElement("cogitoGenerateBtn"), + cogitoStatus: requiredElement("cogitoStatus"), + cogitoQuestionList: requiredElement("cogitoQuestionList"), + documentLinterToggleBtn: requiredElement("documentLinterToggleBtn"), + documentLinterPanel: requiredElement("documentLinterPanel"), + documentLinterAnalyzeBtn: requiredElement("documentLinterAnalyzeBtn"), + documentLinterExportBtn: requiredElement("documentLinterExportBtn"), + documentLinterAutoRunToggle: requiredElement("documentLinterAutoRunToggle"), + documentLinterStatus: requiredElement("documentLinterStatus"), + documentLinterResults: requiredElement("documentLinterResults"), + }; +} + + export interface PermissionCapableHandle { queryPermission?: (descriptor: { mode: "read" | "readwrite" }) => Promise<"granted" | "denied" | "prompt">; @@ -11007,6 +53190,7 @@ export interface AppState { isSidebarCollapsed: boolean; isTemporarySession: boolean; isCogitoModeEnabled: boolean; + editorViewMode: "split" | "source" | "preview"; } export interface DomRefs { @@ -11029,6 +53213,13 @@ export interface DomRefs { workspaceName: HTMLElement; countsPill: HTMLElement; editor: HTMLTextAreaElement; + editorSplit: HTMLElement; + editorPane: HTMLElement; + previewPane: HTMLElement; + editorViewModeGroup: HTMLElement; + editorViewSourceBtn: HTMLButtonElement; + editorViewSplitBtn: HTMLButtonElement; + editorViewPreviewBtn: HTMLButtonElement; preview: HTMLElement; currentFilename: HTMLElement; dirtyDot: HTMLElement; @@ -11044,6 +53235,13 @@ export interface DomRefs { cogitoGenerateBtn: HTMLButtonElement; cogitoStatus: HTMLElement; cogitoQuestionList: HTMLElement; + documentLinterToggleBtn: HTMLButtonElement; + documentLinterPanel: HTMLElement; + documentLinterAnalyzeBtn: HTMLButtonElement; + documentLinterExportBtn: HTMLButtonElement; + documentLinterAutoRunToggle: HTMLInputElement; + documentLinterStatus: HTMLElement; + documentLinterResults: HTMLElement; } declare global { @@ -11444,6 +53642,40 @@ declare global { min-height: 54px; } + .viewModePicker{ + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + flex: 0 0 auto; + } + + .viewModeBtn{ + border: 0; + background: transparent; + color: var(--muted); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + line-height: 1; + font-weight: 700; + white-space: nowrap; + } + + .viewModeBtn:hover{ + background: rgba(255, 255, 255, 0.06); + color: var(--text); + } + + .viewModeBtn.active{ + background: rgba(94, 234, 212, 0.16); + color: var(--text); + box-shadow: inset 0 0 0 1px rgba(94, 234, 212, 0.35); + } + .filename{ font-weight: 800; letter-spacing: 0.2px; @@ -11495,7 +53727,20 @@ declare global { grid-template-columns: 1fr 1fr; } - .split.with-cogito{ + .split.view-source, + .split.view-preview{ + grid-template-columns: 1fr; + } + + .split.view-source.with-cogito, + .split.view-source.with-document-linter, + .split.view-preview.with-cogito, + .split.view-preview.with-document-linter{ + grid-template-columns: 1fr 320px; + } + + .split.view-split.with-cogito, + .split.view-split.with-document-linter{ grid-template-columns: 1fr 1fr 320px; } @@ -11506,9 +53751,24 @@ declare global { display: flex; flex-direction: column; } - .editorPane{ - border-right: 1px solid var(--border); - background: var(--panel2); + .editorPane{ + border-right: 1px solid var(--border); + background: var(--panel2); + } + + .split.view-source .editorPane{ + border-right: 0; + } + + .split.view-preview .previewPane{ + border-left: 0; + } + + .editorPane[hidden], + .previewPane[hidden], + .cogitoPanel[hidden], + .documentLinterPanel[hidden]{ + display: none !important; } textarea{ @@ -11547,8 +53807,194 @@ declare global { overflow: auto; } - .cogitoPanel[hidden]{ - display: none; + .cogitoPanel[hidden]{ + display: none; + } + + .documentLinterPanel{ + min-height: 0; + border-left: 1px solid var(--border); + background: var(--panel2); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: auto; + } + + .documentLinterPanel[hidden]{ + display: none; + } + + .documentLinterHeader{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0; + padding: 0; + border: 0; + min-height: 0; + } + + .documentLinterAutoRun{ + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); + user-select: none; + } + + .documentLinterAutoRun input{ + margin: 0; + } + + .documentLinterResults{ + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 12px; + } + + .documentLinterSummary{ + display: grid; + gap: 10px; + margin-bottom: 10px; + } + + .documentLinterSummaryBlock{ + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + } + + .documentLinterSummaryBlock h3{ + margin: 0 0 8px; + font-size: 13px; + font-weight: 700; + } + + .documentLinterSummaryBlock ul, + .documentLinterSummaryBlock ol{ + margin: 0; + padding-left: 18px; + display: grid; + gap: 6px; + } + + .documentLinterCategory{ + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + margin: 0; + } + + .documentLinterCategoryHeader{ + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + } + + .documentLinterCategoryHeader h3{ + margin: 0; + font-size: 13px; + font-weight: 600; + } + + .documentLinterScore{ + font-size: 14px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + } + + .documentLinterSuggestions{ + display: flex; + flex-direction: column; + gap: 4px; + } + + .documentLinterSuggestion{ + font-size: 12px; + line-height: 1.4; + } + + .documentLinterLineButton{ + border: 0; + background: transparent; + color: var(--accent); + padding: 0; + font: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + } + + .documentLinterLineButton:hover, + .documentLinterLineButton:focus-visible{ + color: var(--text); + outline: none; + } + + .documentLinterSectionAnalysis{ + display: grid; + gap: 10px; + margin-top: 2px; + padding-top: 8px; + } + + .documentLinterSectionAnalysis h3{ + margin: 0; + font-size: 13px; + font-weight: 700; + } + + .documentLinterSectionList{ + display: grid; + gap: 10px; + } + + .documentLinterSectionCard{ + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + display: grid; + gap: 8px; + } + + .documentLinterSectionHeader{ + display: flex; + justify-content: space-between; + gap: 10px; + align-items: baseline; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + } + + .documentLinterSectionHeader h4{ + margin: 0; + font-size: 12px; + font-weight: 700; + } + + .documentLinterSectionHeader span{ + font-size: 11px; + color: var(--muted); + white-space: nowrap; + } + + .documentLinterSectionNotes{ + display: grid; + gap: 4px; } .cogitoHeader{ @@ -11700,6 +54146,10 @@ declare global { font-size: 12px; } + .menu-action-btn + .menu-action-btn{ + margin-left: 8px; + } + .menu-action-label{ font-weight: 700; } @@ -11945,8 +54395,15 @@ declare global { .app.sidebar-collapsed{ grid-template-columns: 1fr; } - .split{ grid-template-columns: 1fr; } - .split.with-cogito{ grid-template-columns: 1fr; } + .split, + .split.view-source, + .split.view-preview, + .split.view-split.with-cogito, + .split.view-split.with-document-linter, + .split.view-source.with-cogito, + .split.view-source.with-document-linter, + .split.view-preview.with-cogito, + .split.view-preview.with-document-linter{ grid-template-columns: 1fr; } .editorPane{ border-right: none; border-bottom: 1px solid var(--border); @@ -11958,6 +54415,11 @@ declare global { border-top: 1px solid var(--border); min-height: 30vh; } + .documentLinterPanel{ + border-left: none; + border-top: 1px solid var(--border); + min-height: 30vh; + } .filename{ max-width: 70vw; } .sidebarToggle{ position: static; @@ -11998,7 +54460,7 @@ declare global { { "name": "ink", - "version": "1.2.0", + "version": "1.2.1", "description": "Ink is a functional and minimalistic webapp to write documents in markdown and export them", "homepage": "https://github.com/feddernico/ink#readme", "bugs": { @@ -12032,7 +54494,7 @@ declare global { "cypress": "^15.12.0", "esbuild": "^0.27.3", "eslint": "^10.1.0", - "globals": "^17.4.0", + "globals": "^17.6.0", "http-server": "^14.1.1", "nyc": "^18.0.0", "qunit": "^2.24.2", @@ -12049,35 +54511,6 @@ declare global { } - -# Copilot Instructions - -- Maintainability work should follow the refactor plan in `openspec/changes/refactor-simplification-plan/plan.md`. -- The refactor implementation proposal lives in `openspec/changes/refactor-app-controller-modules/` and should be followed before touching app controller or build scripts. -- Prefer small, explicit modules with flat control flow and minimal indirection. -- Keep `src/app.ts` as a tiny entrypoint and preserve single-file build output (`ink-app.html`). -- Local git hooks are tracked under `.githooks/`; keep lint-plus-repomix pre-commit automation there and point setup docs at `git config core.hooksPath .githooks` instead of ad hoc hook creation snippets. -- Regression coverage for refactor-sensitive flows lives in `cypress/e2e/workspace-actions.cy.js` and `tests/qunit/fs-api.test.js`. -- The app controller is in `src/app/app-controller.ts`, with UI wiring in `src/app/ui-events.ts` and workspace flows in `src/app/workspace-io.ts`. -- Top menu visuals are authored directly in `ink.template.html`; when adding icons to dropdown rows, keep the text in a nested `.menu-label-text` span so dynamic updates like the Sort label do not remove the icon. -- Keyboard shortcut precedence lives in `src/app/ui-events.ts`; editor-scoped handlers must not swallow longer chords like Cmd/Ctrl+Shift+S that are reserved for export. -- Test bundles are built via `build/build-test.js`. -- Dependency security overrides live in `package.json` under `overrides` (immutable pinned to 5.1.5+). -- GitHub workflows should stay triggerable for PRs and use job-level docs-only gating rather than workflow-level path filters so required checks do not remain pending. -- Release automation is handled by `release-please`; keep `release-please-config.json` and `.release-please-manifest.json` aligned with the latest published tag, keep root tags in the existing plain `v*` format, and prefer Conventional Commit subjects (`fix:`, `feat:`, `deps:`) so release PRs are generated correctly. -- Merged release PRs are finalized by `.github/workflows/release.yml`: the workflow detects `chore(main): release ink x.y.z` merges, creates the canonical `v*` tag plus a compatibility `ink-v*` tag, publishes the GitHub release from `v*`, and clears stale `autorelease:*` labels so the next release PR is not blocked. -- Protected-branch repos should prefer a `RELEASE_PLEASE_TOKEN` PAT for the release workflow so bot-created release PRs trigger the normal PR checks. -- Contributor-facing workflow and validation steps now live in `CONTRIBUTING.md`; keep build, test, and single-file output guidance aligned with that document when project workflows change. -- Declarative WebMCP note creation lives in the `#webmcpNoteForm` form in `ink.template.html`; keep its submit path wired to `createNoteFromTool()` in `src/app/workspace-io.ts` so agent-invoked note creation stays in the single-page app and does not navigate away. -- Cogito Mode work is tracked in `openspec/changes/add-cogito-mode/`; preserve the strict three-question JSON contract and the markdown insertion block format (`> ### AI` then question text) when implementing it. -- Cogito Mode entrypoint must be a top-right menu bar button using a thinking-man glyphicon with accessible Cogito labeling. -- Cogito runtime integration lives in `src/app/cogito.ts`; keep the prompt contract and JSON parsing helpers (`extractLastSentence`, `parseCogitoQuestionPayload`, `formatCogitoQuestionBlock`) stable and covered by QUnit tests. -- Cypress end-to-end coverage for Cogito uses a test-only `globalThis.__INK_TEST_WEBLLM__` override; preserve that seam so the full panel/generate/insert flow remains deterministic under test. -- 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. - - @@ -12261,19 +54694,31 @@ declare global { -

    - + + + @@ -12332,16 +54777,28 @@ declare global { No note open +
    + + + +
    +
    Ready
    -
    -
    +
    +
    -
    +
    -
    - + + +
    +
    @@ -12441,6 +54911,41 @@ declare global { + +# Copilot Instructions + +- Maintainability work should follow the refactor plan in `openspec/changes/refactor-simplification-plan/plan.md`. +- The refactor implementation proposal lives in `openspec/changes/refactor-app-controller-modules/` and should be followed before touching app controller or build scripts. +- Prefer small, explicit modules with flat control flow and minimal indirection. +- Keep `src/app.ts` as a tiny entrypoint and preserve single-file build output (`ink-app.html`). +- Local git hooks are tracked under `.githooks/`; keep lint-plus-repomix pre-commit automation there and point setup docs at `git config core.hooksPath .githooks` instead of ad hoc hook creation snippets. +- Regression coverage for refactor-sensitive flows lives in `cypress/e2e/workspace-actions.cy.js` and `tests/qunit/fs-api.test.js`. +- The app controller is in `src/app/app-controller.ts`, with UI wiring in `src/app/ui-events.ts` and workspace flows in `src/app/workspace-io.ts`. +- Top menu visuals are authored directly in `ink.template.html`; when adding icons to dropdown rows, keep the text in a nested `.menu-label-text` span so dynamic updates like the Sort label do not remove the icon. +- Keyboard shortcut precedence lives in `src/app/ui-events.ts`; editor-scoped handlers must not swallow longer chords like Cmd/Ctrl+Shift+S that are reserved for export. +- Test bundles are built via `build/build-test.js`. +- Dependency security overrides live in `package.json` under `overrides` (immutable pinned to 5.1.5+). +- GitHub workflows should stay triggerable for PRs and use job-level docs-only gating rather than workflow-level path filters so required checks do not remain pending. +- Release automation is handled by `release-please`; keep `release-please-config.json` and `.release-please-manifest.json` aligned with the latest published tag, keep root tags in the existing plain `v*` format, and prefer Conventional Commit subjects (`fix:`, `feat:`, `deps:`) so release PRs are generated correctly. +- Merged release PRs are finalized by `.github/workflows/release.yml`: the workflow detects `chore(main): release ink x.y.z` merges, creates the canonical `v*` tag plus a compatibility `ink-v*` tag, publishes the GitHub release from `v*`, and clears stale `autorelease:*` labels so the next release PR is not blocked. +- Protected-branch repos should prefer a `RELEASE_PLEASE_TOKEN` PAT for the release workflow so bot-created release PRs trigger the normal PR checks. +- Contributor-facing workflow and validation steps now live in `CONTRIBUTING.md`; keep build, test, and single-file output guidance aligned with that document when project workflows change. +- Declarative WebMCP note creation lives in the `#webmcpNoteForm` form in `ink.template.html`; keep its submit path wired to `createNoteFromTool()` in `src/app/workspace-io.ts` so agent-invoked note creation stays in the single-page app and does not navigate away. +- Cogito Mode work is tracked in `openspec/changes/add-cogito-mode/`; preserve the strict three-question JSON contract and the markdown insertion block format (`> ### AI` then question text) when implementing it. +- Cogito Mode entrypoint must be a top-right menu bar button using a thinking-man glyphicon with accessible Cogito labeling. +- Cogito runtime integration lives in `src/app/cogito.ts`; keep the prompt contract and JSON parsing helpers (`extractLastSentence`, `parseCogitoQuestionPayload`, `formatCogitoQuestionBlock`) stable and covered by QUnit tests. +- Cypress end-to-end coverage for Cogito uses a test-only `globalThis.__INK_TEST_WEBLLM__` override; preserve that seam so the full panel/generate/insert flow remains deterministic under test. +- 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. + + @@ -12452,7 +54957,7 @@ declare global { @@ -12624,19 +55129,31 @@ declare global {
    - - + + + @@ -12695,16 +55212,28 @@ declare global { No note open +
    + + + +
    +
    Ready
    -
    -
    +
    +
    -
    +
    -
    - + + +
    +
    @@ -12798,86 +55340,86 @@ declare global {
    diff --git a/src/app/app-controller.ts b/src/app/app-controller.ts index 56551db..433793d 100644 --- a/src/app/app-controller.ts +++ b/src/app/app-controller.ts @@ -3,7 +3,7 @@ import { normalizeTag, parseTags } from "../tags"; import { getDomRefs } from "./dom"; import { ensurePermission, isFileSystemApiAvailable } from "./fs-api"; import { createAutoRefresh } from "./auto-refresh"; -import { renderPreview, updateDirtyUi } from "./editor-preview"; +import { applyEditorViewMode, loadEditorViewMode, renderPreview, setEditorViewMode, updateDirtyUi } from "./editor-preview"; import { createMenuActions, updateMenuShortcuts } from "./menu-actions"; import { applySidebarState, setSidebarCollapsed } from "./sidebar"; import { loadTheme } from "./theme"; @@ -12,6 +12,7 @@ import { createTreeRenderer } from "./tree-render"; import { attachUiEvents } from "./ui-events"; import { createWorkspaceActions } from "./workspace-io"; import { createCogitoController } from "./cogito"; +import { createDocumentLinterController } from "./document-linter/document-linter"; import type { AppState, DeclarativeNoteInput, DeclarativeNoteResult, DomRefs } from "./types"; type RescanWorkspaceFn = (options?: { silent?: boolean }) => Promise; @@ -41,6 +42,7 @@ export function createAppController(els: DomRefs) { isSidebarCollapsed: false, isTemporarySession: false, isCogitoModeEnabled: false, + editorViewMode: loadEditorViewMode(), }; const toastTimerRef = { current: null as ReturnType | null }; @@ -124,15 +126,46 @@ export function createAppController(els: DomRefs) { state.isDirty = value !== state.currentContent; updateDirtyUi(els, state, (message, kind) => setStatus(els, message, kind)); renderPreview(els, value); + documentLinterController.handleEditorChanged(value); } - const cogitoController = createCogitoController({ - els, - getEditorText: () => els.editor.value, - onEditorContentReplaced: (text) => handleEditorInput(text), - showToast, - setStatus: (message, kind) => setStatus(els, message, kind), - }); + function openCogitoPanel(): void { + documentLinterController.closePanel(); + state.isCogitoModeEnabled = true; + cogitoController.setPanelOpen(true); + } + + function closeCogitoPanel(): void { + state.isCogitoModeEnabled = false; + cogitoController.closePanel(); + } + + function openDocumentLinterPanel(): void { + if (cogitoController.isPanelOpen()) { + closeCogitoPanel(); + } + documentLinterController.setPanelOpen(true); + } + + function closeDocumentLinterPanel(): void { + documentLinterController.closePanel(); + } + + const cogitoController = createCogitoController({ + els, + getEditorText: () => els.editor.value, + onEditorContentReplaced: (text) => handleEditorInput(text), + showToast, + setStatus: (message, kind) => setStatus(els, message, kind), + }); + + const documentLinterController = createDocumentLinterController({ + els, + getEditorText: () => els.editor.value, + onEditorContentReplaced: (text) => handleEditorInput(text), + showToast, + setStatus: (message, kind) => setStatus(els, message, kind), + }); function initialize(): void { marked.use({ breaks: true }); @@ -146,31 +179,45 @@ export function createAppController(els: DomRefs) { actions: { handleMenuAction: menuActions.handleMenuAction, toggleSidebar: () => setSidebarCollapsed(els, state, !state.isSidebarCollapsed), - handleRefresh: workspaceActions.handleRefresh, - toggleSort: menuActions.toggleSort, - handleSearchInput, - handleEditorInput, - saveCurrentNote: workspaceActions.saveCurrentNote, - createNewNote: workspaceActions.createNewNote, - createNoteFromTool: (input: DeclarativeNoteInput): Promise => - workspaceActions.createNoteFromTool(input), - openWorkspace: workspaceActions.openWorkspace, - exportAsJson: workspaceActions.exportAsJson, + handleRefresh: workspaceActions.handleRefresh, + toggleSort: menuActions.toggleSort, + handleSearchInput, + handleEditorInput, + saveCurrentNote: workspaceActions.saveCurrentNote, + createNewNote: workspaceActions.createNewNote, + createNoteFromTool: (input: DeclarativeNoteInput): Promise => + workspaceActions.createNoteFromTool(input), + openWorkspace: workspaceActions.openWorkspace, + exportAsJson: workspaceActions.exportAsJson, exportAsMarkdown: workspaceActions.exportAsMarkdown, toggleCogitoPanel: () => { - state.isCogitoModeEnabled = !state.isCogitoModeEnabled; - cogitoController.togglePanel(); - }, - selectCogitoModel: (model) => cogitoController.selectModel(model), - generateCogitoQuestions: () => cogitoController.generateQuestions(), - insertCogitoQuestion: (index) => cogitoController.insertQuestionAtIndex(index), - hideToast, - showToast, + if (cogitoController.isPanelOpen()) { + closeCogitoPanel(); + return; + } + openCogitoPanel(); + }, + selectCogitoModel: (model) => cogitoController.selectModel(model), + generateCogitoQuestions: () => cogitoController.generateQuestions(), + insertCogitoQuestion: (index) => cogitoController.insertQuestionAtIndex(index), + setEditorViewMode: (mode) => setEditorViewMode(els, state, mode), + toggleDocumentLinterPanel: () => { + if (documentLinterController.isPanelOpen()) { + closeDocumentLinterPanel(); + return; + } + openDocumentLinterPanel(); + }, + analyzeDocument: () => documentLinterController.analyzeDocument(), + exportDocumentLinterSuggestions: () => documentLinterController.exportSuggestions(), + hideToast, + showToast, }, }); loadTheme(); applySidebarState(els, state); + applyEditorViewMode(els, state.editorViewMode); updateDirtyUi(els, state, (message, kind) => setStatus(els, message, kind)); treeRenderer.renderTree().catch((error: unknown) => { showToast(`Failed to render tree: ${String(error)}`, { persist: true }); diff --git a/src/app/cogito.ts b/src/app/cogito.ts index 7d67680..29767ef 100644 --- a/src/app/cogito.ts +++ b/src/app/cogito.ts @@ -43,6 +43,9 @@ 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; @@ -279,6 +282,16 @@ export function createCogitoController({ 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, diff --git a/src/app/document-linter/document-linter.ts b/src/app/document-linter/document-linter.ts new file mode 100644 index 0000000..5fcb685 --- /dev/null +++ b/src/app/document-linter/document-linter.ts @@ -0,0 +1,1394 @@ +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; + +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 = { + togglePanel: () => void; + setPanelOpen: (isOpen: boolean) => void; + isPanelOpen: () => boolean; + closePanel: () => void; + handleEditorChanged: (textSnapshot?: string) => Promise; + analyzeDocument: () => Promise; + exportSuggestions: () => Promise; +}; + +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" }, +]; + +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 blocks; +} + +function getBlocksOfType(blocks: MarkdownBlock[], type: MarkdownBlock["type"]): MarkdownBlock[] { + return blocks.filter((block) => block.type === type); +} + +function getPlainTextBlocks(blocks: MarkdownBlock[]): Array> { + return blocks.filter((block): block is Extract => { + return block.type === "paragraph" || block.type === "blockquote"; + }); +} + +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 }; +} + +function createFinding( + category: LinterCategoryId, + severity: Severity, + title: string, + detail: string, + section: string, + isStrength = false, +): Finding { + return { category, severity, title, detail, section, isStrength }; +} + +function summarizeOpeners(blocks: MarkdownBlock[]): string { + const firstParagraph = blocks.find((block) => block.type === "paragraph"); + if (!firstParagraph) { + return openingNeedsStructure(); + } + + if (countWords(firstParagraph.text) >= 22) { + return openingTooDense(); + } + + if (/^(this|these)\b/i.test(firstParagraph.text)) { + return openingIsInformativeNotDirective(); + } + + return openingNeedsStrongerLead(); +} + +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; +} + +export function buildDocumentSections(blocks: MarkdownBlock[]): DocumentSection[] { + const sections: DocumentSection[] = []; + let currentSection: DocumentSection | null = null; + + 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; + } + + function ensureLeadSection(block: MarkdownBlock): DocumentSection { + if (currentSection) { + return currentSection; + } + return startSection("Lead", "implicit", block.lineStart); + } + + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + const nextBlock = blocks[i + 1]; + + if (block.type === "heading") { + startSection(block.text, "heading", block.lineStart); + continue; + } + + 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 (sections.length === 0) { + return [ + { + title: "Document", + kind: "implicit", + lineStart: 1, + lineEnd: 1, + blocks: [], + }, + ]; + } + + return sections; +} + +function analyzeReadability(blocks: MarkdownBlock[]): { result: LinterCategoryResult; findings: Finding[] } { + const proseBlocks = getPlainTextBlocks(blocks); + const findings: Finding[] = []; + let longSentenceCount = 0; + let repeatedWordCount = 0; + + 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 openingBlock = proseBlocks[0]; + if (openingBlock && countWords(openingBlock.text) >= 22) { + findings.unshift( + createFinding( + "readability", + "high", + "Opening is dense", + openingTooDense(), + `Line ${openingBlock.lineStart}`, + ), + ); + } + + const score = clampScore( + 100 - + longSentenceCount * 14 - + repeatedWordCount * 10 - + (openingBlock && countWords(openingBlock.text) >= 22 ? 8 : 0), + ); + + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + }; +} + +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 score = clampScore( + 100 - + (headings.length === 0 ? 20 : 0) - + (pseudoSectionLabel ? 6 : 0) - + (bullets.length >= 4 ? 10 : 0), + ); + + return { + result: { + score, + suggestions: findings.map((finding) => `${finding.title}: ${finding.detail}`), + }, + findings, + pseudoSectionLabel, + }; +} + +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); + + 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}`, + ), + ); + } + + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "engagement", + "high", + "Too little context", + thinDraft(), + `Line ${firstParagraph?.lineStart ?? 1}`, + ), + ); + } + + if (hasOpeningQuestion && totalWords < 18) { + findings.push( + createFinding( + "engagement", + "medium", + "Question needs payoff", + questionNeedsAnswer(), + `Line ${firstParagraph?.lineStart ?? 1}`, + ), + ); + } + + if (hasOpeningQuestion) { + strengths.push(strengthQuestionLead()); + } + + if (hasDirectiveLead || directiveMatches.length >= 2) { + strengths.push(strengthDirectiveLead()); + } + + if (mnemonicMatches.length > 0) { + strengths.push(strengthMnemonic()); + } + + if (concreteAnchorMatches.length >= 2) { + strengths.push(strengthConcreteAnchors()); + } + + if (repeatedBulletStart && bulletTexts.length >= 3) { + strengths.push(strengthParallelList()); + } + + if (strengths.length === 0 && (getBlocksOfType(blocks, "heading").length > 0 || bulletTexts.length >= 3)) { + strengths.push(strengthClearStructure()); + } + + 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, + ); + + 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", + ), + ); + } + + if (repeatedWordMatch) { + findings.unshift( + createFinding( + "style", + "medium", + "Repeated word", + repeatedWord(), + "Document", + ), + ); + } + + 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, + }; +} + +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", + ), + ); + } + + if (bullets.length >= 5 && headings.length === 0) { + findings.push( + createFinding( + "structure", + "medium", + "Break up the middle", + structureBreakMiddle(), + "Bullet list", + ), + ); + } + + if (totalWords > 0 && totalWords < 12) { + findings.push( + createFinding( + "structure", + "high", + "Draft is too thin", + thinDraft(), + "Document", + ), + ); + } + + 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 (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()); + } + + return { + title: section.title, + kind: section.kind, + lineStart: section.lineStart, + lineEnd: section.lineEnd, + notes, + needsAttention, + }; + }); + + return { findings, sectionNotes }; +} + +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}`); + + 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()); + } + + return { + quickTake, + priorities, + strengths: strengths.length > 0 ? strengths : [DOCUMENT_LINTER_FALLBACK_STRENGTH], + }; +} + +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)); +} + +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(); +} + +function isElementLike(value: unknown): value is { getAttribute: (name: string) => string | null; closest?: (selector: string) => unknown } { + return typeof value === "object" && value !== null && "getAttribute" in value; +} + +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); + + 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}`, + ]; + + return lines.join("\n"); +} + +export function createDocumentLinterController({ + els, + getEditorText, + onEditorContentReplaced: _onEditorContentReplaced, + showToast, + setStatus, +}: { + els: DomRefs; + getEditorText: () => string; + onEditorContentReplaced: (text: string) => void; + showToast: ToastFn; + setStatus: SetStatusFn; +}): DocumentLinterController { + let isPanelOpen = false; + let isAnalyzing = false; + let autoRunEnabled = false; + let lastAnalysis: LinterAnalysis | null = null; + let lastTextSnapshot = ""; + + async function runAnalysis(textSnapshot: string = getEditorText()): Promise { + return Promise.resolve(analyzeDocumentText(textSnapshot)); + } + + 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); + } + + function setPanelVisibility(isOpen: boolean): void { + isPanelOpen = isOpen; + els.documentLinterPanel.hidden = !isOpen; + els.documentLinterToggleBtn.setAttribute("aria-expanded", String(isOpen)); + + const split = els.documentLinterPanel.closest(".split"); + if (split) { + split.classList.toggle("with-document-linter", isOpen); + } + } + + 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 = ""; + + 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); + + 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); + }); + + 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); + } + + 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 link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + async function analyzeDocumentAction(): Promise { + if (isAnalyzing) { + return; + } + + const text = getEditorText(); + if (!text.trim()) { + showToast("No content to analyze", { persist: true }); + setStatus("No content to analyze", "warn"); + return; + } + + isAnalyzing = true; + lastTextSnapshot = text; + els.documentLinterAnalyzeBtn.disabled = true; + els.documentLinterStatus.textContent = "Analyzing document..."; + + try { + const analysis = await runAnalysis(text); + lastAnalysis = analysis; + updateResultsPanel(analysis); + els.documentLinterStatus.textContent = "Analysis complete"; + 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; + } + } + + 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; + 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"); + } + } + + async function handleEditorChanged(textSnapshot: string = getEditorText()): Promise { + if (!isPanelOpen || !autoRunEnabled || isAnalyzing || !textSnapshot.trim()) { + if (!textSnapshot.trim() && autoRunEnabled && isPanelOpen) { + lastAnalysis = null; + lastTextSnapshot = textSnapshot; + els.documentLinterStatus.textContent = "No content to analyze"; + } + return Promise.resolve(); + } + if (textSnapshot === lastTextSnapshot) { + return Promise.resolve(); + } + return analyzeDocumentAction(); + } + + els.documentLinterAutoRunToggle.checked = autoRunEnabled; + els.documentLinterAutoRunToggle.addEventListener("change", () => { + autoRunEnabled = els.documentLinterAutoRunToggle.checked; + 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; + } + const lineNumber = Number(lineButton.getAttribute("data-linter-line")); + if (Number.isNaN(lineNumber)) { + return; + } + scrollEditorToLine(lineNumber); + }); + + setPanelVisibility(false); + + return { + togglePanel: () => { + setPanelVisibility(!isPanelOpen); + if (isPanelOpen) { + els.documentLinterStatus.textContent = "Ready to analyze document"; + } + }, + setPanelOpen: (nextIsOpen: boolean) => { + setPanelVisibility(nextIsOpen); + if (nextIsOpen) { + els.documentLinterStatus.textContent = "Ready to analyze document"; + } + }, + isPanelOpen: () => isPanelOpen, + closePanel: () => { + setPanelVisibility(false); + }, + handleEditorChanged, + analyzeDocument: analyzeDocumentAction, + exportSuggestions: exportSuggestionsAction, + }; +} diff --git a/src/app/document-linter/messages.ts b/src/app/document-linter/messages.ts new file mode 100644 index 0000000..7353b53 --- /dev/null +++ b/src/app/document-linter/messages.ts @@ -0,0 +1,137 @@ +export const DOCUMENT_LINTER_FALLBACK_STRENGTH = + "No clear strengths stand out yet; the draft needs more signal before the linter can praise specific choices."; + +export const DOCUMENT_LINTER_NO_MAJOR_FIXES = "No major fixes stood out."; + +export const DOCUMENT_LINTER_NO_NOTABLE_ISSUES = "No notable issues in this category."; + +export const DOCUMENT_LINTER_NO_SECTION_STRUCTURE = + "This note did not expose any obvious structural sections."; + +export function openingNeedsStructure(): string { + return "The note opens cleanly, but it still needs a visible structure signal."; +} + +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."; +} + +export function openingNeedsStrongerLead(): string { + return "The opening is understandable, but it could be shaped into a sharper lead sentence."; +} + +export function openingIsInformativeNotDirective(): string { + return "Lead with the payoff or takeaway first, then use the topic sentence to support it."; +} + +export function quickTakeStrengthsDetected(): string { + return "There are real strengths here: concrete details and structure cues give the document memory and shape."; +} + +export function quickTakeNeedsScaffold(): string { + return "The content is solid, but it needs a clearer scaffold so the reader can move through it faster."; +} + +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."; +} + +export function missingHeading(): string { + return "Add a heading at the top so the chapter reads as a structured note instead of a raw outline."; +} + +export function explicitSectionLabel(label: string): string { + return `Turn "${label}" into a heading so the bullet list has a clean anchor.`; +} + +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."; +} + +export function structureSimpleOutline(): string { + return "This would scan better with three visible beats: a short lead, a grouped facts section, and a closing takeaway."; +} + +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."; +} + +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."; +} + +export function repeatedWord(): string { + return "A word is repeated back-to-back. Clean that up before worrying about higher-level style."; +} + +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."; +} + +export function thinSection(): string { + return "This section is too thin to evaluate well; add a clearer claim or supporting detail."; +} + +export function questionNeedsAnswer(): string { + return "A question can be a good hook, but it needs an immediate answer or payoff."; +} + +export function balancedSection(): string { + return "This section is balanced and easy to follow."; +} + +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.`; +} + +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.`; +} + +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.`; +} + +export function sectionLabelPromotion(title: string): string { + return `Turn "${title}:" into a heading so the section reads as intentional structure.`; +} + +export function sectionDenseBulletRun(): string { + return "This bullet run is dense; split the longest items or group them by theme."; +} + +export function sectionLongMaterial(): string { + return "This section carries a lot of material; consider splitting it into two smaller beats."; +} + +export function sectionListNeedsLead(): string { + return "The heading works, but this section is list-only; a short lead sentence could help orient the reader."; +} + +export function sectionNeedsAttention(title: string, note: string): string { + return `The "${title}" section deserves attention: ${note.toLowerCase()}`; +} + +export function strengthConcreteAnchors(): string { + return "Concrete anchors such as names, dates, or numbers make the material easier to remember."; +} + +export function strengthMnemonic(): string { + return "The document gives the reader a memory hook, which makes the takeaway easier to retain."; +} + +export function strengthParallelList(): string { + return "The parallel list structure creates a strong rhythm and makes the sequence easy to scan."; +} + +export function strengthQuestionLead(): string { + return "The opening question creates forward pull and gives the reader a reason to keep going."; +} + +export function strengthDirectiveLead(): string { + return "The lead uses clear directive language, which gives the document momentum."; +} + +export function strengthClearStructure(): string { + return "The document already has enough visible structure that a reader can scan it quickly."; +} diff --git a/src/app/dom.ts b/src/app/dom.ts index 9a986be..cc72388 100644 --- a/src/app/dom.ts +++ b/src/app/dom.ts @@ -29,6 +29,13 @@ export function getDomRefs(): DomRefs { workspaceName: requiredElement("workspaceName"), countsPill: requiredElement("countsPill"), editor: requiredElement("editor"), + editorSplit: requiredElement("editorSplit"), + editorPane: requiredElement("editorPane"), + previewPane: requiredElement("previewPane"), + editorViewModeGroup: requiredElement("editorViewModeGroup"), + editorViewSourceBtn: requiredElement("editorViewSourceBtn"), + editorViewSplitBtn: requiredElement("editorViewSplitBtn"), + editorViewPreviewBtn: requiredElement("editorViewPreviewBtn"), preview: requiredElement("preview"), currentFilename: requiredElement("currentFilename"), dirtyDot: requiredElement("dirtyDot"), @@ -44,5 +51,12 @@ export function getDomRefs(): DomRefs { cogitoGenerateBtn: requiredElement("cogitoGenerateBtn"), cogitoStatus: requiredElement("cogitoStatus"), cogitoQuestionList: requiredElement("cogitoQuestionList"), + documentLinterToggleBtn: requiredElement("documentLinterToggleBtn"), + documentLinterPanel: requiredElement("documentLinterPanel"), + documentLinterAnalyzeBtn: requiredElement("documentLinterAnalyzeBtn"), + documentLinterExportBtn: requiredElement("documentLinterExportBtn"), + documentLinterAutoRunToggle: requiredElement("documentLinterAutoRunToggle"), + documentLinterStatus: requiredElement("documentLinterStatus"), + documentLinterResults: requiredElement("documentLinterResults"), }; } diff --git a/src/app/editor-preview.ts b/src/app/editor-preview.ts index 5bb5506..2fcee04 100644 --- a/src/app/editor-preview.ts +++ b/src/app/editor-preview.ts @@ -3,6 +3,62 @@ import { escapeHtml } from "./utils"; import type { AppState, DomRefs } from "./types"; import type { StatusKind } from "./toast-status"; +export type EditorViewMode = "split" | "source" | "preview"; + +export const EDITOR_VIEW_MODE_STORAGE_KEY = "ink-editor-view-mode"; +export const VALID_EDITOR_VIEW_MODES: EditorViewMode[] = ["split", "source", "preview"]; + +function normalizeEditorViewMode(value: string | null): EditorViewMode { + if (value === "source" || value === "preview") { + return value; + } + return "split"; +} + +export function loadEditorViewMode(): EditorViewMode { + try { + return normalizeEditorViewMode(localStorage.getItem(EDITOR_VIEW_MODE_STORAGE_KEY)); + } catch { + return "split"; + } +} + +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"); + + els.editorPane.hidden = mode === "preview"; + els.previewPane.hidden = mode === "source"; + + const buttons: Array<[HTMLButtonElement, EditorViewMode]> = [ + [els.editorViewSourceBtn, "source"], + [els.editorViewSplitBtn, "split"], + [els.editorViewPreviewBtn, "preview"], + ]; + + buttons.forEach(([button, buttonMode]) => { + const isActive = buttonMode === mode; + button.classList.toggle("active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); +} + +export function setEditorViewMode(els: DomRefs, state: AppState, mode: EditorViewMode): void { + if (!VALID_EDITOR_VIEW_MODES.includes(mode)) { + return; + } + + state.editorViewMode = mode; + applyEditorViewMode(els, mode); + + try { + localStorage.setItem(EDITOR_VIEW_MODE_STORAGE_KEY, mode); + } catch { + // ignore localStorage errors + } +} + export function renderPreview(els: DomRefs, text: string): void { try { els.preview.innerHTML = marked.parse(text || "") as string; diff --git a/src/app/types.ts b/src/app/types.ts index e1a0531..fb4a326 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -92,6 +92,7 @@ export interface AppState { isSidebarCollapsed: boolean; isTemporarySession: boolean; isCogitoModeEnabled: boolean; + editorViewMode: "split" | "source" | "preview"; } export interface DomRefs { @@ -114,6 +115,13 @@ export interface DomRefs { workspaceName: HTMLElement; countsPill: HTMLElement; editor: HTMLTextAreaElement; + editorSplit: HTMLElement; + editorPane: HTMLElement; + previewPane: HTMLElement; + editorViewModeGroup: HTMLElement; + editorViewSourceBtn: HTMLButtonElement; + editorViewSplitBtn: HTMLButtonElement; + editorViewPreviewBtn: HTMLButtonElement; preview: HTMLElement; currentFilename: HTMLElement; dirtyDot: HTMLElement; @@ -129,6 +137,13 @@ export interface DomRefs { cogitoGenerateBtn: HTMLButtonElement; cogitoStatus: HTMLElement; cogitoQuestionList: HTMLElement; + documentLinterToggleBtn: HTMLButtonElement; + documentLinterPanel: HTMLElement; + documentLinterAnalyzeBtn: HTMLButtonElement; + documentLinterExportBtn: HTMLButtonElement; + documentLinterAutoRunToggle: HTMLInputElement; + documentLinterStatus: HTMLElement; + documentLinterResults: HTMLElement; } declare global { diff --git a/src/app/ui-events.ts b/src/app/ui-events.ts index 2e6a5e4..12e7a22 100644 --- a/src/app/ui-events.ts +++ b/src/app/ui-events.ts @@ -1,4 +1,5 @@ import type { AppState, DeclarativeNoteInput, DeclarativeNoteResult, DomRefs } from "./types"; +import type { EditorViewMode } from "./editor-preview"; type ToastFn = (message: string, options?: { persist?: boolean }) => void; @@ -19,6 +20,10 @@ export type UiActions = { selectCogitoModel: (model: "lite" | "deep") => void; generateCogitoQuestions: () => Promise; insertCogitoQuestion: (index: number) => void; + setEditorViewMode: (mode: EditorViewMode) => void; + toggleDocumentLinterPanel: () => void; + analyzeDocument: () => Promise; + exportDocumentLinterSuggestions: () => Promise; hideToast: () => void; showToast: ToastFn; }; @@ -246,6 +251,41 @@ export function attachUiEvents({ actions.insertCogitoQuestion(index); }); + els.editorViewModeGroup.addEventListener("click", (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + const button = target.closest("[data-editor-view-mode]"); + if (!button) { + return; + } + + const mode = button.getAttribute("data-editor-view-mode") as EditorViewMode | null; + if (!mode) { + return; + } + + actions.setEditorViewMode(mode); + }); + + els.documentLinterToggleBtn.addEventListener("click", () => { + actions.toggleDocumentLinterPanel(); + }); + + els.documentLinterAnalyzeBtn.addEventListener("click", () => { + actions.analyzeDocument().catch((error: unknown) => { + actions.showToast(`Document analysis failed: ${String(error)}`, { persist: true }); + }); + }); + + els.documentLinterExportBtn.addEventListener("click", () => { + actions.exportDocumentLinterSuggestions().catch((error: unknown) => { + actions.showToast(`Document export failed: ${String(error)}`, { persist: true }); + }); + }); + window.addEventListener("beforeunload", (event: BeforeUnloadEvent) => { if (state.isDirty) { event.preventDefault(); diff --git a/src/styles.scss b/src/styles.scss index 6d1bf56..69a2924 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -387,6 +387,40 @@ min-height: 54px; } + .viewModePicker{ + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + flex: 0 0 auto; + } + + .viewModeBtn{ + border: 0; + background: transparent; + color: var(--muted); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + line-height: 1; + font-weight: 700; + white-space: nowrap; + } + + .viewModeBtn:hover{ + background: rgba(255, 255, 255, 0.06); + color: var(--text); + } + + .viewModeBtn.active{ + background: rgba(94, 234, 212, 0.16); + color: var(--text); + box-shadow: inset 0 0 0 1px rgba(94, 234, 212, 0.35); + } + .filename{ font-weight: 800; letter-spacing: 0.2px; @@ -438,7 +472,20 @@ grid-template-columns: 1fr 1fr; } - .split.with-cogito{ + .split.view-source, + .split.view-preview{ + grid-template-columns: 1fr; + } + + .split.view-source.with-cogito, + .split.view-source.with-document-linter, + .split.view-preview.with-cogito, + .split.view-preview.with-document-linter{ + grid-template-columns: 1fr 320px; + } + + .split.view-split.with-cogito, + .split.view-split.with-document-linter{ grid-template-columns: 1fr 1fr 320px; } @@ -454,6 +501,21 @@ background: var(--panel2); } + .split.view-source .editorPane{ + border-right: 0; + } + + .split.view-preview .previewPane{ + border-left: 0; + } + + .editorPane[hidden], + .previewPane[hidden], + .cogitoPanel[hidden], + .documentLinterPanel[hidden]{ + display: none !important; + } + textarea{ width: 100%; height: 100%; @@ -490,8 +552,194 @@ overflow: auto; } - .cogitoPanel[hidden]{ - display: none; + .cogitoPanel[hidden]{ + display: none; + } + + .documentLinterPanel{ + min-height: 0; + border-left: 1px solid var(--border); + background: var(--panel2); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: auto; + } + + .documentLinterPanel[hidden]{ + display: none; + } + + .documentLinterHeader{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0; + padding: 0; + border: 0; + min-height: 0; + } + + .documentLinterAutoRun{ + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); + user-select: none; + } + + .documentLinterAutoRun input{ + margin: 0; + } + + .documentLinterResults{ + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 12px; + } + + .documentLinterSummary{ + display: grid; + gap: 10px; + margin-bottom: 10px; + } + + .documentLinterSummaryBlock{ + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + } + + .documentLinterSummaryBlock h3{ + margin: 0 0 8px; + font-size: 13px; + font-weight: 700; + } + + .documentLinterSummaryBlock ul, + .documentLinterSummaryBlock ol{ + margin: 0; + padding-left: 18px; + display: grid; + gap: 6px; + } + + .documentLinterCategory{ + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + margin: 0; + } + + .documentLinterCategoryHeader{ + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + } + + .documentLinterCategoryHeader h3{ + margin: 0; + font-size: 13px; + font-weight: 600; + } + + .documentLinterScore{ + font-size: 14px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + } + + .documentLinterSuggestions{ + display: flex; + flex-direction: column; + gap: 4px; + } + + .documentLinterSuggestion{ + font-size: 12px; + line-height: 1.4; + } + + .documentLinterLineButton{ + border: 0; + background: transparent; + color: var(--accent); + padding: 0; + font: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + } + + .documentLinterLineButton:hover, + .documentLinterLineButton:focus-visible{ + color: var(--text); + outline: none; + } + + .documentLinterSectionAnalysis{ + display: grid; + gap: 10px; + margin-top: 2px; + padding-top: 8px; + } + + .documentLinterSectionAnalysis h3{ + margin: 0; + font-size: 13px; + font-weight: 700; + } + + .documentLinterSectionList{ + display: grid; + gap: 10px; + } + + .documentLinterSectionCard{ + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + display: grid; + gap: 8px; + } + + .documentLinterSectionHeader{ + display: flex; + justify-content: space-between; + gap: 10px; + align-items: baseline; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + } + + .documentLinterSectionHeader h4{ + margin: 0; + font-size: 12px; + font-weight: 700; + } + + .documentLinterSectionHeader span{ + font-size: 11px; + color: var(--muted); + white-space: nowrap; + } + + .documentLinterSectionNotes{ + display: grid; + gap: 4px; } .cogitoHeader{ @@ -643,6 +891,10 @@ font-size: 12px; } + .menu-action-btn + .menu-action-btn{ + margin-left: 8px; + } + .menu-action-label{ font-weight: 700; } @@ -888,8 +1140,15 @@ .app.sidebar-collapsed{ grid-template-columns: 1fr; } - .split{ grid-template-columns: 1fr; } - .split.with-cogito{ grid-template-columns: 1fr; } + .split, + .split.view-source, + .split.view-preview, + .split.view-split.with-cogito, + .split.view-split.with-document-linter, + .split.view-source.with-cogito, + .split.view-source.with-document-linter, + .split.view-preview.with-cogito, + .split.view-preview.with-document-linter{ grid-template-columns: 1fr; } .editorPane{ border-right: none; border-bottom: 1px solid var(--border); @@ -901,6 +1160,11 @@ border-top: 1px solid var(--border); min-height: 30vh; } + .documentLinterPanel{ + border-left: none; + border-top: 1px solid var(--border); + min-height: 30vh; + } .filename{ max-width: 70vw; } .sidebarToggle{ position: static; diff --git a/test-document.md b/test-document.md new file mode 100644 index 0000000..2bff232 --- /dev/null +++ b/test-document.md @@ -0,0 +1,19 @@ +# Test Document for Linter + +This is a test document to verify the document linter functionality. + +## Introduction + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +## Features + +- Feature one: This is a very long sentence that goes on and on and on and might exceed our readability threshold for testing purposes +- Feature two: Another feature with some code `example()` +- Feature three: And another feature + +## Conclusion + +In conclusion, this document tests various aspects of the linter including readability, structure, and engagement factors. + +Some might say that the passive voice was used in this sentence, which could be flagged by the style checker. \ No newline at end of file diff --git a/tests/qunit/auth.test.js b/tests/qunit/auth.test.js new file mode 100644 index 0000000..42728d2 --- /dev/null +++ b/tests/qunit/auth.test.js @@ -0,0 +1,343 @@ +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(); + } + + getItem(key) { + return this.store.get(key) ?? null; + } + + setItem(key, value) { + this.store.set(key, value); + } + + removeItem(key) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } +} + +/** + * Mock sessionStorage for testing + */ +class MockSessionStorage { + constructor() { + this.store = new Map(); + } + + getItem(key) { + return this.store.get(key) ?? null; + } + + setItem(key, value) { + this.store.set(key, value); + } + + removeItem(key) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } +} + +/** + * Mock fetch for testing + */ +class MockFetch { + constructor() { + this.responses = new Map(); + this.callHistory = []; + } + + mock(url, response) { + this.responses.set(url, response); + } + + getCalls() { + return [...this.callHistory]; + } + + clear() { + this.responses.clear(); + this.callHistory = []; + } + + 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; + }, + }; + } +} + +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("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"); + }); + + 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"); + }); + + QUnit.test("generateCodeChallenge produces correct format", async function (assert) { + const verifier = "test_verifier_string_with_exact_length_43chars"; + const challenge = await generateCodeChallenge(verifier); + + // 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"); + }); + + 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.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"); + }); + + QUnit.test("returns exponential delay for subsequent attempts", function (assert) { + const delay2 = calculateBackoff(2, 5000); + const delay3 = calculateBackoff(3, 5000); + + // 5000 * 2^2 = 20000 + jitter + assert.ok(delay2 >= 20000 && delay2 < 21000, "Delay should be around 20000-21000ms for attempt 2"); + + // 5000 * 2^3 = 40000 + jitter + assert.ok(delay3 >= 40000 && delay3 < 41000, "Delay should be around 40000-41000ms for attempt 3"); + }); + + 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)"); + }); + }); + + 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"); + }); + + QUnit.test("includes retryAfterMs when provided", function (assert) { + const error = new AuthError(AuthErrorType.ServerError, "Server error", 5000); + assert.strictEqual(error.retryAfterMs, 5000); + }); + }); + + QUnit.module("createAuthManager", (hooks) => { + let mockStorage; + let mockSessionStorage; + let mockFetch; + let originalFetch; + let originalLocalStorage; + let originalSessionStorage; + let mockWindowLocation; + + hooks.beforeEach(function () { + mockStorage = new MockLocalStorage(); + mockSessionStorage = new MockSessionStorage(); + mockFetch = new MockFetch(); + + // Setup mock window.location + mockWindowLocation = { + href: "http://localhost:8000/", + pathname: "/", + hash: "", + }; + + originalFetch = globalThis.fetch; + originalLocalStorage = global.localStorage; + originalSessionStorage = global.sessionStorage; + + globalThis.fetch = function(url, options) { return mockFetch.fetch(url, options); }; + global.localStorage = mockStorage; + global.sessionStorage = mockSessionStorage; + }); + + hooks.afterEach(function () { + globalThis.fetch = originalFetch; + global.localStorage = originalLocalStorage; + global.sessionStorage = originalSessionStorage; + mockFetch.clear(); + mockSessionStorage.clear(); + }); + + QUnit.test("starts with unauthenticated state", function (assert) { + const authManager = createAuthManager(); + const state = authManager.getState(); + + assert.strictEqual(state.isAuthenticated, false); + assert.strictEqual(state.isLoading, false); + assert.strictEqual(state.error, null); + }); + + QUnit.test("restoreSession returns false when no token stored", function (assert) { + const authManager = createAuthManager(); + const restored = authManager.restoreSession(); + + assert.strictEqual(restored, false); + assert.strictEqual(authManager.isAuthenticated(), false); + }); + + QUnit.test("restoreSession returns true and authenticates when token exists", function (assert) { + const authManager = createAuthManager(); + mockStorage.setItem("ink_github_token", "test_token_123"); + + const restored = authManager.restoreSession(); + + assert.strictEqual(restored, true); + assert.strictEqual(authManager.isAuthenticated(), true); + assert.strictEqual(authManager.getToken(), "test_token_123"); + }); + + QUnit.test("logout clears token and resets state", function (assert) { + const authManager = createAuthManager(); + mockStorage.setItem("ink_github_token", "test_token_123"); + authManager.restoreSession(); + + authManager.logout(); + + assert.strictEqual(authManager.isAuthenticated(), false); + assert.strictEqual(authManager.getToken(), null); + assert.strictEqual(mockStorage.getItem("ink_github_token"), null); + }); + + QUnit.test("subscribe receives state change notifications", function (assert) { + const authManager = createAuthManager(); + let stateChanges = 0; + let lastState = null; + + const unsubscribe = authManager.subscribe({ + onStateChange: function(state) { + stateChanges++; + lastState = state; + }, + }); + + mockStorage.setItem("ink_github_token", "test_token"); + authManager.restoreSession(); + + 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"); + }); + + // 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. + }); + + QUnit.module("token storage", (hooks) => { + let mockStorage; + let mockSessionStorage; + let originalLocalStorage; + let originalSessionStorage; + let mockWindowLocation; + + hooks.beforeEach(function () { + mockStorage = new MockLocalStorage(); + mockSessionStorage = new MockSessionStorage(); + originalLocalStorage = global.localStorage; + originalSessionStorage = global.sessionStorage; + global.localStorage = mockStorage; + global.sessionStorage = mockSessionStorage; + + mockWindowLocation = { + href: "http://localhost:8000/", + pathname: "/", + hash: "", + }; + }); + + hooks.afterEach(function () { + global.localStorage = originalLocalStorage; + global.sessionStorage = originalSessionStorage; + }); + + QUnit.test("logout removes token from storage", function (assert) { + const authManager = createAuthManager(); + mockStorage.setItem("ink_github_token", "test_token"); + + authManager.logout(); + + assert.strictEqual(mockStorage.getItem("ink_github_token"), null); + }); + + QUnit.test("logout clears code_verifier from sessionStorage", function (assert) { + const authManager = createAuthManager(); + mockSessionStorage.setItem("ink_github_code_verifier", "test_verifier"); + + authManager.logout(); + + assert.strictEqual(mockSessionStorage.getItem("ink_github_code_verifier"), null); + }); + }); +}); diff --git a/tests/qunit/document-linter.test.js b/tests/qunit/document-linter.test.js new file mode 100644 index 0000000..346f85f --- /dev/null +++ b/tests/qunit/document-linter.test.js @@ -0,0 +1,438 @@ +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 = ""; + } + + appendChild(child) { + child.parentNode = this; + this.children.push(child); + return child; + } + + removeChild(child) { + this.children = this.children.filter((candidate) => candidate !== child); + child.parentNode = null; + return child; + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + getAttribute(name) { + return this.attributes.get(name) ?? null; + } + + closest(selector) { + return this.closestMap.get(selector) ?? null; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatchEvent(event) { + event.target = this; + const listeners = this.listeners.get(event.type) ?? []; + listeners.forEach((listener) => listener(event)); + return true; + } + + focus() { + global.document.activeElement = this; + } + + setSelectionRange(start, end) { + this.selectionStart = start; + this.selectionEnd = end; + } + + click() { + this.clicked = true; + } +} + +function createControllerDomRefs() { + const split = new FakeElement("div"); + const panel = new FakeElement("aside"); + panel.closestMap.set(".split", split); + const editor = new FakeElement("textarea"); + editor.value = ""; + + return { + editor, + documentLinterPanel: panel, + documentLinterToggleBtn: new FakeElement("button"), + documentLinterAnalyzeBtn: new FakeElement("button"), + documentLinterExportBtn: new FakeElement("button"), + documentLinterAutoRunToggle: new FakeElement("input"), + documentLinterStatus: new FakeElement("div"), + documentLinterResults: new FakeElement("div"), + }; +} + +QUnit.module("document-linter", (hooks) => { + hooks.beforeEach(function () { + this.originalDocument = global.document; + this.originalWindow = global.window; + this.originalURL = global.URL; + this.originalBlob = global.Blob; + + const fakeDocument = { + body: new FakeElement("body"), + activeElement: null, + createElement(tagName) { + return new FakeElement(tagName); + }, + }; + + global.document = fakeDocument; + global.window = { + setTimeout, + 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; + } + }; + }); + + hooks.afterEach(function () { + global.document = this.originalDocument; + global.window = this.originalWindow; + global.URL = this.originalURL; + global.Blob = this.originalBlob; + }); + + QUnit.test("parseMarkdownBlocks handles ordered lists, setext headings, quotes, and open code fences", function (assert) { + const text = `Overview +--- + +1. First point +2. Second point + - nested detail + +> quoted paragraph +> +> still quoted + + const value = 1; + +\`\`\`js +console.log("open fence") +`; + + 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"); + }); + + 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", + ); + }); + + 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. + +Remember this: +- Compare river access with food storage +- Compare crop surplus with trade growth +- Compare dense neighborhoods with social roles`; + + const analysis = analyzeDocumentText(text); + + 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", + ); + }); + + QUnit.test("analyzeDocumentText treats thin repetitive drafts as low-signal", function (assert) { + const text = `Gennaro is bruno always always + +Why not bannarpo?`; + const analysis = analyzeDocumentText(text); + + 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 = []; + let currentText = ""; + + const controller = createDocumentLinterController({ + els, + getEditorText: () => currentText, + onEditorContentReplaced: () => {}, + showToast: (message, options) => { + toasts.push({ message, options }); + }, + setStatus: (message, kind) => { + statuses.push({ message, kind }); + }, + }); + + controller.setPanelOpen(true); + assert.strictEqual(els.documentLinterPanel.hidden, false, "panel should open"); + assert.ok(els.documentLinterToggleBtn.getAttribute("aria-expanded") === "true", "toggle should reflect panel visibility"); + + 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"); + + els.documentLinterAutoRunToggle.checked = true; + els.documentLinterAutoRunToggle.dispatchEvent({ type: "change" }); + currentText = `${currentText}\n- Start with the market tolls`; + els.editor.value = currentText; + + await controller.handleEditorChanged(currentText); + + assert.strictEqual(els.documentLinterStatus.textContent, "Analysis complete", "rerun on change should trigger a fresh analysis"); + + 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"); + }); +}); diff --git a/tests/qunit/editor-preview.test.js b/tests/qunit/editor-preview.test.js new file mode 100644 index 0000000..8692e84 --- /dev/null +++ b/tests/qunit/editor-preview.test.js @@ -0,0 +1,116 @@ +import QUnit from "qunit"; +import { applyEditorViewMode, loadEditorViewMode, setEditorViewMode } from "../../dist/test/editor-preview.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); + }, + toArray() { + return [...classes]; + }, + }; +} + +function createButton() { + return { + classList: createClassList(), + attributes: new Map(), + setAttribute(name, value) { + this.attributes.set(name, value); + }, + getAttribute(name) { + return this.attributes.get(name) ?? null; + }, + }; +} + +function createPane() { + return { + hidden: false, + classList: createClassList(), + }; +} + +function createDomRefs() { + return { + editorSplit: { classList: createClassList() }, + editorPane: createPane(), + previewPane: createPane(), + editorViewModeGroup: {}, + editorViewSourceBtn: createButton(), + editorViewSplitBtn: createButton(), + editorViewPreviewBtn: createButton(), + }; +} + +QUnit.module("editor-preview", (hooks) => { + hooks.beforeEach(function () { + this.originalLocalStorage = global.localStorage; + global.localStorage = { + store: new Map(), + getItem(key) { + return this.store.get(key) ?? null; + }, + setItem(key, value) { + this.store.set(key, value); + }, + }; + }); + + hooks.afterEach(function () { + global.localStorage = this.originalLocalStorage; + }); + + QUnit.test("loadEditorViewMode defaults to split", function (assert) { + assert.strictEqual(loadEditorViewMode(), "split"); + }); + + QUnit.test("setEditorViewMode updates state, storage, and buttons", function (assert) { + const els = createDomRefs(); + const state = { editorViewMode: "split" }; + + setEditorViewMode(els, state, "preview"); + + assert.strictEqual(state.editorViewMode, "preview", "state should update"); + assert.strictEqual(global.localStorage.getItem("ink-editor-view-mode"), "preview", "mode should persist"); + assert.strictEqual(els.editorPane.hidden, true, "editor should hide in preview mode"); + assert.strictEqual(els.previewPane.hidden, false, "preview should remain visible"); + assert.strictEqual(els.editorViewPreviewBtn.getAttribute("aria-pressed"), "true", "active button should be pressed"); + assert.ok(els.editorSplit.classList.contains("view-preview"), "split container should reflect preview mode"); + }); + + QUnit.test("applyEditorViewMode switches back to source view", function (assert) { + const els = createDomRefs(); + + applyEditorViewMode(els, "source"); + + assert.strictEqual(els.editorPane.hidden, false, "editor should remain visible in source mode"); + assert.strictEqual(els.previewPane.hidden, true, "preview should hide in source mode"); + assert.strictEqual(els.editorViewSourceBtn.getAttribute("aria-pressed"), "true", "source button should be active"); + assert.ok(els.editorSplit.classList.contains("view-source"), "split container should reflect source mode"); + }); +});