max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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">⋮</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">⋮</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">⋮</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">⋮</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">⋮</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">⋮</button> | |
| 595 | 595 | </div> | |
| 596 | 596 | </div> | |
| 597 | 597 | `; |
| @@ -207,12 +207,12 @@ | |||
| 207 | 207 | const statusIcons = { active: '○', done: '✓', abandoned: '✗' }; | |
| 208 | 208 | const icon = statusIcons[goal.status] || '○'; | |
| 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">✕</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">✕</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">⋮</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">⋮</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">▶︎</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">◎</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">☾︎</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">⋮</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">⋮</button> | |
| 180 | 180 | </div> | |
| 181 | 181 | </div> | |
| 182 | 182 | `; |