Skip to content

spec(ActivityTray): spec and prototype for background task progress - #1944

Draft
Stephen Watkins (stephenjwatkins) wants to merge 7 commits into
mainfrom
feat/task-tray
Draft

Stephen Watkins (stephenjwatkins) wants to merge 7 commits into
mainfrom
feat/task-tray

Conversation

@stephenjwatkins

@stephenjwatkins Stephen Watkins (stephenjwatkins) commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

📝 Changes

https://63f50c7c86f6514d2e0ef4be-mxsjvrjewf.chromatic.com/?path=/docs/prototypes-activitytray--docs

image

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.

  • Spec: documentation/specs/ActivityTray.md
  • Prototype: easy-ui-react/src/ActivityTray/, in Storybook under Prototypes/ActivityTray

Not a toast. A toast is a message with a 4-second life and nothing to track. Notification's singleton queue, mandatory timeout, and role="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:

import { ActivityTray } from "@easypost/easy-ui/ActivityTray";

<ActivityTray>
  <ActivityTray.Task
    title="Buying labels"
    status="running"
    completed={127}
    total={250}
    unit="labels"
  />
</ActivityTray>;

Real usage — mounted above the router so navigation can't unmount it, fed from whatever already tracks the work:

function BackgroundActivity() {
  const { tasks, cancel, retry, dismiss } = useBackgroundJobs();

  return (
    <ActivityTray placement="bottom-end" offset={{ bottom: "72px" }}>
      {tasks.map((task) => (
        <ActivityTray.Task
          key={task.id}
          title={task.title}
          status={task.status}
          completed={task.completed}
          total={task.total}
          unit="labels"
          description={task.description}
          onDismiss={() => dismiss(task.id)}
        >
          {task.status === "running" && (
            <ActivityTray.Action onPress={() => cancel(task.id)}>
              Cancel
            </ActivityTray.Action>
          )}
          {task.status === "failed" && (
            <ActivityTray.Action onPress={() => retry(task.id)}>
              Retry
            </ActivityTray.Action>
          )}
          {task.status === "partial" && (
            <ActivityTray.Action href={`/shipments?batch=${task.id}`}>
              Review
            </ActivityTray.Action>
          )}
        </ActivityTray.Task>
      ))}
    </ActivityTray>
  );
}

<ActivityTray />

Prop Type Default
children ReactNode — <ActivityTray.Task /> elements
placement "bottom-end" | "bottom-start" | "top-end" | "top-start" "bottom-end" Corner it docks to
offset { top?, right?, bottom?, left? } space.4 Clears app chrome — a sticky footer, a support widget
maxVisibleTasks number 4 Height cap in rows, then it scrolls. Applies from the second task on
autoDismissDelay number | null 6000 Held while the pointer or focus is in the tray; null disables
renderSummary (running: number, total: number) => ReactNode "2 of 5 tasks running" Header text. Never called with one task
isExpanded / defaultExpanded / onExpandedChange boolean / boolean / (isExpanded) => void — / true / — Inert with a single task, where there's nothing to collapse
aria-label string "Background tasks" Names the landmark
getContainer () => HTMLElement | null () => document.body Portal target. Nothing renders until it returns an element

<ActivityTray.Task /> — title, status (pending running succeeded partial failed canceled), completed + total + unit for a determinate bar (omit total for indeterminate), description, onDismiss, a per-task autoDismissDelay override, and up to two <ActivityTray.Action /> children.

<ActivityTray.Action /> — children plus either onPress or href. Running rows get no dismiss button; a Cancel action with a real handler is the honest version of that.

Decisions

  • ActivityTray, not TaskTray. "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.
  • The app owns the task list. The tray is controlled and presentational, so Easy UI holds no opinions about polling, auth, or dedupe.
  • Surviving navigation is a mounting concern, not a feature — mounted above the router, a route change can't unmount it. Crossing a full page load is the server's problem.
  • Composition over a data prop, matching the rest of the package.
  • A single task is the whole tray. No header, no disclosure: the row already names the work, shows its progress, and carries its actions.

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:

  1. No linear ProgressBar exists. The bar is private to ActivityTray for now; extract it before this ships publicly.
  2. Spinner can't be used decoratively — indeterminate mode is a role="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 proposal
  • Stories accompany any component changes
  • Code is in accordance with our style guide
  • Design tokens are utilized — except z-index, which needs a new z_index.activity_tray token and is a literal for now
  • Unit tests accompany any component changes
  • TSDoc is written for any API surface area
  • Specs are up-to-date
  • Console is free from warnings
  • No accessibility violations are reported — not yet run against the stories; needed before this is promoted
  • Cross-browser check is performed (Chrome, Safari, Firefox) — not yet done
  • Changeset is added — nothing published; no index.ts means no entry point

🤖 Generated with Claude Code

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.
…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.
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.
@stephenjwatkins Stephen Watkins (stephenjwatkins) changed the title spec(TaskTray): spec and prototype for background task progress spec(ActivityTray): spec and prototype for background task progress Sep 28, 2026
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.
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