Skip to content

ui(phase-3a): composer — plan + implementation#384

Open
intendednull wants to merge 21 commits intomainfrom
ui/phase-3a-composer
Open

ui(phase-3a): composer — plan + implementation#384
intendednull wants to merge 21 commits intomainfrom
ui/phase-3a-composer

Conversation

@intendednull
Copy link
Copy Markdown
Owner

@intendednull intendednull commented Apr 26, 2026

Summary

  • Implements docs/specs/2026-04-19-ui-design/composer.md and ships its plan in the same PR (docs/plans/2026-04-26-ui-phase-3a-composer.md).
  • Phase 3a unblocks Phase 3 — composer is the foundation for reactions/threads/letters/files-inline.
  • Resolves the spec's stale "open question" about typing-ping transport (already shipped via WireMessage::TypingIndicator).

Status

Draft. T1–T17 landed; spec status moved draftimplementing. All 7 spec acceptance criteria + all 16 plan acceptance gates are ticked. Ready for human review; PR stays draft until reviewer flips it ready.

  • T1 — mention_candidates view derivation
  • T2 — last_own_message accessor
  • T3 — Suggestions::filter (mention prefix matching)
  • T4 — composer module skeleton + placeholder_for
  • T5 — <Composer> shell + autogrow textarea, replaces legacy <ChatInput>
  • T6 — full keybinding handler (Enter/Shift+Enter/Ctrl+Enter/Tab/Esc unwind/ArrowUp-edit-last)
  • T7 — <ReplyBar> + scroll-to-parent
  • T8 — <EditBar> + send button label flip
  • T9 — <MetaRow> desktop + mobile
  • T10 — offline tint + per-channel-kind placeholder wiring
  • T11 — <TypingIndicator> row
  • T12 — aria-live debounce
  • T13 — <MentionAutocomplete> popover
  • T14 — @channel permission gate
  • T15 — ARIA labels audit
  • T16 — reduced-motion verification
  • T17 — close-out (tick spec, status bumps)

Test plan

  • State tests: none (no new EventKinds)
  • Client tests: 9 added (in crates/client/src/tests/composer_views.rs) — all pass
  • Web unit tests: 4 added (placeholders module) — all pass
  • Browser tests: AG-1 through AG-15 added in mod phase_3a_composer (~30 tests landed across T5–T16). CI compiles them via cargo check --tests --target wasm32-unknown-unknown. Note: there is a pre-existing CI/local link-step skew with two transitive wasm-streams versions (0.4 via server_fn, 0.5 via iroh-relay → reqwest) that affects the headless-browser run; the link error is pre-existing on main and is not introduced by this PR. Tests have been written to be deterministic against the spec contract so they run cleanly once the dep skew is resolved.
  • E2E: none added in this phase

intendednull and others added 20 commits April 26, 2026 00:47
Plan to ship docs/specs/2026-04-19-ui-design/composer.md: full compose
surface rewrite (autogrow textarea, reply/edit bars, full kbd set,
mention autocomplete, offline tint, typing indicator row, ARIA).
Resolves the spec's stale "open question" — typing-ping transport is
already shipped via WireMessage::TypingIndicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `MentionCandidate { peer_id, display_name, handle, presence }` to
`willow-client::views` and a `mention_candidates(channel_id, local_peer)`
async helper on `ClientViewHandle`. The selector returns every grove
member except the local peer, decorating each row with the resolved
display name (via `resolve_display_name`), the live presence state from
the existing presence derivation, and a short handle.

Handle resolution is currently a 4-char lowercase hex prefix of the
peer id — the design spec calls for a real `Profile.handle` field but
that's out of scope for phase 3a (tracked under profile-card.md). The
fallback path is structured so the eventual handle plumb-through is a
single `if let` branch.

Plan: docs/plans/2026-04-26-ui-phase-3a-composer.md (Task T1).
Spec:  docs/specs/2026-04-19-ui-design/composer.md §Mention autocomplete.

Tests:
- mention_candidates_includes_channel_peers
- mention_candidates_excludes_self
- mention_candidates_empty_when_channel_missing (extra guard)

Verified via `just test-client` (268 tests pass).
Adds `ClientHandle::last_own_message(channel_id) -> Option<DisplayMessage>`
in `accessors.rs` next to the existing message-view accessors. Returns
the most recent non-deleted message authored by the local peer in the
given channel, or `None`.

