UX polish iteration — dogfood-driven refinement of the Slack-parity surface #10
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_collab#10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Following closure of the Slack feature parity implementation plan (#9), hero_collab now covers roughly 85–90% of Slack's daily-use feature surface. The features work. What we haven't done is use the product for a week straight.
The recent work was feature-by-feature: ship reactions, ship threads, ship canvases, ship huddles. Each feature was verified in isolation — unit tests, integration tests, a browser smoke for the specific flow. What that approach doesn't catch is the friction that only shows up when you string several flows together in the rhythm a real user has: switch channels, read a thread, open a canvas, start a huddle, come back, find the message you lost in the scroll.
This iteration is for that.
Goal
Surface and fix UX friction that only appears during actual extended use of the product. Flow-level, not feature-level. The measure of success is "a new user can do a full workday in hero_collab without feeling like it's fighting them" — not a ticked checkbox list.
Approach
Usage-driven, not plan-driven.
Intentionally NOT doing: a fixed checklist of UX items to audit. Checklists front-load the conclusions; the point here is to discover what actually hurts.
Likely focus areas (non-exhaustive)
Based on what the codebase already suggests might benefit from attention, without pre-committing:
None of these are commitments. The real list comes from using the product.
Non-goals
What "done" looks like
Related
plan/known-issues.md— items tracked as open at closure of #9; several (bootstrap ownership in proxy mode, ES-module split, admin-dashboard coverage) may intersect with items surfaced during this pass.Round 3 — DM symptoms + fixes
Reported:
channel.name, which is the creator-side label.)+pick. Clicking + → picking an existing DM peer added a second row; it merged after a refresh.data-start-dm-user-id) left over from a prior "No DMs yet" design.Fixed (commits
017ab16,0a17a52):channel.listinlinesmembers: [user_id, ...]for DM / group-DM channels; the client readsch.membersto pick the peer id and renders the peer's name + avatar. Removed the N-per-DMchannel.member.listfan-out that the earlier client-only fix used.startDmdedupes against an existing local DM row (peer-id match, not name match) before pushing a new entry, and before callingchannel.find_dm.message.createdWS handler now drops events whosechannel_iddoesn't match the currently-viewed channel — closes the async-close race that let the prior channel's events render into the new view.data-start-dm-user-idbranch from the DM-list delegate.Deferred:
Round 4 — dogfooding symptoms + fixes
Reported:
--auth-mode=dev. Happened after switching to CLI-flag launch (env var drift between attachment.rs / document.rs / canvas.rs).field 'content': must not be empty or whitespace-only.Fixed (commit
2e8869d):channel.listgained optionalmember_of; sidebar filters to joined; Browse still shows all public.message.sendmembership gate now runs on the resolveduser_id(not justcaller_id), so dev mode enforces membership like proxy.attachment.rs+document.rsis_dev_modedelegate topermissions::is_dev_modeOnceLock (same fix canvas.rs already had).COLLAB_AUTH_MODEenv read was broken under hero_proc's clean-env spawn; argv path works now.MessageSend.contentis optional; handler requires content OR attachments and still length-caps content when present.<style>—static/css/chat.cssis never loaded, so all my earlier edits to it were silent dead code. DM + channel rows now render proper pill + bold name on unread.HuddleManager.setOnStateChange.findMessageAnywhere(msgId)helper acrosstoggleReaction,updateReactionsDOM, and themessage.reactedWS handler — thread replies live instate.threadReplies, which the old path ignored.hero_collab_app) still has them pending separate decision.Round 5 — DM panel, canvas back-refresh, DM button scope
Reported:
Fixed (commit
e908265):state.dmPeers(same logic as the sidebar row). Icon →bi-chat-dots-fill. Sub-header → "Direct message" (1:1) or "Group DM · N members" (3+).BroadcastChannel('hero-collab-canvas'). canvas-app postscanvas.changedon delete + on goBack (which also flushes any pending title edit viasaveTitlefirst). chat-app subscribes once at load; any signal triggersloadCanvases(). No server round-trip for the notification. Falls back silently on browsers without the API (baseline = today's manual-refresh behavior).kindisdmorgroup_dm. Slack parity: 1:1 DMs have no member management, group DMs use "close conversation" (separate feature, not built). Per-row × Remove is scoped to private non-DM channels only.Still tracked (deferred):
/ws/user/{user_id}multiplexed relay in hero_collab_ui; ~150 server + 50 client lines; scheduled for Phase 6 hardening.Canvas editor UX improvements landed on
development(5e0d9c2):canvas_idcolumn, HTTP byte route with access control, cascade on canvas delete, cleanup reaper protects canvas rowsTests green (40 unit + 33 integration). Pushed.
Canvas read-only viewer — client-side gating gap
Found while dogfooding: for users with read-only access to a canvas (channel guests / non-editors), the client still renders hover menus and editing toolbars. Clicking them is harmless — the server fails the request — but it's a confusing UX and looks broken.
Not a security issue. The server enforces correctly via
check_canvas_access(write_required: true)at 8 sites incanvas.rs, so write attempts are rejected regardless of UI state.Scope of the fix: role-gate the canvas UI affordances (toolbars, hover menus, drag handles) on the client based on the user's effective canvas role, mirroring how the server gates the RPCs.
Queueing for the next pass — not today.
Update: empirical test changes the framing — UX is worse than "harmless"
Followed up on this with a live test in proxy mode against
127.0.0.1:viewer_testuser withviewerrole on canvas 1 (sameh's canvas, populated with content).127.0.0.1/32,::1/128,::ffff:127.0.0.1/128inproxy.user_networksto the viewer.Server side — defends correctly (two layers, both fire)
routes.rs:1072(hero_collab_ui): viewer'sSync(Update)andSync(SyncStep2)frames are dropped beforeapply_update/ broadcast / persist.is_editoris computed from the canvas role at WS-upgrade time (routes.rs:825).canvas.save_stateRPC —canvas.rs:740: write-requiredcheck_canvas_accessreturns-32002 View-only access.Direct rpc.sock probes as
viewer_test(X-Hero-User injection bypassed, simulating a malicious client):canvas.update(title, is_public)-32002 View-only access: cannot edit canvas 1canvas.save_state(CRDT BLOB)-32002 View-only access: cannot edit canvas 1canvas.share/canvas.unshare/canvas.update_role-32002 View-only accesscanvas.delete-32002 Only the canvas creator can delete itDB verified unchanged after the probes and after the viewer's UI session:
canvas_state.updated_atdid not advance,canvases.title/is_publicunchanged,canvas_collaboratorsintact.UX side — strictly worse than "broken-looking buttons"
When the viewer clicks toolbar buttons (color, format, etc.), the local Yjs Doc accepts the txn and renders the change. The server-side WS handler then silently drops the broadcast, so the change is never persisted, never echoed to other clients, and disappears on refresh.
This is worse than disabled buttons. The viewer sees:
That reads as data loss ("the system saved my edit and then lost it"), not as read-only enforcement ("you don't have permission to edit"). For a collaborative tool this is the worse failure mode — it erodes trust in saves overall, not just on this canvas.
Implication for the fix
Scope is broader than "role-gate the toolbars":
Still a UX-only bug — server is correct. But the urgency is higher than my original comment implied; queueing it for the next pass stands, but it should be near the top of the queue.
— Tested 2026-04-26.
Update on the canvas read-only viewer thread
The canvas read-only viewer issue from the comments above is now fixed and being tracked in #26 (PR #27).
Empirical verification with the merged code, end-to-end, in proxy mode against
127.0.0.1mapped to aviewer_testuser withviewerrole on canvas 1:body[data-canvas-mode="viewer"]CSS.filterTransactionProseMirror plugin — even programmaticeditor.chain().toggleBold().run()from DevTools no longer mutates the local Doc. Yjs sync transactions from collaborators flow through unaffected.canvas_state.updated_atunchanged after click-spam → refresh shows no reverted state because there was no state to revert.Two side-bugs surfaced in this thread, status:
loadChannelsauto-creates#generalfor any zero-channel user (chat-app.js:962) — separate from canvas, not addressed by this PR. Worth filing as its own issue with the proposed fix from above (move default-channel seeding intoworkspace.create, remove the client fallback).hero_proxy_serverbinary (Apr 25 build, predating PR #30 + #33) — a workstation-environment issue that surfaced during testing, not a code bug. Resolved by rebuilding + reinstalling the proxy. Worth keeping in mind that "stack restart" should sometimes mean "rebuild + restart" after merging cross-repo PRs.The original "queued for next pass" comment is now resolved.
Update — three more dogfooding bugs, all fixed and PRs filed
While testing the canvas read-only fix from earlier, three additional bugs surfaced. All have separate issues + PRs (kept scoped so each is reviewable independently):
GET /api/attachment/{id}returns 401caller '<id>' lacks permission for 'channel.create'Common pattern
All three are honest behavioural bugs (not security issues): server is authoritative and correct in each case, but the client UI or a server-side gate disagrees with the actual contract. Pre-existing in main, surfaced by viewer-account dogfooding.
Root causes summary
attachment_http.rsparsedX-Hero-Userasu64, dropping the proxy-mode email-string form. Now mirrors the JSON-RPC dispatcher's resolution chain (parse::<u64>→caller_id_cache→external_id→email/alias).channel.createadmin-gated every kind including DMs. Now splits by kind: public/private remain admin-gated; DM/group_dm only require workspace membership.pushafterawait rpc()was not idempotent againstonMessageReacted's WS-driven authoritative replace. Now checks for presence before pushing.The DM PR also drops two redundant
channel.member.addcalls instartDmthat were leftover from beforechannel.createstarted auto-adding members for DMs — they were tripping the server's "user_id matches caller_id" guard and surfacing as a separate console error even though DMs worked.Status of stack on the test workstation
hero_collab_serverrebuilt + restarted with the attachment fix + DM permission fix.hero_collab_uirebuilt + restarted with the reaction idempotency fix + startDm cleanup.viewer_test.