Skip to content

chore(promotion): test → preview - #615

Merged
EddyOne81 merged 13 commits into
previewfrom
test
Sep 21, 2026
Merged

EddyOne81 merged 13 commits into
previewfrom
test

Conversation

@EddyOne81

Copy link
Copy Markdown
Contributor

Whole-branch promotion testpreview, requested by Natrix. Merged after server-team#225, whose UAT deploy is green.

Diff vs preview: 62 files, src/ alone +5685 / −935.

Payload

🚨 This promotion DELETES tests/ from preview — 15 files

test carries no tests/ directory: each of those files was added there with its fix and then removed again by a later PR (#613, #587, d535365f — the same pattern as #544 and #562 before them). preview still holds 15 of them, so promoting test wholesale removes:

call-tile-drag, chat-mention-workspace-members, org-overview-cache, player-share-click, rail-logo-home, secure-share-subject, workspace-delete-admin-only, plus the eight tests/helpers/ stubs and harnesses.

They are the only files preview has that test does not — everything else in this diff is an addition or an intentional supersede. Nothing in src/ is lost. Say the word and they can be restored on test in a follow-up instead; that is a decision about the suite, not about this promotion.

Back-merge note

previewtest merged with no conflicts. Git tried to re-add tests/chat-mention-workspace-members.test.js (the cherry-picked twin on preview has a different SHA, so it read as a new file rather than one test had deleted); it was dropped again so the merge matches test exactly. git diff --stat origin/test is empty — the merge carries no content of its own.

🤖 Generated with Claude Code

EddyOne81 and others added 13 commits September 16, 2026 23:59
Lexis, 2026-09-16: "I open the Billing page and a few seconds later the
Checkout option disappears from the tab slider" — on lexishoang.drumee.in,
and going on for over a week.

Traced on stage: her org 12d3d4dc12d3d4e1 (domain 13, she is owner_id) has
held a live Stripe subscription since 2026-09-10 — subscription_new
status='active', plan business — and yp.quota carries the matching business
entitlement. So the tab is CORRECTLY withdrawn: a live subscriber has
nothing left to buy self-serve and payment.checkout answers
ALREADY_SUBSCRIBED. The defect is that it was put on screen first.

Why it was put there: _checkoutTabAllowed() reads _hasActiveSub, and only
_loadSubscription() fills it. Until the mirror answers the flag is
`undefined`, the gate reads "nothing is live", and the pill goes up — on a
guess that is wrong for precisely the people who can never use it.
372a1dc (2026-09-08) shortened that window from ~5s to ~250ms by no longer
making the correcting render wait on the Stripe catalog; it could not close
it, because the pill itself was being painted on the unknown.

The guess does not have to be wrong. get_quota is tenant-first — verified on
stage, CALL get_quota(<Lexis>) answers business — so Visitor.quota() already
names the ORGANISATION's plan synchronously, and _paidPlanSync() is that
signal. It is the same stand-in the plan-card CTA has used since 2026-08-06
for the same reason (an early click must not walk a Business owner into a
Pro checkout), so no new source of truth is introduced.

_checkoutTabVisible() is that verdict, and the header renders on it.
Eligibility is untouched: every guard — the click handlers, the deep link,
the upgrade intent — still reads _checkoutTabAllowed() and behaves exactly
as before.

Deliberately one-directional: it can only WITHHOLD a pill, never add one
eligibility refuses. Quota is a superset of "has a Stripe subscription" (a
comped staff grant and a LAUNCH30 org are paid by quota with no mirror row),
so its worst error is a pill that appears ~250ms late instead of one that
vanishes — and appearing takes nothing away from anyone. A checkout deep
link, which opens the tab before the mirror is known, keeps its pill so the
content is never rendered without it.

A free account — the overwhelming majority of page loads — is unaffected in
both directions: the pill is there on the first frame and stays.

tests/billing-checkout-tab-no-flicker.test.js locks both halves against the
shipped source: the gate is lifted out of the widget file, the pill is the
real skeleton/header.js render. Reverting either half fails it.
tests/helpers/load-esmish.js learns `export default`, the shape every
billing skeleton uses, so the header could be rendered at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lexis wants the Checkout tab to be THERE — always, never withdrawn. 51b4103
read her report the other way round and stopped the flicker by never painting
the tab for a subscriber at all. That removed the flicker and the tab with it.
This does what she asked.

The tab is now gated on _mayCheckout() alone: does this deployment sell plans,
and may this caller buy here. Both halves are answered synchronously (libs/
billing canUpgradePlan reads Visitor + the quota cache), so the gate depends on
nothing that arrives over the network and the pill CANNOT appear and then
vanish. That is the property the report is about, and it is now structural
rather than a matter of winning a race — no amount of making the old
withdrawal faster (372a1dc) could have achieved it.

The tier rule did not go away. It moved off the tab and onto the button that
spends money, which is the better place for it: the user is told what will
happen and asked, instead of watching a tab disappear without explanation.
_proceedToCheckout now routes a live subscriber into _confirmReplacePlan — the
same warning the plan cards have used since 2026-07-29 ("your current plan will
be canceled immediately, remaining time is not carried over"). On accept it
sets `supersede` and returns to checkout, so the buyer presses Pay again having
read what they agreed to.

THE MONEY PATH IS STRICTLY MORE CONSERVATIVE THAN BEFORE, not less:

  - payment.checkout has exactly one client caller, _proceedToCheckout.
  - payload.supersede is set only when state.checkout.supersede is already
    truthy, and that is written in exactly one place — inside
    _confirmReplacePlan, after `if (!ok) return`. It cannot be reached without
    an explicit human confirmation. Unchanged by this commit.
  - A live subscriber without that confirmation no longer reaches the POST at
    all; previously they could, and were saved by the server's refusal.
  - Buying the exact plan+cycle already held is still refused, client and
    server.
  - The server guard, the webhook that cancels the replaced subscription as
    the new one is paid, and the USE_SUBSCRIPTION_UPDATE response handler are
    all untouched; the last one stays as the backstop for a stale client.

No new capability is unlocked. Every path this opens funnels into
_confirmReplacePlan, which the plan cards could already reach for exactly the
same accounts — including past_due and pending-cancel. What changes is that
there is now a second door to it, and that the door is visible.

Also: _confirmReplacePlan's "already exactly this" case returned silently. It
used to be unreachable (the card for the plan you hold is disabled), but the
Checkout tab opens with the caller's CURRENT plan preselected, so it is the
first thing Lexis can press — a Pay button that quietly does nothing is the
same class of defect as a tab that quietly disappears. It says
ALREADY_SUBSCRIBED now, the same wording the server's own refusal gets.

Stale comments describing the withdrawn tab are corrected in all four places
that carried them, in index.js and skeleton/header.js.

tests/billing-checkout-tab-always-available.test.js replaces the
no-flicker test and locks both halves against the shipped source (the gate and
the whole _proceedToCheckout body are lifted out of the widget file; the pill
is the real header render). Nine cases. Verified non-vacuous in both
directions: restoring the old tab gate fails 3, removing the money guard fails
a different 3. One of them is a shape lock — _checkoutTabAllowed() may not name
_hasActiveSub, _hasPaidSub, _subLoaded, _canBuy or _paidPlanSync, because a
gate that reads anything fetched is how the tab vanished in the first place.

Full suite green: 63 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#604)

Topbar utility icons (bell / calendar / inbox / contacts / trash / apps):
- spinner on the pressed icon, siblings disabled until its screen is up
- a press on an open bell / Contacts / Trash panel closes it; icons are lit
  only while their panel is open, and a rail __nav-main press closes all
  three and clears their icons
- close button on the notifications panel on desktop

In-window (migrate) tour:
- bell, Contacts and Trash keep the tour and slide in over it
- full-canvas screens (Calendar, Inbox, Admin Console…) end the tour only
  after they have painted, and count as a navigation, so a parked Files tab
  or a late-arriving / re-requested tour no longer lands on top of them
- a refresh with the tour owed hides the restored pane until the tour is up
- the "Unlock Admin Console" card opens over the tour, raising it again when
  it is still owed

Stacking: while an upload runs, or a wrapper-modal is open during a tour,
the window layers are capped so window-folder__split-body no longer paints
over the slide-out panels, full-page screens or the tour.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Typing "@" in a workspace chat offered the visitor's personal CONTACTS
instead of the members of the workspace they were writing in.

This was already fixed once. 299e560 (May) pointed the contact mention at
hub.get_members_by_type whenever the chat's scope was `folder` -- at the
time the only scope a folder chat ever had. 37ab4fc (Aug) then moved chat
to WORKSPACE scope: the team chat's scope became the string `workspace`,
and that commit updated `postNid` on the line below but not the mention
branch. The condition silently stopped matching, so every normal workspace
chat fell through to chat.contact_rooms. A DMZ share kept working, because
a share is the one surface still scoped `folder` -- which is why this read
as the old bug returning rather than as a new one.

The two scope tests that mean "this conversation belongs to a hub" are now
one predicate, isHubScopedChat, so the list cannot drift apart again. The
third copy (_syncScopedFolderContent) goes through it too; scopedNid stays
folder-only on purpose -- a DMZ share is an access boundary, not a view.

A PERSONAL workspace is excluded and keeps its contact rooms: it IS the
user, so its hub_id is Visitor.id and hub._members_by_type answers [] for
it. Without that guard, widening the scope test would have traded the wrong
list for no list at all in personal folders, which keep the chat panel.

No schemas or server change: hub.get_members_by_type is src:read and is the
same call the share path has been making all along.

tests/chat-mention-workspace-members.test.js cuts the branch out of the
source file and runs it against a fake `this` (the pattern in
workspace-delete-admin-only / rail-logo-home), so it reads the shipped text:
it fails on the pre-fix source, and fails again if only the mention
condition is reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Calling a contact who is not connected drops the p2p call window into the
'offline' state, which shows "<name> is not currently online." — and also
started musics/dialtones/offline-seagull.mp3 with loop = 1, so a seagull
squawked over the notice until the caller closed the window.

The panel is the whole message, so the clip goes. Nothing else about the
state changes: the Cancel-only controls, data-call-state="offline" and
closing without firing a cancel signal are untouched, and a dial that does
connect still gets its ring-back tone — the offline guard sits ahead of it
in `case 'dial'`, so no looping tone can outlive the panel.

tests/call-offline-silent.test.js runs the real 'offline' branch out of the
source file and fails if the clip comes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(connect): a 1:1 call stays visible when it loses focus, and parks like a meeting

Clicking anywhere outside a live 1:1 call made it disappear. The call was
never closed -- audio kept running -- it was covered by the desk's slide-out
panels or the sidebar, which reads as "my call just closed".

The window is lifted out of the window manager by DISSOLVING that manager's
stacking context: `data-desk-call` on the desk root turns off
`isolation: isolate` on `.window-manager__ui` so the call layer can resolve
its z-index at the document root. Two things were wrong with how that was
driven.

The flag tracked FOCUS, not presence. Every window rides the shared
`wm-radio` channel, and ui-core's radio behavior (view/behavior/radio.js
`_on_message`) calls setState(0) on every window that is not the raise
origin, which writes data-state="0". The mirror selector was
`.window-connect[data-state="1"]`, so raising any other window -- or simply
clicking the desk -- dropped the flag mid-call, `__ui` re-isolated, and the
call's own z-index (100100) was trapped inside it. The mirror now keys on
the window being MOUNTED, and the topbar compensation rule in desk/skin
tracks the same condition for the same reason.

That alone was not enough, and standing the layer cap down for a call --
`:not([data-desk-call])` on the 1300/1301 rule -- was actively harmful. The
dissolver is a blunt instrument: it releases EVERY layer in the window
manager, not just the call's. So the workspace pane and the floating windows
escaped to 50000/50001 as well, above the slide-out panel containers at
10001, and the Inbox went blank for the duration of a call (chat-p2p__main
covered by window-folder__ui) and came back the moment the call ended. Nor
did it protect the call it stood down for: inside the dissolved root the
call layer is an ordinary window layer on the ordinary focus ladder, so
clicking the workspace pane lifted THAT layer to 50001 and the pane covered
the live call -- the same symptom, one layer along.

So the cap applies during a call exactly as during an upload, and the CALL
LAYER ALONE is lifted out of the ladder, on presence rather than focus, to
20002 -- the tier the parked-call dock already occupies. A call outranks
every desk surface whether it is docked or full size, and the two states
cannot fight because the window is only ever in one of them.

Second half: window_connect never listened for "call:minimize", so
Wm.parkLiveCall() / Desk._parkLiveCall() were no-ops for a 1:1 call and
navigating to a full-page desk screen left it buried with nothing able to
lift it. Rather than copy the meeting's implementation, the parking
machinery moves out of window/meeting into builtins/webrtc/call-parking and
is Object.assign'd onto both prototypes, the way webrtc/reactions and
webrtc/screenshare already are; the tile's own markup moves to
webrtc/skeleton/call-tile. window/meeting loses 577 lines and keeps only the
two seams the mixin calls back into -- _canParkCall (the meeting still
refuses to park an EMBEDDED meeting, whose host container owns its box) and
_onCallTileLeft (the meeting re-asserts its full-frame lock; the 1:1 popup
re-seeds the 734x600 geometry it owns). Those two are deliberately absent
from the mixin: Object.assign would overwrite them.

FOCUS CHANGE, MINIMIZE, CALL END and WINDOW UNMOUNT are now four distinct
things and none of them is inferred from data-state.

Last, the parked tile rendered the whole 734x600 call screen inside the
300x180 dock -- title bar with three buttons, the elapsed timer across the
middle, the 64px round call actions sliced off at the bottom edge. The
shared shell strips the MEETING's chrome at tile size; every control on this
window is its own, so none of it was caught. Measured in the dock, the
chrome took 178 of the 180 available pixels and the video stage got 0. It is
hidden now, and the pre-call identity is sized for a tile (avatar 120 -> 56,
email dropped) so a ringing call still shows who is calling.

Verified in isolation, against the compiled stylesheets and the real mixin
source: the call is the topmost element at its own rect whether focused or
not, with a workspace pane focused, with an upload running and over a
full-page screen; chat-p2p is no longer covered; park/restore moves the live
element between the call layer and the dock without duplicating a window or
leaving a stale dock entry or flag; hangup releases everything. NOT verified
in a real call -- this machine has no WebRTC stack (jitsi-videobridge2,
prosody and jicofo are all inactive), so the parked tile's look and the
survival of a live srcObject across the DOM move still need one real call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(desk): topbar dropdowns stay above the folder window while a dialog is open

Opening the create-folder form (.form-folder__main) and then the organisation
panel or the account menu drew the dropdown UNDER
window-folder__split-body -- it painted, but behind the workspace pane, so it
read as a dead click.

The form is fed into Wm's wrapper-modal, which stamps `data-wm-modal="open"`
on the desk root, which dissolves `.window-manager__ui`'s stacking context so
the modal can be lifted clear (wm/index.js _installWmModalMirror, and the
block above this one in desk/skin). Dissolving releases EVERY window layer to
the document root, headless-layer included, at 50000 -- 50001 while it hosts
the focused window. `.desk-module__topbar` is its own stacking context at
10003, so every dropdown hanging from the bar is trapped at 10003 and loses to
the pane. The dropdowns hang BELOW the 46px strip, over the window, which is
why the bar itself looks fine and only what it opens is affected.

This was already understood and already fixed -- for the workspace switcher
alone. The lift that does it is gated on `data-desk-wsmenu`, a mirror that
watched the ws-wrapper and nothing else, so the organisation panel and the
account menu were never named and never lifted, though all three hang from the
same bar over the same window for the same reason.

So the gate widens from one dropdown to any dropdown. All three roots are
`Skeletons.Menu` (org-tab skeleton/index.js, topbar.js __ws-wrapper and
__account-wrapper), so ui-core gives each `menu-topic` + `data-state` and one
test covers the bar: the mirror now stamps `data-desk-topmenu` when the TOP-BAR
part holds an open menu root.

What does NOT change: the lift is still gated on a dropdown actually being
open, so the bar is still not floating over a dialog the rest of the time --
the reason the gate exists. Specificity stays (0,7,0), the attribute repeated
three times exactly as the flag it replaces was, so the deliberate tie with the
utility-tooltip lift above is still broken by source order and the switcher
keeps its 100001. The mirror stays an attribute on the root rather than a
`:has()` anchored on the desk, whose subject would re-match the whole
application on any mutation anywhere.

The observer is now scoped to the top-bar part with `subtree: true` rather
than to the single ws-wrapper element. That is what "any dropdown" needs, and
it also removes a way the switcher itself could go quiet: the old observer held
a reference to one child, so a re-fed ws-wrapper left it watching a detached
node.

Verified against the compiled stylesheet, driving the real mirror predicate:
with a modal open and the window layer at 50001, the bar is 10003 with no
dropdown open (covered, unchanged), and 100001 with the organisation panel,
the switcher or the account menu open (clear). Before this change the same
harness lifts only the switcher and leaves the other two at 10003. Not
verified in the running app -- this machine has no desk to click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(desk): clicking away from the workspace rename editor ends the edit

Escape and Enter were the only two ways out of the inline rename editor, so a
click anywhere else left the field sitting in the chip with the breadcrumb
still hidden behind it (`data-renaming="1"`). The edit looked abandoned but
was still live, and the crumb did not come back until the user found their way
back to the field.

A press outside `.desk-module-topbar__ws-rename` now ends it, and what happens
to the typing depends on whether there is any:

  unchanged (or emptied) -> close, no request. Exactly what Escape does, and
                            what the Enter path already does for a name that
                            did not change. Nothing to decide.
  changed                -> ask. Save writes it, Discard throws it away.

Neither answer is taken on the user's behalf. Commit-on-blur is the familiar
behaviour from a file manager, but a stray click somewhere else in the desk is
not a decision to rename a workspace -- and it is not a decision to throw away
what was typed either.

MECHANISM. `mousedown` on the document in the capture phase, the same shape as
the desk's other dismissals (_userMenuDismiss, _suggestionsDismiss), bound only
while the editor is up. Deliberately NOT the entry widget's own `blur`, which
looks like the obvious hook: ui-core fires one (widgets/entry/input/index.js
_onBlur), but blur is not "clicked outside" -- it also fires when the browser
window loses focus, and again when the confirm takes focus, which would
re-enter the path in the middle of the question it had just asked. A press on
the prompt itself is excluded for the same reason, and the listener is released
BEFORE the prompt opens so the press that answers it cannot stack a second one.

The commit tail of _onWorkspaceRenameInput moves to _finishWorkspaceRename and
is now shared by both write paths, Enter and Save. The holder-scoped payload
and the breadcrumb follow-up are the one thing here that must not drift between
them, so they are a method rather than a second copy.

No new LOCALE keys: SAVE_CHANGES, SAVE and DISCARD are all already in
locale/en.json, so the other five locale files are untouched.

Covered by tests/ws-rename-outside-click.test.js (12 cases, methods cut out of
the source file the way the other desk tests do): inside-the-editor and
on-the-prompt are not dismissals; unchanged and emptied close silently with no
confirm and no write; changed asks, and shows the new name; the listener is
detached before the prompt opens; Save commits and re-resolves the crumb;
Discard writes nothing; a failed write still closes and warns; and the listener
is released on every ending. Suite 127/127.

Not clicked in a running desk -- this machine has no working app instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(desk): the rename prompt drops its backdrop

The Save/Discard prompt raised when the user clicks away from the workspace
rename editor asked for confirm()'s default "scrim", which dims the whole desk
behind it.

This is confirm()'s own documented case for `overlay: "none"` -- "a caller
confirming an action ON a panel the user is reading can ask for none instead;
dimming the surface the prompt is about makes it harder to check, not easier."
The question here is whether to keep the name the user just typed, and that
name is in the chip directly behind the card. Dimming it hides the one thing
being asked about. The card carries its own shadow, so it still reads as
raised without a backdrop.

Five callers already do this (folder, permission/restricted, chat,
openFeatureLock); no default changes, so every other confirm keeps its scrim.

The test asserts `overlay: "none"` explicitly, because the default is "scrim"
and dropping the line would silently put the dim back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(desk): a Save tick beside the workspace rename field

Enter has always saved the inline rename, and nothing on screen said so. The
field WAS the editor -- no button, no hint -- so the only visible way out of it
was to click away, which until the previous commit did nothing at all and now
raises a Save/Discard prompt. The tick makes the primary action something the
user can see.

It takes the decision Enter takes, and it never asks. The click-outside prompt
exists because a press somewhere else in the desk does not say what the user
meant; pressing Save says exactly what they meant. Empty or unchanged closes
without a request, the same as Enter, because an empty name would rename the
workspace to nothing.

_saveWorkspaceRename reads the FIELD rather than the widget's model, for the
reason _dismissWorkspaceRename does: that is the live text, and the model is
only refreshed on the widget's own commit/blur. The write itself goes through
_finishWorkspaceRename, shared with Enter and with the prompt's Save, so all
three paths keep one implementation of the holder-scoped payload and the
breadcrumb follow-up.

`ico: "checked"` is the topbar's own tick -- skeleton/topbar.js already uses it
for the account menu's active language, so this bar carries it (sprite id
`--icon-checked`). `bubble: 0`, because the press is handled here and has no
business continuing up to the window manager, which answers an unrecognised
service by collapsing the open windows. `LOCALE.SAVE` already exists, so no
locale file changes.

