Skip to content

feat: view transitions, keyboard reading, and a face for every instance - #2075

Merged
karlitschek merged 5 commits into
masterfrom
feat/fediverse-flair
Sep 8, 2026
Merged

feat: view transitions, keyboard reading, and a face for every instance#2075
karlitschek merged 5 commits into
masterfrom
feat/fediverse-flair

Conversation

@karlitschek

Copy link
Copy Markdown
Member

Stacks on #2074, which stacks on #2073. Merge in that order.

Where #2074 gave the app feedback, this gives it depth, identity and speed. Still CSS and platform APIs only — no new dependencies, nothing added to the entrypoints but rules.

The platform features the app wasn't using

The codebase had no startViewTransition, no animation-timeline, no @supports anywhere.

  • A picture grows into the lightbox. Tapping a thumbnail used to swap in an unrelated NcModal. The thumbnail and the viewer now share a view-transition-name for the length of one transition, so the browser carries one into the other — and back on close.
  • Route changes transition, so timeline → profile → post reads as one surface changing rather than two pages.
  • Posts rise as they scroll into view, driven off scroll position by the compositor via animation-timeline: view(). No scroll listener, no IntersectionObserver, no main-thread work. Behind @supports, so browsers without it just show the posts.

All three are enhancements: no API, or prefers-reduced-motion: reduce, and the app behaves exactly as it does today.

Federation, which was invisible

The instance an account lives on appeared only inside a title attribute. Now every instance has a colour derived from its hostname — the same colour on every client and every session, with no storage or coordination:

  • remote avatars carry it as a ring,
  • a chip beside the handle names the server,
  • local accounts get neither, because naming this server would be noise.

A timeline now visibly spans the network. instanceIdentity.js is a pure function with 11 specs, including that two accounts on one instance always agree and that five different hosts get five different colours.

Reading without a mouse

There were no shortcuts at all beyond ctrl+enter. Now: j/k walk the timeline, l/f like, b boost, r reply, o/Enter open, n write, g home, ? lists them all.

The part that matters is what doesn't fire: nothing happens while you are typing — in an input, in the contenteditable composer, or with any modifier held — because a shortcut that eats a keystroke mid-sentence is worse than no shortcut. The focused post is outlined, and it is the one l/b/r act on; boosting a direct message is refused rather than attempted.

And the rest

  • A reply chain is one conversation. TimelineSinglePost stacked ancestors, the post and descendants as three unrelated lists. They now hang off a single spine, with the post being read raised off it.
  • A profile is tinted by its own banner — a few pixels sampled into an 8×8 canvas, pushed to a saturation that reads as an accent. A banner that can't be read (cross-origin, missing, transparent) leaves the theme's colour alone, which is what every profile looked like before.
  • The banner drifts as the page scrolls (scroll-driven, same mechanism as above).
  • Empty-state artwork settles in rather than appearing, and stops glaring on dark backgrounds.
  • A like ticks the phone, for anyone holding one.

Tests

41 new specs — 681 vitest total, 1839 PHP, eslint and stylelint clean. The interesting ones: shortcuts staying silent while typing (four ways), j/k stopping at both ends, the focused post acting and its neighbours not, view transitions applying the change exactly once and still applying it when the transition itself fails, and the instance colour being stable and collision-free.

Two notes on honesty:

The empty-state illustrations are not accent-themed. I said I'd theme them; they turn out to hardcode #0082c9 and are served as <img>, so CSS can't reach inside. Doing it properly means inlining eight ~30 KB SVGs into the bundle, which isn't worth it. They now behave well in dark mode instead, and I'd rather say that than claim the feature.

"Delivering to N servers" is not here. The delivery queue is real (social_req_queue) but no endpoint exposes per-activity state and the status response carries no token to correlate with, so it needs backend work rather than a frontend touch. Happy to do it as its own PR.

🤖 Generated with Claude Code

Frank Karlitschek and others added 5 commits September 8, 2026 15:53
Blocking and muting could only be done from an account's profile, and
there was no way to see what you had blocked: `/api/v1/blocks` and
`/api/v1/mutes` answered correctly but nothing in the web client ever
called them, so undoing a block meant remembering who it was.

Settings → Blocked and muted accounts now lists both, with unblock and
unmute inline. Unblocking goes through the store action the profile page
uses, so a relationship stays in step wherever it is shown, and a row
only disappears once the server has confirmed — a refusal leaves it
where it was rather than claiming success.

The page paths of the client-side router also needed server routes:
/blocked, and /follow_requests which had the same defect already —
reloading or bookmarking either was a 404. They both render the app
shell now. The route names carry a postfix because routes are keyed by
name, and re-using one silently drops every declaration but the last.

Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
'Reset local cache' posted to /api/v1/cache/refresh, a route that does
not exist and never has: the request 404s, nothing catches it, so the
button reported nothing and did nothing. It arrived with the v0.9.3
design refresh without a backend to talk to.

'Help & documentation' pointed at a personal fork rather than any
documentation of this app.

That leaves the settings section with the one entry that does something.

Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The app runs on Vue 3.5 but carried Vue 2 idioms that the compiler
accepts and then ignores, so each one silently did nothing:

- The timeline's fade was dead. Both <transition-group>s use the `list`
  transition, whose starting class was `.list-enter` — Vue 2's name.
  Vue 3 applies `.list-enter-from`, which nothing defined, so posts
  appeared abruptly. Corrected, given the `.list-move` that makes the
  neighbours slide instead of jump when an entry arrives, and defined
  once in the global block rather than duplicated in a scoped one.
- The welcome banner animated out but never in: `slide-fade` had no
  enter classes at all.
- The report dialog could not close itself. `:open.sync` is Vue 2; Vue 3
  ignores the modifier, so the binding was one-way and the dialog's own
  dismissal never reached the flag. Now `v-model:open`.
- eslint was linting a Vue 3 app against the Vue 2 ruleset. The shared
  config ships a `vue3` sub-config for exactly this; switching to it
  rejected nothing valid and immediately found four unused bindings,
  including an unused linkifyjs namespace import.
- Five components emitted events without declaring `emits`.
- Motion now respects prefers-reduced-motion everywhere, which nothing
  did before.
- Deleted the popoverMenu mixin, unused since NcActions replaced
  hand-rolled menus, and the App-level composer path that Navigation
  stopped emitting when it grew its own modal.

tests/js/vue3.test.js asserts all of it against the source, because none
of these fail loudly: it reports the file and line of a Vue 2 transition
class, a `.sync` binding, a removed lifecycle hook or API, an undeclared
emit, or an animation without a motion guard.

Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
The app had four opacity transitions and no keyframes at all. This gives
the surfaces people touch most some feedback, in plain CSS — no
animation library, nothing added to the bundle but the rules themselves.

- Liking a post pops the heart and sends a ring out of it, once, and
  boosting spins the icon. Undoing neither: there is nothing to
  celebrate about taking a like back.
- Both actions are applied optimistically and were rolled back in
  silence when the server refused. The button now shakes, so a refusal
  is something you see rather than something you miss.
- The first page of a timeline is three post-shaped placeholders with a
  travelling sheen instead of a spinner on an empty page, so the
  timeline settles into its own shape. Later pages keep the spinner:
  the posts are already on screen.
- Poll results grow from zero, one bar after another.
- Images resolve out of the blurhash the app already decodes and then
  threw away in a single frame — it now fades under the image.
- Posts that arrive while you are further down the page offer a pill
  instead of appearing unannounced above the fold.
- The composer shows the length allowance as a ring that fills, turning
  amber near the limit and counting down past it, rather than a limit
  discovered by hitting it.
- Post cards lift slightly under the pointer and take a focus ring.

Every one of these is behind prefers-reduced-motion, and the guard test
now insists on it for `animation` as well as `transition`.

TimelineList also asks for its own translate functions rather than
leaning on the implicit globals, which is how the components that were
already testable do it — the global `n` does not interpolate.

Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Where the previous pass gave the app feedback, this one gives it depth,
identity and speed — still CSS and platform APIs only, still nothing
added to the dependency list.

The platform, where it exists and motion is welcome:

- Tapping a picture grows the thumbnail into the lightbox and shrinks it
  back, through the View Transitions API and a shared name on the two
  elements. Route changes go through a transition too, so timeline,
  profile and post read as one surface changing.
- Posts rise into place as they scroll into view, driven off the scroll
  position by the compositor: `animation-timeline: view()`, no listener,
  no observer, no main-thread work. `@supports` means browsers without
  it simply show the posts.

Federation, which was invisible:

- Every instance gets a colour of its own, derived from its hostname, so
  it is the same everywhere without any coordination. Remote avatars
  carry it as a ring, and a chip next to the handle names the server.
  A timeline now visibly spans the network instead of hiding it after
  the @.

Reading, which needed a mouse:

- j/k walk the timeline, l/f like, b boosts, r replies, o opens, n
  writes, g goes home, ? lists all of it. Nothing fires while you are
  typing — in a field, in the composer, or with a modifier held.
- The post the keyboard is on says so, and it is the one l/b/r act on.

And the smaller things:

- A reply chain is drawn on one spine instead of three stacked lists,
  with the post being read raised off it.
- A profile is tinted by its own banner, sampled from a few pixels of
  it, and the banner drifts as the page scrolls. A banner that cannot be
  read leaves the theme's colour alone.
- Empty-state artwork settles in rather than appearing, and stops
  glaring on a dark background.
- A like ticks the phone, for anyone holding one.

Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <frank.karlitschek@nextcloud.com>
@karlitschek
karlitschek merged commit 65f0e5f into master Sep 8, 2026
22 checks passed
@karlitschek
karlitschek deleted the feat/fediverse-flair branch September 8, 2026 14:52
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