mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
fix: touch reachability, contrast and alignment
A pass over desktop, tablet and phone widths, measuring rather than eyeballing. The one that matters: a task could not be deleted on a phone. The delete button is revealed on hover, touch devices report hover: none, so it sat at zero opacity with nothing to reveal it. The README told people to hover over a task and click it. It is now always visible where hover does not exist. Touch targets were under the 44px minimum: the checkbox at 24, delete at 32, the footer links at 23 tall. They now get 44px hit areas from overlays that leave the drawn size alone, behind a pointer: coarse query so a mouse keeps small precise targets and the full row stays draggable. Two contrast failures in light mode. The date was the brand blue on the near-white background at 3.54:1, so text now uses a darker --accent-text and reads 5.14:1. Completed rows carried opacity 0.8 over an already mid-contrast blue, which came to 3.34:1; the tint, the strikethrough and the colour say "done" well enough without it, and dropping it gives 4.78:1. Both themes now pass AA everywhere measured. The checkbox sat vertically centred, so on a task wrapping to five lines it floated in the middle of the block. Rows align to the top and task text gets a 24px line box, matching the checkbox exactly, which also spaces wrapped lines better. Reduced motion was only honoured by the empty-state drawing. Svelte's transitions run in JavaScript and never saw the media query, so tasks still flew in on a stagger. The component reads the preference directly and collapses its durations, and CSS covers the rest. The lift on a dragged card stays, since it tracks the pointer rather than playing at you. Long unbroken words already wrapped and no breakpoint scrolled sideways.
This commit is contained in:
@@ -88,7 +88,7 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
|
|||||||
### Managing Tasks
|
### Managing Tasks
|
||||||
- **Add a task**: Type in the input field and press Enter
|
- **Add a task**: Type in the input field and press Enter
|
||||||
- **Complete a task**: Click the checkbox next to the task
|
- **Complete a task**: Click the checkbox next to the task
|
||||||
- **Delete a task**: Hover over a task and click the delete icon, or press `Delete` with the task focused
|
- **Delete a task**: Click the delete icon on the task (it shows on hover, and is always visible on touch), or press `Delete` with the task focused
|
||||||
- **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was
|
- **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was
|
||||||
- **Reorder tasks**: Drag with a mouse, long-press then drag on touch, or focus a task and press `Alt+↑`/`Alt+↓`
|
- **Reorder tasks**: Drag with a mouse, long-press then drag on touch, or focus a task and press `Alt+↑`/`Alt+↓`
|
||||||
- **Clear input**: Press Escape while the input is focused
|
- **Clear input**: Press Escape while the input is focused
|
||||||
|
|||||||
+23
-4
@@ -84,6 +84,19 @@
|
|||||||
darkMode = !darkMode;
|
darkMode = !darkMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Svelte's transitions are driven in JavaScript, so the CSS media query in
|
||||||
|
// style.css cannot reach them. Read the same preference here and collapse the
|
||||||
|
// durations. The lift on a dragged card stays: it tracks the pointer rather
|
||||||
|
// than playing at you, and losing it would make dragging harder to follow.
|
||||||
|
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
let reduceMotion = $state(motionQuery.matches);
|
||||||
|
|
||||||
|
const flyIn = (index) =>
|
||||||
|
reduceMotion ? { duration: 0 } : { y: -10, duration: 300, delay: index * 30, easing: cubicOut };
|
||||||
|
|
||||||
|
const flyOut = (index) =>
|
||||||
|
reduceMotion ? { duration: 0 } : { x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut };
|
||||||
|
|
||||||
let fileInput;
|
let fileInput;
|
||||||
let status = $state('');
|
let status = $state('');
|
||||||
let statusIsError = $state(false);
|
let statusIsError = $state(false);
|
||||||
@@ -399,6 +412,10 @@
|
|||||||
}, msUntilNextMidnight(new Date()));
|
}, msUntilNextMidnight(new Date()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMotionPreference(event) {
|
||||||
|
reduceMotion = event.matches;
|
||||||
|
}
|
||||||
|
|
||||||
function handleVisibility() {
|
function handleVisibility() {
|
||||||
if (document.visibilityState === 'visible') runRollover();
|
if (document.visibilityState === 'visible') runRollover();
|
||||||
}
|
}
|
||||||
@@ -426,11 +443,13 @@
|
|||||||
scheduleMidnight();
|
scheduleMidnight();
|
||||||
document.addEventListener('visibilitychange', handleVisibility);
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
window.addEventListener('focus', runRollover);
|
window.addEventListener('focus', runRollover);
|
||||||
|
motionQuery.addEventListener('change', handleMotionPreference);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(midnightTimer);
|
clearTimeout(midnightTimer);
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
window.removeEventListener('focus', runRollover);
|
window.removeEventListener('focus', runRollover);
|
||||||
|
motionQuery.removeEventListener('change', handleMotionPreference);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -518,7 +537,7 @@
|
|||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
{#key selectedKey}
|
{#key selectedKey}
|
||||||
{#if tasks.length === 0}
|
{#if tasks.length === 0}
|
||||||
<div class="empty-state" transition:fade={{ duration: 200 }}>
|
<div class="empty-state" transition:fade={{ duration: reduceMotion ? 0 : 200 }}>
|
||||||
<svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
<svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round">
|
<g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round">
|
||||||
<path d="M40 34 L40 34" />
|
<path d="M40 34 L40 34" />
|
||||||
@@ -550,9 +569,9 @@
|
|||||||
class:dragging={task.id === draggedId}
|
class:dragging={task.id === draggedId}
|
||||||
class:settling={task.id === settlingId}
|
class:settling={task.id === settlingId}
|
||||||
style={rowStyle(task.id)}
|
style={rowStyle(task.id)}
|
||||||
animate:flip={{ duration: task.id === draggedId ? 0 : 240, easing: cubicOut }}
|
animate:flip={{ duration: (reduceMotion || task.id === draggedId) ? 0 : 240, easing: cubicOut }}
|
||||||
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
|
in:fly={flyIn(index)}
|
||||||
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
|
out:fly={flyOut(index)}
|
||||||
onpointerdown={(e) => handlePointerDown(e, index)}
|
onpointerdown={(e) => handlePointerDown(e, index)}
|
||||||
onpointermove={handlePointerMove}
|
onpointermove={handlePointerMove}
|
||||||
onpointerup={handlePointerUp}
|
onpointerup={handlePointerUp}
|
||||||
|
|||||||
+63
-3
@@ -4,6 +4,9 @@
|
|||||||
--text-primary: #1A1A1A;
|
--text-primary: #1A1A1A;
|
||||||
--text-secondary: #666666;
|
--text-secondary: #666666;
|
||||||
--accent: #607afb;
|
--accent: #607afb;
|
||||||
|
/* Darker sibling of --accent for text. The brand blue reads 3.54:1 on the
|
||||||
|
light background, under the 4.5:1 needed for body-sized text. */
|
||||||
|
--accent-text: #4C5FD5;
|
||||||
--border: #E0E0E0;
|
--border: #E0E0E0;
|
||||||
--hover: #F5F5F5;
|
--hover: #F5F5F5;
|
||||||
--completed-bg: #EEF1FF;
|
--completed-bg: #EEF1FF;
|
||||||
@@ -18,6 +21,7 @@ html.dark {
|
|||||||
--text-primary: #E0E0E0;
|
--text-primary: #E0E0E0;
|
||||||
--text-secondary: #999999;
|
--text-secondary: #999999;
|
||||||
--accent: #7B93FF;
|
--accent: #7B93FF;
|
||||||
|
--accent-text: #8FA5FF;
|
||||||
--border: #333333;
|
--border: #333333;
|
||||||
--hover: #2A2A2A;
|
--hover: #2A2A2A;
|
||||||
--completed-bg: rgba(96, 122, 251, 0.15);
|
--completed-bg: rgba(96, 122, 251, 0.15);
|
||||||
@@ -177,7 +181,7 @@ body {
|
|||||||
.date-display {
|
.date-display {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input-container {
|
.task-input-container {
|
||||||
@@ -261,7 +265,7 @@ body {
|
|||||||
|
|
||||||
.task-item {
|
.task-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
background-color: var(--bg-surface);
|
background-color: var(--bg-surface);
|
||||||
@@ -321,7 +325,6 @@ html.dark .task-item.dragging {
|
|||||||
|
|
||||||
.task-item.completed {
|
.task-item.completed {
|
||||||
background-color: var(--completed-bg);
|
background-color: var(--completed-bg);
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
@@ -365,6 +368,9 @@ html.dark .task-item.dragging {
|
|||||||
.task-text {
|
.task-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
/* 24px line box, the same height as the checkbox, so the two align on the
|
||||||
|
first line without nudging either. */
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: all 300ms ease;
|
transition: all 300ms ease;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -379,6 +385,7 @@ html.dark .task-item.dragging {
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
|
position: relative;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -461,11 +468,23 @@ html.dark .task-item.dragging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Decorative only, so respect a reduced-motion preference. */
|
/* Decorative only, so respect a reduced-motion preference. */
|
||||||
|
/* Svelte's transitions run in JavaScript and never see this query, so
|
||||||
|
App.svelte checks the same preference and zeroes its durations. This half
|
||||||
|
covers everything driven by CSS. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.empty-art-box,
|
.empty-art-box,
|
||||||
.empty-art-motes path {
|
.empty-art-motes path {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
@@ -528,6 +547,47 @@ html.dark .data-status.error {
|
|||||||
color: #ff8a8a;
|
color: #ff8a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Touch devices.
|
||||||
|
*
|
||||||
|
* The delete button is revealed on hover, which does not exist here. Without
|
||||||
|
* this it stays at zero opacity and a task cannot be deleted on a phone at
|
||||||
|
* all, which is what the README told people to do.
|
||||||
|
*
|
||||||
|
* The rest widens hit areas to the 44px minimum. The overlays are positioned
|
||||||
|
* so nothing moves: the controls keep the size they were drawn at, they are
|
||||||
|
* just easier to hit. Kept behind the media query so a mouse still gets small,
|
||||||
|
* precise targets and the full row stays available to drag. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.checkbox::after,
|
||||||
|
.delete-btn::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn,
|
||||||
|
.theme-toggle {
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-completed,
|
||||||
|
.data-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
Reference in New Issue
Block a user