Commit Graph

2646 Commits

Author SHA1 Message Date
morgmart
16b8dd3464 desktop-new: document the decision path from context to token
A shared role that fails in a real context is a defect in the shared decision,
not a reason to name a nearby ramp step locally. The specimen frames exposed
this with border-primary: neutral-4 was visible enough in light mode and 1.08:1
against a dark panel. The first response bypassed it with neutral-6 just for the
documentation. The correct response was to measure every reader, repair the role
to neutral-4 light / neutral-6 dark, and return every caller to the same role.

DESIGN.md now gives the complete decision order: use a component first; add a
variant if the background changes; choose a surface by its spatial job; use the
shared boundary role; choose ramp steps directly for a hue decision; add a
proposed role only when one step cannot express both modes; and stop and ask
rather than making a local exception. It also records why the border uses two
different steps and why neutral-5 could not be the one-step dark adjustment.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 17:10:36 -07:00
morgmart
8ed0dc9931 desktop-new: border-primary takes a lighter step in dark mode
Light mode keeps neutral-4; dark mode moves to neutral-6. A hairline needs more
separation on a dark surface than the same step number provides, and the old
single value was invisible where it mattered: measured against every surface a
border sits on, neutral-4 is 1.08:1 on a dark panel and 1.09:1 on the floating
composer. Drawn, and not perceptible. neutral-6 is 1.35 and 1.14.

Not neutral-5, which is one step and the obvious answer. `bg-float` and
`bg-chrome-selected` are both neutral-5 in dark, so a border on a floating surface
would have measured 1.00:1 — the same colour as the thing it bounds.

This is the asymmetry the role exists for. Light and dark do not have to reach for
the same step, and no `border-neutral-4` class could express it; it is the same
test the four structural surfaces pass.

The /design specimen frames drop their hardcoded `neutral-6` and read the role
again. They only named a step directly because the role was invisible in dark, so
documentation furniture and product boundaries had diverged for a reason that no
longer holds.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 16:20:55 -07:00
morgmart
1dee19d8a1 desktop-new: the palette's step labels describe Buzz, not the scale it came from
Both label sets came with the transcribed values and described a generic scale's
intentions: step 4 as "component hover", step 6 as "subtle border", step 7 as
"border". The product then decided otherwise and nobody updated the comments, so
the file said step 4 was a hover step while it held `border-primary`, the one
border weight, and said steps 6 and 7 were for borders while they had no readers
at all. A designer opening `tokens.css` or `/design/color` was told the wrong
thing by the only documentation to hand.

The neutral ramp's comments now name the roles that actually point at each step,
including the two steps that have no reader — an empty step is worth seeing,
because it is either a gap or a deletion nobody made yet.

`STEP_JOBS` keeps generic wording, because it is shared by all eight hues and a
hue's step 3 is a tinted surface whatever the product does with it. But it now
matches DESIGN.md's step-to-role table exactly, which it did not, and it says so
about itself: general intention here, real use in the neutral ramp's comments.

The reason it drifted is that it was never rendered. Every palette step carried
this text and no page displayed it, so it could only be checked by reading the
source. The colour page now shows each step's job under its swatch, which makes
the next divergence visible by looking rather than by audit.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 16:14:27 -07:00
morgmart
d9a0223804 desktop-new: move two call sites onto the surviving colour names
`text-danger` was the role that resolved to the wrong red; error text is
`text-red-12`, measured against every surface it lands on. `border-secondary` was
one of three indistinguishable border weights and is now the single
`border-primary`.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 16:01:53 -07:00
morgmart
41fe22853a desktop-new: split the design reasoning out of AGENTS.md
AGENTS.md held two stale copies of the colour system — one section said two
layers, another said three and listed an `--accent-1…12` ramp that only exists in
a comment explaining its removal. Documenting one subject twice in one file is how
that happened, so the duplicates are deleted rather than reconciled: DESIGN.md
holds what to do and why, AGENTS.md holds what this is, how to run it, and what
will fail you.

DESIGN.md gains the colour structure it was missing, rewritten around the reversal
in this branch — build from the ramps, and a role exists only when a step cannot
express it. The three cases where a name is earned are stated explicitly, since
`check-color` now enforces two of them. The step-to-role table survives as
guidance for which step to reach for; it is no longer a generator, which is what
it was when it produced twenty status roles nobody had designed.

Both files also record what is deliberately unresolved: contrast on glass, where
the guard measures opaque surfaces only and a translucent surface's real
background is the fill composited over a gradient that changes across the screen.
Measured, dark mode's brightest glass region puts `text-secondary` at Lc 58 and
`text-tertiary` at Lc 43 against targets of 60 and 45. Three candidate fixes are
listed and none is chosen, because it wants real content on screen rather than a
token edit.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 16:01:17 -07:00
morgmart
d591179a6b desktop-new: the design pages answer which token draws what
`ComponentAnatomy` is a table per component variant: each part, and the token
that draws its fill, text, border, shadow, blur, and radius. It answers the one
question a specimen cannot, because a picture of a control does not say whether
that pill is `bg-chrome-selected` or a hardcoded white.

It reads the live component rather than a hand-written list, for the same reason
the token table does — a typed-out list of parts and tokens is a second source of
truth that goes stale silently. Two things this needed that were not obvious:
Vite injects app CSS by setting `textContent` on a `<style>` tag at dev time and
those rules are not reachable through `sheet.cssRules`, so the stylesheet text is
parsed instead; and matching declarations are ranked by specificity before source
order, because ranking by order alone reported a selected tab as unselected once
a variant rule outranked a shared one. Both failures rendered a plausible table
that was wrong, which is the failure mode it exists to prevent.

Every specimen frame now carries a border. It was `bg-panel` on a page that is
also `bg-panel` — white on white in light mode, `#1c1c1c` on `#1c1c1c` in dark,
with no border and no shadow — so every plain specimen on every component page
had an invisible container and a reader could not tell where the documentation
stopped and the running component began. It hid because the gradient frames
always read as containers, so the pages using them looked correct. A hairline
rather than a tinted fill, because a specimen must show the component against the
surface it is designed for. Measured at `neutral-4` first: 1.23:1 against the
light page and 1.08:1 against the dark one — technically drawn, effectively
invisible. `neutral-6` is 1.41 and 1.35. The overview thumbnails had the same
defect and take the same fix.

The colour table splits into Roles and Ramps, because the ramps — 96 steps across
eight hues, the layer every role resolves to — were not shown anywhere. Blur moves
off the glass page onto elevation, where the other depth values live. The glass
page drops its text-only cards and shows each material over the real app
backdrop, since translucency can only be judged against what shows through it.

Frames scroll their own overflow: chrome tabs with icons and a trailing action
are 377px of unshrinkable inline-flex, so at 420px the whole page used to scroll
sideways. A specimen you swipe beats a documentation page that slides.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 16:01:02 -07:00
morgmart
db12fdb4f7 desktop-new: SegmentedNavigation becomes Tabs, with a surface variant
The old name described one variant's appearance — a segmented control — which is
exactly why a second appearance felt like it needed a second component. `Tabs`
names the behaviour, so both variants sit under it naturally. Base UI's own
export is also `Tabs`, so it is imported as `BaseTabs`; that reads better anyway,
since `BaseTabs.Root` says plainly which layer each part comes from.

`variant="chrome" | "panel"` is the chrome/surface axis DESIGN.md had deferred
until a second component made the case. `IconButton` made it once; this made it
twice, on a real screen. The failure: the chrome container is `glass-2`, which
over a white panel composites to pure white, and its selected pill is `neutral-1`
— also pure white. Container and selection became one colour with only a shadow
between them, and no guard could see it because the component had no way to state
which background it expected. Retinting `--bg-chrome-selected` would only have
moved the collision.

One component with a variant rather than two components, because behaviour,
keyboard model, accessibility, props, and the Base UI parts underneath are
identical — a sibling would duplicate all of it to change how selection is drawn,
and the two would drift exactly as the four hand-assembled chrome surfaces did.
The panel variant still emits `role="tablist"`, correct `aria-selected`, roving
`tabIndex`, and arrow-key navigation, which is the concrete payoff.

Each variant states all three of its states in full rather than inheriting a
colour and overriding part of it. When a shared rule and a variant rule both set
a property, the more specific selector wins regardless of source order, and a
variant rule is always more specific — so the shared `[data-selected]` rule lost
and the selected tab silently rendered as unselected. Hover carries
`:not([data-selected])` because hover and selected genuinely overlap: a cursor
can sit on the selected tab, and DESIGN.md § State says a selected item in a
toggle group is not interactive. Those two selectors score identical specificity,
so without the exclusion only source order would keep it right. This follows
Chrome's modern CSS guidance: use `:not()` to say a state is irrelevant, never to
defend one rule from another.

`WorkspaceSurface` is renamed to `Panel` in the same change, since both touch the
same rules in components.css.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 16:00:28 -07:00
morgmart
698b73d943 desktop-new: glass is one material, applied whole
A glass surface is four things at once — a translucent fill, a blur behind it, a
directional rim, and sometimes an outer lift — and they were four separate
things a consumer assembled by hand. Every consumer did, differently. The four
chrome surfaces meant to match disagreed about whether to carry an outer shadow,
and four inner panels set a translucent fill with no blur at all.

`glass-primary` and `glass-secondary` are Tailwind `@utility` blocks carrying the
whole material, plus `-interactive` variants that add the hover. Named by
stacking depth rather than by component: an earlier cut named them chrome /
panel / float and it was wrong twice over, because `chrome` and `float` both
landed on glass-3 — 66% opaque in dark, which is why chrome did not read as
glass — and naming a material after a component invites a second material the
first time a component wants a different depth.

`box-shadow` is composed inside each material because it has to be: a rim and a
lift both set it, so as separate utilities the second silently overwrites the
first. That is a structural argument for bundling, not a preference. The fills
are deliberately not registered as colour utilities, so a bare `bg-glass-primary`
class cannot exist.

Removing a redundant fill from the four workspace panels fixed something no
token held. They each set the same `glass-2` as the container they exactly cover
— measured 610x551 inside 612x553, the two pixels being its border — and two
identical translucent layers composite: 0.52 over 0.52 is 0.77, between glass-4
and glass-5. Panels meant to be the most translucent surface in the system read
as nearly solid.

