diff --git a/apps/web/src/lib/components/OfflineChip.svelte b/apps/web/src/lib/components/OfflineChip.svelte
new file mode 100644
index 0000000..1015b77
--- /dev/null
+++ b/apps/web/src/lib/components/OfflineChip.svelte
@@ -0,0 +1,41 @@
+
+
+{#if !$isOnline}
+
+
+ {#if !compact}
+ {m.pwa_offline_chip()}
+ {/if}
+ {#if $pendingOpsCount > 0}
+
+ {$pendingOpsCount}
+
+ {/if}
+
+{/if}
diff --git a/apps/web/src/lib/components/layout/DesktopSidebar.svelte b/apps/web/src/lib/components/layout/DesktopSidebar.svelte
index 977e4a7..0e912ea 100644
--- a/apps/web/src/lib/components/layout/DesktopSidebar.svelte
+++ b/apps/web/src/lib/components/layout/DesktopSidebar.svelte
@@ -7,6 +7,7 @@
import type { SectionKey } from '@colectivo/types';
import Avatar from '$lib/components/Avatar.svelte';
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
+ import OfflineChip from '$lib/components/OfflineChip.svelte';
import * as m from '$lib/paraglide/messages';
import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus, Shield } from 'lucide-svelte';
@@ -101,6 +102,9 @@
+
+
+
{#if $isServerAdmin}
diff --git a/apps/web/src/lib/components/layout/MobileTopBar.svelte b/apps/web/src/lib/components/layout/MobileTopBar.svelte
index 1c80b11..af142fb 100644
--- a/apps/web/src/lib/components/layout/MobileTopBar.svelte
+++ b/apps/web/src/lib/components/layout/MobileTopBar.svelte
@@ -2,6 +2,7 @@
import { displayName } from '$lib/stores/auth';
import { currentCollective } from '$lib/stores/collective';
import Avatar from '$lib/components/Avatar.svelte';
+ import OfflineChip from '$lib/components/OfflineChip.svelte';
import { Menu } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
@@ -26,6 +27,8 @@
+
+
diff --git a/apps/web/tests/e2e/offline.test.ts b/apps/web/tests/e2e/offline.test.ts
index 25118fa..f94aeae 100644
--- a/apps/web/tests/e2e/offline.test.ts
+++ b/apps/web/tests/e2e/offline.test.ts
@@ -40,6 +40,37 @@ test.describe('Offline queue + reconnect flush', () => {
await context.setOffline(false);
});
+ test('OF-01: going offline shows the persistent chip + a queue badge', async ({
+ page,
+ context
+ }) => {
+ await loginAs(page, USERS.borja);
+ await page.goto(SEED_LIST_PATH);
+ await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
+
+ // Online → chip hidden.
+ await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0);
+
+ await context.setOffline(true);
+ // At least one chip (mobile + desktop both mount; only the visible one
+ // matters but locator-count-based assertions need both).
+ await expect(page.getByTestId('pwa-offline-chip').first()).toBeVisible({ timeout: 3_000 });
+
+ const itemName = `OF-01-${Date.now()}`;
+ const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
+ await input.fill(itemName);
+ await input.press('Enter');
+
+ // pending_ops should now contain at least the INSERT for the new item.
+ await expect(page.getByTestId('pwa-offline-chip-badge').first()).toHaveText(/\d+/, {
+ timeout: 3_000
+ });
+ await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
+
+ await context.setOffline(false);
+ await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0, { timeout: 5_000 });
+ });
+
test('O-02: going back online flushes the queue to the server', async ({
browser,
context