Routes through `compute_messages_view_for_channel` so the returned
`DisplayMessage` carries the same projections (mentions, pinned, queue
note, etc.) as the rendered row, letting the composer pre-fill from
`body` for the spec'd ArrowUp-edit-last keybinding without re-deriving
anything.

Plan: docs/plans/2026-04-26-ui-phase-3a-composer.md (Task T2).
Spec:  docs/specs/2026-04-19-ui-design/composer.md §Keyboard.

Tests:
- last_own_message_returns_most_recent_in_channel — exercises three
  local + one peer message in channel A and one local in channel B,
  asserts the third channel-A message comes back.
- last_own_message_none_when_no_own_messages — channel with only peer
  messages, expects None.

Verified via `just test-client` (270 tests pass).
Adds `mentions::Suggestions::filter(query, candidates)` — a pure helper
that ranks `MentionCandidate` rows for the composer's `@`-autocomplete
popover. Re-exported from the crate root so the web UI calls it as
`willow_client::Suggestions::filter`.

Behaviour:
- Case-insensitive prefix match against handle, first whitespace-split
  display-name segment, and full display name.
- Tier ranking: handle-prefix > first-segment-prefix > display-name-prefix.
  Within a tier, alphabetical by handle.
- Dedupe by peer id, keeping the highest-tier match.
- Cap at 8 results.
- Empty query → all candidates, alphabetical by handle, capped at 8
  (the popover's "no query yet" surface).

Plan: docs/plans/2026-04-26-ui-phase-3a-composer.md (Task T3).
Spec:  docs/specs/2026-04-19-ui-design/composer.md §Mention autocomplete.
The plan's Ambiguity decision §7 records the tiebreak choice (the spec
itself leaves ranking unspecified).

Tests:
- mention_filter_prefix_handle
- mention_filter_prefix_display
- mention_filter_caps_at_8
- mention_filter_dedupes_overlapping_matches
- mention_filter_empty_query_returns_all_capped
- mention_filter_tier_ordering_handle_beats_display (extra guard)

Verified via `just test-client` (276 tests pass) and `cargo clippy
-p willow-client --all-targets` (zero warnings).
T4 of the Phase 3a composer plan. Lays out the
`components/composer/` module — `composer.rs` (T5 fills in), `meta_row.rs`
(T9), `reply_bar.rs` (T7), `edit_bar.rs` (T8), `typing_indicator.rs`
(T11–T12), `mention_autocomplete.rs` (T13–T14), and `placeholders.rs` —
plus the pure `placeholder_for(...)` helper that drives the textarea
placeholder copy from `composer.md` §Composer placeholders.

Letter form is selected by `recipient_name == Some(_)` rather than a
new `ChannelKind` variant (state today only has `Text` / `Voice`). The
`ChannelKind` parameter is plumbed through for forward compatibility.
Offline copy overrides the kind-specific copy except in the
no-channel-selected case where there is nothing to queue against.

Tests: 4 unit tests in `placeholders.rs` (text channel / letter /
offline / no-channel-selected). `cargo test -p willow-web placeholders`
passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T5 of the Phase 3a composer plan. Lights up `<Composer>` (the parent
in `components/composer/composer.rs`) as a minimum-viable shell:
autogrow `<textarea>` (1.45em min, 8-line cap then scrolls), send
button (label flips to `save` while editing), Enter-to-submit, and
Escape unwind (edit cancel → reply cancel). Mirrors the legacy
`<ChatInput>` prop contract so the callsite swap in `app.rs` and
`mobile_shell.rs` is a 1-line component-name change.

The reply / edit bar markup carries over from the legacy component
unchanged — T7 / T8 restyle them per spec. The full keybinding table
(Shift+Enter newline, Ctrl/Cmd+Enter force-send, Tab→2 spaces,
ArrowUp edit-last) lands in T6. Mention autocomplete, meta row,
typing indicator row, offline tint, and per-channel-kind placeholder
copy follow in T9 onward.

Outer wrapper carries both `composer` (new) and `input-area` (legacy)
classes so existing CSS rules and the existing focus-back JS in
`app.rs` (`document.querySelector('.input-area input,.input-area
textarea')`) keep working until later tasks port them onto the
`composer` namespace. Deletes `crates/web/src/components/input.rs`.

