Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/src/App.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { isConnected } from "luminary-shared";
import { resolveNotificationText, useNotificationStore } from "./stores/notification";
import { mockEnglishContentDto } from "./tests/mockdata";
import { isAppLoading, theme } from "./globalConfig";
import LoadingBar from "@/components/LoadingBar.vue";
import { createMemoryHistory, createRouter } from "vue-router";
import HomePage from "@/pages/HomePage.vue";
import ExplorePage from "@/pages/ExplorePage.vue";
Expand Down Expand Up @@ -45,28 +44,29 @@ describe("App", () => {
isAppLoading.value = true;
});

describe("Splash screen", () => {
// The startup splash itself lives in index.html (see bootSplash.spec.ts); what this
// component owns is withholding the app until startup has finished.
describe("Startup gate", () => {
beforeEach(() => {
(auth as any).useAuth.mockReturnValue({
isLoading: ref(false),
isAuthenticated: ref(false),
});
});

it("displays the splash screen while the app is loading", () => {
it("withholds the app content while the app is loading", () => {
isAppLoading.value = true;

const wrapper = mount(App, { shallow: true });

expect(wrapper.findComponent(LoadingBar).exists()).toBe(true);
expect(wrapper.find("router-view-stub").exists()).toBe(false);
});

it("displays the app content once loading is complete", () => {
isAppLoading.value = false;

const wrapper = mount(App, { shallow: true });

expect(wrapper.findComponent(LoadingBar).exists()).toBe(false);
expect(wrapper.find("router-view-stub").exists()).toBe(true);
});
});
Expand Down
31 changes: 5 additions & 26 deletions app/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,7 @@
import { RouterView } from "vue-router";
import { computed, onErrorCaptured, onMounted, watch } from "vue";
import { isConnected } from "luminary-shared";
import {
appName,
isAppLoading,
userPreferencesAsRef,
mediaQueue,
localCacheVersion,
} from "./globalConfig";
import LoadingBar from "@/components/LoadingBar.vue";
import { isAppLoading, userPreferencesAsRef, mediaQueue, localCacheVersion } from "./globalConfig";
import { useNotificationStore } from "./stores/notification";
import { ArrowLeftEndOnRectangleIcon, SignalSlashIcon } from "@heroicons/vue/20/solid";
import * as Sentry from "@sentry/vue";
Expand All @@ -24,12 +17,9 @@ import { useAuthWithPrivacyPolicy } from "@/composables/useAuthWithPrivacyPolicy
import { showProviderSelectionModal } from "@/auth";
import AuthProviderSelectionModal from "@/components/authProvider/AuthProviderSelectionModal.vue";
import { useI18n } from "vue-i18n";
import defaultLogo from "@/assets/logo.svg?url";
import { usePwaUpdate } from "@/composables/usePwaUpdate";
import { useHydrated } from "@/composables/useHydrated";

const LOGO = import.meta.env.VITE_LOGO || defaultLogo;

const { t } = useI18n();
const { needRefresh, reload } = usePwaUpdate();

Expand Down Expand Up @@ -172,22 +162,11 @@ onErrorCaptured((err) => {
</script>

<template>
<!-- The startup splash is plain HTML in index.html, so it can paint before this
component (or any module) exists. This gate only withholds the app until
startup finishes; index.html's copy is what covers the wait. -->
<div
v-if="isAppLoading"
class="absolute flex h-full w-full items-center justify-center"
>
<div class="flex flex-col items-center gap-4">
<img
class="w-72"
:src="LOGO"
:alt="appName"
/>
<LoadingBar />
</div>
</div>

<div
v-else
v-if="!isAppLoading"
class="absolute bottom-0 left-0 right-0 top-0 flex w-full flex-col overflow-hidden"
>
<div class="flex-1 overflow-y-scroll scrollbar-hide">
Expand Down
83 changes: 83 additions & 0 deletions app/src/bootSplash.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
BOOT_SPLASH_ID,
BOOT_SPLASH_OFF_CLASS,
DEFAULT_BOOT_LOGO,
bootSplashMarkup,
bootSplashPrePaintScript,
bootSplashStyle,
resolveBootLogo,
} from "./bootSplash";

const read = (relative: string) =>
readFileSync(fileURLToPath(new URL(relative, import.meta.url)), "utf8");

// The splash is spread across this module, the Vite plugin that injects it, and main.ts's
// removal, with nothing at runtime that would fail loudly if one drifted from the others.
describe("boot splash", () => {
it("is injected by the SPA build only, never the web build", () => {
expect(read("../vite.config.ts")).toContain("bootSplash(env.VITE_LOGO)");
expect(read("../vite.config.web.ts")).not.toContain("bootSplash");
// Nothing inline in the template, so the web build cannot inherit it by accident.
expect(read("../index.html")).not.toContain(BOOT_SPLASH_ID);
});

it("uses the id main.ts removes once the app has rendered", () => {
expect(bootSplashMarkup()).toContain(`id="${BOOT_SPLASH_ID}"`);
expect(read("./main.ts")).toContain("getElementById(BOOT_SPLASH_ID)?.remove()");
});

it("switches to an error panel on the render state a failed startup sets", () => {
const style = bootSplashStyle();

expect(style).toContain(
`html[data-render-state="error"] #${BOOT_SPLASH_ID} .boot-splash-loading { display: none; }`,
);
expect(style).toContain(
`html[data-render-state="error"] #${BOOT_SPLASH_ID} .boot-splash-error { display: flex; }`,
);
expect(bootSplashMarkup()).toContain('id="boot-splash-reload"');
// markAppError() is what sets that attribute, and it must stay on the failure path.
expect(read("./main.ts")).toContain("markAppError()");
});

it("honours the ?nosplash opt-out that isAppLoading also honours", () => {
expect(bootSplashPrePaintScript()).toContain('has("nosplash")');
expect(bootSplashPrePaintScript()).toContain(BOOT_SPLASH_OFF_CLASS);
expect(bootSplashStyle()).toContain(
`html.${BOOT_SPLASH_OFF_CLASS} #${BOOT_SPLASH_ID} { display: none; }`,
);
expect(read("./globalConfig.ts")).toContain('has("nosplash")');
});

it("resolves the theme before paint, so an explicit choice beats the OS preference", () => {
const script = bootSplashPrePaintScript();

expect(script).toContain('theme === "dark"');
expect(script).toContain('theme !== "light"');
expect(script).toContain("prefers-color-scheme: dark");
expect(bootSplashStyle()).toContain(`html.dark #${BOOT_SPLASH_ID}`);
});

it("keeps a configured logo only when the browser can resolve it from any route", () => {
expect(resolveBootLogo("https://cdn.example.org/brand.svg")).toBe(
"https://cdn.example.org/brand.svg",
);
expect(resolveBootLogo("//cdn.example.org/brand.svg")).toBe("//cdn.example.org/brand.svg");
expect(resolveBootLogo("/brand.svg")).toBe("/brand.svg");
// The .env.example default: relative to the source tree, and never emitted by the build.
expect(resolveBootLogo("../src/assets/logo.svg")).toBe(DEFAULT_BOOT_LOGO);
expect(resolveBootLogo(undefined)).toBe(DEFAULT_BOOT_LOGO);
});

it("stays legible and still, for the readers who need it", () => {
const style = bootSplashStyle();

// Light-mode text at #d4d4d8 on white is 1.48:1 — below the threshold of perception.
expect(style).toContain("--boot-splash-fg: #71717a;");
expect(style).toContain("@media (prefers-reduced-motion: reduce)");
expect(bootSplashMarkup()).toContain('role="status"');
});
});
164 changes: 164 additions & 0 deletions app/src/bootSplash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* The startup splash, as plain HTML/CSS/JS injected into `index.html` by
* `vite-plugins/bootSplash.ts`. The app mounts only after the data layer and auth have
* initialised, so without this everything before mount paints an empty `#app`.
*
* It sits outside `#app` rather than inside it, so it survives `app.mount()` and covers the
* wait for `initLanguage()` too; `main.ts` removes it by `BOOT_SPLASH_ID` once the app has
* rendered. Tailwind is not loaded this early, so the colours and metrics that mirror
* `LoadingBar.vue` are written out longhand.
*/
export const BOOT_SPLASH_ID = "boot-splash";

/** Mirrors the `?nosplash` opt-out `isAppLoading` honours, applied before first paint. */
export const BOOT_SPLASH_OFF_CLASS = "boot-splash-off";

/** Shipped in `public/`, so this URL resolves from any route depth. */
export const DEFAULT_BOOT_LOGO = "/logo.svg";

/**
* A configured logo is only usable here if the browser can resolve it from any route and the
* build actually publishes it. A path into the source tree (the `.env.example` default) meets
* neither, so it falls back to the copy in `public/`.
*/
export function resolveBootLogo(configured?: string): string {
if (!configured) return DEFAULT_BOOT_LOGO;
const usable = /^(?:https?:)?\/\//.test(configured) || configured.startsWith("/");
return usable ? configured : DEFAULT_BOOT_LOGO;
}

const escapeAttribute = (value: string) =>
value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");

/**
* The error panel is driven purely by the `data-render-state` attribute `renderState.ts`
* already writes, so a failed boot needs no teardown coordination in `main.ts` — which never
* reaches its removal call on that path.
*/
export function bootSplashStyle(): string {
return `
#${BOOT_SPLASH_ID} {
--boot-splash-bg: #ffffff;
--boot-splash-fg: #71717a;
--boot-splash-track: #e4e4e7;
--boot-splash-slug: #71717a;
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: var(--boot-splash-bg);
font-family: ui-sans-serif, system-ui, sans-serif;
}
html.dark #${BOOT_SPLASH_ID} {
--boot-splash-bg: #0f172a;
--boot-splash-fg: #94a3b8;
--boot-splash-track: #334155;
--boot-splash-slug: #94a3b8;
}
html.${BOOT_SPLASH_OFF_CLASS} #${BOOT_SPLASH_ID} { display: none; }
#${BOOT_SPLASH_ID} .boot-splash-panel {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 0 1rem;
}
#${BOOT_SPLASH_ID} .boot-splash-logo { width: 18rem; max-width: 80%; }
#${BOOT_SPLASH_ID} .boot-splash-label {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--boot-splash-fg);
text-align: center;
}
#${BOOT_SPLASH_ID} .boot-splash-track {
position: relative;
height: 0.75rem;
width: 80%;
max-width: 28rem;
overflow: hidden;
border-radius: 9999px;
background: var(--boot-splash-track);
}
#${BOOT_SPLASH_ID} .boot-splash-slug {
position: absolute;
top: 0;
bottom: 0;
width: 40%;
border-radius: 9999px;
background: var(--boot-splash-slug);
animation: boot-splash-slug 1.2s linear infinite;
}
@keyframes boot-splash-slug {
0% { left: -40%; }
100% { left: 100%; }
}
@media (prefers-reduced-motion: reduce) {
#${BOOT_SPLASH_ID} .boot-splash-slug { animation: none; left: 30%; }
}
#${BOOT_SPLASH_ID} button {
border-radius: 0.375rem;
border: 1px solid var(--boot-splash-slug);
background: transparent;
padding: 0.5rem 1.25rem;
font-size: 1rem;
color: var(--boot-splash-fg);
cursor: pointer;
}
#${BOOT_SPLASH_ID} .boot-splash-error { display: none; }
html[data-render-state="error"] #${BOOT_SPLASH_ID} .boot-splash-loading { display: none; }
html[data-render-state="error"] #${BOOT_SPLASH_ID} .boot-splash-error { display: flex; }
`.trim();
}

/**
* Both strings are hardcoded English: the splash paints long before i18n, whose messages come
* from Language documents fetched at runtime.
*/
export function bootSplashMarkup(configuredLogo?: string): string {
const logo = escapeAttribute(resolveBootLogo(configuredLogo));
return `<div id="${BOOT_SPLASH_ID}" role="status" aria-live="polite">
<div class="boot-splash-panel boot-splash-loading">
<img class="boot-splash-logo" src="${logo}" alt="" />
<p class="boot-splash-label">Loading...</p>
<div class="boot-splash-track"><div class="boot-splash-slug"></div></div>
</div>
<div class="boot-splash-panel boot-splash-error">
<p class="boot-splash-label">The app could not be started.</p>
<button type="button" id="boot-splash-reload">Reload</button>
</div>
</div>`;
}

/**
* Runs during head parse, before the splash paints. The theme resolution mirrors
* `globalConfig`'s, so an explicit light/dark choice wins over the OS preference — a
* `prefers-color-scheme` media query alone would paint the wrong theme for those users.
*/
export function bootSplashPrePaintScript(): string {
return `(function () {
var el = document.documentElement;
try {
var theme = localStorage.getItem("theme");
if (theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
el.classList.add("dark");
}
} catch (e) {}
try {
if (new URLSearchParams(window.location.search).has("nosplash")) {
el.classList.add("${BOOT_SPLASH_OFF_CLASS}");
}
} catch (e) {}
})();`;
}

/** The only route out of the error panel, which `main.ts` cannot reach to wire up itself. */
export function bootSplashReloadScript(): string {
return `(function () {
var button = document.getElementById("boot-splash-reload");
if (button) button.addEventListener("click", function () { window.location.reload(); });
})();`;
}
Loading
Loading