Event-driven framework, unified window channels, presence (0.5.5) - #41
Merged
Conversation
…red store, and window channel bus - Implement tests for the activity channel API to verify publish/subscribe functionality and filtering. - Create tests for the heartbeat bus to ensure data contribution and subscription behavior. - Add tests for the keyed list rendering to confirm DOM node reuse and event listener persistence. - Develop presence tests to validate user status tracking and event firing on status changes. - Introduce shared store tests to check state management, subscription behavior, and reset functionality. - Establish window channel bus tests to confirm message dispatching and subscriber behavior across different window contexts.
- Added `wp.desktop.icons` API to manage icon badges, allowing plugins to set and clear badges on desktop icons. - Implemented badge persistence across grid rebuilds to ensure badges remain visible after updates. - Enhanced the `Dock` class to support badge management with a rail discriminator for dock and taskbar. - Introduced new hooks for badge changes and window highlight changes to facilitate plugin interactions. - Removed the deprecated built-in wallpapers registration as it is now handled server-side. - Added comprehensive tests for the new badge functionality across icons and dock.
Move new event-driven-framework / presence / shared-store sections into AGENTS.md (the new home for what used to live in CLAUDE.md). CLAUDE.md becomes the trunk-side @AGENTS.md pointer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR formalises the way Desktop Mode is built. The shell stops
making UX decisions on apps' behalf and starts behaving like an OS
kernel: it publishes events, exposes synchronous state, and
routes data between plugins. Apps own their UX policy.
Demo.Chat.mov
CHAT PLUGIN DEMO:
wp-desktop-messages.zip
The mental model is documented end-to-end in the new
docs/event-driven-framework.md.This PR ships:
stores, unified window-channel API, presence.
wp.desktop.presence.*.wp.desktop.icons.setBadge,completing rail symmetry across dock / taskbar / icons so
plugin authors can write a single badge wrapper that fans across
every surface.
to the framework's event-driven contract —
ICON_BADGE_CHANGED,DOCK_ITEM_REMOVED,WINDOW_HIGHLIGHT_CHANGED.left behind in the touched modules is gone. One emission point
per state change, one canonical home per symbol, no dead code.
Why
Two recent footguns drove the design:
was focused. Convenient for "5 unread", wrong for "5 failed
deploys". The framework can't know what every app's badge
means; if it tries, apps that disagree have no override and the
heuristic and the app fight over the same DOM.
another. Every multi-bundle feature kept reinventing
window.__myPluginShared. Days of debugging burned chasing"I called the setter, why is the reader still seeing the
initial value?"
Plus a third, surfaced by a consumer plugin author writing the
ported messages plugin:
shipped
setBadge( id, count )and mirrored on thewp-desktop/badge-changedactivity channel — but the iconrail forced plugins into DOM scraping (
[data-icon-id]selectors + a hand-rolled
<span>decorator). One of threerails followed the contract; two didn't.
The fix is the same in every case: the framework is a transport,
not a policy maker. Give apps the events and state they need to
make their own decisions, and the same primitive shape across
every surface so they don't have to special-case.
What's new — public APIs
Layer 1 — synchronous state
wp.desktop.windowManager.getById(id)/isActive(id).isActivecollapses three sub-checks (exists, not minimized,focused) into one boolean — the canonical "is the user looking at
this window right now?" query.
wp.desktop.createSharedStore(key, init)— typed reactivestore keyed by string. First call creates, every subsequent call
with the same key (in any bundle) returns the same store.
Mutate-then-notify; no reducer enum, no immutable plumbing.
wp.desktop.icons.getBadge(id)(new in 0.24.0) — currentbadge count for an icon; returns
0for unset ids.Layer 2 — window lifecycle
HOOKS.WINDOW_BLURRED— symmetric counterpart toWINDOW_FOCUSED. Fires with{ windowId, focusedTo }. Managerfires this BEFORE the new window's
WINDOW_FOCUSEDso order isdeterministic.
HOOKS.WINDOW_HIGHLIGHT_CHANGED(new in 0.24.0) — fireson every
Window.setHighlight()change with{ windowId, mode, color? }. Lets onboarding / drag-bridge /guidance plugins react without observing DOM mutations.
wp.desktop.onWindow(id, handlers, options?)— typedper-window facade. Auto-filters by id. One-shot (default;
auto-unsubscribes on
closed) and{ persistent: true }forbadge-policy-style subscribers that keep firing across every
open/close cycle.
Window.requestAttention(mode, options?)— pulse / shake /bounce, three intensities,
prefers-reduced-motionfallback.Filterable via
wp-desktop/window-attention-requested.render( body )callbacks may nowreturn a function; the shell calls it on
close().Layer 2a — the unified window-self channel
Window.send/Window.onandwp.desktop.send/wp.desktop.onare the canonical way to talk to a window's content. One call
shape regardless of whether the body is an iframe or a native
render:
Iframes go via
postMessage; native windows route in-processthrough
src/window-channels.ts. Plugin authors never branch onwindow type and never reach for
postMessagedirectly.wp.desktop.connect()(peer-to-peer connection bridge) now worksidentically for both targets — pre-0.5.5 it silently no-op'd on
native targets.
Layer 3 — activity channels
wp.desktop.activity.publish/subscribe/filter— typed,named-channel bus on top of
wp.hooks. Channel naming is<plugin>/<event>; payload shapes type via theActivityChannelMapinterface that plugins augment in theirown
.d.ts.wp-desktop/toast-requested(pre-show, filterable),
…/toast-shown,…/window-attention-requested,…/badge-changed,…/open-requested,…/presence-changed,…/presence-snapshot-applied.wp.desktop.broadcast()mirrors onto the activity bus soin-tab consumers can subscribe through one surface.
Layer 3+ — the heartbeat bus
Shared subscription helper around
heartbeat-send/heartbeat-tick. Replaces every feature re-binding the same fivelines of jQuery boilerplate.
Last-writer-wins for
contribute; many subscribers compose forsubscribe; errors in any one supplier or subscriber are isolated.wp.desktop.icons— the third badge rail (new in 0.24.0)The wallpaper-icon rail now mirrors the dock's
setBadgeshapeexactly. Plugin authors write one wrapper and fan across every
surface; the rail that owns the id paints, the others silently
no-op:
Three calls. One painted tile. One activity event. One
hook fire. Properties of every rail's
setBadge:fan-to-all-rails pattern work without triple-emitting.
badge across plugin activations / live menu refreshes — set
once, the renderer re-paints from internal state.
>99renders as99+.Every applied change publishes:
wp-desktop/badge-changedon the activity bus with{ itemId, count, rail: 'dock' | 'taskbar' | 'icon' }.railis now required — every emission stamps which surface owned the
paint, so a single subscriber can compose a unified count across
rails without inferring from id space.
HOOKS.ICON_BADGE_CHANGEDwith{ iconId, count, previousCount }for callers that only careabout that surface (delta-aware unread counters, etc.).
Hook-bus symmetry
Three new hooks complete the "every state transition fires a hook"
contract for the existing JS surfaces:
HOOKS.ICON_BADGE_CHANGEDwp.desktop.icons.setBadge{ iconId, count, previousCount }HOOKS.DOCK_ITEM_REMOVEDDock.removeSystemItem(symmetric toDOCK_ITEM_APPENDED){ id, placement: 'dock' | 'taskbar' }HOOKS.WINDOW_HIGHLIGHT_CHANGEDWindow.setHighlight{ windowId, mode, color? }Presence — first framework primitive built on the new layers
Tracks who's currently in desktop-mode WP-Admin, with three states
derived from two timestamps: online / inactive / offline.
PHP storage in
_wp_desktop_presence(autoload=false, single row).Public PHP helpers, JS API at
wp.desktop.presence.*, REST at/wp-desktop/v1/presence. Filters:wp_desktop_presence_inactive_after,_offline_after,_can_track,_visible_users. Actions:wp_desktop_presence_recorded(every bump),wp_desktop_presence_changed(transitions only).Storage routes through
createSharedStore('wp-desktop/presence')so any bundle reads the same map. Plugins with a faster delivery
channel (SSE, WebSocket) push directly into the store via
applyBatch().New utilities
renderKeyedList(host, items, options)— keyed listreconciler. Reuses DOM nodes across renders so a click that
spans a repaint (mousedown on old node, mouseup on new) doesn't
silently drop.
hashTitleToHue(title)— moved fromsrc/dock.tstosrc/ui/util/hash-hue.tsso<wpd-avatar>can share it. Nore-export shim from dock.ts — callers import from the
canonical home.
New
<wpd-*>components<wpd-avatar>— image-or-initials user tile. Deterministichue fallback. Optional presence dot in the bottom-end corner;
setting
user-idauto-subscribes towp-desktop-presence-changed.<wpd-textarea>— multi-line sibling of<wpd-text-field>with
auto-grow+max-rowsandsubmit-on-enter.What changed — internals
src/window-channels.ts(new) — storage layer for theunified window-message API. Per-window registries, FIFO
pre-load buffer, subscriber drop on
clearWindowChannels().src/connection/index.ts— connection bridge generalisedfrom "talk to an iframe" to "talk to whatever the window
contains".
getSyntheticIframe()for routing into the bodyiframe of
iframeContentnatives, plusdispatchToNative/dispatchFromWindowwires for pure natives.src/window/iframe-bridge.ts— handles a newwp-desktop-window-publishmessage type from iframe content;calls
markWindowContentReady()onwp-desktop-readysoqueued
Window.send()calls flush.src/iframe-bridge-standalone.ts— installswp.desktop.send/
wp.desktop.oninside chromeless iframes. Same call shape asthe parent-shell API.
src/desktop-icons.ts— full rewrite around the badgesurface. Module-level
_badgesmap is the source of truth;the renderer consults it at build time so badges survive a
live menu refresh for free. Surgical repaint on every
setBadge,early-bail when the id isn't on this rail.
src/dock.ts—rail: 'dock' | 'taskbar'derived fromorientation, stamped onto every
wp-desktop/badge-changedemission. New
badgeOverridesmap preserves client-set badgesacross
replaceItems()(live menu refresh would otherwise dropthem).
removeSystemItemfiresHOOKS.DOCK_ITEM_REMOVEDanddrops its override.
src/window/index.ts—setHighlightfiresHOOKS.WINDOW_HIGHLIGHT_CHANGED. Native render callbacks mayreturn a teardown.
src/recycle-bin/badge.ts— migrated to the new publicAPIs. The 80-line DOM-scrape (
[data-system-id]/[data-icon-id]lookup, hand-rolledapplyBadge,cssEscapepolyfill) is gone; the module is now a 6-linepaintBadgethat fans acrossdock/taskbar/icons.Canonical in-tree consumer of the new framework discipline.
assets/css/dock.css— attention animations(pulse / shake / bounce, three intensities) gated on
prefers-reduced-motion: no-preference.assets/css/window-states.css(new) — extracted statevisuals; includes
wp-desktop-window--shakingkeyframes forWindow.shake().package.json—types: "src/public-api.ts"+exportsmap. The plugin's TypeScript types are consumable as a package.
desktop-mode.php— wiresincludes/presence.php.Removals — clean-plugin sweep
The user wants the plugin clean, not a museum of old APIs. Every
backwards-compat shim that touched the modified surfaces has been
pulled:
Window.iframeSend— removed. The unifiedWindow.send( channel, payload )does the same job (with thesame pre-load FIFO buffering) AND works for pure-native windows.
wpd-dock-item-badge-changedCustomEvent — removed. Activitybus (
wp-desktop/badge-changed) is the single emission point;there is no shadow CustomEvent path.
railis required, notoptional.
hashTitleToHuere-export fromsrc/dock.ts— removed.The canonical home is
src/ui/util/hash-hue.ts; callers importfrom there.
registerBuiltInWallpapers()andsrc/wallpapers/built-in.tsmodule — deleted. The function had been a
@deprecatedno-opsince 0.11.0; the module's only other export
(
BUILT_IN_PRESET_IDS) was unreferenced. Wallpaper presets shipexclusively via PHP.
Several "Experimental" hooks shipped in 0.17.0
(
desktop_mode_settings_tab_*) are also promoted to Stable.Audit findings — legacy still in the codebase
While stripping shims I noticed a few more legacy surfaces that
could go in a follow-up sweep, but each one is woven across PHP /
JS / public docs and deserves its own focused PR rather than a
drive-by removal here:
AiSettings.apiKey(src/settings/types.ts:48) — single-keyfield "treated as the OpenAI key for backwards compat". The
per-provider
apiKeysmap covers the same job; the legacy fieldforces every reader to special-case OpenAI.
past deactivation" — three sync modules implement a "graceful
backwards-compat" path for plugin authors who didn't set
owneror declare ascript. Drop the safety net and requireregistration; plugins that don't opt in lose their UI elements
immediately on deactivation, which is the correct behaviour.
wp.desktop.iframe.publish/subscribe/onConnection— oldiframe-side API, paralleled by
wp.desktop.send/onsince 0.5.5but still installed by the iframe-bridge for the
multi-listener handshake-aware
wp.desktop.connect()flow. Thebridge protocol itself (
wp-desktop-bridge-publish, etc.) isload-bearing; unifying onto the channel bus is a real
refactor, not a delete.
If you'd like any of these in this PR rather than as follow-ups,
shout — they're isolated enough to do cleanly.
Documentation
docs/event-driven-framework.md— the mental model, the three layers, the worked example, the
anti-patterns. Indexed from
docs/README.mdas item ci: makenpm run test:phpactually work #2 (readbefore anything non-trivial).
docs/examples/window-request-attention.md,keyed-list.md,shared-store.md,presence.md.docs/examples/dock-badge.mdrewritten — shows the unifiedfan-to-all-rails pattern, the activity-bus subscription, the
per-rail hook, the "apps own the suppress-while-active rule"
policy.
docs/javascript-reference.md—wp.desktop.iconssection(sibling of
dockandtaskbar);Window.setHighlightcalloutfor
WINDOW_HIGHLIGHT_CHANGED;dockcallout forDOCK_ITEM_REMOVED+ the rail discriminator; +806 lines fromthe 0.5.5 work covering
createSharedStore,activity,heartbeat,presence,Window.send/on,wp.desktop.send/on.docs/hooks-reference.md— adds the entire Presencefilter / action / helper section. Promotes 0.17.0 settings-tab
hooks Experimental → Stable.
CLAUDE.md— adds the "Event-driven framework (since0.5.5)" and "Presence — framework-level (since 0.5.5)" sections
so future contributors don't reinvent UX heuristics inside
framework primitives.
Tests
tests/phpunit/tests/presence.php(218 lines):state-machine transitions, filter veto, visibility narrowing,
action ordering, multisite paths.
tests/vitest/:desktop-icons-badge.test.ts(new) — idempotency, 0 clears,silent no-op on unknown id, activity emission with
rail: 'icon',HOOKS.ICON_BADGE_CHANGEDwithpreviousCount,badge survives full grid rebuild, clamp/floor on bad inputs.
dock-badge.test.ts(new) — left orientation publishesrail: 'dock', bottom publishesrail: 'taskbar', no-op onunknown id,
replaceItemsre-applies client-set badges,setBadge(0)drops the override so server-declared badgeswin,
removeSystemItemfiresHOOKS.DOCK_ITEM_REMOVED.activity.test.ts,connection-bridge.test.ts,heartbeat-bus.test.ts,keyed-list.test.ts,presence.test.ts,shared-store.test.ts,window-channel-bus.test.ts,toast.test.ts— the 0.5.5framework primitive coverage.
Status:
npm run lintclean,tsc --noEmitclean,602 vitest cases pass (one fewer than before because the
removed CustomEvent test went with the shim it covered).
Compatibility
Window.iframeSend, thewpd-dock-item-badge-changedCustomEvent, the optionalrailfield on the activity channel, and the dock-side
hashTitleToHuere-export are all gone. None had real consumers in-tree; any
third-party that depended on them migrates with a one-line
rename.
discriminator (was previously absent — adding it doesn't break
existing subscribers, only completes the contract), and the
three new hooks.
Test plan
npm run lintclean.tsc --noEmitclean.npm run test:js— 602 / 602 passing.npm run test:phpclean.npm run buildrebuilt every bundle (desktop,iframe-bridge,code-editor,recycle-bin).desktop_mode_register_icon(). Callwp.desktop.icons.setBadge( id, 5 ). Confirm the badge paints,survives plugin activate / deactivate cycles, clears on
0.wp-desktop/badge-changedfrom a peer plugin —confirm
railcarries'dock','taskbar','icon'correctly per surface.
Window.setHighlight('persistent')from devtools —confirm
HOOKS.WINDOW_HIGHLIGHT_CHANGEDfires.confirm
HOOKS.DOCK_ITEM_REMOVEDfires.badge updates across both the dock-tile and the wallpaper-icon
paths through the new public APIs (no DOM scraping).