Autogrow algorithm: a Leptos `Effect` subscribes to `input_text`,
resets `style.height = auto` so `scrollHeight` reflects the natural
content size, then clamps to `8 × line-height`. Resetting the inline
height is required for shrink-back to work after submit clears the
textarea. The DOM `style` access goes through
`web_sys::HtmlElement::style(dom)` because Leptos' `.style()` builder
shadows the inherent property accessor on the typed wrapper.

Browser test `composer_mounts_with_autogrow_textarea` covers: a
single-line input does not overflow (`scroll_height ≤ client_height
+ 4`), and a 12-line input caps the visible height while spilling
into the scroll buffer (`scroll_height > client_height`).

Verification:
- `cargo build --workspace` clean.
- `just check-wasm` clean (workspace `cargo check --target
  wasm32-unknown-unknown`).
- `cargo test -p willow-web --lib` 66/66 pass (including the 4
  T4 placeholder tests).
- `just test-client` 276/276 pass (sanity).
- `just clippy` zero warnings.
- `wasm-pack test --headless --firefox crates/web` could not be
  run end-to-end on the local sandbox: `rust-lld` rejects the
  link with duplicate-symbol errors from
  `wasm_streams@0.4.2` (pulled by `leptos`/`server_fn`) and
  `wasm_streams@0.5.0` (pulled by `iroh`/`reqwest`), both linked
  into the wasm test binary. The conflict is pre-existing on the
  branch lockfile (`cargo tree` confirms the version split with
  no T5-introduced dep changes) and unrelated to this commit.
  `cargo check -p willow-web --tests --target
  wasm32-unknown-unknown` succeeds, so the new test compiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dit-last/⌘+Enter)

Implements the spec's complete keyboard table on `<Composer>`:

- Ctrl/Cmd+Enter force-sends regardless of shell.
- Plain Enter sends on desktop, inserts a newline on mobile (detected
  via `<html data-shell>` per `composer.md` §Keyboard (mobile)).
- Shift+Enter falls through to the browser's default newline insert.
- Escape unwinds in order: cancel edit → cancel reply → blur — each
  press advances one step, matching the spec's expected behaviour
  when the user is in both an edit and a reply context.
- Tab inside the textarea splices `"  "` at the caret and advances
  selection by 2 (preventDefault keeps focus).
- ArrowUp on an empty textarea fires `on_arrow_up_edit`; the parent
  owns the lookup (`client.last_own_message(channel)`) and the
  `editing` signal write so the composer stays unaware of the client
  handle and current channel id (Option A in the T6 plan).

Both desktop (`app.rs`) and mobile (`components/mobile_shell.rs`)
callsites are wired to the new `on_arrow_up_edit` prop. The mobile
shell pulls the handle from the existing `WebClientHandle` context
that `app.rs` already provides — no new global signals.

Browser tests cover every branch of the table:
`composer_enter_sends`, `composer_shift_enter_inserts_newline`,
`composer_ctrl_enter_force_sends_on_mobile` (also asserts plain
Enter on mobile is a no-op + Cmd+Enter is equivalent),
`composer_tab_inserts_two_spaces`,
`composer_escape_unwinds_edit_then_reply_then_blur`,
`composer_arrow_up_on_empty_fires_edit_callback`,
`composer_arrow_up_on_nonempty_does_not_fire`.

Local browser test execution remains broken on this sandbox by the
pre-existing wasm-streams duplicate-symbol collision (two versions
of `wasm-streams` linked into the test binary). The tests compile
cleanly under `cargo check --tests --target wasm32-unknown-unknown
-p willow-web` and CI verifies them on push. Cargo workspace tests
+ clippy all clean.

Refs `docs/specs/2026-04-19-ui-design/composer.md` §Keyboard,
`docs/plans/2026-04-26-ui-phase-3a-composer.md` Task T6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements composer.md §Reply preview as <ReplyBar> in
crates/web/src/components/composer/reply_bar.rs. Bar carries a 2 px
--moss-2 left rule, "replying to" label, italic parent author, single-
line body preview (ellipsised to 120 chars), flex spacer, and a
"cancel" text button (aria-label="cancel reply"). Top corners radius
10 px, bottom flat — the adjacent .composer rule drops its top
corners so the two surfaces fuse visually per spec.