That in turn exposed a bug the density had been masking since the dock landed:
dockview's theme sets `--dv-group-view-background-color` to a literal `white`
and `DockWorkspace` passes `themeLight` in both modes, so an opaque white sheet
sat behind the glass. Reset here, because every other dockview surface the
variable feeds was already overridden to transparent.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 15:59:07 -07:00
morgmart
ec918bb75f desktop-new: build screens from the ramps, cut 54 colour roles to 15
Registers all eight palette ramps as Tailwind classes, so a screen writes
`bg-purple-9` or `text-red-12` directly. This reverses the rule the colour
guard used to enforce, and the reversal turns on one fact: every step in this
palette is authored per mode, so `neutral-4` is one value in light and another
in dark and a component naming it behaves correctly in both. That is what makes
a raw step safe here and unsafe in stock Tailwind, where `neutral-200` is a
single literal.

Once a step is mode-aware, a role whose light and dark values are the same step
is a name in front of a number, and a name in front of a number hides the
decision instead of recording it. `bg-accent` was `purple-9`; `text-error` was
`red-12`. Nineteen roles were exactly that.

The fifteen that remain each earn the name: the four structural surfaces take a
different step per mode, so no class can express them; the emphasis roles enforce
a rule a ramp cannot state, which is that there are three levels of text and one
border weight; the glass materials are a bundled treatment; and
`--text-on-accent` follows its fill rather than the mode.

Also fixes a namespace collision that had shipped a real defect. `--color-x` is
not one utility, it is all of them — one declaration defines `bg-x`, `text-x`,
`border-x` and `ring-x` from a single value — so `text-danger` resolved to the
saturated fill (red-9) instead of the text step. Three error messages rendered
at APCA Lc 34 on a dark panel against a target of 60, and `check-contrast`
passed the whole time because it measured `--text-danger`, a token no class
could reach. Text roles now register as `--text-color-*`, matching the
`--border-color-*` split that fixed the same bug for borders.

Guards updated to enforce the new rule rather than the old one: `check-color`
now rejects a role whose two modes agree and names the class to write instead,
and `check-contrast` measures the ramp steps screens actually use, so deleting a
role cannot leave it auditing a name nobody types. `registry.test.ts` binds the
documented token list to the stylesheet, and `pnpm census` reports every role's
readers without failing a build.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-07 15:58:41 -07:00
morgmart
ce94bb1599 desktop-new: delete the proving-ground dashboard
It was meant to stress the design system by putting many decisions next
to each other. It did surface one real finding - the neutral hover step
is tuned for hovering a filled component, not a bare row, so the same
token is too weak for a quiet button and too strong for a bare row - but
the page was ~85% bespoke CSS routed around the shared components, so
most of what it showed was its own scaffolding rather than the system.

The app itself is the better proving ground. Removing this rather than
carrying a mockup whose defects have to be triaged separately from the
product's.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 22:23:48 -07:00
morgmart
4100246b75 desktop-new: ignore vite.local.config.ts
A local Vite config can wrap the committed one to append a personal
dev-only plugin. Ignoring it makes that structurally uncommittable
rather than something to remember not to stage.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 22:05:29 -07:00
morgmart
adc6639c06 desktop-new: cards carry no default fill
A card sits on the panel and is grouped by a hairline. Fill is reserved
for bg-hover, and only where the card is clickable, so a filled card
always means you are pointing at this rather than merely this is a box.

The region tiles were using bg-inset, which means pushed in - an input
or a code block - the opposite gesture from grouping, and read as a grid
of empty text fields inside a filled panel. No new role is needed: a
card with no default fill has nothing to name.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 21:22:59 -07:00
morgmart
efb443a2cb desktop-new: delete the categorical tints, add a proving-ground dashboard
Two changes, both about finding out whether the colour system actually works.

Deleted the six categorical tint roles. Evidence: zero component usage — not one
reference outside the token file and the registry. They were hand-picked literals
of exactly the kind the palette exists to prevent, and the open question 'how
many categorical colours does the product need, and does a person choose one or
does the product assign it' is unanswered, so naming them now was guessing. The
eight palette hues stay; a categorical role gets named when a feature needs to
distinguish things.

That deletion turned a guard check vacuous, which is its own finding: the drift
check named four specific pairs (--tint-purple vs --accent-tint, etc.) and would
have passed silently forever once those tokens were gone. Replaced with the
general rule — no two family steps may resolve to the same palette step — which
catches the same class for any future family without naming pairs. Falsified by
pointing --danger-tint at the purple ramp.

Added /proving-ground: a fictional fleet-monitoring dashboard, deliberately
nothing to do with Buzz. Specimen pages are honest but sympathetic — one
component at a time, on a chosen backdrop, at a chosen size. A dashboard puts
forty decisions next to each other, which is where a missing role shows up. It
is also the surface a generated theme has to be judged on: 'Neon Tokyo' can look
fine as twelve swatches and be unusable as a page.

Built only from role tokens, and it held: no new roles were needed, contrast
passes both modes, and the layout works at 1440 and 620. Page-local parts live
in the feature rather than shared/ui on purpose — this page exists to find out
what repeats, so promoting its pieces first would be guessing.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 20:41:56 -07:00
morgmart
cef97be05b desktop-new: add a palette layer beneath the colour families
The colour system had three layers, and the middle one held two different kinds
of thing under one name. --neutral-1..12 is a real scale; --accent-2 and
--danger-9 are five specific jobs (tint, tint-hover, border, fill, text) wearing
ramp-style numbering. So --accent-2 read as 'a light purple' when it meant 'the
surface an accent tint sits on'.

Underneath it was 114 hand-picked hex values with nothing enforcing that two
tokens doing the same job agreed. They drifted: --accent-2 and --tint-purple were
the same purple in light mode and two different purples in dark. I introduced
that yesterday and did not notice.

Four layers now:
  0 palette    --palette-purple-3   every hue, 12 steps, authored per mode
  1 families   --accent-tint        the five jobs a hue does, named not numbered
  2 roles      --bg-accent-tint     what a thing is in the interface
  3 components bg-accent-tint       Tailwind utilities

Dark stops being freehand. A family points at a palette step and .dark redefines
the whole palette, so 50 hand-picked dark values are simply gone.

This also answers a concrete trap. Tailwind's purple gets MORE saturated as it
descends (950 is 87% saturation), so a dark tint drawn from its bottom end reads
as oversaturated, and the only way to reach a muted deep purple was to write
purple-950/50 in a component. That value is correct — it composites to within a
few percent of the palette step now in use — but as an expression it has no name,
no light/dark pair, and nothing the contrast guard can measure. The palette's
dark ramps are authored for dark surfaces, so it is a step you pick.

New scripts/check-color.mjs enforces it: rejects opacity modifiers on colour
utilities, color-mix() and alpha rgb()/hsl() in component code, components
reaching past the role layer, and the layering itself — palette steps must be
literals, family steps must reference the palette, two tokens doing one job must
resolve to one step, and dark must not restate families. Falsified by
reintroducing the original drift and each other failure mode in turn.

Values are Radix Colors (MIT), transcribed rather than depended on; Radix is not
on Block's Tech Radar so this is a values-only copy with no package. Its
twelve-step contract is the one this system already described in comments, step
for step. Two documented divergences, both found by the contrast guard: family
text takes step 12 rather than the 11 Radix names 'low-contrast text' (Radix
sizes 11 for WCAG 4.5:1; every hue's 11 measured Lc 55-61 against our Lc 60
target), and dark neutral surfaces stay hand-authored because they were sized
against the real panel stack.

The colour page now renders the palette, so picking a step is visual.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 19:12:35 -07:00
morgmart
7b8c73b95f desktop-new: purple accent, rename tile to chip, redesign from Figma
Accent hue is now Tailwind purple. The ramp is a slot, so this is five values
in tokens.css and every role follows; steps 2 and 3 are purple-100/200 exactly
because the chip design was drawn against them and the tint pair is what a
person sees most. Dark mode cannot use those two — near-white reads as a light
surface on a dark panel — so it holds the hue at dark violets instead.

Renamed InlineTile to InlineChip throughout, plus shared/tiles to shared/chips,
tileNode to chipNode, and every token, class, and identifier. A chip is what
this is.

Redesigned to match Figma 700-2782:
- One treatment for every kind: accent tint behind primary text. Kinds are told
  apart by sigil and, where the label is ambiguous, an icon — not by colour.
  This replaces the earlier per-kind colour split, which spent palette on a
  distinction the sigil already makes.
- Label is primary text, not accent text; the tint does the colour work.
- Two new kinds the design draws: message and link, both icon-led with no
  sigil. AUTHORABLE_CHIP_KINDS records that v1's picker still offers only
  person, agent, and channel; the others arrive by another route.
- Dropped avatar and status dot. The design has neither, and neither was
  earning its place at text size.
- Unresolved is now a real state: neutral fill, lesser text, and never a
  control, because there is nothing to open.
- Chip inset and gap intentionally sit below the 4px spacing floor (3px inset,
  4px gap) — a chip hugs its label. Recorded on the tokens.

Eight new rendered-contract tests bound to the real specimen page: sigils and
selective icons, assistive naming, unresolved-is-never-a-control, read-only
claims no stop, activation reports the address, truncation, never breaking
across a line, and hover deepening the tint without moving the text.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 18:15:13 -07:00
morgmart
c05b85c421 desktop-new: tile address model and shared InlineTile
Implements the foundation the composer sits on: a reference is an address
(kind + identity), never the letters of a name. One shared InlineTile serves
both the composer and the conversation, mounted in the editor through a React
node view.

- shared/tiles/address.ts: kind + id, canonical buzz:// form, parse/format.
- shared/tiles/faceResolver.ts: resolves the displayed face from an address at
  render time, so a rename never edits anyone's draft or enters undo history.
  Community-scoped cache with an explicit reset.
- shared/ui/InlineTile.tsx: the one component, person/agent/channel kinds.
- features/composer/tileNode.ts: the document node. Stores only the address.
- New semantic tokens: --space-tile-inset, --space-tile-gap, --radius-tile.
- Specimen page at /design/components/inline-tile covering kinds, in-sentence
  use, read-only, unresolved, truncation, and activation.

Findings while building, all now covered by tests:
- A React node view REPAIRS a caret boundary. With static markup, Home before a
  leading tile landed after it; with the node view both Home and ArrowLeft land
  correctly. Only the pointer click on a leading tile's left edge still fails —
  now the single known caret gap, still marked test.fail().
- useSyncExternalStore needs a reference-stable snapshot. Building a fresh
  unresolved face per call was an infinite render loop that unmounted the whole
  editor. Unresolved faces are cached like resolved ones.
- An unresolved tile must not announce an abbreviated identity to a screen
  reader. TileFace now carries , and the accessible name says
  "Unresolved person" rather than reading the stand-in aloud.

