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() { > `, ); 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.