The button sits INSIDE .desk-module-topbar__ws-rename, so the click-outside
listener added in the previous commit already reads its mousedown as "inside"
and leaves the edit alone -- otherwise the dismissal would fire first and raise
the prompt on top of the very button just pressed. There is a test pinning
that, because it is exactly the kind of thing a later refactor breaks in
silence.

Skin: 22px square to match the field's own height so the two read as one
control, 4px gap, flex-shrink: 0 so a narrow bar squeezes the 220px field
rather than the button. Measured against the compiled stylesheet: field 220x22,
tick 22x22, gap 4, tick inside the row.

Six new cases in tests/ws-rename-outside-click.test.js (18 in the file, suite
133/133): Save writes and refreshes the crumb without asking; unchanged and
emptied close without writing; Save releases the click-outside listener; a
press on the button is not an outside click; Save with no edit in flight is a
no-op.

Not clicked in a running desk -- this machine has no working app instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(desk): the rename Save tick becomes a filled primary button

ico "checked" -> "app-check", white glyph on --primary-40.

A bare dark glyph beside the field read as decoration on the chip rather than
as the control that ends the edit. Filled, it is unmistakably the commit, which
matters here because the thing next to it is a field the user is still typing
in.

Hover steps to --primary-50, the next stop on the same ramp, rather than the
translucent black wash the bar's other icon buttons use: over a saturated fill
that muddies the colour instead of darkening it.

