diff --git a/js/app/README.md b/js/app/README.md
index 88bba8c7f..56dde7b42 100644
--- a/js/app/README.md
+++ b/js/app/README.md
@@ -61,6 +61,13 @@ without replaying successfully loaded scripts. Styles come from design tokens
at build time; no React, theme provider, fonts, or app modules load to show it.
Development Metro and installed iOS/Android entrypoints are unchanged.
+The loading UI uses the regular web Loader's SVG ring with no visible loading
+text (an accessible progress label remains). `pkg/linking` injects the node's
+existing `primaryColor` and dark `backgroundColor` as CSS variables in the
+initial HTML, so the spinner and page are branded before JavaScript runs.
+Unset or invalid colors retain the design-token defaults; only validated hex
+colors are embedded in CSS. Static hosting uses those defaults too.
+
The bootstrap build uses the project's installed TypeScript compiler to read
design tokens. It does not require Node's optional native TypeScript support or
`--experimental-strip-types`; the existing Node 22 requirement is unchanged.
@@ -92,8 +99,11 @@ UI with `hack/e2e-web-local.sh`.
`bootstrap.spec.ts` runs in Chromium and Firefox: it enforces a sub-1-MB first
loading payload, holds app downloads to check the visible spinner, then checks
-the handoff to Home and recovery from a failed common-chunk request. This
-HTML-only bootstrap does not apply to native installs.
+the handoff to Home and recovery from a failed common-chunk request.
+`bootstrap-branding.spec.ts` edits the real node's branding through the admin UI,
+checks spinner/page colors while Expo downloads are held, and restores the
+original branding bundle. This HTML-only bootstrap does not apply to native
+installs.
`lazy-navigation.spec.ts` checks deferred settings requests, navigation away from
a pending chunk, recovery from a failed chunk, and a cold settings deep link. The platform-neutral
diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx
index 2276bb135..3df10379b 100644
--- a/js/app/components/settings/branding-admin.tsx
+++ b/js/app/components/settings/branding-admin.tsx
@@ -1252,12 +1252,14 @@ export function BrandingAdmin() {
>
uploadText("primaryColor", primaryColor)}
disabled={uploading || !primaryColor.trim()}
width="min"
@@ -2013,6 +2015,7 @@ export function BrandingAdmin() {
>
@@ -2024,6 +2027,7 @@ export function BrandingAdmin() {
/>
uploadText(
"backgroundColor",
diff --git a/js/app/scripts/bootstrap-web.mjs b/js/app/scripts/bootstrap-web.mjs
index a1fbf63f8..d1a6e0807 100644
--- a/js/app/scripts/bootstrap-web.mjs
+++ b/js/app/scripts/bootstrap-web.mjs
@@ -14,7 +14,8 @@ const { outputText } = ts.transpileModule(
);
const {
borderRadius,
- motion,
+ colors,
+ loadingIndicator,
spacing,
surfaces,
textAlphas,
@@ -50,17 +51,17 @@ const css = `
#web-bootstrap {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: ${spacing[4]}px; padding: ${spacing[6]}px;
- background: ${surfaces.dark[0]}; color: ${textAlphas.dark[1]};
+ background: var(--bootstrap-background, ${surfaces.dark[0]}); color: ${textAlphas.dark[1]};
font: ${typeScale.base.fontSize}px/${typeScale.base.lineHeight}px system-ui, sans-serif;
text-align: center;
}
#bootstrap-spinner {
- width: ${spacing[8]}px; height: ${spacing[8]}px;
- border: ${spacing[1]}px solid ${textAlphas.dark[4]}; border-top-color: currentColor;
- border-radius: ${borderRadius.full}px;
- animation: bootstrap-spin ${motion.slow * 3}ms linear infinite;
+ width: ${loadingIndicator.size}px; height: ${loadingIndicator.size}px;
+ color: var(--bootstrap-primary, ${colors.primary[500]});
+ animation: bootstrap-spin ${loadingIndicator.duration}ms linear infinite;
}
#web-bootstrap [hidden] { display: none; }
+html,body { background-color: var(--bootstrap-background, ${surfaces.dark[0]}); }
#bootstrap-retry {
font: inherit; color: inherit; background: ${surfaces.dark[2]};
padding: ${spacing[2]}px ${spacing[4]}px; border: none;
@@ -74,8 +75,11 @@ html = html.replace("", ``);
html = html.replace(
'
',
`
-
-
Loading Streamplace
+
+
+
+
+
Try again
`,
);
diff --git a/js/app/scripts/web-bootstrap.js b/js/app/scripts/web-bootstrap.js
index a5e339f00..b398018f9 100644
--- a/js/app/scripts/web-bootstrap.js
+++ b/js/app/scripts/web-bootstrap.js
@@ -10,8 +10,9 @@ let loaded = 0;
async function boot() {
retry.hidden = true;
retry.disabled = true;
- spinner.hidden = false;
- status.textContent = "Loading Streamplace";
+ spinner.removeAttribute("hidden");
+ status.hidden = true;
+ status.textContent = "";
try {
// Keep Expo's runtime/common/entry order. On retry, don't replay scripts
// that already ran: in particular, reinitializing Metro loses its modules.
@@ -33,8 +34,9 @@ async function boot() {
// React replaces the bootstrap inside #root when its first render commits.
} catch (error) {
console.error("Streamplace bootstrap:", error);
- spinner.hidden = true;
+ spinner.setAttribute("hidden", "");
status.textContent = "Could not load Streamplace. Check your connection.";
+ status.hidden = false;
retry.disabled = false;
retry.hidden = false;
}
diff --git a/js/components/src/lib/theme/tokens.ts b/js/components/src/lib/theme/tokens.ts
index bc9d40d9c..ae722b113 100644
--- a/js/components/src/lib/theme/tokens.ts
+++ b/js/components/src/lib/theme/tokens.ts
@@ -822,6 +822,13 @@ export const motion = {
sheetSpring: { damping: 30, stiffness: 300 },
} as const;
+// Match React Native Web's large ActivityIndicator before React is available.
+export const loadingIndicator = {
+ size: 36,
+ duration: 750,
+ trackOpacity: 0.2,
+} as const;
+
/** @deprecated use `motion` (fast/base/slow) */
export const animations = {
fast: motion.fast,
diff --git a/js/e2e-web/flows/bootstrap-branding.spec.ts b/js/e2e-web/flows/bootstrap-branding.spec.ts
new file mode 100644
index 000000000..8b8944042
--- /dev/null
+++ b/js/e2e-web/flows/bootstrap-branding.spec.ts
@@ -0,0 +1,89 @@
+import { expect, test } from "@playwright/test";
+import { loginThroughPds } from "./login";
+
+const HTTPS_URL = process.env.SERVER_HTTPS_URL;
+// HTML bootstrap is web-only. Real branding edits require the OAuth/HTTPS harness.
+test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames");
+
+test("bootstrap: persisted branding colors paint before application scripts", async ({
+ page,
+ context,
+}) => {
+ test.setTimeout(4 * 60_000);
+ await loginThroughPds(page);
+ await page.goto(`${HTTPS_URL}/settings/branding`);
+
+ // Restore the whole original bundle through the same admin UI in finally.
+ // The suite uses one worker, so no other flow edits this node concurrently.
+ const downloading = page.waitForEvent("download");
+ await page
+ .getByRole("button", { name: "Download bundle", exact: true })
+ .click();
+ const original = test.info().outputPath("original-branding.zip");
+ await (await downloading).saveAs(original);
+ const preview = await context.newPage();
+ let release = () => {};
+ try {
+ for (const [key, color] of [
+ ["primaryColor", "#22d3ee"],
+ ["backgroundColor", "#122334"],
+ ]) {
+ await page.getByTestId(`branding-input-${key}`).fill(color);
+ const updated = page.waitForResponse((response) =>
+ response.url().endsWith("/xrpc/place.stream.branding.updateBlob"),
+ );
+ await page.getByTestId(`branding-update-${key}`).click();
+ expect((await updated).ok()).toBe(true);
+ }
+
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ await preview.route(/\/__expo-metro-runtime-[^/]+\.js$/, async (route) => {
+ await held;
+ await route.continue();
+ });
+ await preview.goto(HTTPS_URL!, { waitUntil: "domcontentloaded" });
+ await expect(
+ preview.getByRole("progressbar", { name: "Loading" }),
+ ).toBeVisible();
+ await expect(preview.locator("#bootstrap-spinner")).toHaveCSS(
+ "color",
+ "rgb(34, 211, 238)",
+ );
+ for (const selector of ["html", "body", "#web-bootstrap"]) {
+ await expect(preview.locator(selector)).toHaveCSS(
+ "background-color",
+ "rgb(18, 35, 52)",
+ );
+ }
+ await expect(preview.locator("#web-bootstrap")).toHaveJSProperty(
+ "innerText",
+ "",
+ );
+ await expect(preview.getByTestId("home-stream-card")).toHaveCount(0);
+ const screenshot = test.info().outputPath("branded-bootstrap.png");
+ await preview.screenshot({ path: screenshot });
+ await test.info().attach("branded-bootstrap", { path: screenshot });
+ release();
+ await expect(preview.getByTestId("home-stream-card").first()).toBeVisible();
+ await expect(preview.locator("#web-bootstrap")).toHaveCount(0);
+ } finally {
+ release();
+ await preview.close();
+ const choosing = page.waitForEvent("filechooser");
+ await page
+ .getByRole("button", { name: "Import bundle…", exact: true })
+ .click();
+ const dryRun = page.waitForResponse((response) =>
+ response.url().includes("/xrpc/place.stream.branding.importBundle"),
+ );
+ await (await choosing).setFiles(original);
+ expect((await dryRun).ok()).toBe(true);
+ const applying = page.waitForResponse((response) =>
+ response.url().includes("/xrpc/place.stream.branding.importBundle"),
+ );
+ await page.getByRole("button", { name: "Apply", exact: true }).click();
+ expect((await applying).ok()).toBe(true);
+ }
+});
diff --git a/js/e2e-web/flows/bootstrap.spec.ts b/js/e2e-web/flows/bootstrap.spec.ts
index f3983a3b0..2f43fa24e 100644
--- a/js/e2e-web/flows/bootstrap.spec.ts
+++ b/js/e2e-web/flows/bootstrap.spec.ts
@@ -28,8 +28,14 @@ test("bootstrap: paint loading UI before app downloads, below 1 MB", async ({
});
try {
await page.goto("/");
- await expect(page.getByRole("status")).toHaveText("Loading Streamplace");
- await expect(page.locator("#bootstrap-spinner")).toBeVisible();
+ await expect(page.locator("#bootstrap-status")).toBeHidden();
+ await expect(
+ page.getByRole("progressbar", { name: "Loading" }),
+ ).toBeVisible();
+ await expect(page.locator("#web-bootstrap")).toHaveJSProperty(
+ "innerText",
+ "",
+ );
await expect(page.getByTestId("home-stream-card")).toHaveCount(0);
} finally {
release();
@@ -56,9 +62,7 @@ test("bootstrap: retry a failed common chunk without replaying Metro", async ({
}
});
await page.goto("/");
- await expect(page.getByRole("status")).toHaveText(
- "Could not load Streamplace. Check your connection.",
- );
+ await expect(page.getByRole("status")).toBeVisible();
await expect(page.locator("#bootstrap-spinner")).toBeHidden();
await page.getByRole("button", { name: "Try again" }).click();
await expect(page.getByTestId("home-stream-card").first()).toBeVisible();
diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go
index a7f65f7a8..8656d671e 100644
--- a/pkg/linking/linking.go
+++ b/pkg/linking/linking.go
@@ -66,11 +66,9 @@ const inlineBrandingImageLimit = 96 * 1024
// theme accepts, so a stored value can be dropped straight into a style.
var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
-// bodyBackground returns the node's branded dark background color, if any,
-// so the page can paint it before the bundle loads instead of the default
-// then re-painting (the "flash of unbranded content").
-func (l *Linker) bodyBackground() string {
- v := l.brandingText("backgroundColor")
+// brandingColor returns a validated node color safe to embed in CSS.
+func (l *Linker) brandingColor(key string) string {
+ v := l.brandingText(key)
if !hexColor.MatchString(v) {
return ""
}
@@ -572,19 +570,34 @@ func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, erro
// element too, since that is what mobile browsers show behind their
// translucent bars and in overscroll, and as theme-color for the bars
// themselves.
- if bg := l.bodyBackground(); bg != "" {
- style := &html.Node{Type: html.ElementNode, Data: "style"}
- head.AppendChild(style)
- style.AppendChild(&html.Node{Type: html.TextNode, Data: "html,body{background-color:" + bg + "}"})
+ background := l.brandingColor("backgroundColor")
+ primary := l.brandingColor("primaryColor")
+ var css strings.Builder
+ if background != "" {
+ css.WriteString("html{--bootstrap-background:")
+ css.WriteString(background)
+ css.WriteString("}html,body{background-color:")
+ css.WriteString(background)
+ css.WriteString("}")
head.AppendChild(&html.Node{
Type: html.ElementNode,
Data: "meta",
Attr: []html.Attribute{
{Key: "name", Val: "theme-color"},
- {Key: "content", Val: bg},
+ {Key: "content", Val: background},
},
})
}
+ if primary != "" {
+ css.WriteString("html{--bootstrap-primary:")
+ css.WriteString(primary)
+ css.WriteString("}")
+ }
+ if css.Len() > 0 {
+ style := &html.Node{Type: html.ElementNode, Data: "style"}
+ style.AppendChild(&html.Node{Type: html.TextNode, Data: css.String()})
+ head.AppendChild(style)
+ }
// Add Sentry DSN script if configured
if pc.SentryDSN != "" {
diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go
index 1c941c149..a55bdf038 100644
--- a/pkg/linking/linking_test.go
+++ b/pkg/linking/linking_test.go
@@ -307,4 +307,20 @@ func TestCardTemplates(t *testing.T) {
require.Contains(t, page, ` `)
}
+func TestBootstrapBrandingRejectsCSSInjection(t *testing.T) {
+ ctx := context.Background()
+ sdb, err := statedb.MakeDB(ctx, &config.CLI{DBURL: ":memory:"}, nil, nil)
+ require.NoError(t, err)
+ linker, err := NewLinker(ctx, IndexHTML(t), sdb, &config.CLI{BroadcasterHost: "example.com"})
+ require.NoError(t, err)
+ require.NoError(t, sdb.PutBrandingBlob("did:web:example.com", "backgroundColor", "text/plain", []byte(" #123456 "), nil, nil))
+ require.NoError(t, sdb.PutBrandingBlob("did:web:example.com", "primaryColor", "text/plain", []byte(``), nil, nil))
+
+ out, err := linker.GenerateHTML(ctx, &PageConfig{})
+ require.NoError(t, err)
+ require.Contains(t, string(out), "--bootstrap-background:#123456")
+ require.NotContains(t, string(out), "--bootstrap-primary:")
+ require.NotContains(t, string(out), "alert(1)")
+}
+
// Without the home templates the front page keeps the site's own card.