From 921b850dd83e05d9fe0125430778fdf625d77fcf Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Thu, 22 Oct 2026 12:42:29 +0800 Subject: [PATCH 01/24] underline links in the task editor and open them with Ctrl+click Render a transparent overlay behind the task editor that mirrors the textarea's metrics. Any http(s) URL in Today or Inbox is underlined with a subtle themed color. Hold Cmd (macOS) or Ctrl (Windows/Linux) and click a link to open it in a new tab. Zero dependencies: plain DOM APIs only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/demo-week.html | 2 +- app/demo.html | 2 +- app/help.html | 14 ++++++--- app/icon-normal.html | 5 +-- app/icon-notify.html | 5 +-- app/index.html | 5 +-- app/main.js | 73 ++++++++++++++++++++++++++++++++++++++++++++ app/send.html | 2 +- app/style.css | 24 +++++++++++++++ app/week.html | 2 +- 10 files changed, 119 insertions(+), 15 deletions(-) diff --git a/app/demo-week.html b/app/demo-week.html index 705e311..00dcc0d 100644 --- a/app/demo-week.html +++ b/app/demo-week.html @@ -4,7 +4,7 @@ Demo of Weekly Routine - +
diff --git a/app/demo.html b/app/demo.html index c75add5..06d731c 100644 --- a/app/demo.html +++ b/app/demo.html @@ -4,7 +4,7 @@ Demo of Frogtab - +
diff --git a/app/help.html b/app/help.html index cfd2309..bff4e5c 100644 --- a/app/help.html +++ b/app/help.html @@ -61,7 +61,7 @@

Frogtab Help

Overview of Frogtab

- +

Today is your to-do list for today. Begin the day by writing down everything you'd like to accomplish. @@ -79,7 +79,7 @@

Overview of Frogtab

Frogtab displays a notification badge to remind you to triage your inbox:

- +

At the beginning of each day, Frogtab:

@@ -91,6 +91,10 @@

Overview of Frogtab

You can then triage your inbox and decide what you'd like to accomplish today.

+

+ Frogtab underlines any link that you add to Today or Inbox. + To open a link, hold ⌘ (macOS) or Ctrl (Windows/Linux) and click it. +

Adding tasks to your inbox

Frogtab makes it easy to capture any thoughts or potential tasks that arise as you're working through your priorities for the day. @@ -125,7 +129,7 @@

Triaging your inbox

Snooze the task by writing # in front of the task:

- +

Frogtab will unsnooze the task at the beginning of the next day.

@@ -161,12 +165,12 @@

Setting up recurring tasks

Select a day, then write down your recurring tasks for that day. For example:

- +

If you like planning in the evening, write down your recurring tasks under the previous day, with # in front of each task. For example:

- +

You'll then be able to see the recurring tasks one day in advance. When Frogtab adds the tasks to your inbox, they'll be snoozed (because of the #), so they won't cause Frogtab to display a notification badge. diff --git a/app/icon-normal.html b/app/icon-normal.html index 16b0ac4..8ea0aa3 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -9,7 +9,7 @@ - +

@@ -39,6 +39,7 @@
+
@@ -58,6 +59,6 @@
- + diff --git a/app/icon-notify.html b/app/icon-notify.html index 927a8a0..e9cb618 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -9,7 +9,7 @@ - +
@@ -39,6 +39,7 @@
+
@@ -58,6 +59,6 @@
- + diff --git a/app/index.html b/app/index.html index ee658d0..d18f88c 100644 --- a/app/index.html +++ b/app/index.html @@ -9,7 +9,7 @@ - +
@@ -39,6 +39,7 @@
+
@@ -58,6 +59,6 @@
- + diff --git a/app/main.js b/app/main.js index c76f80b..534b20e 100644 --- a/app/main.js +++ b/app/main.js @@ -172,6 +172,7 @@ function refreshView() { dom.editor[selectedTab].scrollTop = 0; refreshInfo(); } + renderLinks(); } function refreshInfo() { @@ -184,6 +185,62 @@ function refreshInfo() { } } +function escapeHTML(text) { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function trimTrailingPunctuation(url) { + let trimmed = url; + while (trimmed.length > 0) { + const lastChar = trimmed.charAt(trimmed.length - 1); + if (".!,;:?".includes(lastChar)) { + trimmed = trimmed.substring(0, trimmed.length - 1); + } + else if (lastChar == ")" && trimmed.split(")").length > trimmed.split("(").length) { + trimmed = trimmed.substring(0, trimmed.length - 1); + } + else { + break; + } + } + return trimmed; +} + +function renderLinks() { + const editor = dom.editor[selectedTab]; + dom.links.style.width = `${editor.clientWidth + 4}px`; + let html = ""; + let copiedUpTo = 0; + for (const match of editor.value.matchAll(/\bhttps?:\/\/[^\s<>"']+/g)) { + html += escapeHTML(editor.value.substring(copiedUpTo, match.index)); + const url = trimTrailingPunctuation(match[0]); + html += `${escapeHTML(url)}`; + copiedUpTo = match.index + url.length; + } + html += escapeHTML(editor.value.substring(copiedUpTo)); + dom.links.innerHTML = html; + dom.links.scrollTop = editor.scrollTop; +} + +function openLinkAtPoint(x, y) { + dom.links.style.pointerEvents = "auto"; + const element = document.elementFromPoint(x, y); + dom.links.style.pointerEvents = ""; + if (element === null) { + return; + } + const link = element.closest("#links a"); + if (link === null) { + return; + } + window.open(link.getAttribute("href"), "_blank", "noopener,noreferrer"); +} + function addSpaceForCompletionOffset(editor) { const cursorPos = editor.selectionEnd; const lineStart = editor.value.lastIndexOf("\n", editor.selectionEnd - 1) + 1; @@ -753,6 +810,7 @@ function toggleSnap() { dom.snapToCenter.classList.remove("display"); localStorage.setItem("ui.snap", "center"); } + renderLinks(); } function setExportAndSave() { @@ -825,6 +883,9 @@ function createDataJSON() { function storeThenSave(key, value) { localStorage.setItem(key, value); + if (key.startsWith("value.")) { + renderLinks(); + } requestSave(); } @@ -1137,6 +1198,17 @@ async function startApp() { dom.editor.inbox.addEventListener("focusin", () => { dom.menu.classList.remove("display"); }); + for (const editor of [dom.editor.today, dom.editor.inbox]) { + editor.addEventListener("scroll", () => { + dom.links.scrollTop = editor.scrollTop; + }); + editor.addEventListener("click", event => { + if ((platformApple && event.metaKey) || (!platformApple && event.ctrlKey)) { + openLinkAtPoint(event.clientX, event.clientY); + } + }); + } + window.addEventListener("resize", renderLinks); dom.menu.addEventListener("keydown", event => { if (event.target.hasAttribute("data-menu-seq") && matchKeyboardEvent(event, "", "arrowdown")) { event.preventDefault(); @@ -1377,6 +1449,7 @@ const dom = { today: document.getElementById("editor-today"), inbox: document.getElementById("editor-inbox") }, + links: document.getElementById("links"), today: document.getElementById("tab-today"), inbox: document.getElementById("tab-inbox"), info: document.getElementById("tab-info"), diff --git a/app/send.html b/app/send.html index 0b32f07..9ef9baa 100644 --- a/app/send.html +++ b/app/send.html @@ -9,7 +9,7 @@ - +
diff --git a/app/style.css b/app/style.css index 34570ba..7719435 100644 --- a/app/style.css +++ b/app/style.css @@ -167,6 +167,7 @@ a.popup:focus-visible { .main { display: flex; flex: 1; + position: relative; } textarea { flex: 1; @@ -191,6 +192,29 @@ textarea::placeholder { textarea.display { display: block; } +.links-overlay { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + padding: 0.6rem; + box-sizing: border-box; + border: 2px solid transparent; + border-radius: 6px; + color: transparent; + font-family: sans-serif; + font-size: 1rem; + white-space: pre-wrap; + overflow-wrap: break-word; + overflow: hidden; + user-select: none; +} +.links-overlay a { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--secondary-text); + text-underline-offset: 2px; +} .actions { display: flex; box-sizing: border-box; diff --git a/app/week.html b/app/week.html index c26b07b..0a91732 100644 --- a/app/week.html +++ b/app/week.html @@ -9,7 +9,7 @@ - +
From 3d4f0965dca20a0c3809e73c405f8c3d8cf133a1 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Thu, 22 Oct 2026 12:56:36 +0800 Subject: [PATCH 02/24] show hand cursor over links while holding the modifier key Set the textarea cursor to pointer when Cmd (macOS) or Ctrl (Windows/Linux) is held and the pointer is over a link. Track the pointer position so pressing or releasing the modifier key updates the cursor without mouse movement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/icon-normal.html | 2 +- app/icon-notify.html | 2 +- app/index.html | 2 +- app/main.js | 43 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/icon-normal.html b/app/icon-normal.html index 8ea0aa3..1f6074a 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -59,6 +59,6 @@
- + diff --git a/app/icon-notify.html b/app/icon-notify.html index e9cb618..94445d0 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -59,6 +59,6 @@
- + diff --git a/app/index.html b/app/index.html index d18f88c..ca6c7b8 100644 --- a/app/index.html +++ b/app/index.html @@ -59,6 +59,6 @@ - + diff --git a/app/main.js b/app/main.js index 534b20e..d10e05c 100644 --- a/app/main.js +++ b/app/main.js @@ -228,17 +228,29 @@ function renderLinks() { } function openLinkAtPoint(x, y) { + const link = linkAtPoint(x, y); + if (link === null) { + return; + } + window.open(link.getAttribute("href"), "_blank", "noopener,noreferrer"); +} + +function linkAtPoint(x, y) { dom.links.style.pointerEvents = "auto"; const element = document.elementFromPoint(x, y); dom.links.style.pointerEvents = ""; if (element === null) { - return; - } - const link = element.closest("#links a"); - if (link === null) { - return; + return null; } - window.open(link.getAttribute("href"), "_blank", "noopener,noreferrer"); + return element.closest("#links a"); +} + +let lastPointerX = 0; +let lastPointerY = 0; + +function updateLinkCursor(editor, x, y, modifier) { + const overLink = modifier && linkAtPoint(x, y) !== null; + editor.style.cursor = overLink ? "pointer" : ""; } function addSpaceForCompletionOffset(editor) { @@ -1207,7 +1219,26 @@ async function startApp() { openLinkAtPoint(event.clientX, event.clientY); } }); + editor.addEventListener("mousemove", event => { + lastPointerX = event.clientX; + lastPointerY = event.clientY; + updateLinkCursor(editor, event.clientX, event.clientY, (platformApple && event.metaKey) || (!platformApple && event.ctrlKey)); + }); } + window.addEventListener("keydown", event => { + if (event.key == (platformApple ? "Meta" : "Control")) { + for (const editor of [dom.editor.today, dom.editor.inbox]) { + updateLinkCursor(editor, lastPointerX, lastPointerY, (platformApple && event.metaKey) || (!platformApple && event.ctrlKey)); + } + } + }); + window.addEventListener("keyup", event => { + if (event.key == (platformApple ? "Meta" : "Control")) { + for (const editor of [dom.editor.today, dom.editor.inbox]) { + updateLinkCursor(editor, lastPointerX, lastPointerY, (platformApple && event.metaKey) || (!platformApple && event.ctrlKey)); + } + } + }); window.addEventListener("resize", renderLinks); dom.menu.addEventListener("keydown", event => { if (event.target.hasAttribute("data-menu-seq") && matchKeyboardEvent(event, "", "arrowdown")) { From 764a1d50bba8acec46ba792cf2e1bcb3b564518b Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Thu, 22 Oct 2026 13:02:32 +0800 Subject: [PATCH 03/24] clarify the link overlay implementation Extract modifierHeld() to replace three copies of the platform modifier check. Merge the keydown and keyup listeners into one loop. Comment the non-obvious mechanics: the width sync, the scroll copy, the pointer-events toggle, and the overlay technique. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/demo-week.html | 2 +- app/demo.html | 2 +- app/help.html | 10 +++++----- app/icon-normal.html | 4 ++-- app/icon-notify.html | 4 ++-- app/index.html | 4 ++-- app/main.js | 38 +++++++++++++++++++++++--------------- app/send.html | 2 +- app/style.css | 2 ++ app/week.html | 2 +- 10 files changed, 40 insertions(+), 30 deletions(-) diff --git a/app/demo-week.html b/app/demo-week.html index 00dcc0d..1b40102 100644 --- a/app/demo-week.html +++ b/app/demo-week.html @@ -4,7 +4,7 @@ Demo of Weekly Routine - +
diff --git a/app/demo.html b/app/demo.html index 06d731c..e93c6e0 100644 --- a/app/demo.html +++ b/app/demo.html @@ -4,7 +4,7 @@ Demo of Frogtab - +
diff --git a/app/help.html b/app/help.html index bff4e5c..c52c272 100644 --- a/app/help.html +++ b/app/help.html @@ -61,7 +61,7 @@

Frogtab Help

Overview of Frogtab

- +

Today is your to-do list for today. Begin the day by writing down everything you'd like to accomplish. @@ -79,7 +79,7 @@

Overview of Frogtab

Frogtab displays a notification badge to remind you to triage your inbox:

- +

At the beginning of each day, Frogtab:

@@ -129,7 +129,7 @@

Triaging your inbox

Snooze the task by writing # in front of the task:

- +

Frogtab will unsnooze the task at the beginning of the next day.

@@ -165,12 +165,12 @@

Setting up recurring tasks

Select a day, then write down your recurring tasks for that day. For example:

- +

If you like planning in the evening, write down your recurring tasks under the previous day, with # in front of each task. For example:

- +

You'll then be able to see the recurring tasks one day in advance. When Frogtab adds the tasks to your inbox, they'll be snoozed (because of the #), so they won't cause Frogtab to display a notification badge. diff --git a/app/icon-normal.html b/app/icon-normal.html index 1f6074a..c68ca8a 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -9,7 +9,7 @@ - +

@@ -59,6 +59,6 @@
- + diff --git a/app/icon-notify.html b/app/icon-notify.html index 94445d0..a3df63d 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -9,7 +9,7 @@ - +
@@ -59,6 +59,6 @@
- + diff --git a/app/index.html b/app/index.html index ca6c7b8..eff43ff 100644 --- a/app/index.html +++ b/app/index.html @@ -9,7 +9,7 @@ - +
@@ -59,6 +59,6 @@
- + diff --git a/app/main.js b/app/main.js index d10e05c..5f2cba6 100644 --- a/app/main.js +++ b/app/main.js @@ -213,6 +213,9 @@ function trimTrailingPunctuation(url) { function renderLinks() { const editor = dom.editor[selectedTab]; + // Match the overlay's text wrap width to the editor's: + // the editor's clientWidth excludes its 2px borders and scrollbar, + // while the overlay's width includes its own 2px transparent borders dom.links.style.width = `${editor.clientWidth + 4}px`; let html = ""; let copiedUpTo = 0; @@ -224,6 +227,7 @@ function renderLinks() { } html += escapeHTML(editor.value.substring(copiedUpTo)); dom.links.innerHTML = html; + // Copy the scroll position explicitly: setting scrollTop doesn't trigger a scroll event dom.links.scrollTop = editor.scrollTop; } @@ -236,6 +240,7 @@ function openLinkAtPoint(x, y) { } function linkAtPoint(x, y) { + // The overlay ignores pointer events, so let it receive them temporarily dom.links.style.pointerEvents = "auto"; const element = document.elementFromPoint(x, y); dom.links.style.pointerEvents = ""; @@ -245,6 +250,12 @@ function linkAtPoint(x, y) { return element.closest("#links a"); } +function modifierHeld(event) { + return (platformApple && event.metaKey) || (!platformApple && event.ctrlKey); +} + +// Last known pointer position, so that pressing or releasing the modifier key +// can update the cursor without any mouse movement let lastPointerX = 0; let lastPointerY = 0; @@ -1215,30 +1226,27 @@ async function startApp() { dom.links.scrollTop = editor.scrollTop; }); editor.addEventListener("click", event => { - if ((platformApple && event.metaKey) || (!platformApple && event.ctrlKey)) { + if (modifierHeld(event)) { openLinkAtPoint(event.clientX, event.clientY); } }); editor.addEventListener("mousemove", event => { lastPointerX = event.clientX; lastPointerY = event.clientY; - updateLinkCursor(editor, event.clientX, event.clientY, (platformApple && event.metaKey) || (!platformApple && event.ctrlKey)); + updateLinkCursor(editor, lastPointerX, lastPointerY, modifierHeld(event)); }); } - window.addEventListener("keydown", event => { - if (event.key == (platformApple ? "Meta" : "Control")) { - for (const editor of [dom.editor.today, dom.editor.inbox]) { - updateLinkCursor(editor, lastPointerX, lastPointerY, (platformApple && event.metaKey) || (!platformApple && event.ctrlKey)); - } - } - }); - window.addEventListener("keyup", event => { - if (event.key == (platformApple ? "Meta" : "Control")) { - for (const editor of [dom.editor.today, dom.editor.inbox]) { - updateLinkCursor(editor, lastPointerX, lastPointerY, (platformApple && event.metaKey) || (!platformApple && event.ctrlKey)); + // Update the cursor when the modifier key is pressed or released, + // even if the pointer hasn't moved since the last mousemove + for (const keyEvent of ["keydown", "keyup"]) { + window.addEventListener(keyEvent, event => { + if (event.key == (platformApple ? "Meta" : "Control")) { + for (const editor of [dom.editor.today, dom.editor.inbox]) { + updateLinkCursor(editor, lastPointerX, lastPointerY, modifierHeld(event)); + } } - } - }); + }); + } window.addEventListener("resize", renderLinks); dom.menu.addEventListener("keydown", event => { if (event.target.hasAttribute("data-menu-seq") && matchKeyboardEvent(event, "", "arrowdown")) { diff --git a/app/send.html b/app/send.html index 9ef9baa..b2dbc18 100644 --- a/app/send.html +++ b/app/send.html @@ -9,7 +9,7 @@ - +
diff --git a/app/style.css b/app/style.css index 7719435..6d2fd21 100644 --- a/app/style.css +++ b/app/style.css @@ -192,6 +192,8 @@ textarea::placeholder { textarea.display { display: block; } +/* Transparent overlay that mirrors the textarea's metrics. + Only the underlines of the links are visible. */ .links-overlay { position: absolute; inset: 0; diff --git a/app/week.html b/app/week.html index 0a91732..bd3ce3d 100644 --- a/app/week.html +++ b/app/week.html @@ -9,7 +9,7 @@ - +
From 1ab0776aa96e19c6dd1a7f3355ff8e3c20cc32ee Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Thu, 22 Oct 2026 13:30:05 +0800 Subject: [PATCH 04/24] add zero-dependency browser tests for the link overlay The tests embed app/index.html in an iframe and drive the real app: rendering, re-render triggers, scroll sync, hit-testing, and the hand cursor. Serve the repository root and open tests/test_links.html in a browser to run them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_links.html | 359 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 tests/test_links.html diff --git a/tests/test_links.html b/tests/test_links.html new file mode 100644 index 0000000..f8ac550 --- /dev/null +++ b/tests/test_links.html @@ -0,0 +1,359 @@ + + + + + Frogtab link tests + + + +

Frogtab link tests

+

Serve the repository root, then open this page in a browser:

+
python3 -m http.server 8124
+

+ The tests embed app/index.html in the iframe below and drive the real app. + They cover the link overlay: rendering, re-render triggers, scroll sync, hit-testing, + and the hand cursor. +

+

+  
+  
+
+
\ No newline at end of file

From 393d00ed1e3458e8d990aab40e1d76a40fe7200a Mon Sep 17 00:00:00 2001
From: Dave Wilding 
Date: Thu, 22 Oct 2026 13:54:54 +0800
Subject: [PATCH 05/24] move the link tests to app-test and add a just recipe
 to run them

The final pass over link handling found one gap: the url regex was
case-sensitive, so a pasted HTTPS://... url never became a link. Match
schemes in any case, and cover the gap in the tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 {tests => app-test}/test_links.html | 54 ++++++++++++++++++++++++++++-
 app/icon-normal.html                |  2 +-
 app/icon-notify.html                |  2 +-
 app/index.html                      |  2 +-
 app/main.js                         |  3 +-
 justfile                            | 14 ++++++++
 6 files changed, 72 insertions(+), 5 deletions(-)
 rename {tests => app-test}/test_links.html (82%)

diff --git a/tests/test_links.html b/app-test/test_links.html
similarity index 82%
rename from tests/test_links.html
rename to app-test/test_links.html
index f8ac550..ae340bf 100644
--- a/tests/test_links.html
+++ b/app-test/test_links.html
@@ -19,13 +19,14 @@
 
 
   

Frogtab link tests

-

Serve the repository root, then open this page in a browser:

+

Run just app-test from the repository root. To run the tests without just, serve the repository root and open this page in a browser:

python3 -m http.server 8124

The tests embed app/index.html in the iframe below and drive the real app. They cover the link overlay: rendering, re-render triggers, scroll sync, hit-testing, and the hand cursor.

+

The page title shows the result: Link tests: all passed.


   
   
+    
   
 
diff --git a/app/icon-notify.html b/app/icon-notify.html
index a3df63d..3bb0d70 100644
--- a/app/icon-notify.html
+++ b/app/icon-notify.html
@@ -59,6 +59,6 @@
         
- + diff --git a/app/index.html b/app/index.html index eff43ff..a02dc94 100644 --- a/app/index.html +++ b/app/index.html @@ -59,6 +59,6 @@ - + diff --git a/app/main.js b/app/main.js index 5f2cba6..bbfc982 100644 --- a/app/main.js +++ b/app/main.js @@ -219,7 +219,8 @@ function renderLinks() { dom.links.style.width = `${editor.clientWidth + 4}px`; let html = ""; let copiedUpTo = 0; - for (const match of editor.value.matchAll(/\bhttps?:\/\/[^\s<>"']+/g)) { + // URL schemes are case-insensitive, so match any case + for (const match of editor.value.matchAll(/\bhttps?:\/\/[^\s<>"']+/gi)) { html += escapeHTML(editor.value.substring(copiedUpTo, match.index)); const url = trimTrailingPunctuation(match[0]); html += `${escapeHTML(url)}`; diff --git a/justfile b/justfile index d7f766b..08450cb 100644 --- a/justfile +++ b/justfile @@ -78,3 +78,17 @@ app: _app-libs _app-libs: wget -O simple.min.css "https://cdn.simplecss.org/simple.min.css" wget -O openpgp.min.mjs "https://unpkg.com/openpgp@5.x/dist/openpgp.min.mjs" + +# Run the app tests in a browser +app-test: + #!/bin/sh + python3 -m http.server 8124 --bind 127.0.0.1 & + server_pid=$! + trap 'kill $server_pid 2>/dev/null' EXIT + for _ in $(seq 1 50); do + wget -q --spider http://127.0.0.1:8124/ && break + sleep 0.1 + done + xdg-open http://127.0.0.1:8124/app-test/test_links.html + echo "The tests are running in your browser. Press Ctrl+C to stop the server." + wait $server_pid From ef6a85a051ae87f7a9361e9f58767787d4df9366 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Thu, 22 Oct 2026 15:03:44 +0800 Subject: [PATCH 06/24] run the app tests in CI Serve the repository and run the test page in the runner's preinstalled headless Chrome. The virtual time budget lets the async test sequence settle before Chrome dumps the DOM, and the step greps the dumped title for the passing result. On failure it prints the test output block so the run log shows which checks failed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/app-test.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/app-test.yml diff --git a/.github/workflows/app-test.yml b/.github/workflows/app-test.yml new file mode 100644 index 0000000..25ee02d --- /dev/null +++ b/.github/workflows/app-test.yml @@ -0,0 +1,28 @@ +name: app-test + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + app-test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - name: Run the app tests + run: | + python3 -m http.server 8124 --bind 127.0.0.1 & + server_pid=$! + trap 'kill $server_pid 2>/dev/null' EXIT + for _ in $(seq 1 50); do + wget -q --spider http://127.0.0.1:8124/ && break + sleep 0.1 + done + google-chrome --headless=new --virtual-time-budget=20000 \ + --dump-dom http://127.0.0.1:8124/app-test/test_links.html > dom.html + grep -q "Link tests: all passed" dom.html || { + sed -n '/
/,/<\/pre>/p' dom.html
+            exit 1
+          }
\ No newline at end of file

From 3875979b9f1226bb638ea1a26c3084f70f5d3b4e Mon Sep 17 00:00:00 2001
From: Dave Wilding 
Date: Thu, 22 Oct 2026 15:14:07 +0800
Subject: [PATCH 07/24] match the gimmegit workflow style

Move the headless test run into a just recipe so CI and local
runs share one entry point. Pin the actions to commit SHAs,
drop the workflow's permissions to nothing, and rename the
file to .yaml.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .github/workflows/app-test.yaml | 21 +++++++++++++++++++++
 .github/workflows/app-test.yml  | 28 ----------------------------
 justfile                        | 19 +++++++++++++++++++
 3 files changed, 40 insertions(+), 28 deletions(-)
 create mode 100644 .github/workflows/app-test.yaml
 delete mode 100644 .github/workflows/app-test.yml

diff --git a/.github/workflows/app-test.yaml b/.github/workflows/app-test.yaml
new file mode 100644
index 0000000..72b784f
--- /dev/null
+++ b/.github/workflows/app-test.yaml
@@ -0,0 +1,21 @@
+name: App tests
+
+on:
+  pull_request:
+  push:
+    branches:
+      - main
+
+permissions: {}
+
+jobs:
+  app-test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
+        with:
+          persist-credentials: false
+      - name: Install uv
+        uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7  # v10.2.0
+      - name: Run the app tests
+        run: uvx --from rust-just@1.58.0 just app-test-ci
diff --git a/.github/workflows/app-test.yml b/.github/workflows/app-test.yml
deleted file mode 100644
index 25ee02d..0000000
--- a/.github/workflows/app-test.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: app-test
-
-on: [push, pull_request]
-
-permissions:
-  contents: read
-
-jobs:
-  app-test:
-    runs-on: ubuntu-latest
-    timeout-minutes: 5
-    steps:
-      - uses: actions/checkout@v7
-      - name: Run the app tests
-        run: |
-          python3 -m http.server 8124 --bind 127.0.0.1 &
-          server_pid=$!
-          trap 'kill $server_pid 2>/dev/null' EXIT
-          for _ in $(seq 1 50); do
-            wget -q --spider http://127.0.0.1:8124/ && break
-            sleep 0.1
-          done
-          google-chrome --headless=new --virtual-time-budget=20000 \
-            --dump-dom http://127.0.0.1:8124/app-test/test_links.html > dom.html
-          grep -q "Link tests: all passed" dom.html || {
-            sed -n '/
/,/<\/pre>/p' dom.html
-            exit 1
-          }
\ No newline at end of file
diff --git a/justfile b/justfile
index 08450cb..a716ddd 100644
--- a/justfile
+++ b/justfile
@@ -92,3 +92,22 @@ app-test:
   xdg-open http://127.0.0.1:8124/app-test/test_links.html
   echo "The tests are running in your browser. Press Ctrl+C to stop the server."
   wait $server_pid
+
+# Run the app tests in headless Chrome
+app-test-ci:
+  #!/bin/sh
+  set -eu
+  dom="$(mktemp)"
+  python3 -m http.server 8124 --bind 127.0.0.1 &
+  server_pid=$!
+  trap 'kill "$server_pid" 2>/dev/null; rm -f "$dom"' EXIT
+  for _ in $(seq 1 50); do
+    wget -q --spider http://127.0.0.1:8124/ && break
+    sleep 0.1
+  done
+  google-chrome --headless=new --virtual-time-budget=20000 \
+    --dump-dom http://127.0.0.1:8124/app-test/test_links.html > "$dom"
+  grep -q "Link tests: all passed" "$dom" || {
+    sed -n '/
/,/<\/pre>/p' "$dom"
+    exit 1
+  }

From 7167c7b58f885aab6e01f4bfea986741df47d0fa Mon Sep 17 00:00:00 2001
From: Dave Wilding 
Date: Thu, 22 Oct 2026 15:24:55 +0800
Subject: [PATCH 08/24] put the CI logic in the workflow

The uvx indirection existed only to install just on the
runner. Run the headless tests directly in the workflow
instead, and keep the app-test recipe for local use.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .github/workflows/app-test.yaml | 21 +++++++++++++++++----
 justfile                        | 19 -------------------
 2 files changed, 17 insertions(+), 23 deletions(-)

diff --git a/.github/workflows/app-test.yaml b/.github/workflows/app-test.yaml
index 72b784f..557439a 100644
--- a/.github/workflows/app-test.yaml
+++ b/.github/workflows/app-test.yaml
@@ -1,4 +1,4 @@
-name: App tests
+name: Browser-side app tests
 
 on:
   pull_request:
@@ -15,7 +15,20 @@ jobs:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
         with:
           persist-credentials: false
-      - name: Install uv
-        uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7  # v10.2.0
       - name: Run the app tests
-        run: uvx --from rust-just@1.58.0 just app-test-ci
+        run: |
+          set -euo pipefail
+          dom="$(mktemp)"
+          python3 -m http.server 8124 --bind 127.0.0.1 &
+          server_pid=$!
+          trap 'kill "$server_pid" 2>/dev/null; rm -f "$dom"' EXIT
+          for _ in $(seq 1 50); do
+            wget -q --spider http://127.0.0.1:8124/ && break
+            sleep 0.1
+          done
+          google-chrome --headless=new --virtual-time-budget=20000 \
+            --dump-dom http://127.0.0.1:8124/app-test/test_links.html > "$dom"
+          grep -q "Link tests: all passed" "$dom" || {
+            sed -n '/
/,/<\/pre>/p' "$dom"
+            exit 1
+          }
diff --git a/justfile b/justfile
index a716ddd..08450cb 100644
--- a/justfile
+++ b/justfile
@@ -92,22 +92,3 @@ app-test:
   xdg-open http://127.0.0.1:8124/app-test/test_links.html
   echo "The tests are running in your browser. Press Ctrl+C to stop the server."
   wait $server_pid
-
-# Run the app tests in headless Chrome
-app-test-ci:
-  #!/bin/sh
-  set -eu
-  dom="$(mktemp)"
-  python3 -m http.server 8124 --bind 127.0.0.1 &
-  server_pid=$!
-  trap 'kill "$server_pid" 2>/dev/null; rm -f "$dom"' EXIT
-  for _ in $(seq 1 50); do
-    wget -q --spider http://127.0.0.1:8124/ && break
-    sleep 0.1
-  done
-  google-chrome --headless=new --virtual-time-budget=20000 \
-    --dump-dom http://127.0.0.1:8124/app-test/test_links.html > "$dom"
-  grep -q "Link tests: all passed" "$dom" || {
-    sed -n '/
/,/<\/pre>/p' "$dom"
-    exit 1
-  }

From bcb75f499847f6b0fda596f3acf9ddadf101eee7 Mon Sep 17 00:00:00 2001
From: Dave Wilding 
Date: Thu, 22 Oct 2026 15:39:01 +0800
Subject: [PATCH 09/24] add pre-commit config with generic hygiene hooks

The app tests stay out of the commit hooks: just app-test opens a
browser and waits for Ctrl+C, so it can't gate commits. Run it on
demand, and let the CI workflow gate pushes and pull requests.

The first run of the fixers appended missing final newlines and
trimmed one trailing space, so the sha1 references are updated too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .pre-commit-config.yaml  | 10 ++++++++++
 app-test/test_links.html |  2 +-
 app/achievements.html    |  2 +-
 app/help.html            |  4 ++--
 app/simple.min.css       |  2 +-
 local/package/README.md  |  2 +-
 6 files changed, 16 insertions(+), 6 deletions(-)
 create mode 100644 .pre-commit-config.yaml

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..b89a71f
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,10 @@
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v6.0.0
+    hooks:
+      - id: check-merge-conflict
+      - id: trailing-whitespace
+      - id: mixed-line-ending
+      - id: end-of-file-fixer
+      - id: check-yaml
+      - id: check-toml
diff --git a/app-test/test_links.html b/app-test/test_links.html
index ae340bf..656d182 100644
--- a/app-test/test_links.html
+++ b/app-test/test_links.html
@@ -408,4 +408,4 @@ 

Frogtab link tests

})(); - \ No newline at end of file + diff --git a/app/achievements.html b/app/achievements.html index 44885b2..c636644 100644 --- a/app/achievements.html +++ b/app/achievements.html @@ -9,7 +9,7 @@ - + diff --git a/app/help.html b/app/help.html index c52c272..9b934d9 100644 --- a/app/help.html +++ b/app/help.html @@ -9,7 +9,7 @@ - + @@ -1217,4 +1217,4 @@

Changing the theme

startApp(); - \ No newline at end of file + diff --git a/app/simple.min.css b/app/simple.min.css index c7513b0..84cbd1d 100644 --- a/app/simple.min.css +++ b/app/simple.min.css @@ -1 +1 @@ -:root{--sans-font:-apple-system,BlinkMacSystemFont,"Avenir Next",Avenir,"Nimbus Sans L",Roboto,"Noto Sans","Segoe UI",Arial,Helvetica,"Helvetica Neue",sans-serif;--mono-font:Consolas,Menlo,Monaco,"Andale Mono","Ubuntu Mono",monospace;--standard-border-radius:5px;--border-width:1px;--bg:#fff;--accent-bg:#f5f7ff;--text:#212121;--text-light:#585858;--border:#898ea4;--accent:#0d47a1;--accent-hover:#1266e2;--accent-text:var(--bg);--code:#d81b60;--preformatted:#444;--marked:#fd3;--disabled:#efefef}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#212121;--accent-bg:#2b2b2b;--text:#dcdcdc;--text-light:#ababab;--accent:#ffb300;--accent-hover:#ffe099;--accent-text:var(--bg);--code:#f06292;--preformatted:#ccc;--disabled:#111}img,video{opacity:.8}}*,:before,:after{box-sizing:border-box}textarea,select,input,progress{-webkit-appearance:none;-moz-appearance:none;appearance:none}html{font-family:var(--sans-font);scroll-behavior:smooth}body{color:var(--text);background-color:var(--bg);grid-template-columns:1fr min(45rem,90%) 1fr;margin:0;font-size:1.15rem;line-height:1.5;display:grid}body>*{grid-column:2}body>header{background-color:var(--accent-bg);border-bottom:var(--border-width)solid var(--border);text-align:center;grid-column:1/-1;padding:0 .5rem 2rem}body>header>:only-child{margin-block-start:2rem}body>header h1{max-width:1200px;margin:1rem auto}body>header p{max-width:40rem;margin:1rem auto}main{padding-top:1.5rem}body>footer{color:var(--text-light);text-align:center;border-top:var(--border-width)solid var(--border);margin-top:4rem;padding:2rem 1rem 1.5rem;font-size:.9rem}h1{font-size:3rem}h2{margin-top:3rem;font-size:2.6rem}h3{margin-top:3rem;font-size:2rem}h4{font-size:1.44rem}h5{font-size:1.15rem}h6{font-size:.96rem}p{margin:1.5rem 0}p,h1,h2,h3,h4,h5,h6{overflow-wrap:break-word}h1,h2,h3{line-height:1.1}@media only screen and (width<=720px){h1{font-size:2.5rem}h2{font-size:2.1rem}h3{font-size:1.75rem}h4{font-size:1.25rem}}a,a:visited{color:var(--accent)}a:hover{text-decoration:none}button,.button,a.button,input[type=submit],input[type=reset],input[type=button]{border:var(--border-width)solid var(--accent);background-color:var(--accent);color:var(--accent-text);padding:.5rem .9rem;line-height:normal;text-decoration:none}.button[aria-disabled=true],input:disabled,textarea:disabled,select:disabled,button[disabled]{cursor:not-allowed;background-color:var(--disabled);border-color:var(--disabled);color:var(--text-light)}input[type=range]{padding:0}abbr[title]{cursor:help;text-decoration-line:underline;text-decoration-style:dotted}button:enabled:hover,.button:not([aria-disabled=true]):hover,input[type=submit]:enabled:hover,input[type=reset]:enabled:hover,input[type=button]:enabled:hover{background-color:var(--accent-hover);border-color:var(--accent-hover);cursor:pointer}.button:focus-visible,button:focus-visible:where(:enabled),input:enabled:focus-visible:where([type=submit],[type=reset],[type=button]){outline:2px solid var(--accent);outline-offset:1px}header nav{padding:1rem 0 0;font-size:1rem;line-height:2}header nav ul,header nav ol{flex-flow:wrap;place-content:space-around center;align-items:center;margin:0;padding:0;list-style-type:none;display:flex}header nav ul li,header nav ol li{display:inline-block}header nav a,header nav a:visited{border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);color:var(--text);margin:0 .5rem 1rem;padding:.1rem 1rem;text-decoration:none;display:inline-block}header nav a:hover,header nav a.current,header nav a[aria-current=page],header nav a[aria-current=true]{border-color:var(--accent);color:var(--accent);cursor:pointer}@media only screen and (width<=720px){header nav a{border:none;padding:0;line-height:1;text-decoration:underline}}aside,details,pre,progress{background-color:var(--accent-bg);border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem}aside{float:right;width:30%;margin-inline-start:15px;padding:0 15px;font-size:1rem}[dir=rtl] aside{float:left}@media only screen and (width<=720px){aside{float:none;width:100%;margin-inline-start:0}}article,fieldset,dialog{border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem;padding:1rem}article h2:first-child,section h2:first-child,article h3:first-child,section h3:first-child{margin-top:1rem}section{border-top:var(--border-width)solid var(--border);border-bottom:var(--border-width)solid var(--border);margin:3rem 0;padding:2rem 1rem}section+section,section:first-child{border-top:0;padding-top:0}section+section{margin-top:0}section:last-child{border-bottom:0;padding-bottom:0}details{padding:.7rem 1rem}summary{cursor:pointer;word-break:break-all;margin:-.7rem -1rem;padding:.7rem 1rem;font-weight:700}details[open]>summary+*{margin-top:0}details[open]>summary{margin-bottom:.5rem}details[open]>:last-child{margin-bottom:0}table{border-collapse:collapse;margin:1.5rem 0}figure>table{width:max-content;margin:0}td,th{border:var(--border-width)solid var(--border);text-align:start;padding:.5rem}th{background-color:var(--accent-bg);font-weight:700}tr:nth-child(2n){background-color:var(--accent-bg)}table caption{margin-bottom:.5rem;font-weight:700}textarea,select,input,button,.button{font-size:inherit;border-radius:var(--standard-border-radius);box-shadow:none;max-width:100%;margin-bottom:.5rem;padding:.5em;font-family:inherit;display:inline-block}textarea,select,input{color:var(--text);background-color:var(--bg);border:var(--border-width)solid var(--border)}label{display:block}textarea:not([cols]){width:100%}select:not([multiple]){background-image:linear-gradient(45deg,transparent 49%,var(--text)51%),linear-gradient(135deg,var(--text)51%,transparent 49%);background-position:calc(100% - 15px),calc(100% - 10px);background-repeat:no-repeat;background-size:5px 5px,5px 5px;padding-inline-end:25px}[dir=rtl] select:not([multiple]){background-position:10px,15px}input[type=checkbox],input[type=radio]{vertical-align:middle;width:min-content;position:relative}input[type=checkbox]+label,input[type=radio]+label{display:inline-block}input[type=radio]{border-radius:100%}input[type=checkbox]:checked,input[type=radio]:checked{background-color:var(--accent)}input[type=checkbox]:checked:after{content:" ";border-right:solid var(--bg).08em;border-bottom:solid var(--bg).08em;background-color:#0000;border-radius:0;width:.2em;height:.4em;font-size:1.8em;position:absolute;top:.04em;left:.18em;transform:rotate(45deg)}input[type=radio]:checked:after{content:" ";background-color:var(--bg);border-radius:100%;width:.3em;height:.3em;font-size:1.8em;position:absolute;top:.125em;left:.125em}@media only screen and (width<=720px){textarea,select,input{width:100%}}input[type=color]{height:2.5rem;padding:.2rem}input[type=file]{border:0}hr{height:var(--border-width);background:var(--border);border:none;margin:1rem auto}mark{border-radius:var(--standard-border-radius);background-color:var(--marked);color:#000;padding:2px 5px}mark a{color:#0d47a1}img,video{border-radius:var(--standard-border-radius);max-width:100%;height:auto}figure{margin:0;display:block;overflow-x:auto}figure>img,figure>picture>img{margin-inline:auto;display:block}figcaption{text-align:center;color:var(--text-light);margin-block:1rem;font-size:.9rem;position:sticky;left:0}blockquote{border-inline-start:.35rem solid var(--accent);color:var(--text-light);margin-block:2rem;margin-inline:2rem 0;padding:.4rem .8rem;font-style:italic}cite{color:var(--text-light);font-size:.9rem;font-style:normal}dt{color:var(--text-light)}code,pre,pre span,kbd,samp{font-family:var(--mono-font);color:var(--code)}kbd{color:var(--preformatted);border:var(--border-width)solid var(--preformatted);border-bottom:3px solid var(--preformatted);border-radius:var(--standard-border-radius);padding:.1rem .4rem}pre{max-width:100%;color:var(--preformatted);padding:1rem 1.4rem;overflow:auto}pre code{color:var(--preformatted);background:0 0;margin:0;padding:0}progress{width:100%}progress:indeterminate{background-color:var(--accent-bg)}progress::-webkit-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent-bg)}progress::-webkit-progress-value{border-radius:var(--standard-border-radius);background-color:var(--accent)}progress::-moz-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent);transition-property:width;transition-duration:.3s}progress:indeterminate::-moz-progress-bar{background-color:var(--accent-bg)}dialog{background-color:var(--bg);max-width:40rem;margin:auto}dialog::backdrop{background-color:var(--bg);opacity:.8}@media only screen and (width<=720px){dialog{max-width:calc(100vw - 2rem)}}sup,sub{vertical-align:baseline;position:relative}sup{top:-.4em}sub{top:.3em}.notice{background:var(--accent-bg);border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin:2rem 0;padding:1.5rem}@media print{@page{margin:1cm}body{display:block}body>header{background-color:unset}body>header nav,body>footer{display:none}article{border:none;padding:0}a[href^=http]:after{content:" <" attr(href)">"}abbr[title]:after{content:" (" attr(title)")"}a{text-decoration:none}p{widows:3;orphans:3}hr{border-top:var(--border-width)solid var(--border)}mark{border:var(--border-width)solid var(--border)}pre,table,figure,img,svg{break-inside:avoid}pre code{white-space:pre-wrap}} \ No newline at end of file +:root{--sans-font:-apple-system,BlinkMacSystemFont,"Avenir Next",Avenir,"Nimbus Sans L",Roboto,"Noto Sans","Segoe UI",Arial,Helvetica,"Helvetica Neue",sans-serif;--mono-font:Consolas,Menlo,Monaco,"Andale Mono","Ubuntu Mono",monospace;--standard-border-radius:5px;--border-width:1px;--bg:#fff;--accent-bg:#f5f7ff;--text:#212121;--text-light:#585858;--border:#898ea4;--accent:#0d47a1;--accent-hover:#1266e2;--accent-text:var(--bg);--code:#d81b60;--preformatted:#444;--marked:#fd3;--disabled:#efefef}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#212121;--accent-bg:#2b2b2b;--text:#dcdcdc;--text-light:#ababab;--accent:#ffb300;--accent-hover:#ffe099;--accent-text:var(--bg);--code:#f06292;--preformatted:#ccc;--disabled:#111}img,video{opacity:.8}}*,:before,:after{box-sizing:border-box}textarea,select,input,progress{-webkit-appearance:none;-moz-appearance:none;appearance:none}html{font-family:var(--sans-font);scroll-behavior:smooth}body{color:var(--text);background-color:var(--bg);grid-template-columns:1fr min(45rem,90%) 1fr;margin:0;font-size:1.15rem;line-height:1.5;display:grid}body>*{grid-column:2}body>header{background-color:var(--accent-bg);border-bottom:var(--border-width)solid var(--border);text-align:center;grid-column:1/-1;padding:0 .5rem 2rem}body>header>:only-child{margin-block-start:2rem}body>header h1{max-width:1200px;margin:1rem auto}body>header p{max-width:40rem;margin:1rem auto}main{padding-top:1.5rem}body>footer{color:var(--text-light);text-align:center;border-top:var(--border-width)solid var(--border);margin-top:4rem;padding:2rem 1rem 1.5rem;font-size:.9rem}h1{font-size:3rem}h2{margin-top:3rem;font-size:2.6rem}h3{margin-top:3rem;font-size:2rem}h4{font-size:1.44rem}h5{font-size:1.15rem}h6{font-size:.96rem}p{margin:1.5rem 0}p,h1,h2,h3,h4,h5,h6{overflow-wrap:break-word}h1,h2,h3{line-height:1.1}@media only screen and (width<=720px){h1{font-size:2.5rem}h2{font-size:2.1rem}h3{font-size:1.75rem}h4{font-size:1.25rem}}a,a:visited{color:var(--accent)}a:hover{text-decoration:none}button,.button,a.button,input[type=submit],input[type=reset],input[type=button]{border:var(--border-width)solid var(--accent);background-color:var(--accent);color:var(--accent-text);padding:.5rem .9rem;line-height:normal;text-decoration:none}.button[aria-disabled=true],input:disabled,textarea:disabled,select:disabled,button[disabled]{cursor:not-allowed;background-color:var(--disabled);border-color:var(--disabled);color:var(--text-light)}input[type=range]{padding:0}abbr[title]{cursor:help;text-decoration-line:underline;text-decoration-style:dotted}button:enabled:hover,.button:not([aria-disabled=true]):hover,input[type=submit]:enabled:hover,input[type=reset]:enabled:hover,input[type=button]:enabled:hover{background-color:var(--accent-hover);border-color:var(--accent-hover);cursor:pointer}.button:focus-visible,button:focus-visible:where(:enabled),input:enabled:focus-visible:where([type=submit],[type=reset],[type=button]){outline:2px solid var(--accent);outline-offset:1px}header nav{padding:1rem 0 0;font-size:1rem;line-height:2}header nav ul,header nav ol{flex-flow:wrap;place-content:space-around center;align-items:center;margin:0;padding:0;list-style-type:none;display:flex}header nav ul li,header nav ol li{display:inline-block}header nav a,header nav a:visited{border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);color:var(--text);margin:0 .5rem 1rem;padding:.1rem 1rem;text-decoration:none;display:inline-block}header nav a:hover,header nav a.current,header nav a[aria-current=page],header nav a[aria-current=true]{border-color:var(--accent);color:var(--accent);cursor:pointer}@media only screen and (width<=720px){header nav a{border:none;padding:0;line-height:1;text-decoration:underline}}aside,details,pre,progress{background-color:var(--accent-bg);border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem}aside{float:right;width:30%;margin-inline-start:15px;padding:0 15px;font-size:1rem}[dir=rtl] aside{float:left}@media only screen and (width<=720px){aside{float:none;width:100%;margin-inline-start:0}}article,fieldset,dialog{border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem;padding:1rem}article h2:first-child,section h2:first-child,article h3:first-child,section h3:first-child{margin-top:1rem}section{border-top:var(--border-width)solid var(--border);border-bottom:var(--border-width)solid var(--border);margin:3rem 0;padding:2rem 1rem}section+section,section:first-child{border-top:0;padding-top:0}section+section{margin-top:0}section:last-child{border-bottom:0;padding-bottom:0}details{padding:.7rem 1rem}summary{cursor:pointer;word-break:break-all;margin:-.7rem -1rem;padding:.7rem 1rem;font-weight:700}details[open]>summary+*{margin-top:0}details[open]>summary{margin-bottom:.5rem}details[open]>:last-child{margin-bottom:0}table{border-collapse:collapse;margin:1.5rem 0}figure>table{width:max-content;margin:0}td,th{border:var(--border-width)solid var(--border);text-align:start;padding:.5rem}th{background-color:var(--accent-bg);font-weight:700}tr:nth-child(2n){background-color:var(--accent-bg)}table caption{margin-bottom:.5rem;font-weight:700}textarea,select,input,button,.button{font-size:inherit;border-radius:var(--standard-border-radius);box-shadow:none;max-width:100%;margin-bottom:.5rem;padding:.5em;font-family:inherit;display:inline-block}textarea,select,input{color:var(--text);background-color:var(--bg);border:var(--border-width)solid var(--border)}label{display:block}textarea:not([cols]){width:100%}select:not([multiple]){background-image:linear-gradient(45deg,transparent 49%,var(--text)51%),linear-gradient(135deg,var(--text)51%,transparent 49%);background-position:calc(100% - 15px),calc(100% - 10px);background-repeat:no-repeat;background-size:5px 5px,5px 5px;padding-inline-end:25px}[dir=rtl] select:not([multiple]){background-position:10px,15px}input[type=checkbox],input[type=radio]{vertical-align:middle;width:min-content;position:relative}input[type=checkbox]+label,input[type=radio]+label{display:inline-block}input[type=radio]{border-radius:100%}input[type=checkbox]:checked,input[type=radio]:checked{background-color:var(--accent)}input[type=checkbox]:checked:after{content:" ";border-right:solid var(--bg).08em;border-bottom:solid var(--bg).08em;background-color:#0000;border-radius:0;width:.2em;height:.4em;font-size:1.8em;position:absolute;top:.04em;left:.18em;transform:rotate(45deg)}input[type=radio]:checked:after{content:" ";background-color:var(--bg);border-radius:100%;width:.3em;height:.3em;font-size:1.8em;position:absolute;top:.125em;left:.125em}@media only screen and (width<=720px){textarea,select,input{width:100%}}input[type=color]{height:2.5rem;padding:.2rem}input[type=file]{border:0}hr{height:var(--border-width);background:var(--border);border:none;margin:1rem auto}mark{border-radius:var(--standard-border-radius);background-color:var(--marked);color:#000;padding:2px 5px}mark a{color:#0d47a1}img,video{border-radius:var(--standard-border-radius);max-width:100%;height:auto}figure{margin:0;display:block;overflow-x:auto}figure>img,figure>picture>img{margin-inline:auto;display:block}figcaption{text-align:center;color:var(--text-light);margin-block:1rem;font-size:.9rem;position:sticky;left:0}blockquote{border-inline-start:.35rem solid var(--accent);color:var(--text-light);margin-block:2rem;margin-inline:2rem 0;padding:.4rem .8rem;font-style:italic}cite{color:var(--text-light);font-size:.9rem;font-style:normal}dt{color:var(--text-light)}code,pre,pre span,kbd,samp{font-family:var(--mono-font);color:var(--code)}kbd{color:var(--preformatted);border:var(--border-width)solid var(--preformatted);border-bottom:3px solid var(--preformatted);border-radius:var(--standard-border-radius);padding:.1rem .4rem}pre{max-width:100%;color:var(--preformatted);padding:1rem 1.4rem;overflow:auto}pre code{color:var(--preformatted);background:0 0;margin:0;padding:0}progress{width:100%}progress:indeterminate{background-color:var(--accent-bg)}progress::-webkit-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent-bg)}progress::-webkit-progress-value{border-radius:var(--standard-border-radius);background-color:var(--accent)}progress::-moz-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent);transition-property:width;transition-duration:.3s}progress:indeterminate::-moz-progress-bar{background-color:var(--accent-bg)}dialog{background-color:var(--bg);max-width:40rem;margin:auto}dialog::backdrop{background-color:var(--bg);opacity:.8}@media only screen and (width<=720px){dialog{max-width:calc(100vw - 2rem)}}sup,sub{vertical-align:baseline;position:relative}sup{top:-.4em}sub{top:.3em}.notice{background:var(--accent-bg);border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin:2rem 0;padding:1.5rem}@media print{@page{margin:1cm}body{display:block}body>header{background-color:unset}body>header nav,body>footer{display:none}article{border:none;padding:0}a[href^=http]:after{content:" <" attr(href)">"}abbr[title]:after{content:" (" attr(title)")"}a{text-decoration:none}p{widows:3;orphans:3}hr{border-top:var(--border-width)solid var(--border)}mark{border:var(--border-width)solid var(--border)}pre,table,figure,img,svg{break-inside:avoid}pre code{white-space:pre-wrap}} diff --git a/local/package/README.md b/local/package/README.md index 138d1ea..45ab1c1 100644 --- a/local/package/README.md +++ b/local/package/README.md @@ -8,7 +8,7 @@ Frogtab Local is a version of the [Frogtab](https://frogtab.com) task manager th For details, see the [Frogtab Local docs](https://github.com/dwilding/frogtab/blob/main/local/README.md#frogtab-local). -This package also provides a command `serve-frogtab /path/to/config.json`, which enables other service managers to run the server as a daemon. +This package also provides a command `serve-frogtab /path/to/config.json`, which enables other service managers to run the server as a daemon. ## License From 6c9c24769a6ba68bd7ab98be319432da89f006d4 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Thu, 22 Oct 2026 15:57:22 +0800 Subject: [PATCH 10/24] paint the action menus above the link overlay The overlay no longer sets z-index: 1. It still paints above the static textareas, and the action menus now paint above it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 12 ++++++++++++ app/demo-week.html | 2 +- app/demo.html | 2 +- app/help.html | 10 +++++----- app/icon-normal.html | 2 +- app/icon-notify.html | 2 +- app/index.html | 2 +- app/send.html | 2 +- app/style.css | 5 +++-- app/week.html | 2 +- 10 files changed, 27 insertions(+), 14 deletions(-) diff --git a/app-test/test_links.html b/app-test/test_links.html index 656d182..7babb33 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -208,6 +208,18 @@

Frogtab link tests

check("the overlay ignores pointer events", () => { assertEqual(win.getComputedStyle(links).pointerEvents, "none", "pointerEvents"); }); + check("the action menus paint above the overlay", () => { + const overlayCS = win.getComputedStyle(links); + const menuCS = win.getComputedStyle(doc.querySelector(".action-menu")); + // both are positioned, so compare paint order by z-index, then DOM order + assertEqual(overlayCS.zIndex, "auto", "overlay z-index"); + assertEqual(menuCS.zIndex, "auto", "menu z-index"); + const overlayPos = links.compareDocumentPosition(doc.querySelector(".action-menu")); + assert( + overlayPos & Node.DOCUMENT_POSITION_FOLLOWING, + "the menu should follow the overlay in DOM order" + ); + }); check("links are underlined in the secondary text color", () => { const anchorCS = win.getComputedStyle(links.querySelector("a")); assertEqual(anchorCS.textDecorationLine, "underline", "textDecorationLine"); diff --git a/app/demo-week.html b/app/demo-week.html index 1b40102..d95692b 100644 --- a/app/demo-week.html +++ b/app/demo-week.html @@ -4,7 +4,7 @@ Demo of Weekly Routine - +
diff --git a/app/demo.html b/app/demo.html index e93c6e0..7e2f0b4 100644 --- a/app/demo.html +++ b/app/demo.html @@ -4,7 +4,7 @@ Demo of Frogtab - +
diff --git a/app/help.html b/app/help.html index 9b934d9..787535b 100644 --- a/app/help.html +++ b/app/help.html @@ -61,7 +61,7 @@

Frogtab Help

Overview of Frogtab

- +

Today is your to-do list for today. Begin the day by writing down everything you'd like to accomplish. @@ -79,7 +79,7 @@

Overview of Frogtab

Frogtab displays a notification badge to remind you to triage your inbox:

- +

At the beginning of each day, Frogtab:

@@ -129,7 +129,7 @@

Triaging your inbox

Snooze the task by writing # in front of the task:

- +

Frogtab will unsnooze the task at the beginning of the next day.

@@ -165,12 +165,12 @@

Setting up recurring tasks

Select a day, then write down your recurring tasks for that day. For example:

- +

If you like planning in the evening, write down your recurring tasks under the previous day, with # in front of each task. For example:

- +

You'll then be able to see the recurring tasks one day in advance. When Frogtab adds the tasks to your inbox, they'll be snoozed (because of the #), so they won't cause Frogtab to display a notification badge. diff --git a/app/icon-normal.html b/app/icon-normal.html index 2fa9a95..0b18279 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -9,7 +9,7 @@ - +

diff --git a/app/icon-notify.html b/app/icon-notify.html index 3bb0d70..aeea3f2 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -9,7 +9,7 @@ - +
diff --git a/app/index.html b/app/index.html index a02dc94..1cbc833 100644 --- a/app/index.html +++ b/app/index.html @@ -9,7 +9,7 @@ - +
diff --git a/app/send.html b/app/send.html index b2dbc18..e7b9f66 100644 --- a/app/send.html +++ b/app/send.html @@ -9,7 +9,7 @@ - +
diff --git a/app/style.css b/app/style.css index 6d2fd21..7905438 100644 --- a/app/style.css +++ b/app/style.css @@ -193,11 +193,12 @@ textarea.display { display: block; } /* Transparent overlay that mirrors the textarea's metrics. - Only the underlines of the links are visible. */ + Only the underlines of the links are visible. + No z-index: the overlay still paints above the static textareas, + and the action menus paint above the overlay. */ .links-overlay { position: absolute; inset: 0; - z-index: 1; pointer-events: none; padding: 0.6rem; box-sizing: border-box; diff --git a/app/week.html b/app/week.html index bd3ce3d..cc51b5a 100644 --- a/app/week.html +++ b/app/week.html @@ -9,7 +9,7 @@ - +
From 63ffa82e8af2463e094dd001ab77756357303624 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Thu, 22 Oct 2026 23:17:30 +0800 Subject: [PATCH 11/24] test scrolled links in the app tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app-test/test_links.html b/app-test/test_links.html index 7babb33..8e39f10 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -288,6 +288,32 @@

Frogtab link tests

editorToday.dispatchEvent(new Event("scroll")); assertEqual(links.scrollTop, 40, "overlay scrollTop"); }); + check("the seed text overflows the editor", () => { + assert(editorToday.scrollHeight > editorToday.clientHeight, "the editor does not overflow"); + assert(links.scrollHeight > links.clientHeight, "the overlay does not overflow"); + }); + check("a link scrolled out of view is not clickable", () => { + // scroll to the end: the first line's link sits far above the clip box + editorToday.scrollTop = editorToday.scrollHeight; + editorToday.dispatchEvent(new Event("scroll")); + const first = links.querySelector("a"); + const overlayRect = links.getBoundingClientRect(); + const firstRect = first.getBoundingClientRect(); + assert(firstRect.bottom <= overlayRect.top, "the first link is still in view"); + assertEqual(win.linkAtPoint(firstRect.left + firstRect.width / 2, firstRect.top + firstRect.height / 2), null, "element"); + }); + check("a link visible while scrolled stays clickable", () => { + // scroll half a line: the first link is partly clipped, the second is fully in view + const lineHeight = links.querySelector("a").getBoundingClientRect().height; + editorToday.scrollTop = Math.round(lineHeight / 2); + editorToday.dispatchEvent(new Event("scroll")); + const second = links.querySelectorAll("a")[1]; + const secondRect = second.getBoundingClientRect(); + const overlayRect = links.getBoundingClientRect(); + assert(secondRect.top >= overlayRect.top && secondRect.bottom <= overlayRect.bottom, "the second link is not fully in view"); + const hit = win.linkAtPoint(secondRect.left + secondRect.width / 2, secondRect.top + secondRect.height / 2); + assertEqual(hit, second, "element"); + }); editorToday.scrollTop = 0; editorToday.dispatchEvent(new Event("scroll")); From 4434728d6a9b69350a31b36189ba974c3fbedde3 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 06:53:17 +0800 Subject: [PATCH 12/24] document opening a URL in the keyboard shortcuts table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/help.html | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/help.html b/app/help.html index 787535b..168b45d 100644 --- a/app/help.html +++ b/app/help.html @@ -91,10 +91,6 @@

Overview of Frogtab

You can then triage your inbox and decide what you'd like to accomplish today.

-

- Frogtab underlines any link that you add to Today or Inbox. - To open a link, hold ⌘ (macOS) or Ctrl (Windows/Linux) and click it. -

Adding tasks to your inbox

Frogtab makes it easy to capture any thoughts or potential tasks that arise as you're working through your priorities for the day. @@ -574,6 +570,17 @@

Keyboard shortcuts

Ctrl + B + + +

+ Open a URL +

+ + + ⌘ + click + Ctrl + click + +

From 03dcc9b5baecde1e165714391a3dbb76e9e4829e Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 06:59:43 +0800 Subject: [PATCH 13/24] update simple.min.css from the cdn Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/achievements.html | 2 +- app/help.html | 2 +- app/simple.min.css | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/achievements.html b/app/achievements.html index c636644..bd317b5 100644 --- a/app/achievements.html +++ b/app/achievements.html @@ -9,7 +9,7 @@ - + diff --git a/app/help.html b/app/help.html index 168b45d..cfb31d0 100644 --- a/app/help.html +++ b/app/help.html @@ -9,7 +9,7 @@ - + diff --git a/app/simple.min.css b/app/simple.min.css index 84cbd1d..514a7f7 100644 --- a/app/simple.min.css +++ b/app/simple.min.css @@ -1 +1 @@ -:root{--sans-font:-apple-system,BlinkMacSystemFont,"Avenir Next",Avenir,"Nimbus Sans L",Roboto,"Noto Sans","Segoe UI",Arial,Helvetica,"Helvetica Neue",sans-serif;--mono-font:Consolas,Menlo,Monaco,"Andale Mono","Ubuntu Mono",monospace;--standard-border-radius:5px;--border-width:1px;--bg:#fff;--accent-bg:#f5f7ff;--text:#212121;--text-light:#585858;--border:#898ea4;--accent:#0d47a1;--accent-hover:#1266e2;--accent-text:var(--bg);--code:#d81b60;--preformatted:#444;--marked:#fd3;--disabled:#efefef}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#212121;--accent-bg:#2b2b2b;--text:#dcdcdc;--text-light:#ababab;--accent:#ffb300;--accent-hover:#ffe099;--accent-text:var(--bg);--code:#f06292;--preformatted:#ccc;--disabled:#111}img,video{opacity:.8}}*,:before,:after{box-sizing:border-box}textarea,select,input,progress{-webkit-appearance:none;-moz-appearance:none;appearance:none}html{font-family:var(--sans-font);scroll-behavior:smooth}body{color:var(--text);background-color:var(--bg);grid-template-columns:1fr min(45rem,90%) 1fr;margin:0;font-size:1.15rem;line-height:1.5;display:grid}body>*{grid-column:2}body>header{background-color:var(--accent-bg);border-bottom:var(--border-width)solid var(--border);text-align:center;grid-column:1/-1;padding:0 .5rem 2rem}body>header>:only-child{margin-block-start:2rem}body>header h1{max-width:1200px;margin:1rem auto}body>header p{max-width:40rem;margin:1rem auto}main{padding-top:1.5rem}body>footer{color:var(--text-light);text-align:center;border-top:var(--border-width)solid var(--border);margin-top:4rem;padding:2rem 1rem 1.5rem;font-size:.9rem}h1{font-size:3rem}h2{margin-top:3rem;font-size:2.6rem}h3{margin-top:3rem;font-size:2rem}h4{font-size:1.44rem}h5{font-size:1.15rem}h6{font-size:.96rem}p{margin:1.5rem 0}p,h1,h2,h3,h4,h5,h6{overflow-wrap:break-word}h1,h2,h3{line-height:1.1}@media only screen and (width<=720px){h1{font-size:2.5rem}h2{font-size:2.1rem}h3{font-size:1.75rem}h4{font-size:1.25rem}}a,a:visited{color:var(--accent)}a:hover{text-decoration:none}button,.button,a.button,input[type=submit],input[type=reset],input[type=button]{border:var(--border-width)solid var(--accent);background-color:var(--accent);color:var(--accent-text);padding:.5rem .9rem;line-height:normal;text-decoration:none}.button[aria-disabled=true],input:disabled,textarea:disabled,select:disabled,button[disabled]{cursor:not-allowed;background-color:var(--disabled);border-color:var(--disabled);color:var(--text-light)}input[type=range]{padding:0}abbr[title]{cursor:help;text-decoration-line:underline;text-decoration-style:dotted}button:enabled:hover,.button:not([aria-disabled=true]):hover,input[type=submit]:enabled:hover,input[type=reset]:enabled:hover,input[type=button]:enabled:hover{background-color:var(--accent-hover);border-color:var(--accent-hover);cursor:pointer}.button:focus-visible,button:focus-visible:where(:enabled),input:enabled:focus-visible:where([type=submit],[type=reset],[type=button]){outline:2px solid var(--accent);outline-offset:1px}header nav{padding:1rem 0 0;font-size:1rem;line-height:2}header nav ul,header nav ol{flex-flow:wrap;place-content:space-around center;align-items:center;margin:0;padding:0;list-style-type:none;display:flex}header nav ul li,header nav ol li{display:inline-block}header nav a,header nav a:visited{border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);color:var(--text);margin:0 .5rem 1rem;padding:.1rem 1rem;text-decoration:none;display:inline-block}header nav a:hover,header nav a.current,header nav a[aria-current=page],header nav a[aria-current=true]{border-color:var(--accent);color:var(--accent);cursor:pointer}@media only screen and (width<=720px){header nav a{border:none;padding:0;line-height:1;text-decoration:underline}}aside,details,pre,progress{background-color:var(--accent-bg);border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem}aside{float:right;width:30%;margin-inline-start:15px;padding:0 15px;font-size:1rem}[dir=rtl] aside{float:left}@media only screen and (width<=720px){aside{float:none;width:100%;margin-inline-start:0}}article,fieldset,dialog{border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem;padding:1rem}article h2:first-child,section h2:first-child,article h3:first-child,section h3:first-child{margin-top:1rem}section{border-top:var(--border-width)solid var(--border);border-bottom:var(--border-width)solid var(--border);margin:3rem 0;padding:2rem 1rem}section+section,section:first-child{border-top:0;padding-top:0}section+section{margin-top:0}section:last-child{border-bottom:0;padding-bottom:0}details{padding:.7rem 1rem}summary{cursor:pointer;word-break:break-all;margin:-.7rem -1rem;padding:.7rem 1rem;font-weight:700}details[open]>summary+*{margin-top:0}details[open]>summary{margin-bottom:.5rem}details[open]>:last-child{margin-bottom:0}table{border-collapse:collapse;margin:1.5rem 0}figure>table{width:max-content;margin:0}td,th{border:var(--border-width)solid var(--border);text-align:start;padding:.5rem}th{background-color:var(--accent-bg);font-weight:700}tr:nth-child(2n){background-color:var(--accent-bg)}table caption{margin-bottom:.5rem;font-weight:700}textarea,select,input,button,.button{font-size:inherit;border-radius:var(--standard-border-radius);box-shadow:none;max-width:100%;margin-bottom:.5rem;padding:.5em;font-family:inherit;display:inline-block}textarea,select,input{color:var(--text);background-color:var(--bg);border:var(--border-width)solid var(--border)}label{display:block}textarea:not([cols]){width:100%}select:not([multiple]){background-image:linear-gradient(45deg,transparent 49%,var(--text)51%),linear-gradient(135deg,var(--text)51%,transparent 49%);background-position:calc(100% - 15px),calc(100% - 10px);background-repeat:no-repeat;background-size:5px 5px,5px 5px;padding-inline-end:25px}[dir=rtl] select:not([multiple]){background-position:10px,15px}input[type=checkbox],input[type=radio]{vertical-align:middle;width:min-content;position:relative}input[type=checkbox]+label,input[type=radio]+label{display:inline-block}input[type=radio]{border-radius:100%}input[type=checkbox]:checked,input[type=radio]:checked{background-color:var(--accent)}input[type=checkbox]:checked:after{content:" ";border-right:solid var(--bg).08em;border-bottom:solid var(--bg).08em;background-color:#0000;border-radius:0;width:.2em;height:.4em;font-size:1.8em;position:absolute;top:.04em;left:.18em;transform:rotate(45deg)}input[type=radio]:checked:after{content:" ";background-color:var(--bg);border-radius:100%;width:.3em;height:.3em;font-size:1.8em;position:absolute;top:.125em;left:.125em}@media only screen and (width<=720px){textarea,select,input{width:100%}}input[type=color]{height:2.5rem;padding:.2rem}input[type=file]{border:0}hr{height:var(--border-width);background:var(--border);border:none;margin:1rem auto}mark{border-radius:var(--standard-border-radius);background-color:var(--marked);color:#000;padding:2px 5px}mark a{color:#0d47a1}img,video{border-radius:var(--standard-border-radius);max-width:100%;height:auto}figure{margin:0;display:block;overflow-x:auto}figure>img,figure>picture>img{margin-inline:auto;display:block}figcaption{text-align:center;color:var(--text-light);margin-block:1rem;font-size:.9rem;position:sticky;left:0}blockquote{border-inline-start:.35rem solid var(--accent);color:var(--text-light);margin-block:2rem;margin-inline:2rem 0;padding:.4rem .8rem;font-style:italic}cite{color:var(--text-light);font-size:.9rem;font-style:normal}dt{color:var(--text-light)}code,pre,pre span,kbd,samp{font-family:var(--mono-font);color:var(--code)}kbd{color:var(--preformatted);border:var(--border-width)solid var(--preformatted);border-bottom:3px solid var(--preformatted);border-radius:var(--standard-border-radius);padding:.1rem .4rem}pre{max-width:100%;color:var(--preformatted);padding:1rem 1.4rem;overflow:auto}pre code{color:var(--preformatted);background:0 0;margin:0;padding:0}progress{width:100%}progress:indeterminate{background-color:var(--accent-bg)}progress::-webkit-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent-bg)}progress::-webkit-progress-value{border-radius:var(--standard-border-radius);background-color:var(--accent)}progress::-moz-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent);transition-property:width;transition-duration:.3s}progress:indeterminate::-moz-progress-bar{background-color:var(--accent-bg)}dialog{background-color:var(--bg);max-width:40rem;margin:auto}dialog::backdrop{background-color:var(--bg);opacity:.8}@media only screen and (width<=720px){dialog{max-width:calc(100vw - 2rem)}}sup,sub{vertical-align:baseline;position:relative}sup{top:-.4em}sub{top:.3em}.notice{background:var(--accent-bg);border:var(--border-width)solid var(--border);border-radius:var(--standard-border-radius);margin:2rem 0;padding:1.5rem}@media print{@page{margin:1cm}body{display:block}body>header{background-color:unset}body>header nav,body>footer{display:none}article{border:none;padding:0}a[href^=http]:after{content:" <" attr(href)">"}abbr[title]:after{content:" (" attr(title)")"}a{text-decoration:none}p{widows:3;orphans:3}hr{border-top:var(--border-width)solid var(--border)}mark{border:var(--border-width)solid var(--border)}pre,table,figure,img,svg{break-inside:avoid}pre code{white-space:pre-wrap}} +:root{--sans-font:-apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, "Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica, "Helvetica Neue", sans-serif;--serif-font:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;--mono-font:Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace;--standard-border-radius:5px;--border-width:1px;--bg:#fff;--accent-bg:#f5f7ff;--text:#212121;--text-light:#585858;--border:#898ea4;--accent:#0d47a1;--accent-hover:#1266e2;--accent-text:var(--bg);--code:#d81b60;--preformatted:#444;--marked:#fd3;--disabled:#efefef}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#212121;--accent-bg:#2b2b2b;--text:#dcdcdc;--text-light:#ababab;--accent:#ffb300;--accent-hover:#ffe099;--accent-text:var(--bg);--code:#f06292;--preformatted:#ccc;--disabled:#111}img,video{opacity:.8}}*,:before,:after{box-sizing:border-box}textarea,select,input,progress{-webkit-appearance:none;-moz-appearance:none;appearance:none}html{font-family:var(--sans-font);scroll-behavior:smooth}body{color:var(--text);background-color:var(--bg);grid-template-columns:1fr min(45rem,90%) 1fr;margin:0;font-size:1.15rem;line-height:1.5;display:grid}body>*{grid-column:2}body>header{background-color:var(--accent-bg);border-bottom:var(--border-width) solid var(--border);text-align:center;grid-column:1/-1;padding:0 .5rem 2rem}body>header>:only-child{margin-block-start:2rem}body>header h1{max-width:1200px;margin:1rem auto}body>header p{max-width:40rem;margin:1rem auto}main{padding-top:1.5rem}body>footer{color:var(--text-light);text-align:center;border-top:var(--border-width) solid var(--border);margin-top:4rem;padding:2rem 1rem 1.5rem;font-size:.9rem}h1{font-size:3rem}h2{margin-top:3rem;font-size:2.6rem}h3{margin-top:3rem;font-size:2rem}h4{font-size:1.44rem}h5{font-size:1.15rem}h6{font-size:.96rem}p{margin:1.5rem 0}p,h1,h2,h3,h4,h5,h6{overflow-wrap:break-word}h1,h2,h3{line-height:1.1}@media only screen and (width<=720px){h1{font-size:2.5rem}h2{font-size:2.1rem}h3{font-size:1.75rem}h4{font-size:1.25rem}}a,a:visited{color:var(--accent)}a:hover{text-decoration:none}button,.button,a.button,input[type=submit],input[type=reset],input[type=button]{border:var(--border-width) solid var(--accent);background-color:var(--accent);color:var(--accent-text);padding:.5em;line-height:normal;text-decoration:none}.button[aria-disabled=true],input:disabled,textarea:disabled,select:disabled,button[disabled]{cursor:not-allowed;background-color:var(--disabled);border-color:var(--disabled);color:var(--text-light)}input[type=range]{padding:0}abbr[title]{cursor:help;text-decoration-line:underline;text-decoration-style:dotted}button:enabled:hover,.button:not([aria-disabled=true]):hover,input[type=submit]:enabled:hover,input[type=reset]:enabled:hover,input[type=button]:enabled:hover{background-color:var(--accent-hover);border-color:var(--accent-hover);cursor:pointer}.button:focus-visible,button:focus-visible:where(:enabled),input:enabled:focus-visible:where([type=submit],[type=reset],[type=button]){outline:2px solid var(--accent);outline-offset:1px}header nav{padding:1rem 0 0;font-size:1rem;line-height:2}header nav ul,header nav ol{flex-flow:wrap;place-content:space-around center;align-items:center;margin:0;padding:0;list-style-type:none;display:flex}header nav ul li,header nav ol li{display:inline-block}header nav a,header nav a:visited{border:var(--border-width) solid var(--border);border-radius:var(--standard-border-radius);color:var(--text);margin:0 .5rem 1rem;padding:.1rem 1rem;text-decoration:none;display:inline-block}header nav a:hover,header nav a.current,header nav a[aria-current=page],header nav a[aria-current=true]{background:var(--bg);border-color:var(--accent);color:var(--accent);cursor:pointer}@media only screen and (width<=720px){header nav a{border:none;padding:0;line-height:1;text-decoration:underline}header nav a.current{background:0 0}}aside,details,pre,progress{background-color:var(--accent-bg);border:var(--border-width) solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem}aside{float:right;width:30%;margin-inline-start:15px;padding:0 15px;font-size:1rem}[dir=rtl] aside{float:left}@media only screen and (width<=720px){aside{float:none;width:100%;margin-inline-start:0}}article,fieldset,dialog{border:var(--border-width) solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem;padding:1rem}article h2:first-child,section h2:first-child,article h3:first-child,section h3:first-child{margin-top:1rem}section{border-top:var(--border-width) solid var(--border);border-bottom:var(--border-width) solid var(--border);margin:3rem 0;padding:2rem 1rem}section+section,section:first-child{border-top:0;padding-top:0}section+section{margin-top:0}section:last-child{border-bottom:0;padding-bottom:0}details{padding:.7rem 1rem}summary{cursor:pointer;word-break:break-word;margin:-.7rem -1rem;padding:.7rem 1rem;font-weight:700}details[open]>summary+*{margin-top:0}details[open]>summary{margin-bottom:.5rem}details[open]>:last-child{margin-bottom:0}details h2:first-of-type{margin-top:0}table{border-collapse:collapse;margin:1.5rem 0}figure>table{width:max-content;margin:0}td,th{border:var(--border-width) solid var(--border);text-align:start;padding:.5rem}th{background-color:var(--accent-bg);font-weight:700}tr:nth-child(2n){background-color:var(--accent-bg)}table caption{margin-bottom:.5rem;font-weight:700}textarea,select,input,button,.button{font-size:inherit;border-radius:var(--standard-border-radius);box-shadow:none;max-width:100%;margin-bottom:.5rem;padding:.5em;font-family:inherit;display:inline-block}textarea,select,input{color:var(--text);background-color:var(--bg);border:var(--border-width) solid var(--border)}label{display:block}textarea:not([cols]){width:100%}select:not([multiple]){background-image:linear-gradient(45deg, transparent 49%, var(--text) 51%), linear-gradient(135deg, var(--text) 51%, transparent 49%);background-position:calc(100% - 15px),calc(100% - 10px);background-repeat:no-repeat;background-size:5px 5px,5px 5px;padding-inline-end:25px}[dir=rtl] select:not([multiple]){background-position:10px,15px}input[type=checkbox],input[type=radio]{vertical-align:middle;width:min-content;position:relative}input[type=checkbox]+label,input[type=radio]+label{display:inline-block}input[type=radio]{border-radius:100%}input[type=checkbox]:checked,input[type=radio]:checked{background-color:var(--accent)}input[type=checkbox]:checked:after{content:" ";border-right:solid var(--bg) .08em;border-bottom:solid var(--bg) .08em;background-color:#0000;border-radius:0;width:.2em;height:.4em;font-size:1.8em;position:absolute;top:.04em;left:.18em;transform:rotate(45deg)}input[type=radio]:checked:after{content:" ";background-color:var(--bg);border-radius:100%;width:.3em;height:.3em;font-size:1.8em;position:absolute;top:.125em;left:.125em}@media only screen and (width<=720px){textarea,select,input{width:100%}}input[type=color]{height:2.5rem;padding:.2rem}input[type=file]{border:0}hr{height:var(--border-width);background:var(--border);border:none;margin:1rem auto}mark{border-radius:var(--standard-border-radius);background-color:var(--marked);color:#000;padding:2px 5px}mark a{color:var(--accent)}img,video{border-radius:var(--standard-border-radius);max-width:100%;height:auto}figure{margin:0;display:block;overflow-x:auto}figure>img,figure>picture>img{margin-inline:auto;display:block}figcaption{text-align:center;color:var(--text-light);margin-block:1rem;font-size:.9rem;position:sticky;left:0}blockquote{border-inline-start:.35rem solid var(--accent);color:var(--text-light);margin-block:2rem;margin-inline:2rem 0;padding:.4rem .8rem;font-style:italic}cite{color:var(--text-light);font-size:.9rem;font-style:normal}dt{color:var(--text-light)}code,pre,pre span,kbd,samp{font-family:var(--mono-font);color:var(--code)}kbd{color:var(--preformatted);border:var(--border-width) solid var(--preformatted);border-bottom:3px solid var(--preformatted);border-radius:var(--standard-border-radius);padding:.1rem .4rem}pre{max-width:100%;color:var(--preformatted);padding:1rem 1.4rem;overflow:auto}pre code{color:var(--preformatted);background:0 0;margin:0;padding:0}progress{width:100%}progress:indeterminate{background-color:var(--accent-bg)}progress::-webkit-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent-bg)}progress::-webkit-progress-value{border-radius:var(--standard-border-radius);background-color:var(--accent)}progress::-moz-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent);transition-property:width;transition-duration:.3s}progress:indeterminate::-moz-progress-bar{background-color:var(--accent-bg)}dialog{background-color:var(--bg);max-width:40rem;margin:auto}dialog::backdrop{background-color:var(--bg);opacity:.8}dialog h2:first-of-type{margin-top:0}@media only screen and (width<=720px){dialog{max-width:calc(100vw - 2rem)}}sup,sub{vertical-align:baseline;position:relative}sup{top:-.4em}sub{top:.3em}.notice{background:var(--accent-bg);border:var(--border-width) solid var(--border);border-radius:var(--standard-border-radius);margin:2rem 0;padding:1.5rem}div.notice p:first-of-type{margin-top:0}div.notice p:last-of-type{margin-bottom:0}@media print{@page{margin:1cm}body{display:block}body>header{background-color:unset}body>header nav,body>footer{display:none}article{border:none;padding:0}a[href^=http]:after{content:" <" attr(href) ">"}abbr[title]:after{content:" (" attr(title) ")"}a{text-decoration:none}p{widows:3;orphans:3}hr{border-top:var(--border-width) solid var(--border)}mark{border:var(--border-width) solid var(--border)}figure{break-inside:avoid}pre code{white-space:pre-wrap}} From c922c474307eb10eaf6d22cef006ec2f7b092d8c Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 07:01:58 +0800 Subject: [PATCH 14/24] end the vendored files with a newline in the app recipe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- justfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/justfile b/justfile index 08450cb..b9a1b25 100644 --- a/justfile +++ b/justfile @@ -76,8 +76,15 @@ app: _app-libs [working-directory: "app"] _app-libs: + #!/bin/sh + set -e wget -O simple.min.css "https://cdn.simplecss.org/simple.min.css" wget -O openpgp.min.mjs "https://unpkg.com/openpgp@5.x/dist/openpgp.min.mjs" + for file in simple.min.css openpgp.min.mjs; do + if [ -n "$(tail -c 1 "$file")" ]; then + echo >> "$file" + fi + done # Run the app tests in a browser app-test: From af2aa1c10856a8c60d77cf664a12dedd5b3daa33 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 07:04:37 +0800 Subject: [PATCH 15/24] use set -e in every sh recipe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- justfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/justfile b/justfile index b9a1b25..7e66293 100644 --- a/justfile +++ b/justfile @@ -15,6 +15,7 @@ package: app licenses ruff _package-stage _package-build [working-directory: "local"] _package-build: #!/bin/sh + set -e rm -rf .venv python3 -m venv .venv . .venv/bin/activate @@ -45,6 +46,7 @@ _package-templates-empty: [working-directory: "local"] package-upload: #!/bin/sh + set -e . .venv/bin/activate pip install twine twine upload package/dist/* @@ -89,6 +91,7 @@ _app-libs: # Run the app tests in a browser app-test: #!/bin/sh + set -e python3 -m http.server 8124 --bind 127.0.0.1 & server_pid=$! trap 'kill $server_pid 2>/dev/null' EXIT From 5e84e5af6e0ee6ab87bfbd61611a4fce2ba1e70e Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 07:16:13 +0800 Subject: [PATCH 16/24] show a tooltip over links with the modifier held Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 5 +++++ app/icon-normal.html | 2 +- app/icon-notify.html | 2 +- app/index.html | 2 +- app/main.js | 3 +++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app-test/test_links.html b/app-test/test_links.html index 8e39f10..84a2582 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -371,13 +371,18 @@

Frogtab link tests

win.updateLinkCursor(editorToday, onLink.x, onLink.y, true); assertEqual(editorToday.style.cursor, "pointer", "cursor"); }); + check("the tooltip names the platform modifier", () => { + assertEqual(editorToday.title, `Press ${platformApple ? "⌘" : "Ctrl"} + click to open`, "title"); + }); check("the hand disappears without the modifier", () => { win.updateLinkCursor(editorToday, onLink.x, onLink.y, false); assertEqual(editorToday.style.cursor, "", "cursor"); + assertEqual(editorToday.title, "", "title"); }); check("the hand does not appear on plain text", () => { win.updateLinkCursor(editorToday, onText.x, onText.y, true); assertEqual(editorToday.style.cursor, "", "cursor"); + assertEqual(editorToday.title, "", "title"); }); check("mousemove with the modifier shows the hand", () => { editorToday.dispatchEvent(new MouseEvent("mousemove", {clientX: onLink.x, clientY: onLink.y, ...modifierDown})); diff --git a/app/icon-normal.html b/app/icon-normal.html index 0b18279..ccd2af8 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -59,6 +59,6 @@
- + diff --git a/app/icon-notify.html b/app/icon-notify.html index aeea3f2..6d01df6 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -59,6 +59,6 @@
- + diff --git a/app/index.html b/app/index.html index 1cbc833..6f7d627 100644 --- a/app/index.html +++ b/app/index.html @@ -59,6 +59,6 @@
- + diff --git a/app/main.js b/app/main.js index bbfc982..017e340 100644 --- a/app/main.js +++ b/app/main.js @@ -263,6 +263,9 @@ let lastPointerY = 0; function updateLinkCursor(editor, x, y, modifier) { const overLink = modifier && linkAtPoint(x, y) !== null; editor.style.cursor = overLink ? "pointer" : ""; + // The overlay ignores pointer events, so the browser can't show a tooltip + // on the anchors. Set the tooltip on the editor instead. + editor.title = overLink ? `Press ${platformApple ? "⌘" : "Ctrl"} + click to open` : ""; } function addSpaceForCompletionOffset(editor) { From 9cf55b28ffd91f76d5036a535d80b514ff5ef823 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 07:23:06 +0800 Subject: [PATCH 17/24] show the tooltip on hover without the modifier Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 6 ++++-- app/icon-normal.html | 2 +- app/icon-notify.html | 2 +- app/index.html | 2 +- app/main.js | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app-test/test_links.html b/app-test/test_links.html index 84a2582..29ef28c 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -372,12 +372,14 @@

Frogtab link tests

assertEqual(editorToday.style.cursor, "pointer", "cursor"); }); check("the tooltip names the platform modifier", () => { - assertEqual(editorToday.title, `Press ${platformApple ? "⌘" : "Ctrl"} + click to open`, "title"); + assertEqual(editorToday.title, `${platformApple ? "⌘" : "Ctrl"}+click to open`, "title"); }); check("the hand disappears without the modifier", () => { win.updateLinkCursor(editorToday, onLink.x, onLink.y, false); assertEqual(editorToday.style.cursor, "", "cursor"); - assertEqual(editorToday.title, "", "title"); + }); + check("the tooltip stays without the modifier", () => { + assertEqual(editorToday.title, `${platformApple ? "⌘" : "Ctrl"}+click to open`, "title"); }); check("the hand does not appear on plain text", () => { win.updateLinkCursor(editorToday, onText.x, onText.y, true); diff --git a/app/icon-normal.html b/app/icon-normal.html index ccd2af8..0fb5f1a 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -59,6 +59,6 @@ - + diff --git a/app/icon-notify.html b/app/icon-notify.html index 6d01df6..7c75185 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -59,6 +59,6 @@ - + diff --git a/app/index.html b/app/index.html index 6f7d627..56b8005 100644 --- a/app/index.html +++ b/app/index.html @@ -59,6 +59,6 @@ - + diff --git a/app/main.js b/app/main.js index 017e340..8fbf9e9 100644 --- a/app/main.js +++ b/app/main.js @@ -261,11 +261,11 @@ let lastPointerX = 0; let lastPointerY = 0; function updateLinkCursor(editor, x, y, modifier) { - const overLink = modifier && linkAtPoint(x, y) !== null; - editor.style.cursor = overLink ? "pointer" : ""; + const overLink = linkAtPoint(x, y) !== null; + editor.style.cursor = modifier && overLink ? "pointer" : ""; // The overlay ignores pointer events, so the browser can't show a tooltip // on the anchors. Set the tooltip on the editor instead. - editor.title = overLink ? `Press ${platformApple ? "⌘" : "Ctrl"} + click to open` : ""; + editor.title = overLink ? `${platformApple ? "⌘" : "Ctrl"}+click to open` : ""; } function addSpaceForCompletionOffset(editor) { From 51e24f9bdf973aceca3eee92e0d2ccc86a669733 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 10:23:53 +0800 Subject: [PATCH 18/24] test the mac modifier branches with a spoofed platform CI runs on ubuntu, so platformApple was always false and the Cmd branches never executed. The test page now accepts ?platform=mac: it loads the app in a srcdoc iframe with navigator.platform spoofed as MacIntel, injected before main.js captures the platform. CI runs the suite in both modes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/app-test.yaml | 17 +++++++++++------ app-test/test_links.html | 28 ++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/app-test.yaml b/.github/workflows/app-test.yaml index 557439a..b325e7f 100644 --- a/.github/workflows/app-test.yaml +++ b/.github/workflows/app-test.yaml @@ -26,9 +26,14 @@ jobs: wget -q --spider http://127.0.0.1:8124/ && break sleep 0.1 done - google-chrome --headless=new --virtual-time-budget=20000 \ - --dump-dom http://127.0.0.1:8124/app-test/test_links.html > "$dom" - grep -q "Link tests: all passed" "$dom" || { - sed -n '/
/,/<\/pre>/p' "$dom"
-            exit 1
-          }
+          for url in \
+            "http://127.0.0.1:8124/app-test/test_links.html" \
+            "http://127.0.0.1:8124/app-test/test_links.html?platform=mac"; do
+            echo "Running $url"
+            google-chrome --headless=new --virtual-time-budget=20000 \
+              --dump-dom "$url" > "$dom"
+            grep -q "Link tests: all passed" "$dom" || {
+              sed -n '/
/,/<\/pre>/p' "$dom"
+              exit 1
+            }
+          done
diff --git a/app-test/test_links.html b/app-test/test_links.html
index 29ef28c..0b66702 100644
--- a/app-test/test_links.html
+++ b/app-test/test_links.html
@@ -24,7 +24,12 @@ 

Frogtab link tests

The tests embed app/index.html in the iframe below and drive the real app. They cover the link overlay: rendering, re-render triggers, scroll sync, hit-testing, - and the hand cursor. + the hand cursor, and the tooltip. +

+

+ With ?platform=mac in the URL, the tests load the app with + navigator.platform spoofed as MacIntel, so the Mac branches of + the modifier logic run on any OS. CI runs both modes.

The page title shows the result: Link tests: all passed.


@@ -47,8 +52,11 @@ 

Frogtab link tests

localStorage.setItem("value.inbox", inboxSeed); localStorage.setItem("tab", "today"); -// main.js gates the modifier key on the platform, so the tests must too -const platformApple = ( +// main.js gates the modifier key on the platform, so the tests must too. +// In ?platform=mac mode the app runs with a spoofed platform, so the +// fixtures and expected strings here flip with it. +const platformMac = new URLSearchParams(location.search).get("platform") == "mac"; +const platformApple = platformMac || ( navigator.platform.startsWith("Mac") || navigator.platform == "iPad" || navigator.platform == "iPhone" @@ -116,8 +124,20 @@

Frogtab link tests

} (async () => { - iframe.src = "../app/index.html"; try { + if (platformMac) { + // The spoof must run inside the iframe, before main.js captures + // platformApple at load time. The tag keeps the app's + // relative URLs resolving against the server. + const html = await (await fetch("../app/index.html")).text(); + const spoof = ` + diff --git a/app/icon-notify.html b/app/icon-notify.html index 7c75185..6afdc67 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -59,6 +59,6 @@ - + diff --git a/app/index.html b/app/index.html index 56b8005..f17a3d9 100644 --- a/app/index.html +++ b/app/index.html @@ -59,6 +59,6 @@ - + diff --git a/app/main.js b/app/main.js index 8fbf9e9..53e42a3 100644 --- a/app/main.js +++ b/app/main.js @@ -265,7 +265,7 @@ function updateLinkCursor(editor, x, y, modifier) { editor.style.cursor = modifier && overLink ? "pointer" : ""; // The overlay ignores pointer events, so the browser can't show a tooltip // on the anchors. Set the tooltip on the editor instead. - editor.title = overLink ? `${platformApple ? "⌘" : "Ctrl"}+click to open` : ""; + editor.title = overLink ? `${platformApple ? "⌘" : "Ctrl"}-click to open` : ""; } function addSpaceForCompletionOffset(editor) { From fb220ccf73ea90f07067367df53b3a378ce87920 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 10:42:35 +0800 Subject: [PATCH 20/24] disable the editor scroll bounce The overlay syncs to scrollTop, which stays clamped while the editor rubber-bands, so the underlines would drift during the bounce. The tests assert the computed overscroll-behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 5 +++++ app/demo-week.html | 2 +- app/demo.html | 2 +- app/help.html | 10 +++++----- app/icon-normal.html | 2 +- app/icon-notify.html | 2 +- app/index.html | 2 +- app/send.html | 2 +- app/style.css | 3 +++ app/week.html | 2 +- 10 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app-test/test_links.html b/app-test/test_links.html index 317bbaf..762465d 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -312,6 +312,11 @@

Frogtab link tests

assert(editorToday.scrollHeight > editorToday.clientHeight, "the editor does not overflow"); assert(links.scrollHeight > links.clientHeight, "the overlay does not overflow"); }); + check("the editor disables the scroll bounce", () => { + // The overlay syncs to scrollTop, which stays clamped while the editor + // rubber-bands, so the underlines would drift during the bounce. + assertEqual(win.getComputedStyle(editorToday).overscrollBehavior, "none", "overscroll-behavior"); + }); check("a link scrolled out of view is not clickable", () => { // scroll to the end: the first line's link sits far above the clip box editorToday.scrollTop = editorToday.scrollHeight; diff --git a/app/demo-week.html b/app/demo-week.html index d95692b..527bef8 100644 --- a/app/demo-week.html +++ b/app/demo-week.html @@ -4,7 +4,7 @@ Demo of Weekly Routine - +
diff --git a/app/demo.html b/app/demo.html index 7e2f0b4..0492a67 100644 --- a/app/demo.html +++ b/app/demo.html @@ -4,7 +4,7 @@ Demo of Frogtab - +
diff --git a/app/help.html b/app/help.html index cfb31d0..5a83890 100644 --- a/app/help.html +++ b/app/help.html @@ -61,7 +61,7 @@

Frogtab Help

Overview of Frogtab

- +

Today is your to-do list for today. Begin the day by writing down everything you'd like to accomplish. @@ -79,7 +79,7 @@

Overview of Frogtab

Frogtab displays a notification badge to remind you to triage your inbox:

- +

At the beginning of each day, Frogtab:

@@ -125,7 +125,7 @@

Triaging your inbox

Snooze the task by writing # in front of the task:

- +

Frogtab will unsnooze the task at the beginning of the next day.

@@ -161,12 +161,12 @@

Setting up recurring tasks

Select a day, then write down your recurring tasks for that day. For example:

- +

If you like planning in the evening, write down your recurring tasks under the previous day, with # in front of each task. For example:

- +

You'll then be able to see the recurring tasks one day in advance. When Frogtab adds the tasks to your inbox, they'll be snoozed (because of the #), so they won't cause Frogtab to display a notification badge. diff --git a/app/icon-normal.html b/app/icon-normal.html index f80d575..17a4528 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -9,7 +9,7 @@ - +

diff --git a/app/icon-notify.html b/app/icon-notify.html index 6afdc67..623d094 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -9,7 +9,7 @@ - +
diff --git a/app/index.html b/app/index.html index f17a3d9..9239a79 100644 --- a/app/index.html +++ b/app/index.html @@ -9,7 +9,7 @@ - +
diff --git a/app/send.html b/app/send.html index e7b9f66..6cd207c 100644 --- a/app/send.html +++ b/app/send.html @@ -9,7 +9,7 @@ - +
diff --git a/app/style.css b/app/style.css index 7905438..9ecabac 100644 --- a/app/style.css +++ b/app/style.css @@ -181,6 +181,9 @@ textarea { color: inherit; font-family: sans-serif; font-size: 1rem; + /* The link overlay syncs to scrollTop, which stays clamped while the + editor rubber-bands. Disable the bounce so the underlines stay put. */ + overscroll-behavior: none; display: none; } textarea::placeholder { diff --git a/app/week.html b/app/week.html index cc51b5a..5225867 100644 --- a/app/week.html +++ b/app/week.html @@ -9,7 +9,7 @@ - +
From 8bd06a64969d2778a7fd00135676fd4121117a2a Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 11:38:49 +0800 Subject: [PATCH 21/24] handle stale cursor state after scrolling or losing focus The final review found two gaps in the link overlay: Scrolling moves the content under a stationary pointer without any mousemove, so the hand cursor and the tooltip went stale. Track the last pointer position and modifier state, re-evaluate the cursor in the scroll listener, and clear the state when the window loses focus, since the modifier can be released while the window is unfocused without a keyup reaching the page. Nothing guarded font-metric parity between the textarea and the overlay, so a future font change on one but not the other would drift the underlines while every existing check still passed. Add a computed-style tripwire to the test suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 58 ++++++++++++++++++++++++++++++++++++++++ app/icon-normal.html | 2 +- app/icon-notify.html | 2 +- app/index.html | 2 +- app/main.js | 22 ++++++++++++--- 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/app-test/test_links.html b/app-test/test_links.html index 762465d..7d36f80 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -190,6 +190,16 @@

Frogtab link tests

// borders can render at a fractional width, so allow a sub-pixel difference assert(Math.abs(overlayWrap - editorWrap) <= 1, `wrap widths differ: overlay ${overlayWrap}, editor ${editorWrap}`); }); + check("the overlay font metrics match the editor", () => { + // the underlines sit under the right text only if the overlay wraps and + // measures text exactly like the editor, so guard the font properties + // that determine wrapping and glyph positions + const overlayCS = win.getComputedStyle(links); + const editorCS = win.getComputedStyle(editorToday); + for (const property of ["font-family", "font-size", "font-weight", "line-height", "letter-spacing", "font-kerning", "font-variant-ligatures"]) { + assertEqual(overlayCS[property], editorCS[property], property); + } + }); check("the overlay text mirrors the editor value", () => { assertEqual(links.textContent, editorToday.value, "textContent"); }); @@ -449,6 +459,54 @@

Frogtab link tests

assertEqual(editorInbox.style.cursor, "", "inbox cursor"); }); + line("staleness"); + // Scrolling moves the content under a stationary pointer, so the scroll + // listener must re-evaluate the cursor and the tooltip. + check("scrolling clears the tooltip when the link moves off the pointer", () => { + editorToday.dispatchEvent(new MouseEvent("mousemove", {clientX: onLink.x, clientY: onLink.y})); + assertEqual(editorToday.title, `${platformApple ? "⌘" : "Ctrl"}-click to open`, "title before scrolling"); + editorToday.scrollTop = editorToday.scrollHeight; + editorToday.dispatchEvent(new Event("scroll")); + assertEqual(editorToday.style.cursor, "", "cursor"); + assertEqual(editorToday.title, "", "title"); + }); + check("scrolling shows the tooltip when a link moves under the pointer", () => { + editorToday.dispatchEvent(new MouseEvent("mousemove", {clientX: onLink.x, clientY: onLink.y})); + editorToday.scrollTop = editorToday.scrollHeight; + editorToday.dispatchEvent(new Event("scroll")); + assertEqual(editorToday.title, "", "title while the link is away"); + editorToday.scrollTop = 0; + editorToday.dispatchEvent(new Event("scroll")); + assertEqual(editorToday.title, `${platformApple ? "⌘" : "Ctrl"}-click to open`, "title"); + }); + check("scrolling with the modifier held clears the hand when the link moves away", () => { + editorToday.dispatchEvent(new MouseEvent("mousemove", {clientX: onLink.x, clientY: onLink.y, ...modifierDown})); + win.dispatchEvent(new KeyboardEvent("keydown", {key: modifierKey, ...modifierDown})); + assertEqual(editorToday.style.cursor, "pointer", "cursor before scrolling"); + editorToday.scrollTop = editorToday.scrollHeight; + editorToday.dispatchEvent(new Event("scroll")); + assertEqual(editorToday.style.cursor, "", "cursor"); + assertEqual(editorToday.title, "", "title"); + // scroll back so the blur check starts with the link under the pointer + editorToday.scrollTop = 0; + editorToday.dispatchEvent(new Event("scroll")); + }); + check("window blur clears the tracked modifier state", () => { + editorToday.dispatchEvent(new MouseEvent("mousemove", {clientX: onLink.x, clientY: onLink.y, ...modifierDown})); + win.dispatchEvent(new KeyboardEvent("keydown", {key: modifierKey, ...modifierDown})); + assertEqual(editorToday.style.cursor, "pointer", "cursor before blur"); + win.dispatchEvent(new Event("blur")); + assertEqual(editorToday.style.cursor, "", "cursor after blur"); + // The modifier can be released while the window is unfocused, without a + // keyup reaching the page. Scrolling must not bring the hand back. + editorToday.scrollTop = editorToday.scrollHeight; + editorToday.dispatchEvent(new Event("scroll")); + editorToday.scrollTop = 0; + editorToday.dispatchEvent(new Event("scroll")); + assertEqual(editorToday.style.cursor, "", "cursor after scrolling back"); + assertEqual(editorToday.title, `${platformApple ? "⌘" : "Ctrl"}-click to open`, "title after scrolling back"); + }); + line("modifier"); check("modifierHeld recognizes the platform modifier", () => { assert(win.modifierHeld(modifierDown), "the platform modifier is not recognized"); diff --git a/app/icon-normal.html b/app/icon-normal.html index 17a4528..1d21bc6 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -59,6 +59,6 @@
- + diff --git a/app/icon-notify.html b/app/icon-notify.html index 623d094..edc9627 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -59,6 +59,6 @@
- + diff --git a/app/index.html b/app/index.html index 9239a79..6a7660d 100644 --- a/app/index.html +++ b/app/index.html @@ -59,6 +59,6 @@
- + diff --git a/app/main.js b/app/main.js index 53e42a3..ca340b6 100644 --- a/app/main.js +++ b/app/main.js @@ -255,10 +255,12 @@ function modifierHeld(event) { return (platformApple && event.metaKey) || (!platformApple && event.ctrlKey); } -// Last known pointer position, so that pressing or releasing the modifier key -// can update the cursor without any mouse movement +// Last known pointer position and modifier state, so that pressing or +// releasing the modifier key, or scrolling, can update the cursor +// without any mouse movement let lastPointerX = 0; let lastPointerY = 0; +let modifierIsHeld = false; function updateLinkCursor(editor, x, y, modifier) { const overLink = linkAtPoint(x, y) !== null; @@ -1228,6 +1230,8 @@ async function startApp() { for (const editor of [dom.editor.today, dom.editor.inbox]) { editor.addEventListener("scroll", () => { dom.links.scrollTop = editor.scrollTop; + // The content moved under the pointer, so re-evaluate the cursor + updateLinkCursor(editor, lastPointerX, lastPointerY, modifierIsHeld); }); editor.addEventListener("click", event => { if (modifierHeld(event)) { @@ -1237,7 +1241,8 @@ async function startApp() { editor.addEventListener("mousemove", event => { lastPointerX = event.clientX; lastPointerY = event.clientY; - updateLinkCursor(editor, lastPointerX, lastPointerY, modifierHeld(event)); + modifierIsHeld = modifierHeld(event); + updateLinkCursor(editor, lastPointerX, lastPointerY, modifierIsHeld); }); } // Update the cursor when the modifier key is pressed or released, @@ -1245,12 +1250,21 @@ async function startApp() { for (const keyEvent of ["keydown", "keyup"]) { window.addEventListener(keyEvent, event => { if (event.key == (platformApple ? "Meta" : "Control")) { + modifierIsHeld = keyEvent == "keydown"; for (const editor of [dom.editor.today, dom.editor.inbox]) { - updateLinkCursor(editor, lastPointerX, lastPointerY, modifierHeld(event)); + updateLinkCursor(editor, lastPointerX, lastPointerY, modifierIsHeld); } } }); } + // The modifier key can be released while the window is unfocused, without + // a keyup reaching us. Clear the tracked state when the window loses focus. + window.addEventListener("blur", () => { + modifierIsHeld = false; + for (const editor of [dom.editor.inbox, dom.editor.today]) { + updateLinkCursor(editor, lastPointerX, lastPointerY, false); + } + }); window.addEventListener("resize", renderLinks); dom.menu.addEventListener("keydown", event => { if (event.target.hasAttribute("data-menu-seq") && matchKeyboardEvent(event, "", "arrowdown")) { From 25e3b44881c43f72de3b3b5156d19808287993fb Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 13:46:16 +0800 Subject: [PATCH 22/24] render links from the editor's final state after every mutation The overlay rendered from the editor's value and scroll position before three mutators finished writing them: inboxSnoozeSelected rendered before its scroll-measurement dance, selectUnsnoozed rendered before focus() could scroll to the caret, and the body Ctrl+Enter handler rendered before it reset the scroll to the top of the gap. In each case the underlines could stay stale until the next render. Move each storeThenSave call after the final value and scrollTop assignment so the overlay always renders from the final state. The scroll-measurement dance in inboxSnoozeSelected is unchanged. The mutators test section drives every code path that changes the editor's text or scroll position and verifies the overlay against the editor afterwards. Each check starts from the seeded state, so a failure cannot leak its setup into the checks that follow. Mutation testing confirms each fix is caught by its check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 209 +++++++++++++++++++++++++++++++++++++++ app/icon-normal.html | 2 +- app/icon-notify.html | 2 +- app/index.html | 2 +- app/main.js | 6 +- 5 files changed, 215 insertions(+), 6 deletions(-) diff --git a/app-test/test_links.html b/app-test/test_links.html index 7d36f80..2e64734 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -515,6 +515,215 @@

Frogtab link tests

assert(!win.modifierHeld(other), "the other modifier is treated as held"); }); + line("mutators"); + // Every code path that changes the editor's text or scroll position must + // leave the overlay rendered from the final state. Each check drives one + // mutator and verifies the overlay against the editor afterwards. + const assertOverlayMatches = editor => { + assertEqual(links.textContent, editor.value, "overlay text"); + const expected = [...editor.value.matchAll(/\bhttps?:\/\/[^\s<>"']+/gi)] + .map(match => win.trimTrailingPunctuation(match[0])); + const anchors = links.querySelectorAll("a"); + assertEqual(anchors.length, expected.length, "link count"); + for (let i = 0; i < anchors.length; i++) { + assertEqual(anchors[i].getAttribute("href"), expected[i], `href ${i}`); + } + assertEqual(links.scrollTop, editor.scrollTop, "overlay scrollTop"); + }; + // FrogtabDate is a class declaration, so it does not land on window. + // This replicates its stringFromObject to compute expected dates. + const dateString = date => [ + date.toLocaleDateString(undefined, {year: "numeric"}), + date.toLocaleDateString(undefined, {month: "2-digit"}), + date.toLocaleDateString(undefined, {day: "2-digit"}) + ].join("-"); + const restoreLinkState = () => { + localStorage.setItem("value.inbox", inboxSeed); + localStorage.removeItem("achievements"); + win.switchToTab("today"); + editorToday.value = todaySeed; + // Assigning value resets the caret to the end, and a later focus() would + // scroll that out-of-view caret into view. Keep the caret at the top. + editorToday.setSelectionRange(0, 0); + editorToday.scrollTop = 0; + win.storeThenSave("value.today", todaySeed); + win.setNotifyStatus(); + }; + + // Each mutator check starts from the seeded state, so a failure cannot + // leak its setup into the checks that follow. + const mutatorCheck = (name, fn) => check(name, () => { + restoreLinkState(); + fn(); + }); + mutatorCheck("Ctrl+/ on today snoozes the line and re-renders the overlay", () => { + editorToday.setSelectionRange(0, 0); + editorToday.dispatchEvent(new KeyboardEvent("keydown", {key: "/", ...modifierDown})); + assertEqual(editorToday.value, todaySeed.split("\n").slice(1).join("\n"), "editor value"); + assertOverlayMatches(editorToday); + assertEqual(localStorage.getItem("value.inbox"), `# ${todaySeed.split("\n")[0]}\n\n${inboxSeed}`, "stored inbox value"); + win.switchToTab("inbox"); + assertOverlayMatches(editorInbox); + }); + mutatorCheck("Ctrl+/ on inbox adds # and shifts the underline", () => { + win.switchToTab("inbox"); + editorInbox.setSelectionRange(0, 0); + editorInbox.dispatchEvent(new KeyboardEvent("keydown", {key: "/", ...modifierDown})); + assertEqual(editorInbox.value, `# ${inboxSeed}`, "editor value"); + assertOverlayMatches(editorInbox); + }); + mutatorCheck("Ctrl+/ on a scrolled inbox keeps the overlay scroll in sync", () => { + const lines = [inboxSeed, ...Array.from({length: 60}, (_, i) => `filler line ${i}`)]; + localStorage.setItem("value.inbox", lines.join("\n")); + win.switchToTab("inbox"); + editorInbox.scrollTop = 0; + const cursorPos = lines.slice(0, 40).join("\n").length + 1; + editorInbox.setSelectionRange(cursorPos, cursorPos); + editorInbox.blur(); + editorInbox.dispatchEvent(new KeyboardEvent("keydown", {key: "/", ...modifierDown})); + const expectedValue = [...lines.slice(0, 40), `# ${lines[40]}`, ...lines.slice(41)].join("\n"); + assertEqual(editorInbox.value, expectedValue, "editor value"); + assert(editorInbox.scrollTop > 0, "the editor did not scroll to the snoozed line"); + assertOverlayMatches(editorInbox); + }); + mutatorCheck("Ctrl+Shift+Enter on inbox moves the task to today", () => { + localStorage.setItem("value.inbox", "# snoozed task https://snooze.example.com/x\n\nplain line https://plain.example.com/y"); + win.switchToTab("inbox"); + editorInbox.setSelectionRange(0, 0); + editorInbox.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", shiftKey: true, ...modifierDown})); + assertEqual(editorInbox.value, "plain line https://plain.example.com/y", "editor value"); + assertOverlayMatches(editorInbox); + assertEqual(localStorage.getItem("value.today"), `${todaySeed}\n\nsnoozed task https://snooze.example.com/x`, "stored today value"); + win.switchToTab("today"); + assertOverlayMatches(editorToday); + }); + mutatorCheck("Ctrl+k on today completes the task and records the achievement", () => { + localStorage.setItem("achievements", "[]"); + editorToday.setSelectionRange(0, 0); + editorToday.dispatchEvent(new KeyboardEvent("keydown", {key: "k", ...modifierDown})); + assertEqual(editorToday.value, todaySeed.split("\n").slice(1).join("\n"), "editor value"); + assertOverlayMatches(editorToday); + const achievements = JSON.parse(localStorage.getItem("achievements")); + assertEqual(achievements.length, 1, "achievement count"); + assertEqual(achievements[0].date, dateString(new Date()), "achievement date"); + assertEqual(JSON.stringify(achievements[0].tasks), JSON.stringify([todaySeed.split("\n")[0]]), "achievement tasks"); + }); + mutatorCheck("Ctrl+k on inbox completes the task", () => { + localStorage.setItem("achievements", "[]"); + win.switchToTab("inbox"); + editorInbox.setSelectionRange(0, 0); + editorInbox.dispatchEvent(new KeyboardEvent("keydown", {key: "k", ...modifierDown})); + assertEqual(editorInbox.value, "", "editor value"); + assertOverlayMatches(editorInbox); + const achievements = JSON.parse(localStorage.getItem("achievements")); + assertEqual(JSON.stringify(achievements[0].tasks), JSON.stringify([inboxSeed]), "achievement tasks"); + }); + mutatorCheck("Enter completes a task with a date offset", () => { + localStorage.setItem("achievements", "[]"); + editorToday.value = "x -1 fix https://offset.example.com/z"; + editorToday.setSelectionRange(5, 5); + editorToday.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter"})); + assertEqual(editorToday.value, "", "editor value"); + assertOverlayMatches(editorToday); + const yesterday = dateString(new Date(Date.now() - 86400000)); + const achievements = JSON.parse(localStorage.getItem("achievements")); + assertEqual(achievements.length, 1, "achievement count"); + assertEqual(achievements[0].date, yesterday, "achievement date"); + assertEqual(JSON.stringify(achievements[0].tasks), JSON.stringify(["fix https://offset.example.com/z"]), "achievement tasks"); + editorToday.value = "x done https://plain.example.com/q"; + editorToday.setSelectionRange(2, 2); + editorToday.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter"})); + assertOverlayMatches(editorToday); + const updated = JSON.parse(localStorage.getItem("achievements")); + assertEqual(updated.length, 2, "achievement count"); + assertEqual(updated[0].date, dateString(new Date()), "today's achievement date"); + assertEqual(updated[0].tasks[0], "done https://plain.example.com/q", "today's first task"); + }); + mutatorCheck("the completion-offset input helper re-renders the overlay", () => { + localStorage.setItem("achievements", "[]"); + editorToday.value = "X https://offset.example.com/w"; + editorToday.setSelectionRange(2, 2); + editorToday.dispatchEvent(new InputEvent("input", {inputType: "insertText"})); + assertEqual(editorToday.value, "x https://offset.example.com/w", "editor value"); + assertOverlayMatches(editorToday); + }); + mutatorCheck("the inbox input listener re-renders the overlay", () => { + win.switchToTab("inbox"); + editorInbox.value = `${inboxSeed}\nsee https://inbox-input.example.com/n`; + editorInbox.dispatchEvent(new InputEvent("input")); + assertOverlayMatches(editorInbox); + assertEqual(localStorage.getItem("value.inbox"), editorInbox.value, "stored inbox value"); + }); + mutatorCheck("body Ctrl+Enter re-renders at the top of the gap", () => { + editorToday.scrollTop = 40; + editorToday.blur(); + doc.body.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", ...modifierDown})); + assertEqual(editorToday.value, `\n\n${todaySeed}`, "editor value"); + assertOverlayMatches(editorToday); + }); + mutatorCheck("body Ctrl+Shift+i re-renders the reordered inbox", () => { + // A short inbox cannot scroll, so the reorder needs a long one to move + // the overlay's scroll position and expose a stale render. + const lines = [ + "# snoozed https://b.example.com/2", + "plain https://a.example.com/1", + ...Array.from({length: 40}, (_, i) => `filler line ${i}`) + ]; + localStorage.setItem("value.inbox", lines.join("\n")); + win.setNotifyStatus(); + win.switchToTab("inbox"); + editorInbox.scrollTop = 40; + editorInbox.blur(); + doc.body.dispatchEvent(new KeyboardEvent("keydown", {key: "i", shiftKey: true, ...modifierDown})); + const expected = [...lines.slice(1), "", lines[0]].join("\n"); + assertEqual(editorInbox.value, expected, "editor value"); + assertOverlayMatches(editorInbox); + }); + mutatorCheck("body Ctrl+a, Ctrl+u and Ctrl+i switch focus and tabs", () => { + doc.body.dispatchEvent(new KeyboardEvent("keydown", {key: "a", ...modifierDown})); + assertEqual(doc.activeElement, editorToday, "focused editor"); + assertOverlayMatches(editorToday); + doc.body.dispatchEvent(new KeyboardEvent("keydown", {key: "u", ...modifierDown})); + assertOverlayMatches(editorToday); + doc.body.dispatchEvent(new KeyboardEvent("keydown", {key: "i", ...modifierDown})); + assertOverlayMatches(editorInbox); + doc.body.dispatchEvent(new KeyboardEvent("keydown", {key: "u", ...modifierDown})); + }); + mutatorCheck("Ctrl+a scrolls a deep caret into view and the listener heals the overlay", () => { + // focus() scrolls an unfocused editor to the caret when it is out of + // view, and delivers that as a scroll event. The listener syncs the + // overlay, so the underlines catch up as soon as the event lands. + editorToday.setSelectionRange(todaySeed.length, todaySeed.length); + editorToday.blur(); + editorToday.scrollTop = 0; + doc.body.dispatchEvent(new KeyboardEvent("keydown", {key: "a", ...modifierDown})); + assertEqual(doc.activeElement, editorToday, "focused editor"); + assert(editorToday.scrollTop > 0, "the editor did not scroll to the caret"); + editorToday.dispatchEvent(new Event("scroll")); + assertOverlayMatches(editorToday); + }); + mutatorCheck("the storage listener re-renders the open tab", () => { + const storedValue = `${todaySeed}\nsee https://storage.example.com/s`; + localStorage.setItem("value.today", storedValue); + win.dispatchEvent(new StorageEvent("storage")); + assertEqual(editorToday.value, storedValue, "editor value"); + assertOverlayMatches(editorToday); + }); + mutatorCheck("updateValues rolls today into the inbox", () => { + win.updateValues(); + win.preferInbox(); + assertEqual(localStorage.getItem("value.today"), "", "stored today value"); + assertEqual(localStorage.getItem("value.inbox"), `${todaySeed}\n\n${inboxSeed}`, "stored inbox value"); + assertOverlayMatches(editorInbox); + }); + mutatorCheck("Ctrl+x selects the task without changing the text", () => { + editorToday.setSelectionRange(0, 0); + editorToday.dispatchEvent(new KeyboardEvent("keydown", {key: "x", ...modifierDown})); + assertOverlayMatches(editorToday); + assertEqual(editorToday.selectionStart, 0, "selection start"); + assertEqual(editorToday.selectionEnd, todaySeed.split("\n")[0].length + 1, "selection end"); + }); + line("resize"); check("resizing re-renders the overlay", () => { links.innerHTML = ""; diff --git a/app/icon-normal.html b/app/icon-normal.html index 1d21bc6..9318111 100644 --- a/app/icon-normal.html +++ b/app/icon-normal.html @@ -59,6 +59,6 @@ - + diff --git a/app/icon-notify.html b/app/icon-notify.html index edc9627..26dece1 100644 --- a/app/icon-notify.html +++ b/app/icon-notify.html @@ -59,6 +59,6 @@ - + diff --git a/app/index.html b/app/index.html index 6a7660d..800ddec 100644 --- a/app/index.html +++ b/app/index.html @@ -59,6 +59,6 @@ - + diff --git a/app/main.js b/app/main.js index ca340b6..f66f773 100644 --- a/app/main.js +++ b/app/main.js @@ -604,7 +604,6 @@ function inboxSnoozeSelected() { if (capturing) { newInboxValue = newInboxValue.trimEnd(); } - storeThenSave("value.inbox", newInboxValue); const prevScrollTop = dom.editor.inbox.scrollTop; dom.editor.inbox.scrollTop = 0; dom.editor.inbox.value = newInboxValue.substring(0, newSelectionPos); @@ -612,6 +611,7 @@ function inboxSnoozeSelected() { dom.editor.inbox.value = newInboxValue; dom.editor.inbox.scrollTop = Math.max(prevScrollTop, dom.editor.inbox.scrollTop); dom.editor.inbox.setSelectionRange(newSelectionPos, newSelectionPos); + storeThenSave("value.inbox", newInboxValue); } } @@ -642,10 +642,10 @@ function selectUnsnoozed() { else { dom.editor.inbox.value = unsnoozed; } - storeThenSave("value.inbox", dom.editor.inbox.value); dom.editor.inbox.setSelectionRange(0, unsnoozed.length); dom.editor.inbox.scrollTop = 0; dom.editor.inbox.focus(); + storeThenSave("value.inbox", dom.editor.inbox.value); } function appendToBottom(original, value) { @@ -1302,10 +1302,10 @@ async function startApp() { value = `\n\n${value}`; } dom.editor[selectedTab].value = value; - storeThenSave(`value.${selectedTab}`, dom.editor[selectedTab].value); dom.editor[selectedTab].setSelectionRange(0, 0); dom.editor[selectedTab].scrollTop = 0; dom.editor[selectedTab].focus(); + storeThenSave(`value.${selectedTab}`, dom.editor[selectedTab].value); refreshInfo(); } else if (matchKeyboardEvent(event, "c", "u")) { From 38b9f818c3cdcf85ae1049900712996175e15351 Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 14:11:45 +0800 Subject: [PATCH 23/24] render links as clickable html on the achievements page Completed tasks render URLs as native anchors in the normal view. The editing views keep plain text, so the whole task stays one coherent move-back control. simple.css appends the URL after external links; suppress that echo on task links, which already show the URL as their text. Anchors open in a new tab with noopener noreferrer. Trailing punctuation is trimmed from the href but kept in the surrounding text. Task text is rendered with DOM APIs, not innerHTML, so markup in a task stays inert. The tests drive the real page in an iframe: rendering, escaping, the editing views, the import flow, the storage event, and the todo.txt round trip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/app-test.yaml | 5 +- app-test/test_achievements_links.html | 372 ++++++++++++++++++++++++++ app/achievements.html | 53 +++- app/help.html | 6 +- app/simple-customizations.css | 5 + 5 files changed, 436 insertions(+), 5 deletions(-) create mode 100644 app-test/test_achievements_links.html diff --git a/.github/workflows/app-test.yaml b/.github/workflows/app-test.yaml index b325e7f..420070f 100644 --- a/.github/workflows/app-test.yaml +++ b/.github/workflows/app-test.yaml @@ -28,11 +28,12 @@ jobs: done for url in \ "http://127.0.0.1:8124/app-test/test_links.html" \ - "http://127.0.0.1:8124/app-test/test_links.html?platform=mac"; do + "http://127.0.0.1:8124/app-test/test_links.html?platform=mac" \ + "http://127.0.0.1:8124/app-test/test_achievements_links.html"; do echo "Running $url" google-chrome --headless=new --virtual-time-budget=20000 \ --dump-dom "$url" > "$dom" - grep -q "Link tests: all passed" "$dom" || { + grep -q "Link tests: all passed\|Achievements link tests: all passed" "$dom" || { sed -n '/
/,/<\/pre>/p' "$dom"
               exit 1
             }
diff --git a/app-test/test_achievements_links.html b/app-test/test_achievements_links.html
new file mode 100644
index 0000000..98f9446
--- /dev/null
+++ b/app-test/test_achievements_links.html
@@ -0,0 +1,372 @@
+
+
+
+  
+  Frogtab achievements link tests
+  
+
+
+  

Frogtab achievements link tests

+

Run just app-test from the repository root. To run the tests without just, serve the repository root and open this page in a browser:

+
python3 -m http.server 8124
+

+ The tests embed app/achievements.html in the iframe below and drive the real page. + They cover the clickable links in the task list: rendering, escaping, the editing views, + the import flow, and the todo.txt round trip. +

+

The page title shows the result: Achievements link tests: all passed.

+

+  
+  
+
+
diff --git a/app/achievements.html b/app/achievements.html
index bd317b5..f97c5a1 100644
--- a/app/achievements.html
+++ b/app/achievements.html
@@ -10,7 +10,7 @@
     
     
     
-    
+    
   
   
     
@@ -272,6 +272,55 @@

Achievements

localStorage.setItem("achievements", achievementsCached); } + function trimTrailingPunctuation(url) { + let trimmed = url; + while (trimmed.length > 0) { + const lastChar = trimmed.charAt(trimmed.length - 1); + if (".!,;:?".includes(lastChar)) { + trimmed = trimmed.substring(0, trimmed.length - 1); + } + else if (lastChar == ")" && trimmed.split(")").length > trimmed.split("(").length) { + trimmed = trimmed.substring(0, trimmed.length - 1); + } + else { + break; + } + } + return trimmed; + } + + // Render a task as plain text, with any URLs as clickable links. + // In the editing views the whole task is a move-back control, + // so URLs stay plain text there. + function displayTaskText(domTaskText, taskText) { + if (editingState != "") { + domTaskText.textContent = taskText; + return; + } + let copiedUpTo = 0; + // URL schemes are case-insensitive, so match any case + for (const match of taskText.matchAll(/\bhttps?:\/\/[^\s<>"']+/gi)) { + if (match.index > copiedUpTo) { + domTaskText.appendChild(document.createTextNode( + taskText.substring(copiedUpTo, match.index) + )); + } + const url = trimTrailingPunctuation(match[0]); + const domLink = document.createElement("a"); + domLink.href = url; + domLink.textContent = url; + domLink.target = "_blank"; + domLink.rel = "noopener noreferrer"; + domTaskText.appendChild(domLink); + copiedUpTo = match.index + url.length; + } + if (copiedUpTo < taskText.length) { + domTaskText.appendChild(document.createTextNode( + taskText.substring(copiedUpTo) + )); + } + } + function displayTasks() { const weekdayNameByDate = buildWeekdayNameByDate(); for (let indexDay = 0; indexDay < achievementsParsed.length; indexDay++) { @@ -294,7 +343,7 @@

Achievements

const domTaskText = document.createElement("span"); domTaskListItem.appendChild(domTaskText); domTaskText.className = "task-text"; - domTaskText.textContent = day.tasks[indexTask]; + displayTaskText(domTaskText, day.tasks[indexTask]); domTaskText.addEventListener("mouseenter", () => { domTaskListItem.classList.add("inner-hover"); }); diff --git a/app/help.html b/app/help.html index 5a83890..c4468e7 100644 --- a/app/help.html +++ b/app/help.html @@ -10,7 +10,7 @@ - +
@@ -230,6 +230,10 @@

Tracking your completed tasks

Click Edit at the top of the Achievements page, select Today or Inbox as the new location for the task, then click the task to move it.

+

+ Any URLs in your completed tasks are clickable. + While you're editing your completed tasks, URLs are not clickable, so that clicking a task always moves it. +

To make other changes to your completed tasks, click Edit at the top of the Achievements page, then click Export a todo.txt file. You can edit your completed tasks in todo.txt format, then import the modified todo.txt file. diff --git a/app/simple-customizations.css b/app/simple-customizations.css index 176b74d..0f1dee1 100644 --- a/app/simple-customizations.css +++ b/app/simple-customizations.css @@ -185,6 +185,11 @@ td > p + p { .checklist ul { list-style-type: none; } +/* Task links show their own URL as the link text, so suppress the + URL that simple.css appends after external links */ +.checklist .task-text a[href^="http"]::after { + content: none; +} .checklist.editing li { color: var(--text-light); } From 1b61d984b5e693300a2ea743911d68e25e87abdc Mon Sep 17 00:00:00 2001 From: Dave Wilding Date: Fri, 23 Oct 2026 14:18:42 +0800 Subject: [PATCH 24/24] test the scroll bounce fix on every editor page The bounce fix lives on the bare textarea selector in style.css, which every editor page loads, so it already applies everywhere. The tests only asserted the Today editor. Load each other editor page in the iframe and assert the computed overscroll-behavior on every textarea it renders: icon-normal, icon-notify, week, send, demo, and demo-week. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app-test/test_links.html | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app-test/test_links.html b/app-test/test_links.html index 2e64734..709e05f 100644 --- a/app-test/test_links.html +++ b/app-test/test_links.html @@ -739,6 +739,47 @@

Frogtab link tests

assertEqual(links.style.width, `${editorToday.clientWidth + 4}px`, "style.width"); }); + // The overlay only exists on the Today and Inbox editors, but the scroll + // bounce fix belongs on every editor: the bare textarea selector in + // style.css carries it, and every editor page loads that stylesheet. + // Load each other editor page and assert the computed style on every + // textarea it renders. + line("other editors"); + const otherPages = [ + ["icon-normal", "../app/icon-normal.html"], + ["icon-notify", "../app/icon-notify.html"], + ["week", "../app/week.html"], + ["send", "../app/send.html"], + ["demo", "../app/demo.html#today/a%20b%20c/false"], + ["demo-week", "../app/demo-week.html#wed/a%20b%20c"] + ]; + for (const [name, src] of otherPages) { + await new Promise(resolve => { + iframe.addEventListener("load", resolve, {once: true}); + iframe.removeAttribute("srcdoc"); + iframe.src = src; + }); + const pageDoc = iframe.contentDocument; + const pageWin = iframe.contentWindow; + const textareas = [...pageDoc.querySelectorAll("textarea")]; + check(`${name} disables the scroll bounce on every editor`, () => { + assert(textareas.length > 0, "the page renders no textarea"); + for (const textarea of textareas) { + assertEqual( + pageWin.getComputedStyle(textarea).overscrollBehavior, + "none", + `${textarea.id || "(anonymous)"} overscroll-behavior` + ); + } + }); + } + // Restore the app for any code after this section. + await new Promise(resolve => { + iframe.addEventListener("load", resolve, {once: true}); + iframe.src = "../app/index.html"; + }); + await waitForApp(); + line(""); line(`${failed} failed, ${passed} passed`); document.title = failed === 0 ? "Link tests: all passed" : `Link tests: ${failed} failed`;