Skip to content

Commit 9ef20dc

Browse files
authored
Fix WebKit PDF-engine and storage failures, and catch them in cross-browser CI (#7366)
# Description of Changes Follow-up to #7314, which fixed the IndexedDB blob rejection itself. This one fixes the remaining WebKit engine gaps, fixes the ways that class of failure surfaced to the user, and adds the cross-browser signal that would have caught them on the PR instead of six weeks later. ## Why this exists Two total WebKit outages sat on `main` for weeks: 1. pdf.js reads its text stream with `for await (… of readableStream)`, and WebKit has no `ReadableStream[Symbol.asyncIterator]`. **All** pdf.js text extraction threw `TypeError: undefined is not a function` — Compare, read-aloud and the PDF text editor were dead on Safari. 2. IndexedDB in WebKit rejects Blob/File values with `UnknownError: Error preparing Blob/File data to be stored in object store`, so nothing persisted and every reload came back empty. Neither was caught, because the existing specs never did the work. The Compare specs filled both slots and asserted the button was enabled; none of them clicked it. The persistence specs asserted a *filename* reappeared after a reload, which only needs the metadata record, not the bytes. Every failure here **looked like success** — empty panes, blank thumbnails, a `src` that was set but empty. That shapes the tests more than the fixes. ## WebKit engine gaps - **`ReadableStream[Symbol.asyncIterator]`**, installed at the entry point before any PDF work starts. The lock discipline is the subtle part: releasing is idempotent, is *not* done after a successful read, and *is* done in the read's error steps — `for await` never calls `return()` when `next()` rejects, so nothing else would ever unlock an errored stream. - **`requestIdleCallback`**, installed once instead of guarded at each call site. This one wasn't broken, it was mistimed: the local fallbacks fired at 200ms and 1000ms, landing the pdfium WASM compile on top of the app's first renders. The shim honours the caller's full timeout, so `{timeout: 2000}` means 2000ms. - **`convertToBlob()` does not fail on a format it can't encode.** Per spec it silently serialises to PNG, so asking for WebP and getting PNG back looks like success. Canvas output now probes what the engine really produced (once per realm) and uses the best lossy format it honours. PNG of a rendered page is several times the size of the equivalent WebP or JPEG, held as object URLs for every page on screen, on the engine with the tightest renderer memory budget. ## WebKit storage failures These read as generic transaction hygiene. They aren't — a refused blob write **aborts its transaction**, which is the mechanism that turned a WebKit rejection into a hang. - **Blob refusal is remembered from any write**, not just the initial `add`. WebKit reports it when it can't write the blob's *backing file*, which is per-operation — an engine that accepted the add can still refuse the rewrite, and every read-modify-write rewrites the record with its body attached. - **Aborted transactions no longer hang.** Read-modify-write moves to a single `updateRecord` helper that owns its transaction, guards it once, and resolves on **commit** rather than on the put's `onsuccess`. The previous shape — two promises over one shared transaction, with an `await` between the get and the put — put the abort guard on the read, leaving the write with no handler at all. `persistVersionedOutputs` awaits that, and `.catch` can't rescue a promise that never settles, so tool outputs could silently stop persisting. - **Stored blobs are no longer re-wrapped on read.** Since #7175 the record holds the `File` itself; wrapping it in `new Blob([record.data])` can cost WebKit the backing handle, giving you an object that looks valid and reads as empty. - **The file sidebar reaches a resting state** when the library can't be read, instead of spinning forever on a rejection nobody observes. It carries on with the in-memory workbench files: an unreadable library should cost the user their history, not the file they're working on. - **Thumbnail failures are logged.** Three `catch {}` blocks returned `""`, and an empty thumbnail is indistinguishable from "this file has no preview" — which is how outage #1 hid as a cosmetic nicety. ## CI `main` now runs the whole stubbed suite once per engine (#7304), so the new `@engine-capability` specs get chromium, firefox and webkit for free. They assert the primitives actually work — a **counted** comparison, a raster thumbnail data URL with real payload, and a page rendered from a file restored by a reload — rather than that the UI rendered. Deliberately small: anything added there is paid for three times per PR, so add depth, not breadth. Run them alone with `task e2e:cross-browser -- --grep @engine-capability`. The cross-browser projects now share the stubbed project's viewport. At the device presets' default 1280x720 a layout difference would fail these specs on Firefox/WebKit only, which reads as an engine outage. `vite.config.ts` gains a `worker.plugins` entry so `@app/*` resolves inside worker bundles. Worker bundles are a separate Rollup pass and don't inherit `plugins`, so the alias worked in the app and failed in a worker — previously worked around with a relative import plus a lint exemption, which silently bypasses the layer cascade. ## Verification - `task frontend:check` green: typecheck, oxlint, theme lint, stylelint, prettier, 215 test files / 1841 tests. - The `@engine-capability` suite passes on Chromium and WebKit locally. - **Negative control:** with the `ReadableStream` shim removed, the WebKit comparison spec fails at the Deletions/Additions assertion — the exact reported Safari symptom. Restored, and it passes. Both the fix and the test that guards it are load-bearing. - The worker alias change verified both ways: the build inlines the encoding probe into the worker chunk, and removing `worker.plugins` fails with `Rollup failed to resolve import "@app/utils/canvasImageEncoding"`. - The abort regression test aborts the transaction mid-write and asserts `markFileAsProcessed` settles. Before the fix it never settles and the test times out. ## Split out of this PR Two things in earlier revisions of this branch were engine-agnostic — found via the same symptom, not the same cause — and now have their own PRs: - **#7416** — blocked IndexedDB upgrades hanging the file library (multi-tab lifecycle, the concurrent-open race, `onversionchange`). - **#7417** — the thumbnail TTL rewriting the whole library on every listing. `FileSidebar`'s try/catch appears in both this PR and #7416, identically: a WebKit rejection and a blocked-open rejection both have to stop stranding the spinner. Whichever merges second is a no-op for that file. ## Known gaps - The blob-refused **rewrite** recovery in `updateRecord` isn't unit-tested. `fake-indexeddb` never returns Blob values from a read, so the branch that converts to a copy can't be reached there. Noted in the test file. - For the same reason, `fileFromRecord`'s "hand the stored File back untouched" path is only covered on a real engine, by the reload spec. - Nothing asserts that `src/index.tsx` imports the shims. The unit suite installs the same module via `setupTests.ts` (jsdom has the same gaps WebKit does), so a future regression where the entry point drops the import would still be green under vitest. - `FileSidebar`'s resting-state fix loses its E2E coverage until #7416 lands — forcing WebKit's blob refusal from a spec isn't practical, which is why that spec blocks the database instead. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] My changes generate no new warnings ### Documentation - [x] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed)
1 parent a7eb6eb commit 9ef20dc

45 files changed

Lines changed: 2306 additions & 405 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/.storybook/main.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ import tsconfigPaths from "vite-tsconfig-paths";
1010
* the portal layer at editor/src/portal/). MDX docs pages live in
1111
* editor/src/portal/docs/.
1212
*/
13+
/**
14+
* Editor stories import via `@app/*` (proprietary→core fallback), `@core/*` and
15+
* `@proprietary/*`. Resolve them exactly the way the editor's own build does -
16+
* through vite-tsconfig-paths against the proprietary vite tsconfig - so the
17+
* shared Storybook can host editor components without duplicating the alias map
18+
* here. Built per pass: the main bundle and the worker bundle each need their own.
19+
*/
20+
const editorPathAliases = () =>
21+
tsconfigPaths({
22+
projects: [resolve(__dirname, "../editor/tsconfig.proprietary.vite.json")],
23+
});
24+
1325
const config: StorybookConfig = {
1426
stories: [
1527
"../editor/src/portal/**/*.mdx",
@@ -47,19 +59,15 @@ const config: StorybookConfig = {
4759
// than a relative path.
4860
"@public": resolve(__dirname, "../editor/public"),
4961
};
50-
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
51-
// @proprietary/*. Resolve them exactly the way the editor's own build does —
52-
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
53-
// shared Storybook can host editor components without duplicating the alias
54-
// map here.
5562
config.plugins = config.plugins ?? [];
56-
config.plugins.push(
57-
tsconfigPaths({
58-
projects: [
59-
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
60-
],
61-
}),
62-
);
63+
config.plugins.push(editorPathAliases());
64+
// Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, so
65+
// without this a worker importing @app/* fails to resolve while the same
66+
// import works everywhere else. Mirrors editor/vite.config.ts.
67+
config.worker = {
68+
...(config.worker ?? {}),
69+
plugins: () => [editorPathAliases()],
70+
};
6371
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
6472
// (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
6573
// their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host

frontend/editor/playwright.config.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ import { defineConfig, devices } from "@playwright/test";
1717
*
1818
* @see https://playwright.dev/docs/test-configuration
1919
*/
20+
/** Shared by every stubbed project so a spec sees one layout on all engines. */
21+
const STUBBED_VIEWPORT = { width: 1920, height: 1080 };
22+
2023
const chromiumViewport = {
2124
...devices["Desktop Chrome"],
22-
viewport: { width: 1920, height: 1080 },
25+
viewport: STUBBED_VIEWPORT,
2326
};
2427

2528
export default defineConfig({
@@ -55,7 +58,8 @@ export default defineConfig({
5558
},
5659

5760
projects: [
58-
// Stubbed - no backend required, chromium-only for CI speed
61+
// Stubbed - no backend required. The chromium arm of the cross-browser
62+
// set below; CI fans all three out, one job per engine.
5963
{
6064
name: "stubbed",
6165
testDir: "./src/core/tests/stubbed",
@@ -93,16 +97,17 @@ export default defineConfig({
9397
},
9498
},
9599

96-
// Cross-browser coverage for the stubbed suite (opt-in locally)
100+
// Cross-browser coverage for the stubbed suite. Same viewport as `stubbed`,
101+
// or a layout difference here reads as an engine outage.
97102
{
98103
name: "stubbed-firefox",
99104
testDir: "./src/core/tests/stubbed",
100-
use: { ...devices["Desktop Firefox"] },
105+
use: { ...devices["Desktop Firefox"], viewport: STUBBED_VIEWPORT },
101106
},
102107
{
103108
name: "stubbed-webkit",
104109
testDir: "./src/core/tests/stubbed",
105-
use: { ...devices["Desktop Safari"] },
110+
use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT },
106111
},
107112
],
108113

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3890,6 +3890,8 @@ addFiles = "Add files"
38903890
addingFiles = "Adding files…"
38913891
collapse = "Collapse sidebar"
38923892
customizeGroups = "Customize groups"
3893+
dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it."
3894+
dataLostTitle = "File data is unavailable"
38933895
dropHint = "Open files to get started"
38943896
dropToAdd = "Drop files to add"
38953897
expand = "Expand sidebar"
@@ -3908,6 +3910,8 @@ viewAll = "View all {{count}} files"
39083910

39093911
[fileSidebar.fileItem]
39103912
closeViewer = "Close viewer"
3913+
dataLost = "Data lost"
3914+
dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it."
39113915
delete = "Delete"
39123916
moreActions = "More actions"
39133917
openInViewer = "Open in viewer"

frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@ const FileEditorThumbnail = ({
300300
const [showVersionHistory, setShowVersionHistory] = useState(false);
301301

302302
const policyEnforcing = policies.some((p) => p.enforcing);
303+
// The overlay swallows clicks, so a run that never settles would leave the card
304+
// unusable with no way out. Dismissible, like the viewer's; resets per run.
305+
const [enforcingDismissed, setEnforcingDismissed] = useState(false);
306+
if (!policyEnforcing && enforcingDismissed) setEnforcingDismissed(false);
303307
// The policy currently enforcing, so the overlay's icon/spinner match that
304308
// policy's badge instead of a fixed blue.
305309
const enforcingPolicy = policies.find((p) => p.enforcing);
@@ -548,8 +552,9 @@ const FileEditorThumbnail = ({
548552

549553
{/* Policy enforcement overlay — shown while any policy is in-flight */}
550554
<PolicyEnforcingOverlay
551-
enforcing={policyEnforcing}
555+
enforcing={policyEnforcing && !enforcingDismissed}
552556
zIndex={2}
557+
onDismiss={() => setEnforcingDismissed(true)}
553558
accentVar={enforcingPolicy?.accentColor}
554559
categoryId={enforcingPolicy?.id}
555560
/>

frontend/editor/src/core/components/layout/Workbench.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, Suspense, lazy } from "react";
22
import { useTranslation } from "react-i18next";
33
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
4-
import { Box, Loader, Center } from "@mantine/core";
4+
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
55
import { Button } from "@app/ui/Button";
66
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
77
import { useFileHandler } from "@app/hooks/useFileHandler";
@@ -44,7 +44,7 @@ export default function Workbench() {
4444
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
4545

4646
// Use context-based hooks to eliminate all prop drilling
47-
const { files: activeFiles } = useAllFiles();
47+
const { files: activeFiles, fileIds } = useAllFiles();
4848
const { workbench: currentView } = useNavigationState();
4949
const { actions: navActions } = useNavigationActions();
5050
const setCurrentView = navActions.setWorkbench;
@@ -134,6 +134,20 @@ export default function Workbench() {
134134
}
135135

136136
if (activeFiles.length === 0) {
137+
// Files are open but their bytes are still loading (a cold PDF engine can
138+
// take seconds). Showing the drop zone here reads as "the click did nothing".
139+
if (fileIds.length > 0) {
140+
return (
141+
<Center h="100%" w="100%">
142+
<Stack align="center" gap="md">
143+
<Loader size="lg" />
144+
<Text c="dimmed" size="sm">
145+
{t("fileManager.loadingFiles", "Loading files...")}
146+
</Text>
147+
</Stack>
148+
</Center>
149+
);
150+
}
137151
return <LandingPage />;
138152
}
139153

frontend/editor/src/core/components/shared/FileSidebar.tsx

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ import {
5959
deleteServerFile,
6060
type DeleteScope,
6161
} from "@app/services/serverStorageDelete";
62-
import { fileStorage } from "@app/services/fileStorage";
62+
import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage";
63+
import { alert } from "@app/components/toast";
6364
import { useBulkAddProgress } from "@app/services/bulkAddProgress";
6465
import { useFolderMembership } from "@app/hooks/useFolderMembership";
6566
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
@@ -280,6 +281,19 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
280281

281282
// Leaf files = user-visible files (excludes intermediate tool outputs)
282283
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
284+
// Files whose stored bytes this session PROVED unreadable. Rows render a
285+
// "data lost" state instead of pretending the file can open; storage keeps
286+
// the record so a reload re-tests it.
287+
const [lostFileIds, setLostFileIds] = useState<ReadonlySet<string>>(
288+
() => new Set(),
289+
);
290+
useEffect(
291+
() =>
292+
onRecordUnreadable((fileId) =>
293+
setLostFileIds((prev) => new Set(prev).add(fileId as string)),
294+
),
295+
[],
296+
);
283297
const [stubsLoaded, setStubsLoaded] = useState(false);
284298
// Kebab "Save to cloud" target; drives BulkUploadToServerModal.
285299
const [saveToServerTarget, setSaveToServerTarget] = useState<
@@ -298,32 +312,45 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
298312
const storageEnabled = config?.storageEnabled === true && !isAnonymous;
299313

300314
const refreshStubs = useCallback(async () => {
301-
// Leaf files from IDB - same source as the file selection modal.
302-
const stubs = await indexedDB.loadLeafMetadata();
303-
const idbIds = new Set(stubs.map((s) => s.id as string));
304-
305-
// Also include workbench files not yet flushed to IDB.
306-
const pendingStubs = state.files.ids
307-
.map((id) => state.files.byId[id])
308-
.filter(
309-
(stub): stub is NonNullable<typeof stub> =>
310-
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
311-
);
315+
// `stubsLoaded` gates the spinner, so the `finally` below must set it on
316+
// every path - callers never await this, so a rejection goes nowhere.
317+
let stubs: StirlingFileStub[] = [];
318+
try {
319+
// Leaf files from IDB - same source as the file selection modal.
320+
stubs = await indexedDB.loadLeafMetadata();
321+
} catch (error) {
322+
// Carry on with the in-memory workbench files: an unreadable library
323+
// should cost the user their history, not the file they're working on.
324+
console.error("Failed to read the file library from storage:", error);
325+
}
312326

313-
const allStubs = [...stubs, ...pendingStubs];
314-
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
315-
const superseded = new Set(
316-
allStubs.map((s) => s.parentFileId as string | undefined),
317-
);
318-
const currentStubs = allStubs.filter(
319-
(s) => !superseded.has(s.id as string),
320-
);
321-
setAllFileStubs(
322-
currentStubs.sort(
323-
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
324-
),
325-
);
326-
setStubsLoaded(true);
327+
try {
328+
const idbIds = new Set(stubs.map((s) => s.id as string));
329+
330+
// Also include workbench files not yet flushed to IDB.
331+
const pendingStubs = state.files.ids
332+
.map((id) => state.files.byId[id])
333+
.filter(
334+
(stub): stub is NonNullable<typeof stub> =>
335+
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
336+
);
337+
338+
const allStubs = [...stubs, ...pendingStubs];
339+
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
340+
const superseded = new Set(
341+
allStubs.map((s) => s.parentFileId as string | undefined),
342+
);
343+
const currentStubs = allStubs.filter(
344+
(s) => !superseded.has(s.id as string),
345+
);
346+
setAllFileStubs(
347+
currentStubs.sort(
348+
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
349+
),
350+
);
351+
} finally {
352+
setStubsLoaded(true);
353+
}
327354
}, [indexedDB, state.files.ids, state.files.byId]);
328355

329356
// Refresh on mount, workbench changes, or external IndexedDB writes —
@@ -362,7 +389,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
362389
setDeleteTarget(stub);
363390
return;
364391
}
365-
await fileActions.removeFiles([fileId], true);
392+
// Its superseded versions go too - see orphanedAncestorIds.
393+
const orphans = await fileStorage.orphanedAncestorIds([fileId]);
394+
await fileActions.removeFiles([fileId, ...orphans], true);
366395
await refreshStubs();
367396
},
368397
[allFileStubs, fileActions, refreshStubs],
@@ -380,7 +409,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
380409
await deleteServerFile(stub.remoteStorageId);
381410
}
382411
if (scope === "device" || scope === "everywhere") {
383-
await fileActions.removeFiles([stub.id], true);
412+
const orphans = await fileStorage.orphanedAncestorIds([stub.id]);
413+
await fileActions.removeFiles([stub.id, ...orphans], true);
384414
} else if (scope === "cloud") {
385415
// Local copy kept - drop the dead remote pointer so the cloud badge
386416
// clears (the sidebar doesn't reconcile with the server itself).
@@ -484,6 +514,22 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
484514
const stub = allFileStubs.find((s) => s.id === fileId);
485515
if (!stub) return;
486516

517+
// Its bytes are gone; opening it can only fail. Say so instead of a
518+
// click that goes nowhere.
519+
if (stub.dataUnavailable || lostFileIds.has(fileId as string)) {
520+
alert({
521+
alertType: "warning",
522+
title: t("fileSidebar.dataLostTitle", "File data is unavailable"),
523+
body: t(
524+
"fileSidebar.dataLostBody",
525+
"This browser lost this file's contents. Upload it again to keep working with it.",
526+
),
527+
expandable: false,
528+
durationMs: 6000,
529+
});
530+
return;
531+
}
532+
487533
// In the Watched Folders view a click sends the file into the open folder
488534
// (mirrors how a click toggles a file into the active workbench elsewhere).
489535
// On the folder list (no folder open) it's a no-op so browsing isn't disrupted.
@@ -538,6 +584,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
538584
},
539585
[
540586
allFileStubs,
587+
lostFileIds,
588+
t,
541589
state.files.ids,
542590
state.ui.selectedFileIds,
543591
fileActions,
@@ -725,6 +773,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
725773
? state.files.byId[workbenchFileId]?.thumbnailUrl
726774
: undefined) || stub.thumbnailUrl;
727775
const fileOrigin = getFileOrigin(stub);
776+
const dataUnavailable =
777+
stub.dataUnavailable === true || lostFileIds.has(stub.id as string);
728778
// Key by lineage (originalFileId) so a version swap updates the row in place instead of
729779
// remounting. But a 1-input→many-output op (split) yields sibling leaves that share one
730780
// originalFileId; those would collide on the key, so fall back to the unique leaf id when a
@@ -747,6 +797,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
747797
thumbnailUrl={thumbnailUrl}
748798
onClick={handleFileClick}
749799
onEyeClick={handleEyeClick}
800+
dataUnavailable={dataUnavailable}
750801
draggable={isWatchedFoldersActive}
751802
onDragStart={handleWatchedFolderDragStart}
752803
folders={memberFolders}

frontend/editor/src/core/components/shared/FileSidebarFileItem.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,13 @@
447447
transform: translateY(-50%) scale(1);
448448
}
449449
}
450+
451+
/* The stored bytes are gone - the row says so instead of pretending to open. */
452+
.file-sidebar-datalost-badge {
453+
display: inline-flex;
454+
align-items: center;
455+
gap: 0.15rem;
456+
color: var(--c-danger);
457+
font-size: 0.7rem;
458+
white-space: nowrap;
459+
}

0 commit comments

Comments
 (0)