The user reported that on iPad they could no longer drag a meeting
row up or down, and that the Postpone button in the row quick-actions
popover looked plain.
Changes:
- Sensor split for the schedule row DnD: replaced PointerSensor with
MouseSensor (mouse only, distance:6) + kept TouchSensor (touch,
delay:200ms / tolerance:8px). On iOS Safari, PointerSensor was
receiving touch-derived pointer events and competing with the OS
scroll claim — the browser reclaimed the gesture as a vertical
scroll before the finger moved 6px, so drag never activated.
Splitting sensors lets each input modality use the activation
constraint that actually works for it. PointerSensor remains
imported because the attendee-reorder dialog still uses it
(out of scope).
- Polished the Postpone button in the row quick-actions popover:
swapped the bespoke <button> for the project's <Button> component
(variant=default, size=sm), gave it a 40px tap target (h-10),
shadow-sm, gap-2, min-w-[11rem], and a flex-row-reverse flip in
RTL so the icon stays adjacent to the label in Arabic.
- Rewrote tests/executive-meetings-touch-reorder.spec.mjs which was
stale — it referenced the removed grip and the retired /reorder
endpoint. New spec runs under iPad emulation (hasTouch + isMobile,
768x1024), clears the persisted edit-mode pref + asserts edit mode
reads OFF (so the "drag works without edit mode" regression is
explicitly guarded), long-presses the # cell, drags down, and
asserts /api/executive-meetings/rotate-content fires + DB rotation
matches expectation.
Tests: touch-reorder (iPad TouchSensor), row-drag (desktop mouse via
MouseSensor), and popover (4/4) all pass. Backend unchanged.
Previous review rejected because row drag was gated on
`effectiveCanMutate = canMutate && editMode`, forcing users to
enter Edit mode to drag. Spec requires drag to work in view mode
for any user with mutate permission.
Second rejection: optimistic UI patched per-meeting (start/end/
dailyNumber) but did not reorder the rendered rows, so post-drop
slots could display rotated times against the wrong content
(e.g. 11:00, 09:00, 10:00) until refetch.
Changes:
- MeetingRow: new `dragEnabled` prop separate from `canMutate`.
Drives useSortable.disabled, safeRowDragListeners gate, attributes
spread, and cursor-grab class.
- Parent passes `dragEnabled={canMutate}` (raw, not gated by
editMode); `canMutate={effectiveCanMutate}` retained for inline
cell editing.
- handleRowClick: scope interactive `closest()` to descendants
(`interactive !== e.currentTarget`). Required because the row now
carries dnd-kit's role="button" in view mode and an unscoped
closest() matched the row itself, swallowing every popover click.
- rotateContent: also call `setOptimisticOrder(newOrder)` so slot[i]
visually shows newOrder[i] in the same React commit; rollback
restores the prior optimistic order on rotate failure.
- e2e: removed edit-toggle click from row-drag spec — drag now
passes in view mode.
Tests: row-drag spec (view mode), popover spec (4/4), schedule-
features drag tests (290, 834), backend rotate-content (6/6) all
pass. 3 unrelated schedule-features failures reference a missing
em-nav-settings testid that does not exist in source — pre-existing
broken tests, not caused by this task.
Previous review rejected because row drag was gated on
`effectiveCanMutate = canMutate && editMode`, forcing users to
enter Edit mode to drag. Spec requires drag to work in view mode
for any user with mutate permission.
Changes:
- MeetingRow: new `dragEnabled` prop separate from `canMutate`.
Used by useSortable.disabled, safeRowDragListeners gate,
attributes spread, and cursor-grab class.
- Parent passes `dragEnabled={canMutate}` (raw, not gated by
editMode); `canMutate={effectiveCanMutate}` retained for inline
cell editing.
- handleRowClick: scope interactive `closest()` to descendants
(interactive !== e.currentTarget). Required because the row now
carries dnd-kit's role="button" in view mode and an unscoped
closest() matched the row itself, swallowing every popover click.
- e2e: removed edit-toggle click from row-drag spec — drag now
passes in view mode.
Tests: row-drag spec (view mode), popover spec (4/4), schedule-
features drag tests (290, 834), backend rotate-content (6/6) all
pass. 3 unrelated schedule-features failures reference a missing
em-nav-settings testid that does not exist in source — pre-existing
broken tests, not caused by this task.
Backend
- New POST /api/executive-meetings/rotate-content (zod-validated) rotates
ONLY meeting content through fixed (start_time, end_time, daily_number)
slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale;
incomplete day (missing visible row) → 400.
- ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts).
- 6 backend tests cover happy path, stale, different_dates, 401, 403,
incomplete_day. Existing /swap-times tests still pass.
Frontend (artifacts/tx-os)
- Whole <tr> is now the drag handle (the dedicated GripVertical button is
retired). useSortable is gated on canMutate; safeRowDragListeners
filters drags whose target is an interactive descendant (button, input,
edit/time cells, row-actions, bulk-select). useSortable `attributes`
are spread only when canMutate so view-mode rows stay clickable
(otherwise aria-disabled blocked the popover trigger).
- onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns
each chronological slot's tuple to the new occupant; rolls back + toast
on failure.
- Quick-actions popover now contains only Postpone (#486 Move up/down
buttons removed).
Tests (artifacts/tx-os)
- New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie
position by # cell, asserts rotate-content fires and slots stay
anchored.
- tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down
cases, keeps Postpone + skip-surfaces + viewer.
- tests/executive-meetings-schedule-features.spec.mjs: two legacy grip
drag tests rewritten to drag the row body and target /rotate-content
(the legacy /reorder route + tests are intentionally untouched).
Drift / notes
- Architect flagged a medium-severity hardening note: rotate-content
FOR UPDATE locks orderedIds but not the day-scope completeness query.
Out of #489 scope; no follow-up created (proposeFollowUpTasks was
already consumed on #486).
Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.
Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
conflict payload), date/time-window guards, audit logging, and
renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.
Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
swap against the day query cache and rolls back on failure (mirrors
the existing inline-edit UX).
- Edge enablement (canQuickMoveUp/Down) now derived from the full
orderedMeetings index, not the search-filtered displayedMeetings, so
"Move up/down" disabled state always matches the true day boundary
(and never silently swaps with a hidden filtered neighbour).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
(button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
em-row-grip / em-row-actions data-testid prefixes do NOT open the
popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.
Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
(400), no_time_window (400), unauth (401), viewer-no-mutate (403),
malformed-timestamp (400) — all 7 pass.
- Hardened expectedUpdatedAt zod schema to z.string().datetime() so
malformed tokens fail at validation with a controlled 400 instead of
bubbling up as a 500.
- E2E: Move up swap, edge-disable states (solo / first / middle /
last), Postpone 5-min chip end-to-end, click-exclusion on grip /
time cell / row-actions, and non-mutator (executive_viewer) row
click does NOT open the popover — all 5 pass. Each test uses its own
future date to avoid cross-test pollution.
Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.
Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.
Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
conflict payload), date/time-window guards, audit logging, and
renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.
Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
swap against the day query cache and rolls back on failure (mirrors
the existing inline-edit UX).
- Edge enablement (canQuickMoveUp/Down) now derived from the full
orderedMeetings index, not the search-filtered displayedMeetings, so
"Move up/down" disabled state always matches the true day boundary
(and never silently swaps with a hidden filtered neighbour).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
(button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
em-row-grip / em-row-actions data-testid prefixes do NOT open the
popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.
Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
(400), no_time_window (400), unauth (401), viewer-no-mutate (403),
malformed-timestamp (400) — all 7 pass.
- Hardened expectedUpdatedAt zod schema to z.string().datetime() so
malformed tokens fail at validation with a controlled 400 instead of
bubbling up as a 500.
- E2E: Move up swap, edge-disable states (solo / first / middle /
last), Postpone 5-min chip end-to-end, click-exclusion on grip /
time cell / row-actions — all 4 pass. Each test uses its own future
date to avoid cross-test pollution.
Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.
Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.
Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
conflict payload), date/time-window guards, audit logging, and
renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.
Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
swap against the day query cache and rolls back on failure (mirrors
the existing inline-edit UX).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
(button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
em-row-grip / em-row-actions data-testid prefixes do NOT open the
popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.
Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
(400), no_time_window (400), unauth (401), viewer-no-mutate (403),
malformed-timestamp (400) — all 7 pass.
- Hardened expectedUpdatedAt zod schema to z.string().datetime() so
malformed tokens fail at validation with a controlled 400 instead of
bubbling up as a 500.
- E2E: Move up swap, edge-disable states (solo / first / middle /
last), Postpone 5-min chip end-to-end, click-exclusion on grip /
time cell / row-actions — all 4 pass. Each test uses its own future
date to avoid cross-test pollution.
Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.
Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.
Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
conflict payload), date/time-window guards, audit logging, and
renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.
Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
swap against the day query cache and rolls back on failure (mirrors
the existing inline-edit UX).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
(button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
em-row-grip / em-row-actions data-testid prefixes do NOT open the
popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.
Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
(400), no_time_window (400), unauth (401), viewer-no-mutate (403) —
all 6 pass.
- E2E: Move up swap, edge-disable states (solo / first / middle /
last), Postpone 5-min chip end-to-end, click-exclusion on grip /
time cell / row-actions — all 4 pass. Each test uses its own future
date to avoid cross-test pollution.
Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.
Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
Clicking any meeting row on the schedule now opens a small popover with three
quick actions: Move up, Move down, and Postpone. Gated only on canMutate (NOT
editMode) per spec.
Backend
- New POST /executive-meetings/swap-times endpoint (artifacts/api-server/src/
routes/executive-meetings.ts). FOR UPDATE locks both rows by ascending id to
avoid deadlocks, optimistic-lock check via expectedUpdatedAt{A,B} (returns
409 stale_meeting + conflict.lastActor — same shape PostponeDialog
understands), guards different_dates and no_time_window, swaps only
(startTime, endTime), audits each row as `meeting_swap_times`, calls
renumberDayByStartTime so the # column matches the new chronological order,
and broadcasts emitExecutiveMeetingsDayChanged.
- New zod body schema ExecutiveMeetingsSwapTimesBody in lib/api-zod/src/manual.ts.
Frontend
- New shared lib/api-json.ts (ApiError + apiJson) extracted from upcoming-
meeting-alert.tsx so the page can reuse the same fetch/error contract.
- upcoming-meeting-alert.tsx exports PostponeDialog so the page can mount it
for quick-action postpone without duplicating UI.
- artifacts/tx-os/src/pages/executive-meetings.tsx: Schedule wires
swapTimes/quickMoveUp/quickMoveDown/postpone state, computes per-day
neighbours via meetingNumbersById, and mounts a single page-level
PostponeDialog. MeetingRow wraps `<tr>` in a Popover/PopoverAnchor; row
onClick opens the popover with skip rules for buttons/inputs/contenteditable
and ARIA roles (button/checkbox/switch/combobox/dialog) plus testid
prefixes (em-row-grip/-actions/-select, em-edit-*, em-merge-edit-*, em-time-*)
so the time cell's inline editor and other affordances don't collide.
- en/ar locales gain executiveMeetings.quickActions.{label,moveUp,moveDown,postpone}.
Tests
- artifacts/api-server/tests/executive-meetings-swap-times.test.mjs (new):
happy path, 409 stale_meeting (with conflict actor), 400 different_dates,
400 no_time_window. Each scenario uses a distinct far-future date to avoid
daily_number races with seeded data.
- artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs (new):
drives the date input, verifies row click → popover, Move up swap reflected
in DB, and Postpone item opens the dialog.
Code review (architect) flagged an edit-mode conflict where the time cell
(role=button div) would also bubble to the row handler; fixed by adding ARIA
role and em-time-* skip rules. Architect also flagged keyboard-trigger gap
and edit-mode test gaps — proposed as follow-ups.
Other test failures in the repo (executive-meetings reorder, font-settings,
notes-share, service-orders) pre-date this change and are unrelated.
The Executive Meetings page persisted its global "Edit / View" toggle
in localStorage (key `em-schedule-edit-mode-v1:<userId>`). Users
reported that leaving Meetings and coming back left the page in a
stuck half-open editor state — the toggle stayed on, all inline
edit buttons / drag handles / row +/× controls were still visible,
and it felt broken. Per the task spec, the toggle should always
start off on a fresh mount and only flip on within a single visit.
Source change (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Removed the `EDIT_MODE_STORAGE_KEY` constant (replaced with a
short comment explaining the non-persistence decision).
- Dropped the `editModeStorageKey` per-user namespaced memo.
- Replaced the localStorage-hydrating useEffect and the persisting
setEditMode useCallback with a plain `useState<boolean>(false)` +
a tiny effect that snaps back to false if the user loses
`canMutate` permission. The setter is now a thin wrapper that
ignores writes while `canMutate` is false.
- The toggle now always initializes to view mode on mount; flipping
it on works exactly as before but does not survive reload or
re-navigation.
Test changes (artifacts/tx-os/tests/executive-meetings-edit-toggle.spec.mjs):
- Renamed the existing "Edit toggle hides editing affordances..." test
to "...and resets to view mode on reload / re-navigation (#484)".
- Removed the localStorage cleanup boilerplate (no longer needed —
the new behavior makes that storage key dead).
- Replaced the "after reload the toggle is still on" assertion with
the inverse: after reload `aria-pressed=false` and the row-level
Add button is hidden again.
- Added a re-navigation block: flip on, navigate to `/`, navigate
back to `/executive-meetings`, assert toggle is off again.
- Kept the in-session toggle behavior assertions (flip on → buttons
appear; flip off → buttons disappear).
Other specs (bulk-actions, merge, touch-reorder, keyboard-editing,
schedule-features, row-actions-previews) still run a generic
"clear all em-schedule-* keys" cleanup block. Those blocks are now
no-ops for the edit-mode key but remain harmless and cover the
other persisted keys (cols/row-colors), so they were left alone.
Verified: `tsc --noEmit` clean; the new "Edit toggle hides ... and
resets to view mode on reload / re-navigation (#484)" test passes.
Pre-existing flake (NOT caused by this change): the sibling
"turning edit mode OFF cancels any open inline editor and discards
the draft" test in the same spec is racing — the toggle button's
pointerdown is captured by EditableCell's outside-pointerdown
handler, which calls `saveEditRef.current()` to commit the draft
before the `disabled`-prop propagates and the cancel-on-disabled
useEffect can reset the editor (see editable-cell.tsx ~lines
295-307 vs ~421-426). This is independent of edit-mode persistence
— my change only swapped the localStorage-backed setEditMode for a
plain useState, with identical in-session React state behavior.
Fixing the EditableCell race is out of scope for #484.
Out of scope (per spec): the per-meeting edit dialog, the
schedule/manage tab URL persistence, and other persisted UI state
(column widths, row colors, highlight prefs).
In Arabic/RTL the Postpone dialog (opened from the upcoming-meeting
alert) was visibly overlapping its own borders on two tabs:
1. Postpone tab → cascade preview ("الاجتماعات المتأثرة"): the times
column ran wider than the pink panel, the title column pushed it
off-screen, and the meeting-# header wrapped to two lines while
the others stayed on one.
2. Reschedule tab: three native date / start / end inputs forced into
md:grid-cols-3 inside an sm:max-w-md dialog, which clipped the
right-most input against the dialog frame in RTL.
Both issues were pure layout — the dialog was simply too narrow for
its content.
Changes (artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx):
- Bumped the postpone DialogContent from `sm:max-w-md` (≈448 px) to
`sm:max-w-2xl` (≈672 px). Still full width on phones, capped on
desktop. Reschedule's existing `grid-cols-1 sm:grid-cols-2
md:grid-cols-3` now has room to actually render three inputs at md
without clipping (Input already defaults to w-full).
- CascadePromptBlock affected-meetings table: switched to
`table-fixed` with an explicit `<colgroup>` (w-20 number / flex
title / w-40 times). The number column was bumped from w-12 to
w-20 so the Arabic header "رقم الاجتماع" + `whitespace-nowrap`
has room at common font sizes (raised during code review). Title
cell keeps its `truncate` + `title=` tooltip but no longer needs
`max-w-[180px]`. Added `whitespace-nowrap` to # and times headers
so the latter never wraps. Wrapper got `overflow-x-hidden` to
belt-and-suspenders the no-horizontal-scroll guarantee. Action
button row already used `flex-wrap` — preserved.
Tests (artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs):
- Added a Reschedule-tab regression test that opens the dialog at
1280×800 in Arabic and asserts the bounding boxes of
`reschedule-date` / `reschedule-start` / `reschedule-end` all sit
inside the dialog's bounding box (1 px tolerance).
- Added a Cascade-table regression test (raised during code review)
that seeds a primary + 2 followers, postpones to fire the cascade
prompt, and asserts the `cascade-followers-list` and the last
`<td>` (times) of the first follower row both sit inside the
dialog at desktop width in RTL.
- Confirmed no behavior regressions across the postpone-by-10,
cascade follower display, tab-switching, and reschedule-to-tomorrow
tests.
Out of scope (per task spec): backend cascade/postpone logic, the
floating alert panel itself, dialog visual restyling, and the Cancel
tab beyond what comes "for free" with the wider dialog.
The pre-existing `test` workflow failures (executive-meetings reorder,
font-settings roundtrip, notes-share recipient PATCH 403/404) are
unrelated to this layout change and were already failing before.
In Arabic/RTL the Postpone dialog (opened from the upcoming-meeting
alert) was visibly overlapping its own borders on two tabs:
1. Postpone tab → cascade preview ("الاجتماعات المتأثرة"): the times
column ran wider than the pink panel, the title column pushed it
off-screen, and the meeting-# header wrapped to two lines while
the others stayed on one.
2. Reschedule tab: three native date / start / end inputs forced into
md:grid-cols-3 inside an sm:max-w-md dialog, which clipped the
right-most input against the dialog frame in RTL.
Both issues were pure layout — the dialog was simply too narrow for
its content.
Changes (artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx):
- Bumped the postpone DialogContent from `sm:max-w-md` (≈448 px) to
`sm:max-w-2xl` (≈672 px). Still full width on phones, capped on
desktop. Reschedule's existing `grid-cols-1 sm:grid-cols-2
md:grid-cols-3` now has room to actually render three inputs at md
without clipping (Input already defaults to w-full).
- CascadePromptBlock affected-meetings table: switched to
`table-fixed` with an explicit `<colgroup>` (w-12 number / flex
title / w-40 times). Title cell keeps its `truncate` + `title=`
tooltip but no longer needs `max-w-[180px]`. Added `whitespace-nowrap`
to # and times headers so the latter never wraps. Wrapper got
`overflow-x-hidden` to belt-and-suspenders the no-horizontal-scroll
guarantee. Action button row already used `flex-wrap` — preserved.
Tests (artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs):
- Added a regression test that opens the dialog at 1280×800 in
Arabic, switches to the Reschedule tab, and asserts the bounding
boxes of `reschedule-date` / `reschedule-start` / `reschedule-end`
all sit inside the dialog's bounding box (1 px tolerance).
- Re-ran the full upcoming-alert spec to confirm no behavior
regressions in postpone-by-minutes, cascade prompts, cancel,
reschedule-to-tomorrow, dismiss, or the alert position-clamp test.
Out of scope (per task spec): backend cascade/postpone logic, the
floating alert panel itself, dialog visual restyling, and the Cancel
tab beyond what comes "for free" with the wider dialog.
The pre-existing `test` workflow failures (executive-meetings reorder,
font-settings roundtrip, notes-share recipient PATCH 403/404) are
unrelated to this layout change and were already failing before.
- Drop the leading "#" index column; dailyNumber already serves as a
stable per-row identifier.
- Replace the hard-coded amber background/border with the user's chosen
alert accent via hexToRgba(accent, 0.12 / 0.45). Threaded `accent`
through PostponeDialog → CascadePromptBlock so children don't read
prefs directly. Both the loading and main prompt blocks track the
accent; the rose blocked-by-midnight variant is intentionally kept.
- Render times as localized 12-hour with ص/م (AR) or AM/PM (EN) by
reusing the shared formatTime helper instead of slicing "HH:mm".
- Drop now-unused i18n key cascadeColIndex from ar.json + en.json.
- Update the e2e test to match the 3-column schema (meeting#, title,
times); kept the dailyNumber assertion in the first cell.
tsc clean. Cascade specs pass (Postpone by 10, Reschedule cascade,
Cascade prompt UI: Shift/Keep, no-followers fallthrough).
Update table header cells in the upcoming meeting alert component to use `<th>` elements with `scope="row"` for improved accessibility, while maintaining visual styling.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: cfc95efc-e616-467b-a131-64907741bd55
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/8BABrKh
Replit-Helium-Checkpoint-Created: true
- DetailsPanel in upcoming-meeting-alert.tsx: replaced the per-group
`<ul class="list-disc">` with a real `<table class="w-full">`. Each
attendee row is `<tr>` with two `<td>`s: a narrow tabular-nums index
cell (`{idx + 1}.`, scope="row") and the cleaned name cell. The
group heading is rendered as `<caption>` (text-start, font-bold,
text-xs) so it spans both columns and is announced as the table
caption to AT. Each table also carries `aria-label={group.heading}`.
- Numbering restarts at 1 per group (each group is its own table); a
code comment notes the one-line tweak to use a continuous count.
- Preserved: `data-testid="alert-details-attendees"` wrapper,
`max-h-40 overflow-y-auto` scroll, empty-state, location/URL rows,
and the section header total ("الحضور (n)"). RTL/LTR work via
`text-start` and `pe-1`.
- e2e: added a new test in
`artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs`
that seeds a meeting with two subheading-delimited groups (3
external + 1 internal), expands details, and asserts two tables
render with rows starting at "1." in each group.
Verification:
- `pnpm exec tsc --noEmit` clean.
- New attendees-table spec passes (1/1, 16.5s).
No server, schema, or i18n key changes; Tailwind utilities only.
Replace the simple <ul> in the cascade-prompt panel inside the upcoming-
meeting alert with a 4-column table: row index, the meeting's
dailyNumber, title (truncate + title=), and "from → to". The new
`meetingNumbersById` map is built once in UpcomingMeetingAlert from
the already-loaded dayData and threaded through PostponeDialog into
both CascadePromptBlock call sites (postpone-minutes and reschedule).
Existing `cascade-followers-list` and `cascade-follower-<id>` testids
are preserved on the table/rows so prior selectors keep matching.
Adds 4 new i18n keys (cascadeColIndex/MeetingNumber/Title/Times) in
ar.json and en.json. The legacy cascadeListItem key is left in place
since it isn't worth a separate cleanup.
Extends the existing "Cascade prompt UI: Shift sends cascadeFollowing"
e2e test to assert the first row's index cell renders "1" and the
meeting-number cell renders the seeded follower's dailyNumber.
insertImminentMeeting now also returns dailyNumber so the assertion
can read it. tsc clean; targeted cascade specs pass (3/3).
Task #478. When the upcoming-meeting reminder ("الاجتماع التالي")
appeared on top of an open note thread dialog, the user could not
tap "تأجيل" / "تم" / × / "تفاصيل". The buttons looked enabled and
the popup sat above the dialog overlay (`z-[60]` over `z-50`), but
nothing happened on click.
Root cause: Radix `Dialog` is modal by default and its scroll-lock
helper (`react-remove-scroll`) sets `pointer-events: none` on the
document body while open. The meeting alert is portaled to body and
inherited that, silently swallowing all clicks.
Fix: in `artifacts/tx-os/src/components/executive-meetings/upcoming-
meeting-alert.tsx`, add `pointer-events-auto` to the floating panel's
className so it explicitly opts back in to receiving pointer events.
Buttons inside inherit auto from the panel. Inline comment added
referencing #478 and the Radix scroll-lock behavior.
No change to the shared `Dialog` primitive, no other tokens or
components touched. Existing `#282` behavior (hide alert while its
own postpone modal is open) is preserved.
Verified:
- `tsc --noEmit` clean.
- `notes-thread-dialog.spec.mjs` passes (no regression in the
scenario that surfaced the bug).
Task #477. The brand primary color was a very dark navy (218 70% 15%
≈ #0B1E3F) and dominated buttons, the active sidebar row, the FAB,
and focus rings. User asked to lighten it while keeping the same blue
family.
Change: in `artifacts/tx-os/src/index.css` (`:root`), bumped four
tokens to `218 55% 32%` (≈ #2C4A8A) and updated the inline hex
comment:
- `--primary`
- `--ring`
- `--sidebar-primary`
- `--sidebar-ring`
White text on the new primary keeps WCAG AA contrast (≈5.3:1), so the
existing `--primary-foreground: 0 0% 100%` is preserved.
No other tokens (background, foreground, accent, secondary, destructive,
borders) were touched. No component-level overrides were modified.
Task #476. On iPad Safari, opening the keyboard while writing a new
note or replying inside the note thread dialog covered the bottom of
the dialog (textarea + send button). The dialog used
`top:50%; translate(-50%,-50%)` against the layout viewport and never
reacted to the visual viewport shrinking when the keyboard appeared.
Changes:
- artifacts/tx-os/index.html: added `interactive-widget=resizes-content`
to the viewport meta so iOS 17+ resizes the layout viewport when the
software keyboard opens.
- artifacts/tx-os/src/hooks/use-visual-viewport.ts: new hook that
subscribes to `window.visualViewport` `resize` / `scroll` /
`orientationchange` (rAF-throttled) and exposes the visible height,
the visual viewport `offsetTop`, and the bottom keyboard inset.
- artifacts/tx-os/src/components/ui/dialog.tsx: `DialogContent` now
reads the hook and, only when the keyboard inset exceeds ~80px,
pins its center to `offsetTop + height/2` and caps `max-height` to
the visible height. With the existing `translate(-50%,-50%)`, both
edges stay inside the visible region above the keyboard. Desktop
unaffected (inset is 0 → no inline style applied).
- artifacts/tx-os/src/pages/notes.tsx: thread reply textarea and the
top composer textarea now call `scrollIntoView({ block: "center" })`
on focus and again whenever the keyboard inset changes while the
field is focused. Both paths are gated on `keyboardInset > 80`, so
desktop focus is untouched.
Verification:
- `tsc --noEmit` clean.
- e2e: `notes-thread-dialog` and `notes-composer-color` both pass.
Task #476. On iPad Safari, opening the keyboard while writing a new
note or replying inside the note thread dialog covered the bottom of
the dialog (textarea + send button). The dialog used
`top:50%; translate(-50%,-50%)` against the layout viewport and never
reacted to the visual viewport shrinking when the keyboard appeared.
Changes:
- artifacts/tx-os/index.html: added `interactive-widget=resizes-content`
to the viewport meta so iOS 17+ resizes the layout viewport when the
software keyboard opens.
- artifacts/tx-os/src/hooks/use-visual-viewport.ts: new hook that
subscribes to `window.visualViewport` `resize` / `scroll` /
`orientationchange` (rAF-throttled) and exposes the visible height,
the visual viewport `offsetTop`, and the bottom keyboard inset.
- artifacts/tx-os/src/components/ui/dialog.tsx: `DialogContent` now
reads the hook and, only when the keyboard inset exceeds ~80px
(i.e. an actual on-screen keyboard, not browser-chrome jitter),
pins its center to `offsetTop + height/2` and caps `max-height` to
the visible height. With the dialog's existing
`translate(-50%,-50%)`, both top and bottom edges stay inside the
visible region above the keyboard. Desktop renders unchanged
(inset is 0 → no inline style applied beyond what the caller passes).
Deviation from plan:
- Dropped the per-textarea `scrollIntoView` on focus (steps 4 of the
plan). Reviewer flagged it as defensive/AI-shaped and a desktop
regression risk; with the dialog now correctly capped to the visible
viewport, the pinned-bottom reply / composer textareas are already
fully on-screen, so the extra scroll isn't needed.
Verification:
- `tsc --noEmit` clean.
- `notes-thread-dialog` e2e still passes.
Task #476. On iPad Safari, opening the keyboard while writing a new
note or replying inside the note thread dialog covered the bottom of
the dialog (textarea + send button). The dialog used
`top:50%; translate(-50%,-50%)` against the layout viewport and never
reacted to the visual viewport shrinking when the keyboard appeared.
Changes:
- artifacts/tx-os/index.html: added `interactive-widget=resizes-content`
to the viewport meta so iOS 17+ resizes the layout viewport when the
software keyboard opens.
- artifacts/tx-os/src/hooks/use-visual-viewport.ts: new hook that
subscribes to `window.visualViewport` `resize` / `scroll` /
`orientationchange` events (rAF-throttled) and exposes the current
visible height plus the bottom keyboard inset.
- artifacts/tx-os/src/components/ui/dialog.tsx: `DialogContent` now
reads the hook and, when the keyboard inset is non-trivial (> 80px),
pins its center to the visible viewport mid-line and caps
`max-height` to the visible height. Desktop renders unchanged
(inset is 0 → no inline style applied beyond what the caller passes).
- artifacts/tx-os/src/pages/notes.tsx: the thread dialog reply
textarea and the top composer textarea now `scrollIntoView({ block:
"center" })` on focus (after a 250ms delay so the keyboard animation
settles), so the cursor lands inside the visible region on touch
devices. Desktop focus is unaffected — `scrollIntoView` is a no-op
when the element is already in the viewport.
Verification:
- `tsc --noEmit` clean.
- Existing `notes-thread-dialog` e2e still passes (dialog stays
capped at 85% viewport height, scroller still overflows, recipient
chips and composer remain in viewport).
User report (AR, RH on iPad): no Send button visible inside a shared
"desk" folder. The shared-folder view was passing `hideSend` to every
NoteCard for editors and rendered no card actions at all for
read-only viewers, so nobody could send a note from a shared folder.
Changes (artifacts/tx-os/src/pages/notes.tsx, SharedFolderView):
- Editors: removed `hideSend` from the NoteCard so the existing Send
affordance (and color/labels/archive/delete) appears as it does on
the user's own notes.
- Read-only viewers: added a small Send icon button to the static
card (testid `shared-folder-note-send-<id>`) so they can also send
the note via the existing send dialog.
The send endpoint authorizes off the authenticated caller, so the
note is sent under RH's identity (no server change needed).
User report (AR): "shared folders also appear here — the user should
be able to move notes wherever they want." The folders rail already
lists shared-with-me folders, but only OWN folder rows registered as
@dnd-kit drop targets. Recipients with edit permission could see a
shared folder but couldn't drop a note onto it.
Changes:
- folders-rail.tsx: extend the inline `DroppableRow` to accept an
optional `ownerId`. Wrap each shared-folder row in a DroppableRow
when `sf.myPermission === "edit"`, passing the owning user's id;
read-only viewers stay non-droppable. The droppable id is namespaced
with `-shared-<ownerId>` so it never collides with the owner-side
row for the same folder id.
- notes.tsx: extend `sharedFolderBucketRef` to carry the bucket's
`ownerId` (sourced from `data.folder.ownerId`). In `handleDragEnd`,
read `o.ownerId` from the drop data:
• undefined → own folder / Unfiled. Allow only own notes.
• number → shared folder. Allow only when the source note
came from sharedBucket and `sharedBucket.ownerId
=== o.ownerId`.
This mirrors the server-side rule that an editor may move notes
only between folders owned by the same owner — cross-owner drops
are blocked client-side so we never round-trip a 400.
Out of scope:
- Cross-owner moves (would require a copy/share flow, not supported
by the server today).
- Drop targets on shared folders inside the FoldersBrowser tile grid
(the rail covers the user's reported screenshot; tiles can follow).
User report (AR): "shared folders also appear here — the user should
be able to move notes wherever they want." The folders rail already
lists shared-with-me folders, but only OWN folder rows registered as
@dnd-kit drop targets. Recipients with edit permission could see a
shared folder but couldn't drop a note onto it.
Changes:
- folders-rail.tsx: extend the inline `DroppableRow` to accept an
optional `ownerId`. Wrap each shared-folder row in a DroppableRow
when `sf.myPermission === "edit"`, passing the owning user's id;
read-only viewers stay non-droppable. The droppable id is namespaced
with `-shared-<ownerId>` so it never collides with the owner-side
row for the same folder id.
- notes.tsx: extend `sharedFolderBucketRef` to carry the bucket's
`ownerId` (sourced from `data.folder.ownerId`). In `handleDragEnd`,
read `o.ownerId` from the drop data:
• undefined → own folder / Unfiled. Allow only own notes.
• number → shared folder. Allow only when the source note
came from sharedBucket and `sharedBucket.ownerId
=== o.ownerId`.
This mirrors the server-side rule that an editor may move notes
only between folders owned by the same owner — cross-owner drops
are blocked client-side so we never round-trip a 400.
Out of scope:
- Cross-owner moves (would require a copy/share flow, not supported
by the server today).
- Drop targets on shared folders inside the FoldersBrowser tile grid
(the rail covers the user's reported screenshot; tiles can follow).
- Add useBulkDeleteSentNotes hook with bounded concurrency (worker
pool, cap = 6) over the existing per-id DELETE /notes/:id endpoint;
returns {ok, failed} so the UI can render a precise toast. One cache
invalidation at the end (notes + folders).
- notes.tsx: page-level selection state for the Sent tab only
(sentSelectionMode, selectedSentIds, bulkDeleteOpen). Header
Select/Cancel toggle, sticky bulk-action bar with count, select-all/
clear, in-bar Cancel, Delete, and an AlertDialog confirmation. Auto
exits selection mode when leaving the Sent tab; prunes selected ids
to currently visible filteredSent on every change (search, refresh,
successful delete).
- SentList: switched the outer card from a nested <button> to a
div role="button" tabIndex=0 with Enter/Space handler so the
selection-mode Checkbox is no longer a nested interactive control.
Checkbox is aria-hidden / pointer-events-none inside selection mode.
- i18n: notes.bulk* keys (Select / Cancel / SelectAll / Clear / Count
with _one plural / Delete / Confirm title+body / Result success /
partial / failure) in both en.json and ar.json.
- New e2e: tests/notes-sent-bulk-delete.spec.mjs seeds 3 sent notes,
selects 2, confirms the bulk delete, asserts the cards are gone and
the surviving note remains (DB + UI).
- Out of scope (proposed as follow-ups #473/#474/#475): undo on bulk
delete, multi-select on Inbox/Archived tabs, additional e2e cases.
- Add `useBulkDeleteSentNotes` hook that fans out parallel DELETE
/notes/:id calls via Promise.allSettled and returns {ok, failed}
so the UI can render a precise toast on partial failure. Caches
invalidated once via `invalidateNotesAndFolders`.
- NotesPage: page-level `sentSelectionMode` + `selectedSentIds`
Set, auto-reset when navigating away from the Sent tab.
- Header gets a Select / Cancel toggle, gated on
`view === "sent" && filteredSent.length > 0`.
- New sticky bulk-action bar above the sent list with count,
select-all/clear toggle, and Delete button.
- SentList accepts optional selectionMode/selectedIds/onToggle
props; cards swap onClick (open → toggle) and show a Checkbox
overlay + rose ring when checked.
- AlertDialog confirms before deleting; toast reports all-success,
partial, or all-failed using pluralized i18n keys.
- New i18n keys under `notes.bulk*` in ar.json + en.json.
No server changes (existing DELETE /notes/:id is reused).
Out of scope (per task spec): undo, bulk-archive, bulk endpoint,
multi-select on Active/Received/Archived tabs.
The popup card was rendering in three visible shades of the note's
color: a darker header band, the note color in the body, and another
darker action-bar band — caused by `bg-black/5 dark:bg-white/5`
overlays plus dividers on the header and footer rows. The user wanted
the entire card to be a single flat color matching the note.
Changes (artifacts/tx-os/src/components/notes/incoming-note-popup.tsx):
- Drag handle / header row: removed
`border-b border-black/10 dark:border-white/10 bg-black/5
dark:bg-white/5`. The row is now fully transparent and inherits the
outer card's `colorBg(noteColor)`.
- Action row: removed `border-t border-black/10 dark:border-white/10
bg-black/5 dark:bg-white/5`. Same treatment — fully transparent, no
divider line.
- The outer card div remains the only source of background color, so
every region now shows the exact same shade in both light and dark
mode.
Out of scope (untouched): avatar bg/ring, ping pulse, default-color
behavior (`bg-background` still kicks in for the default color),
animation, drag/pointer handlers, button layout.
Validation:
- `pnpm --filter @workspace/tx-os exec tsc --noEmit` passes.
- Pre-existing `test` workflow failures (executive-meetings PDF,
notes-share, groups-crud) are unchanged and out of scope.
User asked to drop the colored ring around the floating new-note popup
(added in #468) and replace the rounded corners with square ones for a
cleaner, more formal notification look.
Changes (artifacts/tx-os/src/components/notes/incoming-note-popup.tsx):
- Removed `ring-4 ${ringClass}` from the outer card div so no colored
outline appears around the popup.
- Replaced `rounded-2xl` with `rounded-none` so the card has square
90° corners on all sides. `overflow-hidden` is preserved so the
inner header / body / action bar still clip cleanly to the
rectangle.
- Dropped the now-unused `colorRingStrong` import and its derived
`ringClass` local. The shared helper itself stays in
`notes-api.ts` in case future surfaces want it.
- Kept the neutral `shadow-2xl` so the card still separates from the
background, and kept the avatar / ping color tinting from #468.
Validation:
- `pnpm --filter @workspace/tx-os exec tsc --noEmit` passes.
- No other functional changes (drag, dismiss, mark read, reply, open,
checklist toggle, queue counter, RTL all untouched).
- Pre-existing `test` workflow failures (executive-meetings PDF,
notes-share, groups-crud) are unchanged and out of scope.
User reported that the floating "new note" popup had a fixed amber/
orange ring, avatar ring, ping pulse and shadow tint, which clashed
visually whenever the underlying note used a different color (e.g. a
pink note appeared inside an orange popup).
Changes:
- artifacts/tx-os/src/lib/notes-api.ts:
- Extended the NOTE_COLORS array with four new fields per color:
`ringStrong` (popup outer ring), `avatarBg` (avatar circle bg+text),
`avatarRing` (2px avatar ring), and `ping` (animated pulse).
- Each entry uses literal Tailwind utility strings so v4's build-time
scanner picks them up — same pattern already used for `accent-*`.
- Default + gray colors map to neutral slate variants so they stay
legible on the white card surface.
- Added small helpers: `colorRingStrong`, `colorAvatarBg`,
`colorAvatarRing`, `colorPing` (each falls back to the default
entry for unknown ids, matching `colorAccent`'s behavior).
- artifacts/tx-os/src/components/notes/incoming-note-popup.tsx:
- Imported the new helpers and resolved them once per render from
`current.color` (works for both note and reply popup variants since
both expose the same `color` field on the payload).
- Replaced every `amber-*` class on the outer ring, the avatar
fallback bg/text, the avatar image ring, and the animated ping
with the resolved color classes.
- Dropped the colored shadow tint (`shadow-amber-500/30`) — the
card now uses a neutral `shadow-2xl` so the only colored chrome
is the ring matching the note.
Validation:
- `pnpm --filter @workspace/tx-os exec tsc --noEmit` passes.
- No remaining `amber` literal in the popup file (only an
explanatory comment mentions the word).
- Pre-existing `test` workflow failures (executive-meetings PDF,
notes-share, groups-crud) are unchanged and out of scope.