Falsifiability checked by breaking each protection in turn: selectable:false,
the address text projection, and the community reset each turn tests red.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 15:20:42 -07:00
morgmart
179af349f1 desktop-new: composition-input spike for inline composer tiles
Answers the blocking question in the Desktop New plan before the composer is
designed: does staged character input (ja/ko/zh) survive beside an inline atom
tile? It does. Twelve tests, Chromium composition driven through CDP
Input.imeSetComposition plus a WebKit pass.

Measured findings:
- Composition beside a tile is safe in every position; a committed composition
  leaves the tile address byte-identical. No fence needed.
- selectable:false is load-bearing. With selectable:true an arrow key selects
  the tile and the next keystroke replaces it.
- atom:true is redundant for a contentless node (isAtom = isLeaf || spec.atom)
  and kept as documentation of intent.
- Whole-tile deletion works natively; no custom Backspace handler required.
- One real gap: the caret position before a leading tile. ArrowLeft reaches it,
  Home and a pointer click do not, in both engines. Those two tests carry
  test.fail() until the caret-boundary layer lands.

The harness is temporary and deleted when the real composer lands.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-06 15:01:11 -07:00
morgmart
ef1b407e02 feat(desktop-new): establish visual component foundation
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-04 21:45:07 -07:00
morgmart
2d4d36e086 feat(desktop-next): build channel-backed Sessions workspace
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-04 20:02:30 -07:00
morgmart
8dc0610f5d Merge remote-tracking branch 'origin/main' into morgan/desktop-next-sessions
* origin/main:
  Add generic information-flow control core (#7293)
  feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents (#7335)
  fix(desktop): restore mention chip identity icons (#7338)
  Persist video playback speed preference (#7336)
  Verify ACP relay events before prompt routing (#7010)
  fix(buzz-acp): bound busy-owner hold to prevent cross-channel starvation (#7337)
  feat(desktop): invite owned agents from standalone forums (#7125)
  fix(desktop): authorize remote mentions at publication (#7124)
  fix(acp): rename system tag to agent-instructions (#7332)
  fix(desktop): bind duplicate mention selections to exact recipients (#7133)
  refactor(relay): extract NIP-29 membership authorization (#7285)
  chore(release): release Buzz Desktop version 0.5.22 (#7308)
  feat(desktop): preserve mentions across copy and paste (#7228)
  test(desktop): await Bestie drag and profile hover endpoints (#7294)
  Collapse contiguous join messages (#7262)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-04 15:53:06 -07:00
Jordan Mecom
4d447b9c20 Add generic information-flow control core (#7293)
Adds a dependency-free `ifc-core` crate implementing reader-set
confidentiality labels and monotonic per-computation flow state.

- Defines flow ordering, join, and meet over caller-supplied principal
universes.
- Fails closed for unknown or cross-universe input and checks reader
widening at egress.
- Includes property tests for lattice laws and monotonic taint, plus the
design paper that motivates the broker integration.

The crate deliberately contains no Buzz, Nostr, channel, membership, or
grant policy; those remain in the Buzz adapter.

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-09-04 22:26:44 +00:00
Salman Mohammed
4beffef697 feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents (#7335)
## Summary

- launch Pi through a private Buzz wrapper so managed Pi agents receive
Buzz's base prompt plus the configured thread/channel session model via
`--system-prompt`
- load the Buzz workspace skill directory with Pi's repeatable `--skill`
flag, making `~/.buzz/.agents/skills/buzz-cli` available alongside Pi's
normal global skill discovery
- report Pi and `pi-acp` installation states separately so setup
guidance points to the missing component

`pi-acp` does not currently consume the ACP `session/new` system prompt,
but its normal new-session and restore paths do honor
`PI_ACP_PI_COMMAND` as the executable used to launch Pi. Buzz reserves
that variable and sets it to a private generated launcher. The launcher
invokes `pi` from Buzz's effective `PATH`, adds `--system-prompt <file>`
and `--skill <workspace>/.agents/skills`, and forwards `pi-acp`'s RPC
and session arguments unchanged. This keeps the integration entirely in
`block/buzz`, without changes to Pi or `pi-acp`.

### How PI_ACP_PI_COMMAND works in `pi-acp`

`PI_ACP_PI_COMMAND` selects one executable; it does not accept
arguments. Therefore:

```bash
# Does not work
PI_ACP_PI_COMMAND="pi --skill ~/.buzz/.agents/skills"
```

Buzz does not expose this variable as user configuration. It creates a
private launcher and sets `PI_ACP_PI_COMMAND` only on the `pi-acp`
child. Conceptually, that launcher executes:

```sh
#!/bin/sh
exec pi \
  --system-prompt "$PRIVATE_SYSTEM_PROMPT" \
  --skill "$HOME/.buzz/.agents/skills" \
  "$@"
```

### Related issue

Follow-up to #7208. No duplicate issue or open PR found.

### Testing

- `just ci`
- `cargo test -p buzz-acp pi_launcher`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml
managed_agents::env_vars::tests`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml
managed_agents::discovery::presets::tests`
- pre-push branch checks

No screenshots: the UI change is state-dependent setup guidance only.

---
**Update Sep 4, 13:35:** Made `PI_ACP_PI_COMMAND` entirely Buzz-owned.
- Managed agent configuration now rejects the variable as a user
override.
- Buzz always launches `pi` from its effective `PATH` and rejects
inherited values before creating its private launcher.
- Removed the custom Pi executable discovery path and its internal
environment alias.

---
**Update Sep 4, 14:03:** Reject inherited `PI_ACP_PI_COMMAND` values
instead of replacing them.
- Pi startup now fails with an actionable message telling the user to
unset the variable.
- Removed the Pi-specific exception from the generic ACP environment
injection path.

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
2026-09-04 21:17:00 +00:00
Logan Johnson
4afef8649a fix(desktop): restore mention chip identity icons (#7338)
## Summary

Restore the missing **@ glyph for people and robot icon for agents**
after #7133, and incorporate the requested compact public-key display.

- Wrapping mention chips render their existing bounded icon-bearing
leading fragment.
- Readonly chips show bound keys using the same `8 leading…4 trailing`
formatter as the channel member list: `Scout (150b20bd…15dc)`.
- Full literal labels and exact keys remain authoritative in metadata,
profile targets, title/accessible-name attributes, editor text, saved
bodies and recipient tags. Display abbreviations are never used for
recipient lookup.
- Copy/paste restores the full literal label for a complete compact
chip; partial selections remain plain text. Two keys sharing the same
abbreviation still round-trip to their separate exact recipients.
- No recipient-resolution, authorization, wire-format, composer, or CSS
changes. Existing labels, icons, cloud markers and ordinary mentions
remain intact.

## Verification

Published candidate `9365ab9bc9d9c5d10802580cd576f8e378ff492f`, based on
main `e09f715c9d0ee2cb7bf8a39061e601f3a502f588`:

- **6,444 desktop unit tests passed** on this candidate's final source
tree.
- **44 mock-Chromium tests passed, zero retries in the final run**
across mention recipients, clipboard and cloud provenance: exact
recipient selection, ambiguity rejection, send/edit/reopen, forwarding,
full/partial copy, mismatched-key rejection, matching-abbreviation
collisions and 100%/150% narrow-window geometry.
- TypeScript, desktop Biome/check guards, protected-feature production
build and E2E build passed.
- The compact-renderer regression fails with the formatting call
removed. Original missing-icon and hidden-text accessibility regressions
have red/green evidence.
- Fresh self-review traced rendering, full-key metadata, copy
classifier, paste normalization and identity trust. The same display
formatter owns the accepted compact form on both clipboard sides.

Iteration exposed an existing team-insertion separator flake (passed
final full run) and two new fixture assumptions: non-member sends
require invitation, and Chromium rich paste may retain an NBSP
separator. Tests now exercise invitation and normalize only that
separator when comparing the captured full body and exact tags; no
product change was needed for either.

Earlier local repository-wide `just ci` completed in two invocations
because its initial call hit the ten-minute tool limit during Tauri
compilation. Unchanged native/mobile/backend evidence is reused; the
desktop delta received the full checks above and new remote CI. Native
VoiceOver, real Tauri selection, dark theme and non-Chromium observation
were not performed. Browser artifacts exercise real frontend with mocked
Tauri/relay boundaries, not an installed release.

## Review and visual evidence

Current-head CI and automated review must complete after this update;
the old `2997bfb5` green results do not establish this new head.
Required human review remains separate from agent approvals. No
merge/install/restart authorization.

Before/after icon evidence:
https://github.com/block/buzz/pull/7338#issuecomment-5544575866

Updated compact-key screenshots are posted below. The editor
intentionally retains the full literal address; only readonly chip
display is abbreviated.

Origin:
buzz://message?channel=3355d33a-b72a-423a-b064-a58275f9a8af&id=38b3a27e689f5a9604e273d45f4e3122fceba76081e7bcd3bbdbf439524a5a18

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-04 13:36:17 -07:00
morgmart
5d107836c6 Persist video playback speed preference (#7336)
## Summary

- persist the selected desktop video playback speed as a device-level
preference
- apply the shared preference to inline and review video players,
including after a reload
- cover persistence, validation, cross-window updates, and the
end-to-end playback flow

## Verification

- `node --test
desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs`
- `cd desktop && pnpm exec tsc --noEmit`
- `cd desktop && pnpm check:px-text`
- `cd desktop && pnpm test`
- `cd desktop && pnpm build:e2e && pnpm exec playwright test
--project=smoke video-attachment.spec.ts`
- pre-push: desktop check, file-size check, typecheck, and 6,416 desktop
tests

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-04 13:10:17 -07:00
Jordan Mecom
e09f715c9d Verify ACP relay events before prompt routing (#7010)
## What changed

Verify every Nostr EVENT received by buzz-acp immediately after
deserialization. Events with an invalid NIP-01 ID or Schnorr signature
are dropped before subscription routing, deduplication, replay-watermark
updates, membership handling, or either harness queue.

## Safety

Signature verification runs on the blocking pool so cryptographic work
does not block the relay task. A verification failure drops only that
event and keeps the connection available for subsequent valid traffic.
The existing observer-control verification remains as defense in depth.

Regression tests cover valid delivery; changed content, ID, signature,
pubkey, tags, and timestamp; a forged owner shutdown command with a
recomputed ID; forged membership notifications; and forged
observer-control events.

## Testing

cargo test -p buzz-acp

cargo clippy -p buzz-acp --all-targets -- -D warnings

just ci

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-09-04 11:19:03 -07:00
Will Pfleger
b17c0776b7 fix(buzz-acp): bound busy-owner hold to prevent cross-channel starvation (#7337)
## Problem

#6732 added a busy-owner hold to the ACP harness: when a scope's
recorded session owner (`session_owners`) is checked out on **any**
turn, `dispatch_pending` holds the scope's batch instead of dispatching
it. The hold was added to keep one provider session per thread — but it
is unconditional: it applies to `Conversation` scopes too, and it has no
time bound.

Under the default `session_policy=channel`, every channel collapses to a
single `Conversation` scope, so once two channels' sessions land on the
same worker (pass 2 of `try_claim` picks the first idle worker by index,
so this happens quickly after any restart), channel A's mention starves
behind channel B's in-flight turn — for up to the full
`max_turn_duration` (7200s by default) — while other workers sit idle.
The only signal is a DEBUG-level log, and the 👀 seen-reaction is added
at queue admission *before* the hold decision, so the user sees the
agent acknowledge the mention and then nothing.

Observed in production on the first day of the v0.5.22 rollout: three
separate incidents where a mention got 👀 but no turn started until an
unrelated channel's turn ended on the shared worker (in the worst case
the blocking turn sat in a single tool call for 6+ minutes).

## Fix

One new seam, `AgentPool::hold_decision`, replaces the raw
`should_hold_for_busy_owner` check in `dispatch_pending` (the predicate
itself is unchanged and remains the inner check):

- **`Conversation` scopes never hold.** Channel-policy channels and all
DMs dispatch immediately; a busy owner means forking onto an idle
worker, exactly the pre-#6732 behavior. This removes the cross-channel
head-of-line blocking entirely for the default policy.
- **`Thread` scopes hold for a bounded window.**
`HOLD_BUSY_OWNER_TIMEOUT` (10s) is measured from the first time the
batch is held (`held_since` stamp); once elapsed, the batch stops
holding and forks a fresh session on an idle worker, rebuilding thread
context from the relay. This preserves #6732's session-continuity intent
for the momentary-busy case while capping the worst-case wait. No new
timer is needed: held batches are requeued with preserved timestamps and
re-evaluated on every dispatch trigger (turn end, relay event, 30s
maintenance tick), so the effective worst-case re-check gap on a fully
silent system is one maintenance tick.
- **Holds are observable.** Holding logs at INFO and a hold expiry logs
at WARN (previously DEBUG-only), and both emit observer-feed events
(`busy_owner_hold`, `busy_owner_hold_forked`) with the scope, owner
index, and held duration.

`held_since` is derived state and is cleared on every removal path:
dispatch/fork (inside `hold_decision`), `invalidate_channel_sessions`,
`invalidate_scope_session`, and `switch_idle_agent_model`.

## Accepted trade-offs

- A fork after an expired hold leaves the old owner's now-orphaned
thread session in its session map until natural rotation/invalidation —
benign, and identical to pre-#6732 fork semantics (`loadSession: false`;
sessions are worker-pinned, so migration is not an option).
- Under sustained pool exhaustion the hold stamp is cleared on the fork
attempt and re-stamped next cycle, so the bound is effectively "timeout
after a worker frees up," not absolute wall clock.

## Tests

- New table test `hold_decision_covers_variant_session_busy_and_timeout`
over the full input space (scope variant × idle-session presence × owner
busyness × elapsed vs. window). The `Conversation` + busy-owner row is
the cross-channel regression guard; the past-window row guards the
bound. Both were mutation-checked: removing the variant gate or the
timeout branch fails the suite.
- `busy_session_owner_holds_batch_instead_of_forking_session` extended
with the Hold → ForkAfterHold transition, the `Conversation` dispatch
guard, and `held_since` pruning on channel invalidation.
- Scope-invalidation and idle-model-switch tests extended to cover
`held_since` cleanup alongside the existing `session_owners` assertions.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-09-04 14:07:45 -04:00
morgmart
f8f07a3b33 fix(desktop-next): make coloured text legible on tint hovers, and widen the audit
A second pass over the colour system, checking the surfaces the first audit did
not cover.

The gap was tint hovers. DESIGN.md says a tint carries meaning and takes coloured
text, but the audit only measured text against the four neutral surfaces — so
`--bg-*-tint-hover`, the hardest surface in each coloured family, was never
checked. Four pairings were below target: light danger (Lc 56.6) and warning
(59.2), and dark accent and info (58.4 each). All four passed WCAG AA, which is
why nothing else caught them.

Size step 11 against the tint hover rather than the neutral panel, the same rule
already applied to the neutral ramp: light danger-11 #b91c1c -> #a80008, light
warning-11 #a16207 -> #9c5e00, dark accent-11 and info-11 #8fbaff -> #95c0ff.
Minimum moves in OKLCH holding hue and chroma, so the hues do not shift.

The audit now covers every tint and tint hover in all five families. Verified by
falsification: reverting any one of the four fails the check.

Two findings that are deliberately not changes:

Hairline dividers stay at 1.2-1.8:1. WCAG's 3:1 non-text rule covers boundaries
needed to identify a control or its state, not grouping lines; Radix ships 1.29
and Apple 1.26 for the same job. Raising them would draw the box the fill already
implies. Recorded in DESIGN.md, along with the note that a control's own outline
is a different question when the input layer lands.

`--ring-focus` is an accent against accent: legible on every neutral surface at
3.0-3.5:1, but 1.1:1 on `bg-accent`, where it vanishes. Not yet reachable — the
token is unregistered and unused — so the constraint is documented at the token
instead of being solved speculatively: the component layer that introduces focus
owns a surface-coloured offset, and there is no second ring token.

Glass surfaces were checked by compositing each translucent value over the app
gradient's own stops; all clear the target in both modes, which the relit
neutral ramp is what earns.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-04 11:07:31 -07:00
Logan Johnson
e7e29937a1 feat(desktop): invite owned agents from standalone forums (#7125)
## Summary

In Desktop's standalone Forums, selecting an owned agent from another
device could leave a post or reply unsendable if the agent had not
joined the forum. This adds **Invite / Cancel** to the send flow so you
can resolve membership without leaving your draft.

- **Invite** checks response policy and your permission to add members,
adds the agent to the forum, waits for refreshed membership, then
rechecks authorization before posting to the original destination.
Membership is forum-wide, not limited to one post.
- **Cancel / Escape** keeps the text, attachments and selected
recipients for retry. Unlike chat's **Do nothing / Send anyway**, this
dialog has no reference-only send choice. Invite is disabled while
pending; Cancel remains available.
- Leaving the source post/reply cancels its pending invitation, even if
you return. Errors remain visible, focus returns to the initiating
editor when appropriate, and late completion cannot resume a cancelled
post or interfere with a newer attempt.
- A rejected send restores text, uploaded media and exact selected
recipients to the source draft only if no newer edit, deletion, upload
intent or send supersedes it. Clipboard verification settles before
recipient capture, with stale edits/navigation fenced out.

### Related issue

Targets `main` after #7124 merged. This PR reuses its publication checks
and draft protection; the five forum commits have been replayed
unchanged onto the merged parent. Owned-agent discovery (#7122) is
already merged. Split from #7114.

Forum creation/templates, channel-less Notes and local-agent management
are unchanged. Duplicate-name binding from #7133 is already merged and
retained by this stack. Inviting does not start a remote agent or
promise that it is online or will reply.

### Testing

Forum composer lifecycle tests, including clipboard-settlement cases,
and TypeScript/changed-file formatting checks passed after the restack.
Earlier invitation, focus and transport-recovery browser coverage is
retained, not claimed as a fresh full browser run on this head. See
[live CI](https://github.com/block/buzz/pull/7125/checks) for
current-head results. Browser evidence uses mock IPC; no full local
`just ci` pass or native/live-relay validation is claimed.

To try it: open a forum post or reply, select an owned nonmember agent
and send. Cancel, then retry without reselecting; Invite should add that
agent before posting. Deny the add to check the visible error and
retained draft. Navigate away/back during a pending invitation or
rejected send; no stale publication or overwrite of a newer draft should
occur.

![Forum invitation with Invite and
Cancel](https://raw.githubusercontent.com/block/buzz/6481bd088d661975672addcc834231df6ac88705/pr-7125--forum-invite.png)

*Earlier mock-browser capture, not current-head runtime proof. [Error
and successful-post
captures](https://github.com/block/buzz/pull/7125#issuecomment-5513883462);
no before-state/native capture available.*

**Limits:** cancellation cannot undo accepted membership changes or
dispatched posts; authorization and publication are not atomic. Recovery
is same-window, subject to browser storage limits, and is not a durable
in-flight send journal: reload/crash can lose a pending snapshot.
Cross-window coordination and in-flight upload custody are unchanged.
The parent's legacy member-agent compatibility does not establish
ownership for nonmember invitations.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-04 17:33:30 +00:00
Logan Johnson
d595806fc3 fix(desktop): authorize remote mentions at publication (#7124)
🤖

## Summary

In Buzz Desktop, you could own an agent running on another device but be
unable to mention it in a channel where it had not yet joined: it was
filtered out before you could invite it. A selected agent could also
disappear from the message's recipients when permissions changed. This
lets you select an eligible agent in the existing **@ menu**, invite it
from the message composer, and send to that agent—or see an error and
keep your draft rather than silently sending without it.

#### Where the experience changes

| Screen / control | Before → after |
| --- | --- |
| A channel's **Message #…** composer, or a message's **Reply in thread
to …** composer | Type `@` (or use the existing @ button), choose your
agent, write the message and press **Send message**. An owned agent not
yet in the channel can now reach the existing **“Mention people outside
this channel?”** dialog when its response settings allow you to address
it. |
| That dialog's **Invite** button | Previously the membership
requirement could block the agent before the invitation. Now Invite
checks permission to add it, adds it as an agent member of the
**channel** (not just the thread), then rechecks membership and response
permission before sending the waiting message. An agent already in the
channel needs no invitation. |
| Existing direct message, or the new-message screen with the **To:**
field | A mention is checked against the conversation the message will
actually enter, including a newly created direct message, rather than
the old or not-yet-created destination. This does not add an Invite
control to direct messages. |
| Editing a message / sending attachments | The selected agent remains
part of the send or edit attempt through attachment upload and the final
permission check. Lost permission produces a visible error instead of
dropping that recipient. |

**Invite is not the only chat choice.** The existing **Do nothing**
button sends the message *without inviting or notifying the nonmembers*;
their names remain references in the text. Where you cannot invite, that
choice is labelled **Send anyway**. To abandon the send instead, dismiss
the dialog with Escape. Invitation actions are disabled while
preparation is pending, preventing duplicate clicks.

**Leaving and returning must not resurrect a cancelled send.** Switching
threads or leaving the composer cancels its pending invitation, even if
you return to the same thread. Cancellation before dispatch sends no
message; an accepted membership change cannot be automatically undone.
An ordinary send without a pending invitation remains bound to its
original destination rather than following you into another
conversation.

**Failed sends must not overwrite your next draft.** If you leave a
thread, return and replace or deliberately clear its draft while an
older send is pending, the older failure cannot restore deleted text,
recipients or files; success cannot erase the newer draft—even if its
text is identical. An untouched draft cleared automatically for sending
remains recoverable on failure. This protection also covers reopening
the composer and starting a newer send.

The channel timeline also keeps its existing **new-messages / Jump to
latest** button available when newer messages are waiting to be
displayed. For example, after sharing a reply to the channel and closing
the thread panel, you can click the catch-up button to reveal buffered
messages. Closing the thread does **not** guarantee the shared row
appears automatically or force you away from reading history.

### Related issue

Built on [#7122](https://github.com/block/buzz/pull/7122), base branch
`split/owned-agent-discovery`, which lets Desktop find and verify owned
agents independently of this device. Current integration head:
`1144465d00273cf74b7c22544ae5a3299bd98560`, built on exact published
root `3a56d17824522580fe04cae463b54f4c7ba66021`. Root #7122 has its own
CI and security gates; this PR must not land ahead of that dependency.
Finding an agent is not channel membership, online status or a promise
of a reply. This PR changes what the existing message controls can do
with those agents; it adds no profile, presence, cloud marker or remote
start/stop UI.

Standalone forum post/reply **Invite / Cancel** is added separately in
[#7125](https://github.com/block/buzz/pull/7125); here those composers
only gain visible authorization errors. Same-name selection/binding
fixes ([#7133](https://github.com/block/buzz/pull/7133)) and mention
spacing ([#7128](https://github.com/block/buzz/pull/7128)) are not
included.

Extracted from [#7114](https://github.com/block/buzz/pull/7114)
(historical source `98fe33ec`). [Behavior and draft-recovery
contract](1144465d00/docs/remote-mention-routing.md)
· [Originating
discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848).

### Testing

![Channel composer after Send: RemoteScout is not a channel member;
Invite adds it before sending, while Do nothing sends without inviting
or notifying
it](https://raw.githubusercontent.com/block/buzz/b20345da0bd648ecbc78988637c89893a33ca2f3/pr-7124--remote-invite.png)

*Earlier candidate, mock desktop browser: the existing channel dialog
now reachable for an eligible owned agent on another device. The two
buttons have different send outcomes; Do nothing is not Cancel.*
[Success and denial
captures](https://github.com/block/buzz/pull/7124#issuecomment-5482292703)
· [Pending-state
capture](https://github.com/block/buzz/pull/7124#issuecomment-5482604329).
These show the relevant UI, not live agent availability, native
authorization or the later draft-storage/catch-up repairs. No
before-state screenshot is available.

Existing coverage exercises exact recipients, invitation
rejection/cancellation, new direct-message destinations, uploads, edits,
thread re-entry and stored-draft deletion. The timeline regression
checks the shared reply becomes visible using the available catch-up
action.

**Integration validation (2026-09-02):** independently reviewed the
routing delta onto root `3a56d178`: seven original patches unchanged;
two reconciliations retain generic publication-error toasts alongside
authorization errors and retain non-authored editability updates. Added
two production-hook regression tests (normal and queued-media
publication) requiring visible generic error, recovered draft and
released pending state.

- Writer validation: **5,995 Desktop tests**, **42 focused tests**, **22
mock-IPC browser journeys** (18 routing, 2 root provenance, 2
destination binding), and **1 voice-note failure journey** passed; lint,
types, size guards and E2E build also passed.
- The broad suite ran before the final formatting-only test amendment,
not as an exact-final-head rerun. Independent AST comparison confirmed
that amendment is semantics-preserving; **4 fresh assertions at final
`1144465d`** passed. Publication rechecked final-head TypeScript,
amended-test formatting and `git diff --check` successfully. No new full
repository `just ci` run is claimed.
- Browser tests use an isolated E2E build and **mock IPC**, not live
relay/native authorization. Historical screenshots above are explicitly
earlier UI evidence, not exact-head runtime certification. Packaged
Tauri/live-relay behavior was not independently witnessed.
- **Published-head gates:** [current CI
run](https://github.com/block/buzz/actions/runs/33657948560) and
[renewed exact-head formal review
request](https://github.com/block/buzz/pull/7124#issuecomment-5513218340)
must clear before landing. [Earlier CI
run](https://github.com/block/buzz/actions/runs/33438436438) and the two
earlier approvals cover `7ffead0f`, not this new head. Root CI/security
clearance remains separate; the independent scoped integration approval
is not merge authorization.

To try it: in a channel or thread, select an owned nonmember agent,
Send, then Invite or Escape and retry. Deny the add or revoke its
response permission before sending: expect a visible error and
recoverable draft, not a message missing the agent. During a pending
send, return to the source thread, edit or clear the draft, then leave
again: late completion must not overwrite that choice.

**Limits:** permission checks and sending are separate operations;
cancellation cannot retract a dispatched message. Draft protection is
same-window, not new cross-window deletion synchronization. Standalone
forum transport failure can still restore text/media without the exact
selected recipients. Native compatibility is inherited: open-source
builds may still recognize a valid legacy, self-declared agent already
in the channel when verified ownership is absent or rejected; that does
not establish ownership or unlock this owned-nonmember invitation path.
Invalid policy from a verified owner is still rejected. No agent
response is guaranteed.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-04 16:35:35 +00:00
Salman Mohammed
ce9decb235 fix(acp): rename system tag to agent-instructions (#7332)
## Summary

Rename the paired persona prompt boundary from `<system>` to
`<agent-instructions>` in both modern and legacy ACP delivery paths.
Update Desktop diagnostics to label the new boundary as Agent
Instructions while keeping archived `<system>` captures readable.

`system` is confusing (esp with system role) and agent instructions is
what shows up in the UI today

<img width="767" height="298" alt="Screenshot 2026-09-04 at 11 37 54 AM"
src="https://github.com/user-attachments/assets/4e34ac06-db50-461e-a555-ce17885b0031"
/>

### Related issue

Follow-up to #6701. No duplicate open issue or PR found.

### Testing

- `cargo test -p buzz-acp`
- `cargo clippy -p buzz-acp --all-targets -- -D warnings`
- `node --import ./desktop/test-loader.mjs --experimental-strip-types
--test
desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs
desktop/src/features/agents/ui/agentSessionTranscript.test.mjs`
- `pnpm --dir desktop typecheck`
- Pre-push hook: 6,237 Desktop tests, 3,164 Tauri tests, and all 13 Rust
unit-test lanes passed

Generated with Codex

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
2026-09-04 16:35:18 +00:00
Logan Johnson
ee883d73fb fix(desktop): bind duplicate mention selections to exact recipients (#7133)
## Summary

Selecting two people or agents named Scout could replace the first
recipient with the second even though the message still looked right.
This binds each selection to its exact identity: the first keeps
`@Scout`, and a conflicting selection gets `@Scout (<full public key>)`.
Removing one no longer removes or redirects the other.

- Reuse the existing **@ suggestion list**; team selection and automatic
agent addressing reserve and reuse distinct labels too. Typing an
ambiguous name manually shows an instruction to use the picker and
preserves the draft without publishing—in chat, edits and standalone
forums.
- Share literal mention matching across recipient extraction, removal,
display and editing so a shorter name cannot claim another recipient's
longer or qualified label.
- Rebuild authored references when a message is edited, and use the
latest authorized snapshot when reopening or forwarding.
Rendering/editing can recover qualified identities only from the
message's recorded references, not from a key typed into its body.
Unresolvable historical names remain literal rather than guessed
recipients.
- Wrap full-key labels within narrow/zoomed layouts while keeping their
complete accessible label and ordinary mention icons. Edit activation
waits for the action menu's focus cleanup before focusing the editor.

### Related issue

Targets `main`; mention spacing (#7128) is already merged. Split from
#7114. This is independent of the #7124 → #7125 remote-invitation stack
and does not expand agent eligibility or invitation permissions. The
separate Enter-selection suffix issue remains tracked in #7253.

### Testing

The desktop unit suite and focused mock-Chromium checks passed on the
published integration candidate, including pending-paste selection,
edit/forwarding, copy and narrow-layout cases; formatting, types and
frontend builds passed. On `0b3b18c0`, 64 focused trust/paste/selection
unit tests and six mock-Chromium tests passed with zero browser retries,
including actual timeline chip copy → fresh channel paste → send and
mismatched-key rejection; TypeScript, changed-file Biome and an isolated
E2E build passed. The broader browser run had copy failures before the
focused repairs and is not claimed as wholly green. See [live
CI](https://github.com/block/buzz/pull/7133/checks) for current-head
results. No full local `just ci` pass, native/live-relay or
cross-browser validation is claimed.

To try it: select two same-name recipients, remove one, send, then
edit/reopen and forward; only the intended identities should remain.
Type an ambiguous name without choosing a suggestion and check that
sending retains the draft with an error. Inspect full-key labels in a
narrow window at 150% text size, and open Edit and type immediately.

![Ambiguous mention keeps the chat
draft](https://raw.githubusercontent.com/block/buzz/c48053209b36b3b1bf7197cf2d606d27b48a27c0/pr-7133--ambiguous-general.png)

![Full-key label in a narrow, zoomed
composer](https://raw.githubusercontent.com/block/buzz/ac262f00de6fe3860bff2f87dc0e98165ed6b651/pr-7133--layout-1.5-composer.png)

*Earlier mock-browser captures, not current-head runtime proof. No new
before-state capture; screenshots alone do not prove recipient
delivery.*

**Clipboard trust:** generated full-key-qualified mentions now retain
their exact recipient after copy/paste when the full key matches the
clipboard record and community directory/profile state independently
vouches for the base alias, including numeric collision suffixes. A
qualifier alone does not establish trust. Arbitrary historical labels
still cannot always be reconstructed.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Bad Janet <150b20bdf6130418df9239dd1bd082c71612c8d653b47c277200365b9be215dc@buzz>
2026-09-04 11:51:41 -04:00
Luke Tornquist
cd02b693aa refactor(relay): extract NIP-29 membership authorization (#7285)
## Why

NIP-29 membership authorization for kinds 9000, 9001, and 9022 was
embedded in a large async handler, requiring Postgres and Redis to test
and repeating the last-owner rule across five call sites. This addresses
[TheSentinel454/buzz#24](https://github.com/TheSentinel454/buzz/issues/24)
without adding the broader state/repository abstraction suggested there
because HTTP and WebSocket writes already share ingestion.

## What

- Extract pure, typed membership authorization decisions while leaving
database reads and mutations in `validate_admin_event`.
- Preserve existing client-visible errors and independent database
last-owner safeguards.
- Collapse five last-owner policy restatements into one predicate and
share the identical self-departure policy.
- Include the relay decision modules in `just test-unit`, so these tests
execute in CI.

## Risk Assessment

Medium-low. This touches production relay authorization, but
intentionally preserves wire behavior and database defense in depth;
exhaustive decision tables and relay-backed tests cover the affected
paths.

## Simplification

This removes repeated policy from the orchestration path and makes the
rule set directly testable without introducing a repository trait or
second transport path.

## Verification

Verified at `15255a090797f85874921120003c645962fefaed`:

- `just test-unit` — all 10 package summaries passed. An initial run hit
two unrelated timing-sensitive `buzz-acp` failures; the complete retry
passed 905/905 in that package.
- `cargo fmt --all -- --check`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `just file-size-check` — 10/10 policy tests passed.
- `just security-review-check` — 13/13 tests passed.
- Push preflight — `push-head-scope`, `branch-skew`, `file-size-check`,
`rust-tests`, and `desktop-tauri-checks` passed.
- Blox Postgres relay lane — 84/84 at the byte-identical source patch;
full live `e2e_relay` behavior matched the clean base (45 pass and the
same pre-existing kind:9002 failure on each).

Generated with Claude Code

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 10:46:55 -04:00
Will Pfleger
01bacb8df3 chore(release): release Buzz Desktop version 0.5.22 (#7308)
## Buzz Desktop release v0.5.22

- **Frozen main:** `75f101d8b4f5b4b26f9891b73b87c67fedac25a0`
- **Reviewed candidate:** `9ceb1f79bbc21785a0a075c40aecb3c058b1ea15`
- **Previous desktop release:** `desktop-v0.5.20`
- **Proposed immutable tag:** `desktop-v0.5.22`

This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.

The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-09-04 09:57:17 -04:00
Matt Toohey
b1f6b7ef77 feat(desktop): preserve mentions across copy and paste (#7228)
Copying a message out of the timeline lost the mention. The rendered
chip drops
the `@` for display, so the clipboard carried "John Smith" — two
ordinary words
no composer could bind back to a pubkey. Pasting into another channel
produced
dead text, and sending it tagged nobody.

## What changed

Every Buzz copy now writes two clipboard flavors in one transaction:

- **`text/plain`** — readable anywhere, sigils restored, no pubkeys.
This is what
  TextEdit, Slack, and every other external app receive.
- **`text/html`** — the same content with each mention wrapped in a span
carrying
  `data-mention-pubkey` / `-label` / `-kind`.

On paste, the composer harvests those records, registers each `name →
pubkey`
with the existing mention machinery, and inserts the content: the chip
re-lights
and the send path recovers the identity the author tagged. A marker
attribute
records what the plain flavor holds, so a Markdown copy pastes through
the text
pipeline and a rendered copy through the HTML one.

**Covered surfaces:** timeline selection copy, thread-panel selection
copy,
forum post/reply selection copy, "Copy message", and composer copy/cut —
plus
paste in both the channel and forum composers.

## Trust boundary

Clipboard HTML is untrusted, and the branch treats it that way:

- Records are capped (50), labels bounded (200 chars), and a pubkey must
be
  64 hex before it can become a `p` tag.
- **Only mentions the paste actually shows are registered.** An empty
`<span data-mention-pubkey=… -label="Jane Doe">` would otherwise rebind
that
display name for the rest of the composer session, so a later
hand-written
@Jane Doe would chip-light convincingly against the attacker's key. Each
  branch registers only the records whose label appears in the text *it*
inserts, matched with `getMentionOffsets` — the same matcher the
send-time
  extractor uses.
- **The visibility gate reads only what ProseMirror will insert.**
`DOMParser` hard-drops `script`, `style`, `title`, `noscript`, `object`,
and
`head` content, so `visible<style>@Jane Doe</style>` beside an empty
chip span
used to smuggle a binding past the gate. Those elements are stripped
before
  either output is derived.
- **A partial chip never gains a sigil.** A selection crossing a chip
boundary
falls through to the browser's default copy, which serializes the full
identity
attributes around a slice of the text — pasting that invented "@Smith"
out of
"John Smith". Paste now leaves a fragment as plain text, tolerating only
what a
whole chip picks up in transit (restored sigil, author casing, U+00A0
swaps,
  and the label cap's own ellipsis).

Both clipboard sides share one `matchChipTextToLabel` helper, in the
module that
owns the label attributes, so copy and paste cannot drift on what counts
as a
whole chip.

## Notes

- Mention matching reuses `getMentionOffsets`, so code spans and fences
are
  excluded and the longest display name wins.
- The plain flavor inlines chip boxes before reading `innerText`; a chip
is a
flex container, so the browser's own copy split "@John Smith" onto its
own line.
- `MarkdownMention` and `MacEmacsTextShortcuts` are extracted verbatim
from
`markdown.tsx` and `useRichTextEditor.ts` to keep both files under the
size gate.

## Testing

- ~40 unit tests over the flavor builder/parser, the visibility filter,
the
  ignored-tag sweep, and the chip-match verdicts.
- A Playwright spec (`desktop/tests/e2e/mention-clipboard.spec.ts`)
driving real
  copy/cut/paste DOM events: timeline and "Copy message" of a multi-word
non-member mention pasted into another channel and sent with the
original
pubkey in its `p` tag; the forum copy → forum reply round trip; composer
  copy/cut; plain flavor asserted to contain no 64-hex string; the
boundary-crossing drag; and the hidden-record and `<style>` smuggling
vectors.
- Every regression test is bound to a production seam and fails with its
guard
  removed.
- `just ci` / pre-push lanes green (5927 desktop unit tests, typecheck,
lint,
  file-size gate).

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 16:45:13 +10:00
morgmart
b7dbe90001 fix(desktop-next): relight the dark text ramp and enforce contrast in CI
Three fixes, and a guard so they cannot regress silently.

`text-on-warning` pointed at `var(--neutral-12)`, which is near-black in light
mode and near-white in dark. The warning fill is the same amber in both, so dark
mode put #f5f5f5 on #eab308: APCA Lc 34, WCAG 1.76:1. It is a literal now — a
paired-text token follows its fill, never the mode.

The dark neutral text steps were too dim. `text-secondary` (#8f8f8f) measured
Lc 38–41 and `text-tertiary` (#6e6e6e) Lc 23–26 against an Lc 60 body target,
while passing WCAG AA at 4.4–5.6:1 — legal, and visibly murky. Raise dark
neutral-9 to #a6a6a6 and neutral-10 to #c1c1c1, sized against bg-float
(#2a2a2a), the lightest dark surface and so the binding case. Light neutral-9
moves #a3a3a3 -> #909090 for the same reason at the meta target. Both ramps stay
monotonic, and bg-inverse-hover (the other consumer of neutral-10) still carries
its paired text at Lc 70.

This is APCA's polarity asymmetry doing real work: light-on-dark needs more
separation than the same WCAG ratio implies, so the dark ramp's text steps sit
above where mirroring the light ramp would put them.

`text-disabled` stays below target deliberately — low contrast is the signal
that a control is unavailable, and WCAG exempts inactive controls for the same
reason. Raising it would make disabled read as enabled. Recorded as the one
exception, with that reason.

The guard is the durable part. `scripts/check-contrast.mjs` measures every text
role against every surface it can sit on, in both modes, and fails the build
below target. It parses `tokens.css` and resolves each `var()` chain rather than
holding a copied list of values, so it cannot drift from the system it audits.
`scripts/apca.mjs` is the algorithm, asserted against the published reference
values on every run so a bad edit to the maths fails loudly rather than quietly
shifting every verdict. Verified by falsification: reverting either fix fails
the check, and perturbing one APCA exponent trips the reference assertion.

Also corrects an earlier misreading: the two `.dark` blocks are not duplicate
declarations. The first holds the dark ramp, the second overrides surface roles
so the panel lifts off pure black. That is the intended layering.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 21:41:51 -07:00
Logan Johnson
4b0744d7f3 test(desktop): await Bestie drag and profile hover endpoints (#7294) 2026-09-03 23:52:55 -04:00
morgmart
972edc6fa6 fix(desktop): deepen the default accent so its white label text is legible
The default accent was #3b82f6, and white label text on it measures WCAG 2
3.68:1 — below the 4.5:1 AA minimum for normal text. That applied to every
primary button and active sidebar row in a fresh install, not to a setting
someone had opted into.

Move it to #2563eb, one step deeper: WCAG 5.17:1 and APCA Lc 80, so it now
passes both standards. Radix, Bootstrap, and Tailwind's blue-600 all sit in this
range, which is where a blue has to be to carry white text.

Darkening the fill is the fix rather than switching the label to black. Black on
the old hue scores WCAG 5.71 and would "pass", but measures APCA Lc 40 —
perceptually unreadable, and visibly worse. This is the documented WCAG 2 flaw
on saturated blue: the formula underweights the blue channel and ignores
polarity. Apple ships white on #0088ff–#3daefc in Messages at WCAG 2.4–3.5 for
the same reason. DESIGN.md now records the position (judge with APCA, report
WCAG, constrain the fill rather than degrade the text) so it is a decision with
evidence rather than a preference.

Retiring a hex is not only cosmetic: it leaves the old value in people's
storage, where it is no longer in ACCENT_COLORS and therefore fails the
community-theme allowlist — parseCommunityThemePreference would return null and
discard the user's syntax theme and follow-system flag along with the accent.
Both storage paths now migrate the retired value instead: the community
preference maps it on parse, and the local accent upgrades on read (all three
read sites go through one helper). A regression test covers the migration and
asserts an arbitrary hex is still rejected, so the migration cannot become a
general escape hatch; removing the mapping fails that test.

The communityThemeSync fixture moves to a current accent — it covers coordinate
logic, and a retired hex there would be rewritten mid-assertion for reasons
unrelated to what it tests.

Verified: 6112 desktop tests pass, tsc clean, biome clean.
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 20:16:58 -07:00
morgmart
4c2e417b37 style(desktop-next): wrap the token table into three equal columns
The value column had been holding a single unbroken gradient literal, which under
the default `auto` layout claimed most of the table's width and pushed the other
two columns into a scroll. Switch to `table-fixed` with an explicit colgroup so
the thirds hold regardless of content, and let cells wrap instead of scrolling —
the horizontal scroll container is gone.

The value uses `break-all` rather than `break-words`: a gradient is one token with
no spaces to break at, so word-boundary wrapping still overflows. The swatch stays
pinned to the first line while the value wraps beside it, so the kind label moves
under the value rather than competing with it for the same line.

Verified equal columns and zero overflowing cells at 380, 480, 700, 1024, and
1280px.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 18:43:00 -07:00
morgmart
69149c7a1b feat(desktop-next): add a token table, and declare four missing paired-text roles
The colour page explains the system but never lists the tokens themselves, so
there is nowhere to answer "what does bg-inset actually paint". Add
/design/color/table: every token in one table — the name you type, the base
token it resolves through, and the value it paints.

The values are read from the live CSS rather than typed into the registry. A hex
column written by hand is a second source of truth for the same fact: edit a ramp
and the documentation is silently wrong. This needs two different reads, because
neither answers both questions — getComputedStyle returns the resolved value
(#ffffff) and has already lost the indirection, while walking document.styleSheets
finds the declared value (var(--neutral-1)), which is the only place the chain
still exists. So CSSOM for what a token points at, computed style for what colour
that is. It re-resolves on scheme change, which is visible in dark mode where
bg-panel points at neutral 3 rather than neutral 1.

The table immediately found a real defect. The registry has documented
text-on-danger, text-on-success, text-on-warning, and text-on-info since the
status groups were generated, but tokens.css never declared them — all four
resolved to nothing, so text on a danger button fell back to inherited near-black
on red. Declare them and register them with Tailwind. text-on-warning is
neutral-12, not white: the warning fill is amber (#eab308) and white on it fails
contrast.

Also spell it "Color" in the nav, per the product's US English. The route is now
/design/color, with the table nested under it. The file is color_.table.tsx —
the trailing underscore keeps the URL while opting out of nesting, because
otherwise /design/color becomes a layout route whose component must render an
Outlet, and ColourPage does not, so the parent page rendered in place of the
table. Component and type names still say Colour; renaming those is a separate
change from what the interface says.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 18:35:39 -07:00
Luke Tornquist
2ac0aa1dd1 Collapse contiguous join messages (#7262) 2026-09-03 19:26:21 -04:00
morgmart
bd627f675d Merge remote-tracking branch 'origin/main' into morganm/design-system
* origin/main:
  chore(release): release Buzz Desktop version 0.5.21 (#7301)
  fix(scripts): copy global-agent-config.json in buzz-adopt-prod-agents (#7303)
  feat(relay): add early startup lifecycle logs (#7258)
  docs(nip-fi): add Blossom kind-24242 media possession-proof exception (#7278)
  fix(desktop): wrap message tables within the available pane (#7279)
  🤖 fix(desktop): harden smoke E2E tests against Bestie overlay and toast timing (#7270)
  Show status and huddle indicators beside names (#7112)
  Add mobile voice notes (#7121)
  perf(desktop): publish mention sends before waking agents (#7154)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 15:39:53 -07:00
Will Pfleger
75f101d8b4 chore(release): release Buzz Desktop version 0.5.21 (#7301)
## Buzz Desktop release v0.5.21

- **Frozen main:** `88687876f7808a2fd742b7eb2e4b9f87d999ad8d`
- **Reviewed candidate:** `47e4e0347fb4142df55b03e0e3dc537fe33a5d62`
- **Previous desktop release:** `desktop-v0.5.20`
- **Proposed immutable tag:** `desktop-v0.5.21`

This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.

The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-09-03 17:41:22 -04:00
Will Pfleger
8e7b275cb0 fix(scripts): copy global-agent-config.json in buzz-adopt-prod-agents (#7303)
## What

Extend `scripts/buzz-adopt-prod-agents.sh` to copy
`agents/global-agent-config.json` from the prod app-data store to dev as
part of the existing atomic bundle commit, and document what the script
cannot copy (baked build-time defaults).

## Why

`global-agent-config.json` holds the owner's global agent defaults
(`env_vars`, `provider`, `model`, `preferred_runtime`). Previously the
script carried the dev copy across verbatim — it was absent from the
bundle drop list and not in `RECORD_FILES`. After a `just reset`, the
dev build inherited stale global defaults (or none at all) instead of
mirroring prod, inconsistent with how all other bundle files are
handled.

The "WHAT THIS DOES NOT DO" header lacked any mention of baked
build-time defaults, which are a separate (and common) source of
prod/dev parity confusion.

## Changes

- **`RECORD_FILES`** — add `global-agent-config.json`. The file has no
volatile fields (`GlobalAgentConfig` struct =
`env_vars`/`provider`/`model`/`preferred_runtime`), so it joins the
verbatim-copy path already used by `teams.json` — no new transform.
- **Stage-seed block** — add `global-agent-config.json` to the `rm -rf`
drop list so the prod overlay replaces it cleanly; remove its stale
mention in the "carried verbatim" comment.
- **Docs/comments** — update "4-file bundle" → "5-file bundle" in the
DMG concurrent-run warning and the step 2 comment; list the file
explicitly in the step 2 header comment.
- **"WHAT THIS DOES NOT DO" header** — add a bullet explaining that
baked build-time agent defaults (`BUZZ_BUILD_BUZZ_AGENT_PROVIDER` /
`BUZZ_BUILD_AGENT_ENV` via `option_env!` in `build.rs`) are compiled
into the binary, not stored in app-data. Includes the correct `just
production` invocation pattern and a `.env` warning.
- **Preflight symlink checks and dry-run output** iterate `RECORD_FILES`
and pick up the new entry automatically — no other changes needed.

Verified: `bash -n` clean, `shellcheck` clean, `--dry-run --force` shows
`[dry-run] stage global-agent-config.json (0600)` in the expected
position.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-03 17:32:10 -04:00
morgmart
61e3792118 refactor(desktop-next): take colour and type specimens out of cards
A swatch on a grey fill is being judged against a surface the product will never
reproduce, and the dividers between swatches were redundant — each colour already
separates itself. Same for type specimens: a box around a size sample changes the
contrast you are evaluating it in.

Roles and specimens now sit directly on the page, separated by space alone. The
distinction is between two list shapes rather than a preference: `Rows` keeps
dividers for uniform rows where the eye needs a line to track along (the size and
tracking ramps, the audit table); `Specimens` has neither container nor divider
for entries that carry their own visible difference.

The swatch keeps its hairline. `bg-panel` is white on a white page, so without it
the most-used role in the system renders as nothing — that is a genuine boundary,
not decoration, and it is what makes the no-container treatment safe in both modes.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 13:07:51 -07:00
morgmart
30847f42ff feat(desktop-next): add the type system, fix unreachable border roles
The design system site rendered black borders and dividers while the tokens
said `#d4d4d4`. The border roles were declared in layer 2 and never registered
with Tailwind, so `border-primary` silently resolved to `--color-primary` — the
*text* colour — and every hairline drew at text contrast. Tailwind's `--color-*`
is a single namespace and text/borders share the emphasis names while holding
different values, so borders now register as `--border-color-*`.

Add typography as the second foundation, same three layers as colour: a nine-step
size ramp plus leading, tracking, and weight ramps in layer 1; ten public roles
in layer 2, each carrying its whole setting via Tailwind v4's
`--text-<name>--<property>` convention, so `text-body` alone produces correctly
set text and two supposedly identical labels cannot drift apart. Inter Variable
and JetBrains Mono, both already shipped in every current Buzz client. Every
size derives from one virtual rem, so keyboard zoom and the font-size preference
work by construction.

Soften the documentation surface to match where the product is going: regions
separated by soft fills rather than outlines, hairlines only for genuine
boundaries and never above `border-secondary`, no page-wide gradient behind
reading columns, and no all-caps labels anywhere.

The `/design/typography` page renders from the registry, so it cannot drift from
the system. Add `scripts/check-type.mjs` enforcing the four rules a token cannot
express — no arbitrary sizes (px or rem), no `uppercase`, no hand-applied
tracking, no size role paired with a weight or leading utility.

Also collapse the nav to a wrapped row below `lg`; the fixed 256px column
overflowed the viewport under 420px.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 12:50:39 -07:00
ravarora2
88687876f7 feat(relay): add early startup lifecycle logs (#7258)
## Why

Early relay failures can currently appear as a container restart without
a trustworthy in-process account of whether crypto, structured logging,
configuration, relay identity, or the metrics listener failed. Most of
those steps happen before the Prometheus exporter exists, so their
chronology belongs in logs rather than metrics.

Implements the logs-only early-startup slice of #7238. Post-bind
Prometheus exporter supervision is tracked separately in #7284.

## What changed

- create a process lifecycle recorder before the Tokio runtime and emit
a fixed, versioned JSON schema directly to stderr;
- record started and exactly one terminal event for `crypto_init`,
`tracing_init`, `config_load`, `key_load`, `metrics_bind`, and the
aggregate `process_telemetry` phase;
- keep every status and reason bounded and suppress raw errors that
could contain credentials, keys, URLs, or other secrets;
- return typed metrics-install errors so `metrics_bind` can be
classified without logging raw values, while preserving the existing
public `metrics::install` API;
- document the logs-only evidence contract and add real child-process
regressions for success and failure paths.

This PR adds **no startup metric families** and no dashboard contract.
Existing application metrics remain unchanged.

## Verification

Exact head: `8faf7526822a119efa035e58b2b3c59aa67fc81d`

- `cargo fmt --all -- --check`
- `cargo clippy -p buzz-relay --all-targets -- -D warnings`
- relay binary target: 13 passed, 1 PostgreSQL-only test ignored
- real relay child-process lifecycle target: 9 passed
- full relay package library target: 1,023 passed, 89 ignored; the same
six media tests failed at `crates/buzz-relay/src/api/media.rs:1145` with
`Sqlx(PoolTimedOut)` because local PostgreSQL is unavailable
- three independent exact-head reviews found no correctness, security,
compatibility, lifecycle-accounting, logs-only-scope, or test-adequacy
finding

All exact-head GitHub CI gates are green, including lint, unit tests,
PostgreSQL, relay/backend/desktop integration, both Linux server
cross-compiles, Windows/macOS builds, and security checks.

## Staging verification

- exact multi-architecture image:
`dev-sha-8faf7526822a119efa035e58b2b3c59aa67fc81d-run-33708188952-1`
- immutable manifest:
`sha256:26cad28266a6bb0b0e7081eb6091d374e5489f8bb78c475a4a65737dee86cc67`
- image workflow: https://github.com/block/buzz/actions/runs/33708188952
- focused staging deployment:
https://github.com/squareup/builderbot-platform-core-infrastructure/pull/314
- replacement ReplicaSet `buzz-d68764bc7` has two Ready pods with zero
restarts
- Datadog received one complete, contiguous sequence 1-12 from each pod;
both end with `process_telemetry/terminal/succeeded` at 3 ms
- queries scoped to the replacement ReplicaSet return no data for the
removed `buzz_startup_phase_terminal` or
`buzz_startup_phase_duration_seconds` families

The experimental Row 7 was removed from the Buzz Startup & Rollout
Safety dashboard. This logs-only PR deliberately adds no replacement
dashboard row.

---

**Update Sep 3, 12:26 ET:** Clarified the review boundary: this PR does
not close the broader #7238. Later exporter-task termination is
pre-existing runtime behavior and is now explicitly tracked in #7284; no
production code or staged image changed in this update.

Generated with Codex

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-09-03 13:37:51 -04:00
Will Pfleger
752cbfc037 docs(nip-fi): add Blossom kind-24242 media possession-proof exception (#7278)
Adds the media/Blossom possession-proof exception section to
\`docs/nips/NIP-FI.md\`.

## What this changes

Encodes the condition set confirmed by Thufir's security review
(2026-09-03) as normative MUSTs in NIP-FI. Kind-24242 Blossom auth
events are admitted as the NIP-FI pairing possession proof for **media
routes only** — a named, bounded exception with an explicit precedent
fence against expansion.

### Scope fence

Kind-24242 proofs are valid only on:

| Proof type (`t` tag) | Valid route | Method |
|---|---|---|
| `upload` | `PUT /upload` (and temporary alias `PUT /media/upload`
until removed) | PUT |
| `get` | `GET /media/{hash…}`, `HEAD /media/{hash…}` | GET, HEAD |

All other protected routes MUST reject kind-24242 proofs.

### Upload proofs

Exactly one `x` tag over consumed body bytes; temporal check precedes
body consumption.

### Read proofs (the relaxation Will approved)

Host-wide MAY: no `x` required. Exactly one `server` tag matching the
resolved tenant host is a MUST. Optional `x` must match parent hash if
present.

Named residual (verbatim in spec): within at most 60 seconds from
minting (plus 5s future-skew), a captured full header set allows reading
any media blob on exactly one tenant host — read-only,
membership-checked, revocable, not state-changing, not cross-tenant.

### Freshness (Thufir option 2)

- `created_at <= now + 5s` (bounded future skew)
- `now - created_at <= 60s`
- Exactly one `expiration`, valid at admission, satisfying `expiration
<= created_at + 60s`

### Transport/cardinality

Exactly one each of `Authorization` (Nostr scheme), `t`, `expiration`,
`server`; `x` at most once; reject any duplicate, malformed, or
conflicting instance.

### Per-request pairing

Full assertion verification, exact key equality between assertion
`nostr_pubkey` and kind-24242 signer, deny-map enforcement on every
request. Stub gap named.

### Compliance note

PR #7264 implementation is explicitly non-compliant until the bounded
hardening task lands (named gaps: multi-tag acceptance, 3600s window,
optional `server`).

## Behavioral oracle

`FI-TRACE-HTTP-INGRESS` extended to cover kind-24242 admission and
denial cases.

## Scope

Docs-only. No code changes. The code hardening is a separate follow-on
task.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-03 12:48:23 -04:00
Logan Johnson
6cf514ed90 fix(desktop): wrap message tables within the available pane (#7279)
## Summary
- Let Markdown tables use the available message width instead of their
maximum-content width, inheriting the renderer's existing
`wrap-anywhere` handling for long tokens and links.
- Top-align header cells as well as body cells. Give cells a `min-w-24`
readability floor so short labels do not collapse into one-letter
columns; retain the existing table-local scrollbar when many columns
genuinely cannot fit.
- Preserve semantic table markup, links, inline code, and surrounding
message layout. Add three browser regressions through the real message
renderer (channel, narrow thread, and many-column overflow).

### Related issue
Fixes #5313. No matching open table-readability PR found.

Owner-authorized task channel: `819b36d5-7371-4ed0-bf9b-d461d723779e`

Source:
buzz://message?channel=819b36d5-7371-4ed0-bf9b-d461d723779e&id=3bf2d1bd5df28fcb481e71159dc1be2b3888918b05f67e270354fbfea65a8c90

### Testing
Original production candidate:
`4427f2e6f9ef85a01a267fe9eabd1cf13cacfb78`; base:
`7a9a5233d9d755e715be0c585cf7850e935d28cf`.

- `pnpm -C desktop check` — passed.
- `pnpm -C desktop test` — 6,110 passed, none skipped.
- `pnpm -C desktop typecheck` — passed.
- `BUZZ_PROTECTED_BUILD_OUTPUT=<isolated-production-directory> pnpm -C
desktop build` — passed.
- `pnpm -C desktop build:e2e --outDir <isolated-candidate-directory>` —
passed.
- `just file-size-check` and `git diff --check` — passed.
- Chromium / mock Tauri bridge: all 3 new Playwright tests passed
against the fixed candidate build. Both wrapping tests fail on the
unchanged base build, while the overflow-fallback test passes. Each
browser run used a unique output directory and a non-reused local server
pinned to its build directory.
- At 883px channel message width: table scroll width **1,395 → 883px**.
At 292px thread message width: **1,395 → 292px**. All cells top-aligned;
long tokens/URLs wrap, link destinations and code text remain intact,
short labels stay on one line, and document width remains 1,280px.
- Focused fresh-frame review checked actual before/after screenshots and
the complete diff. It caught one-letter label wrapping in an early
width-only candidate; the final cell-width floor and regression
assertion address that finding.

Limitations: native Tauri/WebKit and relay-backed integration were not
exercised for this CSS-only delta. Repository-wide `just ci` was
attempted but exceeded the local command budget during unrelated Rust
compilation; it is **not** reported green. The full affected TypeScript
package gates above passed on the exact candidate. Production/E2E builds
retain existing chunk-size and mixed static/dynamic import warnings.

Before/after screenshots are posted below using the repository's
screenshot publication script. No merge, production installation, or
runtime restart is requested.


### CI-driven test-only follow-up
Current head: `925d964b6cf31ba704d74baf73769110774b2789`.

Smoke shard 3 exposed an older test in `messaging.spec.ts:457` that
still required three columns of ordinary prose to overflow horizontally
([failure
log](https://github.com/block/buzz/actions/runs/33771325404/job/100702242706)).
This expectation contradicts the intended wrapping change. Updated its
name and assertion to require containment; the separate many-column
local-scroll test is unchanged. No production code or harness settings
changed in this follow-up.

- Affected browser validation: **4/4 passed** (updated existing
prose/narrow table case plus the three new channel/thread/overflow
cases).
- Reused the immutable `4427f2e6` E2E build on a fresh non-reused
server; production source is unchanged at the current head. The
package/build checks above remain evidence for that unchanged production
tree, not a claim of rerunning the full suite at the new SHA.
- Targeted Biome check, file-size gate, and `git diff --check` passed.
- Current-head CI:
https://github.com/block/buzz/actions/runs/33773490446
- Prior unrelated Bestie baseline failure and reproduction:
https://github.com/block/buzz/pull/7279#issuecomment-5528077181
- Security authorization and review must target the **current** head,
not the old screenshot/build SHA. A Block organization member must
comment exactly `@buzz-security-review
925d964b6cf31ba704d74baf73769110774b2789`.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-03 16:07:14 +00:00
Will Pfleger
7a9a5233d9 🤖 fix(desktop): harden smoke E2E tests against Bestie overlay and toast timing (#7270)
## What

Two remaining E2E hardening fixes from the Desktop Smoke flake pattern
introduced by `ac5a18697` (Bestie — added `VITE_BUZZ_BESTIE=1` to
`.env.e2e` and mounted `BestieGlobalOverlay` globally).

The toast/DM-retry fix landed independently in main via #7127
(`input.hover()` + bounded `toHaveCount` wait); that hunk is dropped
from this PR.

## Fixes

### `agent-control-regressions.spec.ts:240` — Stop does not accept an
unconfirmed or foreign-channel result

**Cause:** Playwright 1.60.0's `page.clock.install()` fakes all timers
including `requestAnimationFrame`. The test called it before opening the
settings menu. With RAF frozen, the `DropdownMenuContent`'s `zoom-in-95
duration-150` CSS enter-animation never advances — Playwright's
stability check observes a continuously-changing bounding box until the
30s test timeout.

**Fix:** Re-sequence so the menu is opened on real time first. After
`openAgentActivity`, open the trigger, assert visibility/enabled, call
`waitForAnimations(page)` to settle the enter-animation (real
`setTimeout`, no fake clock installed yet), then install the clock. The
`fastForward(8_001)` correlation timeout still works because it's
scheduled after the clock is active. Pointer actionability preserved —
normal `stop.click()` (no `force`) fails with pointer-interception under
a covering surface.

### `message-feedback-snapshots.spec.ts:97` — profile hover uses the
channel hover surface

**Cause:** `channel.hover()` triggers a CSS `transition-colors`
animation. With the Bestie `LayoutGroup` mounted, `evaluate()` captures
a mid-transition background value that never matches the profile card's
settled token.

**Fix:** `waitForAnimations(page)` after `channel.hover()` and before
reading `channelHoverColor`.

## Evidence

- Target specs pass: stop-turn 8/8, profile-hover passes.
- Full `agent-control-regressions.spec.ts` (7 tests) green.
- `just desktop-typecheck` clean at pushed head `0fbfe2a9f`.
- No production code changed.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
2026-09-03 08:59:49 -04:00
klopez4212
c6ca9d94d9 Show status and huddle indicators beside names (#7112)
## Summary

- show status and huddle emoji beside names in DMs and message rows
- provide accessible tooltips, fallback status emoji, and profile-menu
icon replacement
- add the desktop status editor with preset durations, a ShadCN
calendar, and a capped half-hour time menu

## Validation

- desktop checks, typecheck, and file-size guard
- 5,802 desktop tests
- focused Playwright coverage (3 passed)
- E2E build and native Builderlab staging verification

Updated visual snapshots are attached in the PR comments.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-09-03 09:39:37 +00:00