`var(--white)` and `var(--primary-40)` rather than literals, matching how the
rest of this file names colours (topbar.scss already pairs exactly these two on
the utility button's hover). Both verified to resolve at runtime, not merely to
compile: the button computes to rgb(255,255,255) on rgb(89,80,255), and the
sprite carries the glyph as `--icon-app-check`. Geometry is unchanged -- 22x22,
4px gap, inside the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(desk): shrink the glyph in the rename Save button

16px in a 22px button with 3px padding meant the tick filled the content box
edge to edge, so it read as a square of colour with a shape cut out of it
rather than as an icon on a button. 12px leaves 5px of fill around it.

The button itself is unchanged at 22px, which is what keeps it the same height
as the field beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(desk): the workspace rename editor animates in and out

The field appeared and vanished between two frames. It takes the CRUMB'S place
in the chip, so a swap with no transition read as the breadcrumb glitching
rather than as the name becoming editable.

`data-anim` on the box, the desk's own convention for a surface arriving and
leaving (desk/skin already reads [data-anim="out"], chat-p2p mounts with "in").
Keyframes rather than transitions: there is no from-state to set and no frame
to wait for, so the animation runs the moment the attribute lands. A 6px slide
from where the name was, 140ms, plus the fade.

ONLY THE VISUAL TEARDOWN WAITS. _endWorkspaceRename is reached from six paths
and the state teardown -- __wsRename, the document listener -- is done by the
callers and stays synchronous, so every one of them keeps the ordering it
already had. What is deferred is emptying the slot and giving the crumb back,
and the crumb comes back AFTER the field has gone rather than beside it: they
share the row, so un-hiding it any earlier puts both in the chip at once and
the name appears to jump as the field collapses.

A pending teardown is cancelled when another edit starts, and when another
ending arrives. Escape, then Rename again inside the 140ms window, would
otherwise fire the first timer against the editor that had just opened and
blank it. A box that is already gone is torn down at once instead of holding
the crumb hostage for 140ms of nothing.

140ms lives in two places -- WS_RENAME_ANIM_MS in index.js, which owns when the
field leaves the DOM, and the keyframes, which own what it looks like on the
way. Each names the other; the test reads the constant out of the source so it
cannot drift from either.

THE REDUCED-MOTION RULE IS NOT IN THE BLOCK AT THE TOP OF THIS FILE, where it
would look like it belongs. Both selectors are (0,2,0) -- the attribute is all
either adds -- so the cascade falls through to source order and up there the
override loses to the two rules it is meant to beat. Measured with chromium
--force-prefers-reduced-motion: the animation still ran. Nested immediately
after them it wins: animation-name none, nothing running. index.js's timer is
not branched on the media query, so a reduced-motion user gets the identical
teardown sequence without the movement.

Verified in a browser rather than from the declarations: no attribute is a
clean resting state, "in" and "out" each run one 0.14s animation with fill
both, "out" also drops pointer-events so a press cannot land on a field on its
way out, and clearing the attribute returns to none. Four new cases in the test
(22 in the file, suite 137/137), and the existing assertions now flush the
timer before reading the crumb.

Not seen in a running desk -- this machine has no working app instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(connect): the call window is smaller, and centred in the desk canvas

The 1:1 call opened at the Figma 734x600 and sat down and to the RIGHT of
centre. A call is one or two faces and a row of controls; at that size it
covered most of the desk behind it for no gain, and it was not where it looked
like it was meant to be.

The frame is now 640x520, comfortably clear of the window's own 480x420
minimums.

THE SKEW IS A COORDINATE-SPACE BUG. The shared _setSize
(window/interact/webrtc.js) computes `left = innerWidth / 2 - width / 2` and a
`top` from innerHeight. Those are VIEWPORT coordinates, but this window's
offset parent is the call layer, whose origin is the WM work area -- below the
46px topbar and to the right of the sidebar rail (64px pinned, 231px expanded).
An offset measured from the viewport and applied from the canvas origin lands
right and low by exactly the rail and the bar. On a 1440x800 desk with the rail
open that is ~123px right of centre; the `- 100` fudge and the `top < 90` clamp
in that method then decide the vertical error on their own.

_centerInWorkArea measures the box Wm.clampWindows measures -- the window
manager's container, which IS the canvas -- and centres in that. Whole pixels,
because a subpixel left/top blurs the text inside; clamped to a 16px inset so a
work area narrower than the window cannot produce a negative offset; the
viewport as a fallback when the desk has not laid out or measures 0x0, which it
does for a frame during a rebuild.

CORRECTED HERE, NOT IN _setSize. That method is shared with window/meeting,
which is a full-frame screen that throws its inline geometry away in
_lockGeometry, and with the orphaned window/screenshare. Neither can be
exercised on a box with no WebRTC stack, so the shared arithmetic is left alone
and the one live consumer places itself. The bug is still there for anything
that starts using it.

Also caught: _onCallTileLeft, which re-seeds the geometry when a parked call
comes back out of the desk dock, carried its own literal 734x600. Left alone it
would have opened the window at one size and restored it at another. It now
uses the same constants and re-centres, with a test that reads its body and
fails on a literal dimension.

Ten cases in tests/connect-window-centered.test.js, reading the constants out
of the source so they cannot drift from the assertions. Suite 147/147.

Not seen in a running desk -- no WebRTC stack on this machine, so the placement
is verified by arithmetic against the real work-area box, not by opening a call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#609)

The bottom sheet listed workspaces but never said which one was open, and
none of its rows could be tapped.

- a header naming the open workspace (the desktop switcher's __ws-head),
  with the area-tinted glyph, plus the share chip on an external workspace
  and a ⋯ that opens that workspace's actions in place of the list. The
  rows come from the same resolution the desktop ⋯ uses, lifted into
  _resolveWorkspaceActions so there is one copy of it.
- workspace rows are tappable again. __msheet-list carried
  kidsOpt {active: 0}, which ui-core merges into every DIRECT kid, so
  triggerHandlers returned on its first line: no service, nothing thrown,
  nothing happened. The "New workspace" button, a sibling of the list,
  kept working — which is what the report looked like.
- the open workspace resolves by _workspaceKey, never hub_id. Every
  personal workspace carries the user's own, so id-matching lit the whole
  PERSONAL section at once and would have pointed the header at the wrong
  row.
- the list is grouped INTERNAL / EXTERNAL / PUBLIC / PERSONAL through the
  desk's own _groupWorkspaces, and no longer repeats the workspace the
  header already names.
- the phone topbar pill's folder glyph tracks the open workspace. It was
  built once from `ui.mget(_a.area)` — the desk's own model, which never
  carries an area — so every workspace drew the same default folder with
  no emblem.

Three ui-core behaviours this had to be taught, each recorded at its site:
kidsOpt is merged into direct kids; `data-state="closed"` is hidden by an
unscoped rule in skin/lib; and Image.Svg deletes `ico`, renaming it to
`chartId`, so a descriptor built elsewhere must be read by that name.

45 tests for the sheet, run against the shipped builders rather than
copies, and the descriptor harness now models all three behaviours — it
had been reporting controls the app was hiding.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(inbox): a scope tab lands on its first conversation

Switching between Direct chat and Workspace chat refetched the list but
left the conversation pane alone, so the previous scope's chat stayed
open beside a list it no longer belonged to — a Direct conversation
showing while "Workspace chat" read as selected.

The landing that opens a conversation on first load is a `once` on the
list's `eod`, consumed by the first page and never re-armed. _setRoomScope
now re-arms it per switch, via _armScopeLanding.

Armed AFTER list.restart(), which is load-bearing: restart() triggers
`eod` synchronously to flush stale listeners before it calls start()
(ui-core letc/widgets/list/index.js), so a handler armed before the call
burns on that flush against the old page and the real one arrives with
nothing listening.

The scope is captured at arm time and re-checked both when `eod` fires
and after the widget_chat wait, so a fast second tab press wins instead
of letting the page it left open a row.

Mobile/tablet stays on the inbox, as the first-load landing already does:
the user just tapped a tab there, and opening flips data-mview to "chat"
and hides it. An empty scope clears the pane to the header's `--empty`
variant on every screen size, dropping chatWidget so a stale widget_chat
cannot keep acknowledging messages in a scope nobody is looking at.

_landingRow also learns to tell the two senses of "placeholder" apart:
`is_placeholder` marks the support row we draw (a real conversation),
while an empty list carries the smart list's own NO_CONTACT note in
`children`. Only the first is landable, so the note is excluded by
requiring an entity_id — latent before, since openChat ran on the note
until _openConversation bailed on a missing hub_id, and load-bearing now
that a scope switch can land on an empty list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(inbox): loading skeletons for the scope switch and first paint

Switching scope tabs is a refetch: restart()'s start(1) empties the
collection synchronously and only then fetches, so the inbox column goes
blank for a round trip while the conversation beside it waits to be
replaced by the new scope's first row. The first mount has the same gap
and no cover at all — the list's spinner option draws nothing in ui-team,
since ui-core's spinner injects markup with no base styles here.

Both panes now hold their shape, using the house primitives
(mixins/drumee skeleton-layer / skeleton-content-in), so the pulse
follows the theme and stands down under prefers-reduced-motion.

Gated on two stamps on __ui, the widget's own root — never a lazy
placeholder, so a kind that has not resolved cannot read as "ready":

  data-list-state  loading|ready, seeded to loading so the FIRST paint is
                   already the skeleton rather than a blank 400px column.
  data-chat-mounted 0|1, paired with :not(:has([data-painted="1"])).
                   widget_chat stamps itself once its messages are ready,
                   so CSS alone lowers the pane skeleton with nothing to
                   watch it. The flag supplies what CSS cannot infer: a
                   pane with no painted child because it is LOADING versus
                   one that is empty on purpose (_clearConversation), which
                   must not pulse forever.

The dataset keys are hyphenated deliberately. onRender applies that map
with setAttribute("data-" + key) verbatim, with none of the camelCase ->
kebab folding el.dataset does, so `listState` would have landed as
data-liststate and no selector written the obvious way would have matched.

A 6s deadline lowers the inbox skeleton regardless, since `eod` is the
only thing that lowers it and a dropped request never fires one. Armed on
first paint as well as on every switch — the first fetch is the one case
with no tab press behind it to re-arm anything.

Forced by the stamp: the first-load eod handler gains the scope capture
_armScopeLanding already had. restart() fires eod synchronously as a
listener flush, so pressing a tab while the first page is still in flight
detonated that handler against the list the user just left. Latent before
(it opened the wrong row); visible now, as a flash of empty list.

Verified by compiling the skin standalone and rendering the real class
chain headless: the mask's first avatar lands at x=28 y=28 48x48 against a
real row's x=28 y=28 48x48, and its fifth at y=108 against the second
row's y=108, confirming the 80px pitch. All three states check out —
skeleton up while loading, down on ready, and absent over a deliberately
empty pane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(inbox): the two skeletons rise and fall together

The inbox column and the conversation pane could never overlap. A tab
press raised the list skeleton; the pane kept the OLD conversation, still
carrying data-painted, so its skeleton stayed down; `eod` then lowered the
list skeleton; and only then did _openConversation clear the pane and
raise the pane skeleton. Two loading states in sequence, so the screen
resolved in two steps with a blank pane beside an uncovered list in
between.

Now the inbox waits for the conversation, which is the slower of the two.
The list holds its skeleton even once its own rows have arrived, and both
columns reveal on the same frame.

data-list-state and data-chat-mounted collapse into a single data-loading
on __ui. One attribute rather than one per region is what makes the two
move together: a single flip raises both and a single flip lowers both, so
their pulses start in phase and end together by construction, with nothing
to keep in sync. data-chat-mounted's old job — telling "loading" apart
from "empty on purpose" — is just data-loading="0", and the
:has([data-painted]) gating leaves the stylesheet entirely.

_raiseSkeletons also clears the pane, which _openConversation used to do a
round trip later. Until the previous scope's widget_chat goes there is a
painted conversation in the pane and nothing for a skeleton to cover.
Nothing is wasted: _openConversation cleared it anyway.

Lowered on the conversation's paint. widget_chat stamps data-painted but
announces it to nobody, so the DOM is the only place that fact exists —
hence a MutationObserver scoped to the pane and to that one attribute,
disconnected as soon as it fires. Deliberately NOT the CSS condition it
replaces: "the pane has no painted child" is equally true while the user
is merely clicking between conversations, and would have dragged the inbox
list under a skeleton on every click, taking the row they just clicked
with it. The flag is raised only for a scope load.

Lowered directly on the four paths where nothing will ever paint: an empty
scope, an account with no conversations, either mobile bail, and the 6s
deadline.

Verified headless with getAnimations({subtree:true}) over both regions:
one flip, both pulses running with an identical startTime, and both gone
after the second flip. The animation clock is frozen under virtual time,
so equal startTime plus identical duration is the real evidence of phase,
not the elapsed-time sample. Screenshot confirms both columns up together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… arriving (#612)

Dropping a file on a task description did nothing. resolveZone had no entry
for it, so the pointer resolved to no zone and the dragover handler stamped
`dropEffect = "none"` — a deliberate refusal, and one the code documented.
This makes it a drop target for the two TASK descriptions (detail + create),
splitting a drop exactly as _onEditorPaste already splits a paste: an image
goes INTO the body at the point it was dropped, and anything the marker
grammar cannot express — a PDF, a video — attaches beside it, which is where
that editor's own paperclip already puts it.

The new zone is `__desc-editor[data-desc-scope]`, first in the table. It is
special-cased like `comment-row` but with the opposite refusal rule: an
unrecognised scope CONTINUES rather than returning null. A foreign comment row
refuses because nothing above it would claim the drop; a mention editor sits
inside the composer or reply box that does, and those must keep the drops they
have always taken. The scope rides an attribute because sys_pn is a model
field read with mget and never reaches the DOM.

An inline image also uploads before it can be shown, and until now nothing
appeared during those seconds — the drop read as one that had been ignored.
_insertPastedImage is split into _beginInlineImage (synchronous: the
placeholder, showing the local file dimmed under the strip's own spinner) and
_settleInlineImage (the upload, then the swap — or a red state with retry and
discard). Paste goes through the same pair, so both surfaces gained the
loading state and the two cannot diverge.

Nothing in the placeholder can reach the saved description, by three separate
properties: __inline-img-pending is not __inline-img under classList's
whole-token match, so no image marker is emitted; every child is an element,
so the serializer's fallback walk finds no text; and the retry/discard glyphs
are CSS ::after content, which is never in childNodes. A failed placeholder
can therefore sit in the editor indefinitely and a save stores the body as if
it were not there. tests/task-desc-drop.test.js runs the real _serializeEditor
over one to prove it, with a committed inline image as the positive control.

Three things that would otherwise have broken quietly:

  - attachExistingNodes normalises a desc zone to its form. Without it an
    internal grid drag resolves no draft and returns false, while
    canAttachExisting() has already told the folder not to insert the file —
    it would land nowhere at all.
  - _rememberDropScope excludes desc for the same reason it excludes
    detail/create: a task surface is recoverable from the pointer, and
    remembering it would let a stale hover write with no overlay shown.
  - _pasteZone excludes desc, so paste behaviour is unchanged. Pasting INTO a
    description is the editor's own path; this branch is the case where the
    caret is elsewhere and only the pointer is over the editor.

The affordance is CSS-only on the element itself, unlike every other zone's
overlay child: the editor is contenteditable, so an injected node would be
editable content that _serializeEditor carries into the body and _onDescInput
counts when deciding the field is empty.

Also fills in tests/helpers/render-skeleton.js's makeUi, which was missing 15
reader methods the skeleton calls — render() threw on the first column it drew,
hidden because both existing consumers go through renderModule.

Known limit: the placeholder is DOM-only. _renderEditorContent rebuilds the
body from the draft's markers on every render, so a render mid-upload wipes it;
the image still lands via the append fallback, which is what this path did
before there were placeholders. Covered by a test rather than left to chance.

Not verified in a browser: the task schema is not provisioned locally (no hub
instance has a `task` table), so the drag itself needs stage.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tasks): every drop zone in the create and detail cards lights in one colour

The description editor lit with the brand tokens on a drag —
--hover-bg-40 behind a dashed --active-border frame — while the
attachment, create-files and comment surfaces lit orange from a
hardcoded rgba(250, 133, 64, 0.08) and a 2px/1px dashed
var(--primary, #fa8540). Dragging one file across a single detail card
therefore changed colour depending on which half of the card the pointer
was over.

--primary is defined in neither light.scss nor dark.scss, so those
overlays were never themed at all: the orange was always the literal
fallback. That is also why the overlay icon and text move here too. They
carried the same var(--primary, #fa8540), and recolouring only the
background and the frame would have left an orange glyph and orange
"Drop files to attach" inside a purple-tinted, purple-dashed box. They
take a solid #5950ff fallback rather than the frame's 0.4-alpha one,
which would render washed out on 15px type if the theme failed to load.

The frames become outlines with outline-offset: -2px rather than
borders, matching the rule they are being synced to. An outline paints
outside its box by default, so on an inset: 0 overlay it would bleed 2px
past the zone it marks; the negative offset puts it back exactly where
the border sat.

This costs the comment overlays their deliberate 1px dash, the "one size
down" treatment for a frame that sits inside a card rather than over a
modal. Their content hierarchy is untouched (still no icon, still 13px
type) and the stale comment now says so.

Verified by compiling the sheet standalone with
sass -I src/drumee/skin -I src/sass/helpers and reading back the six
zones' computed rules; not verified in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: remove the test suite

Deletes tests/ in full: 17 node:test cases and the 8 support files under
tests/helpers/ that rendered real skeletons for them.

Nothing depended on it. There is no test script in package.json, none of
the four workflows in .github/ ran it, and no module outside tests/
required the helpers — the only importers were the cases removed here.
So this drops coverage without breaking a pipeline.

Worth knowing for whoever rebuilds it: tests/helpers/render-skeleton.js
was also the fastest way to verify a skin change headlessly, since it
stubbed the Skeletons globals and the webpack aliases and handed back the
real descriptor tree. git history is the place to recover that pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it (#614)

A blocked device was indistinguishable from a muted one: the pill looked
the same, the picker showed a single flat line, and turning a blocked mic
OFF succeeded silently because mute() never attempts capture, so the block
did not surface until a later click had to re-acquire the device.

- guidance panel (webrtc/skeleton/media-blocked): browser steps plus the
  settings path for the platform the user is actually on, shown in the
  device picker in place of the device list, with Retry
- apps-warning badge on the top-right corner of the mic / camera toggle,
  the toggle gated via _setService, and the pill tinted amber so "blocked"
  no longer reads as "muted"
- failures classified where they happen: changeLocalAudio had no catch at
  all (onUiEvent does not await it, so a blocked mic rejected into
  nothing) and changeLocalVideo's catch only reverted the button
- toggles ask the browser before choosing a branch, so the FIRST click
  lands on the blocked state whichever way it was pointing
- media alerts close whichever device picker is open first (mediaAlert)
- denial overlay carries the same guidance, drops its corner X in favour
  of the topbar Leave split (now interactive while denied), and offers
  Retry, which re-runs the startup after resetStartupState clears the
  stale connection + _startupMediaFailed that made a re-join hang
- restores the camera picker and background-effects controller and their
  styles, dropped from room/index.js and command.scss by the
  merge/preview-to-test merge (37bd5ef) while their skeletons survived
- window-info: flat header, 300px min width, spacer-6 padding, no corner
  X, smaller action buttons
- desk sidebar nav scrolls without painting a scrollbar

Locale keys added to all six files.

Co-authored-by: Drumee Dev <drumee@debian.local.drumee>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@EddyOne81
EddyOne81 merged commit b9c7651 into preview Sep 21, 2026
11 of 12 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67f13ff51f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +127 to +132
Skeletons.Note({
content: LOCALE.RETRY,
service: "retry-media-permission",
className: `media-blocked-retry clickable`,
uiHandler: _ui_,
dataset: { kind },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the device kind on the Retry control

When the camera permission guidance is shown, this bare dataset property is dropped during skeleton rendering, so cmd.$el.data("kind") in room/index.js returns undefined. retryMediaPermission then uses its default audio kind: clicking Retry on the camera panel checks the microphone and refreshes the audio-device list while leaving the camera blocked. Render data-kind through attrOpt or read the kind from a model field.

Useful? React with 👍 / 👎.

Comment on lines +3 to +5
// Brand logo only. The X was removed: every caller's action row already
// carries a dismissing button (Close / Cancel / Got it), so the corner cross
// was a second way out that only cost the header its padding.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain a close control for header-and-body info windows

The invited-workspace and billing loading notices in desk/index.js use window_info with mode: "hb", so info/skeleton/index.js deliberately renders no footer. Removing the header X therefore leaves those notices with no manual dismissal; if their asynchronous cleanup stalls, they cover the UI until the 30-second backstop fires. Keep the X for footerless modes or provide those callers with another close action.

Useful? React with 👍 / 👎.

Comment on lines +8089 to +8091
if (!editorEl.isConnected) {
this._releaseInlinePreview(ph);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for dropped inline images before saving the task

If a user drops an image into a task description and presses Create/Update before its upload finishes, the submit serializes the placeholder as nothing and closes the editor. The successful upload then reaches this detached-editor branch and is discarded, so the saved task silently omits the image and the uploaded file is orphaned. Track these upload promises and either await them or disable submission until they settle.

Useful? React with 👍 / 👎.

Comment on lines +1237 to +1241
case "video-device-select":
this.selectedVideoDevice = cmd.$el.data("deviceid");
// Remember the pick so reopening re-highlights it and toggling the
// camera off/on re-acquires this device (see changeLocalVideo).
this.preferredVideoInputDevice = this.selectedVideoDevice;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the selected camera device ID

When a user chooses a camera, the corresponding row in skeleton/video-device-list.js carries its ID only through a bare dataset, which this skeleton pipeline drops from the rendered element. This new handler therefore reads undefined, so confirmCameraSelection() has no selected device to apply and the camera remains unchanged. Render data-deviceid through attrOpt or read the row's model value instead.

Useful? React with 👍 / 👎.

Comment on lines 220 to 222
}
}
const list = await this.ensurePart("contact-list");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel stale workspace-scope switches after the preload

If the user selects Workspace chat and then returns to Direct chat while _fetchWorkspaces() is still pending, the first invocation resumes here after Direct has already become current. It raises the skeletons, clears the conversation, and restarts the list using the now-current Direct API, but arms the landing with stale scope workspace; that handler declines the resulting eod, leaving the Direct list with no conversation until another action. Re-check _scopeKey() === next after the preload and after ensurePart() before mutating the list.

Useful? React with 👍 / 👎.

Comment on lines +849 to +854
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revoke uploaded background object URLs on teardown

Every uploaded virtual background creates an object URL here, but the only URL.revokeObjectURL in this module is in removeBackground(); ending the call never revokes backgrounds that remain in the list, and an image that fails to load is not added to that list at all. Each upload therefore keeps its full Blob allocated until the page reloads, which can accumulate substantial memory across calls. Revoke pending URLs on load failure and all retained background URLs in the room teardown.

Useful? React with 👍 / 👎.

Comment on lines +638 to +643
if (!(await this.ensureMediaPermission(_a.video))) {
this._setMediaDeniedUi(_a.video, true);
// Stamp the wrapper so the skin can widen it for the guidance text; a
// structural selector would be at the mercy of the panel's markup.
p.el.dataset.blocked = 1;
p.feed(require("../skeleton/media-blocked")(this, _a.video));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish camera failures from permission denial

On browsers where camera permission is not queryable, ensureMediaPermission() probes with getUserMedia() and returns false for every rejection, including NotFoundError when no camera exists and NotReadableError when it is temporarily busy. This branch treats all of those cases as a permanent block, shows browser/OS permission instructions, and disables the camera toggle; the NO_CAMERA_DEVICE fallback below is therefore unreachable in the no-camera case. Preserve the error classification so only actual permission errors enter the blocked state and unavailable devices retain their appropriate message and usable toggle.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants