diff --git a/.github/workflows/app-test.yaml b/.github/workflows/app-test.yaml new file mode 100644 index 0000000..420070f --- /dev/null +++ b/.github/workflows/app-test.yaml @@ -0,0 +1,40 @@ +name: Browser-side 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: Run the app tests + 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 + 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" \ + "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\|Achievements link tests: all passed" "$dom" || { + sed -n '/
/,/<\/pre>/p' "$dom"
+              exit 1
+            }
+          done
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_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-test/test_links.html b/app-test/test_links.html
new file mode 100644
index 0000000..709e05f
--- /dev/null
+++ b/app-test/test_links.html
@@ -0,0 +1,789 @@
+
+
+
+  
+  Frogtab link tests
+  
+
+
+  

Frogtab 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/index.html in the iframe below and drive the real app. + They cover the link overlay: rendering, re-render triggers, scroll sync, hit-testing, + 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.

+

+  
+  
+
+
diff --git a/app/achievements.html b/app/achievements.html
index 44885b2..f97c5a1 100644
--- a/app/achievements.html
+++ b/app/achievements.html
@@ -9,8 +9,8 @@
     
     
     
-    
-    
+    
+    
   
   
     
@@ -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/demo-week.html b/app/demo-week.html index 705e311..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 c75add5..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 cfd2309..c4468e7 100644 --- a/app/help.html +++ b/app/help.html @@ -9,8 +9,8 @@ - - + +
@@ -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. @@ -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. @@ -570,6 +574,17 @@

Keyboard shortcuts

Ctrl + B + + +

+ Open a URL +

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

@@ -1213,4 +1228,4 @@

Changing the theme

startApp(); - \ No newline at end of file + diff --git a/app/icon-normal.html b/app/icon-normal.html index 16b0ac4..9318111 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..26dece1 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..800ddec 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..f66f773 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,91 @@ 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]; + // 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; + // 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)}`; + copiedUpTo = match.index + url.length; + } + 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; +} + +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) { + // 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 = ""; + if (element === null) { + return null; + } + return element.closest("#links a"); +} + +function modifierHeld(event) { + return (platformApple && event.metaKey) || (!platformApple && event.ctrlKey); +} + +// 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; + 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` : ""; +} + function addSpaceForCompletionOffset(editor) { const cursorPos = editor.selectionEnd; const lineStart = editor.value.lastIndexOf("\n", editor.selectionEnd - 1) + 1; @@ -518,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); @@ -526,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); } } @@ -556,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) { @@ -753,6 +839,7 @@ function toggleSnap() { dom.snapToCenter.classList.remove("display"); localStorage.setItem("ui.snap", "center"); } + renderLinks(); } function setExportAndSave() { @@ -825,6 +912,9 @@ function createDataJSON() { function storeThenSave(key, value) { localStorage.setItem(key, value); + if (key.startsWith("value.")) { + renderLinks(); + } requestSave(); } @@ -1137,6 +1227,45 @@ 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; + // The content moved under the pointer, so re-evaluate the cursor + updateLinkCursor(editor, lastPointerX, lastPointerY, modifierIsHeld); + }); + editor.addEventListener("click", event => { + if (modifierHeld(event)) { + openLinkAtPoint(event.clientX, event.clientY); + } + }); + editor.addEventListener("mousemove", event => { + lastPointerX = event.clientX; + lastPointerY = event.clientY; + modifierIsHeld = modifierHeld(event); + updateLinkCursor(editor, lastPointerX, lastPointerY, modifierIsHeld); + }); + } + // 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")) { + modifierIsHeld = keyEvent == "keydown"; + for (const editor of [dom.editor.today, dom.editor.inbox]) { + 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")) { event.preventDefault(); @@ -1173,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")) { @@ -1377,6 +1506,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..6cd207c 100644 --- a/app/send.html +++ b/app/send.html @@ -9,7 +9,7 @@ - +
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); } diff --git a/app/simple.min.css b/app/simple.min.css index c7513b0..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}} \ 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;--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}} diff --git a/app/style.css b/app/style.css index 34570ba..9ecabac 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; @@ -180,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 { @@ -191,6 +195,32 @@ textarea::placeholder { textarea.display { display: block; } +/* Transparent overlay that mirrors the textarea's metrics. + 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; + 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..5225867 100644 --- a/app/week.html +++ b/app/week.html @@ -9,7 +9,7 @@ - +
diff --git a/justfile b/justfile index d7f766b..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/* @@ -76,5 +78,27 @@ 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: + #!/bin/sh + set -e + 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 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