spec(ActivityTray): spec and prototype for background task progress - #1944
Draft
Stephen Watkins (stephenjwatkins) wants to merge 7 commits into
Draft
Stephen Watkins (stephenjwatkins) wants to merge 7 commits into
Stephen Watkins (stephenjwatkins) wants to merge 7 commits into
Conversation
Adds a specification and a working prototype for a corner-docked, non-blocking surface that reports on background work — bulk label purchases, report generation, CSV imports — while the user keeps working. The pattern has no settled industry name, so the spec records the prior art (Google Drive's upload panel, Chrome's download bubble, Salesforce's utility bar) and argues for `TaskTray`. It is not a fancier toast: a toast is a message with a 4-second life and nothing to track, and `Notification`'s singleton queue, mandatory timeout, and `role="status"` all actively work against a task list. The two compose instead. Three decisions shape the API: - The app owns the task list. The tray is controlled and presentational, so Easy UI never holds opinions about polling, auth, or dedupe. - Surviving navigation is a mounting concern, not a feature: mounted above the router, a route change cannot unmount it. Crossing a full page load is the server's problem, and the spec says so rather than pretending `localStorage` solves it. - Composition over a data prop, matching the rest of the package. The accessibility line is that progress is polled and outcomes are pushed. The tray is a named landmark rather than a live region, progress reaches AT through `role="progressbar"` with the real unit counts, and only terminal transitions write into a single polite live region, where same-tick transitions coalesce into one sentence. The prototype has no `index.ts`, so it is not an entry point of the published package and nothing can import it. It lives in Storybook under `Prototypes/TaskTray`. Building it surfaced two gaps worth fixing before this ships publicly, both recorded in the spec: - There is no linear `ProgressBar`. The bar is private to `TaskTray` for now so the public API gets designed on its own terms. - `Spinner` cannot be used decoratively. Indeterminate mode is a `role="status"` live region, and its only label channel renders visible text, so an unlabeled one warns on every render. A private CSS ring stands in.
Stephen Watkins (stephenjwatkins)
requested review from
a team
as code owners
September 28, 2026 16:00
…rames Two problems visible on the docs page. Markdown tables rendered as literal pipes. Storybook's MDX pipeline is plain CommonMark, so GitHub-flavored syntax never compiled; `remark-gfm` wires it up. This also repairs the table in ForgeLayout's docs, which was broken the same way. Every story portaled its tray to `document.body`, so on the docs page all of them docked to the same corner of the real viewport and piled up on top of each other. Stories now render into a bounded frame that establishes a containing block for fixed positioning, and pass it through `getContainer`, so each story gets its own corner. Both are docs-only concerns — in an app the tray goes to the body and docks to the real viewport, which is the point of it. Adds a `ManyTasks` story for the question the pile-up raised: what a dozen concurrent tasks actually looks like, and what the height cap does about it. Collapsing and aggregating like tasks are the answers; a second disclosure inside each row is not, since a row holds one line of detail and hiding it behind a chevron would make a glance cost a click. The spec's aggregation open question now says which of the two places that grouping could live, and what the tray would take on by owning it.
`StoryFrame` handed the tray `() => frameRef.current`, which is null on the first render, so the tray portaled to the body and docked to the real viewport corner. The first re-render—hovering it, via `useHover`—moved the portal into the frame, and the tray vanished from under the cursor. A supplied `getContainer` is now taken at its word, with no fallback to the body, and the frame is held in state so children wait for it.
Stephen Watkins (stephenjwatkins)
force-pushed
the
feat/task-tray
branch
from
September 28, 2026 16:58
8903c4a to
171d399
Compare
Two passes over the prototype. Consistency: the tray now takes the surface `Menu`, `Select`, and `MultiSelect` share through `Menu/_mixins.scss` and `Popover` matches — `neutral.000` on a `neutral.300` hairline, `border_radius.md`, `shadow.overlay`, `space.2` of horizontal padding, `neutral.050` on hover. Scrolling goes through `useScrollbar` with the overlay theme, as `Menu` does, which is why a `div` now wraps the `ul`. Typography drops the TaskTray-only `caption2` for `subtitle2`/`body2`/`caption`, and the status ring borrows `Spinner`'s track color, stroke, and easing rather than turning at its own speed. A single task no longer grows a header. The row already names the work, shows its progress, and carries its actions, so a header could only repeat the title above a disclosure with nothing behind it. Past one task nothing changes. Dismissal focus moves to the tray region, which outlives every row, instead of the header toggle that can now vanish — and landing there holds the remaining timers.
The overlay scrollbar's visible track is 2px, but it sits in an 18px lane whose hit area the handle covers in full — so a progress counter or a dismiss button at the row's edge was both drawn through and unclickable. Rows and the header now reserve that lane as right padding; the header only needs it so its toggle stays in line with the dismiss buttons under it. A row with a progress bar or a description is several lines tall and its status glyph hangs from the first of them. A title-only row has no first line to hang from, so the glyph and the row's actions center against it instead, and the two-pixel nudge that aligned the glyph to a cap height comes back off.
"Task" is the riskier word to claim at the component level, so it moves down a level: the surface is an activity tray, and each row in it is still an `ActivityTray.Task`—a single unit of work rather than the thing holding them. The naming section records the trade, and the name is off the spec's open questions list. The cost of "activity" is that it can read as an audit log. What keeps the two apart is behavior the tray already has: the header counts what is running rather than what has happened, finished rows retire themselves, and there is no scrollback.
The header's status glyph was a size down from a row's, so with the same left padding and the same gap the summary started 4px left of every task title. Matching the row size puts them in one column. Growing the glyph rather than centering a smaller one in a reserved column: that version aligns the text but insets the header's ring 2px from the rows'. Hierarchy between the summary and the rows is already carried by weight, and the collapsed tray keeps a full-size indicator.
Stephen Watkins (stephenjwatkins)
marked this pull request as draft
September 28, 2026 19:00
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.
📝 Changes
https://63f50c7c86f6514d2e0ef4be-mxsjvrjewf.chromatic.com/?path=/docs/prototypes-activitytray--docs
A spec and working prototype for a corner-docked, non-blocking surface that reports on background work — bulk label purchases, report generation, CSV imports — while the user keeps working.
documentation/specs/ActivityTray.mdeasy-ui-react/src/ActivityTray/, in Storybook under Prototypes/ActivityTrayNot a toast. A toast is a message with a 4-second life and nothing to track.
Notification's singleton queue, mandatory timeout, androle="status"all work against a task list, so this doesn't build on it — they compose instead. The pattern has no settled industry name; the spec records the prior art and the naming trade-off.The API
Minimal case — one task is one row, with no header above it:
Real usage — mounted above the router so navigation can't unmount it, fed from whatever already tracks the work:
<ActivityTray />childrenReactNode<ActivityTray.Task />elementsplacement"bottom-end" | "bottom-start" | "top-end" | "top-start""bottom-end"offset{ top?, right?, bottom?, left? }space.4maxVisibleTasksnumber4autoDismissDelaynumber | null6000nulldisablesrenderSummary(running: number, total: number) => ReactNode"2 of 5 tasks running"isExpanded/defaultExpanded/onExpandedChangeboolean/boolean/(isExpanded) => voidtrue/ —aria-labelstring"Background tasks"getContainer() => HTMLElement | null() => document.body<ActivityTray.Task />—title,status(pendingrunningsucceededpartialfailedcanceled),completed+total+unitfor a determinate bar (omittotalfor indeterminate),description,onDismiss, a per-taskautoDismissDelayoverride, and up to two<ActivityTray.Action />children.<ActivityTray.Action />—childrenplus eitheronPressorhref. Running rows get no dismiss button; a Cancel action with a real handler is the honest version of that.Decisions
ActivityTray, notTaskTray. "Task" is the riskier word to claim at the component level, so it moves down a level: the surface is an activity tray, each row in it is a task. The cost is that "activity" can read as an audit log — what keeps them apart is that the header counts what is running, finished rows retire themselves, and there is no scrollback.Accessibility: progress is polled, outcomes are pushed. Named landmark rather than a live region; progress reaches AT through
role="progressbar"with real unit counts; only terminal transitions write into one polite live region, coalescing same-tick finishes into a single sentence.Two gaps this surfaced, both written up under Dependencies:
ProgressBarexists. The bar is private toActivityTrayfor now; extract it before this ships publicly.Spinnercan't be used decoratively — indeterminate mode is arole="status"live region, and its only label channel renders visible text, so an unlabeled one warned on every render. A private CSS ring stands in.No
index.ts, so the prototype is not an entry point of the published package and nothing can import it yet.Worth discussing: the spec's 7 open questions, particularly stacking against
Modal, mobile, and whether cancel needs confirmation.✅ Checklist
Visuals match Design Specs in Figma— no Figma yet; this is the proposalz-index, which needs a newz_index.activity_traytoken and is a literal for nowChangeset is added— nothing published; noindex.tsmeans no entry point🤖 Generated with Claude Code