Skip to content

Event-driven framework, unified window channels, presence (0.5.5) - #41

Merged
AllTerrainDeveloper merged 3 commits into
trunkfrom
feat/comms
Apr 29, 2026
Merged

Event-driven framework, unified window channels, presence (0.5.5)#41
AllTerrainDeveloper merged 3 commits into
trunkfrom
feat/comms

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. The transport — activity channels, heartbeat bus, shared
    stores, unified window-channel API, presence.
  2. The first major framework primitivewp.desktop.presence.*.
  3. The third badge surfacewp.desktop.icons.setBadge,
    completing rail symmetry across dock / taskbar / icons so
    plugin authors can write a single badge wrapper that fans across
    every surface.
  4. Hook-bus symmetry for state transitions previously invisible
    to the framework's event-driven contract — ICON_BADGE_CHANGED,
    DOCK_ITEM_REMOVED, WINDOW_HIGHLIGHT_CHANGED.
  5. Legacy cleanup — every backwards-compat shim introduced or
    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:

  1. The Dock briefly auto-suppressed badges while their window
    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.
  2. Module-level state in one Vite bundle is invisible to
    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:

  1. Wallpaper icons had no badge API. The dock and taskbar each
    shipped setBadge( id, count ) and mirrored on the
    wp-desktop/badge-changed activity channel — but the icon
    rail forced plugins into DOM scraping ([data-icon-id]
    selectors + a hand-rolled <span> decorator). One of three
    rails 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).
    isActive collapses 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 reactive
    store 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) — current
    badge count for an icon; returns 0 for unset ids.

Layer 2 — window lifecycle

  • HOOKS.WINDOW_BLURRED — symmetric counterpart to
    WINDOW_FOCUSED. Fires with { windowId, focusedTo }. Manager
    fires this BEFORE the new window's WINDOW_FOCUSED so order is
    deterministic.
  • HOOKS.WINDOW_HIGHLIGHT_CHANGED (new in 0.24.0) — fires
    on 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?) — typed
    per-window facade. Auto-filters by id. One-shot (default;
    auto-unsubscribes on closed) and { persistent: true } for
    badge-policy-style subscribers that keep firing across every
    open/close cycle.
  • Window.requestAttention(mode, options?) — pulse / shake /
    bounce, three intensities, prefers-reduced-motion fallback.
    Filterable via wp-desktop/window-attention-requested.
  • Native render teardownrender( body ) callbacks may now
    return a function; the shell calls it on close().

Layer 2a — the unified window-self channel

Window.send / Window.on and wp.desktop.send / wp.desktop.on
are 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
:

const win = wp.desktop.windowManager.getById( 'wpdc-editor' );
win.send( 'editor:open-file', { path: 'foo.php', line: 42 } );
const off = win.on( 'editor:saved', repaint );

Iframes go via postMessage; native windows route in-process
through src/window-channels.ts. Plugin authors never branch on
window type and never reach for postMessage directly.

wp.desktop.connect() (peer-to-peer connection bridge) now works
identically 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 the
    ActivityChannelMap interface that plugins augment in their
    own .d.ts.
  • Built-in framework channelswp-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 so
    in-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 five
lines of jQuery boilerplate.

wp.desktop.heartbeat.contribute( 'my-plugin/active', () => isActive() );
wp.desktop.heartbeat.subscribe(  'my-plugin/payload', applySnapshot );

Last-writer-wins for contribute; many subscribers compose for
subscribe; 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 setBadge shape
exactly. Plugin authors write one wrapper and fan across every
surface; the rail that owns the id paints, the others silently
no-op:

function setBadgeEverywhere( id: string, count: number ): void {
    wp.desktop.dock?.setBadge?.(    id, count );
    wp.desktop.taskbar?.setBadge?.( id, count );
    wp.desktop.icons?.setBadge?.(   id, count );
}

Three calls. One painted tile. One activity event. One
hook fire. Properties of every rail's setBadge:

  • Idempotent. Same count twice = no DOM mutation, no re-emit.
  • Silent no-op when the id isn't on the rail. Lets the
    fan-to-all-rails pattern work without triple-emitting.
  • Survives a full grid rebuild. The framework persists the
    badge across plugin activations / live menu refreshes — set
    once, the renderer re-paints from internal state.
  • >99 renders as 99+.

Every applied change publishes:

  • wp-desktop/badge-changed on the activity bus with
    { itemId, count, rail: 'dock' | 'taskbar' | 'icon' }. rail
    is 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.
  • The icon rail also fires HOOKS.ICON_BADGE_CHANGED with
    { iconId, count, previousCount } for callers that only care
    about 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:

Hook Fired by Payload
HOOKS.ICON_BADGE_CHANGED wp.desktop.icons.setBadge { iconId, count, previousCount }
HOOKS.DOCK_ITEM_REMOVED Dock.removeSystemItem (symmetric to DOCK_ITEM_APPENDED) { id, placement: 'dock' | 'taskbar' }
HOOKS.WINDOW_HIGHLIGHT_CHANGED Window.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 list
    reconciler. 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 from src/dock.ts to
    src/ui/util/hash-hue.ts so <wpd-avatar> can share it. No
    re-export shim from dock.ts
    — callers import from the
    canonical home.

New <wpd-*> components

  • <wpd-avatar> — image-or-initials user tile. Deterministic
    hue fallback. Optional presence dot in the bottom-end corner;
    setting user-id auto-subscribes to
    wp-desktop-presence-changed.
  • <wpd-textarea> — multi-line sibling of <wpd-text-field>
    with auto-grow + max-rows and submit-on-enter.

What changed — internals

  • src/window-channels.ts (new) — storage layer for the
    unified window-message API. Per-window registries, FIFO
    pre-load buffer, subscriber drop on clearWindowChannels().
  • src/connection/index.ts — connection bridge generalised
    from "talk to an iframe" to "talk to whatever the window
    contains". getSyntheticIframe() for routing into the body
    iframe of iframeContent natives, plus dispatchToNative /
    dispatchFromWindow wires for pure natives.
  • src/window/iframe-bridge.ts — handles a new
    wp-desktop-window-publish message type from iframe content;
    calls markWindowContentReady() on wp-desktop-ready so
    queued Window.send() calls flush.
  • src/iframe-bridge-standalone.ts — installs wp.desktop.send
    / wp.desktop.on inside chromeless iframes. Same call shape as
    the parent-shell API.
  • src/desktop-icons.ts — full rewrite around the badge
    surface. Module-level _badges map 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.tsrail: 'dock' | 'taskbar' derived from
    orientation, stamped onto every wp-desktop/badge-changed
    emission. New badgeOverrides map preserves client-set badges
    across replaceItems() (live menu refresh would otherwise drop
    them). removeSystemItem fires HOOKS.DOCK_ITEM_REMOVED and
    drops its override.
  • src/window/index.tssetHighlight fires
    HOOKS.WINDOW_HIGHLIGHT_CHANGED. Native render callbacks may
    return a teardown.
  • src/recycle-bin/badge.ts — migrated to the new public
    APIs. The 80-line DOM-scrape ([data-system-id] /
    [data-icon-id] lookup, hand-rolled applyBadge,
    cssEscape polyfill) is gone; the module is now a 6-line
    paintBadge that fans across dock / 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 state
    visuals; includes wp-desktop-window--shaking keyframes for
    Window.shake().
  • package.jsontypes: "src/public-api.ts" + exports
    map. The plugin's TypeScript types are consumable as a package.
  • desktop-mode.php — wires includes/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 unified
    Window.send( channel, payload ) does the same job (with the
    same pre-load FIFO buffering) AND works for pure-native windows.
  • wpd-dock-item-badge-changed CustomEvent — removed. Activity
    bus (wp-desktop/badge-changed) is the single emission point;
    there is no shadow CustomEvent path. rail is required, not
    optional.
  • hashTitleToHue re-export from src/dock.ts — removed.
    The canonical home is src/ui/util/hash-hue.ts; callers import
    from there.
  • registerBuiltInWallpapers() and src/wallpapers/built-in.ts
    module
    — deleted. The function had been a @deprecated no-op
    since 0.11.0; the module's only other export
    (BUILT_IN_PRESET_IDS) was unreferenced. Wallpaper presets ship
    exclusively 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-key
    field "treated as the OpenAI key for backwards compat". The
    per-provider apiKeys map covers the same job; the legacy field
    forces every reader to special-case OpenAI.
  • "Untagged commands / settings tabs / title-bar buttons survive
    past deactivation"
    — three sync modules implement a "graceful
    backwards-compat" path for plugin authors who didn't set
    owner or declare a script. Drop the safety net and require
    registration; plugins that don't opt in lose their UI elements
    immediately on deactivation, which is the correct behaviour.
  • wp.desktop.iframe.publish/subscribe/onConnection — old
    iframe-side API, paralleled by wp.desktop.send/on since 0.5.5
    but still installed by the iframe-bridge for the
    multi-listener handshake-aware wp.desktop.connect() flow. The
    bridge protocol itself (wp-desktop-bridge-publish, etc.) is
    load-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

  • NEW docs/event-driven-framework.md
    — the mental model, the three layers, the worked example, the
    anti-patterns. Indexed from docs/README.md as item ci: make npm run test:php actually work #2 (read
    before anything non-trivial).
  • NEW docs/examples/window-request-attention.md,
    keyed-list.md,
    shared-store.md,
    presence.md.
  • docs/examples/dock-badge.md rewritten — shows the unified
    fan-to-all-rails pattern, the activity-bus subscription, the
    per-rail hook, the "apps own the suppress-while-active rule"
    policy.
  • docs/javascript-reference.mdwp.desktop.icons section
    (sibling of dock and taskbar); Window.setHighlight callout
    for WINDOW_HIGHLIGHT_CHANGED; dock callout for
    DOCK_ITEM_REMOVED + the rail discriminator; +806 lines from
    the 0.5.5 work covering createSharedStore, activity,
    heartbeat, presence, Window.send/on, wp.desktop.send/on.
  • docs/hooks-reference.md — adds the entire Presence
    filter / action / helper section. Promotes 0.17.0 settings-tab
    hooks Experimental → Stable.
  • CLAUDE.md — adds the "Event-driven framework (since
    0.5.5)" and "Presence — framework-level (since 0.5.5)" sections
    so future contributors don't reinvent UX heuristics inside
    framework primitives.

Tests

  • PHPUnittests/phpunit/tests/presence.php (218 lines):
    state-machine transitions, filter veto, visibility narrowing,
    action ordering, multisite paths.
  • Vitest — full new suite under 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_CHANGED with previousCount,
      badge survives full grid rebuild, clamp/floor on bad inputs.
    • dock-badge.test.ts (new) — left orientation publishes
      rail: 'dock', bottom publishes rail: 'taskbar', no-op on
      unknown id, replaceItems re-applies client-set badges,
      setBadge(0) drops the override so server-declared badges
      win, removeSystemItem fires HOOKS.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.5
      framework primitive coverage.

Status: npm run lint clean, tsc --noEmit clean,
602 vitest cases pass (one fewer than before because the
removed CustomEvent test went with the shim it covered).

Compatibility

  • BreakingWindow.iframeSend, the
    wpd-dock-item-badge-changed CustomEvent, the optional rail
    field on the activity channel, and the dock-side hashTitleToHue
    re-export are all gone. None had real consumers in-tree; any
    third-party that depended on them migrates with a one-line
    rename.
  • Pure-additive for the icon rail (new API surface), the rail
    discriminator (was previously absent — adding it doesn't break
    existing subscribers, only completes the contract), and the
    three new hooks.

Test plan

  • npm run lint clean.
  • tsc --noEmit clean.
  • npm run test:js — 602 / 602 passing.
  • npm run test:php clean.
  • npm run build rebuilt every bundle (desktop,
    iframe-bridge, code-editor, recycle-bin).
  • Manual QA in the Docker host:
    • Register an icon via desktop_mode_register_icon(). Call
      wp.desktop.icons.setBadge( id, 5 ). Confirm the badge paints,
      survives plugin activate / deactivate cycles, clears on 0.
    • Subscribe to wp-desktop/badge-changed from a peer plugin —
      confirm rail carries 'dock', 'taskbar', 'icon'
      correctly per surface.
    • Trigger Window.setHighlight('persistent') from devtools —
      confirm HOOKS.WINDOW_HIGHLIGHT_CHANGED fires.
    • Unregister a system tile via the live menu refresh path —
      confirm HOOKS.DOCK_ITEM_REMOVED fires.
    • Recycle Bin badge: cold-load, trash an item, restore it — the
      badge updates across both the dock-tile and the wallpaper-icon
      paths through the new public APIs (no DOM scraping).
Open WordPress Playground Preview

AllTerrainDeveloper and others added 3 commits April 29, 2026 18:45
…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>
@AllTerrainDeveloper
AllTerrainDeveloper merged commit 6a7463f into trunk Apr 29, 2026
7 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the feat/comms branch April 29, 2026 22:30
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.

1 participant