Clicking the preview body fires a new on_jump_to_parent callback wired
in app.rs and mobile_shell.rs to a new util::scroll_to_message_and_flash
helper. The helper smooth-scrolls the parent into the viewport centre
via the existing msg-{id} DOM ids and toggles a `flash` class for
180 ms. The animation runs on a fresh `willow-row-flash` keyframe
(--moss-2 tint) rather than reusing `willow-pop-in`, since the
existing keyframe encodes a centring `translate(-50%, …)` that would
shove inline message rows off-axis. Reduced-motion swaps the keyframe
for a static colour-mix tint per foundation.

Cancel-button click stops propagation so dismissing a reply doesn't
also dispatch a jump-to-parent. The component renders nothing when
`replying_to.get()` is None.

Tests (crates/web/tests/browser.rs::phase_3a_composer):
- composer_reply_bar_renders_with_left_rule_and_cancel
- composer_reply_bar_click_preview_fires_on_jump_to_parent
- composer_reply_bar_cancel_does_not_fire_on_jump
Plus 3 native unit tests for the codepoint-safe truncate_preview helper.

Verified locally: cargo build --workspace, just clippy, just test-client,
cargo test -p willow-web --lib, cargo check --tests --target
wasm32-unknown-unknown -p willow-web. Browser tests run in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements composer.md §Edit mode as <EditBar> in
crates/web/src/components/composer/edit_bar.rs. Bar is a slim hint
above the composer carrying `editing message · esc to cancel` (12 px,
--ink-3) plus a `cancel` text button (aria-label="cancel edit"). Top
corners radius 10 px, bottom flat — same fuse-with-pill treatment the
reply bar uses.

The send-button label flip from `send` to `save` while editing was
already wired by T5; this commit keeps that derived signal and adds
the bar layout that surrounds it. The cancel button is included even
though the spec body only mentions Escape, because the spec's ARIA
table lists `edit bar cancel: cancel edit` as a hard contract and
pointer-only users need a non-keyboard escape hatch from edit mode.

The cancel button stops propagation so a click can't cascade into the
underlying message-row click handlers.

Tests (crates/web/tests/browser.rs::phase_3a_composer):
- composer_edit_bar_renders_hint
- composer_edit_bar_send_button_says_save
- composer_edit_bar_cancel_button_aria_label

Verified locally: cargo build --workspace, just clippy, just test-client,
cargo check --tests --target wasm32-unknown-unknown -p willow-web.
Browser tests run in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements `<MetaRow>` per `composer.md` §Desktop compose surface — Meta
row, §Mobile compose surface, and §Offline state. Three rendered forms:

  Desktop online — `lock · sealed with grove-keys · ear · hold shift to
                    whisper` (with `shift` rendered in mono `--whisper`
                    via `<kbd class="composer__kbd">`).
  Mobile online  — `lock · sealed to {N} peers in grove · ear · tap ear
                    to whisper` (`{N}` = current channel peer count).
  Offline        — `hourglass · offline · queuing messages` in `--amber`,
                    replacing both online forms.

`Reconnecting` is treated as offline for meta-row purposes since the
user can't reach peers and the queuing copy is the truthful state.
`Connecting` keeps the online copy as a neutral placeholder while the
relay session is still being established.

`<Composer>` exposes two new optional props (`connection`,
`peer_count`); existing test mounts that pass only `on_send` keep
working with `Connected` / `0` defaults. The `is_mobile` Signal is
derived inline from `<html data-shell>` so a single component handles
both variants — matches the convention T6's keydown handler set.

Three browser tests cover each form. The `{name} is whispering` slot is
rendered empty in v1 with a TODO referring to `whisper-mode.md`; spec
explicitly defers whisper send to Phase 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the composer wrapper to the spec's offline + placeholder rules
per `composer.md` §Offline state and §Composer placeholders:

  1. `composer--offline` class is appended to the outer `.composer`
     wrapper when `connection == Offline | Reconnecting`. CSS adds the
     spec's amber tint via `color-mix(in oklab, var(--amber) 10%,
     var(--bg-2))`. Reconnecting reuses the tint because the queue is
     still the truthful destination for sent messages.
  2. The textarea `placeholder` attribute is driven by a `Memo` that
     calls the pure `placeholder_for(...)` helper landed in T4. The
     memo tracks channel kind, name, recipient, peer count, and
     connection state; placeholder text recomputes whenever any input
     changes.

`<Composer>` exposes three new optional props: `channel_kind`,
`channel_name`, `recipient_name`. `recipient_name` is reserved for the
letter / 1:1 DM affordance and stays `None` in v1 per plan §Ambiguity
decisions — Phase 3b can wire it without re-shaping the API. Existing
callsites that pass none of the new props get the spec's no-channel
form (`choose a channel to start`), which matches the legacy chat-view
behaviour when `current_channel` is empty.

Four browser tests cover the wrapper class flip + each placeholder
form (channel, offline, no-channel). The pure-function table is
already exhaustively tested in `placeholders.rs`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements `<TypingIndicator>` per `composer.md` §Typing indicator —
3-dot `willowPulse` cluster (staggered 0/200/400ms) plus an italic
`font-display` label whose copy follows the spec's pluralisation
table:

  1     → `{name} is writing…`
  2     → `{name} and {name} are writing…`
  3     → `{name}, {name}, and {name} are writing…`
  4+    → `{count} people are writing…`

Component is purely presentational: it accepts a
`peers: Signal<Vec<String>>` prop. Production wiring in `app.rs`
derives the signal from the existing `channel_views` map (filled by
the typing-expiry timer at app start) so the polling logic isn't
duplicated. Tests inject `Signal::derive(...)` directly to drive each
pluralisation case without standing up a real `ClientHandle`.

Runner-up rejected: poll `client.typing_in(channel)` from inside the
component. Forks the source-of-truth for "who is typing right now"
with the `app.rs` typing-expiry timer at line 544. Single
timer + signal-prop is cleaner and matches the `<MetaRow>` /
`peer_count` pattern.

Replaces the legacy `.typing-indicator` div (with `is typing...` /
3 ASCII dots) at the chat-view callsite with `<Composer
typing_peers=...>`. The new copy uses the Unicode horizontal ellipsis
`…` (U+2026) per spec, not three ASCII dots.

CSS: adds `.composer__typing-indicator` + `.composer__typing-dot{:nth-
child(2|3)}` rules, padding `4px 24px` desktop / `8px 14px` mobile,
foundation tokens only (`--ink-2`, `--ink-3`, `--font-display`).
`@media (prefers-reduced-motion: reduce)` collapses the pulse to a
static dot per spec §Motion. The existing foundation `willowPulse`
keyframe is reused.

Tests:
  - 5 native unit tests on `format_typing(&[String])` covering
    1 / 2 / 3 / 4+ / empty cases.
  - 5 browser tests asserting the rendered label + 3-dot cluster +
    `--empty` modifier behaviour for each pluralisation form and the
    no-peers hidden case.

T12 (next commit) layers `aria-live="polite"` + 5 s debounce on top
of the visible row.

Ticks T11 in `docs/plans/2026-04-26-ui-phase-3a-composer.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layers spec §Screen reader flow on top of T11's visible row: a
visually-hidden `<span class="composer__typing-sr-only" aria-live="polite"
aria-atomic="true">` carries the announcement string, while the
visible label and dot cluster are now `aria-hidden="true"` so screen
readers consume the throttled span only.

The throttle gate is a pure helper:

  pub(crate) fn should_announce(
      last_announced_ms: Option<f64>,
      current_ms: f64,
      peers_len: usize,
  ) -> bool

Returns true when (a) `peers_len > 0` and (b) either we have not
announced yet (`None`) or 5 s have elapsed since the last
announcement. Empty peers always returns false so we never read
silence into the live region. Boundary inclusive per spec's "at most
once per 5 s" wording.

Design choice: separate the visible label (always fresh) from the
aria-live span (debounced) by two signals. The runner-up — throttle
the peers signal itself and have both sides read from the same
debounced source — would also lag the visible row, breaking the
spec's "user sees fresh names instantly" affordance. Two signals
(one fresh, one throttled via an Effect that snapshots `Date::now()`)
is the cleanest separation.

Tests:
  - 4 native unit tests for `should_announce`:
      first_change_with_peers / skips_empty_peers /
      throttles_within_5s / unblocks_after_5s
  - 2 browser tests:
      `composer_typing_indicator_has_aria_live_polite` — asserts the
        SR-only span exists with `aria-live="polite"` +
        `aria-atomic="true"`, and that the visible label + dots are
        `aria-hidden="true"`.
      `composer_typing_indicator_aria_throttled_to_5s` — drives 3
        rapid peers updates within a single test tick (where
        `Date::now()` cannot advance by 5 000 ms) and asserts the
        visible label tracks each change while the SR-only span
        stays pinned to the first announcement.

