Skip to content

feat: motion and polish across the timeline - #2074

Merged
karlitschek merged 4 commits into
masterfrom
feat/motion-polish
Sep 8, 2026
Merged

feat: motion and polish across the timeline#2074
karlitschek merged 4 commits into
masterfrom
feat/motion-polish

Conversation

@karlitschek

Copy link
Copy Markdown
Member

Stacks on #2073 — branched off it, because the prefers-reduced-motion guard test belongs underneath this. Merge that one first.

The app had four transition: opacity declarations and zero keyframes. This gives the surfaces people actually touch some feedback, in plain CSS — no animation library, nothing added to the entrypoints but the rules themselves (they compile into the lazy chunks their components already live in).

What changes

Surface Before Now
Like outline → filled in one frame heart pops, a ring expands out of it once
Boost icon recolours icon spins 360°
A refused like/boost rolled back in silence the button shakes
First page load spinner on a blank page three post-shaped placeholders with a travelling sheen
Later pages spinner spinner (correctly — the posts are already on screen)
Poll results bars render at final width bars grow from zero, 80 ms apart
Images blurhash → image, hard swap image resolves out of its own blurhash
Posts arriving mid-read appear unannounced above the fold a pill offers the jump, and only when the top is out of sight
Composer length discovered by exceeding it a ring that fills, ambers at 50 left, counts down past 0
Post cards flat, no hover 1 px lift under the pointer, focus ring for keyboards

Undoing a like or a boost deliberately gets no celebration — there's nothing to celebrate about taking a like back.

The two that are more than decoration

The refusal shake. postLike/postBoost apply the change optimistically and roll it back when the server says no, which until now happened invisibly — the icon just flipped back. Making that visible is motion doing work.

Skeletons over a spinner. A spinner on an empty page says nothing about what is coming; placeholders in the shape of posts mean the timeline settles into itself. Note this also fixed a real bug I introduced and caught mid-change: the "more to load" marker was briefly rendering during the first load.

Constraints held

  • Everything is behind prefers-reduced-motion, and the guard test from feat: manage blocked and muted accounts, and drop the settings entries that never worked #2073 now insists on it for animation as well as transition — verified by deleting one guard and watching it name the file.
  • No new dependencies, no Lottie/GSAP/confetti. The entrypoint is unchanged in size to within a rounding error.
  • Nextcloud design tokens throughout (--color-primary-element, --color-warning, --border-radius-pill), so it themes with the rest of the suite rather than against it.

Tests

7 new specs: the like burst appearing only when liking, the refusal class on a rejected action, skeletons on the first page and a spinner on later ones, the arrival pill showing only when scrolled away, the pill scrolling to top and clearing, the blurhash crossfade classes, and the composer ring's fill/warning/over states. Suites: 640 vitest, 1839 PHP, eslint (Vue 3 config) and stylelint clean.

Two harness fixes fell out: mountPost's dispatch used to resolve undefined, which is exactly what the store returns on failure, so success and refusal were indistinguishable in tests; and TimelineList now imports its translate functions rather than leaning on the implicit globals, because the global n does not interpolate %n — the same reason the better-tested components here already import them.

🤖 Generated with Claude Code

Frank Karlitschek and others added 4 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>
@karlitschek
karlitschek merged commit 9f687df into master Sep 8, 2026
17 of 25 checks passed
@karlitschek
karlitschek deleted the feat/motion-polish branch September 8, 2026 14:51
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