From a0eca5f55e26d62fe121e4d00032ab55fbae8219 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:41:20 +0000 Subject: [PATCH] Rows: only the due date is colored, tags in the line of the text on wide screens; lists at the bottom; rem font sizes; deny non-icon files of webicons - an overdue entry has its date in the warning color, without the warning sign and not bold, at every width - the tabs of the lists are below the entries - on a wide screen note, recurrence, start and tags are in the line of the text, right-aligned next to the due date - font sizes and icon sizes in rem (the 16px minimum of the fields on phones stays for iOS) - webicons/: copyright holder named; README, check.js and the preview page are denied in .htaccess and nginx.conf.sample Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AFJFMPsCLSiYLjcJ1agpYs --- .htaccess | 3 ++ UI-PLAN.md | 10 ++++-- index.php | 2 +- js/render.js | 2 +- nginx.conf.sample | 2 ++ tests/apache-access.sh | 4 ++- tests/deny-lists.test.js | 20 +++++++++++ tests/layout.test.js | 4 +++ tests/rows.test.js | 35 +++++++++++++----- todo.css | 77 +++++++++++++++++++++++++--------------- webicons/LICENSE | 2 +- 11 files changed, 118 insertions(+), 43 deletions(-) diff --git a/.htaccess b/.htaccess index 61334f8..d44d35b 100644 --- a/.htaccess +++ b/.htaccess @@ -21,6 +21,9 @@ AuthUserFile /path/outside/webroot/.htpasswd # never serve internal files directly (webauth/ has its own .htaccess which denies everything) RedirectMatch 404 ^.*/(sql|scripts|secrets|tests|node_modules|lang|\.git|\.github)(/|$) +# webicons/ (a subtree of codeling/webicons): only the icons (webicons/icons/*.svg) are used by the application, not +# its README, check.js and the preview page +RedirectMatch 404 ^.*/webicons/(?!icons/) # (FilesMatch also applies to requests with a trailing path like db.php/x, unlike # a match on the request URI) diff --git a/UI-PLAN.md b/UI-PLAN.md index 9a119f6..c715290 100644 --- a/UI-PLAN.md +++ b/UI-PLAN.md @@ -59,8 +59,14 @@ and filled. Until then step 0 can only be prepared as files in a scratch directo while the list scrolls, safe area of the screen); `alert()` is replaced by messages (`js/toast.js`; a failure is `role="alert"`; one inside an open dialog, because everything outside a modal dialog is inert), moving an entry to the trash has an Undo; a web app manifest with icons so that the page can be put on the home screen (`crossorigin="use-credentials"`: the login of the web - server). Not done: `pt` to `rem` for all sizes (the sizes of the new elements are in px, the body is 15px on phones). - `tests/quickadd.test.js`. + server). `tests/quickadd.test.js`. + +* Step 6 (after review of the rows): an overdue entry only has its date in the warning color (no warning sign, not bold, at every + width); the tabs of the lists are below the entries (rarely used); on a wide screen the note, the recurrence, a start and the + tags are in the line of the text, right-aligned next to the due date (below the text on a phone); all font sizes and the icons + are in `rem` (the page follows the text size of the browser; the 16px of the fields on phones stay a minimum for iOS, which + zooms into smaller ones); the files of `webicons/` which are not icons (README, `check.js`, the preview page) are denied in + `.htaccess` and `nginx.conf.sample`. ## 1. Findings diff --git a/index.php b/index.php index dec44ad..f87cbbd 100644 --- a/index.php +++ b/index.php @@ -44,7 +44,6 @@ -
">
@@ -59,6 +58,7 @@
    + diff --git a/js/render.js b/js/render.js index 94a302c..1483d9e 100644 --- a/js/render.js +++ b/js/render.js @@ -218,7 +218,7 @@ function renderItem(it) { ((it.completed==1)?'checked="true" ':'')+((it.deleted==1)?'disabled ':'')+ 'aria-label="'+escapeHtml($T('DONE'))+'" />'; line += ''; - line += ''+dueHtml+(isOverdue ? ' '+icon('warning')+'':'')+''; + line += ''+dueHtml+''; line += ''; if (it.deleted == 0) { line += iconButton('trash'+it.id, 'deleteButton', 'delete', $T('DELETE')); diff --git a/nginx.conf.sample b/nginx.conf.sample index 4c6b2a5..f1744c6 100644 --- a/nginx.conf.sample +++ b/nginx.conf.sample @@ -25,6 +25,8 @@ location /todo/ { location ~ (/\.|~$|\.(sql|sh|ini|bak|swp|orig|md|yml|neon|lock|sample)$|/config[^/]*\.php$|/package(-lock)?\.json$) { return 404; } location ~ ^/todo/(session|todo-core|lang)\.php$ { return 404; } location ~ ^/todo/webauth/ { return 404; } + # only the icons of webicons/ are used (not its README, check.js and the preview page) + location ~ ^/todo/webicons/(?!icons/) { return 404; } location ~ ^/todo/queries/(db|date|tags|todo-list-query|recurrence|db-connect|endpoint|response|todo-input|limits|statistics-data)\.php$ { return 404; } # The client is made of ES modules (js/*.js). Only the URL of js/main.js has a version (assetUrl() in diff --git a/tests/apache-access.sh b/tests/apache-access.sh index 85e7ae1..1e84d84 100644 --- a/tests/apache-access.sh +++ b/tests/apache-access.sh @@ -109,6 +109,8 @@ case "$location" in esac check vendor/jquery/jquery.min.js 401 200 check todo.css 401 200 +# the icons are used by the pages, the rest of webicons/ (check.js, the preview page) is not +check webicons/icons/home.svg 401 200 # The client is made of ES modules: the browser refuses a module which is not served as JavaScript, and, # as only the URL of js/main.js has a version, has to ask the server each time whether a module # changed (Cache-Control: no-cache; the answer is 304 without the file if not), see .htaccess. @@ -143,7 +145,7 @@ for name in $include_only; do check "$name.php" 403 403 check "$name.php/x" 403 403 done -for hidden in scripts/migrate.php secrets/todo_password secrets .git/HEAD node_modules/jquery/dist/jquery.js tests/lib.js; do +for hidden in webicons/check.js webicons/preview.html webicons/preview.css webicons/preview.js webicons/LICENSE scripts/migrate.php secrets/todo_password secrets .git/HEAD node_modules/jquery/dist/jquery.js tests/lib.js; do check "$hidden" 401 404 done # migrations written in PHP: sql/ is hidden as a whole, not by the extension (.sql files are denied by it) diff --git a/tests/deny-lists.test.js b/tests/deny-lists.test.js index f3e6080..58eb866 100644 --- a/tests/deny-lists.test.js +++ b/tests/deny-lists.test.js @@ -120,3 +120,23 @@ test('nginx.conf.sample denies exactly the include-only files of the root', () = 'location ~ ^/todo/(a|b)\\.php$ { return 404; }'); assertSameSet(names(list), includeOnly, 'nginx.conf.sample (location for the root)', 'root .php files which are not index.php, not referenced by index.php and not config*.php'); }); + +// webicons/ is a subtree: the application only uses webicons/icons/*.svg, so everything else in it (README, check.js, +// the preview page) is denied by one rule in both files (the rule says "not under icons/") +function webiconsFiles(dir = 'webicons') { + return fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true }) + .flatMap((e) => e.isDirectory() ? webiconsFiles(path.join(dir, e.name)) : [path.join(dir, e.name)]); +} + +test('.htaccess and nginx.conf.sample deny the files of webicons/ which are not icons', () => { + const apache = new RegExp(find('.htaccess', /^RedirectMatch 404 (\^\.\*\/webicons\/\(\?!icons\/\))$/gm, 'RedirectMatch 404 for webicons/')); + const nginx = new RegExp(find('nginx.conf.sample', /^\s*location ~ (\^\/todo\/webicons\/\(\?!icons\/\)) \{ return 404; \}/gm, 'location ~ ^/todo/webicons/(?!icons/) { return 404; }')); + const files = webiconsFiles(); + assert.ok(files.some((f) => f.startsWith('webicons/icons/')), 'webicons/icons/ has files'); + assert.ok(files.some((f) => !f.startsWith('webicons/icons/')), 'webicons/ has files besides the icons (check.js, ...)'); + for (const f of files) { + const icon = f.startsWith('webicons/icons/'); + assert.equal(apache.test('/todo/' + f), !icon, `.htaccess: ${f}`); + assert.equal(nginx.test('/todo/' + f), !icon, `nginx.conf.sample: ${f}`); + } +}); diff --git a/tests/layout.test.js b/tests/layout.test.js index c388262..e09639d 100644 --- a/tests/layout.test.js +++ b/tests/layout.test.js @@ -63,6 +63,10 @@ test('the lists are tabs: the one which is shown is marked, a tab shows its entr const tabs = () => page.locator('#lists .listTab').evaluateAll((t) => t.map((e) => [e.textContent, e.getAttribute('aria-current')])); assert.deepEqual(await tabs(), [['Main', 'true'], ['Work', 'false']]); assert.equal(await page.locator('#lists').getAttribute('aria-label'), 'Lists'); + // the lists are chosen rarely: the tabs are below the entries + const list = await page.locator('#todoList').boundingBox(); + const lists = await page.locator('#lists').boundingBox(); + assert.ok(lists.y >= list.y + list.height, 'the tabs are below the entries'); assert.deepEqual((await titles(page)).sort(), ['Buy milk', 'Call Bob', 'Untagged', 'Write report']); // a filter of this list ... diff --git a/tests/rows.test.js b/tests/rows.test.js index 9332bc2..e720dd3 100644 --- a/tests/rows.test.js +++ b/tests/rows.test.js @@ -73,7 +73,7 @@ test('an entry is a list item: checkbox, text, date and the button for the trash await page.context().close(); }); -test('what else there is to know is below the text: a note, that it recurs, a start which is to come, the tags', async () => { +test('what else there is to know is shown with the text: a note, that it recurs, a start which is to come, the tags', async () => { seed(); const page = await openPage(); const note = row(page, 'Overdue with a note'); @@ -87,16 +87,31 @@ test('what else there is to know is below the text: a note, that it recurs, a st assert.match(await row(page, 'Done and repeating').locator('.recurring').getAttribute('title'), /year/); // the tooltip of the entry says when it was created assert.match(await row(page, 'Plain').locator('.todoMain').getAttribute('title'), /^Created: \d{4}-\d\d-\d\d/); - // below the text, not beside it - const title = await note.locator('.todoTitle').boundingBox(); - const meta = await note.locator('.todoMeta').boundingBox(); - assert.ok(meta.y >= title.y + title.height - 1, 'the meta line is below the title'); + // on a wide screen in the line of the text, right-aligned, between the text and the due date + const tagged = row(page, 'Plain'); + const title = await tagged.locator('.todoTitle').boundingBox(); + const meta = await tagged.locator('.todoMeta').boundingBox(); + const due = await tagged.locator('.todoDue').boundingBox(); + assert.ok(meta.y < title.y + title.height && title.y < meta.y + meta.height, 'the tags are in the line of the text'); + assert.ok(meta.x >= title.x + 1 && meta.x + meta.width <= due.x + 1, 'the tags are right of the text and left of the due date'); + assert.ok(due.x - (meta.x + meta.width) < 24, 'the tags end next to the due date: ' + (due.x - (meta.x + meta.width))); // an entry without any of it has no empty line assert.equal(await row(page, 'In the trash').locator('.todoMeta').count(), 0); assert.deepEqual(page.errors, []); await page.context().close(); }); +test('on a narrow screen what else there is to know is below the text', async () => { + seed(); + const page = await openPage({ viewport: { width: 400, height: 700 } }); + const tagged = row(page, 'Plain'); + const title = await tagged.locator('.todoTitle').boundingBox(); + const meta = await tagged.locator('.todoMeta').boundingBox(); + assert.ok(meta.y >= title.y + title.height - 1, 'the meta line is below the title'); + assert.deepEqual(page.errors, []); + await page.context().close(); +}); + test('a long title has two lines at most, and does not push the date or the button out', async () => { seed(); for (const width of [1000, 320]) { @@ -231,18 +246,22 @@ test('the trash button of a row moves the entry to the trash and back, the check await page.context().close(); }); -test('an overdue entry has its date in red, with a warning sign on a wide screen only', async () => { +test('an overdue entry has only its date in red, on a wide and on a narrow screen (no warning sign, not bold)', async () => { seed(); const wide = await openPage(); const date = row(wide, 'Overdue with a note').locator('.todoDue'); assert.equal(await row(wide, 'Overdue with a note').evaluate((e) => e.classList.contains('overdue')), true); - assert.equal(await date.locator('.exclamation svg').isVisible(), true); + assert.equal(await row(wide, 'Overdue with a note').locator('.exclamation').count(), 0); + assert.equal(await date.locator('svg').count(), 0); const red = await date.evaluate((e) => getComputedStyle(e).color); const plain = await row(wide, 'Plain').locator('.todoDue').evaluate((e) => getComputedStyle(e).color); assert.notEqual(red, plain); + // only the color differs (the weight of the font and the color of the text do not) + const look = (r) => r.evaluate((e) => ({ weight: getComputedStyle(e.querySelector('.todoDue')).fontWeight, text: getComputedStyle(e.querySelector('.todoTitle')).color })); + assert.deepEqual(await look(row(wide, 'Overdue with a note')), await look(row(wide, 'Plain'))); await wide.context().close(); const narrow = await openPage({ viewport: { width: 400, height: 700 } }); - assert.equal(await row(narrow, 'Overdue with a note').locator('.exclamation').isVisible(), false); + assert.equal(await row(narrow, 'Overdue with a note').locator('.exclamation').count(), 0); assert.equal(await row(narrow, 'Overdue with a note').locator('.todoDue').evaluate((e) => getComputedStyle(e).color), red); // the year of a date is not shown on a narrow screen if it is the current one assert.equal(await row(narrow, 'Overdue with a note').locator('.date_year').isVisible(), false); diff --git a/todo.css b/todo.css index 2231f6c..fb3c545 100644 --- a/todo.css +++ b/todo.css @@ -49,16 +49,16 @@ body { font-family:Tahoma,sans-serif; background-color: var(--page-bg); color: var(--page-fg); - font-size: 9pt; + font-size: .75rem; margin: 5px; } h1 { - font-size: 16pt; + font-size: 1.3333rem; font-weight: bold; margin: 5px auto 3px 5px; } h2 { - font-size: 14pt; + font-size: 1.1667rem; font-weight: bold; } @@ -89,7 +89,7 @@ h2 { } input[type="text"], input[type="date"] { font-family:Tahoma,sans-serif; - font-size: 9pt; + font-size: .75rem; } /* the line to enter an entry: the text, the start and due date, and the button */ #enterLine { @@ -202,11 +202,31 @@ input[type="text"], input[type="date"] { } .todoItem.overdue .todoDue { color: var(--danger); - font-weight: bold; } .todoActions { flex: none; } +/* On a wide screen what else there is to know (note, recurrence, start, tags) is in the line of the text, right-aligned + next to the due date, so that the width of the screen is used; on a narrow screen it is below the text (see the + media query below). */ +@media (min-width: 576px) +{ + .todoMain { + display: flex; + align-items: center; + gap: 4px 12px; + } + .todoTitle { + flex: 1 1 0; + min-width: 8em; + } + .todoMeta { + flex: 0 1 auto; + justify-content: flex-end; + max-width: 60%; + margin-top: 0; + } +} .todo_deleted .todoTitle { text-decoration: line-through; } @@ -302,11 +322,11 @@ span.modify_desc { #tag_merge { vertical-align: middle; } -/* the lists: tabs which scroll sideways if there are many */ +/* the lists: tabs below the entries (they are changed rarely), which scroll sideways if there are many */ #lists { display: flex; gap: 4px; - margin: 4px 0; + margin: 8px 0 4px; overflow-x: auto; } .listTab { @@ -508,8 +528,8 @@ button.tagChip { /* the icons are inline SVGs which use the files of webicons/icons/ (iconSvg() in todo-core.php, icon() in js/html.js); the files have no colors, so an icon has the color of its element */ svg.icon { - width: 16px; - height: 16px; + width: 1rem; + height: 1rem; vertical-align: middle; color: var(--icon-fg); } @@ -517,8 +537,8 @@ svg.icon { display: inline-flex; align-items: center; justify-content: center; - width: 20px; - height: 20px; + width: 1.25rem; + height: 1.25rem; padding: 0; border: 0; background-color: transparent; @@ -545,11 +565,10 @@ svg.icon { .addButton svg.icon { color: var(--ok); } -.deleteButton svg.icon, -.exclamation svg.icon { +.deleteButton svg.icon { color: var(--danger); } -.exclamation, .note, .recurring { +.note, .recurring { display: inline-block; line-height: 0; } @@ -595,8 +614,8 @@ svg.icon { if a scheme was chosen, see js/theme.js) */ .themeswitch svg.icon { display: none; - width: 20px; - height: 20px; + width: 1.25rem; + height: 1.25rem; } :root:not([data-theme]) .themeswitch .icon-theme-auto, :root[data-theme="light"] .themeswitch .icon-theme-light, @@ -664,7 +683,7 @@ svg.icon { list scrolls: the text, the due date and the button. The start date is left out (today). The due date is the label (a calendar icon if empty) with the native date input on top of it, invisible, which opens the picker on tap. */ body { - font-size: 15px; + font-size: .9375rem; } #mainContent { padding-bottom: calc(64px + env(safe-area-inset-bottom)); @@ -720,7 +739,7 @@ svg.icon { } #enter_due_label { display: block; - font-size: 13px; + font-size: .8125rem; text-align: center; color: var(--muted); } @@ -750,14 +769,14 @@ svg.icon { top: 6px; right: 4px; min-width: 0; - font-size: 13px; + font-size: .8125rem; line-height: 1.4; pointer-events: none; } .todoTitle { padding-right: var(--due-room); } - .todoDue .exclamation, .date_year { + .date_year { display: none; } /* modify / tag dialog: labels above full-width fields */ @@ -777,23 +796,23 @@ svg.icon { } /* larger touch targets */ .iconButton { - width: 32px; - height: 32px; + width: 2rem; + height: 2rem; } .iconButton svg.icon { - width: 20px; - height: 20px; + width: 1.25rem; + height: 1.25rem; } .themeswitch { padding: 6px; } .themeswitch svg.icon { - width: 24px; - height: 24px; + width: 1.5rem; + height: 1.5rem; } - /* font size of at least 16px prevents iOS from zooming in on focus */ + /* font size of at least 16px prevents iOS from zooming in on focus (it follows the text size setting above that) */ input, input[type="text"], input[type="date"], select, textarea, .tagify__input { - font-size: 16px; + font-size: max(1rem, 16px); } } /********************/ @@ -921,6 +940,6 @@ dialog::backdrop { } .dialogButton { padding: 8px 16px; - font-size: 16px; + font-size: 1rem; } } diff --git a/webicons/LICENSE b/webicons/LICENSE index 147aa1b..d01922c 100644 --- a/webicons/LICENSE +++ b/webicons/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 the webicons contributors +Copyright (c) 2026 Bernhard Froehler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal