Task #453. Recipient (e.g. yas) couldn't see folders that an owner
(e.g. admin) had shared with them — the server returned them
correctly via /note-folders/shared-with-me, but the rail's
"Shared with me" section was gated on `mode === "rail"`. On the
default Notes screen (My Notes tab + "All notes" selection) the
rail is mounted in `chips-only` mode, so the section was never
rendered. Most recipients only ever saw the default screen, so
shared folders were effectively invisible.
Fix: drop the `mode === "rail"` gate in folders-rail.tsx so the
shared-with-me section renders in both modes. The section already
uses `contents md:block` (so chips inline into the mobile horizontal
strip), and its "Shared with me" label is already `hidden md:block`,
so the rendering stays compact on mobile and labelled on desktop in
both modes.
No backend changes; the existing socket invalidations on
`note-folder-shared` / `note-folder-unshared` already keep the
list fresh in realtime.
Out of scope:
- FoldersBrowser (the grid/list of own folders in the main content
area) intentionally not extended to also list shared folders;
the rail entry is sufficient and matches the task's "as a chip
alongside the own-folder chips" outcome on mobile and
"Shared with me" sidebar section on desktop.
- No changes to permissions, notifications, or per-note sharing.
- Notes top-bar freeze (Task #452) is untouched.
Task #452. After #451, the page wrapper was h-screen + overflow-y-auto
with the header still using `sticky top-0 z-20`. Users kept reporting
that the bar slides up out of view on scroll — the well-known
`position: sticky` failure mode when the sticky child is a direct flex
item of the same element that is also the scroll container.
Restructured artifacts/tx-os/src/pages/notes.tsx so the bar is a
sibling of the scroller, not inside it:
- Page wrapper: `h-screen os-bg flex flex-col overflow-hidden`
(was `... overflow-y-auto`). The wrapper itself never scrolls.
- Header div: dropped `sticky top-0 z-20`, added `shrink-0`. As a
non-shrinking flex child of the column wrapper it stays pinned at
the top by layout, with no sticky math involved.
- Wrapped the composer block and the main content row
(`flex-1 px-4 py-6 max-w-6xl ... flex gap-6` plus its inner
`flex-1 min-w-0` column) inside a single new scrolling region:
`<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">`.
`min-h-0` is required so the flex child can shrink below its
content height and let `overflow-y-auto` actually engage.
- Dialogs (Edit, Labels, Send, FolderShare, Thread) remain siblings
of the scroller, so popovers/modals are unaffected.
No changes to index.css, header styling, composer styling, folder
layout, or any other page. RTL/LTR behavior unchanged.
Out of scope: pre-existing test failures (executive-meetings,
notes-share, service-orders) are unrelated to this layout fix.
Despite Tasks #448–#450 (sticky header + fixing .os-bg / html,body
overflow rules), the user kept reporting the bar disappears on
scroll. position: sticky is sensitive — the browser was picking
the wrong scroll container in this layout.
Robust fix: stop relying on document/body for scrolling on the
Notes page. Changed the page wrapper className in
artifacts/tx-os/src/pages/notes.tsx from
min-h-screen os-bg flex flex-col
to
h-screen os-bg flex flex-col overflow-y-auto
Now the Notes wrapper is a known scroll container that's exactly
one viewport tall. The sticky header inside it pins reliably to
the top edge of that container (== top of the viewport). Composer
flush-placement (Task #448), index.css overflow-x:clip on
html,body (Task #450), and other pages using .os-bg are all
untouched.
Single-line change, no other refactors.
After Task #449 the sticky Notes header was still scrolling away.
Root cause: `overflow-x: hidden` on `html, body` makes both
elements scroll containers. Body sized itself to its content
(no internal scroll happens), so a `position: sticky` descendant
got scoped to a non-scrolling container and rode off-screen with
the page.
`overflow-x: clip` clips overflowing content the same visual way
`hidden` does, but it does NOT establish a scroll container, so
sticky descendants continue to resolve against the viewport and
pin correctly.
Changed only `artifacts/tx-os/src/index.css` (the `html, body`
rule added in #449). All other rules untouched. `overflow: clip`
is supported in Chrome 90+, Firefox 81+, Safari 16+.
After Task #448 switched the Notes header to `sticky top-0`, it
still scrolled away with the page. Root cause was in
`artifacts/tx-os/src/index.css`: the shared `.os-bg` class set
`overflow-x: hidden`, which per CSS spec turns the element into a
scroll container. Since the Notes page wrapper carries `os-bg` and
also has `min-h-screen`, the wrapper expanded to fit content
(never scrolled internally) while the document body scrolled —
which meant the sticky header was scoped to a non-scrolling
container and rode along with the page.
Fix:
- Removed `overflow-x: hidden` from `.os-bg`.
- Added `html, body { overflow-x: hidden; }` inside the existing
`@layer base` block so the horizontal-clip protection still
exists at the document level (which does not break sticky in
descendants).
No other changes. Notes page header now actually pins to the top
during scroll across all tabs / folder views, and other pages that
use `.os-bg` (home, services, executive-meetings, etc.) keep their
horizontal-overflow protection via the body-level rule.
Task #446 made the Notes header `position: fixed` and added an
aria-hidden spacer below it sized to the header's measured height
(via ResizeObserver). The spacer was exactly the "big empty band"
the user kept seeing between the header bar and the
"اكتب ملاحظة..." composer — the page background gradient showed
through the reserved space.
Switched the header back to `sticky top-0 z-20`. Sticky keeps it
pinned to the top of the viewport while the page scrolls, but it
stays in document flow, so the composer renders immediately under
the bar with no artificial gap. Removed the now-unused
`headerRef`, `headerHeight` state, ResizeObserver effect, and the
spacer div. Bumped composer wrapper padding from `pt-1` back to
`pt-3` for a small breathing space (no longer competing with the
spacer).
No other changes; folders rail, composer behavior, RTL/LTR all
untouched.
Task #446 made the Notes header `position: fixed` and added an
aria-hidden spacer below it sized to the header's measured height
(via ResizeObserver). The spacer was exactly the "big empty band"
the user kept seeing between the header bar and the
"اكتب ملاحظة..." composer — the page background gradient showed
through the reserved space.
Switched the header back to `sticky top-0 z-20`. Sticky keeps it
pinned to the top of the viewport while the page scrolls, but it
stays in document flow, so the composer renders immediately under
the bar with no artificial gap. Removed the now-unused
`headerRef`, `headerHeight` state, ResizeObserver effect, and the
spacer div. Bumped composer wrapper padding from `pt-1` back to
`pt-3` for a small breathing space (no longer competing with the
spacer).
No other changes; folders rail, composer behavior, RTL/LTR all
untouched.
The composer wrapper that sits directly under the fixed header's
height-matching spacer had `pt-4` (16px) on top, which made an obvious
empty band between the bottom border of the frozen header and the top
of the "اكتب ملاحظة..." card. Reduced that to `pt-1` (4px) so the
composer now sits flush under the bar with a minimal breathing space,
without affecting the rest of the page (folders rail / browser keep
their own `py-6`).
artifacts/tx-os/src/pages/notes.tsx:497 — single class change.
User wanted the Notes top bar to truly stay pinned (فريز / ثابت) while
scrolling, and wanted the "اكتب ملاحظة..." composer to live at the top
of the page above the folders area instead of being tucked under the
folders chips and only showing inside a folder.
Changes (artifacts/tx-os/src/pages/notes.tsx):
- Header bar switched from `sticky top-0` to `fixed top-0 inset-x-0
z-30`. Measured its live height with a `ResizeObserver` + `offsetHeight`
(header wraps differently per tab / label set / viewport width), and
rendered a same-height aria-hidden spacer immediately below so the
first row of content never slides under the bar.
- Composer was lifted out of the in-folder branch and is now rendered as
the first content block under the spacer (above FoldersRail and
FoldersBrowser) on the active "My Notes" view. It is hidden on Inbox,
Sent, Archived, and the Shared-with-me read-only folder view, so the
read-only contract from Task #445 stays intact. `folderSelection` is
still passed in unchanged, so a new note created from this top
composer continues to land in the currently-selected folder (or
Unfiled if none).
- Removed the duplicate Composer mount inside the per-folder branch and
left a comment explaining the move.
Notes:
- No backend or i18n changes — composer placeholder, search input, tabs,
label chips and grid/list toggle all kept their existing markup and
behavior.
- Pre-existing TS errors in artifacts/api-server/src/routes/executive-meetings.ts
remain (out of scope) and the `test` workflow continues to fail for
that unrelated reason.
Re-applied review fixes on top of the live folder-sharing implementation:
- PATCH/DELETE /notes/:id now do an id-only lookup and return an explicit
403 when the caller is not the owner (previously returned an ambiguous
404). This makes the read-only contract for shared-folder recipients
precise instead of silently masquerading as "not found".
- PUT /note-folders/:id/shares rejects self-share with 400
"Cannot share folder with yourself" instead of silently filtering the
caller's id out of the recipient set.
- GET /note-folders/shared-with-me now includes a `noteCount` (live count
of the owner's non-archived notes in the folder) computed via SQL
subquery; SharedFolder TS type updated; folders rail renders the count
next to each shared folder.
- SharedFolderView accepts the page's `search` value and filters the
read-only list client-side over title + content + checklist item text,
matching the owner's search experience. Empty state distinguishes
"no notes" from "no matches".
- Added GET /notes?sharedFolderId=:id as a spec-aligned alias that
returns the owner's live notes inside a folder shared with the caller
(gated by an active row in note_folder_shares).
- Updated the stale comment block above the share routes that claimed
recipient writes "just 404"; documents the new 403 contract.
Deviations / out of scope:
- Project uses `drizzle-kit push` (no migrations directory); no SQL
migration file was added.
- Pre-existing TS errors in artifacts/api-server/src/routes/executive-meetings.ts
remain; unrelated to Task #445.
- "Strict reject for nonexistent recipient ids in share PUT" left as
optional follow-up (currently silently dropped per existing behavior).
Owner can share a whole folder with other users; recipients see it
under "Shared with me" in the folders rail and view notes read-only
(cannot edit, add, move, or delete).
- DB: new noteFolderSharesTable (folderId/recipientUserId, cascade,
unique idx). Pushed via drizzle-kit.
- API (artifacts/api-server/src/routes/notes.ts):
- GET/PATCH/POST /note-folders return sharedWithCount.
- GET /note-folders/shared-with-me, GET /note-folders/:id/shares,
PUT /note-folders/:id/shares (idempotent diff), DELETE
/note-folders/:id/shares/:userId, GET /note-folders/:id/shared-notes
(verifies recipient via noteFolderSharesTable, stamps readOnly).
- Emits note-folder-shared / note-folder-unshared on share changes.
- Existing note write paths remain owner-scoped → recipients can't
mutate owner notes.
- Frontend types/hooks (notes-api.ts): SharedFolder, FolderShareRecipient,
SharedFolderNote/View; useFolderShares, useUpdateFolderShares,
useSharedWithMeFolders, useSharedFolderNotes.
- FoldersRail: Share menu item, sharedWithCount badge, "Shared with me"
section, "shared-folder" selection kind, onShareFolder prop.
- notes.tsx: SharedFolderView (no Composer, read-only cards using
ChecklistView), FolderShareDialog (seeds existing recipients,
idempotent PUT on save), revocation fallback effect, dialog mount.
- use-notifications-socket.ts: subscribe to note-folder-shared /
-unshared → invalidate shared-with-me + open shared-folder notes for
live rail/view refresh.
- i18n: AR/EN keys for share/sharedBy/sharedByName/sharedWithCount/
sharedWithMe/readOnly/shareRevoked/sharedEmpty/shareSaved/shareFailed.
Pre-existing failing `test` workflow (executive-meetings type errors)
is unrelated and out of scope.
Problem: the new-note composer in the Notes page only exposed Save —
to send a freshly-typed note the user had to save it, hover the
resulting card, and click the small Send icon. The user asked for an
explicit Send action right inside the composer and confirmed they're
fine with auto-saving as a side effect of pressing Send.
Implementation (artifacts/tx-os/src/pages/notes.tsx):
- Added `onSend: (id: number) => void` prop to <Composer> and wired
the NotesPage call site to forward to the existing
`setSendForNoteId(id)` flow that already mounts <SendNoteDialog>.
- Extracted a `buildPayload()` helper from save() so save() and the
new sendNew() use the exact same payload shape (title, content,
color, labels, kind, items, folderId).
- Added `sendNew()` that runs the same `create.mutate` save and, on
success, resets the composer and calls `onSend(created.id)` so the
send dialog opens for the freshly created note. On failure the
composer state is left intact (same UX as save() failures).
- Added a Send icon button next to Save inside the composer footer,
aria-labeled with the existing `notes.send` translation and tagged
`data-testid="notes-composer-send"`. The button is disabled while
the composer is empty (no title and no content/checklist text) and
while a create request is in flight to prevent duplicate notes
from rapid clicks.
The button lives inside the composer's `ref` so it doesn't trip the
existing click-outside-to-save handler, and <SendNoteDialog> renders
in a Radix portal which is already excluded from that handler.
No changes to send dialog logic, the per-card hover Send button, or
any other surface. tsc --noEmit passes for tx-os. The pre-existing
api-server `test` workflow failures (executive-meetings, service-
orders) are unrelated and out of scope.
Problem: in the Notes page header search field, the magnifier icon is
absolutely positioned on the start side (right in RTL, left in LTR)
via inline style, but the input's reserved padding was using
`isRtl ? "pe-9" : "ps-9"`. In RTL, `pe-9` resolves to padding-LEFT —
the opposite side from the icon — so the placeholder text "ابحث في
الملاحظات" started flush to the right edge and visibly overlapped
the icon. In LTR it happened to look correct because both `ps-9` and
the icon's `left: 10` line up.
Fix: pin the padding to the start side unconditionally
(`className="ps-9"`) so it always matches the icon's start-side
placement in both directions. Added an inline comment documenting
the invariant ("icon on start side ⇒ padding on start side") so a
future edit doesn't re-introduce the asymmetry.
Files:
- artifacts/tx-os/src/pages/notes.tsx (search input around line 304-321)
No behavioural changes to LTR rendering, no other search inputs
affected (only this one bound the padding to `isRtl`). Verified with
`pnpm exec tsc --noEmit` (clean). The pre-existing failing `test`
workflow stems from unrelated api-server tsc errors in
executive-meetings.ts and is out of scope for this task.
- artifacts/tx-os/src/lib/notes-api.ts: extended NOTE_COLORS so each
entry carries an `accent` Tailwind class alongside `bg`/`ring`
(default→amber, red→red, orange→orange, yellow→yellow, green→green,
teal→teal, blue→sky, purple→purple, pink→pink, gray→slate; all *-500
for visibility in light + dark). Added a `colorAccent(id)` helper
that resolves to a literal accent-* class, falling back to the
default amber for unknown/null ids. The class strings are written
out as literals so Tailwind v4's source scanner emits all of them
into the generated CSS — no dynamic class names, no safelist needed.
- artifacts/tx-os/src/pages/notes.tsx: ChecklistView gained an optional
`noteColor?: string | null` prop and now resolves its checkbox
accent via `colorAccent(noteColor)` instead of the hard-coded
`accent-amber-500`. Updated all four call sites (NoteCard, received
list, sent list, thread dialog) to pass `note.color` / `n.color` /
`thread.color`.
- artifacts/tx-os/src/components/notes/incoming-note-popup.tsx:
PopupChecklistPreview gained the same optional `noteColor` prop and
uses `colorAccent` for the checkbox className. Call site now passes
`current.color` from the popup queue.
- Unchecked boxes still render with the neutral browser default; only
the checked accent (the filled tint) follows the note color. The
default (white/card) note keeps amber for visibility against the
neutral surface in both themes.
- Verified: `pnpm exec tsc --noEmit` is clean and the
`tests/notes-popup-on-receive.spec.mjs` regression suite (incl. the
Task #433 checklist popup case) still passes 3/3.
- Pre-existing failing `test` workflow (api-server tsc errors in
executive-meetings.ts) is unrelated and out of scope.
- Rewrote artifacts/tx-os/src/lib/notification-sounds.ts to use the
Web Audio API (lazy AudioContext + decoded AudioBuffer cache) instead
of HTMLAudioElement. unlock() resumes the ctx for any non-running
state (handles iOS `interrupted`), plays a 1-sample silent buffer to
satisfy iOS Safari's gesture requirement, and pre-warms decodes.
decodeAudioData wrapped to support both Promise and callback forms
for older WebKit.
- play() dispatches an AUTOPLAY_BLOCKED_EVENT with `{ isIos }` detail
when ctx isn't running. Throttle is 3 s; new bypassThrottle option
(used by testPlay) skips the gate AND avoids advancing lastPlayAt
so settings previews can't suppress real socket chimes.
- use-audio-unlock.ts keeps gesture listeners attached and re-calls
unlock() on every gesture (handles iPad ctx auto-suspend on
background / AirPlay).
- use-autoplay-hint.ts reads detail.isIos and switches between
notifSettings.autoplayHint and notifSettings.autoplayHintIos
(added to en.json + ar.json with Control Center / silent-switch
guidance).
- notification-settings.tsx per-sound preview button now calls
notificationPlayer.testPlay(id).
- Test globals __txosNotifPlayCount/__txosNotifLastSound preserved;
added __txosNotifCtxState() accessor (dev/test-only, gated by
import.meta.env.MODE).
- Added tests/notification-sound-webaudio.spec.mjs covering the
preview button gesture path: counter increments, throttle bypass
works, singleton AudioContext reaches `running` state.
Notes:
- Pre-existing api-server tsc errors in executive-meetings.ts and the
failing `test` workflow are unrelated to this task and out of scope.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
note row so concurrent toggles from multiple collaborators can't
lose each other's updates. Live notes.items + every
note_recipients.items snapshot are mirrored atomically in the same
tx.
- Emits note_checklist_changed to the audience minus the actor with
the full updated items array so receivers can patch state without
an extra fetch.
Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
override for instant visual feedback (popup renders from socket
payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
query keys AND patches the open popup payload directly via a new
IncomingNotePopupContext.updateChecklistItems action so
collaborators' open popups stay in sync.
Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
match the popup's avatar pulse.
Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
owner/recipient toggle + mirroring, 403 non-recipient, 403
archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
recipient ticks an item from the popup, server + sender snapshot
both reflect the change).
Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
onToggle.
Previously POST /api/notes/:id/send used onConflictDoNothing on
(noteId, recipientUserId) and only iterated `inserted` rows for
side-effects, so a re-send to the same recipient was silent — no
note_received socket event, no notification — even though the API
returned 200. Reported by the user: "I sent again to rh, why
didn't it reach them?"
Fix: after the idempotent insert, load the existing recipient
rows for any recipients that were skipped, and run the
notification + socket emit loop over BOTH inserted and existing
rows. The DB row is still not duplicated (snapshot semantics
preserved), but the recipient now sees the popup and gets a fresh
notification on every re-send.
Verified:
- @workspace/api-server tests/notes-share.test.mjs — 12/12 pass
(including "re-sending to the same recipient does not duplicate
the recipient row").
- tx-os e2e notes-popup-on-receive — 3/3 pass.
The popup was a colored body sitting inside a white frame; users
wanted the WHOLE popup to look like the sticky note itself, with
only the outer ring + chrome remaining as frame.
- Outer rounded-2xl card now uses `colorBg(current.color)` directly
(replacing `bg-background`). Default-color notes still render on
`bg-white dark:bg-card` so the look is unchanged for uncolored
notes.
- Removed the inner card-in-card colored body wrapper; title +
content/checklist now render directly on the tinted surface.
- Header divider switched from `bg-muted/40` (opaque chrome) to a
translucent `border-black/10 dark:border-white/10` so the tint
flows under the avatar/title/X without an opaque strip.
- Action row moved into its own bordered footer strip (same
translucent divider) so the 3 buttons stay legible on every
palette tint while letting the colored surface bleed through.
- Added `data-popup-color` on the card for diagnostics.
- Reply popups inherit the same tint via the existing `color` field
on the reply payload (no plumbing change).
No payload, locale, or test-id changes. All popup e2e suites pass:
notes-popup-on-receive (3/3), notes-popup-touch-tap (2/2).
The popup was a colored body sitting inside a white frame; users
wanted the WHOLE popup to look like the sticky note itself, with
only the outer ring + chrome remaining as frame.
- Outer rounded-2xl card now uses `colorBg(current.color)` directly
(replacing `bg-background`). Default-color notes still render on
`bg-white dark:bg-card` so the look is unchanged for uncolored
notes.
- Removed the inner card-in-card colored body wrapper; title +
content/checklist now render directly on the tinted surface.
- Header divider switched from `bg-muted/40` (opaque chrome) to a
translucent `border-black/10 dark:border-white/10` so the tint
flows under the avatar/title/X without an opaque strip.
- Action row moved into its own bordered footer strip (same
translucent divider) so the 3 buttons stay legible on every
palette tint while letting the colored surface bleed through.
- Added `data-popup-color` on the card for diagnostics.
- Reply popups inherit the same tint via the existing `color` field
on the reply payload (no plumbing change).
No payload, locale, or test-id changes. All popup e2e suites pass:
notes-popup-on-receive (3/3), notes-popup-touch-tap (2/2).
- Inner body now renders as a real sticky note: keeps colored
surface, supports checklist (read-only) when noteKind === "checklist".
- Action row trimmed to exactly 3 buttons: Done (تم), Open note
(فتح الملاحظة), Reply (رد). Removed the standalone Dismiss button;
header X still closes.
- Reply variant uses the same 3-button row (Done is a dismiss-only
no-op since the note owner can't mark their own note read).
- Wire kind + items from backend `note_received` payload into client
IncomingNotePayload (noteKind/items) via use-notifications-socket.
- Locales: markRead → "Done"/"تم"; new openNote/done keys; openThread
unified to "Open note"/"فتح الملاحظة".
- Tests: replaced removed `incoming-note-popup-dismiss` testid usages
with header `-close`; updated reply variant assertions to expect the
3-button row; fixed pre-existing pluralization bug in reply path
(`/replies` → `/reply`) that was unrelated to this task but blocked
the reply popup test from validating.
Labels and pin were not surfaced in the popup because they are
sender-private state (note_recipients only snapshots
title/content/color/kind/items); the recipient never receives the
sender's labelIds/isPinned, so showing them would be misleading.
Added a new e2e ("recipient popup renders checklist items for a
checklist note") that composes a checklist note via the UI, sends it,
and asserts `incoming-note-popup-checklist` is visible with both
items — locks in the noteKind/items socket plumbing.
All affected e2e tests pass (notes-popup-on-receive note + reply +
checklist, notes-popup-touch-tap both, notes-inbox); queue unit
tests pass.
- Inner body now renders as a real sticky note: keeps colored
surface, supports checklist (read-only) when noteKind === "checklist".
- Action row trimmed to exactly 3 buttons: Done (تم), Open note
(فتح الملاحظة), Reply (رد). Removed the standalone Dismiss button;
header X still closes.
- Reply variant uses the same 3-button row (Done is a dismiss-only
no-op since the note owner can't mark their own note read).
- Wire kind + items from backend `note_received` payload into client
IncomingNotePayload (noteKind/items) via use-notifications-socket.
- Locales: markRead → "Done"/"تم"; new openNote/done keys; openThread
unified to "Open note"/"فتح الملاحظة".
- Tests: replaced removed `incoming-note-popup-dismiss` testid usages
with header `-close`; updated reply variant assertions to expect the
3-button row; fixed pre-existing pluralization bug in reply path
(`/replies` → `/reply`) that was unrelated to this task but blocked
the reply popup test from validating.
Labels and pin were not surfaced in the popup because they are
sender-private state (note_recipients only snapshots
title/content/color/kind/items); the recipient never receives the
sender's labelIds/isPinned, so showing them would be misleading.
Added a new e2e ("recipient popup renders checklist items for a
checklist note") that composes a checklist note via the UI, sends it,
and asserts `incoming-note-popup-checklist` is visible with both
items — locks in the noteKind/items socket plumbing.
All affected e2e tests pass (notes-popup-on-receive note + reply +
checklist, notes-popup-touch-tap both, notes-inbox); queue unit
tests pass.
- Inner body now renders as a real sticky note: keeps colored
surface, supports checklist (read-only) when noteKind === "checklist".
- Action row trimmed to exactly 3 buttons: Done (تم), Open note
(فتح الملاحظة), Reply (رد). Removed the standalone Dismiss button;
header X still closes.
- Reply variant uses the same 3-button row (Done is a dismiss-only
no-op since the note owner can't mark their own note read).
- Wire kind + items from backend `note_received` payload into client
IncomingNotePayload (noteKind/items) via use-notifications-socket.
- Locales: markRead → "Done"/"تم"; new openNote/done keys; openThread
unified to "Open note"/"فتح الملاحظة".
- Tests: replaced removed `incoming-note-popup-dismiss` testid usages
with header `-close`; updated reply variant assertions to expect the
3-button row; fixed pre-existing pluralization bug in reply path
(`/replies` → `/reply`) that was unrelated to this task but blocked
the reply popup test from validating.
Labels and pin were not surfaced in the popup because they are
sender-private state (note_recipients only snapshots
title/content/color/kind/items); the recipient never receives the
sender's labelIds/isPinned, so showing them would be misleading.
Added a new e2e ("recipient popup renders checklist items for a
checklist note") that composes a checklist note via the UI, sends it,
and asserts `incoming-note-popup-checklist` is visible with both
items — locks in the noteKind/items socket plumbing.
All affected e2e tests pass (notes-popup-on-receive note + reply +
checklist, notes-popup-touch-tap both, notes-inbox); queue unit
tests pass.