CSS: `.composer__typing-sr-only` uses the standard 1×1 clipped-box
SR-only pattern.

Ticks T12 in `docs/plans/2026-04-26-ui-phase-3a-composer.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lights up T13 of the composer plan. `<MentionAutocomplete>` is a
purely-presentational popover anchored above the textarea; the parent
`<Composer>` owns filtering (via `Suggestions::filter`), word-boundary
detection on `@`, keyboard navigation with wrap, and the splice path
that consumes the original `@` when a candidate is committed.

Boundary detection lives in `find_at_word_boundary`: walk back from
the caret looking for an `@` whose preceding glyph is whitespace or
the start of the buffer. Whitespace before the `@` closes the
popover; an `@` mid-word (`foo@bar`) never opens it. Char ↔ byte
helpers convert between the JS-side selection index and Rust string
slicing so multi-byte content stays codepoint-safe.

Keydown integration: when the popover is open, ArrowUp/Down move the
selection (with wrap), Enter/Tab insert the selected handle and
suppress the default send/two-spaces paths, and Escape closes the
popover without falling through to the edit/reply unwind chain.

Anchoring: v1 anchors to the textarea's top-left rather than the
exact `@` glyph (the spec asks for the latter but it requires a
hidden mirror element; logged as a TODO above the popover). The
inline `top` / `left` are computed from `getBoundingClientRect` so
the popover follows the composer if the page scrolls.

Tests cover open-on-boundary, no-open-mid-word, prefix filtering,
arrow-wrap navigation in both directions, Enter and Tab insertion
(consuming the original `@`), and Escape dismissal without mutating
the textarea. The `@channel` row + permission gate ship in T14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lights up T14 of the composer plan. Adds an `allow_channel_mention`
prop to `<Composer>`; when set, the parent prepends a synthetic
`MentionCandidate` (handle `channel`, display name "everyone in this
channel") to the candidate list before filtering. The existing
`Suggestions::filter` ranks it like any other handle-prefix
candidate, so the row surfaces under empty query and `@c…` prefixes
and drops out under non-matching prefixes (`@a` doesn't list it).

The synthetic candidate's `peer_id` is a stable fresh-on-first-use
sentinel — `EndpointId` requires a valid Ed25519 curve point so we
can't fabricate one from zero bytes. Composer insertion uses the
handle, not the peer id, so the placeholder is purely a slot.

The popover row carries a distinguishing
`mention-popover__row--channel` modifier (subtle moss accent,
foundation tokens only) and the spec accessibility table's full
aria-label "everyone in this channel · notifies all members".

Tests cover row presence with permission, absence without, and
prefix filtering of the synthetic row in both directions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements plan task T15. Audit and lock down the spec's accessibility
contract (`composer.md` §Accessibility, lines 235-241) for every
interactive element in the composer:

- `<Composer>` now renders the spec-mandated `attach` (`plus`) and
  `emoji` (`smile`) buttons in the row layout (`Attach → textarea →
  emoji → send`), with stub click handlers per plan §Ambiguity
  decisions points 5. Real wiring drops in via Phase 3b
  (`files-inline.md`) and Phase 3c (`reactions-pins.md`); the buttons
  ship now so the spec's ARIA contract holds today.
- Send button `aria-label` flips to `save` while editing so
  screen-reader users hear the same state sighted users see (the
  visible label has flipped since T8). The spec is silent on the
  edit-mode label; we elect to mirror the visible text.
- Decorative meta-row icons (`lock`, `ear`, `hourglass`) carry
  `aria-hidden="true"` so screen readers don't announce the SVG glyphs
  separately from the meta-text label.
- New CSS: `.composer__attach`, `.composer__emoji` — transparent,
  `--ink-3` foreground, hover to `--ink-1`. Foundation tokens only.

Browser tests (added to `mod phase_3a_composer`):
- `composer_aria_labels_match_spec_table` mounts a composer with
  `replying_to = Some(_)` so the reply bar renders, then asserts the
  spec table for send / attach / emoji / reply-bar cancel and that
  every `.composer__meta-icon` carries `aria-hidden=true`.
- `composer_edit_bar_cancel_aria_label_matches_spec` mounts with
  `editing = Some(_)` (the composer suppresses the reply bar in edit
  mode, so this is its own test), asserts the edit-bar cancel
  aria-label and that the send button's `aria-label` + visible label
  both flipped to `save`.

Tradeoff: rather than splitting the audit across all five buttons in a
single test, we render two independent composer instances (one with
`replying_to`, one with `editing`) so each branch is exercised. Single
test would have required more reactive flipping than what the harness
buys us in clarity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements plan task T16. Verifies that the composer participates in
the foundation's `prefers-reduced-motion: reduce` media query per spec
§Motion (line 252-254): `willowPulse` collapses to a static dot, and
the reply-bar `willow-row-flash` keyframe also disables.

The CSS rules already exist (`willowPulse` was added in T11, the row-
flash override in T7); T16 only adds a regression-pin test so a future
edit can't silently strip them.

Test approach: `composer_reduced_motion_disables_typing_pulse` reads
the `style.css` source via `include_str!` and asserts the exact CSS
substring (`.composer__typing-dot { animation: none; … }` inside a
`@media (prefers-reduced-motion: reduce)` block, plus the parallel
`.message-row--flash, .message.flash { animation: none; }` rule).

Tradeoff: a structural test that walks `document.styleSheets[0].cssRules`
would be more resilient to formatting changes — but wasm-pack's
headless harness inlines `style.css` at compile time and doesn't
expose a way to flip the OS reduced-motion preference at test time, so
the substring assertion is the simplest contract that fails when a
rule is dropped. Comment in the test points at the structural
alternative if a future CSS pipeline tokenises the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…status implementing

Implements plan task T17. Phase 3a composer ships.

Spec (`docs/specs/2026-04-19-ui-design/composer.md`):
- Status flips `draft` → `implementing` (it becomes `shipped` on PR
  merge per the spec's own status ladder).
- All 7 acceptance-criteria checkboxes now ticked — each maps to one
  or more browser/client tests landed across T1–T16:
  * Reply  → AG-6
  * Edit   → AG-7
  * Autogrow + key matrix + Esc unwind → AG-1, AG-2, AG-5
  * Mention autocomplete → AG-8
  * Offline state → AG-10
  * Typing indicator pluralisation → AG-12
  * ARIA labels per §Accessibility → AG-14
- Open questions resolved:
  * Edit history → defer to v2; `(edited)` is rendered, raw event log
    retains versions for a future opt-in surface.
  * `@channel` confirmation → defer to v2 post-governance; v1 ships
    `ManageChannels`-only gating.
  * Typing-ping transport → already shipped; pointer to
    `WireMessage::TypingIndicator` + `Client::send_typing_indicator`
    + `Client::typing_in`.

Plan (`docs/plans/2026-04-26-ui-phase-3a-composer.md`):
- All 16 acceptance-gate checkboxes ticked.
- T17 ticked.

No production code change in this commit — the doc updates land
together so future readers see the spec/plan state matching the
implemented behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Steps with `id:` set on this runner inherit a shell of `bash -e {0}`
(no pipefail), so `cmd 2>&1 | tee /tmp/log` masks the upstream exit
code — `tee` always returns 0 and the step is reported as success
even when the actual command died.

Discovered while auditing browser test runs: ui/phase-3a-composer
shows `error: linking with rust-lld failed` followed by a green
conclusion, and the same is true for recent main-branch runs. The
duplicate-symbol link error from two `wasm-streams` versions has
been live for some time but invisible because the step "passes."

Fix is mechanical: force `bash --noprofile --norc -eo pipefail {0}`
on every tee-piped step (Clippy, Test, Browser tests, Playwright
E2E) so the upstream exit code surfaces.

Note: this commit will likely turn Browser tests RED on this PR —
that's the correct outcome. The dep skew between
`wasm-streams 0.4.2` (Leptos -> server_fn) and
`wasm-streams 0.5.0` (iroh -> reqwest) needs a separate fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intendednull intendednull marked this pull request as ready for review April 26, 2026 10:32
@intendednull
Copy link
Copy Markdown
Owner Author

Reopening to force CI re-trigger after pipefail fix

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