From f0136fd4fc9a8576b59c354636d2d83fa8a48f4b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 15:09:40 -0400 Subject: [PATCH 1/6] perf(homepage): load CSS by route group Keep fonts, preflight and custom tokens shared while loading homepage, content and playground utilities through route links. Preserve responsive cascade ordering with shared shell sources, and prefetch documentation only on navigation intent. Reduce initial CSS from 71,764 to 45,176 raw bytes and from 13,099 to 10,366 gzip bytes. Record the deliberate additional stylesheet and 252-byte gzip route-module overhead. Cover cold routes, navigation, fonts, prefetch and playground themes without changing visual baselines. --- apps/docs/app/DocsRoutes.res | 2 +- apps/docs/app/DocsRoutes.resi | 2 +- apps/docs/app/layouts/ContentLayoutRoute.res | 7 + apps/docs/app/layouts/ContentLayoutRoute.resi | 6 +- apps/docs/app/layouts/HomepageLayoutRoute.res | 9 + .../docs/app/layouts/HomepageLayoutRoute.resi | 7 + apps/docs/app/routes/TryRoute.res | 7 + apps/docs/app/routes/TryRoute.resi | 4 + apps/docs/e2e-playwright/route-css.spec.mjs | 189 ++++++ apps/docs/e2e/Playground.cy.res | 8 + apps/docs/src/components/LandingPageIntro.res | 2 +- .../docs/src/components/LandingPageIntro.resi | 1 + apps/docs/styles/_shared-sources.css | 5 + apps/docs/styles/_theme.css | 252 +++++++ apps/docs/styles/content.css | 88 +++ apps/docs/styles/homepage.css | 29 + apps/docs/styles/main.css | 623 +----------------- apps/docs/styles/playground.css | 271 ++++++++ apps/docs/styles/search.css | 2 +- apps/docs/styles/test-utilities.css | 12 + apps/docs/vitest.setup.mjs | 4 + 21 files changed, 908 insertions(+), 622 deletions(-) create mode 100644 apps/docs/app/layouts/HomepageLayoutRoute.res create mode 100644 apps/docs/app/layouts/HomepageLayoutRoute.resi create mode 100644 apps/docs/e2e-playwright/route-css.spec.mjs create mode 100644 apps/docs/styles/_shared-sources.css create mode 100644 apps/docs/styles/_theme.css create mode 100644 apps/docs/styles/content.css create mode 100644 apps/docs/styles/homepage.css create mode 100644 apps/docs/styles/playground.css create mode 100644 apps/docs/styles/test-utilities.css diff --git a/apps/docs/app/DocsRoutes.res b/apps/docs/app/DocsRoutes.res index 326a19350..35886a279 100644 --- a/apps/docs/app/DocsRoutes.res +++ b/apps/docs/app/DocsRoutes.res @@ -73,7 +73,7 @@ let syntaxLookupDetailRoutes = ) let default = [ - index("./routes/LandingPageRoute.jsx"), + layout("./layouts/HomepageLayoutRoute.jsx", [index("./routes/LandingPageRoute.jsx")]), route("try", "./routes/TryRoute.jsx"), layout( "./layouts/ContentLayoutRoute.jsx", diff --git a/apps/docs/app/DocsRoutes.resi b/apps/docs/app/DocsRoutes.resi index c60e74bfd..ba1514a46 100644 --- a/apps/docs/app/DocsRoutes.resi +++ b/apps/docs/app/DocsRoutes.resi @@ -10,5 +10,5 @@ let docsReactRoutes: array let docsGuidesRoutes: array let communityRoutes: array let syntaxLookupDetailRoutes: array -/** Keep home, the lazy playground, and not-found outside the content highlighting boundary. */ +/** Keep homepage, playground, and content styles scoped to their route groups. */ let default: array diff --git a/apps/docs/app/layouts/ContentLayoutRoute.res b/apps/docs/app/layouts/ContentLayoutRoute.res index 5d0015195..a03d7b926 100644 --- a/apps/docs/app/layouts/ContentLayoutRoute.res +++ b/apps/docs/app/layouts/ContentLayoutRoute.res @@ -1,3 +1,10 @@ +@module("../../styles/content.css?url") +external contentCss: string = "default" + +type stylesheet = {rel: string, href: string} + +let links = () => [{rel: "stylesheet", href: contentCss}] + // Route-module initialization runs before any content children render. let () = ContentHighlighting.register(HighlightLanguages.defaultInstance) diff --git a/apps/docs/app/layouts/ContentLayoutRoute.resi b/apps/docs/app/layouts/ContentLayoutRoute.resi index a5c43f841..381da2b21 100644 --- a/apps/docs/app/layouts/ContentLayoutRoute.resi +++ b/apps/docs/app/layouts/ContentLayoutRoute.resi @@ -1,3 +1,7 @@ -/** Own runtime highlighting for content routes without adding a DOM wrapper. */ +type stylesheet = {rel: string, href: string} + +let links: unit => array + +/** Own content styles and runtime highlighting without adding a DOM wrapper. */ @react.component let default: unit => React.element diff --git a/apps/docs/app/layouts/HomepageLayoutRoute.res b/apps/docs/app/layouts/HomepageLayoutRoute.res new file mode 100644 index 000000000..c8bdddb7d --- /dev/null +++ b/apps/docs/app/layouts/HomepageLayoutRoute.res @@ -0,0 +1,9 @@ +@module("../../styles/homepage.css?url") +external homepageCss: string = "default" + +type stylesheet = {rel: string, href: string} + +let links = () => [{rel: "stylesheet", href: homepageCss}] + +@react.component +let default = () => diff --git a/apps/docs/app/layouts/HomepageLayoutRoute.resi b/apps/docs/app/layouts/HomepageLayoutRoute.resi new file mode 100644 index 000000000..bf0517cf9 --- /dev/null +++ b/apps/docs/app/layouts/HomepageLayoutRoute.resi @@ -0,0 +1,7 @@ +type stylesheet = {rel: string, href: string} + +let links: unit => array + +/** Own homepage styles without adding a DOM wrapper. */ +@react.component +let default: unit => React.element diff --git a/apps/docs/app/routes/TryRoute.res b/apps/docs/app/routes/TryRoute.res index d3636e6f6..22785fe5b 100644 --- a/apps/docs/app/routes/TryRoute.res +++ b/apps/docs/app/routes/TryRoute.res @@ -1,3 +1,10 @@ +@module("../../styles/playground.css?url") +external playgroundCss: string = "default" + +type stylesheet = {rel: string, href: string} + +let links = () => [{rel: "stylesheet", href: playgroundCss}] + type props = { bundleBaseUrl: string, versions: array, diff --git a/apps/docs/app/routes/TryRoute.resi b/apps/docs/app/routes/TryRoute.resi index 28764a056..65e640435 100644 --- a/apps/docs/app/routes/TryRoute.resi +++ b/apps/docs/app/routes/TryRoute.resi @@ -1,3 +1,7 @@ +type stylesheet = {rel: string, href: string} + +let links: unit => array + type props = { bundleBaseUrl: string, versions: array, diff --git a/apps/docs/e2e-playwright/route-css.spec.mjs b/apps/docs/e2e-playwright/route-css.spec.mjs new file mode 100644 index 000000000..b02b13c2c --- /dev/null +++ b/apps/docs/e2e-playwright/route-css.spec.mjs @@ -0,0 +1,189 @@ +import { expect, test } from "playwright/test"; + +const homepageTitle = "JavaScript Made Simple for Humans and AI"; + +async function expectDesktopLogo(page) { + const homeLink = page.getByRole("link", { name: "homepage" }); + await expect(homeLink).toHaveCSS("width", "128px"); + await expect(homeLink).toHaveCSS("height", "40px"); + await expect( + homeLink.getByRole("img", { name: "ReScript Home" }), + ).toBeVisible(); +} + +async function loadedStyles(page) { + return page.locator('link[rel="stylesheet"]').evaluateAll(async (links) => { + const styles = await Promise.all( + links.map(async (link) => (await fetch(link.href)).text()), + ); + return styles.join("\n"); + }); +} + +async function foundationCounts(page) { + return page.evaluate(() => { + function flattenRules(rules) { + return Array.from(rules).flatMap((rule) => + "cssRules" in rule ? [rule, ...flattenRules(rule.cssRules)] : [rule], + ); + } + + const rules = Array.from(document.styleSheets).flatMap((sheet) => + flattenRules(sheet.cssRules), + ); + const fonts = rules + .filter((rule) => rule instanceof CSSFontFaceRule) + .map((rule) => rule.cssText); + const tokens = [ + "--font-sans", + "--color-gray-90", + "--color-fire", + "--text-48", + ].map( + (token) => + rules.filter( + (rule) => + rule instanceof CSSStyleRule && rule.style.getPropertyValue(token), + ).length, + ); + const resets = rules.filter( + (rule) => + rule instanceof CSSStyleRule && + rule.selectorText + .split(",") + .some((selector) => selector.trim() === "*") && + rule.style.boxSizing === "border-box", + ).length; + return { + fonts: fonts.length, + uniqueFonts: new Set(fonts).size, + tokens, + resets, + }; + }); +} + +test("homepage styles exclude content, search, and playground rules", async ({ + page, +}) => { + await page.goto("/"); + await expect( + page.getByRole("heading", { level: 1, name: homepageTitle }), + ).toBeVisible(); + + const styles = await loadedStyles(page); + + expect(styles).toContain(".gallery-selector"); + expect(styles).not.toContain(".markdown-body"); + expect(styles).not.toContain(".playground-theme"); + expect(styles).not.toContain(".DocSearch-Modal"); +}); + +test("shared foundations are emitted once across route navigation", async ({ + page, +}) => { + await page.goto("/"); + const initial = await foundationCounts(page); + expect(initial.fonts).toBeGreaterThan(0); + expect(initial.uniqueFonts).toBe(initial.fonts); + expect(initial.tokens).toEqual([1, 1, 1, 1]); + expect(initial.resets).toBe(1); + + await page.getByRole("link", { name: "Docs", exact: true }).click(); + await expect( + page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), + ).toBeVisible(); + expect(await foundationCounts(page)).toEqual(initial); + + await page.getByRole("link", { name: "homepage" }).click(); + await expect( + page.getByRole("heading", { level: 1, name: homepageTitle }), + ).toHaveCSS("font-size", "68px"); + expect(await foundationCounts(page)).toEqual(initial); +}); + +test("desktop navigation keeps its responsive logo across route stylesheets", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await expectDesktopLogo(page); + await page.getByRole("link", { name: "Docs", exact: true }).click(); + await expect( + page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), + ).toBeVisible(); + await expectDesktopLogo(page); + await page.getByRole("link", { name: "homepage" }).click(); + await expect( + page.getByRole("heading", { level: 1, name: homepageTitle }), + ).toBeVisible(); + await expectDesktopLogo(page); +}); + +test("documentation styles are prefetched only after navigation intent", async ({ + page, +}) => { + await page.goto("/"); + const contentPrefetch = page.locator( + 'link[rel="prefetch"][as="style"][href*="/content-"]', + ); + await expect(contentPrefetch).toHaveCount(0); + await page.getByRole("link", { name: "Get started", exact: true }).focus(); + await expect(contentPrefetch).toHaveCount(1); + await page.getByRole("link", { name: "Docs", exact: true }).focus(); + await expect(contentPrefetch).toHaveCount(0); +}); + +for (const route of [ + { path: "/docs/manual/introduction/", title: "ReScript" }, + { path: "/brand/", title: "Brand Assets" }, + { path: "/packages/", title: "Libraries & Bindings" }, +]) { + test(`cold ${route.path} loads its content styles`, async ({ page }) => { + await page.goto(route.path); + const heading = page.getByRole("heading", { + level: 1, + name: route.title, + exact: true, + }); + await expect(heading).toBeVisible(); + await expect(heading).toHaveCSS("font-weight", "600"); + await expect(heading).toHaveCSS("font-size", "48px"); + const styles = await loadedStyles(page); + expect(styles).toContain(".markdown-body"); + expect(styles).not.toContain(".playground-theme"); + }); +} + +test("cold blog styles preserve article typography", async ({ page }) => { + await page.goto("/blog/"); + const featuredTitle = page.getByRole("heading", { level: 2 }).first(); + await expect(featuredTitle).toBeVisible(); + await expect(featuredTitle).toHaveCSS("font-size", "48px"); + await expect(featuredTitle).toHaveCSS("font-weight", "600"); + const styles = await loadedStyles(page); + expect(styles).toContain(".markdown-body"); + expect(styles).not.toContain(".playground-theme"); +}); + +test("mobile documentation drawer retains its layout after navigation", async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/"); + await page.getByRole("link", { name: "Docs", exact: true }).click(); + await expect( + page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Toggle navigation menu" }).click(); + const drawer = page.getByRole("dialog"); + await expect(drawer).toBeVisible(); + await expect(drawer).toHaveCSS("background-color", "rgb(255, 255, 255)"); + await expect(drawer).toHaveCSS("margin-left", "0px"); + await drawer.getByRole("link", { name: "Installation", exact: true }).click(); + await expect( + page.getByRole("heading", { level: 1, name: "Installation", exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(drawer).not.toBeVisible(); +}); diff --git a/apps/docs/e2e/Playground.cy.res b/apps/docs/e2e/Playground.cy.res index 1b77dc10e..f4fd5eb09 100644 --- a/apps/docs/e2e/Playground.cy.res +++ b/apps/docs/e2e/Playground.cy.res @@ -66,6 +66,8 @@ describe("Playground", () => { it("should highlight JavaScript after a direct playground load", () => { visit("/try") get(".cm-editor")->shouldBeVisible->ignore + get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore + get(".cm-editor")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore get("pre.whitespace-pre-wrap")->shouldContainText("react/jsx-runtime")->ignore get("pre code.lang-js span[class^='hljs-']")->should("exist")->ignore }) @@ -134,6 +136,10 @@ describe("Playground", () => { // Verify playground shell is in light mode get("main")->shouldWithValue("have.class", "playground-theme-light")->ignore + get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(250, 251, 252)")->ignore + get(".cm-editor") + ->shouldWithKeyValue("have.css", "background-color", "rgb(255, 255, 255)") + ->ignore cyWindow() ->its("localStorage") ->invokeWithArg("getItem", "playgroundTheme") @@ -150,6 +156,8 @@ describe("Playground", () => { // Verify playground shell is back to dark mode get("main")->shouldWithValue("have.class", "playground-theme-dark")->ignore + get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore + get(".cm-editor")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore cyWindow() ->its("localStorage") ->invokeWithArg("getItem", "playgroundTheme") diff --git a/apps/docs/src/components/LandingPageIntro.res b/apps/docs/src/components/LandingPageIntro.res index fcf9d1163..17e368c4d 100644 --- a/apps/docs/src/components/LandingPageIntro.res +++ b/apps/docs/src/components/LandingPageIntro.res @@ -18,7 +18,7 @@ let make = () => { confidence as your codebase grows.`)}

diff --git a/apps/docs/src/components/LandingPageIntro.resi b/apps/docs/src/components/LandingPageIntro.resi index 1ca44ce26..26c2771b5 100644 --- a/apps/docs/src/components/LandingPageIntro.resi +++ b/apps/docs/src/components/LandingPageIntro.resi @@ -1,2 +1,3 @@ +/** Prefetch installation content only after hover or focus intent. */ @react.component let make: unit => React.element diff --git a/apps/docs/styles/_shared-sources.css b/apps/docs/styles/_shared-sources.css new file mode 100644 index 000000000..ffaf36f90 --- /dev/null +++ b/apps/docs/styles/_shared-sources.css @@ -0,0 +1,5 @@ +/* Repeat shell utilities in each route so responsive variants follow every base utility. */ +@source "../app/DocsRoot.{res,jsx}"; +@source "../app/routes/NotFoundRoute.{res,jsx}"; +@source "../src/components/{NavbarPrimary,NavbarMobileOverlay,NavbarUtils,Search,SearchNotice}.{res,jsx}"; +@source "../../../packages/shared/src/Icon.{res,jsx}"; diff --git a/apps/docs/styles/_theme.css b/apps/docs/styles/_theme.css new file mode 100644 index 000000000..5982f28f0 --- /dev/null +++ b/apps/docs/styles/_theme.css @@ -0,0 +1,252 @@ +/* Inline defaults keep route-only utilities independent of root source detection. */ +@import "tailwindcss/theme.css" layer(theme) theme(inline); + +/* Emit custom tokens once at the root, including those referenced by lazy routes. */ +@theme static { + --radius-*: initial; + --radius-none: 0; + --radius-sm: 0.125rem; + --radius: 0.25rem; + --radius-lg: 0.5rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + --breakpoint-*: initial; + --breakpoint-xs: 599px; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; + --breakpoint-xl: 1440px; + + --text-*: initial; + --text-11: 0.6875rem; + --text-12: 0.75rem; + --text-14: 0.875rem; + --text-16: 1rem; + --text-18: 1.125rem; + --text-24: 1.5rem; + --text-32: 2rem; + --text-48: 3rem; + --text-68: 4.25rem; + + --font-weight-*: initial; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --leading-*: initial; + --leading-2: 1.35; + --leading-4: 1.5; + --leading-5: 1.75; + --leading-none: 1; + --leading-tight: 1.25; + + --tracking-*: initial; + --tracking-tighter: -0.03em; + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-wide: 0.075em; + --tracking-widest: 0.15em; + + --z-index-*: initial; + --z-index-0: 0; + --z-index-1: 1; + --z-index-2: 2; + --z-index-3: 3; + --z-index-4: 4; + --z-index-5: 5; + --z-index-10: 10; + --z-index-20: 20; + --z-index-25: 25; + --z-index-30: 30; + --z-index-40: 40; + --z-index-50: 50; + --z-index-75: 75; + --z-index-100: 100; + --z-index-auto: auto; + + --font-*: initial; + --font-sans: + Inter, SF Pro Text, Roboto, -apple-system, BlinkMacSystemFont, + Helvetica Neue, Arial, sans-serif; + --font-mono: Roboto Mono, SFMono-Regular, Menlo, Segoe UI, Courier, monospace; + + --color-gray-5: #fafbfc; + --color-gray-10: #fafbfc; + --color-gray-20: #edf0f2; + --color-gray-30: #cdcdd6; + --color-gray-40: #979aad; + --color-gray-60: #696b7d; + --color-gray-70: #3c3d4e; + --color-gray-80: #232538; + --color-gray-90: #14162c; + --color-gray-95: #14162c; + --color-gray-100: #0b0d22; + --color-gray-5-tr: rgba(1, 20, 29, 0.02); + --color-gray-10-tr: rgba(1, 20, 38, 0.02); + --color-gray-60-tr: rgba(1, 4, 39, 0.6); + --color-gray-80-tr: rgba(1, 4, 39, 0.8); + --color-gray-90-tr: rgba(1, 4, 39, 0.95); + + --color-white: #ffffff; + --color-white-80-tr: rgba(255, 255, 255, 0.8); + --color-white-60-tr: rgba(255, 255, 255, 0.6); + --color-white-40-tr: rgba(255, 255, 255, 0.4); + + --color-fire-5: #fcf1f1; + --color-fire-10: #fbb8bb; + --color-fire-30: #f4646a; + --color-fire-50: #e6484f; + --color-fire-70: #c3373d; + --color-fire-90: #790c10; + --color-fire-100: #211332; + --color-fire: #e6484f; + --color-fire-dark: #e06c75; + + --color-sky-5: #ecf1fc; + --color-sky-10: #dde8fd; + --color-sky-30: #638fe6; + --color-sky-70: #2258c3; + --color-sky-90: #0c2e6f; + --color-sky: #376fdd; + + --color-berry-15: rgba(171, 94, 163, 0.15); + --color-berry-40: #a766d0; + --color-berry: #b151dd; + --color-berry-dark-50: #b984db; + + --color-water: #5e5ede; + --color-water-dark: #637cc1; + + --color-ocean-dark: #8aaec8; + + --color-turtle: #02a875; + --color-turtle-dark: #388b72; + + --color-orange-10: rgba(224, 172, 0, 0.1); + --color-orange-15: rgba(224, 172, 0, 0.15); + --color-orange: #dd8c1b; + --color-orange-dark: #d59b74; + + --height-18: 4.5rem; + --height-inherit: inherit; + + --min-width-320: 20rem; + + --container-320: 20rem; + --container-400: 25rem; + --container-576: 36rem; + --container-740: 46.25rem; + --container-1060: 66.25rem; + --container-1280: 80rem; + --container-sm: 30rem; + --container-md: 40rem; + --container-xl: 66.25rem; + --container-none: none; + --container-full: 100%; + + --inset-16: 4rem; + --inset-18: 4.5rem; + + --spacing-2\/3: 66.666667%; + --spacing-9\/16: 56.25%; + --spacing-0_75: 0.135rem; + + --animate-pulse: pulse 0.5s cubic-bezier(0.4, 0, 0.6, 1); + + --shadow-sm: 0 0.5px 0.5px 0 rgba(0, 0, 0, 0.05); + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + } +} + +@utility hl-title { + @apply text-[2.5rem] tracking-tighter leading-tight font-semibold text-gray-80; + + @media (width >=theme(--breakpoint-xs)) { + @apply text-48; + } + + @media (width >=theme(--breakpoint-md)) { + @apply text-68; + } + + @media (width >=theme(--breakpoint-lg)) { + @apply font-bold; + } +} + +@utility hl-1 { + @layer components { + @apply text-48 font-semibold tracking-tighter leading-tight text-black; + } +} + +@utility hl-2 { + @layer components { + @apply text-32 font-semibold tracking-tighter leading-tight text-black; + } +} + +@utility hl-3 { + @layer components { + @apply text-24 font-semibold tracking-tight leading-tight text-black; + } +} + +@utility hl-4 { + @layer components { + @apply text-18 font-semibold tracking-tight leading-tight text-gray-80; + } +} + +@utility hl-5 { + @layer components { + @apply text-14 font-bold tracking-normal leading-tight text-gray-80; + } +} + +@utility hl-overline { + @layer components { + @apply text-11 font-semibold tracking-wide leading-tight uppercase text-gray-80; + } +} + +@utility body-lg { + @layer components { + @apply text-18 font-normal tracking-tight leading-4; + } +} + +@utility body-button { + @layer components { + @apply text-16 font-semibold tracking-normal leading-2; + } +} + +@utility body-md { + @layer components { + @apply text-16 font-normal tracking-tight leading-4; + } +} + +@utility body-sm { + @layer components { + @apply text-14 font-medium tracking-normal leading-2; + } +} + +@utility captions { + @layer components { + @apply text-12 font-medium tracking-normal leading-2; + } +} diff --git a/apps/docs/styles/content.css b/apps/docs/styles/content.css new file mode 100644 index 000000000..2d1ca0f91 --- /dev/null +++ b/apps/docs/styles/content.css @@ -0,0 +1,88 @@ +@reference "./_theme.css"; +@import "tailwindcss/utilities.css" layer(utilities) source(none); +@import "./_shared-sources.css"; +@import "./_markdown.css" layer(base); + +@source "../app/routes/**/*.{res,jsx}"; +@source not "../app/routes/LandingPage*.{res,jsx}"; +@source not "../app/routes/TryRoute.{res,jsx}"; +@source "../app/layouts/**/*.{res,jsx}"; +@source "../src/**/*.{res,jsx,js,mjs}"; +@source "../markdown-pages/**/*.mdx"; +@source "../../../packages/shared/src/**/*.{res,jsx}"; + +@layer components { + a > code { + @apply text-fire; + } +} + +.wrapper { + position: relative; + display: inline-block; +} + +.version-popover[popover] { + inset: unset; + width: 100%; + height: auto; + /* padding: 0; */ + background: transparent; + border: none; + position: fixed; + top: 0px; + right: 0px; + pointer-events: none; + + overlay: none; + /* this alone would do the trick, but its not supported yet in all major browsers*/ +} + +.menu { + visibility: hidden; + position: absolute; + left: 0; + top: 100%; + opacity: 0; + transition: + visibility 0s ease, + opacity 0.3s ease, + transform 0.3s ease; +} + +.version-popover[popover]:popover-open ~ .menu { + opacity: 1; + visibility: visible; +} + +.trigger { + svg { + display: inline-block; + height: 0.75rem; + width: 0.75rem; + transform: rotate(0); + } +} + +.version-popover[popover]:popover-open ~ .trigger { + svg { + transform: rotate(180deg); + } +} + +#sidebar[popover] { + inset: unset; + width: unset; + border: none; + position: relative; + top: 0px; + left: 0px; + overlay: none; + height: 100%; + @apply min-w-64 bg-white border-gray-20 border-r-2 overflow-y-scroll h-full md:block; +} + +#mobile-tertiary-drawer { + max-width: 100%; + margin: 0; +} diff --git a/apps/docs/styles/homepage.css b/apps/docs/styles/homepage.css new file mode 100644 index 000000000..9fbc8a667 --- /dev/null +++ b/apps/docs/styles/homepage.css @@ -0,0 +1,29 @@ +@reference "./_theme.css"; +@import "tailwindcss/utilities.css" layer(utilities) source(none); +@import "./_shared-sources.css"; + +@source "../app/routes/LandingPage*.{res,jsx}"; +@source "../src/components/{Footer,Button,ImageGallery,ResponsiveImage}.{res,jsx}"; + +.gallery-selector::after { + content: ""; + inline-size: 100%; + block-size: 1px; + background-color: currentColor; +} + +@media (prefers-reduced-motion: no-preference) { + .gallery-photo { + animation: gallery-fade-in 1s ease-in-out; + } +} + +@keyframes gallery-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/apps/docs/styles/main.css b/apps/docs/styles/main.css index 045eb257d..b6d69f239 100644 --- a/apps/docs/styles/main.css +++ b/apps/docs/styles/main.css @@ -1,180 +1,10 @@ -@import "./_markdown.css" layer(base); -@import "./_fonts.css" layer(base); - -@import "tailwindcss"; - -@source '../src/**/*.{jsx,res}'; -@source '../pages/**/*.{jsx,mdx}'; -@source '../app/**/*.{jsx,mdx}'; -/* Include workspace packages that render inside the docs app, e.g. the playground. */ -@source '../../../packages/*/src/**/*.{jsx,res}'; - -@theme { - --radius-*: initial; - --radius-none: 0; - --radius-sm: 0.125rem; - --radius: 0.25rem; - --radius-lg: 0.5rem; - --radius-xl: 1rem; - --radius-full: 9999px; - - --breakpoint-*: initial; - --breakpoint-xs: 599px; - --breakpoint-sm: 576px; - --breakpoint-md: 768px; - --breakpoint-lg: 1024px; - --breakpoint-xl: 1440px; - - --text-*: initial; - --text-11: 0.6875rem; - --text-12: 0.75rem; - --text-14: 0.875rem; - --text-16: 1rem; - --text-18: 1.125rem; - --text-24: 1.5rem; - --text-32: 2rem; - --text-48: 3rem; - --text-68: 4.25rem; - - --font-weight-*: initial; - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; - - --leading-*: initial; - --leading-2: 1.35; - --leading-4: 1.5; - --leading-5: 1.75; - --leading-none: 1; - --leading-tight: 1.25; - - --tracking-*: initial; - --tracking-tighter: -0.03em; - --tracking-tight: -0.02em; - --tracking-normal: 0; - --tracking-wide: 0.075em; - --tracking-widest: 0.15em; - - --z-index-*: initial; - --z-index-0: 0; - --z-index-1: 1; - --z-index-2: 2; - --z-index-3: 3; - --z-index-4: 4; - --z-index-5: 5; - --z-index-10: 10; - --z-index-20: 20; - --z-index-25: 25; - --z-index-30: 30; - --z-index-40: 40; - --z-index-50: 50; - --z-index-75: 75; - --z-index-100: 100; - --z-index-auto: auto; - - --font-*: initial; - --font-sans: - Inter, SF Pro Text, Roboto, -apple-system, BlinkMacSystemFont, - Helvetica Neue, Arial, sans-serif; - --font-mono: Roboto Mono, SFMono-Regular, Menlo, Segoe UI, Courier, monospace; - - --color-gray-5: #fafbfc; - --color-gray-10: #fafbfc; - --color-gray-20: #edf0f2; - --color-gray-30: #cdcdd6; - --color-gray-40: #979aad; - --color-gray-60: #696b7d; - --color-gray-70: #3c3d4e; - --color-gray-80: #232538; - --color-gray-90: #14162c; - --color-gray-95: #14162c; - --color-gray-100: #0b0d22; - --color-gray-5-tr: rgba(1, 20, 29, 0.02); - --color-gray-10-tr: rgba(1, 20, 38, 0.02); - --color-gray-60-tr: rgba(1, 4, 39, 0.6); - --color-gray-80-tr: rgba(1, 4, 39, 0.8); - --color-gray-90-tr: rgba(1, 4, 39, 0.95); - - --color-white: #ffffff; - --color-white-80-tr: rgba(255, 255, 255, 0.8); - --color-white-60-tr: rgba(255, 255, 255, 0.6); - --color-white-40-tr: rgba(255, 255, 255, 0.4); - - --color-fire-5: #fcf1f1; - --color-fire-10: #fbb8bb; - --color-fire-30: #f4646a; - --color-fire-50: #e6484f; - --color-fire-70: #c3373d; - --color-fire-90: #790c10; - --color-fire-100: #211332; - --color-fire: #e6484f; - --color-fire-dark: #e06c75; - - --color-sky-5: #ecf1fc; - --color-sky-10: #dde8fd; - --color-sky-30: #638fe6; - --color-sky-70: #2258c3; - --color-sky-90: #0c2e6f; - --color-sky: #376fdd; - - --color-berry-15: rgba(171, 94, 163, 0.15); - --color-berry-40: #a766d0; - --color-berry: #b151dd; - --color-berry-dark-50: #b984db; - - --color-water: #5e5ede; - --color-water-dark: #637cc1; - - --color-ocean-dark: #8aaec8; - - --color-turtle: #02a875; - --color-turtle-dark: #388b72; - - --color-orange-10: rgba(224, 172, 0, 0.1); - --color-orange-15: rgba(224, 172, 0, 0.15); - --color-orange: #dd8c1b; - --color-orange-dark: #d59b74; - - --height-18: 4.5rem; - --height-inherit: inherit; - - --min-width-320: 20rem; - - --container-320: 20rem; - --container-400: 25rem; - --container-576: 36rem; - --container-740: 46.25rem; - --container-1060: 66.25rem; - --container-1280: 80rem; - --container-sm: 30rem; - --container-md: 40rem; - --container-xl: 66.25rem; - --container-none: none; - --container-full: 100%; +@layer base, theme, components, utilities; - --inset-16: 4rem; - --inset-18: 4.5rem; - - --spacing-2\/3: 66.666667%; - --spacing-9\/16: 56.25%; - --spacing-0_75: 0.135rem; - - --animate-pulse: pulse 0.5s cubic-bezier(0.4, 0, 0.6, 1); - - --shadow-sm: 0 0.5px 0.5px 0 rgba(0, 0, 0, 0.05); - - @keyframes pulse { - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.5; - } - } -} +@import "./_fonts.css" layer(base); +@import "./_theme.css"; +@import "tailwindcss/preflight.css" layer(base); +@import "tailwindcss/utilities.css" layer(utilities) source(none); +@import "./_shared-sources.css"; /* The default border color has changed to `currentcolor` in Tailwind CSS v4, @@ -194,324 +24,6 @@ } } -@layer components { - .playground-theme { - --playground-bg: var(--color-gray-100); - --playground-surface: var(--color-gray-100); - --playground-surface-border: transparent; - --playground-control-bg: var(--color-gray-100); - --playground-control-border: transparent; - --playground-elevated-bg: var(--color-gray-100); - --playground-overlay-bg: var(--color-gray-90); - --playground-overlay-border: var(--color-gray-70); - --playground-text-primary: var(--color-gray-20); - --playground-text-secondary: var(--color-gray-40); - --playground-border: var(--color-gray-60); - --playground-border-strong: var(--color-gray-80); - --playground-hover-surface: var(--color-gray-40); - --playground-hover-elevated: var(--color-gray-80); - --playground-active-surface: var(--color-gray-40); - --playground-input-bg: var(--color-gray-90); - --playground-placeholder: rgba(237, 240, 242, 0.5); - --playground-divider-bg: var(--color-gray-70); - --playground-divider-border: transparent; - --playground-divider-opacity: 0.3; - --playground-divider-hover-opacity: 0.5; - --playground-divider-handle: var(--color-gray-20); - --playground-scrollbar-track: var(--color-gray-100); - --playground-scrollbar-thumb: var(--color-gray-70); - --playground-scrollbar-thumb-hover: var(--color-gray-60); - --playground-toggle-track: #374151; - --playground-toggle-track-border: #4b5563; - --playground-toggle-thumb: var(--color-white); - --playground-toggle-label: #d1d5db; - --playground-tab-active: var(--color-white); - --playground-editor-bg: var(--color-gray-100); - --playground-editor-text: var(--color-gray-20); - --playground-editor-cursor: var(--color-orange); - --playground-editor-active-line: rgba(255, 255, 255, 0.02); - --playground-editor-gutter-bg: transparent; - --playground-editor-gutter-text: var(--color-gray-60); - --playground-editor-gutter-border: transparent; - --playground-editor-active-gutter-bg: transparent; - --playground-editor-active-gutter-text: var(--color-white); - --playground-editor-selection: rgba(255, 255, 255, 0.2); - --playground-editor-selection-match: #aafe661a; - --playground-editor-syntax-keyword: var(--color-berry-dark-50); - --playground-editor-syntax-variable: var(--color-gray-30); - --playground-editor-syntax-type: var(--color-orange-dark); - --playground-editor-syntax-string: var(--color-turtle-dark); - --playground-editor-syntax-comment: var(--color-gray-60); - --playground-editor-syntax-namespace-def: var(--color-orange); - --playground-editor-syntax-namespace: var(--color-water-dark); - --playground-editor-syntax-property: var(--color-ocean-dark); - --playground-editor-syntax-attribute: #bcc9ab; - } - - .playground-theme-light { - --playground-bg: var(--color-gray-5); - --playground-surface: var(--color-white); - --playground-surface-border: var(--color-gray-20); - --playground-control-bg: #f3f5f7; - --playground-control-border: var(--color-gray-20); - --playground-elevated-bg: var(--color-white); - --playground-overlay-bg: var(--color-white); - --playground-overlay-border: var(--color-gray-30); - --playground-text-primary: var(--color-gray-80); - --playground-text-secondary: var(--color-gray-80); - --playground-border: var(--color-gray-30); - --playground-border-strong: var(--color-gray-60); - --playground-hover-surface: var(--color-gray-20); - --playground-hover-elevated: var(--color-gray-10); - --playground-active-surface: var(--color-gray-20); - --playground-input-bg: var(--color-gray-10); - --playground-placeholder: var(--color-gray-60); - --playground-divider-bg: #f5f7f9; - --playground-divider-border: var(--color-gray-20); - --playground-divider-opacity: 1; - --playground-divider-hover-opacity: 1; - --playground-divider-handle: var(--color-gray-40); - --playground-scrollbar-track: #f5f7f9; - --playground-scrollbar-thumb: var(--color-gray-30); - --playground-scrollbar-thumb-hover: var(--color-gray-40); - --playground-toggle-track: var(--color-gray-30); - --playground-toggle-track-border: var(--color-gray-40); - --playground-toggle-thumb: var(--color-white); - --playground-toggle-label: var(--color-gray-80); - --playground-tab-active: var(--color-gray-80); - --playground-editor-bg: var(--color-white); - --playground-editor-text: var(--color-gray-80); - --playground-editor-cursor: var(--color-sky-70); - --playground-editor-active-line: rgba(34, 88, 195, 0.07); - --playground-editor-gutter-bg: var(--color-gray-10); - --playground-editor-gutter-text: var(--color-gray-60); - --playground-editor-gutter-border: var(--color-gray-20); - --playground-editor-active-gutter-bg: var(--color-gray-20); - --playground-editor-active-gutter-text: var(--color-gray-80); - --playground-editor-selection: rgba(34, 88, 195, 0.22); - --playground-editor-selection-match: rgba(94, 94, 222, 0.18); - --playground-editor-syntax-keyword: var(--color-berry); - --playground-editor-syntax-variable: var(--color-gray-80); - --playground-editor-syntax-type: var(--color-orange); - --playground-editor-syntax-string: var(--color-turtle); - --playground-editor-syntax-comment: var(--color-gray-60); - --playground-editor-syntax-namespace-def: var(--color-orange); - --playground-editor-syntax-namespace: var(--color-water); - --playground-editor-syntax-property: var(--color-sky); - --playground-editor-syntax-attribute: #4f5f78; - } - - .playground-main { - background-color: var(--playground-bg); - color: var(--playground-text-secondary); - } - - .playground-surface { - background-color: var(--playground-surface); - border-color: var(--playground-surface-border); - } - - .playground-text-primary { - color: var(--playground-text-primary) !important; - } - - .playground-text-secondary { - color: var(--playground-text-secondary) !important; - } - - .playground-select { - background-color: var(--playground-elevated-bg); - border-color: var(--playground-border); - color: var(--playground-text-primary) !important; - } - - .playground-selection-option { - background-color: var(--playground-elevated-bg); - border: 1px solid var(--playground-border); - color: var(--playground-text-primary); - } - - .playground-selection-option:hover { - background-color: var(--playground-hover-surface); - } - - .playground-selection-option-active { - background-color: var(--color-fire); - border-color: var(--color-fire); - color: var(--color-white); - } - - .playground-selection-option-active:hover { - background-color: var(--color-fire); - } - - .playground-overlay { - background-color: var(--playground-overlay-bg); - border-color: var(--playground-overlay-border); - color: var(--playground-text-primary); - } - - .playground-field { - border-color: var(--playground-border); - } - - .playground-field-active { - border-color: var(--playground-text-primary); - } - - .playground-icon-button:hover { - background-color: var(--playground-hover-surface); - } - - .playground-input { - background-color: var(--playground-input-bg); - color: var(--playground-text-primary); - } - - .playground-input::placeholder { - color: var(--playground-placeholder); - } - - .playground-chip-active { - background-color: var(--playground-active-surface); - } - - .playground-suggestion-active { - background-color: var(--playground-active-surface); - } - - .playground-control-panel { - background-color: var(--playground-control-bg); - border-bottom: 1px solid var(--playground-control-border); - } - - .playground-toast { - background-color: var(--playground-overlay-bg); - border-color: var(--playground-overlay-border); - color: var(--playground-text-primary); - } - - .playground-toggle-label { - color: var(--playground-toggle-label); - } - - .playground-toggle-track { - background-color: var(--playground-toggle-track); - border-color: var(--playground-toggle-track-border); - } - - .playground-toggle-track::after { - background-color: var(--playground-toggle-thumb); - border-color: var(--playground-toggle-track-border); - } - - .playground-tab-active { - color: var(--playground-tab-active); - border-top-color: var(--color-sky-70) !important; - } - - .playground-editor-shell { - background-color: var(--playground-editor-bg); - } - - .playground-divider { - background-color: var(--playground-divider-bg); - border: 1px solid var(--playground-divider-border); - opacity: var(--playground-divider-opacity); - } - - .playground-divider:hover { - opacity: var(--playground-divider-hover-opacity); - background-color: var(--playground-hover-surface); - } - - .playground-divider-handle { - color: var(--playground-divider-handle); - } -} - -@utility hl-title { - @apply text-[2.5rem] tracking-tighter leading-tight font-semibold text-gray-80; - - @media (width >=theme(--breakpoint-xs)) { - @apply text-48; - } - - @media (width >=theme(--breakpoint-md)) { - @apply text-68; - } - - @media (width >=theme(--breakpoint-lg)) { - @apply font-bold; - } -} - -@utility hl-1 { - @layer components { - @apply text-48 font-semibold tracking-tighter leading-tight text-black; - } -} - -@utility hl-2 { - @layer components { - @apply text-32 font-semibold tracking-tighter leading-tight text-black; - } -} - -@utility hl-3 { - @layer components { - @apply text-24 font-semibold tracking-tight leading-tight text-black; - } -} - -@utility hl-4 { - @layer components { - @apply text-18 font-semibold tracking-tight leading-tight text-gray-80; - } -} - -@utility hl-5 { - @layer components { - @apply text-14 font-bold tracking-normal leading-tight text-gray-80; - } -} - -@utility hl-overline { - @layer components { - @apply text-11 font-semibold tracking-wide leading-tight uppercase text-gray-80; - } -} - -@utility body-lg { - @layer components { - @apply text-18 font-normal tracking-tight leading-4; - } -} - -@utility body-button { - @layer components { - @apply text-16 font-semibold tracking-normal leading-2; - } -} - -@utility body-md { - @layer components { - @apply text-16 font-normal tracking-tight leading-4; - } -} - -@utility body-sm { - @layer components { - @apply text-14 font-medium tracking-normal leading-2; - } -} - -@utility captions { - @layer components { - @apply text-12 font-medium tracking-normal leading-2; - } -} - @layer components { /* @import "./_typography.css"; */ @@ -531,124 +43,6 @@ background: transparent; /* Chrome/Safari/Webkit */ } - - .playground-scrollbar { - scrollbar-width: thin; - scrollbar-color: var(--playground-scrollbar-thumb) - var(--playground-scrollbar-track); - scrollbar-gutter: stable; /* Reserve only at the scrollbar edge to avoid padding shifts */ - } - - .playground-scrollbar::-webkit-scrollbar { - width: 0.65rem; - height: 0.65rem; - } - - .playground-scrollbar::-webkit-scrollbar-track { - background: var(--playground-scrollbar-track); - } - - .playground-scrollbar::-webkit-scrollbar-thumb { - background-color: var(--playground-scrollbar-thumb); - border-radius: 9999px; - border: 2px solid var(--playground-scrollbar-track); - } - - .playground-scrollbar::-webkit-scrollbar-thumb:hover { - background-color: var(--playground-scrollbar-thumb-hover); - } - - a > code { - @apply text-fire; - } -} - -.wrapper { - position: relative; - display: inline-block; -} - -.gallery-selector::after { - content: ""; - inline-size: 100%; - block-size: 1px; - background-color: currentColor; -} - -@media (prefers-reduced-motion: no-preference) { - .gallery-photo { - animation: gallery-fade-in 1s ease-in-out; - } -} - -@keyframes gallery-fade-in { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -.version-popover[popover] { - inset: unset; - width: 100%; - height: auto; - /* padding: 0; */ - background: transparent; - border: none; - position: fixed; - top: 0px; - right: 0px; - pointer-events: none; - - overlay: none; - /* this alone would do the trick, but its not supported yet in all major browsers*/ -} - -.menu { - visibility: hidden; - position: absolute; - left: 0; - top: 100%; - opacity: 0; - transition: - visibility 0s ease, - opacity 0.3s ease, - transform 0.3s ease; -} - -.version-popover[popover]:popover-open ~ .menu { - opacity: 1; - visibility: visible; -} - -.trigger { - svg { - display: inline-block; - height: 0.75rem; - width: 0.75rem; - transform: rotate(0); - } -} - -.version-popover[popover]:popover-open ~ .trigger { - svg { - transform: rotate(180deg); - } -} - -#sidebar[popover] { - inset: unset; - width: unset; - border: none; - position: relative; - top: 0px; - left: 0px; - overlay: none; - height: 100%; - @apply min-w-64 bg-white border-gray-20 border-r-2 overflow-y-scroll h-full md:block; } /* When any dialog is open as a modal, lock the body scroll */ @@ -661,11 +55,6 @@ body:has(dialog[open]) { margin: 0; } -#mobile-tertiary-drawer { - max-width: 100%; - margin: 0; -} - body { scrollbar-gutter: stable; height: 100%; diff --git a/apps/docs/styles/playground.css b/apps/docs/styles/playground.css new file mode 100644 index 000000000..dd6955bee --- /dev/null +++ b/apps/docs/styles/playground.css @@ -0,0 +1,271 @@ +@reference "./_theme.css"; +@import "tailwindcss/utilities.css" layer(utilities) source(none); +@import "./_shared-sources.css"; + +@source "../app/routes/TryRoute.{res,jsx}"; +@source "../../../packages/playground/src/**/*.{res,jsx}"; +@source "../../../packages/shared/src/**/*.{res,jsx}"; + +@layer components { + .playground-theme { + --playground-bg: var(--color-gray-100); + --playground-surface: var(--color-gray-100); + --playground-surface-border: transparent; + --playground-control-bg: var(--color-gray-100); + --playground-control-border: transparent; + --playground-elevated-bg: var(--color-gray-100); + --playground-overlay-bg: var(--color-gray-90); + --playground-overlay-border: var(--color-gray-70); + --playground-text-primary: var(--color-gray-20); + --playground-text-secondary: var(--color-gray-40); + --playground-border: var(--color-gray-60); + --playground-border-strong: var(--color-gray-80); + --playground-hover-surface: var(--color-gray-40); + --playground-hover-elevated: var(--color-gray-80); + --playground-active-surface: var(--color-gray-40); + --playground-input-bg: var(--color-gray-90); + --playground-placeholder: rgba(237, 240, 242, 0.5); + --playground-divider-bg: var(--color-gray-70); + --playground-divider-border: transparent; + --playground-divider-opacity: 0.3; + --playground-divider-hover-opacity: 0.5; + --playground-divider-handle: var(--color-gray-20); + --playground-scrollbar-track: var(--color-gray-100); + --playground-scrollbar-thumb: var(--color-gray-70); + --playground-scrollbar-thumb-hover: var(--color-gray-60); + --playground-toggle-track: #374151; + --playground-toggle-track-border: #4b5563; + --playground-toggle-thumb: var(--color-white); + --playground-toggle-label: #d1d5db; + --playground-tab-active: var(--color-white); + --playground-editor-bg: var(--color-gray-100); + --playground-editor-text: var(--color-gray-20); + --playground-editor-cursor: var(--color-orange); + --playground-editor-active-line: rgba(255, 255, 255, 0.02); + --playground-editor-gutter-bg: transparent; + --playground-editor-gutter-text: var(--color-gray-60); + --playground-editor-gutter-border: transparent; + --playground-editor-active-gutter-bg: transparent; + --playground-editor-active-gutter-text: var(--color-white); + --playground-editor-selection: rgba(255, 255, 255, 0.2); + --playground-editor-selection-match: #aafe661a; + --playground-editor-syntax-keyword: var(--color-berry-dark-50); + --playground-editor-syntax-variable: var(--color-gray-30); + --playground-editor-syntax-type: var(--color-orange-dark); + --playground-editor-syntax-string: var(--color-turtle-dark); + --playground-editor-syntax-comment: var(--color-gray-60); + --playground-editor-syntax-namespace-def: var(--color-orange); + --playground-editor-syntax-namespace: var(--color-water-dark); + --playground-editor-syntax-property: var(--color-ocean-dark); + --playground-editor-syntax-attribute: #bcc9ab; + } + + .playground-theme-light { + --playground-bg: var(--color-gray-5); + --playground-surface: var(--color-white); + --playground-surface-border: var(--color-gray-20); + --playground-control-bg: #f3f5f7; + --playground-control-border: var(--color-gray-20); + --playground-elevated-bg: var(--color-white); + --playground-overlay-bg: var(--color-white); + --playground-overlay-border: var(--color-gray-30); + --playground-text-primary: var(--color-gray-80); + --playground-text-secondary: var(--color-gray-80); + --playground-border: var(--color-gray-30); + --playground-border-strong: var(--color-gray-60); + --playground-hover-surface: var(--color-gray-20); + --playground-hover-elevated: var(--color-gray-10); + --playground-active-surface: var(--color-gray-20); + --playground-input-bg: var(--color-gray-10); + --playground-placeholder: var(--color-gray-60); + --playground-divider-bg: #f5f7f9; + --playground-divider-border: var(--color-gray-20); + --playground-divider-opacity: 1; + --playground-divider-hover-opacity: 1; + --playground-divider-handle: var(--color-gray-40); + --playground-scrollbar-track: #f5f7f9; + --playground-scrollbar-thumb: var(--color-gray-30); + --playground-scrollbar-thumb-hover: var(--color-gray-40); + --playground-toggle-track: var(--color-gray-30); + --playground-toggle-track-border: var(--color-gray-40); + --playground-toggle-thumb: var(--color-white); + --playground-toggle-label: var(--color-gray-80); + --playground-tab-active: var(--color-gray-80); + --playground-editor-bg: var(--color-white); + --playground-editor-text: var(--color-gray-80); + --playground-editor-cursor: var(--color-sky-70); + --playground-editor-active-line: rgba(34, 88, 195, 0.07); + --playground-editor-gutter-bg: var(--color-gray-10); + --playground-editor-gutter-text: var(--color-gray-60); + --playground-editor-gutter-border: var(--color-gray-20); + --playground-editor-active-gutter-bg: var(--color-gray-20); + --playground-editor-active-gutter-text: var(--color-gray-80); + --playground-editor-selection: rgba(34, 88, 195, 0.22); + --playground-editor-selection-match: rgba(94, 94, 222, 0.18); + --playground-editor-syntax-keyword: var(--color-berry); + --playground-editor-syntax-variable: var(--color-gray-80); + --playground-editor-syntax-type: var(--color-orange); + --playground-editor-syntax-string: var(--color-turtle); + --playground-editor-syntax-comment: var(--color-gray-60); + --playground-editor-syntax-namespace-def: var(--color-orange); + --playground-editor-syntax-namespace: var(--color-water); + --playground-editor-syntax-property: var(--color-sky); + --playground-editor-syntax-attribute: #4f5f78; + } + + .playground-main { + background-color: var(--playground-bg); + color: var(--playground-text-secondary); + } + + .playground-surface { + background-color: var(--playground-surface); + border-color: var(--playground-surface-border); + } + + .playground-text-primary { + color: var(--playground-text-primary) !important; + } + + .playground-text-secondary { + color: var(--playground-text-secondary) !important; + } + + .playground-select { + background-color: var(--playground-elevated-bg); + border-color: var(--playground-border); + color: var(--playground-text-primary) !important; + } + + .playground-selection-option { + background-color: var(--playground-elevated-bg); + border: 1px solid var(--playground-border); + color: var(--playground-text-primary); + } + + .playground-selection-option:hover { + background-color: var(--playground-hover-surface); + } + + .playground-selection-option-active { + background-color: var(--color-fire); + border-color: var(--color-fire); + color: var(--color-white); + } + + .playground-selection-option-active:hover { + background-color: var(--color-fire); + } + + .playground-overlay { + background-color: var(--playground-overlay-bg); + border-color: var(--playground-overlay-border); + color: var(--playground-text-primary); + } + + .playground-field { + border-color: var(--playground-border); + } + + .playground-field-active { + border-color: var(--playground-text-primary); + } + + .playground-icon-button:hover { + background-color: var(--playground-hover-surface); + } + + .playground-input { + background-color: var(--playground-input-bg); + color: var(--playground-text-primary); + } + + .playground-input::placeholder { + color: var(--playground-placeholder); + } + + .playground-chip-active { + background-color: var(--playground-active-surface); + } + + .playground-suggestion-active { + background-color: var(--playground-active-surface); + } + + .playground-control-panel { + background-color: var(--playground-control-bg); + border-bottom: 1px solid var(--playground-control-border); + } + + .playground-toast { + background-color: var(--playground-overlay-bg); + border-color: var(--playground-overlay-border); + color: var(--playground-text-primary); + } + + .playground-toggle-label { + color: var(--playground-toggle-label); + } + + .playground-toggle-track { + background-color: var(--playground-toggle-track); + border-color: var(--playground-toggle-track-border); + } + + .playground-toggle-track::after { + background-color: var(--playground-toggle-thumb); + border-color: var(--playground-toggle-track-border); + } + + .playground-tab-active { + color: var(--playground-tab-active); + border-top-color: var(--color-sky-70) !important; + } + + .playground-editor-shell { + background-color: var(--playground-editor-bg); + } + + .playground-divider { + background-color: var(--playground-divider-bg); + border: 1px solid var(--playground-divider-border); + opacity: var(--playground-divider-opacity); + } + + .playground-divider:hover { + opacity: var(--playground-divider-hover-opacity); + background-color: var(--playground-hover-surface); + } + + .playground-divider-handle { + color: var(--playground-divider-handle); + } +} + +@layer components { + .playground-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--playground-scrollbar-thumb) + var(--playground-scrollbar-track); + scrollbar-gutter: stable; /* Reserve only at the scrollbar edge to avoid padding shifts */ + } + + .playground-scrollbar::-webkit-scrollbar { + width: 0.65rem; + height: 0.65rem; + } + + .playground-scrollbar::-webkit-scrollbar-track { + background: var(--playground-scrollbar-track); + } + + .playground-scrollbar::-webkit-scrollbar-thumb { + background-color: var(--playground-scrollbar-thumb); + border-radius: 9999px; + border: 2px solid var(--playground-scrollbar-track); + } + + .playground-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: var(--playground-scrollbar-thumb-hover); + } +} diff --git a/apps/docs/styles/search.css b/apps/docs/styles/search.css index fa6ddef6a..9ff7d5318 100644 --- a/apps/docs/styles/search.css +++ b/apps/docs/styles/search.css @@ -1,3 +1,3 @@ /* Reuse tokens and utilities without duplicating the global theme or preflight. */ -@reference "./main.css"; +@reference "./_theme.css"; @import "./_docsearch.css" layer(base); diff --git a/apps/docs/styles/test-utilities.css b/apps/docs/styles/test-utilities.css new file mode 100644 index 000000000..096e01272 --- /dev/null +++ b/apps/docs/styles/test-utilities.css @@ -0,0 +1,12 @@ +@reference "./_theme.css"; +@import "tailwindcss/utilities.css" layer(utilities) source(none); +@import "./_shared-sources.css"; + +/* Tests render every route together, so their final utility sheet needs the complete union. */ +@source "../app/**/*.{res,jsx}"; +@source "../src/**/*.{res,jsx,js,mjs}"; +@source "../markdown-pages/**/*.mdx"; +@source "../../../packages/shared/src/**/*.{res,jsx}"; +@source "../../../packages/playground/src/**/*.{res,jsx}"; +@source "../__tests__/**/*.{res,jsx}"; +@source "../test-utils/**/*.{res,jsx}"; diff --git a/apps/docs/vitest.setup.mjs b/apps/docs/vitest.setup.mjs index acb31a714..7c5201827 100644 --- a/apps/docs/vitest.setup.mjs +++ b/apps/docs/vitest.setup.mjs @@ -1,4 +1,8 @@ import "./styles/main.css"; +import "./styles/homepage.css"; +import "./styles/content.css"; +import "./styles/playground.css"; +import "./styles/test-utilities.css"; import "./styles/_hljs.css"; import "./styles/utils.css"; import "./styles/test-overrides.css"; From 4fcd2bf466ebfbd3b8f7fe22f9e08326bc4075a4 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 16:21:33 -0400 Subject: [PATCH 2/6] fix(homepage): remove legacy page visibility gate Let head stylesheet links handle render blocking without hiding the entire document. Keep fallback content readable when fonts or shared CSS fail, and assert real applied styles in navigation coverage. --- apps/docs/app/DocsRoot.res | 1 - apps/docs/app/DocsRoot.resi | 1 + apps/docs/e2e-playwright/homepage.spec.mjs | 194 ++++++++++++++++++ .../e2e-playwright/initial-content.spec.mjs | 53 +++++ apps/docs/styles/main.css | 6 - 5 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 apps/docs/e2e-playwright/homepage.spec.mjs create mode 100644 apps/docs/e2e-playwright/initial-content.spec.mjs diff --git a/apps/docs/app/DocsRoot.res b/apps/docs/app/DocsRoot.res index f45def69d..3e38c1f3d 100644 --- a/apps/docs/app/DocsRoot.res +++ b/apps/docs/app/DocsRoot.res @@ -11,7 +11,6 @@ let default = () => { {CypressBootstrap.element()} - diff --git a/apps/docs/app/DocsRoot.resi b/apps/docs/app/DocsRoot.resi index 98c027984..e9a4c6ad1 100644 --- a/apps/docs/app/DocsRoot.resi +++ b/apps/docs/app/DocsRoot.resi @@ -1,4 +1,5 @@ /** Shared shell, token styles, and the stable Cypress bootstrap slot. Runtime grammars belong to content routes and the playground. */ +/** Keep server-rendered content visible; head stylesheets handle render blocking. */ @react.component let default: unit => Jsx.element diff --git a/apps/docs/e2e-playwright/homepage.spec.mjs b/apps/docs/e2e-playwright/homepage.spec.mjs new file mode 100644 index 000000000..e81892f22 --- /dev/null +++ b/apps/docs/e2e-playwright/homepage.spec.mjs @@ -0,0 +1,194 @@ +import { expect, test } from "playwright/test"; + +function observeRuntimeErrors(page) { + const errors = []; + + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()); + } + }); + + return errors; +} + +function observeFailedLocalImages(page) { + const failures = []; + + page.on("response", (response) => { + const request = response.request(); + if ( + request.resourceType() === "image" && + new URL(response.url()).origin === "http://127.0.0.1:4173" && + !response.ok() + ) { + failures.push(`${response.status()} ${response.url()}`); + } + }); + + return failures; +} + +function observeFontRequests(page) { + const requests = []; + + page.on("request", (request) => { + const url = new URL(request.url()); + if ( + request.resourceType() === "font" || + url.hostname === "fonts.googleapis.com" || + url.hostname === "fonts.gstatic.com" + ) { + requests.push(url); + } + }); + + return requests; +} + +async function expectPageStyles(page) { + await expect(page.locator("body")).toHaveCSS("font-family", /^Inter,/); + await expect(page.locator("body")).toHaveCSS( + "background-color", + "rgb(255, 255, 255)", + ); + await expect(page.locator("html")).toHaveCSS("opacity", "1"); +} + +async function loadHomepageImages(page) { + const sections = page.locator("main section"); + const sectionCount = await sections.count(); + + for (let index = 0; index < sectionCount; index += 1) { + await sections.nth(index).scrollIntoViewIfNeeded(); + } + + return page.locator("img").evaluateAll(async (images) => { + await Promise.all( + images.map((image) => image.decode().catch(() => undefined)), + ); + return images + .filter((image) => image.complete && image.naturalWidth === 0) + .map((image) => image.currentSrc || image.src); + }); +} + +test("homepage hydrates with working links, fonts, and images", async ({ + page, +}) => { + const runtimeErrors = observeRuntimeErrors(page); + const failedImages = observeFailedLocalImages(page); + const fontRequests = observeFontRequests(page); + + await page.goto("/"); + await expectPageStyles(page); + + await expect( + page.getByRole("heading", { + level: 1, + name: "JavaScript Made Simple for Humans and AI", + }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { + level: 1, + name: "JavaScript Made Simple for Humans and AI", + }), + ).toHaveCSS("font-weight", "700"); + await expect + .poll(() => + page.evaluate(async () => { + await Promise.all([ + document.fonts.load('400 1rem "Homepage Inter"'), + document.fonts.load('600 1rem "Homepage Inter"'), + document.fonts.load('700 1rem "Homepage Inter"'), + document.fonts.load('700 1rem "Red Hat Mono"'), + ]); + return [ + document.fonts.check('400 1rem "Homepage Inter"'), + document.fonts.check('600 1rem "Homepage Inter"'), + document.fonts.check('700 1rem "Homepage Inter"'), + document.fonts.check('700 1rem "Red Hat Mono"'), + ]; + }), + ) + .toEqual([true, true, true, true]); + await expect( + page.getByRole("link", { name: "Get started", exact: true }), + ).toHaveAttribute("href", "/docs/manual/installation"); + await expect( + page.getByRole("link", { name: "Edit this example in Playground" }), + ).toHaveAttribute("href", /\/try\?code=.+/); + + const brokenLoadedImages = await loadHomepageImages(page); + + expect(brokenLoadedImages).toEqual([]); + expect(failedImages).toEqual([]); + expect(fontRequests.map((url) => url.pathname)).toEqual( + expect.arrayContaining([ + "/fonts/red-hat-mono-700.woff2", + "/fonts/subset-Inter-Bold.woff2", + "/fonts/subset-Inter-Regular.woff2", + "/fonts/subset-Inter-SemiBold.woff2", + ]), + ); + expect( + fontRequests.every((url) => url.origin === "http://127.0.0.1:4173"), + ).toBe(true); + expect(runtimeErrors).toEqual([]); +}); + +test("client navigation preserves homepage and documentation styles", async ({ + page, +}) => { + const runtimeErrors = observeRuntimeErrors(page); + + await page.goto("/"); + await expectPageStyles(page); + await page.getByRole("link", { name: "Docs", exact: true }).click(); + + await expect(page).toHaveURL(/\/docs\/manual\/introduction$/); + await expect( + page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), + ).toBeVisible(); + await expectPageStyles(page); + + await page.getByRole("link", { name: "homepage" }).click(); + await expect(page).toHaveURL("/"); + await expect( + page.getByRole("heading", { + level: 1, + name: "JavaScript Made Simple for Humans and AI", + }), + ).toBeVisible(); + await expectPageStyles(page); + + expect(runtimeErrors).toEqual([]); +}); + +test("mobile navigation opens the packages route", async ({ page }) => { + const runtimeErrors = observeRuntimeErrors(page); + + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/"); + + await page.getByRole("button", { name: "Toggle additional menu" }).click(); + const packagesLink = page.getByRole("link", { + name: "Packages", + exact: true, + }); + await expect(packagesLink).toBeVisible(); + await packagesLink.click(); + + await expect(page).toHaveURL(/\/packages(?:\?search=)?$/); + await expect( + page.getByRole("heading", { + level: 1, + name: "Libraries & Bindings", + exact: true, + }), + ).toBeVisible(); + await expectPageStyles(page); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/docs/e2e-playwright/initial-content.spec.mjs b/apps/docs/e2e-playwright/initial-content.spec.mjs new file mode 100644 index 000000000..3a3389008 --- /dev/null +++ b/apps/docs/e2e-playwright/initial-content.spec.mjs @@ -0,0 +1,53 @@ +import { expect, test } from "playwright/test"; + +test.use({ javaScriptEnabled: false }); + +const introduction = + "ReScript is a strongly typed language that compiles to clean,"; + +test("homepage paragraphs render without JavaScript or downloaded fonts", async ({ + page, +}) => { + await page.route(/\.(?:woff2?|ttf|otf)(?:\?|$)/, (route) => route.abort()); + + await page.goto("/"); + + await expect(page.locator("html")).toHaveCSS("opacity", "1"); + await expect(page.getByText(introduction, { exact: false })).toBeVisible(); + await expect( + page.getByText("Its fast compiler and static type system", { + exact: false, + }), + ).toBeVisible(); + await expect(page.getByText(introduction, { exact: false })).toHaveCSS( + "color", + "rgb(105, 107, 125)", + ); +}); + +for (const path of ["/", "/docs/manual/introduction/"]) { + test(`${path} remains readable when the shared stylesheet fails`, async ({ + page, + }) => { + const stylesheetFailure = page.waitForEvent("requestfailed", { + predicate: (request) => + new URL(request.url()).pathname.startsWith("/assets/main-"), + }); + await page.route("**/assets/main-*.css", (route) => route.abort()); + + await page.goto(path); + + expect((await stylesheetFailure).resourceType()).toBe("stylesheet"); + await expect(page.locator("html")).toHaveCSS("opacity", "1"); + await expect( + page.getByRole("heading", { + level: 1, + name: + path === "/" + ? "JavaScript Made Simple for Humans and AI" + : "ReScript", + exact: true, + }), + ).toBeVisible(); + }); +} diff --git a/apps/docs/styles/main.css b/apps/docs/styles/main.css index b6d69f239..fefc0ceca 100644 --- a/apps/docs/styles/main.css +++ b/apps/docs/styles/main.css @@ -60,9 +60,3 @@ body { height: 100%; min-height: 100vh; } - -/* This has to stay at the end! */ -/* This is to prevent FOUC (flash of unstyled content) */ -html { - opacity: 1; -} From 6aeded9881152a4787daa0361bd6a9615084d01f Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 18:21:06 -0400 Subject: [PATCH 3/6] test(css): migrate route and prerender checks to Cypress Preserve route stylesheet isolation, typography, navigation, foundation deduplication, and readable prerendered content without app hydration or available fonts/styles. Addresses PR #1355 comment 4054821928. --- apps/docs/e2e-playwright/homepage.spec.mjs | 194 ------------------ .../e2e-playwright/initial-content.spec.mjs | 53 ----- apps/docs/e2e-playwright/route-css.spec.mjs | 189 ----------------- apps/docs/e2e/homepage/initial-content.cy.js | 45 ++++ apps/docs/e2e/homepage/route-css.cy.js | 169 +++++++++++++++ 5 files changed, 214 insertions(+), 436 deletions(-) delete mode 100644 apps/docs/e2e-playwright/homepage.spec.mjs delete mode 100644 apps/docs/e2e-playwright/initial-content.spec.mjs delete mode 100644 apps/docs/e2e-playwright/route-css.spec.mjs create mode 100644 apps/docs/e2e/homepage/initial-content.cy.js create mode 100644 apps/docs/e2e/homepage/route-css.cy.js diff --git a/apps/docs/e2e-playwright/homepage.spec.mjs b/apps/docs/e2e-playwright/homepage.spec.mjs deleted file mode 100644 index e81892f22..000000000 --- a/apps/docs/e2e-playwright/homepage.spec.mjs +++ /dev/null @@ -1,194 +0,0 @@ -import { expect, test } from "playwright/test"; - -function observeRuntimeErrors(page) { - const errors = []; - - page.on("pageerror", (error) => errors.push(error.message)); - page.on("console", (message) => { - if (message.type() === "error") { - errors.push(message.text()); - } - }); - - return errors; -} - -function observeFailedLocalImages(page) { - const failures = []; - - page.on("response", (response) => { - const request = response.request(); - if ( - request.resourceType() === "image" && - new URL(response.url()).origin === "http://127.0.0.1:4173" && - !response.ok() - ) { - failures.push(`${response.status()} ${response.url()}`); - } - }); - - return failures; -} - -function observeFontRequests(page) { - const requests = []; - - page.on("request", (request) => { - const url = new URL(request.url()); - if ( - request.resourceType() === "font" || - url.hostname === "fonts.googleapis.com" || - url.hostname === "fonts.gstatic.com" - ) { - requests.push(url); - } - }); - - return requests; -} - -async function expectPageStyles(page) { - await expect(page.locator("body")).toHaveCSS("font-family", /^Inter,/); - await expect(page.locator("body")).toHaveCSS( - "background-color", - "rgb(255, 255, 255)", - ); - await expect(page.locator("html")).toHaveCSS("opacity", "1"); -} - -async function loadHomepageImages(page) { - const sections = page.locator("main section"); - const sectionCount = await sections.count(); - - for (let index = 0; index < sectionCount; index += 1) { - await sections.nth(index).scrollIntoViewIfNeeded(); - } - - return page.locator("img").evaluateAll(async (images) => { - await Promise.all( - images.map((image) => image.decode().catch(() => undefined)), - ); - return images - .filter((image) => image.complete && image.naturalWidth === 0) - .map((image) => image.currentSrc || image.src); - }); -} - -test("homepage hydrates with working links, fonts, and images", async ({ - page, -}) => { - const runtimeErrors = observeRuntimeErrors(page); - const failedImages = observeFailedLocalImages(page); - const fontRequests = observeFontRequests(page); - - await page.goto("/"); - await expectPageStyles(page); - - await expect( - page.getByRole("heading", { - level: 1, - name: "JavaScript Made Simple for Humans and AI", - }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { - level: 1, - name: "JavaScript Made Simple for Humans and AI", - }), - ).toHaveCSS("font-weight", "700"); - await expect - .poll(() => - page.evaluate(async () => { - await Promise.all([ - document.fonts.load('400 1rem "Homepage Inter"'), - document.fonts.load('600 1rem "Homepage Inter"'), - document.fonts.load('700 1rem "Homepage Inter"'), - document.fonts.load('700 1rem "Red Hat Mono"'), - ]); - return [ - document.fonts.check('400 1rem "Homepage Inter"'), - document.fonts.check('600 1rem "Homepage Inter"'), - document.fonts.check('700 1rem "Homepage Inter"'), - document.fonts.check('700 1rem "Red Hat Mono"'), - ]; - }), - ) - .toEqual([true, true, true, true]); - await expect( - page.getByRole("link", { name: "Get started", exact: true }), - ).toHaveAttribute("href", "/docs/manual/installation"); - await expect( - page.getByRole("link", { name: "Edit this example in Playground" }), - ).toHaveAttribute("href", /\/try\?code=.+/); - - const brokenLoadedImages = await loadHomepageImages(page); - - expect(brokenLoadedImages).toEqual([]); - expect(failedImages).toEqual([]); - expect(fontRequests.map((url) => url.pathname)).toEqual( - expect.arrayContaining([ - "/fonts/red-hat-mono-700.woff2", - "/fonts/subset-Inter-Bold.woff2", - "/fonts/subset-Inter-Regular.woff2", - "/fonts/subset-Inter-SemiBold.woff2", - ]), - ); - expect( - fontRequests.every((url) => url.origin === "http://127.0.0.1:4173"), - ).toBe(true); - expect(runtimeErrors).toEqual([]); -}); - -test("client navigation preserves homepage and documentation styles", async ({ - page, -}) => { - const runtimeErrors = observeRuntimeErrors(page); - - await page.goto("/"); - await expectPageStyles(page); - await page.getByRole("link", { name: "Docs", exact: true }).click(); - - await expect(page).toHaveURL(/\/docs\/manual\/introduction$/); - await expect( - page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), - ).toBeVisible(); - await expectPageStyles(page); - - await page.getByRole("link", { name: "homepage" }).click(); - await expect(page).toHaveURL("/"); - await expect( - page.getByRole("heading", { - level: 1, - name: "JavaScript Made Simple for Humans and AI", - }), - ).toBeVisible(); - await expectPageStyles(page); - - expect(runtimeErrors).toEqual([]); -}); - -test("mobile navigation opens the packages route", async ({ page }) => { - const runtimeErrors = observeRuntimeErrors(page); - - await page.setViewportSize({ width: 375, height: 812 }); - await page.goto("/"); - - await page.getByRole("button", { name: "Toggle additional menu" }).click(); - const packagesLink = page.getByRole("link", { - name: "Packages", - exact: true, - }); - await expect(packagesLink).toBeVisible(); - await packagesLink.click(); - - await expect(page).toHaveURL(/\/packages(?:\?search=)?$/); - await expect( - page.getByRole("heading", { - level: 1, - name: "Libraries & Bindings", - exact: true, - }), - ).toBeVisible(); - await expectPageStyles(page); - expect(runtimeErrors).toEqual([]); -}); diff --git a/apps/docs/e2e-playwright/initial-content.spec.mjs b/apps/docs/e2e-playwright/initial-content.spec.mjs deleted file mode 100644 index 3a3389008..000000000 --- a/apps/docs/e2e-playwright/initial-content.spec.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, test } from "playwright/test"; - -test.use({ javaScriptEnabled: false }); - -const introduction = - "ReScript is a strongly typed language that compiles to clean,"; - -test("homepage paragraphs render without JavaScript or downloaded fonts", async ({ - page, -}) => { - await page.route(/\.(?:woff2?|ttf|otf)(?:\?|$)/, (route) => route.abort()); - - await page.goto("/"); - - await expect(page.locator("html")).toHaveCSS("opacity", "1"); - await expect(page.getByText(introduction, { exact: false })).toBeVisible(); - await expect( - page.getByText("Its fast compiler and static type system", { - exact: false, - }), - ).toBeVisible(); - await expect(page.getByText(introduction, { exact: false })).toHaveCSS( - "color", - "rgb(105, 107, 125)", - ); -}); - -for (const path of ["/", "/docs/manual/introduction/"]) { - test(`${path} remains readable when the shared stylesheet fails`, async ({ - page, - }) => { - const stylesheetFailure = page.waitForEvent("requestfailed", { - predicate: (request) => - new URL(request.url()).pathname.startsWith("/assets/main-"), - }); - await page.route("**/assets/main-*.css", (route) => route.abort()); - - await page.goto(path); - - expect((await stylesheetFailure).resourceType()).toBe("stylesheet"); - await expect(page.locator("html")).toHaveCSS("opacity", "1"); - await expect( - page.getByRole("heading", { - level: 1, - name: - path === "/" - ? "JavaScript Made Simple for Humans and AI" - : "ReScript", - exact: true, - }), - ).toBeVisible(); - }); -} diff --git a/apps/docs/e2e-playwright/route-css.spec.mjs b/apps/docs/e2e-playwright/route-css.spec.mjs deleted file mode 100644 index b02b13c2c..000000000 --- a/apps/docs/e2e-playwright/route-css.spec.mjs +++ /dev/null @@ -1,189 +0,0 @@ -import { expect, test } from "playwright/test"; - -const homepageTitle = "JavaScript Made Simple for Humans and AI"; - -async function expectDesktopLogo(page) { - const homeLink = page.getByRole("link", { name: "homepage" }); - await expect(homeLink).toHaveCSS("width", "128px"); - await expect(homeLink).toHaveCSS("height", "40px"); - await expect( - homeLink.getByRole("img", { name: "ReScript Home" }), - ).toBeVisible(); -} - -async function loadedStyles(page) { - return page.locator('link[rel="stylesheet"]').evaluateAll(async (links) => { - const styles = await Promise.all( - links.map(async (link) => (await fetch(link.href)).text()), - ); - return styles.join("\n"); - }); -} - -async function foundationCounts(page) { - return page.evaluate(() => { - function flattenRules(rules) { - return Array.from(rules).flatMap((rule) => - "cssRules" in rule ? [rule, ...flattenRules(rule.cssRules)] : [rule], - ); - } - - const rules = Array.from(document.styleSheets).flatMap((sheet) => - flattenRules(sheet.cssRules), - ); - const fonts = rules - .filter((rule) => rule instanceof CSSFontFaceRule) - .map((rule) => rule.cssText); - const tokens = [ - "--font-sans", - "--color-gray-90", - "--color-fire", - "--text-48", - ].map( - (token) => - rules.filter( - (rule) => - rule instanceof CSSStyleRule && rule.style.getPropertyValue(token), - ).length, - ); - const resets = rules.filter( - (rule) => - rule instanceof CSSStyleRule && - rule.selectorText - .split(",") - .some((selector) => selector.trim() === "*") && - rule.style.boxSizing === "border-box", - ).length; - return { - fonts: fonts.length, - uniqueFonts: new Set(fonts).size, - tokens, - resets, - }; - }); -} - -test("homepage styles exclude content, search, and playground rules", async ({ - page, -}) => { - await page.goto("/"); - await expect( - page.getByRole("heading", { level: 1, name: homepageTitle }), - ).toBeVisible(); - - const styles = await loadedStyles(page); - - expect(styles).toContain(".gallery-selector"); - expect(styles).not.toContain(".markdown-body"); - expect(styles).not.toContain(".playground-theme"); - expect(styles).not.toContain(".DocSearch-Modal"); -}); - -test("shared foundations are emitted once across route navigation", async ({ - page, -}) => { - await page.goto("/"); - const initial = await foundationCounts(page); - expect(initial.fonts).toBeGreaterThan(0); - expect(initial.uniqueFonts).toBe(initial.fonts); - expect(initial.tokens).toEqual([1, 1, 1, 1]); - expect(initial.resets).toBe(1); - - await page.getByRole("link", { name: "Docs", exact: true }).click(); - await expect( - page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), - ).toBeVisible(); - expect(await foundationCounts(page)).toEqual(initial); - - await page.getByRole("link", { name: "homepage" }).click(); - await expect( - page.getByRole("heading", { level: 1, name: homepageTitle }), - ).toHaveCSS("font-size", "68px"); - expect(await foundationCounts(page)).toEqual(initial); -}); - -test("desktop navigation keeps its responsive logo across route stylesheets", async ({ - page, -}) => { - await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto("/"); - await expectDesktopLogo(page); - await page.getByRole("link", { name: "Docs", exact: true }).click(); - await expect( - page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), - ).toBeVisible(); - await expectDesktopLogo(page); - await page.getByRole("link", { name: "homepage" }).click(); - await expect( - page.getByRole("heading", { level: 1, name: homepageTitle }), - ).toBeVisible(); - await expectDesktopLogo(page); -}); - -test("documentation styles are prefetched only after navigation intent", async ({ - page, -}) => { - await page.goto("/"); - const contentPrefetch = page.locator( - 'link[rel="prefetch"][as="style"][href*="/content-"]', - ); - await expect(contentPrefetch).toHaveCount(0); - await page.getByRole("link", { name: "Get started", exact: true }).focus(); - await expect(contentPrefetch).toHaveCount(1); - await page.getByRole("link", { name: "Docs", exact: true }).focus(); - await expect(contentPrefetch).toHaveCount(0); -}); - -for (const route of [ - { path: "/docs/manual/introduction/", title: "ReScript" }, - { path: "/brand/", title: "Brand Assets" }, - { path: "/packages/", title: "Libraries & Bindings" }, -]) { - test(`cold ${route.path} loads its content styles`, async ({ page }) => { - await page.goto(route.path); - const heading = page.getByRole("heading", { - level: 1, - name: route.title, - exact: true, - }); - await expect(heading).toBeVisible(); - await expect(heading).toHaveCSS("font-weight", "600"); - await expect(heading).toHaveCSS("font-size", "48px"); - const styles = await loadedStyles(page); - expect(styles).toContain(".markdown-body"); - expect(styles).not.toContain(".playground-theme"); - }); -} - -test("cold blog styles preserve article typography", async ({ page }) => { - await page.goto("/blog/"); - const featuredTitle = page.getByRole("heading", { level: 2 }).first(); - await expect(featuredTitle).toBeVisible(); - await expect(featuredTitle).toHaveCSS("font-size", "48px"); - await expect(featuredTitle).toHaveCSS("font-weight", "600"); - const styles = await loadedStyles(page); - expect(styles).toContain(".markdown-body"); - expect(styles).not.toContain(".playground-theme"); -}); - -test("mobile documentation drawer retains its layout after navigation", async ({ - page, -}) => { - await page.setViewportSize({ width: 375, height: 812 }); - await page.goto("/"); - await page.getByRole("link", { name: "Docs", exact: true }).click(); - await expect( - page.getByRole("heading", { level: 1, name: "ReScript", exact: true }), - ).toBeVisible(); - await page.getByRole("button", { name: "Toggle navigation menu" }).click(); - const drawer = page.getByRole("dialog"); - await expect(drawer).toBeVisible(); - await expect(drawer).toHaveCSS("background-color", "rgb(255, 255, 255)"); - await expect(drawer).toHaveCSS("margin-left", "0px"); - await drawer.getByRole("link", { name: "Installation", exact: true }).click(); - await expect( - page.getByRole("heading", { level: 1, name: "Installation", exact: true }), - ).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(drawer).not.toBeVisible(); -}); diff --git a/apps/docs/e2e/homepage/initial-content.cy.js b/apps/docs/e2e/homepage/initial-content.cy.js new file mode 100644 index 000000000..7269f0f3c --- /dev/null +++ b/apps/docs/e2e/homepage/initial-content.cy.js @@ -0,0 +1,45 @@ +import { headline } from "./helpers.js"; + +function visitWithoutHydration(path) { + // Cypress needs JavaScript itself; empty application modules keep the AUT prerender-only. + cy.intercept(/\/assets\/[^/]+\.js(?:\?.*)?$/, (request) => { + if (/\/entry\.client-[^/]+\.js/.test(request.url)) + request.alias = "clientEntry"; + request.reply({ + statusCode: 200, + headers: { "content-type": "text/javascript" }, + body: "", + }); + }); + cy.visit(path); + cy.wait("@clientEntry").its("response.body").should("equal", ""); +} + +it("homepage paragraphs render without hydration or downloaded fonts", () => { + cy.intercept(/\.(?:woff2?|ttf|otf)(?:\?|$)/, { forceNetworkError: true }); + visitWithoutHydration("/"); + cy.get("html").should("have.css", "opacity", "1"); + cy.contains( + "p", + "ReScript is a strongly typed language that compiles to clean,", + ) + .should("be.visible") + .and("have.css", "color", "rgb(105, 107, 125)"); + cy.contains("p", "Its fast compiler and static type system").should( + "be.visible", + ); +}); + +for (const path of ["/", "/docs/manual/introduction/"]) { + it(`${path} remains readable when the shared stylesheet fails`, () => { + cy.intercept("**/assets/main-*.css", { forceNetworkError: true }).as( + "sharedStylesheet", + ); + visitWithoutHydration(path); + cy.wait("@sharedStylesheet").should("have.property", "error"); + cy.get("html").should("have.css", "opacity", "1"); + cy.contains("h1", path === "/" ? headline : /^ReScript$/).should( + "be.visible", + ); + }); +} diff --git a/apps/docs/e2e/homepage/route-css.cy.js b/apps/docs/e2e/homepage/route-css.cy.js new file mode 100644 index 000000000..1f25d54e8 --- /dev/null +++ b/apps/docs/e2e/homepage/route-css.cy.js @@ -0,0 +1,169 @@ +import { headline } from "./helpers.js"; + +function expectDesktopLogo() { + cy.get('a[aria-label="homepage"]') + .should("have.css", "width", "128px") + .and("have.css", "height", "40px"); + cy.get('a[aria-label="homepage"] img[alt="ReScript Home"]').should( + "be.visible", + ); +} + +function loadedStyles() { + return cy.window().then(async (window) => { + const links = [ + ...window.document.querySelectorAll('link[rel="stylesheet"]'), + ]; + const styles = await Promise.all( + links.map(async (link) => { + const response = await window.fetch(link.href); + expect(response.status, link.href).to.equal(200); + return response.text(); + }), + ); + return styles.join("\n"); + }); +} + +function flattenRules(rules) { + return Array.from(rules).flatMap((rule) => + "cssRules" in rule ? [rule, ...flattenRules(rule.cssRules)] : [rule], + ); +} + +function foundationCounts(window) { + const rules = Array.from(window.document.styleSheets).flatMap((sheet) => + flattenRules(sheet.cssRules), + ); + const fonts = rules + .filter((rule) => rule instanceof window.CSSFontFaceRule) + .map((rule) => rule.cssText); + const styles = rules.filter((rule) => rule instanceof window.CSSStyleRule); + const tokens = [ + "--font-sans", + "--color-gray-90", + "--color-fire", + "--text-48", + ].map( + (token) => + styles.filter((rule) => rule.style.getPropertyValue(token)).length, + ); + const resets = styles.filter( + (rule) => + rule.selectorText + .split(",") + .some((selector) => selector.trim() === "*") && + rule.style.boxSizing === "border-box", + ).length; + return { + fonts: fonts.length, + uniqueFonts: new Set(fonts).size, + tokens, + resets, + }; +} + +function expectContentStyles() { + loadedStyles().then((styles) => { + expect(styles).to.include(".markdown-body"); + expect(styles).not.to.include(".playground-theme"); + }); +} + +it("homepage styles exclude content, search, and playground rules", () => { + cy.visit("/"); + cy.contains("h1", headline).should("be.visible"); + loadedStyles().then((styles) => { + expect(styles).to.include(".gallery-selector"); + for (const selector of [ + ".markdown-body", + ".playground-theme", + ".DocSearch-Modal", + ]) { + expect(styles).not.to.include(selector); + } + }); +}); + +it("shared foundations are emitted once across route navigation", () => { + cy.visit("/"); + cy.window() + .then(foundationCounts) + .then((initial) => { + expect(initial.fonts).to.be.greaterThan(0); + expect(initial.uniqueFonts).to.equal(initial.fonts); + expect(initial.tokens).to.deep.equal([1, 1, 1, 1]); + expect(initial.resets).to.equal(1); + cy.contains("a", /^Docs$/).click(); + cy.contains("h1", /^ReScript$/).should("be.visible"); + cy.window().then(foundationCounts).should("deep.equal", initial); + cy.get('a[aria-label="homepage"]').click(); + cy.contains("h1", headline).should("have.css", "font-size", "68px"); + cy.window().then(foundationCounts).should("deep.equal", initial); + }); +}); + +it("desktop navigation keeps its responsive logo across route stylesheets", () => { + cy.visit("/"); + expectDesktopLogo(); + cy.contains("a", /^Docs$/).click(); + cy.contains("h1", /^ReScript$/).should("be.visible"); + expectDesktopLogo(); + cy.get('a[aria-label="homepage"]').click(); + cy.contains("h1", headline).should("be.visible"); + expectDesktopLogo(); +}); + +it("documentation styles are prefetched only after navigation intent", () => { + cy.visit("/"); + const prefetch = 'link[rel="prefetch"][as="style"][href*="/content-"]'; + cy.get(prefetch).should("not.exist"); + cy.contains("a", /^Get started$/).focus(); + cy.get(prefetch).should("have.length", 1); + cy.contains("a", /^Docs$/).focus(); + cy.get(prefetch).should("not.exist"); +}); + +for (const route of [ + { path: "/docs/manual/introduction/", title: "ReScript" }, + { path: "/brand/", title: "Brand Assets" }, + { path: "/packages/", title: "Libraries & Bindings" }, +]) { + it(`cold ${route.path} loads its content styles`, () => { + cy.visit(route.path); + cy.contains("h1", route.title) + .should("be.visible") + .and("have.css", "font-weight", "600") + .and("have.css", "font-size", "48px"); + expectContentStyles(); + }); +} + +it("cold blog styles preserve article typography", () => { + cy.visit("/blog/"); + cy.get("h2") + .first() + .should("be.visible") + .and("have.css", "font-size", "48px") + .and("have.css", "font-weight", "600"); + expectContentStyles(); +}); + +it("mobile documentation drawer retains its layout after navigation", () => { + cy.viewport(375, 812); + cy.visit("/"); + cy.contains("a", /^Docs$/).click(); + cy.contains("h1", /^ReScript$/).should("be.visible"); + cy.get('button[aria-label="Toggle navigation menu"]').click(); + cy.get("dialog#mobile-tertiary-drawer") + .as("drawer") + .should("be.visible") + .and("have.css", "background-color", "rgb(255, 255, 255)") + .and("have.css", "margin-left", "0px"); + cy.get("@drawer") + .contains("a", /^Installation$/) + .click(); + cy.contains("h1", /^Installation$/).should("be.visible"); + cy.realPress("Escape"); + cy.get("@drawer").should("not.be.visible"); +}); From 9abc7dfe0ffc01d6bc1f495c25a74e972c3a720e Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 18:40:35 -0400 Subject: [PATCH 4/6] fix(css): scan moved homepage components Include src/components/LandingPage modules in homepage utilities and exclude them from content utilities. Add emitted-CSS assertions covering the misplaced layout classes without changing component locations. --- apps/docs/e2e/homepage/route-css.cy.js | 13 +++++++++++++ apps/docs/styles/content.css | 1 + apps/docs/styles/homepage.css | 1 + 3 files changed, 15 insertions(+) diff --git a/apps/docs/e2e/homepage/route-css.cy.js b/apps/docs/e2e/homepage/route-css.cy.js index 1f25d54e8..a17eb3155 100644 --- a/apps/docs/e2e/homepage/route-css.cy.js +++ b/apps/docs/e2e/homepage/route-css.cy.js @@ -1,5 +1,12 @@ import { headline } from "./helpers.js"; +const homepageLayoutSelectors = [ + ".max-w-1060", + ".md\\:grid-cols-10", + ".md\\:col-span-6", + ".min-h-148", +]; + function expectDesktopLogo() { cy.get('a[aria-label="homepage"]') .should("have.css", "width", "128px") @@ -67,6 +74,9 @@ function expectContentStyles() { loadedStyles().then((styles) => { expect(styles).to.include(".markdown-body"); expect(styles).not.to.include(".playground-theme"); + for (const selector of homepageLayoutSelectors) { + expect(styles).not.to.include(selector); + } }); } @@ -75,6 +85,9 @@ it("homepage styles exclude content, search, and playground rules", () => { cy.contains("h1", headline).should("be.visible"); loadedStyles().then((styles) => { expect(styles).to.include(".gallery-selector"); + for (const selector of homepageLayoutSelectors) { + expect(styles).to.include(selector); + } for (const selector of [ ".markdown-body", ".playground-theme", diff --git a/apps/docs/styles/content.css b/apps/docs/styles/content.css index 2d1ca0f91..bee0e3cd0 100644 --- a/apps/docs/styles/content.css +++ b/apps/docs/styles/content.css @@ -8,6 +8,7 @@ @source not "../app/routes/TryRoute.{res,jsx}"; @source "../app/layouts/**/*.{res,jsx}"; @source "../src/**/*.{res,jsx,js,mjs}"; +@source not "../src/components/LandingPage*.{res,jsx}"; @source "../markdown-pages/**/*.mdx"; @source "../../../packages/shared/src/**/*.{res,jsx}"; diff --git a/apps/docs/styles/homepage.css b/apps/docs/styles/homepage.css index 9fbc8a667..675a10b53 100644 --- a/apps/docs/styles/homepage.css +++ b/apps/docs/styles/homepage.css @@ -3,6 +3,7 @@ @import "./_shared-sources.css"; @source "../app/routes/LandingPage*.{res,jsx}"; +@source "../src/components/LandingPage*.{res,jsx}"; @source "../src/components/{Footer,Button,ImageGallery,ResponsiveImage}.{res,jsx}"; .gallery-selector::after { From b8330302e915f5149e4461bc258666c6db1514c5 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 23:55:04 -0400 Subject: [PATCH 5/6] docs(css): document shared theme source Point contributors to the route-shared Tailwind token and utility file introduced by the CSS boundary split. --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 051982a20..20a6c4e85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,14 +154,14 @@ let default: unit => React.element ## Styling - **Tailwind CSS v4** configured via the Vite plugin (`@tailwindcss/vite`). There is no `tailwind.config.js`. -- All Tailwind configuration is in `styles/main.css` using CSS-native `@theme` blocks. +- Tailwind design tokens and custom utilities live in `styles/_theme.css`. It is imported by `styles/main.css` and referenced by route-specific stylesheets. - **LightningCSS** is used as the CSS transformer. -- The project defines custom design tokens in `styles/main.css`: +- The project defines custom design tokens in `styles/_theme.css`: - Custom colors: `gray-*`, `fire-*`, `sky-*`, `berry-*`, `water`, `turtle`, `orange-*` - Custom font sizes: `text-11` through `text-68` - Custom utility classes: `hl-title`, `hl-1`–`hl-5`, `body-lg`, `body-md`, `body-sm`, `captions` - Fonts: Inter (sans), Roboto Mono (mono) -- Use existing custom utilities and design tokens. Check `styles/main.css` before creating new ones. +- Use existing custom utilities and design tokens. Check `styles/_theme.css` before creating new ones. ## Testing From 9604ead6f591ba2e55a5c89e8009acbc85226662 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 10:33:15 -0400 Subject: [PATCH 6/6] test(homepage): port route CSS Cypress specs to ReScript Move the no-hydration and route stylesheet coverage into typed ReScript specs. Restrict the homepage Cypress config to generated .cy.jsx specs now that the JavaScript tests are gone. --- apps/docs/cypress.homepage.config.mjs | 2 +- apps/docs/e2e/bindings/Cypress.res | 19 ++ .../homepage/HomepageInitialContent.cy.res | 45 +++++ .../docs/e2e/homepage/HomepageRouteCss.cy.res | 183 ++++++++++++++++++ apps/docs/e2e/homepage/initial-content.cy.js | 45 ----- apps/docs/e2e/homepage/route-css.cy.js | 182 ----------------- 6 files changed, 248 insertions(+), 228 deletions(-) create mode 100644 apps/docs/e2e/homepage/HomepageInitialContent.cy.res create mode 100644 apps/docs/e2e/homepage/HomepageRouteCss.cy.res delete mode 100644 apps/docs/e2e/homepage/initial-content.cy.js delete mode 100644 apps/docs/e2e/homepage/route-css.cy.js diff --git a/apps/docs/cypress.homepage.config.mjs b/apps/docs/cypress.homepage.config.mjs index 93fe3f1b2..484a30a2f 100644 --- a/apps/docs/cypress.homepage.config.mjs +++ b/apps/docs/cypress.homepage.config.mjs @@ -9,7 +9,7 @@ export default defineConfig({ e2e: { ...config.e2e, baseUrl: "http://127.0.0.1:4173", - specPattern: ["e2e/homepage/**/*.cy.jsx", "e2e/homepage/**/*.cy.js"], + specPattern: "e2e/homepage/**/*.cy.jsx", excludeSpecPattern: [], supportFile: "e2e/homepage/HomepageSupport.jsx", screenshotOnRunFailure: true, diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index 793df1e4c..be8c3c607 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -15,6 +15,9 @@ type rec window = { } and clipboard type response = {status: int, body: string, headers: Dict.t} +type replyResponse = {statusCode: int, headers: Dict.t, body: string} +type networkError = {forceNetworkError: bool} +type fetchResponse type automation = {command: string, params?: {permissions: array, origin: string}} type request = {url: string, resourceType: string} type routeMatcher = {resourceType?: string, pathname?: string} @@ -59,6 +62,10 @@ external interceptStatic: (searchRouteMatcher, staticResponse) => chain = @val @scope("cy") external interceptPattern: (RegExp.t, request => unit) => chain = "intercept" @val @scope("cy") +external interceptFailurePattern: (RegExp.t, networkError) => chain = "intercept" +@val @scope("cy") +external interceptFailureString: (string, networkError) => chain = "intercept" +@val @scope("cy") external interceptDeferred: (RegExp.t, unit => promise) => chain = "intercept" @val @scope("cy") external interceptDeferredRequest: (routeMatcher, request => promise) => chain = @@ -110,6 +117,9 @@ external shouldProperty: (chain, @as("have.prop") _, string, string) = "should" @send external attribute: (chain, @as("attr") _, string) => chain = "invoke" @send external propertyInt: (chain<'a>, string) => chain = "its" +@send external propertyString: (chain<'a>, string) => chain = "its" +@send +external shouldPropertyExist: (chain<'a>, @as("have.property") _, string) => chain<'a> = "should" @send external shouldSatisfy: (chain<'a>, 'a => unit) => chain<'a> = "should" @send external click: chain => chain = "click" @send external first: chain => chain = "first" @@ -127,6 +137,8 @@ external containsChildRegex: (chain, string, RegExp.t) => chain int = "naturalWidth" @send external readText: clipboard => promise = "readText" @send external destroy: request => unit = "destroy" +@set external setRequestAlias: (request, string) => unit = "alias" +@send external reply: (request, replyResponse) => unit = "reply" @val external expect: ('a, ~message: string=?) => assertion = "expect" @send @scope("to") external equal: (assertion, 'a) => unit = "equal" @@ -164,6 +176,7 @@ external addEventListenerOnce: (Dom.document, string, unit => unit, listenerOpti @get external baseURI: Dom.element => string = "baseURI" @get external currentSrc: Dom.element => string = "currentSrc" @get external parentElement: Dom.element => Nullable.t = "parentElement" +@get external elementHref: Dom.element => string = "href" @send external decode: Dom.element => promise = "decode" @val @scope("Array") external elementsFrom: elementList => array = "from" @@ -183,6 +196,9 @@ external addEventListenerOnce: (Dom.document, string, unit => unit, listenerOpti @get external fontLoaded: fontFace => promise = "loaded" @send external windowRequestAnimationFrame: (window, float => unit) => int = "requestAnimationFrame" +@send external windowFetch: (window, string) => promise = "fetch" +@get external fetchStatus: fetchResponse => int = "status" +@send external responseText: fetchResponse => promise = "text" @get external styleSheets: Dom.document => styleSheetList = "styleSheets" @val @scope("Array") external styleSheetsFrom: styleSheetList => array = "from" @@ -190,7 +206,10 @@ external addEventListenerOnce: (Dom.document, string, unit => unit, listenerOpti @val @scope("Array") external rulesFrom: cssRuleList => array = "from" @get external ruleType: cssRule => int = "type" @get external nestedRules: cssRule => Nullable.t = "cssRules" +@get external cssText: cssRule => string = "cssText" +@get external selectorText: cssRule => string = "selectorText" @get external ruleStyle: cssRule => cssStyle = "style" +@get external boxSizing: cssStyle => string = "boxSizing" @get external fontFamily: cssStyle => string = "fontFamily" @get external fontWeight: cssStyle => string = "fontWeight" @get external fontStyle: cssStyle => string = "fontStyle" diff --git a/apps/docs/e2e/homepage/HomepageInitialContent.cy.res b/apps/docs/e2e/homepage/HomepageInitialContent.cy.res new file mode 100644 index 000000000..9116e5dd9 --- /dev/null +++ b/apps/docs/e2e/homepage/HomepageInitialContent.cy.res @@ -0,0 +1,45 @@ +open Cypress +open HomepageHelpers + +let visitWithoutHydration = path => { + // Cypress needs JavaScript itself; empty application modules keep the AUT prerender-only. + interceptPattern(/\/assets\/[^/]+\.js(?:\?.*)?$/, request => { + if /\/entry\.client-[^/]+\.js/->RegExp.test(request.url) { + request->setRequestAlias("clientEntry") + } + request->reply({ + statusCode: 200, + headers: Dict.fromArray([("content-type", "text/javascript")]), + body: "", + }) + })->ignore + visit(path) + wait("@clientEntry")->propertyString("response.body")->shouldEqual("")->ignore +} + +it("homepage paragraphs render without hydration or downloaded fonts", () => { + interceptFailurePattern(/\.(?:woff2?|ttf|otf)(?:\?|$)/, {forceNetworkError: true})->ignore + visitWithoutHydration("/") + get("html")->shouldCss("opacity", "1")->ignore + containsIn("p", "ReScript is a strongly typed language that compiles to clean,") + ->should("be.visible") + ->shouldCss("color", "rgb(105, 107, 125)") + ->ignore + containsIn("p", "Its fast compiler and static type system")->should("be.visible")->ignore +}) + +["/", "/docs/manual/introduction/"]->Array.forEach(path => { + it(`${path} remains readable when the shared stylesheet fails`, () => { + interceptFailureString("**/assets/main-*.css", {forceNetworkError: true}) + ->as_("sharedStylesheet") + ->ignore + visitWithoutHydration(path) + wait("@sharedStylesheet")->shouldPropertyExist("error")->ignore + get("html")->shouldCss("opacity", "1")->ignore + if path === "/" { + containsIn("h1", headline)->should("be.visible")->ignore + } else { + containsInRegex("h1", /^ReScript$/)->should("be.visible")->ignore + } + }) +}) diff --git a/apps/docs/e2e/homepage/HomepageRouteCss.cy.res b/apps/docs/e2e/homepage/HomepageRouteCss.cy.res new file mode 100644 index 000000000..1b3b679d4 --- /dev/null +++ b/apps/docs/e2e/homepage/HomepageRouteCss.cy.res @@ -0,0 +1,183 @@ +open Cypress +open HomepageHelpers + +type foundationCounts = { + fonts: int, + uniqueFonts: int, + tokens: array, + resets: int, +} + +let homepageLayoutSelectors = [ + ".max-w-1060", + ".md\\:grid-cols-10", + ".md\\:col-span-6", + ".min-h-148", +] + +let expectDesktopLogo = () => { + get(`a[aria-label="homepage"]`) + ->shouldCss("width", "128px") + ->shouldCss("height", "40px") + ->ignore + get(`a[aria-label="homepage"] img[alt="ReScript Home"]`)->should("be.visible")->ignore +} + +let loadedStyles = () => + cyWindow()->thenPromise(async window => { + let styles = await window.document + ->querySelectorAll(`link[rel="stylesheet"]`) + ->elementsFrom + ->Array.map(async link => { + let response = await window->windowFetch(link->elementHref) + expect(response->fetchStatus, ~message=link->elementHref)->equal(200) + await response->responseText + }) + ->Promise.all + styles->Array.join("\n") + }) + +let rec flattenRules = rules => + rules->Array.flatMap(rule => + switch rule->nestedRules->Nullable.toOption { + | Some(nested) => [rule]->Array.concat(nested->rulesFrom->flattenRules) + | None => [rule] + } + ) + +let foundationCounts = window => { + let rules = + window.document + ->styleSheets + ->styleSheetsFrom + ->Array.flatMap(sheet => sheet->sheetRules->rulesFrom->flattenRules) + let fonts = rules->Array.filter(rule => rule->ruleType === 5)->Array.map(rule => rule->cssText) + let styles = rules->Array.filter(rule => rule->ruleType === 1) + let tokens = + ["--font-sans", "--color-gray-90", "--color-fire", "--text-48"]->Array.map(token => + styles->Array.filter(rule => rule->ruleStyle->propertyValue(token) !== "")->Array.length + ) + let uniqueFonts = + fonts->Array.reduce([], (unique, font) => + unique->Array.includes(font) ? unique : unique->Array.concat([font]) + ) + let resets = styles->Array.filter(rule => + rule + ->selectorText + ->String.split(",") + ->Array.some(selector => selector->String.trim === "*") && + rule->ruleStyle->boxSizing === "border-box" + ) + { + fonts: fonts->Array.length, + uniqueFonts: uniqueFonts->Array.length, + tokens, + resets: resets->Array.length, + } +} + +let expectContentStyles = () => + loadedStyles()->then(styles => { + expect(styles)->include_(".markdown-body") + expect(styles)->notInclude(".playground-theme") + homepageLayoutSelectors->Array.forEach(selector => expect(styles)->notInclude(selector)) + }) + +it("homepage styles exclude content, search, and playground rules", () => { + visit("/") + containsIn("h1", headline)->should("be.visible")->ignore + loadedStyles() + ->then(styles => { + expect(styles)->include_(".gallery-selector") + homepageLayoutSelectors->Array.forEach(selector => expect(styles)->include_(selector)) + [".markdown-body", ".playground-theme", ".DocSearch-Modal"]->Array.forEach( + selector => expect(styles)->notInclude(selector), + ) + }) + ->ignore +}) + +it("shared foundations are emitted once across route navigation", () => { + visit("/") + cyWindow() + ->thenMap(foundationCounts) + ->then(initial => { + expect(initial.fonts)->greaterThan(0) + expect(initial.uniqueFonts)->equal(initial.fonts) + expect(initial.tokens)->deepEqual([1, 1, 1, 1]) + expect(initial.resets)->equal(1) + containsInRegex("a", /^Docs$/)->click->ignore + containsInRegex("h1", /^ReScript$/)->should("be.visible")->ignore + cyWindow()->thenMap(foundationCounts)->shouldDeepEqual(initial)->ignore + get(`a[aria-label="homepage"]`)->click->ignore + containsIn("h1", headline)->shouldCss("font-size", "68px")->ignore + cyWindow()->thenMap(foundationCounts)->shouldDeepEqual(initial)->ignore + }) + ->ignore +}) + +it("desktop navigation keeps its responsive logo across route stylesheets", () => { + visit("/") + expectDesktopLogo() + containsInRegex("a", /^Docs$/)->click->ignore + containsInRegex("h1", /^ReScript$/)->should("be.visible")->ignore + expectDesktopLogo() + get(`a[aria-label="homepage"]`)->click->ignore + containsIn("h1", headline)->should("be.visible")->ignore + expectDesktopLogo() +}) + +it("documentation styles are prefetched only after navigation intent", () => { + visit("/") + let prefetch = `link[rel="prefetch"][as="style"][href*="/content-"]` + get(prefetch)->should("not.exist")->ignore + containsInRegex("a", /^Get started$/)->focusElement->ignore + get(prefetch)->shouldInt("have.length", 1)->ignore + containsInRegex("a", /^Docs$/)->focusElement->ignore + get(prefetch)->should("not.exist")->ignore +}) + +[ + ("/docs/manual/introduction/", "ReScript"), + ("/brand/", "Brand Assets"), + ("/packages/", "Libraries & Bindings"), +]->Array.forEach(((path, title)) => { + it(`cold ${path} loads its content styles`, () => { + visit(path) + containsIn("h1", title) + ->should("be.visible") + ->shouldCss("font-weight", "600") + ->shouldCss("font-size", "48px") + ->ignore + expectContentStyles()->ignore + }) +}) + +it("cold blog styles preserve article typography", () => { + visit("/blog/") + get("h2") + ->first + ->should("be.visible") + ->shouldCss("font-size", "48px") + ->shouldCss("font-weight", "600") + ->ignore + expectContentStyles()->ignore +}) + +it("mobile documentation drawer retains its layout after navigation", () => { + viewport(375, 812) + visit("/") + containsInRegex("a", /^Docs$/)->click->ignore + containsInRegex("h1", /^ReScript$/)->should("be.visible")->ignore + get(`button[aria-label="Toggle navigation menu"]`)->click->ignore + get("dialog#mobile-tertiary-drawer") + ->as_("drawer") + ->should("be.visible") + ->shouldCss("background-color", "rgb(255, 255, 255)") + ->shouldCss("margin-left", "0px") + ->ignore + alias("@drawer")->containsChildRegex("a", /^Installation$/)->click->ignore + containsInRegex("h1", /^Installation$/)->should("be.visible")->ignore + realPressKey("Escape")->ignore + alias("@drawer")->should("not.be.visible")->ignore +}) diff --git a/apps/docs/e2e/homepage/initial-content.cy.js b/apps/docs/e2e/homepage/initial-content.cy.js deleted file mode 100644 index 7269f0f3c..000000000 --- a/apps/docs/e2e/homepage/initial-content.cy.js +++ /dev/null @@ -1,45 +0,0 @@ -import { headline } from "./helpers.js"; - -function visitWithoutHydration(path) { - // Cypress needs JavaScript itself; empty application modules keep the AUT prerender-only. - cy.intercept(/\/assets\/[^/]+\.js(?:\?.*)?$/, (request) => { - if (/\/entry\.client-[^/]+\.js/.test(request.url)) - request.alias = "clientEntry"; - request.reply({ - statusCode: 200, - headers: { "content-type": "text/javascript" }, - body: "", - }); - }); - cy.visit(path); - cy.wait("@clientEntry").its("response.body").should("equal", ""); -} - -it("homepage paragraphs render without hydration or downloaded fonts", () => { - cy.intercept(/\.(?:woff2?|ttf|otf)(?:\?|$)/, { forceNetworkError: true }); - visitWithoutHydration("/"); - cy.get("html").should("have.css", "opacity", "1"); - cy.contains( - "p", - "ReScript is a strongly typed language that compiles to clean,", - ) - .should("be.visible") - .and("have.css", "color", "rgb(105, 107, 125)"); - cy.contains("p", "Its fast compiler and static type system").should( - "be.visible", - ); -}); - -for (const path of ["/", "/docs/manual/introduction/"]) { - it(`${path} remains readable when the shared stylesheet fails`, () => { - cy.intercept("**/assets/main-*.css", { forceNetworkError: true }).as( - "sharedStylesheet", - ); - visitWithoutHydration(path); - cy.wait("@sharedStylesheet").should("have.property", "error"); - cy.get("html").should("have.css", "opacity", "1"); - cy.contains("h1", path === "/" ? headline : /^ReScript$/).should( - "be.visible", - ); - }); -} diff --git a/apps/docs/e2e/homepage/route-css.cy.js b/apps/docs/e2e/homepage/route-css.cy.js deleted file mode 100644 index a17eb3155..000000000 --- a/apps/docs/e2e/homepage/route-css.cy.js +++ /dev/null @@ -1,182 +0,0 @@ -import { headline } from "./helpers.js"; - -const homepageLayoutSelectors = [ - ".max-w-1060", - ".md\\:grid-cols-10", - ".md\\:col-span-6", - ".min-h-148", -]; - -function expectDesktopLogo() { - cy.get('a[aria-label="homepage"]') - .should("have.css", "width", "128px") - .and("have.css", "height", "40px"); - cy.get('a[aria-label="homepage"] img[alt="ReScript Home"]').should( - "be.visible", - ); -} - -function loadedStyles() { - return cy.window().then(async (window) => { - const links = [ - ...window.document.querySelectorAll('link[rel="stylesheet"]'), - ]; - const styles = await Promise.all( - links.map(async (link) => { - const response = await window.fetch(link.href); - expect(response.status, link.href).to.equal(200); - return response.text(); - }), - ); - return styles.join("\n"); - }); -} - -function flattenRules(rules) { - return Array.from(rules).flatMap((rule) => - "cssRules" in rule ? [rule, ...flattenRules(rule.cssRules)] : [rule], - ); -} - -function foundationCounts(window) { - const rules = Array.from(window.document.styleSheets).flatMap((sheet) => - flattenRules(sheet.cssRules), - ); - const fonts = rules - .filter((rule) => rule instanceof window.CSSFontFaceRule) - .map((rule) => rule.cssText); - const styles = rules.filter((rule) => rule instanceof window.CSSStyleRule); - const tokens = [ - "--font-sans", - "--color-gray-90", - "--color-fire", - "--text-48", - ].map( - (token) => - styles.filter((rule) => rule.style.getPropertyValue(token)).length, - ); - const resets = styles.filter( - (rule) => - rule.selectorText - .split(",") - .some((selector) => selector.trim() === "*") && - rule.style.boxSizing === "border-box", - ).length; - return { - fonts: fonts.length, - uniqueFonts: new Set(fonts).size, - tokens, - resets, - }; -} - -function expectContentStyles() { - loadedStyles().then((styles) => { - expect(styles).to.include(".markdown-body"); - expect(styles).not.to.include(".playground-theme"); - for (const selector of homepageLayoutSelectors) { - expect(styles).not.to.include(selector); - } - }); -} - -it("homepage styles exclude content, search, and playground rules", () => { - cy.visit("/"); - cy.contains("h1", headline).should("be.visible"); - loadedStyles().then((styles) => { - expect(styles).to.include(".gallery-selector"); - for (const selector of homepageLayoutSelectors) { - expect(styles).to.include(selector); - } - for (const selector of [ - ".markdown-body", - ".playground-theme", - ".DocSearch-Modal", - ]) { - expect(styles).not.to.include(selector); - } - }); -}); - -it("shared foundations are emitted once across route navigation", () => { - cy.visit("/"); - cy.window() - .then(foundationCounts) - .then((initial) => { - expect(initial.fonts).to.be.greaterThan(0); - expect(initial.uniqueFonts).to.equal(initial.fonts); - expect(initial.tokens).to.deep.equal([1, 1, 1, 1]); - expect(initial.resets).to.equal(1); - cy.contains("a", /^Docs$/).click(); - cy.contains("h1", /^ReScript$/).should("be.visible"); - cy.window().then(foundationCounts).should("deep.equal", initial); - cy.get('a[aria-label="homepage"]').click(); - cy.contains("h1", headline).should("have.css", "font-size", "68px"); - cy.window().then(foundationCounts).should("deep.equal", initial); - }); -}); - -it("desktop navigation keeps its responsive logo across route stylesheets", () => { - cy.visit("/"); - expectDesktopLogo(); - cy.contains("a", /^Docs$/).click(); - cy.contains("h1", /^ReScript$/).should("be.visible"); - expectDesktopLogo(); - cy.get('a[aria-label="homepage"]').click(); - cy.contains("h1", headline).should("be.visible"); - expectDesktopLogo(); -}); - -it("documentation styles are prefetched only after navigation intent", () => { - cy.visit("/"); - const prefetch = 'link[rel="prefetch"][as="style"][href*="/content-"]'; - cy.get(prefetch).should("not.exist"); - cy.contains("a", /^Get started$/).focus(); - cy.get(prefetch).should("have.length", 1); - cy.contains("a", /^Docs$/).focus(); - cy.get(prefetch).should("not.exist"); -}); - -for (const route of [ - { path: "/docs/manual/introduction/", title: "ReScript" }, - { path: "/brand/", title: "Brand Assets" }, - { path: "/packages/", title: "Libraries & Bindings" }, -]) { - it(`cold ${route.path} loads its content styles`, () => { - cy.visit(route.path); - cy.contains("h1", route.title) - .should("be.visible") - .and("have.css", "font-weight", "600") - .and("have.css", "font-size", "48px"); - expectContentStyles(); - }); -} - -it("cold blog styles preserve article typography", () => { - cy.visit("/blog/"); - cy.get("h2") - .first() - .should("be.visible") - .and("have.css", "font-size", "48px") - .and("have.css", "font-weight", "600"); - expectContentStyles(); -}); - -it("mobile documentation drawer retains its layout after navigation", () => { - cy.viewport(375, 812); - cy.visit("/"); - cy.contains("a", /^Docs$/).click(); - cy.contains("h1", /^ReScript$/).should("be.visible"); - cy.get('button[aria-label="Toggle navigation menu"]').click(); - cy.get("dialog#mobile-tertiary-drawer") - .as("drawer") - .should("be.visible") - .and("have.css", "background-color", "rgb(255, 255, 255)") - .and("have.css", "margin-left", "0px"); - cy.get("@drawer") - .contains("a", /^Installation$/) - .click(); - cy.contains("h1", /^Installation$/).should("be.visible"); - cy.realPress("Escape"); - cy.get("@drawer").should("not.be.visible"); -});