Skip to content

An event is not a message, and a notice is not either (GRYT-896) - #374

Merged
sivert-io merged 2 commits into
mainfrom
claude/GRYT-896-directed-notices
Sep 3, 2026
Merged

An event is not a message, and a notice is not either (GRYT-896)#374
sivert-io merged 2 commits into
mainfrom
claude/GRYT-896-directed-notices

Conversation

@sivert-io

Copy link
Copy Markdown
Member

Client half. Server half is Gryt-chat/server#130merge that one first, or this listens for an event nothing sends.

Two changes, one mistake behind both: everything a server said became a chat message shaped like somebody talking.

Events become one quiet row

A join filled a whole message slot — the word "System" where a nickname goes, in the same weight and size, a 51px avatar column held empty so the absence read as a missing avatar rather than a different kind of thing, a timestamp on its own line, plus reactions and a hover toolbar. You could react to somebody joining. Two people arriving cost two full rows.

Now: the avatar column carries a small arrow — in for joined, out for left — so the space is doing something and the row is unmistakably not somebody talking. No name line, because there is no author. The time trails the text. One 34px line.

The left edge every message shares stays where it is. A centred rule would say "not a message" more loudly and break that edge, and a channel where people come and go is exactly where a run of rules chops the conversation into fragments.

Which arrow is read off the text rather than a new column — the server sends these as ordinary message rows and two verbs are not worth a migration. Anything unrecognised draws the "in" arrow, the right guess for anything that is not a departure.

A directed notice is not in chat at all

The server sends { kind, version } and this side owns every word. That is the security property: text a server chose, in app furniture, addressed to one person, is a phishing message. Taking the text away is the only version that cannot be turned into one.

parseServerNotice re-checks the shape rather than trusting it. The server validating its own output guards against a bug in the server; this guards against the server, which is somebody else's machine and need not be running our code. Unknown kinds, malformed values and smuggled extra fields are dropped in silence — a malformed notice is a server bug or a server trying something, and neither is worth interrupting somebody with.

The panel sits below the channel header, above the messages, and says "From <server>, only to you" in the frame. Inside the server's area, never in Gryt's own chrome — anything painted there should only ever be Gryt. Its two links are constants in the file; a server cannot reach them.

Dismissal is per device and permanent, and the server is not told. A server learning which of its notices somebody has silenced is the beginning of working around it.

Tests

check-server-notice.mjs, wired into CI. Five mutations, each caught:

mutation caught
version pattern widened
payload spread through the parse, carrying extra fields
the switch's default: return null removed
panel reads a URL off the notice
panel's own link points at a host we do not own

The last two are source checks. A panel that interpolated a notice field into an href would typecheck and look reasonable in review.

Verified against a running server

Real emit, real listener, real render:

  • "From Rig, only to you", the title, the version interpolated into client-owned copy, the download button
  • three event rows at 34px each, @name joined the server 23:41 on one line, no "System" anywhere

Two things the rig caught that nothing else would

  • chatView became a fragment, and both places that render it drop it into a flex row — so the chat stopped being the thing that grows and collapsed to a column one character wide. It is a filling flex column now.
  • The timestamp dropped to its own line, because MarkdownRenderer emits a block. A second line for four words is the thing this treatment exists to stop. Baseline-aligned flex row instead.

tsc -b and eslint clean.

🤖 Generated with Claude Code

Two changes, both about the same mistake: everything a server said became a
chat message shaped like somebody talking.

**Events become one quiet row.**

A join used to fill a whole message slot — the word "System" where a nickname
goes, in the same weight and size, a 51px avatar column held empty so the
absence read as a missing avatar rather than a different kind of thing, a
timestamp on its own line, and reactions and a hover toolbar, so you could react
to somebody joining. Two people arriving cost two full rows.

The avatar column carries a small arrow now — in for joined, out for left — so
the space is doing something and the row is unmistakably not somebody talking.
No name line: there is no author. The time trails the text.

The left edge every message shares stays where it is. A centred rule would say
"not a message" more loudly and break that edge, and a channel where people come
and go is exactly where a run of rules chops the conversation into fragments.

Which arrow is read off the text rather than a new column, because the server
sends these as ordinary message rows and two verbs are not worth a migration.
Anything unrecognised draws the "in" arrow, which is the right guess for
anything that is not a departure.

**A directed notice is not in chat at all.**

The server sends `{ kind, version }` and this side owns every word. That is the
security property: a panel in app furniture, carrying text a server chose,
addressed to one person, is a phishing message with a nice border. Taking the
text away is the only version of this that cannot be turned into one.

`parseServerNotice` re-checks the shape rather than trusting it. The server
validating its own output guards against a bug in the server; this guards
against the server, which is somebody else's machine and need not be running our
code. Unknown kinds, malformed values and smuggled extra fields are all dropped
in silence — a malformed notice is a server bug or a server trying something,
and neither is worth interrupting somebody with.

The panel sits below the channel header and above the messages, inside the
server's area rather than in the app's own chrome, and says "From <server>, only
to you" in the frame. Anything painted in Gryt's chrome should only ever be
Gryt. Its two links are constants in this file; a server cannot reach them.

Dismissal is per device and permanent, and the server is not told — a server
learning which of its notices somebody has silenced is the beginning of working
around it.

`check-server-notice.mjs` holds the line, and is wired into CI. Five mutations,
each caught: widening the version pattern, spreading the payload through the
parse, dropping the switch's default arm, reading a URL off the notice, and
pointing the panel's own link at a host we do not own. The last two are source
checks — a panel that interpolated a notice field into an href would typecheck
and look reasonable in review.

One thing the rig caught that nothing else would: `chatView` became a fragment
with two children, and both places that render it drop it into a flex row — so
the chat stopped being the thing that grows and collapsed to a column one
character wide. It is a filling flex column now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sivert-io
sivert-io force-pushed the claude/GRYT-896-directed-notices branch from da5211c to 37028c3 Compare September 3, 2026 21:50
… (GRYT-896)

Two things the design said and the first pass did not do. Both were found by
looking at it rather than by any check.

**It was rendering above the channel header.** `chatView` put the panel beside
`ChatView` rather than inside it, so the panel sat above "# general" — in the
app's own chrome, which is the one place the whole argument says it must not
be. A server's words in Gryt's furniture is exactly what attributing it was
meant to prevent, so the placement was undoing the reason for the attribution.

`ChatView` takes an `underHeader` slot now and renders it directly beneath the
channel name, above the messages. A slot rather than the panel itself, because
`ChatView` does not know about servers — it is the same component for a DM.

That also puts `chatView` back to a single element, so the flex column added a
few commits ago to stop the chat collapsing is no longer needed.

**It was grey.** `--gryt-warning-3` for the ground and `--gryt-warning-11` for
the rule and the icon, which is what the design used. On `neutral-3` it read as
another panel, and the point of this one is that it is the only thing on the
screen asking for something.

The fallback in `var(--gryt-warning-11, var(--gryt-danger-11))` is gone too:
`@gryt/ui` ships a full warning ramp, so the fallback was dead code that would
have quietly turned the notice red if the token had ever been renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sivert-io
sivert-io merged commit a1f5aec into main Sep 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant