Skip to main content

max / goingson

Take the hover-reveal of row actions from the generated sheet Three hand-written reveal families -- .kebab-btn, .task-row-action and .month-goal-delete-btn -- each held their own opacity: 0 and their own list of ancestors that revealed them. makeover-webview 0.5.1 emits exactly that pattern as .row-actions, hidden with opacity plus pointer-events so the control stays focusable and stays in the accessibility tree, revealed by .row:hover and .row:focus-within. e63b3a2 landed the corrected form and noted nothing wore it yet; this is what wears it. .row goes on the task row, the email item, both event rows and the filled month goal. .row-actions goes on each control that hides. The three GO classes keep their look -- font size, padding, colour -- and give up the hiding entirely. Deletions: 16 selectors and 607 minified bytes. Twelve of those selectors were reveal lists that had to name every ancestor a control could sit in, which is the form that let .month-goal-delete-btn miss the focus escape until 4029f15 added it by hand. The generated rule cannot miss one. TWO BEHAVIOUR CHANGES, both fixes: .month-goal-delete-btn is reachable on touch now. Its only reveal was :hover/:focus-within on .month-goal-item, and a filled month goal carries no tabindex, so on a touch device with no hover the delete button could not be shown at all. It now follows the same always-show rule as the other two families. The project card kebab loses style="opacity: 1;". It wore the hover-reveal class and then cancelled the one thing that class was for, because a project card is not one of the three ancestors the reveal named. It is always visible, so it does not wear .row-actions and its card is not a .row. Every "always show them, there is no hover" override -- mobile UI mode, the (hover: none) media query, body.is-touch -- had to gain pointer-events: auto. They restored opacity only, which was enough against an opacity-only rest state and would have left a visible, unclickable row action against this one. Docs: design-system.md gains the .row / .row-actions contract, and its row table said the project card renders in projects-render.js when it renders in projects.js.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 02:38 UTC
Signed with PGP, not checked
Commit: 3d24498b1448e9d53d49a430c56da8436711c0da
Parent: e63b3a2
7 files changed, +55 insertions, -62 deletions
@@ -100,11 +100,24 @@
100 100
101 101 | Kind | Canonical class | Render helper (today) |
102 102 |---|---|---|
103 - | Task row | `.task-row` (in `.task-table`) | `renderTaskRow(t, index)`, `tasks-render.js` |
104 - | Event row | `.event-row-virtual` (in `.event-table-virtual`) | inline in `events.js` |
105 - | Project card | `.card` (in `.cards-grid`) | inline in `projects-render.js` |
103 + | Task row | `.row.task-row` (in `.task-table`) | `renderTaskRow(t, index)`, `tasks-render.js` |
104 + | Event row | `.row.event-row-virtual` (in `.event-table-virtual`) | inline in `events.js` |
105 + | Project card | `.card` (in `.cards-grid`) | `renderProjectCard(p)`, `projects.js` |
106 106 | Contact card | `.card.contact-card` | inline in `contacts-render.js` |
107 - | Email row | `.email-item` (in `.email-list`) | inline in `emails.js` |
107 + | Email row | `.row.email-item` (in `.email-list`) | `renderEmailItem(thread)`, `emails-render.js` |
108 +
109 + ### Hover-revealed row actions: `.row` + `.row-actions`
110 + Both classes are generated (`layout.css`, makeover-layout `RowPart`). `.row-actions`
111 + rests at `opacity: 0` with `pointer-events: none`; `.row:hover` and `.row:focus-within`
112 + bring both back, so the control stays in the DOM, stays focusable, and stays in the
113 + accessibility tree. Put `.row` on the row and `.row-actions` on each control that
114 + hides: `.kebab-btn`, `.task-row-action`, `.month-goal-delete-btn`. Those three classes
115 + carry the look only, never the hiding.
116 +
117 + A control that is always visible does not wear `.row-actions` — the project card's
118 + kebab is one, which is why its card is not a `.row`. In mobile UI mode and on touch
119 + there is no hover, so `.row-actions` is shown unconditionally; any such override must
120 + restore `pointer-events` as well as `opacity`.
108 121
109 122 **Canonical helper (to be added):** `GoingsOn.ui.renderRow(kind, model, opts)`. Each `renderXxx` above becomes a thin adapter that maps the model to the shared "icon · primary · secondary · meta · actions" slot layout. Surface audits in Phase 1+ assume this exists.
110 123
@@ -150,6 +150,9 @@
150 150 NOT .row: it has no rule here, and the generated layout.css claims the
151 151 name for a LIST ROW (makeover-layout RowPart, its parts .row-primary /
152 152 -secondary / -meta / -actions). Do not write class="row" for a flex row.
153 + DO write it on a list row that hides actions until hover: .row is the
154 + ancestor .row-actions reveals from. Worn today by the task row, the email
155 + item, the event row and the filled month goal.
153 156
154 157 COMPONENT PRIMITIVES
155 158 .button + .button--primary / --secondary / --danger / --sm
@@ -4954,22 +4957,13 @@
4954 4957 margin-right: var(--gap-bound);
4955 4958 }
4956 4959
4957 - /* Kebab menu button, extends .button--icon with hover-reveal + bigger font. */
4960 + /* Kebab menu button, extends .button--icon with a bigger font. The hiding and
4961 + the reveal come from the generated .row-actions; this rule is the look only. */
4958 4962 .kebab-btn {
4959 4963 font-size: var(--font-size-xl);
4960 4964 padding: var(--step-tight) var(--step-snug);
4961 4965 border-radius: var(--radius-sm);
4962 4966 color: var(--content-secondary);
4963 - opacity: 0;
4964 - }
4965 -
4966 - .task-row:hover .kebab-btn,
4967 - .task-row:focus-within .kebab-btn,
4968 - .email-item:hover .kebab-btn,
4969 - .email-item:focus-within .kebab-btn,
4970 - .event-row-virtual:hover .kebab-btn,
4971 - .event-row-virtual:focus-within .kebab-btn {
4972 - opacity: 1;
4973 4967 }
4974 4968
4975 4969 .kebab-btn:hover {
@@ -4977,21 +4971,14 @@
4977 4971 }
4978 4972
4979 4973 /* Row-level actions (track time, focus, snooze) sitting beside the kebab.
4980 - Always in the DOM for keyboard and screen readers; revealed on row hover or
4981 - focus so the resting row stays quiet. Never display: none. */
4974 + Always in the DOM for keyboard and screen readers; the generated .row-actions
4975 + reveals them on row hover or focus. Never display: none. */
4982 4976 .task-row-action {
4983 4977 font-size: var(--font-size-base);
4984 4978 line-height: 1;
4985 4979 padding: var(--step-tight) var(--step-snug);
4986 4980 border-radius: var(--radius-sm);
4987 4981 color: var(--content-secondary);
4988 - opacity: 0;
4989 - }
4990 -
4991 - .task-row:hover .task-row-action,
4992 - .task-row:focus-within .task-row-action,
4993 - .task-row-action:focus {
4994 - opacity: 1;
4995 4982 }
4996 4983
4997 4984 .task-row-action:hover {
@@ -6748,20 +6735,12 @@
6748 6735 font-size: var(--font-size-base);
6749 6736 }
6750 6737
6751 - /* Extends .button--icon with hover-reveal opacity and tertiary color. */
6738 + /* Extends .button--icon with a tertiary color. Hiding and reveal come from the
6739 + generated .row-actions, which the button wears alongside this class. */
6752 6740 .month-goal-delete-btn {
6753 6741 color: var(--content-muted);
6754 6742 padding: 0 var(--step-tight);
6755 6743 font-size: var(--font-size-sm);
6756 - opacity: 0;
6757 - }
6758 -
6759 - /* Focus reveals it too, the way .task-row-action and .kebab-btn already do.
6760 - Hover alone left a keyboard user holding an invisible focused button. */
6761 - .month-goal-item:hover .month-goal-delete-btn,
6762 - .month-goal-item:focus-within .month-goal-delete-btn,
6763 - .month-goal-delete-btn:focus {
6764 - opacity: 1;
6765 6744 }
6766 6745
6767 6746 /* Patterns */
@@ -7729,14 +7708,18 @@
7729 7708 display: none;
7730 7709 }
7731 7710
7732 - .ui-mode-mobile .kebab-btn {
7711 + /* There is no hover in mobile UI mode, so every row action is always shown.
7712 + pointer-events comes back with the opacity: the generated .row-actions takes
7713 + both away at rest, and restoring only the opacity would leave a row action
7714 + that is visible and not clickable. */
7715 + .ui-mode-mobile .row-actions {
7733 7716 opacity: 1;
7717 + pointer-events: auto;
7734 7718 }
7735 7719
7736 - /* Mobile rows are cards with their own action strip, so the row actions sit
7737 - next to the kebab at full opacity and full touch-target size. */
7720 + /* Mobile rows are cards with their own action strip, so the task row actions
7721 + also take a full touch-target size. The kebab keeps its own size. */
7738 7722 .ui-mode-mobile .task-row-action {
7739 - opacity: 1;
7740 7723 min-height: 44px;
7741 7724 min-width: 44px;
7742 7725 }
@@ -8039,16 +8022,15 @@
8039 8022 transform: none;
8040 8023 }
8041 8024
8042 - /* Row-action kebabs are hover-revealed on desktop; on touch always show them */
8043 - .task-row .kebab-btn,
8044 - .email-item .kebab-btn,
8045 - .event-row-virtual .kebab-btn {
8025 + /* Row actions are hover-revealed on desktop; on touch there is no hover, so
8026 + show them all and give the pointer back what .row-actions took away. */
8027 + .row-actions {
8046 8028 opacity: 1;
8029 + pointer-events: auto;
8047 8030 }
8048 8031
8049 - /* Same for the row action buttons; there is no hover to reveal them */
8032 + /* Task row actions also take a full touch-target size. */
8050 8033 .task-row .task-row-action {
8051 - opacity: 1;
8052 8034 min-height: 44px;
8053 8035 min-width: 44px;
8054 8036 }
@@ -8072,13 +8054,11 @@
8072 8054
8073 8055 /* Touch-device body class (set by touch.js), equivalent guards for browsers
8074 8056 that report (hover: hover) but are still touch-first (e.g. some Chromebooks). */
8075 - body.is-touch .task-row .kebab-btn,
8076 - body.is-touch .email-item .kebab-btn,
8077 - body.is-touch .event-row-virtual .kebab-btn {
8057 + body.is-touch .row-actions {
8078 8058 opacity: 1;
8059 + pointer-events: auto;
8079 8060 }
8080 8061 body.is-touch .task-row .task-row-action {
8081 - opacity: 1;
8082 8062 min-height: 44px;
8083 8063 min-width: 44px;
8084 8064 }
@@ -31,7 +31,7 @@
31 31 ).join('');
32 32
33 33 return `
34 - <div class="email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}"
34 + <div class="row email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}"
35 35 data-id="${escAttr(e.id)}"
36 36 data-contextmenu="contextMenus.showEmail" data-a1="@event" data-a2="${escAttr(e.id)}"
37 37 data-email-archived="${e.isArchived}" data-email-read="${e.isRead}"
@@ -53,7 +53,7 @@
53 53 <div class="email-subject">${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}</div>
54 54 <div class="email-preview">${esc(e.bodyPreview)}...</div>
55 55 </div>
56 - <button class="button--icon kebab-btn" data-act="contextMenus.showEmail" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Email actions">&#x22EE;</button>
56 + <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEmail" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Email actions">&#x22EE;</button>
57 57 </div>
58 58 `;
59 59 }
@@ -537,7 +537,7 @@
537 537 if (isRecurring) {
538 538 const patternLabel = e.recurrenceDisplay || e.recurrence || 'Recurring';
539 539 return `
540 - <div class="event-row-virtual event-recurring"
540 + <div class="row event-row-virtual event-recurring"
541 541 data-id="${escAttr(e.id)}"
542 542 data-act="events.open" data-a1="${escAttr(e.id)}"
543 543 data-contextmenu="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}"
@@ -547,7 +547,7 @@
547 547 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
548 548 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
549 549 <div class="event-cell" style="text-align: right;" data-act="ui.noop">
550 - <button class="button--icon kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>
550 + <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>
551 551 </div>
552 552 </div>
553 553 `;
@@ -573,7 +573,7 @@
573 573
574 574 return `
575 575 ${dateHeader}
576 - <div class="event-row-virtual ${e.isPast || isPast ? 'event-past' : ''}"
576 + <div class="row event-row-virtual ${e.isPast || isPast ? 'event-past' : ''}"
577 577 data-id="${escAttr(e.id)}"
578 578 data-act="events.open" data-a1="${escAttr(e.id)}"
579 579 data-contextmenu="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}"
@@ -591,7 +591,7 @@
591 591 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
592 592 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
593 593 <div class="event-cell" style="text-align: right;" data-act="ui.noop">
594 - <button class="button--icon kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>
594 + <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>
595 595 </div>
596 596 </div>
597 597 `;
@@ -207,12 +207,12 @@
207 207 const statusIcons = { active: '&#x25CB;', done: '&#x2713;', abandoned: '&#x2717;' };
208 208 const icon = statusIcons[goal.status] || '&#x25CB;';
209 209
210 - let html = `<div class="scope-slot month-goal-item filled ${goal.status}">`;
210 + let html = `<div class="row scope-slot month-goal-item filled ${goal.status}">`;
211 211 html += `<span class="scope-slot-label">Goal #${position}</span>`;
212 212 html += `<div class="month-goal-body">`;
213 213 html += `<button class="button--icon month-goal-status-btn" data-act="monthlyReview.cycleGoalStatus" data-a1="${escAttr(goal.id)}" title="Cycle status">${icon}</button>`;
214 214 html += `<span class="scope-slot-title month-goal-text">${esc(goal.text)}</span>`;
215 - html += `<button class="button--icon month-goal-delete-btn" data-act="monthlyReview.deleteGoal" data-a1="${escAttr(goal.id)}" title="Delete goal">&#x2715;</button>`;
215 + html += `<button class="button--icon row-actions month-goal-delete-btn" data-act="monthlyReview.deleteGoal" data-a1="${escAttr(goal.id)}" title="Delete goal">&#x2715;</button>`;
216 216 html += `</div>`;
217 217 html += '</div>';
218 218 return html;
@@ -120,7 +120,7 @@
120 120 tabindex="0" role="button" aria-label="Open project ${esc(p.name)}">
121 121 <div class="card-header">
122 122 <h3 class="card-title">${esc(p.name)}</h3>
123 - <button class="button--icon kebab-btn" style="opacity: 1;" data-act="contextMenus.showProject" data-a1="@event" data-a2="${escAttr(p.id)}" title="Actions" aria-label="Project actions">&#x22EE;</button>
123 + <button class="button--icon kebab-btn" data-act="contextMenus.showProject" data-a1="@event" data-a2="${escAttr(p.id)}" title="Actions" aria-label="Project actions">&#x22EE;</button>
124 124 </div>
125 125 <div class="card-description markdown-content">${p.descriptionHtml || ''}</div>
126 126 <div class="card-meta">
@@ -124,15 +124,15 @@
124 124 function renderRowActions(t, isStarted) {
125 125 const id = escAttr(t.id);
126 126 const track = isStarted
127 - ? `<button class="button--icon task-row-action task-row-action--track"
127 + ? `<button class="button--icon row-actions task-row-action task-row-action--track"
128 128 data-act="timeTracking.startTimer" data-a1="${id}"
129 129 title="Track time" aria-label="Track time on this task">&#x25B6;&#xFE0E;</button>`
130 130 : '';
131 131 return `${track}
132 - <button class="button--icon task-row-action task-row-action--focus"
132 + <button class="button--icon row-actions task-row-action task-row-action--focus"
133 133 data-act="focusTimer.start" data-a1="${id}"
134 134 title="Focus session" aria-label="Start a focus session on this task">&#x25CE;</button>
135 - <button class="button--icon task-row-action task-row-action--snooze"
135 + <button class="button--icon row-actions task-row-action task-row-action--snooze"
136 136 data-act="snooze.openModal" data-a1="task" data-a2="${id}"
137 137 title="Snooze" aria-label="Snooze this task">&#x263E;&#xFE0E;</button>`;
138 138 }
@@ -144,7 +144,7 @@
144 144 const isStarted = t.status === 'Started';
145 145
146 146 return `
147 - <div class="task-row task-${t.status.toLowerCase()} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}"
147 + <div class="row task-row task-${t.status.toLowerCase()} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}"
148 148 data-id="${escAttr(t.id)}"
149 149 data-contextmenu="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}"
150 150 tabindex="0" role="row">
@@ -176,7 +176,7 @@
176 176 ${isSelected ? 'checked' : ''}
177 177 data-change="tasks.toggleSelection" data-a1="${escAttr(t.id)}" data-a2="@el" data-a3="@event"
178 178 aria-label="Select task">
179 - <button class="button--icon kebab-btn" data-act="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" title="Actions" aria-label="Task actions">&#x22EE;</button>
179 + <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" title="Actions" aria-label="Task actions">&#x22EE;</button>
180 180 </div>
181 181 </div>
182 182 `;