Skip to content

Commit 4896336

Browse files
pmaxhoganclaude
andauthored
feat(ui): sticky shell chrome, indeterminate scan progress and navigation cleanup (#163)
Three user-reported UI items. ## 1. The scan-phase progress bar looked finished **Diagnosis first, because the fix is smaller than the report suggests:** the indeterminate sweep was already on `main` and works. The only render path that produces "a solid teal line that looks like a finished 100% bar" is the `prefers-reduced-motion: reduce` fallback, which set `width: 100%; opacity: 0.6` on the sweep segment - a full-width teal line, visually identical to a completed determinate fill. Windows' "Show animations in Windows" toggle maps straight to that media query in WebView2, so anyone with OS animations off saw a "finished-looking" bar for the entire scan. **Confirmed empirically, not just reasoned about.** Driving the real component in headless Chrome (CDP, seeding a `scanning` state into the live pinia store) and measuring the segment against the track: | CSS | `prefers-reduced-motion` | segment width | animation | | --- | --- | --- | --- | | old (`main`) | `no-preference` | 40% of track | sweep, 1.3s | | old (`main`) | `reduce` | **100% of track** | `none` | | new | `no-preference` | 40% of track | sweep, 1.2s | | new | `reduce` | 40% of track | opacity pulse, 2.4s | The 100% row is the bug, reproduced exactly. (Also rules out the alternatives: a failed animation would leave the segment static at the LEFT 40%, and a broken `w-2/5` would give zero width. Neither matches the report.) The fallback now keeps the segment **partial-width** - so it can never read as 100% - and swaps the travelling sweep for a slow, gentle opacity pulse that still says "working" without moving anything across the screen. The default sweep is also retuned: 1.2s loop, `will-change: transform` (it was already transform-only, so the compositor can run it off the main thread while the scan hammers the disk), and rounded ends so it reads as a travelling pill rather than a fill edge. The determinate upload bar is untouched. Both paths use the existing `bg-teal-600 dark:bg-teal-400` pair on the `bg-teal-100 dark:bg-teal-900/40` track, so light and dark are unchanged. **Testability note:** only the template half of "never renders as a full bar" is unit-testable - jsdom does not apply scoped CSS, so the reduced-motion branch cannot be asserted in vitest (hence the browser measurement above). The new test covers what is observable: the sweep is a partial-width element carrying no inline width, and the determinate fill (the only branch with a width) is absent. ## 2. Navigation cleanup - **Restore now comes before Settings** in the top nav. Restore is a primary user-facing surface; Settings is the configuration drawer and reads last. - **About left the top nav** and is now the fourth tab in the Settings sub-bar, alongside Accounts / Sources / Rules. It is a configuration surface, so that is where it belongs. `Settings.vue` embeds the existing `About.vue` (moved, not duplicated), and its headings are demoted a level (h1 -> h2, inner h2 -> h3) so the tab content nests correctly under the Settings h1 and matches the Rules tab's section-heading pattern. **On deep links: `/about` is unchanged rather than redirected.** The repo's convention is flat per-tab paths - `/accounts`, `/sources` and `/rules` all render `Settings.vue` with a `tab` prop, and there is no nested `/settings/*` anywhere - so `/about` now resolves to `Settings.vue` with `props: { tab: "about" }`. That satisfies the deep-link requirement better than a redirect would, because the URL never changes and there is no new path to redirect to. Deep-link surfaces actually checked, not assumed: - `src-tauri/src/tray.rs` only ever emits `tray:navigate` for `settings`/`activity`/`restore`, and no frontend listener for that event exists at all - nothing on the Rust side navigates to About. - `router-first-run.test.ts` asserts `firstRunTarget("/about")` is null; the first-run guard only special-cases `/` and `/activity`, so that holds. - `nav.about` is now dead and removed from `en-US.json`; the tab reuses the already-present `settings.tabs.about`. ## 3. Sticky shell chrome The progress bar, paused banner and nav are now ONE sticky block (`sticky top-0 z-30`) instead of three normal-flow siblings, so none of them can be scrolled out of the way on a long view - a 10k-row Activity list used to hide the running-backup bar entirely. **Approach chosen: sticky header, NOT an inner scroll container.** The other option - making `<main>` the scroller with `overflow-y-auto` - would break the Restore browser: `useVirtualList` windows off WINDOW scroll and `window.innerHeight`, so moving the scroll into an inner element would stop its scroll events firing and freeze the list on its first window. Sticky also keeps its space in normal flow, so no compensating top padding is needed and the layout at scroll 0 is byte-identical to before (no layout shift). `z-30` clears page content and Restore's existing `sticky bottom-0 z-10` action bar while staying under the `z-50` modal overlays. The header carries an explicit `bg-zinc-50 dark:bg-zinc-950` because `PausedBanner`'s dark fill is semi-transparent (`bg-amber-950/40`) and would otherwise let scrolled content show through. Measured in headless Chrome at `scrollY=900`, both color schemes: header `position: sticky`, `top: 0px`, `z-index: 30`, `getBoundingClientRect().top === 0` (pinned), background resolving to zinc-50 / zinc-950 per scheme, **zero** inner scroll containers (no double scrollbar) and identical document scroll height in both schemes. ## Tests `ui/`: 442 passed (40 files), `prettier --check src` clean, `eslint` 0 errors (17 pre-existing unused-key warnings), `vue-tsc --noEmit` clean. New coverage: the Settings About-tab branch (offered in the sub-bar, navigates to `/about`, renders the About surface and not the Rules form), the nav order and the absent top-nav About link, Settings staying lit on `/about`, the partial-width scan sweep, and the sticky header (all three chrome pieces inside it, no inner scroller, no compensating padding). Browser verification above used the vite dev server, so the Rust backend was not running - the progress bar was driven by seeding its store directly. The determinate upload bar still has no end-to-end check, since that needs a real backup run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0104a8e commit 4896336

9 files changed

Lines changed: 266 additions & 63 deletions

File tree

ui/src/App.vue

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ import { useUpdaterStore } from "./stores/updater";
1616
// menu + deep links navigate the same router.
1717
//
1818
// UI-CORE IA fix: the top nav is the SHELL-level information architecture and
19-
// lists only the four primary surfaces - Activity | Settings | Restore | About.
20-
// Accounts / Sources / Rules are NOT top-nav items: they are subtabs INSIDE the
21-
// Settings page (the only place they live), so the "Settings" item lights up for
22-
// any of /settings, /accounts, /sources, /rules. Teal is the shell accent (brand
23-
// wordmark + active/hover link states).
19+
// lists only the three primary surfaces - Activity | Restore | Settings.
20+
// Accounts / Sources / Rules / About are NOT top-nav items: they are subtabs
21+
// INSIDE the Settings page (the only place they live), so the "Settings" item
22+
// lights up for any of /settings, /accounts, /sources, /rules, /about. Teal is
23+
// the shell accent (brand wordmark + active/hover link states).
2424
const { t } = useI18n();
2525
const route = useRoute();
2626
@@ -86,18 +86,19 @@ onMounted(async () => {
8686
});
8787
8888
// The top-nav surfaces. `match` is the set of route paths for which the item is
89-
// the ACTIVE one; Settings owns its three subtab routes so it stays lit while the
89+
// the ACTIVE one; Settings owns its four subtab routes so it stays lit while the
9090
// user is on any of them. A path is active when it equals a match path or is
9191
// nested under it (so /restore/:sourceId keeps Restore active).
92+
// Restore sits before Settings: it is a primary user-facing surface, whereas
93+
// Settings is the configuration drawer and reads last in the row.
9294
const navLinks = [
9395
{ to: "/activity", label: "nav.activity", match: ["/activity"] },
96+
{ to: "/restore", label: "nav.restore", match: ["/restore"] },
9497
{
9598
to: "/settings",
9699
label: "nav.settings",
97-
match: ["/settings", "/accounts", "/sources", "/rules"],
100+
match: ["/settings", "/accounts", "/sources", "/rules", "/about"],
98101
},
99-
{ to: "/restore", label: "nav.restore", match: ["/restore"] },
100-
{ to: "/about", label: "nav.about", match: ["/about"] },
101102
] as const;
102103
103104
function isActive(matches: readonly string[]): boolean {
@@ -115,28 +116,45 @@ const NAV_LINK_ACTIVE = "text-teal-700 dark:text-teal-300 font-semibold";
115116

116117
<template>
117118
<div class="min-h-screen flex flex-col">
118-
<GlobalProgressBar />
119-
<PausedBanner />
120-
<nav
121-
class="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-zinc-200 bg-white px-6 py-3 text-sm dark:border-zinc-800 dark:bg-zinc-900"
122-
:aria-label="t('nav.primary')"
123-
>
124-
<RouterLink
125-
to="/activity"
126-
class="mr-2 text-base font-bold tracking-tight text-teal-700 transition-colors hover:text-teal-600 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 dark:text-teal-300 dark:hover:text-teal-200"
127-
>
128-
{{ t("app.name") }}
129-
</RouterLink>
130-
<RouterLink
131-
v-for="link in navLinks"
132-
:key="link.to"
133-
:to="link.to"
134-
:class="[NAV_LINK_BASE, isActive(link.match) ? NAV_LINK_ACTIVE : NAV_LINK_INACTIVE]"
135-
:aria-current="isActive(link.match) ? 'page' : undefined"
119+
<!-- The shell chrome - progress bar, paused banner, nav - is ONE sticky
120+
block pinned to the top of the viewport, so none of it can be scrolled
121+
out of the way on a long view (a 10k-row Activity list used to hide the
122+
running-backup bar entirely). Sticky rather than fixed: a sticky element
123+
keeps its space in normal flow, so nothing needs a compensating top
124+
padding and the layout is byte-identical to before at scroll 0.
125+
Deliberately NOT the other option (making <main> the scroll container):
126+
`useVirtualList` windows the Restore browser off WINDOW scroll +
127+
`window.innerHeight`, so moving the scroll into an inner element would
128+
stop its scroll events firing and freeze the list on its first window.
129+
z-30 clears page content and Restore's `sticky bottom-0 z-10` action
130+
bar while staying under the z-50 modal overlays. The explicit surface
131+
color matters: PausedBanner's dark-mode fill is semi-transparent
132+
(`bg-amber-950/40`), so it needs an opaque backdrop of its own or
133+
scrolled content would show through it. -->
134+
<header class="sticky top-0 z-30 bg-zinc-50 dark:bg-zinc-950" data-testid="app-header">
135+
<GlobalProgressBar />
136+
<PausedBanner />
137+
<nav
138+
class="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-zinc-200 bg-white px-6 py-3 text-sm dark:border-zinc-800 dark:bg-zinc-900"
139+
:aria-label="t('nav.primary')"
136140
>
137-
{{ t(link.label) }}
138-
</RouterLink>
139-
</nav>
141+
<RouterLink
142+
to="/activity"
143+
class="mr-2 text-base font-bold tracking-tight text-teal-700 transition-colors hover:text-teal-600 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 dark:text-teal-300 dark:hover:text-teal-200"
144+
>
145+
{{ t("app.name") }}
146+
</RouterLink>
147+
<RouterLink
148+
v-for="link in navLinks"
149+
:key="link.to"
150+
:to="link.to"
151+
:class="[NAV_LINK_BASE, isActive(link.match) ? NAV_LINK_ACTIVE : NAV_LINK_INACTIVE]"
152+
:aria-current="isActive(link.match) ? 'page' : undefined"
153+
>
154+
{{ t(link.label) }}
155+
</RouterLink>
156+
</nav>
157+
</header>
140158
<main class="flex-1 p-6">
141159
<RouterView />
142160
</main>

ui/src/__tests__/app-shell.test.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ vi.mock("@tauri-apps/api/event", () => ({
3131
}));
3232

3333
import App from "../App.vue";
34+
import GlobalProgressBar from "../components/GlobalProgressBar.vue";
35+
import PausedBanner from "../components/PausedBanner.vue";
3436
import { i18n } from "../i18n";
3537
import { createAppRouter } from "../router";
3638

@@ -78,12 +80,37 @@ async function mountAppAt(path: string) {
7880
}
7981

8082
describe("App shell", () => {
81-
it("renders the top nav with the app wordmark and all four primary surfaces", async () => {
83+
it("renders the top nav with the app wordmark and all three primary surfaces", async () => {
8284
const wrapper = await mountAppAt("/activity");
8385
expect(wrapper.find("nav").exists()).toBe(true);
8486
const links = wrapper.findAll("nav a");
85-
// Wordmark + Activity | Settings | Restore | About.
86-
expect(links.length).toBe(5);
87+
// Wordmark + Activity | Restore | Settings. About is no longer a top-nav
88+
// surface - it is a subtab inside Settings.
89+
expect(links.length).toBe(4);
90+
expect(links.map((l) => l.attributes("href"))).toEqual([
91+
"/activity",
92+
"/activity",
93+
"/restore",
94+
"/settings",
95+
]);
96+
});
97+
98+
it("orders Restore before Settings in the nav", async () => {
99+
const wrapper = await mountAppAt("/activity");
100+
const hrefs = wrapper.findAll("nav a").map((l) => l.attributes("href"));
101+
expect(hrefs.indexOf("/restore")).toBeLessThan(hrefs.indexOf("/settings"));
102+
});
103+
104+
it("offers no top-nav About link (it moved into Settings)", async () => {
105+
const wrapper = await mountAppAt("/activity");
106+
expect(wrapper.find('nav a[href="/about"]').exists()).toBe(false);
107+
});
108+
109+
// /about still resolves - it renders the Settings view's About tab - so the
110+
// Settings nav item must stay lit there, exactly as it does for /accounts.
111+
it("keeps Settings active on /about, which now renders the About subtab", async () => {
112+
const wrapper = await mountAppAt("/about");
113+
expect(wrapper.find('a[href="/settings"]').attributes("aria-current")).toBe("page");
87114
});
88115

89116
it("marks the Activity link active (and no other) when on /activity", async () => {
@@ -106,6 +133,38 @@ describe("App shell", () => {
106133
expect(restoreLink.attributes("aria-current")).toBe("page");
107134
});
108135

136+
// The shell chrome must not be scrollable out of the way: on a long view (a
137+
// 10k-row Activity list) the running-backup progress bar used to disappear off
138+
// the top. Progress bar + paused banner + nav are one sticky block, so all
139+
// three pin together and the document stays the scroll container (which
140+
// `useVirtualList` depends on - it windows off window scroll).
141+
it("pins the progress bar, paused banner and nav in one sticky header", async () => {
142+
const wrapper = await mountAppAt("/activity");
143+
const header = wrapper.get('[data-testid="app-header"]');
144+
145+
expect(header.classes()).toContain("sticky");
146+
expect(header.classes()).toContain("top-0");
147+
// Above page content and Restore's `sticky bottom-0 z-10` bar, below modals.
148+
expect(header.classes()).toContain("z-30");
149+
// All three pieces of chrome live INSIDE it (so none can scroll away alone).
150+
expect(header.findComponent(GlobalProgressBar).exists()).toBe(true);
151+
expect(header.findComponent(PausedBanner).exists()).toBe(true);
152+
expect(header.find("nav").exists()).toBe(true);
153+
});
154+
155+
// Sticky, not fixed, and no inner scroll container: the header keeps its space
156+
// in normal flow, so the shell needs no compensating top padding and there is
157+
// exactly one scrollbar (the document's).
158+
it("leaves the document as the only scroll container", async () => {
159+
const wrapper = await mountAppAt("/activity");
160+
const root = wrapper.get('[data-testid="app-header"]').element.parentElement!;
161+
162+
expect(root.className).not.toContain("overflow");
163+
const main = wrapper.get("main");
164+
expect(main.classes()).not.toContain("overflow-y-auto");
165+
expect(main.classes().some((c) => c.startsWith("pt-"))).toBe(false);
166+
});
167+
109168
it("subscribes + hydrates the updater, progress and pause stores on boot", async () => {
110169
await mountAppAt("/activity");
111170
// Three updater events (available, download_progress, downloaded) + the

ui/src/__tests__/global-progress-bar.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,29 @@ describe("GlobalProgressBar", () => {
121121
expect(wrapper.find(INDETERMINATE).exists()).toBe(true);
122122
});
123123

124+
// The scan phase must never LOOK like a finished bar. The sweep segment is a
125+
// partial-width element carrying no inline width, so nothing about it can
126+
// render as a full determinate fill; the determinate branch (the only thing
127+
// that sets an inline width) is absent entirely.
128+
//
129+
// Only the template half of that guarantee is unit-testable: the scoped
130+
// `prefers-reduced-motion` fallback is plain CSS, which jsdom does not apply.
131+
// Keeping the width in a Tailwind class on the element (rather than something
132+
// the media query stretches to 100%) is what makes the partial width hold in
133+
// BOTH motion modes.
134+
it("renders the scan sweep as a partial-width segment with no inline width", async () => {
135+
const { store, wrapper } = mountBar();
136+
store.ingest(perAccount("a", scanning(120)));
137+
await wrapper.vm.$nextTick();
138+
139+
const sweep = wrapper.find('[data-testid="global-progress-indeterminate"]');
140+
expect(sweep.exists()).toBe(true);
141+
expect(sweep.classes()).toContain("w-2/5");
142+
expect(sweep.attributes("style")).toBeUndefined();
143+
// No determinate fill alongside it (that branch is the one with a width).
144+
expect(wrapper.find(BAR).findAll("div[style]").length).toBe(0);
145+
});
146+
124147
// The "Run now looks dead during the scan" fix: the bar carries a visible
125148
// phase readout, and the scan phase streams a live file count into it. Before
126149
// this, every pre-upload phase rendered the same bare "Backing up..." on a

ui/src/__tests__/settings-components.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1891,3 +1891,64 @@ describe("Settings Rules tab", () => {
18911891
expect(wrapper.find('[data-testid="telemetry-preview-modal"]').exists()).toBe(false);
18921892
});
18931893
});
1894+
1895+
// About moved out of the top nav and into the Settings sub-bar as a fourth tab.
1896+
// /about still resolves (the router points it at THIS view with `tab: "about"`),
1897+
// so these cover the new branch: the tab is offered, it is the active one on
1898+
// that route, and it renders the real About surface rather than the Rules form.
1899+
describe("Settings About tab", () => {
1900+
function aboutTab(wrapper: ReturnType<typeof mount>) {
1901+
return wrapper
1902+
.findAll("nav button")
1903+
.find((b) => b.text() === i18n.global.t("settings.tabs.about"));
1904+
}
1905+
1906+
it("offers About as a subtab alongside Accounts / Sources / Rules", async () => {
1907+
invokeMock.mockImplementation((cmd: string) => {
1908+
if (cmd === "list_accounts") return Promise.resolve([]);
1909+
return Promise.resolve(undefined);
1910+
});
1911+
const wrapper = mount(Settings, { props: { tab: "accounts" }, global: globalMountOptions });
1912+
await flushPromises();
1913+
1914+
const labels = wrapper.findAll("nav button").map((b) => b.text());
1915+
expect(labels).toEqual([
1916+
i18n.global.t("settings.tabs.accounts"),
1917+
i18n.global.t("settings.tabs.sources"),
1918+
i18n.global.t("settings.tabs.rules"),
1919+
i18n.global.t("settings.tabs.about"),
1920+
]);
1921+
});
1922+
1923+
it("navigates to /about when the About subtab is clicked", async () => {
1924+
invokeMock.mockImplementation((cmd: string) => {
1925+
if (cmd === "list_accounts") return Promise.resolve([]);
1926+
return Promise.resolve(undefined);
1927+
});
1928+
const wrapper = mount(Settings, { props: { tab: "accounts" }, global: globalMountOptions });
1929+
await flushPromises();
1930+
1931+
await aboutTab(wrapper)!.trigger("click");
1932+
expect(pushMock).toHaveBeenCalledWith("/about");
1933+
});
1934+
1935+
it("renders the About surface (not the Rules form) with the tab marked active", async () => {
1936+
invokeMock.mockImplementation((cmd: string) => {
1937+
if (cmd === "get_settings") return Promise.resolve(makeSettings());
1938+
if (cmd === "get_update_channel") return Promise.resolve("stable");
1939+
if (cmd === "list_releases") return Promise.resolve([]);
1940+
return Promise.resolve(undefined);
1941+
});
1942+
1943+
const wrapper = mount(Settings, { props: { tab: "about" }, global: globalMountOptions });
1944+
await flushPromises();
1945+
1946+
expect(aboutTab(wrapper)!.classes()).toContain("border-teal-600");
1947+
// The About surface's own controls are present...
1948+
expect(wrapper.find('[data-testid="channel-select"]').exists()).toBe(true);
1949+
expect(wrapper.find('[data-testid="check-updates"]').exists()).toBe(true);
1950+
expect(wrapper.text()).toContain(i18n.global.t("about.updatesTitle"));
1951+
// ...and the Rules form is not rendered in its place.
1952+
expect(wrapper.find('[data-testid="rules-form"]').exists()).toBe(false);
1953+
});
1954+
});

ui/src/components/GlobalProgressBar.vue

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,13 @@ const label = computed<string>(() => {
9191
:style="{ width: `${widthPct}%` }"
9292
></div>
9393
<!-- Indeterminate: a teal sliver sweeping across while a run is active but
94-
has no measurable total yet (scan / plan / verify). -->
94+
has no measurable total yet (scan / plan / verify). It is always a
95+
PARTIAL width so it can never be mistaken for a finished bar, and
96+
rounded so it reads as a travelling pill rather than a fill edge. -->
9597
<div
9698
v-else
97-
class="global-progress__indeterminate absolute inset-y-0 left-0 w-2/5 bg-teal-600 dark:bg-teal-400"
99+
class="global-progress__indeterminate absolute inset-y-0 left-0 w-2/5 rounded-full bg-teal-600 dark:bg-teal-400"
100+
data-testid="global-progress-indeterminate"
98101
></div>
99102
</div>
100103
<!-- Phase readout. `aria-live="polite"` so a screen reader hears the phase
@@ -113,8 +116,14 @@ const label = computed<string>(() => {
113116
</template>
114117

115118
<style scoped>
119+
/* Transform-only sweep so the compositor can run it off the main thread - a
120+
scan that is hammering the disk must never make the one thing telling the
121+
user "we are working" stutter. The segment is 40% of the track and the
122+
keyframes are in percentages OF THAT segment, so it enters from fully
123+
off-track on the left and exits fully off-track on the right. */
116124
.global-progress__indeterminate {
117-
animation: driven-progress-indeterminate 1.3s ease-in-out infinite;
125+
animation: driven-progress-indeterminate 1.2s ease-in-out infinite;
126+
will-change: transform;
118127
}
119128
120129
@keyframes driven-progress-indeterminate {
@@ -126,6 +135,17 @@ const label = computed<string>(() => {
126135
}
127136
}
128137
138+
/* Reduced-motion fallback (below) - a slow, gentle fade in place. */
139+
@keyframes driven-progress-pulse {
140+
0%,
141+
100% {
142+
opacity: 1;
143+
}
144+
50% {
145+
opacity: 0.45;
146+
}
147+
}
148+
129149
.driven-progress-fade-enter-active,
130150
.driven-progress-fade-leave-active {
131151
transition: opacity 200ms ease;
@@ -136,13 +156,19 @@ const label = computed<string>(() => {
136156
opacity: 0;
137157
}
138158
139-
/* Respect reduced-motion: drop the sweep animation and show a steady partial
140-
bar so the indeterminate state is still visibly "working". */
159+
/* Respect reduced-motion: drop the travelling sweep for a gentle fade in place.
160+
The segment MUST stay partial-width here. The previous fallback stretched it
161+
to `width: 100%`, which rendered a solid full-width teal line - visually
162+
indistinguishable from a completed determinate fill. That is what a scan
163+
looked like for anyone with OS animations turned off (Windows' "Show
164+
animations in Windows" maps to prefers-reduced-motion: reduce), so the bar
165+
read as "finished" for the entire scan. A partial bar can never be mistaken
166+
for 100%, and the opacity pulse still says "working" without moving anything
167+
across the screen. */
141168
@media (prefers-reduced-motion: reduce) {
142169
.global-progress__indeterminate {
143-
animation: none;
144-
width: 100%;
145-
opacity: 0.6;
170+
animation: driven-progress-pulse 2.4s ease-in-out infinite;
171+
will-change: opacity;
146172
}
147173
}
148174
</style>

ui/src/locales/en-US.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
"accounts": "Accounts",
1515
"sources": "Sources",
1616
"rules": "Rules",
17-
"restore": "Restore",
18-
"about": "About"
17+
"restore": "Restore"
1918
},
2019
"progress": {
2120
"backingUp": "Backing up...",

0 commit comments

Comments
 (0)