Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<FilesMatch "(^\.|~$|\.(sql|sh|ini|bak|swp|orig|md|yml|neon|lock|sample)$|^config.*\.php$|^package(-lock)?\.json$|^session\.php$|^todo-core\.php$|^lang\.php$)">
Expand Down
10 changes: 8 additions & 2 deletions UI-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion index.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
</div>
</div>
</div>
<nav id="lists" aria-label="<?php echo(htmlspecialchars(TodoLang::_("LISTS")));?>"></nav>
<div id="tagbarRow">
<div id="tagbar" role="group" aria-label="<?php echo(htmlspecialchars(TodoLang::_("TAG_FILTER")));?>"></div>
<button type="button" id="tagToggle" class="tagChip" aria-expanded="false" aria-controls="tagbar" hidden></button>
Expand All @@ -59,6 +58,7 @@
</div>
</form>
<ul id="todoList"></ul>
<nav id="lists" aria-label="<?php echo(htmlspecialchars(TodoLang::_("LISTS")));?>"></nav>
</div>
</div>
<!-- messages (js/toast.js) -->
Expand Down
2 changes: 1 addition & 1 deletion js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ function renderItem(it) {
((it.completed==1)?'checked="true" ':'')+((it.deleted==1)?'disabled ':'')+
'aria-label="'+escapeHtml($T('DONE'))+'" /></span>';
line += '<span class="todoBody"><button type="button" class="todoMain" id="open'+it.id+'" title="'+todoTooltip(it)+'">'+todoTitleHtml(it)+'</button>';
line += '<span class="todoDue">'+dueHtml+(isOverdue ? ' <span class="exclamation">'+icon('warning')+'</span>':'')+'</span></span>';
line += '<span class="todoDue">'+dueHtml+'</span></span>';
line += '<span class="todoActions">';
if (it.deleted == 0) {
line += iconButton('trash'+it.id, 'deleteButton', 'delete', $T('DELETE'));
Expand Down
2 changes: 2 additions & 0 deletions nginx.conf.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/apache-access.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions tests/deny-lists.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
});
4 changes: 4 additions & 0 deletions tests/layout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...
Expand Down
35 changes: 27 additions & 8 deletions tests/rows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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]) {
Expand Down Expand Up @@ -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);
Expand Down
77 changes: 48 additions & 29 deletions todo.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -508,17 +528,17 @@ 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);
}
.iconButton {
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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -720,7 +739,7 @@ svg.icon {
}
#enter_due_label {
display: block;
font-size: 13px;
font-size: .8125rem;
text-align: center;
color: var(--muted);
}
Expand Down Expand Up @@ -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 */
Expand All @@ -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);
}
}
/********************/
Expand Down Expand Up @@ -921,6 +940,6 @@ dialog::backdrop {
}
.dialogButton {
padding: 8px 16px;
font-size: 16px;
font-size: 1rem;
}
}
2 changes: 1 addition & 1 deletion webicons/LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading