Browse Source

Compress and cache the web interface's asset files (#3175)

/assets was served uncompressed and without Cache-Control, so phones
downloaded the full 11 MB bundle and revalidated it on every load. Text
assets are now gzip-compressed once per file and kept in memory, and
files with a content hash in their name are marked immutable. The
pdf.js runtime data has no hash and is revalidated instead.

-----

Load pages and languages when they are needed (#3175)

Every page and all 15 languages were one 11 MB script. Pages are now
separate files loaded on first open, and only English is bundled; the
language in use loads before the first render. A tab left on an older
build reloads once when a file is gone, after the server answers
again, so a kiosk is never left on the browser's connection error page.
The service worker serves hashed files from its cache and drops the
previous build's files.
maziggy 1 day ago
parent
commit
89a9a81195
100 changed files with 932 additions and 87 deletions
  1. 8 0
      CHANGELOG.md
  2. 153 0
      backend/app/core/static_assets.py
  3. 3 1
      backend/app/main.py
  4. 207 0
      backend/tests/unit/test_static_assets.py
  5. 67 3
      frontend/public/sw.js
  6. 42 33
      frontend/src/App.tsx
  7. 62 0
      frontend/src/__tests__/i18n/locale-loading.test.ts
  8. 8 1
      frontend/src/__tests__/i18n/sv-outcome-strings.test.ts
  9. 181 0
      frontend/src/__tests__/utils/lazyPage.test.tsx
  10. 6 2
      frontend/src/components/Layout.tsx
  11. 16 0
      frontend/src/components/PageLoading.tsx
  12. 8 5
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  13. 49 34
      frontend/src/i18n/index.ts
  14. 15 6
      frontend/src/main.tsx
  15. 81 0
      frontend/src/utils/lazyPage.ts
  16. 0 0
      static/assets/AdditionalSection-Bcoa_hnW.js
  17. 0 0
      static/assets/AmsUnitCard-DLM3L08d.js
  18. 0 0
      static/assets/ArchivesPage-MaOGuDlA.js
  19. 0 0
      static/assets/AreaChart-J0I0YlYN.js
  20. 0 0
      static/assets/AssignToAmsModal-kr-0EzwV.js
  21. 1 0
      static/assets/Button-pwVKPiYn.js
  22. 0 0
      static/assets/CamWallPage-C1rIEzhG.js
  23. 0 0
      static/assets/CameraDiagnoseModal-DWbtempu.js
  24. 0 0
      static/assets/CameraPage-C1mWeQfG.js
  25. 0 0
      static/assets/CameraWall-Br9MDE6G.js
  26. 1 0
      static/assets/Card-OUAj0Ayh.js
  27. 0 0
      static/assets/CartesianChart-DcboTNtX.js
  28. 0 0
      static/assets/ColumnConfigModal-B9cmtxVm.js
  29. 0 0
      static/assets/ConfigureAmsSlotModal-C4N9v6PJ.js
  30. 0 0
      static/assets/ConnectAuthorizePage-DtVlpfFV.js
  31. 0 0
      static/assets/ContextMenu-CqE3-J0R.js
  32. 0 0
      static/assets/CopyButton-CyNT06yS.js
  33. 0 0
      static/assets/ErrorBarContext-6wvnfk_q.js
  34. 0 0
      static/assets/ExternalLinkPage-CM4__1rh.js
  35. 1 0
      static/assets/FileManagerPage-Kp8jeXP4.js
  36. 0 0
      static/assets/FileUploadModal-hFWmsB_i.js
  37. 0 0
      static/assets/FinancePage-BfhaugR1.js
  38. 0 0
      static/assets/GCodeViewerPage-C24Va31U.js
  39. 0 0
      static/assets/GcodeToolpathViewer-D4M17KI8.js
  40. 0 0
      static/assets/GroupEditPage-i9TLWDyl.js
  41. 0 0
      static/assets/ImagePreviewModal-BcWeiOrd.js
  42. 0 0
      static/assets/ImagePreviewModal-R11uy3ZP.js
  43. 0 0
      static/assets/InventoryPage-BWTPGneX.js
  44. 0 0
      static/assets/LibraryTrashPage-Ck7R9blU.js
  45. 0 0
      static/assets/LoginPage-CXIN3BLj.js
  46. 0 0
      static/assets/MaintenancePage-BAMWk5DI.js
  47. 0 0
      static/assets/ModelSourcesPage-C9OvAi9p.js
  48. 0 0
      static/assets/NotificationsPage-5GOJDRkw.js
  49. 0 0
      static/assets/OrbitControls-Bbqf07B-.js
  50. 1 0
      static/assets/OverlayFrame-BUFy7NI1.js
  51. 1 0
      static/assets/PdfPreviewModal-CRMSWXkI.js
  52. 0 1
      static/assets/PdfPreviewModal-ChNGaUl4.js
  53. 0 0
      static/assets/PhotoGalleryModal-BxTCJXU3.js
  54. 0 0
      static/assets/PrinterLocationsPage-D2AqeJ9i.js
  55. 0 0
      static/assets/PrintersPage-zDUFDj5q.js
  56. 0 0
      static/assets/ProfilesPage-C0jHVdln.js
  57. 0 0
      static/assets/ProjectDetailPage-CaZEFrXK.js
  58. 0 0
      static/assets/ProjectsPage-C2aXY-5x.js
  59. 0 0
      static/assets/QueryClientProvider-Clbyr4Jg.js
  60. 0 0
      static/assets/QueuePage-DTh2aCRG.js
  61. 0 0
      static/assets/ReferenceLine-l3CI8D5f.js
  62. 0 0
      static/assets/RichTextEditor-CtyYZFiu.js
  63. 0 0
      static/assets/SettingsPage-DOroBYzD.js
  64. 0 0
      static/assets/SetupPage-hUHebIlA.js
  65. 0 0
      static/assets/SliceModal-DilRo4MU.js
  66. 0 0
      static/assets/SpoolBuddyAmsPage-CCeWMoIm.js
  67. 0 0
      static/assets/SpoolBuddyCalibrationPage-BPEaykbd.js
  68. 0 0
      static/assets/SpoolBuddyDashboard-B_0MmvgI.js
  69. 0 0
      static/assets/SpoolBuddyInventoryPage-BmxJPbmk.js
  70. 1 0
      static/assets/SpoolBuddySettingsPage-DKia6Rid.js
  71. 0 0
      static/assets/SpoolBuddyWriteTagPage-CMlhWgSL.js
  72. 1 0
      static/assets/SpoolIcon-OZKzbEc8.js
  73. 0 1
      static/assets/SpreadsheetPreviewModal-HCix387J.js
  74. 1 0
      static/assets/SpreadsheetPreviewModal-WUjrw07T.js
  75. 0 0
      static/assets/StatsPage-D0cqrND3.js
  76. 0 0
      static/assets/StreamOverlayPage-CA9KpVK8.css
  77. 0 0
      static/assets/StreamOverlayPage-DWOhiuT-.js
  78. 0 0
      static/assets/SystemInfoPage-ClAReSss.js
  79. 0 0
      static/assets/ToastContext-VFOrCCoA.js
  80. 1 0
      static/assets/Toggle-CwMiiK0g.js
  81. 1 0
      static/assets/arrow-down-C4OBNDqJ.js
  82. 1 0
      static/assets/arrow-left-MSgMxOFV.js
  83. 1 0
      static/assets/arrow-right-Da96kh4S.js
  84. 1 0
      static/assets/arrow-up-Dam8cLfE.js
  85. 1 0
      static/assets/arrow-up-down-C-1Kkt1x.js
  86. 1 0
      static/assets/box-BIRPxdgi.js
  87. 1 0
      static/assets/briefcase-BmuFBTLZ.js
  88. 1 0
      static/assets/calendar-DiVEdzUw.js
  89. 1 0
      static/assets/check-D1Pn8T1J.js
  90. 1 0
      static/assets/chevrons-right-OnQ-9jKz.js
  91. 0 0
      static/assets/client-BdOyoZJz.js
  92. 1 0
      static/assets/cloud-off-DmnG-KIw.js
  93. 1 0
      static/assets/cog-pa-nvQzt.js
  94. 1 0
      static/assets/coins-6tqy9spE.js
  95. 1 0
      static/assets/columns-2-CGEUxYp_.js
  96. 1 0
      static/assets/copy-3N5VKzXz.js
  97. 1 0
      static/assets/cpu-DAX4KhDZ.js
  98. 1 0
      static/assets/createLucideIcon-Yo2YoYjm.js
  99. 0 0
      static/assets/date-G9KUauqz.js
  100. 0 0
      static/assets/de-DvnDPDo7.js

+ 8 - 0
CHANGELOG.md

@@ -111,6 +111,14 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 ### Changed
+- **The web interface downloads about 70% less on each new version (#3175, requested by @mason9468)** — The interface's script files were sent uncompressed and with no caching instructions, so a phone fetched the whole 11 MB again and the browser asked for it on every load.
+  - **Compressed:** scripts, styles and other text files under `/assets` now go out gzip-compressed when the browser accepts it. The main script drops from 11.0 MB to 3.0 MB.
+  - **Cached for good:** files whose name carries a content hash are now marked as never changing, so the browser loads them from its own cache until an update brings new ones. The page itself is still checked on every load, so updates are picked up as before.
+- **The web interface opens faster: pages and languages load when they are needed (#3175, requested by @mason9468)** — Every page and all 15 languages were in one 11 MB script that the browser had to load and run before anything showed, which was slow on phones and in the installed app.
+  - **Pages:** each page is now its own file, loaded the first time it is opened. The login page needs 1.2 MB instead of 11 MB, the Printers page 2.8 MB. While a page loads, a spinner shows in the content area and the sidebar stays.
+  - **Languages:** only English is built in, as the fallback. The language in use is loaded on start, before the first screen, so a German user never sees English first. Switching language in Settings loads the new one before the switch.
+  - **After an update:** a tab left open on the previous version would ask for files the update removed. It now reloads once and continues on the new version. If a file is still missing after that, the error screen shows instead of reloading again. While Bambuddy is unreachable, for example while it restarts for the update, the page keeps its loading spinner and reloads only once the server answers, so a SpoolBuddy kiosk is never left on the browser's connection error page.
+  - **Installed app:** the service worker answers the content-hashed files from its own cache and drops the previous version's files when an update arrives, instead of keeping every version it has seen.
 - **Notification providers are listed alphabetically** — The Provider Type list in the Add Notification Provider dialog is now sorted by name, in whichever language the app is set to, instead of a fixed order that was hard to scan.
 - **The Streaming Overlay builder remembers your choices** — Settings → Camera → Streaming Overlay kept its choices only while the page was open, so the fields to show, printer, size, frame rate, layout, artwork, transparency, camera feed and progress colours were back at their defaults after a reload or a visit to another page. They are now remembered in the browser you used, with a "Settings saved" toast once your edits settle. The token is still never stored. A new **Reset choices** link puts everything back to the defaults and keeps the token and the uploaded logo.
 - **The camera settings stay short on large farms** — Settings → Camera listed every printer with its external camera switch and fields, so a farm of 120 printers got a card many screens long. The **External Cameras** list now holds only the printers that have one: **Add external camera** picks a printer from a list you can search by name or location, each row shows the camera type and address and folds its fields away, and a bin icon switches the printer back to its built-in camera.

+ 153 - 0
backend/app/core/static_assets.py

@@ -0,0 +1,153 @@
+"""Serving the frontend build's /assets directory (#3175).
+
+Plain ``StaticFiles`` sends every file uncompressed and with no
+Cache-Control, so a phone downloads the whole bundle in full and the
+browser asks again on every load. This subclass adds two things:
+
+- gzip for text assets when the browser accepts it. Each file is
+  compressed once and kept in memory, keyed by path, size and mtime: Vite
+  never changes a hashed file in place, and a rebuild that does replace one
+  changes the key. Compressing per request rather than shipping ``.gz``
+  copies keeps ``static/`` (tracked in git) at its current size.
+- ``immutable`` caching for the files Vite names by content hash. The
+  pdf.js runtime data under ``assets/pdfjs/`` keeps its upstream names, so
+  it is revalidated instead.
+
+Only /assets uses this. An app-wide compression middleware would also
+reach the camera streams and the WebSocket.
+"""
+
+from __future__ import annotations
+
+import gzip
+import os
+import re
+from collections import OrderedDict
+
+import anyio
+from starlette.datastructures import Headers
+from starlette.responses import FileResponse, Response
+from starlette.staticfiles import NotModifiedResponse, StaticFiles
+from starlette.types import Scope
+
+# Vite's output name: <name>-<8-char hash>.<ext>, directly in assets/.
+_HASHED_ASSET_RE = re.compile(r"^[^/\\]+-[A-Za-z0-9_-]{8}\.[A-Za-z0-9]+$")
+
+IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
+REVALIDATE_CACHE_CONTROL = "no-cache"
+
+_COMPRESSIBLE_SUFFIXES = (".js", ".mjs", ".css", ".json", ".svg", ".map", ".wasm", ".txt", ".html")
+# Below this the gzip header costs more than it saves.
+_MIN_COMPRESS_SIZE = 1024
+# The whole build gzips to a few MB; the cap only matters if a pile of
+# rebuilds runs against one long-lived process.
+_CACHE_MAX_BYTES = 64 * 1024 * 1024
+
+
+def accepts_gzip(accept_encoding: str | None) -> bool:
+    """Whether an Accept-Encoding header allows gzip (q=0 refuses it)."""
+    if not accept_encoding:
+        return False
+    qualities: dict[str, float] = {}
+    for part in accept_encoding.split(","):
+        coding, *params = part.strip().split(";")
+        q = 1.0
+        for param in params:
+            name, _, value = param.strip().partition("=")
+            if name.strip().lower() == "q":
+                try:
+                    q = float(value)
+                except ValueError:
+                    q = 0.0
+        qualities[coding.strip().lower()] = q
+    if "gzip" in qualities:
+        return qualities["gzip"] > 0
+    return qualities.get("*", 0.0) > 0
+
+
+class _GzipCache:
+    """Compressed bodies, least recently used dropped first."""
+
+    def __init__(self, max_bytes: int) -> None:
+        self.max_bytes = max_bytes
+        self._entries: OrderedDict[tuple[str, int, int], bytes] = OrderedDict()
+        self._size = 0
+
+    async def get(self, path: str, stat_result: os.stat_result) -> bytes:
+        key = (path, stat_result.st_size, stat_result.st_mtime_ns)
+        body = self._entries.get(key)
+        if body is not None:
+            self._entries.move_to_end(key)
+            return body
+        body = await anyio.to_thread.run_sync(_gzip_file, path)
+        # Two first requests for one file can both get here; the second
+        # simply replaces the first's entry.
+        if key in self._entries:
+            self._size -= len(self._entries.pop(key))
+        if len(body) <= self.max_bytes:
+            self._entries[key] = body
+            self._size += len(body)
+            while self._size > self.max_bytes:
+                _, dropped = self._entries.popitem(last=False)
+                self._size -= len(dropped)
+        return body
+
+    def clear(self) -> None:
+        self._entries.clear()
+        self._size = 0
+
+
+def _gzip_file(path: str) -> bytes:
+    with open(path, "rb") as f:
+        # mtime=0 keeps the output identical for identical input.
+        return gzip.compress(f.read(), compresslevel=6, mtime=0)
+
+
+_gzip_cache = _GzipCache(_CACHE_MAX_BYTES)
+
+
+def _gzip_etag(etag: str) -> str:
+    """The gzip variant's ETag. It must differ from the identity one, or a
+    cache could answer a gzip request with the uncompressed body."""
+    return etag[:-1] + '-gzip"' if etag.endswith('"') else etag + "-gzip"
+
+
+class AssetStaticFiles(StaticFiles):
+    async def get_response(self, path: str, scope: Scope) -> Response:
+        response = await super().get_response(path, scope)
+        if response.status_code not in (200, 304):
+            return response
+
+        response.headers["Cache-Control"] = (
+            IMMUTABLE_CACHE_CONTROL if _HASHED_ASSET_RE.match(path) else REVALIDATE_CACHE_CONTROL
+        )
+        if not path.lower().endswith(_COMPRESSIBLE_SUFFIXES):
+            return response
+        response.headers["Vary"] = "Accept-Encoding"
+
+        if (
+            not isinstance(response, FileResponse)
+            or response.status_code != 200
+            or scope["method"] != "GET"
+            or response.stat_result is None
+            or response.stat_result.st_size < _MIN_COMPRESS_SIZE
+        ):
+            return response
+        request_headers = Headers(scope=scope)
+        # Ranges are byte offsets into the identity body; leave them to FileResponse.
+        if "range" in request_headers or not accepts_gzip(request_headers.get("accept-encoding")):
+            return response
+
+        headers = dict(response.headers)
+        headers.pop("content-length", None)
+        headers.pop("accept-ranges", None)
+        headers["content-encoding"] = "gzip"
+        etag = headers.get("etag")
+        if etag:
+            headers["etag"] = _gzip_etag(etag)
+            if_none_match = request_headers.get("if-none-match")
+            if if_none_match and headers["etag"] in [t.strip().removeprefix("W/") for t in if_none_match.split(",")]:
+                return NotModifiedResponse(Headers(headers))
+
+        body = await _gzip_cache.get(str(response.path), response.stat_result)
+        return Response(content=body, status_code=200, headers=headers)

+ 3 - 1
backend/app/main.py

@@ -89,6 +89,7 @@ from backend.app.api.routes.maintenance import _get_printer_maintenance_internal
 from backend.app.api.routes.support import init_debug_logging
 from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.database import async_session, engine, init_db
+from backend.app.core.static_assets import AssetStaticFiles
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.services import kprofile_drift, print_dispatch_context, slot_unlink_grace
@@ -10844,9 +10845,10 @@ app.include_router(spoolbuddy.router, prefix=app_settings.api_prefix)
 
 # Serve static files (React build)
 if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
+    # Compressed, and cached for good where Vite hashed the name (#3175).
     app.mount(
         "/assets",
-        StaticFiles(directory=app_settings.static_dir / "assets"),
+        AssetStaticFiles(directory=app_settings.static_dir / "assets"),
         name="assets",
     )
     if (app_settings.static_dir / "img").exists():

+ 207 - 0
backend/tests/unit/test_static_assets.py

@@ -0,0 +1,207 @@
+"""/assets serving: gzip and long-lived caching for the frontend build (#3175)."""
+
+from __future__ import annotations
+
+import gzip
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+from backend.app.core import static_assets
+from backend.app.core.static_assets import (
+    IMMUTABLE_CACHE_CONTROL,
+    REVALIDATE_CACHE_CONTROL,
+    AssetStaticFiles,
+    accepts_gzip,
+)
+
+BUNDLE = b"export const answer = 42;\n" * 400  # ~10 KB, compresses well
+BUNDLE_NAME = "index-CdoB5JwO.js"
+
+
+@pytest.fixture
+def assets_dir(tmp_path):
+    root = tmp_path / "assets"
+    (root / "pdfjs" / "cmaps").mkdir(parents=True)
+    (root / BUNDLE_NAME).write_bytes(BUNDLE)
+    (root / "index-Dk3mQx9a.css").write_bytes(b"body { color: red; }\n" * 200)
+    (root / "tiny-AbCdEfGh.js").write_bytes(b"export {};\n")
+    (root / "logo-AbCdEfGh.png").write_bytes(b"\x89PNG" + b"\x00" * 4000)
+    (root / "pdfjs" / "cmaps" / "Adobe-Japan1-UCS2.bcmap").write_bytes(b"\x00" * 4000)
+    (root / "pdfjs" / "wasm.json").write_bytes(b'{"a": 1}' * 400)
+    static_assets._gzip_cache.clear()
+    yield root
+    static_assets._gzip_cache.clear()
+
+
+@pytest.fixture
+async def client(assets_dir):
+    app = Starlette(routes=[Mount("/assets", app=AssetStaticFiles(directory=assets_dir))])
+    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+        yield c
+
+
+async def _get(client: AsyncClient, path: str, **headers: str):
+    """GET without httpx decoding the body, so the raw bytes can be checked."""
+    request = client.build_request("GET", path, headers=headers)
+    response = await client.send(request, stream=True)
+    raw = b"".join([chunk async for chunk in response.aiter_raw()])
+    await response.aclose()
+    return response, raw
+
+
+class TestCompression:
+    @pytest.mark.asyncio
+    async def test_bundle_is_gzipped_when_accepted(self, client):
+        response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip, deflate, br"})
+
+        assert response.status_code == 200
+        assert response.headers["content-encoding"] == "gzip"
+        assert response.headers["vary"] == "Accept-Encoding"
+        assert response.headers["content-type"].startswith("text/javascript")
+        assert int(response.headers["content-length"]) == len(raw) < len(BUNDLE)
+        assert gzip.decompress(raw) == BUNDLE
+        assert "accept-ranges" not in response.headers
+
+    @pytest.mark.asyncio
+    async def test_identity_when_gzip_not_accepted(self, client):
+        response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"})
+
+        assert response.status_code == 200
+        assert "content-encoding" not in response.headers
+        assert response.headers["vary"] == "Accept-Encoding"
+        assert raw == BUNDLE
+
+    @pytest.mark.asyncio
+    async def test_gzip_q0_is_a_refusal(self, client):
+        response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip;q=0, *"})
+
+        assert "content-encoding" not in response.headers
+        assert raw == BUNDLE
+
+    @pytest.mark.asyncio
+    async def test_variants_carry_different_etags(self, client):
+        plain, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"})
+        zipped, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
+
+        assert plain.headers["etag"] != zipped.headers["etag"]
+
+    @pytest.mark.asyncio
+    async def test_gzip_etag_revalidates_to_304(self, client):
+        first, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
+        again, raw = await _get(
+            client,
+            f"/assets/{BUNDLE_NAME}",
+            **{"Accept-Encoding": "gzip", "If-None-Match": first.headers["etag"]},
+        )
+
+        assert again.status_code == 304
+        assert raw == b""
+        assert again.headers["etag"] == first.headers["etag"]
+        assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
+
+    @pytest.mark.asyncio
+    async def test_range_request_is_served_uncompressed(self, client):
+        response, raw = await _get(
+            client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip", "Range": "bytes=0-9"}
+        )
+
+        assert response.status_code == 206
+        assert "content-encoding" not in response.headers
+        assert raw == BUNDLE[:10]
+
+    @pytest.mark.asyncio
+    async def test_head_is_not_compressed(self, client):
+        response = await client.head(f"/assets/{BUNDLE_NAME}", headers={"Accept-Encoding": "gzip"})
+
+        assert response.status_code == 200
+        assert "content-encoding" not in response.headers
+        assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("name", ["tiny-AbCdEfGh.js", "logo-AbCdEfGh.png", "pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap"])
+    async def test_small_or_binary_files_are_not_compressed(self, client, name):
+        response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"})
+
+        assert response.status_code == 200
+        assert "content-encoding" not in response.headers
+
+    @pytest.mark.asyncio
+    async def test_compressed_body_is_reused(self, client, monkeypatch):
+        calls = []
+        real = static_assets._gzip_file
+        monkeypatch.setattr(static_assets, "_gzip_file", lambda path: calls.append(path) or real(path))
+
+        for _ in range(3):
+            response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
+            assert gzip.decompress(raw) == BUNDLE
+
+        assert len(calls) == 1
+
+    @pytest.mark.asyncio
+    async def test_rebuilt_file_is_compressed_again(self, client, assets_dir):
+        await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
+        (assets_dir / BUNDLE_NAME).write_bytes(b"export const answer = 43;\n" * 500)
+
+        _, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
+
+        assert gzip.decompress(raw) == b"export const answer = 43;\n" * 500
+
+
+class TestCacheControl:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("name", [BUNDLE_NAME, "index-Dk3mQx9a.css", "logo-AbCdEfGh.png"])
+    async def test_hashed_files_are_immutable(self, client, name):
+        response, _ = await _get(client, f"/assets/{name}")
+
+        assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("name", ["pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap", "pdfjs/wasm.json"])
+    async def test_pdfjs_runtime_files_are_revalidated(self, client, name):
+        response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"})
+
+        assert response.status_code == 200
+        assert response.headers["cache-control"] == REVALIDATE_CACHE_CONTROL
+
+    @pytest.mark.asyncio
+    async def test_identity_304_keeps_cache_control(self, client):
+        first, _ = await _get(client, f"/assets/{BUNDLE_NAME}")
+        again, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"If-None-Match": first.headers["etag"]})
+
+        assert again.status_code == 304
+        assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
+
+    @pytest.mark.asyncio
+    async def test_missing_old_chunk_is_404_not_html(self, client):
+        """A tab still running the previous build asks for chunks the new build
+        deleted. It must get a 404 so the frontend can reload, never a page."""
+        response, _ = await _get(client, "/assets/ProfilesPage-OldHash1.js", **{"Accept-Encoding": "gzip"})
+
+        assert response.status_code == 404
+        assert "cache-control" not in response.headers
+
+
+@pytest.mark.parametrize(
+    ("header", "expected"),
+    [
+        (None, False),
+        ("", False),
+        ("gzip", True),
+        ("GZIP", True),
+        ("gzip, deflate, br, zstd", True),
+        ("br", False),
+        ("identity", False),
+        ("gzip;q=0", False),
+        ("gzip; q=0.5", True),
+        ("*", True),
+        ("*;q=0", False),
+        ("gzip;q=0, *", False),
+        ("br, *;q=0.1", True),
+        ("gzip;q=abc", False),
+    ],
+)
+def test_accepts_gzip(header, expected):
+    assert accepts_gzip(header) is expected

+ 67 - 3
frontend/public/sw.js

@@ -1,5 +1,5 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v30';
+const CACHE_NAME = 'bambuddy-v31';
 const STATIC_CACHE = 'bambuddy-static-v29';
 
 // Static assets to cache on install
@@ -18,6 +18,33 @@ const STATIC_ASSETS = [
   '/fonts/inter-latin-ext.woff2',
 ];
 
+// <name>-<8-char hash>.<ext> directly under /assets/, as Vite emits them.
+const HASHED_ASSET_RE = /^\/assets\/[^/]+-[A-Za-z0-9_-]{8}\.[A-Za-z0-9]+$/;
+const ENTRY_SCRIPT_RE = /\/assets\/index-[A-Za-z0-9_-]{8}\.js/;
+
+// An update ships new file names, and the old files would stay cached for
+// good. When a page names an entry script the cache has not seen, that is a
+// new build: drop the cached /assets files, and the new ones are cached as
+// they are used.
+async function dropOldBuildAssets(response) {
+  try {
+    const html = await response.text();
+    const entry = html.match(ENTRY_SCRIPT_RE);
+    if (!entry) return;
+    const cache = await caches.open(CACHE_NAME);
+    // Listed first, so files the new build caches meanwhile are kept.
+    const keys = await cache.keys();
+    if (await cache.match(entry[0])) return;
+    await Promise.all(
+      keys
+        .filter((key) => new URL(key.url).pathname.startsWith('/assets/'))
+        .map((key) => cache.delete(key)),
+    );
+  } catch {
+    // Pruning is housekeeping; never let it break a page load.
+  }
+}
+
 // Install event - cache static assets
 self.addEventListener('install', (event) => {
   console.log('[SW] Installing service worker...');
@@ -83,6 +110,12 @@ self.addEventListener('fetch', (event) => {
     return;
   }
 
+  // The page polls /health to learn whether the server is back (#3175); an
+  // answer from the cache would say it is when it is not.
+  if (url.pathname === '/health') {
+    return;
+  }
+
   // Skip camera stream/snapshot requests - Safari has issues with streaming through SW
   if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {
     return;
@@ -136,8 +169,33 @@ self.addEventListener('fetch', (event) => {
     return;
   }
 
-  // JS/CSS assets - network first (Vite content-hashes filenames, so
-  // cache-busting is built in; network-first ensures new builds load immediately)
+  // Files Vite names by content hash never change (#3175): serve them from
+  // the cache, so an installed app opens without asking the server for the
+  // bundle. The backend marks them immutable too.
+  if (HASHED_ASSET_RE.test(url.pathname)) {
+    event.respondWith(
+      caches.match(request).then((cached) => {
+        if (cached) {
+          return cached;
+        }
+        return fetch(request).then((response) => {
+          // Kept for good, so never keep a page a proxy sent for a missing file.
+          const isHtml = (response.headers.get('content-type') || '').includes('text/html');
+          if (response.ok && !isHtml) {
+            const clone = response.clone();
+            caches.open(CACHE_NAME).then((cache) => {
+              cache.put(request, clone);
+            });
+          }
+          return response;
+        });
+      })
+    );
+    return;
+  }
+
+  // Other JS/CSS (the pdf.js runtime data under /assets/pdfjs/, ...) - network
+  // first, the cache only when offline.
   if (
     url.pathname.startsWith('/assets/') ||
     url.pathname.endsWith('.js') ||
@@ -170,6 +228,12 @@ self.addEventListener('fetch', (event) => {
           caches.open(CACHE_NAME).then((cache) => {
             cache.put(request, clone);
           });
+          const pruning = dropOldBuildAssets(response.clone());
+          try {
+            event.waitUntil(pruning);
+          } catch {
+            // Too late to extend the event in this browser; pruning still runs.
+          }
         }
         return response;
       })

+ 42 - 33
frontend/src/App.tsx

@@ -1,33 +1,7 @@
-import { Component, type ReactNode, type ErrorInfo } from 'react';
+import { Component, Suspense, type ReactNode, type ErrorInfo } from 'react';
 import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { Layout } from './components/Layout';
-import { PrintersPage } from './pages/PrintersPage';
-import { ArchivesPage } from './pages/ArchivesPage';
-import { QueuePage } from './pages/QueuePage';
-import { StatsPage } from './pages/StatsPage';
-import { SettingsPage } from './pages/SettingsPage';
-import { FinancePage } from './pages/FinancePage';
-import { ProfilesPage } from './pages/ProfilesPage';
-import { MaintenancePage } from './pages/MaintenancePage';
-import { ProjectsPage } from './pages/ProjectsPage';
-import { ProjectDetailPage } from './pages/ProjectDetailPage';
-import { FileManagerPage } from './pages/FileManagerPage';
-import { LibraryTrashPage } from './pages/LibraryTrashPage';
-import { CameraPage } from './pages/CameraPage';
-import { CamWallPage } from './pages/CamWallPage';
-import { StreamOverlayPage } from './pages/StreamOverlayPage';
-import { ExternalLinkPage } from './pages/ExternalLinkPage';
-import { GroupEditPage } from './pages/GroupEditPage';
-import { PrinterLocationsPage } from './pages/PrinterLocationsPage';
-import InventoryPage from './pages/InventoryPage';
-import { ModelSourcesPage } from './pages/ModelSourcesPage';
-import { SystemInfoPage } from './pages/SystemInfoPage';
-import { LoginPage } from './pages/LoginPage';
-import { ConnectAuthorizePage } from './pages/ConnectAuthorizePage';
-import { SetupPage } from './pages/SetupPage';
-import { NotificationsPage } from './pages/NotificationsPage';
-import { GCodeViewerPage } from './pages/GCodeViewerPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { usePrintProgressTitle } from './hooks/usePrintProgressTitle';
 import { useStreamTokenSync } from './hooks/useCameraStreamToken';
@@ -37,12 +11,43 @@ import { SliceJobTrackerProvider } from './contexts/SliceJobTrackerContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
 import { ColorCatalogProvider } from './contexts/ColorCatalogContext';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
-import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
-import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
-import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
-import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
-import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
-import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
+import { PageLoading } from './components/PageLoading';
+import { lazyPage } from './utils/lazyPage';
+
+// Every page loads as its own file when first opened (#3175).
+const PrintersPage = lazyPage(() => import('./pages/PrintersPage'), 'PrintersPage');
+const ArchivesPage = lazyPage(() => import('./pages/ArchivesPage'), 'ArchivesPage');
+const QueuePage = lazyPage(() => import('./pages/QueuePage'), 'QueuePage');
+const StatsPage = lazyPage(() => import('./pages/StatsPage'), 'StatsPage');
+const SettingsPage = lazyPage(() => import('./pages/SettingsPage'), 'SettingsPage');
+const FinancePage = lazyPage(() => import('./pages/FinancePage'), 'FinancePage');
+const ProfilesPage = lazyPage(() => import('./pages/ProfilesPage'), 'ProfilesPage');
+const MaintenancePage = lazyPage(() => import('./pages/MaintenancePage'), 'MaintenancePage');
+const ProjectsPage = lazyPage(() => import('./pages/ProjectsPage'), 'ProjectsPage');
+const ProjectDetailPage = lazyPage(() => import('./pages/ProjectDetailPage'), 'ProjectDetailPage');
+const FileManagerPage = lazyPage(() => import('./pages/FileManagerPage'), 'FileManagerPage');
+const LibraryTrashPage = lazyPage(() => import('./pages/LibraryTrashPage'), 'LibraryTrashPage');
+const CameraPage = lazyPage(() => import('./pages/CameraPage'), 'CameraPage');
+const CamWallPage = lazyPage(() => import('./pages/CamWallPage'), 'CamWallPage');
+const StreamOverlayPage = lazyPage(() => import('./pages/StreamOverlayPage'), 'StreamOverlayPage');
+const ExternalLinkPage = lazyPage(() => import('./pages/ExternalLinkPage'), 'ExternalLinkPage');
+const GroupEditPage = lazyPage(() => import('./pages/GroupEditPage'), 'GroupEditPage');
+const PrinterLocationsPage = lazyPage(() => import('./pages/PrinterLocationsPage'), 'PrinterLocationsPage');
+const InventoryPage = lazyPage(() => import('./pages/InventoryPage'), 'default');
+const ModelSourcesPage = lazyPage(() => import('./pages/ModelSourcesPage'), 'ModelSourcesPage');
+const SystemInfoPage = lazyPage(() => import('./pages/SystemInfoPage'), 'SystemInfoPage');
+const LoginPage = lazyPage(() => import('./pages/LoginPage'), 'LoginPage');
+const ConnectAuthorizePage = lazyPage(() => import('./pages/ConnectAuthorizePage'), 'ConnectAuthorizePage');
+const SetupPage = lazyPage(() => import('./pages/SetupPage'), 'SetupPage');
+const NotificationsPage = lazyPage(() => import('./pages/NotificationsPage'), 'NotificationsPage');
+const GCodeViewerPage = lazyPage(() => import('./pages/GCodeViewerPage'), 'GCodeViewerPage');
+const SpoolBuddyDashboard = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyDashboard'), 'SpoolBuddyDashboard');
+const SpoolBuddyAmsPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyAmsPage'), 'SpoolBuddyAmsPage');
+const SpoolBuddySettingsPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddySettingsPage'), 'SpoolBuddySettingsPage');
+const SpoolBuddyCalibrationPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyCalibrationPage'), 'SpoolBuddyCalibrationPage');
+const SpoolBuddyWriteTagPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyWriteTagPage'), 'SpoolBuddyWriteTagPage');
+const SpoolBuddyInventoryPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyInventoryPage'), 'SpoolBuddyInventoryPage');
+
 class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null; errorInfo: ErrorInfo | null }> {
   state = { error: null as Error | null, errorInfo: null as ErrorInfo | null };
 
@@ -177,6 +182,9 @@ function App() {
             <SliceJobTrackerProvider>
             <StreamTokenSync />
             <BrowserRouter>
+              {/* Standalone pages (login, camera, overlay, ...) wait here; pages
+                  inside a layout wait in that layout, so its frame stays up. */}
+              <Suspense fallback={<PageLoading fullScreen />}>
               <Routes>
                 {/* Setup page - only accessible if auth not enabled */}
                 <Route path="/setup" element={<SetupRoute><SetupPage /></SetupRoute>} />
@@ -243,6 +251,7 @@ function App() {
                   <Route path="camera-tokens" element={<Navigate to="/settings?tab=camera#card-camera-tokens" replace />} />
                 </Route>
               </Routes>
+              </Suspense>
             </BrowserRouter>
             </SliceJobTrackerProvider>
             </ColorCatalogProvider>

+ 62 - 0
frontend/src/__tests__/i18n/locale-loading.test.ts

@@ -0,0 +1,62 @@
+/**
+ * Only English is bundled; the other languages load when used (#3175).
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import i18n, { localeBackend } from '../../i18n';
+import en from '../../i18n/locales/en';
+import de from '../../i18n/locales/de';
+
+function read(language: string) {
+  return new Promise<{ error: unknown; data: unknown }>((resolve) => {
+    localeBackend.read(language, 'translation', (error, data) => resolve({ error, data }));
+  });
+}
+
+afterEach(async () => {
+  await i18n.changeLanguage('en');
+});
+
+describe('locale loading', () => {
+  it('starts with English only', () => {
+    expect(i18n.hasResourceBundle('en', 'translation')).toBe(true);
+    expect(i18n.hasResourceBundle('ja', 'translation')).toBe(false);
+    expect(i18n.t('nav.printers')).toBe(en.nav.printers);
+  });
+
+  it('hands i18next the file of the language asked for', async () => {
+    const { error, data } = await read('de');
+
+    expect(error).toBeNull();
+    expect(data).toEqual(de);
+  });
+
+  it('answers an unknown language with nothing, so English stays the fallback', async () => {
+    const { error, data } = await read('xx');
+
+    expect(error).toBeNull();
+    expect(data).toEqual({});
+  });
+
+  it('loads a language before switching to it', async () => {
+    expect(i18n.hasResourceBundle('ja', 'translation')).toBe(false);
+
+    await i18n.changeLanguage('ja');
+
+    expect(i18n.language).toBe('ja');
+    expect(i18n.hasResourceBundle('ja', 'translation')).toBe(true);
+    expect(i18n.t('nav.printers')).not.toBe(en.nav.printers);
+  });
+
+  it('loads a regional language by its full code', async () => {
+    await i18n.changeLanguage('pt-BR');
+
+    expect(i18n.hasResourceBundle('pt-BR', 'translation')).toBe(true);
+    expect(i18n.t('nav.printers')).not.toBe(en.nav.printers);
+  });
+
+  it('maps a browser region to its supported language', async () => {
+    await i18n.changeLanguage('de-AT');
+
+    expect(i18n.t('nav.printers')).toBe(de.nav.printers);
+  });
+});

+ 8 - 1
frontend/src/__tests__/i18n/sv-outcome-strings.test.ts

@@ -13,12 +13,19 @@
  * is the first thing anyone sees after their first rejected print.
  */
 
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, beforeAll } from 'vitest';
 import i18n from '../../i18n';
 
 const t = i18n.getFixedT('sv');
 
 describe('sv stats.rejectedPrintsCount', () => {
+  // Only English is bundled (#3175); without this, t falls back to English
+  // and the checks below pass without reading a Swedish word.
+  beforeAll(async () => {
+    await i18n.loadLanguages('sv');
+    expect(i18n.hasResourceBundle('sv', 'translation')).toBe(true);
+  });
+
   it('reads grammatically when exactly one print was rejected', () => {
     const rendered = t('stats.rejectedPrintsCount', { rejected: 1 });
 

+ 181 - 0
frontend/src/__tests__/utils/lazyPage.test.tsx

@@ -0,0 +1,181 @@
+/**
+ * Pages load as separate files (#3175). A tab from before an update asks for
+ * files the update deleted: it must reload once, never loop, and never while
+ * the server is down (a kiosk would be left on the browser's error page).
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Component, Suspense, type ReactNode } from 'react';
+
+const originalLocation = window.location;
+let reload: ReturnType<typeof vi.fn>;
+let health: ReturnType<typeof vi.fn>;
+
+async function freshModule() {
+  vi.resetModules();
+  return import('../../utils/lazyPage');
+}
+
+class Catch extends Component<{ children: ReactNode }, { error: string | null }> {
+  state = { error: null as string | null };
+  static getDerivedStateFromError(error: Error) {
+    return { error: error.message };
+  }
+  render() {
+    return this.state.error ? <p>crashed: {this.state.error}</p> : this.props.children;
+  }
+}
+
+beforeEach(() => {
+  reload = vi.fn();
+  Object.defineProperty(window, 'location', { configurable: true, value: { ...originalLocation, reload } });
+  window.sessionStorage.clear();
+  health = vi.fn().mockResolvedValue(new Response('{"status":"healthy"}', { status: 200 }));
+  vi.stubGlobal('fetch', health);
+});
+
+afterEach(() => {
+  Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
+  vi.unstubAllGlobals();
+  vi.useRealTimers();
+  vi.restoreAllMocks();
+});
+
+describe('reloadForMissingChunk', () => {
+  it('reloads once and records when', async () => {
+    const { reloadForMissingChunk } = await freshModule();
+
+    expect(reloadForMissingChunk()).toBe(true);
+    expect(reload).toHaveBeenCalledTimes(1);
+    expect(Number(window.sessionStorage.getItem('bambuddy_chunk_reload_at'))).toBeGreaterThan(0);
+
+    // A second failure in the same page while the reload is under way.
+    expect(reloadForMissingChunk()).toBe(true);
+    expect(reload).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not reload again right after its own reload', async () => {
+    window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now() - 2000));
+    const { reloadForMissingChunk } = await freshModule();
+
+    expect(reloadForMissingChunk()).toBe(false);
+    expect(reload).not.toHaveBeenCalled();
+  });
+
+  it('reloads again for a later update', async () => {
+    window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now() - 60_000));
+    const { reloadForMissingChunk } = await freshModule();
+
+    expect(reloadForMissingChunk()).toBe(true);
+    expect(reload).toHaveBeenCalledTimes(1);
+  });
+
+  it('never reloads without sessionStorage, since nothing could stop a loop', async () => {
+    vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+      throw new Error('blocked');
+    });
+    const { reloadForMissingChunk } = await freshModule();
+
+    expect(reloadForMissingChunk()).toBe(false);
+    expect(reload).not.toHaveBeenCalled();
+  });
+});
+
+describe('lazyPage', () => {
+  it('renders the named export', async () => {
+    const { lazyPage } = await freshModule();
+    const Page = lazyPage(async () => ({ HelloPage: () => <p>hello page</p> }), 'HelloPage');
+
+    render(<Suspense fallback={<p>loading</p>}><Page /></Suspense>);
+
+    expect(await screen.findByText('hello page')).toBeInTheDocument();
+    expect(reload).not.toHaveBeenCalled();
+  });
+
+  it('reloads instead of crashing when the file is gone', async () => {
+    const { lazyPage } = await freshModule();
+    const Page = lazyPage(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module')), 'X');
+
+    render(<Catch><Suspense fallback={<p>loading</p>}><Page /></Suspense></Catch>);
+
+    await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
+    expect(screen.getByText('loading')).toBeInTheDocument();
+    expect(screen.queryByText(/crashed/)).not.toBeInTheDocument();
+  });
+
+  it('keeps the spinner while the server is down, then reloads', async () => {
+    let up = false;
+    health.mockImplementation(() =>
+      up ? Promise.resolve(new Response('', { status: 200 })) : Promise.reject(new TypeError('Failed to fetch')),
+    );
+    const { lazyPage } = await freshModule();
+    const Page = lazyPage(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module')), 'X');
+
+    render(<Catch><Suspense fallback={<p>loading</p>}><Page /></Suspense></Catch>);
+
+    await vi.waitFor(() => expect(health).toHaveBeenCalled());
+    expect(reload).not.toHaveBeenCalled();
+    expect(screen.getByText('loading')).toBeInTheDocument();
+
+    up = true;
+    await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1), { timeout: 5000 });
+    expect(screen.queryByText(/crashed/)).not.toBeInTheDocument();
+  });
+
+  it('shows the error when the reload did not help', async () => {
+    window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now()));
+    const { lazyPage } = await freshModule();
+    vi.spyOn(console, 'error').mockImplementation(() => {});
+    const Page = lazyPage(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module')), 'X');
+
+    render(<Catch><Suspense fallback={<p>loading</p>}><Page /></Suspense></Catch>);
+
+    expect(await screen.findByText(/crashed: Failed to fetch/)).toBeInTheDocument();
+    expect(reload).not.toHaveBeenCalled();
+  });
+});
+
+describe('recoverFromMissingChunk', () => {
+  it('reloads straight away when the server answers', async () => {
+    const { recoverFromMissingChunk } = await freshModule();
+
+    await expect(recoverFromMissingChunk()).resolves.toBe(true);
+    expect(health).toHaveBeenCalledWith('/health', { cache: 'no-store', redirect: 'manual' });
+    expect(reload).toHaveBeenCalledTimes(1);
+  });
+
+  it('waits for the server to come back before reloading', async () => {
+    vi.useFakeTimers();
+    health
+      .mockRejectedValueOnce(new TypeError('Failed to fetch'))
+      .mockResolvedValueOnce(new Response('', { status: 502 }))
+      .mockResolvedValueOnce(new Response('', { status: 522 }));
+    const { recoverFromMissingChunk } = await freshModule();
+
+    const recovered = recoverFromMissingChunk();
+    await vi.advanceTimersByTimeAsync(3000);
+    await vi.advanceTimersByTimeAsync(3000);
+    expect(reload).not.toHaveBeenCalled();
+
+    await vi.advanceTimersByTimeAsync(3000);
+    await expect(recovered).resolves.toBe(true);
+    expect(health).toHaveBeenCalledTimes(4);
+    expect(reload).toHaveBeenCalledTimes(1);
+  });
+
+  it('counts a redirect to a login page as the server answering', async () => {
+    health.mockResolvedValue({ status: 0, type: 'opaqueredirect' });
+    const { recoverFromMissingChunk } = await freshModule();
+
+    await expect(recoverFromMissingChunk()).resolves.toBe(true);
+    expect(reload).toHaveBeenCalledTimes(1);
+  });
+
+  it('resolves false when its own reload did not help', async () => {
+    window.sessionStorage.setItem('bambuddy_chunk_reload_at', String(Date.now()));
+    const { recoverFromMissingChunk } = await freshModule();
+
+    await expect(recoverFromMissingChunk()).resolves.toBe(false);
+    expect(reload).not.toHaveBeenCalled();
+  });
+});

+ 6 - 2
frontend/src/components/Layout.tsx

@@ -1,4 +1,5 @@
-import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
+import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from 'react';
+import { PageLoading } from './PageLoading';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, Megaphone, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
@@ -1068,7 +1069,10 @@ export function Layout() {
             </button>
           </div>
         )}
-        <Outlet />
+        {/* Pages load on first open (#3175); the sidebar stays meanwhile. */}
+        <Suspense fallback={<PageLoading />}>
+          <Outlet />
+        </Suspense>
       </main>
       <AnnouncementsPanel
         open={announcementsOpen}

+ 16 - 0
frontend/src/components/PageLoading.tsx

@@ -0,0 +1,16 @@
+import { Loader2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+/** Shown while a page's file loads (#3175). */
+export function PageLoading({ fullScreen = false }: { fullScreen?: boolean }) {
+  const { t } = useTranslation();
+  return (
+    <div
+      role="status"
+      aria-label={t('common.loading')}
+      className={`flex items-center justify-center ${fullScreen ? 'min-h-screen' : 'py-24'}`}
+    >
+      <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+    </div>
+  );
+}

+ 8 - 5
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { Suspense, useState, useEffect, useRef, useCallback, useMemo } from 'react';
 import { Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { useQuery, useQueries } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -10,6 +10,7 @@ import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
+import { PageLoading } from '../PageLoading';
 import { useToast } from '../../contexts/ToastContext';
 
 export function SpoolBuddyLayout() {
@@ -223,10 +224,12 @@ export function SpoolBuddyLayout() {
         />
 
         <main className="flex-1 overflow-y-auto">
-          <Outlet context={{
-            selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
-            displayBrightness, setDisplayBrightness,
-          }} />
+          <Suspense fallback={<PageLoading />}>
+            <Outlet context={{
+              selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
+              displayBrightness, setDisplayBrightness,
+            }} />
+          </Suspense>
         </main>
 
         {!keyboardVisible && <SpoolBuddyStatusBar alert={alert} />}

+ 49 - 34
frontend/src/i18n/index.ts

@@ -1,50 +1,65 @@
-import i18n from 'i18next';
+import i18n, { type BackendModule, type ResourceLanguage } from 'i18next';
 import { initReactI18next } from 'react-i18next';
 import LanguageDetector from 'i18next-browser-languagedetector';
+import { recoverFromMissingChunk } from '../utils/lazyPage';
 
-// Import translations directly for bundling
+// English is bundled: it is the fallback for every missing key. The other
+// languages are separate files, and only the one in use is loaded (#3175).
 import en from './locales/en';
-import de from './locales/de';
-import es from './locales/es';
-import fr from './locales/fr';
-import ja from './locales/ja';
-import it from './locales/it';
-import nl from './locales/nl';
-import ko from './locales/ko';
-import ptBR from './locales/pt-BR';
-import zhCN from './locales/zh-CN';
-import zhTW from './locales/zh-TW';
-import sv from './locales/sv';
-import tr from './locales/tr';
-import ru from './locales/ru';
-import uk from './locales/uk';
 
-const resources = {
-  en: { translation: en },
-  de: { translation: de },
-  es: { translation: es },
-  fr: { translation: fr },
-  ja: { translation: ja },
-  it: { translation: it },
-  nl: { translation: nl },
-  ko: { translation: ko },
-  'pt-BR': { translation: ptBR },
-  'zh-CN': { translation: zhCN },
-  'zh-TW': { translation: zhTW },
-  sv: { translation: sv },
-  tr: { translation: tr },
-  ru: { translation: ru },
-  uk: { translation: uk },
+const LOCALE_LOADERS: Record<string, () => Promise<{ default: ResourceLanguage }>> = {
+  de: () => import('./locales/de'),
+  es: () => import('./locales/es'),
+  fr: () => import('./locales/fr'),
+  ja: () => import('./locales/ja'),
+  it: () => import('./locales/it'),
+  nl: () => import('./locales/nl'),
+  ko: () => import('./locales/ko'),
+  'pt-BR': () => import('./locales/pt-BR'),
+  'zh-CN': () => import('./locales/zh-CN'),
+  'zh-TW': () => import('./locales/zh-TW'),
+  sv: () => import('./locales/sv'),
+  tr: () => import('./locales/tr'),
+  ru: () => import('./locales/ru'),
+  uk: () => import('./locales/uk'),
+};
+
+/** Hands i18next a language's file when it needs that language. i18next
+ *  loads it before switching, so the interface never shows a half-loaded
+ *  language. */
+export const localeBackend: BackendModule = {
+  type: 'backend',
+  init() {},
+  read(language, _namespace, callback) {
+    const load = LOCALE_LOADERS[language];
+    if (!load) {
+      callback(null, {});
+      return;
+    }
+    load().then(
+      (module) => callback(null, module.default),
+      (error) => {
+        // A tab from before an update asks for a file the update deleted.
+        void recoverFromMissingChunk().then((reloading) => {
+          if (!reloading) callback(error, null);
+        });
+      },
+    );
+  },
 };
 
 const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'nl', 'pt-BR', 'ru', 'sv', 'tr', 'uk', 'zh-CN', 'zh-TW'];
 const APPLIANCE_CONSUMED_KEY = 'bambuddy_appliance_locale_consumed';
 
-i18n
+/** Settles once the language in use has loaded (or failed to). */
+export const i18nReady = i18n
+  .use(localeBackend)
   .use(LanguageDetector)
   .use(initReactI18next)
   .init({
-    resources,
+    resources: { en: { translation: en } },
+    // Load every other language through localeBackend.
+    partialBundledLanguages: true,
     fallbackLng: 'en',
     supportedLngs: SUPPORTED_LNGS,
 

+ 15 - 6
frontend/src/main.tsx

@@ -1,11 +1,20 @@
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import './index.css'
-import './i18n' // Initialize i18n
+import { i18nReady } from './i18n'
 import App from './App.tsx'
 
-createRoot(document.getElementById('root')!).render(
-  <StrictMode>
-    <App />
-  </StrictMode>,
-)
+// Only English is bundled (#3175). Wait for the language in use, so the first
+// paint is not English or raw keys; a slow network gets English after 3 s and
+// switches when the file arrives.
+const LANGUAGE_WAIT_MS = 3000
+
+Promise.race([i18nReady, new Promise((resolve) => setTimeout(resolve, LANGUAGE_WAIT_MS))])
+  .catch(() => {})
+  .then(() => {
+    createRoot(document.getElementById('root')!).render(
+      <StrictMode>
+        <App />
+      </StrictMode>,
+    )
+  })

+ 81 - 0
frontend/src/utils/lazyPage.ts

@@ -0,0 +1,81 @@
+import { lazy, type ComponentType } from 'react';
+
+/**
+ * Pages load as separate files when first opened (#3175).
+ *
+ * An update replaces those files, and the build deletes the old ones. A tab
+ * opened before the update still knows only the old names, so the next page
+ * it opens fails to load. Reloading picks up the new index.html and with it
+ * the new names. The time of that reload is kept in sessionStorage so a file
+ * that is missing for some other reason shows the error screen instead of
+ * reloading forever.
+ *
+ * A file also fails to load while the server is down, for example while it
+ * restarts for that update. Reloading then would leave the browser on its own
+ * "can't connect" page, which a SpoolBuddy kiosk cannot get off by itself, so
+ * the reload waits until the server answers again.
+ */
+
+const RELOAD_AT_KEY = 'bambuddy_chunk_reload_at';
+// A reload more recent than this was ours and did not help.
+const RELOAD_WINDOW_MS = 10_000;
+const SERVER_POLL_MS = 3_000;
+
+let reloading = false;
+
+/** Reload the page for a file that failed to load, at most once per window.
+ *  Returns whether a reload is under way. */
+export function reloadForMissingChunk(): boolean {
+  if (reloading) return true;
+  try {
+    const last = Number(window.sessionStorage.getItem(RELOAD_AT_KEY));
+    if (last && Date.now() - last < RELOAD_WINDOW_MS) return false;
+    window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()));
+  } catch {
+    // No sessionStorage, no way to stop a loop: show the error instead.
+    return false;
+  }
+  reloading = true;
+  window.location.reload();
+  return true;
+}
+
+/** Whether Bambuddy answers. A 5xx is a proxy saying it does not (502-504,
+ *  Cloudflare's 52x). A redirect, such as an SSO proxy sending the browser to
+ *  its login, is an answer: a reload then goes where it should. */
+async function serverAnswers(): Promise<boolean> {
+  try {
+    const response = await fetch('/health', { cache: 'no-store', redirect: 'manual' });
+    return response.status < 500;
+  } catch {
+    return false;
+  }
+}
+
+/** After a file failed to load: wait until the server answers, then reload
+ *  once. Resolves false when a reload would not help. */
+export async function recoverFromMissingChunk(): Promise<boolean> {
+  while (!(await serverAnswers())) {
+    await new Promise((resolve) => setTimeout(resolve, SERVER_POLL_MS));
+  }
+  return reloadForMissingChunk();
+}
+
+/** A page component that loads on first render.
+ *  `name` picks the export, since most pages are named exports. */
+export function lazyPage<M extends Record<string, unknown>, K extends keyof M>(
+  load: () => Promise<M>,
+  name: K,
+) {
+  return lazy(async () => {
+    try {
+      const module = await load();
+      return { default: module[name] as ComponentType };
+    } catch (error) {
+      // The loading spinner stays up while the server is away and the page
+      // reloads; only a reload that did not help reaches the error screen.
+      if (await recoverFromMissingChunk()) return new Promise<never>(() => {});
+      throw error;
+    }
+  });
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-Bcoa_hnW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-DLM3L08d.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-MaOGuDlA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AreaChart-J0I0YlYN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-kr-0EzwV.js


+ 1 - 0
static/assets/Button-pwVKPiYn.js

@@ -0,0 +1 @@
+import{C as e}from"./client-BdOyoZJz.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-C1rIEzhG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-DWbtempu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-C1mWeQfG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-Br9MDE6G.js


+ 1 - 0
static/assets/Card-OUAj0Ayh.js

@@ -0,0 +1 @@
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-BdOyoZJz.js";var r=e(n(),1),i=t(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CartesianChart-DcboTNtX.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-B9cmtxVm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-C4N9v6PJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-DtVlpfFV.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-CqE3-J0R.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CopyButton-CyNT06yS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ErrorBarContext-6wvnfk_q.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-CM4__1rh.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/FileManagerPage-Kp8jeXP4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-hFWmsB_i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-BfhaugR1.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-C24Va31U.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GcodeToolpathViewer-D4M17KI8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-i9TLWDyl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BcWeiOrd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-R11uy3ZP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-BWTPGneX.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-Ck7R9blU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-CXIN3BLj.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-BAMWk5DI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-C9OvAi9p.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-5GOJDRkw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/OrbitControls-Bbqf07B-.js


+ 1 - 0
static/assets/OverlayFrame-BUFy7NI1.js

@@ -0,0 +1 @@
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-BdOyoZJz.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=t();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

File diff suppressed because it is too large
+ 1 - 0
static/assets/PdfPreviewModal-CRMSWXkI.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-ChNGaUl4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-BxTCJXU3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-D2AqeJ9i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-zDUFDj5q.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-C0jHVdln.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-CaZEFrXK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-C2aXY-5x.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueryClientProvider-Clbyr4Jg.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-DTh2aCRG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ReferenceLine-l3CI8D5f.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-CtyYZFiu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-DOroBYzD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-hUHebIlA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-DilRo4MU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CCeWMoIm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BPEaykbd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-B_0MmvgI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BmxJPbmk.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/SpoolBuddySettingsPage-DKia6Rid.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-CMlhWgSL.js


+ 1 - 0
static/assets/SpoolIcon-OZKzbEc8.js

@@ -0,0 +1 @@
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-BdOyoZJz.js";import{n as r}from"./filamentSwatchHelpers-D3oXQSGk.js";import{t as i}from"./spoolPaint-CrvpHqdD.js";var a=e(n(),1),o=t();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/SpreadsheetPreviewModal-HCix387J.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/SpreadsheetPreviewModal-WUjrw07T.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-D0cqrND3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-CA9KpVK8.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-DWOhiuT-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-ClAReSss.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ToastContext-VFOrCCoA.js


+ 1 - 0
static/assets/Toggle-CwMiiK0g.js

@@ -0,0 +1 @@
+import{C as e}from"./client-BdOyoZJz.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};

+ 1 - 0
static/assets/arrow-down-C4OBNDqJ.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 1 - 0
static/assets/arrow-left-MSgMxOFV.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 1 - 0
static/assets/arrow-right-Da96kh4S.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 1 - 0
static/assets/arrow-up-Dam8cLfE.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 1 - 0
static/assets/arrow-up-down-C-1Kkt1x.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};

+ 1 - 0
static/assets/box-BIRPxdgi.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};

+ 1 - 0
static/assets/briefcase-BmuFBTLZ.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};

+ 1 - 0
static/assets/calendar-DiVEdzUw.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};

+ 1 - 0
static/assets/check-D1Pn8T1J.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};

+ 1 - 0
static/assets/chevrons-right-OnQ-9jKz.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`chevrons-left`,[[`path`,{d:`m11 17-5-5 5-5`,key:`13zhaf`}],[`path`,{d:`m18 17-5-5 5-5`,key:`h8a8et`}]]),n=e(`chevrons-right`,[[`path`,{d:`m6 17 5-5-5-5`,key:`xnjwq`}],[`path`,{d:`m13 17 5-5-5-5`,key:`17xmmf`}]]);export{t as n,n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/client-BdOyoZJz.js


+ 1 - 0
static/assets/cloud-off-DmnG-KIw.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`cloud-off`,[[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M5.782 5.782A7 7 0 0 0 9 19h8.5a4.5 4.5 0 0 0 1.307-.193`,key:`yfwify`}],[`path`,{d:`M21.532 16.5A4.5 4.5 0 0 0 17.5 10h-1.79A7.008 7.008 0 0 0 10 5.07`,key:`jlfiyv`}]]);export{t};

+ 1 - 0
static/assets/cog-pa-nvQzt.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`cog`,[[`path`,{d:`M11 10.27 7 3.34`,key:`16pf9h`}],[`path`,{d:`m11 13.73-4 6.93`,key:`794ttg`}],[`path`,{d:`M12 22v-2`,key:`1osdcq`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M14 12h8`,key:`4f43i9`}],[`path`,{d:`m17 20.66-1-1.73`,key:`eq3orb`}],[`path`,{d:`m17 3.34-1 1.73`,key:`2wel8s`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`m20.66 17-1.73-1`,key:`sg0v6f`}],[`path`,{d:`m20.66 7-1.73 1`,key:`1ow05n`}],[`path`,{d:`m3.34 17 1.73-1`,key:`nuk764`}],[`path`,{d:`m3.34 7 1.73 1`,key:`1ulond`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}],[`circle`,{cx:`12`,cy:`12`,r:`8`,key:`46899m`}]]);export{t};

+ 1 - 0
static/assets/coins-6tqy9spE.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]);export{t};

+ 1 - 0
static/assets/columns-2-CGEUxYp_.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`columns-2`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M12 3v18`,key:`108xh3`}]]);export{t};

+ 1 - 0
static/assets/copy-3N5VKzXz.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]);export{t};

+ 1 - 0
static/assets/cpu-DAX4KhDZ.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`cpu`,[[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M17 20v2`,key:`1rnc9c`}],[`path`,{d:`M17 2v2`,key:`11trls`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M2 17h2`,key:`7oei6x`}],[`path`,{d:`M2 7h2`,key:`asdhe0`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`M20 17h2`,key:`1fpfkl`}],[`path`,{d:`M20 7h2`,key:`1o8tra`}],[`path`,{d:`M7 20v2`,key:`4gnj0m`}],[`path`,{d:`M7 2v2`,key:`1i4yhu`}],[`rect`,{x:`4`,y:`4`,width:`16`,height:`16`,rx:`2`,key:`1vbyd7`}],[`rect`,{x:`8`,y:`8`,width:`8`,height:`8`,rx:`1`,key:`z9xiuo`}]]);export{t};

+ 1 - 0
static/assets/createLucideIcon-Yo2YoYjm.js

@@ -0,0 +1 @@
+import{i as e}from"./chunk-aKtaBQYM.js";import{D as t}from"./client-BdOyoZJz.js";var n=e(t()),r=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),i=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),a=e=>{let t=i(e);return t.charAt(0).toUpperCase()+t.slice(1)},o=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),s=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0},c={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},l=(0,n.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:r=2,absoluteStrokeWidth:i,className:a=``,children:l,iconNode:u,...d},f)=>(0,n.createElement)(`svg`,{ref:f,...c,width:t,height:t,stroke:e,strokeWidth:i?Number(r)*24/Number(t):r,className:o(`lucide`,a),...!l&&!s(d)&&{"aria-hidden":`true`},...d},[...u.map(([e,t])=>(0,n.createElement)(e,t)),...Array.isArray(l)?l:[l]])),u=(e,t)=>{let i=(0,n.forwardRef)(({className:i,...s},c)=>(0,n.createElement)(l,{ref:c,iconNode:t,className:o(`lucide-${r(a(e))}`,`lucide-${e}`,i),...s}));return i.displayName=a(e),i};export{u as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/date-G9KUauqz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/de-DvnDPDo7.js


Some files were not shown because too many files changed in this diff