From 1d7e240deb2e3d0382c2eaaf200437d9a62370fd Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 12:23:39 -0400 Subject: [PATCH 1/6] refactor(homepage): simplify clipboard and gallery interactions Extract install controls, use the typed Clipboard API with declarative status, and remove clipboard and gallery effects. Replace gallery transitions with native reduced-motion-aware CSS and keyboard-accessible controls. Cover retries, feedback reset, pending writes, gallery bounds, real clipboard permission recovery, and keyboard selection. Keep existing visual baselines and record measured bundle and DOM changes. --- apps/docs/__tests__/ImageGallery_.test.res | 46 +++++ .../__tests__/LandingPageCopyButton_.test.res | 64 ++++++ .../__tests__/visual/LandingPage_.test.res | 4 - .../docs/app/routes/LandingPageCopyButton.res | 54 +++++ .../app/routes/LandingPageCopyButton.resi | 5 + .../routes/LandingPageInstallInstructions.res | 25 +++ .../LandingPageInstallInstructions.resi | 2 + .../homepage-interactions.spec.mjs | 70 +++++++ apps/docs/e2e-playwright/homepage.spec.mjs | 193 ++++++++++++++++++ apps/docs/src/common/Clipboard.res | 10 + apps/docs/src/components/ImageGallery.res | 120 ++++------- .../components/LandingPageQuickInstall.res | 121 +---------- .../components/LandingPageQuickInstall.resi | 1 + apps/docs/styles/main.css | 23 +++ apps/docs/vitest.config.mjs | 1 + packages/shared/src/Vitest.res | 12 ++ 16 files changed, 545 insertions(+), 206 deletions(-) create mode 100644 apps/docs/__tests__/ImageGallery_.test.res create mode 100644 apps/docs/__tests__/LandingPageCopyButton_.test.res create mode 100644 apps/docs/app/routes/LandingPageCopyButton.res create mode 100644 apps/docs/app/routes/LandingPageCopyButton.resi create mode 100644 apps/docs/app/routes/LandingPageInstallInstructions.res create mode 100644 apps/docs/app/routes/LandingPageInstallInstructions.resi create mode 100644 apps/docs/e2e-playwright/homepage-interactions.spec.mjs create mode 100644 apps/docs/e2e-playwright/homepage.spec.mjs create mode 100644 apps/docs/src/common/Clipboard.res diff --git a/apps/docs/__tests__/ImageGallery_.test.res b/apps/docs/__tests__/ImageGallery_.test.res new file mode 100644 index 000000000..26d21229c --- /dev/null +++ b/apps/docs/__tests__/ImageGallery_.test.res @@ -0,0 +1,46 @@ +open Vitest + +let images = ["/lp/community-3.avif", "/lp/community-2.avif", "/lp/community-1.avif"] + +test( + "gallery selectors show their photo and the next control wraps after the last photo", + async () => { + let screen = await render() + let first = await screen->getByLabelText("Show community photo 1") + let third = await screen->getByLabelText("Show community photo 3") + let next = await screen->getByLabelText("Next community photo") + + await element(first)->toHaveAttribute("aria-pressed", "true") + await third->click + await element(third)->toHaveAttribute("aria-pressed", "true") + let lastImage = await screen->getByAltText("ReScript community photo 3") + await element(lastImage)->toHaveAttribute("src", "/lp/community-1.avif") + await next->click + + await element(first)->toHaveAttribute("aria-pressed", "true") + await element(third)->toHaveAttribute("aria-pressed", "false") + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") + }, +) + +test("gallery renders no controls for an empty image list", async () => { + let screen = await render() + expect(screen->container->textContent->Nullable.toOption)->toEqual(Some("")) + let next = await screen->getByLabelText("Next community photo") + await element(next)->notToBeInTheDocument +}) + +test("gallery returns to the first image when the selected image is removed", async () => { + let screen = await render() + let third = await screen->getByLabelText("Show community photo 3") + await third->click + + await screen->rerender() + + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") + let next = await screen->getByLabelText("Next community photo") + await next->click + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") +}) diff --git a/apps/docs/__tests__/LandingPageCopyButton_.test.res b/apps/docs/__tests__/LandingPageCopyButton_.test.res new file mode 100644 index 000000000..77d2d6ddb --- /dev/null +++ b/apps/docs/__tests__/LandingPageCopyButton_.test.res @@ -0,0 +1,64 @@ +open Vitest + +test( + "copy button writes its command and clears success feedback before copying again", + async () => { + let copiedCommand = ref(None) + let writeClipboard = async code => { + copiedCommand := Some(code) + Ok() + } + let screen = await render() + let button = await screen->getByLabelText("Copy npm install rescript command") + + await button->click + + let feedback = await screen->getByText("Copied!") + await element(feedback)->toBeVisible + expect(copiedCommand.contents)->toEqual(Some("npm install rescript")) + await element(button)->toBeDisabled + await element(button)->notToBeDisabled + await element(feedback)->notToBeInTheDocument + await button->click + await element(feedback)->toBeVisible + }, +) + +test("copy button exposes clipboard failure and allows retry", async () => { + let shouldFail = ref(true) + let writeClipboard = async _ => { + if shouldFail.contents { + shouldFail := false + Error(Clipboard.WriteFailed) + } else { + Ok() + } + } + let screen = await render() + let button = await screen->getByLabelText("Copy npm install rescript command") + + await button->click + + let feedback = await screen->getByText("Could not copy. Try again.") + await element(feedback)->toBeVisible + await element(button)->notToBeDisabled + await button->click + + let copied = await screen->getByText("Copied!") + await element(copied)->toBeVisible + await element(feedback)->notToBeInTheDocument +}) + +test("copy button disables duplicate writes until the clipboard operation settles", async () => { + let complete = ref(_ => ()) + let writeClipboard = _ => Promise.make((resolve, _) => complete := resolve) + let screen = await render() + let button = await screen->getByLabelText("Copy npm install rescript command") + + await button->click + await element(button)->toBeDisabled + complete.contents(Ok()) + + let feedback = await screen->getByText("Copied!") + await element(feedback)->toBeVisible +}) diff --git a/apps/docs/__tests__/visual/LandingPage_.test.res b/apps/docs/__tests__/visual/LandingPage_.test.res index fd8d8aabc..3dc6b6e44 100644 --- a/apps/docs/__tests__/visual/LandingPage_.test.res +++ b/apps/docs/__tests__/visual/LandingPage_.test.res @@ -20,10 +20,6 @@ let snapshotSection = async (~width, ~height, ~sectionTestId, ~screenshotName) = if sectionTestId == "landing-other-selling-points" { let sourceSelector = `[data-testid="${sectionTestId}"]` await TestUtils.waitForImages(sourceSelector) - // Headless UI's appear transition mutates classes after first render. Since - // these tests snapshot a cloned outerHTML string, wait for the live section - // to settle so the clone does not preserve a transient opacity-0 state. - await TestUtils.sleep(1100) } let sandboxTestId = `${sectionTestId}-snapshot` diff --git a/apps/docs/app/routes/LandingPageCopyButton.res b/apps/docs/app/routes/LandingPageCopyButton.res new file mode 100644 index 000000000..cf2b10ad5 --- /dev/null +++ b/apps/docs/app/routes/LandingPageCopyButton.res @@ -0,0 +1,54 @@ +type state = + | Idle + | Pending + | Copied + | Failed + +@react.component +let make = (~code, ~writeClipboard=Clipboard.writeText) => { + let (state, setState) = React.useState(_ => Idle) + + let feedbackRef = React.useCallback(_ => { + let timer = setTimeout(~handler=() => setState(_ => Idle), ~timeout=2000) + Some(() => clearTimeout(timer)) + }, []) + + let copy = async () => { + setState(_ => Pending) + let result = await writeClipboard(code) + setState(_ => { + switch result { + | Ok() => Copied + | Error(Clipboard.WriteFailed) => Failed + } + }) + } + + let feedback = switch state { + | Idle | Pending => React.null + | Copied => + + {React.string("Copied!")} + + | Failed => + + {React.string("Could not copy. Try again.")} + + } + + +} diff --git a/apps/docs/app/routes/LandingPageCopyButton.resi b/apps/docs/app/routes/LandingPageCopyButton.resi new file mode 100644 index 000000000..2f0b04179 --- /dev/null +++ b/apps/docs/app/routes/LandingPageCopyButton.resi @@ -0,0 +1,5 @@ +@react.component +let make: ( + ~code: string, + ~writeClipboard: string => promise>=?, +) => React.element diff --git a/apps/docs/app/routes/LandingPageInstallInstructions.res b/apps/docs/app/routes/LandingPageInstallInstructions.res new file mode 100644 index 000000000..5c1d4d4b3 --- /dev/null +++ b/apps/docs/app/routes/LandingPageInstallInstructions.res @@ -0,0 +1,25 @@ +let copyBox = text => { +
+ {React.string(text)} + +
+} + +@react.component +let make = (~className="") => { +
+

{React.string("Quick Install")}

+
+ {React.string( + "You can quickly add ReScript to your existing JavaScript codebase via npm / yarn:", + )} +
+ {copyBox("npm install rescript")} +
+ {React.string("Or generate a new project from the official template with npx:")} +
+ {copyBox("npx create-rescript-app")} +
+} diff --git a/apps/docs/app/routes/LandingPageInstallInstructions.resi b/apps/docs/app/routes/LandingPageInstallInstructions.resi new file mode 100644 index 000000000..193c81cf3 --- /dev/null +++ b/apps/docs/app/routes/LandingPageInstallInstructions.resi @@ -0,0 +1,2 @@ +@react.component +let make: (~className: string=?) => React.element diff --git a/apps/docs/e2e-playwright/homepage-interactions.spec.mjs b/apps/docs/e2e-playwright/homepage-interactions.spec.mjs new file mode 100644 index 000000000..a7f2d1300 --- /dev/null +++ b/apps/docs/e2e-playwright/homepage-interactions.spec.mjs @@ -0,0 +1,70 @@ +import { expect, test } from "playwright/test"; + +test("community gallery supports keyboard selection and wraps to the first photo", async ({ + page, +}) => { + await page.goto("/"); + + const firstPhoto = page.getByRole("button", { + name: "Show community photo 1", + }); + const thirdPhoto = page.getByRole("button", { + name: "Show community photo 3", + }); + const nextPhoto = page.getByRole("button", { name: "Next community photo" }); + + await expect(firstPhoto).toHaveAttribute("aria-pressed", "true"); + await thirdPhoto.focus(); + await thirdPhoto.press("Enter"); + await expect(thirdPhoto).toHaveAttribute("aria-pressed", "true"); + await expect(thirdPhoto).toBeFocused(); + await expect( + page.getByRole("img", { name: "ReScript community photo 3" }), + ).toBeVisible(); + + await nextPhoto.focus(); + await nextPhoto.press("Space"); + await expect(firstPhoto).toHaveAttribute("aria-pressed", "true"); + await expect(nextPhoto).toBeFocused(); + await expect( + page.getByRole("img", { name: "ReScript community photo 1" }), + ).toBeVisible(); +}); + +test("clipboard denial can recover and both install commands can be copied repeatedly", async ({ + context, + page, +}) => { + const origin = "http://127.0.0.1:4173"; + await context.grantPermissions([], { origin }); + await page.goto("/"); + + const firstCopyButton = page.getByRole("button", { + name: "Copy npm install rescript command", + }); + await firstCopyButton.click(); + await expect( + page.getByText("Could not copy. Try again.", { exact: true }), + ).toBeVisible(); + await expect(firstCopyButton).toBeEnabled(); + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin, + }); + + for (const command of ["npm install rescript", "npx create-rescript-app"]) { + const copyButton = page.getByRole("button", { + name: `Copy ${command} command`, + }); + + await copyButton.click(); + await expect(page.getByRole("status")).toContainText(["Copied!"]); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(command); + await expect(copyButton).toBeEnabled(); + await expect(page.getByText("Copied!", { exact: true })).toHaveCount(0); + await copyButton.click(); + await expect(page.getByText("Copied!", { exact: true })).toBeVisible(); + await expect(copyButton).toBeEnabled(); + } +}); diff --git a/apps/docs/e2e-playwright/homepage.spec.mjs b/apps/docs/e2e-playwright/homepage.spec.mjs new file mode 100644 index 000000000..31b52e22a --- /dev/null +++ b/apps/docs/e2e-playwright/homepage.spec.mjs @@ -0,0 +1,193 @@ +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 + .poll(() => + page.evaluate(() => getComputedStyle(document.documentElement).opacity), + ) + .toBe("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/src/common/Clipboard.res b/apps/docs/src/common/Clipboard.res new file mode 100644 index 000000000..ea9b53b51 --- /dev/null +++ b/apps/docs/src/common/Clipboard.res @@ -0,0 +1,10 @@ +type error = WriteFailed + +let writeText = async text => { + try { + await navigator.clipboard->WebAPI.Clipboard.writeText(text) + Ok() + } catch { + | _ => Error(WriteFailed) + } +} diff --git a/apps/docs/src/components/ImageGallery.res b/apps/docs/src/components/ImageGallery.res index 3fc0c5125..02552c9a5 100644 --- a/apps/docs/src/components/ImageGallery.res +++ b/apps/docs/src/components/ImageGallery.res @@ -1,86 +1,42 @@ -type mode = - | NoAuto - | AutoFadeTransition(int) //milliseconds - @react.component -let make = ( - ~className="", - ~imgClassName="", - ~imgSrcs: array, - ~imgLoading=?, - ~mode=NoAuto, -) => { - let (index, setIndex) = React.useState(_ => 0) - - React.useEffect(() => { - switch mode { - | NoAuto => None - | AutoFadeTransition(ms) => - let timerId = setInterval2(~handler=() => { - setIndex( - prev => { - if prev === imgSrcs->Array.length - 1 { - 0 - } else { - prev + 1 - } - }, - ) - }, ~timeout=ms) - - Some( - () => { - clearInterval(timerId) - }, - ) - } - }, []) - - let src = imgSrcs->Belt.Array.getExn(index) - - let lineEls = imgSrcs->Array.mapWithIndex((src, i) => { - let bgColor = if i === index { - "bg-gray-40" - } else { - "bg-gray-70" - } - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) - - setIndex(_ => i) - } -
-
-
- }) - - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) - - setIndex(prev => { - if prev === imgSrcs->Array.length - 1 { - 0 - } else { - prev + 1 - } - }) - } -
-
- , ~imgLoading=?) => { + let (selected, setSelected) = React.useState(_ => 0) + let count = Array.length(imgSrcs) + let index = selected < count ? selected : 0 + + switch imgSrcs->Array.get(index) { + | None => React.null + | Some(src) => +
+ +
+ {imgSrcs + ->Array.mapWithIndex((src, i) => { + let color = i === index ? "text-gray-40" : "text-gray-70" +
-
{lineEls->React.array}
-
+ } } diff --git a/apps/docs/src/components/LandingPageQuickInstall.res b/apps/docs/src/components/LandingPageQuickInstall.res index b24d8f6f9..bb263356a 100644 --- a/apps/docs/src/components/LandingPageQuickInstall.res +++ b/apps/docs/src/components/LandingPageQuickInstall.res @@ -1,122 +1,3 @@ -module CopyButton = { - let copyToClipboard: string => bool = %raw(` - function(str) { - try { - const el = document.createElement('textarea'); - el.value = str; - el.setAttribute('readonly', ''); - el.style.position = 'absolute'; - el.style.left = '-9999px'; - document.body.appendChild(el); - const selected = - document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false; - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - if (selected) { - document.getSelection().removeAllRanges(); - document.getSelection().addRange(selected); - } - return true; - } catch(e) { - return false; - } - } - `) - - type state = - | Init - | Copied - | Failed - - @react.component - let make = (~code) => { - let (state, setState) = React.useState(_ => Init) - let buttonRef = React.useRef(Nullable.null) - - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) - if copyToClipboard(code) { - setState(_ => Copied) - } else { - setState(_ => Failed) - } - } - - React.useEffect(() => { - switch state { - | Copied => - let buttonEl = Nullable.toOption(buttonRef.current)->Option.getOrThrow - - let bannerEl = WebAPI.Document.createElement(document, "div") - bannerEl.className = "foobar opacity-0 absolute top-0 mt-4 -mr-1 px-2 rounded right-0 - bg-turtle text-gray-80-tr body-sm - transition-all duration-500 ease-in-out " - let textNode = WebAPI.Document.createTextNode(document, "Copied!") - - WebAPI.Element.appendChild(bannerEl, textNode)->ignore - WebAPI.Element.appendChild(buttonEl, bannerEl)->ignore - - let nextFrameId = WebAPI.Window.requestAnimationFrame(window, _ => { - WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-0")->ignore - WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-100")->ignore - }) - - let timeoutId = setTimeout(~handler=() => { - buttonEl->WebAPI.Element.removeChild(bannerEl)->ignore - setState(_ => Init) - }, ~timeout=2000) - - Some( - () => { - cancelAnimationFrame(nextFrameId) - clearTimeout(timeoutId) - }, - ) - | _ => None - } - }, [state]) - - - } -} - -module Instructions = { - let copyBox = text => { -
- {React.string(text)} - -
- } - - @react.component - let make = (~className: string="") => { -
-

{React.string("Quick Install")}

-
- {React.string( - "You can quickly add ReScript to your existing JavaScript codebase via npm / yarn:", - )} -
- {copyBox("npm install rescript")} -
- {React.string("Or generate a new project from the official template with npx:")} -
- {copyBox("npx create-rescript-app")} -
- } -} - @react.component let make = () => {
@@ -135,7 +16,7 @@ let make = () => { > {React.string(`ReScript is used to ship and maintain mission-critical products with good UI and UX.`)}

- +
diff --git a/apps/docs/src/components/LandingPageQuickInstall.resi b/apps/docs/src/components/LandingPageQuickInstall.resi index 1ca44ce26..9d792611d 100644 --- a/apps/docs/src/components/LandingPageQuickInstall.resi +++ b/apps/docs/src/components/LandingPageQuickInstall.resi @@ -1,2 +1,3 @@ +/** Presentational install section with event-driven clipboard controls. */ @react.component let make: unit => React.element diff --git a/apps/docs/styles/main.css b/apps/docs/styles/main.css index ac10e3e5c..b538e8db8 100644 --- a/apps/docs/styles/main.css +++ b/apps/docs/styles/main.css @@ -569,6 +569,29 @@ 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%; diff --git a/apps/docs/vitest.config.mjs b/apps/docs/vitest.config.mjs index 7bd863a6a..c39aeb9fa 100644 --- a/apps/docs/vitest.config.mjs +++ b/apps/docs/vitest.config.mjs @@ -46,6 +46,7 @@ export default defineConfig({ provider: playwright({ contextOptions: { deviceScaleFactor: 1, + permissions: ["clipboard-read", "clipboard-write"], }, }), ui: false, diff --git a/packages/shared/src/Vitest.res b/packages/shared/src/Vitest.res index 2555d707b..1135b0bd2 100644 --- a/packages/shared/src/Vitest.res +++ b/packages/shared/src/Vitest.res @@ -33,6 +33,9 @@ external render: Jsx.element => promise = "render" @send external unmount: element => promise = "unmount" +@send +external rerender: (element, Jsx.element) => promise = "rerender" + @module("vitest") @scope("expect") external element: 'a => element = "element" @@ -51,6 +54,9 @@ external getByTextWithOptions: (element, string, {"exact": bool}) => promise promise = "getByLabelText" +@send +external getByAltText: (element, string) => promise = "getByAltText" + @send external getAllByLabelText: (element, string) => promise> = "getAllByLabelText" @@ -84,6 +90,9 @@ external toBeVisible: element => promise = "toBeVisible" @send @scope("not") external notToBeVisible: element => promise = "toBeVisible" +@send @scope("not") +external notToBeInTheDocument: element => promise = "toBeInTheDocument" + @send external toBeDisabled: element => promise = "toBeDisabled" @@ -99,6 +108,9 @@ external toHaveTextContent: (element, string) => promise = "toHaveTextCont @send external toHaveClass: (element, string) => promise = "toHaveClass" +@send +external toHaveAttribute: (element, string, string) => promise = "toHaveAttribute" + @send external toMatchScreenshot: (element, string) => promise = "toMatchScreenshot" From fd52dc17623ff4f1f3e77bb134216a1d634315a8 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 13:02:23 -0400 Subject: [PATCH 2/6] fix(homepage): reset removed gallery selections Persist the first-photo fallback when the selected index disappears, so shrinking or emptying the list cannot revive stale selection on regrowth. Cover both transitions without introducing an effect. Addresses PR #1362 review comment 4053846691. --- apps/docs/__tests__/ImageGallery_.test.res | 35 ++++++++++++++++++++++ apps/docs/src/components/ImageGallery.res | 4 +++ 2 files changed, 39 insertions(+) diff --git a/apps/docs/__tests__/ImageGallery_.test.res b/apps/docs/__tests__/ImageGallery_.test.res index 26d21229c..3366b6da8 100644 --- a/apps/docs/__tests__/ImageGallery_.test.res +++ b/apps/docs/__tests__/ImageGallery_.test.res @@ -44,3 +44,38 @@ test("gallery returns to the first image when the selected image is removed", as await next->click await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") }) + +test("gallery keeps the first image selected when a shortened list grows again", async () => { + let screen = await render() + let third = await screen->getByLabelText("Show community photo 3") + await third->click + + await screen->rerender() + let first = await screen->getByLabelText("Show community photo 1") + await element(first)->toHaveAttribute("aria-pressed", "true") + + await screen->rerender() + + await element(first)->toHaveAttribute("aria-pressed", "true") + await element(third)->toHaveAttribute("aria-pressed", "false") + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") +}) + +test("gallery keeps the first image selected after an empty list is restored", async () => { + let screen = await render() + let third = await screen->getByLabelText("Show community photo 3") + await third->click + + await screen->rerender() + let next = await screen->getByLabelText("Next community photo") + await element(next)->notToBeInTheDocument + + await screen->rerender() + + let first = await screen->getByLabelText("Show community photo 1") + await element(first)->toHaveAttribute("aria-pressed", "true") + await element(third)->toHaveAttribute("aria-pressed", "false") + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") +}) diff --git a/apps/docs/src/components/ImageGallery.res b/apps/docs/src/components/ImageGallery.res index 02552c9a5..c74b5f7f3 100644 --- a/apps/docs/src/components/ImageGallery.res +++ b/apps/docs/src/components/ImageGallery.res @@ -4,6 +4,10 @@ let make = (~className="", ~imgClassName="", ~imgSrcs: array, ~imgLoadin let count = Array.length(imgSrcs) let index = selected < count ? selected : 0 + if selected !== index { + setSelected(_ => index) + } + switch imgSrcs->Array.get(index) { | None => React.null | Some(src) => From 8782a08b7f80f1147892bc01fcd3f35891a21f7b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 13:03:25 -0400 Subject: [PATCH 3/6] fix(homepage): expose copy feedback outside buttons Keep persistent live regions beside the copy controls, independent of their disabled state. Reuse the existing positioned copy box and keep noninteractive feedback click-through so clipboard failure can be retried without adding DOM wrappers. Extend the real-browser clipboard flow to require sibling status regions and exposed failure and disabled-success feedback. Addresses PR #1362 review comment 4053846682. --- .../docs/app/routes/LandingPageCopyButton.res | 22 ++++++++++--------- .../routes/LandingPageInstallInstructions.res | 2 +- .../homepage-interactions.spec.mjs | 6 +++++ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/docs/app/routes/LandingPageCopyButton.res b/apps/docs/app/routes/LandingPageCopyButton.res index cf2b10ad5..a65b87e35 100644 --- a/apps/docs/app/routes/LandingPageCopyButton.res +++ b/apps/docs/app/routes/LandingPageCopyButton.res @@ -41,14 +41,16 @@ let make = (~code, ~writeClipboard=Clipboard.writeText) => { } - + <> + + feedback + } diff --git a/apps/docs/app/routes/LandingPageInstallInstructions.res b/apps/docs/app/routes/LandingPageInstallInstructions.res index 5c1d4d4b3..f3e4d0562 100644 --- a/apps/docs/app/routes/LandingPageInstallInstructions.res +++ b/apps/docs/app/routes/LandingPageInstallInstructions.res @@ -1,6 +1,6 @@ let copyBox = text => {
{React.string(text)} diff --git a/apps/docs/e2e-playwright/homepage-interactions.spec.mjs b/apps/docs/e2e-playwright/homepage-interactions.spec.mjs index a7f2d1300..c008634bb 100644 --- a/apps/docs/e2e-playwright/homepage-interactions.spec.mjs +++ b/apps/docs/e2e-playwright/homepage-interactions.spec.mjs @@ -42,7 +42,12 @@ test("clipboard denial can recover and both install commands can be copied repea const firstCopyButton = page.getByRole("button", { name: "Copy npm install rescript command", }); + await expect(page.getByRole("status")).toHaveCount(2); + await expect(page.getByRole("button").getByRole("status")).toHaveCount(0); await firstCopyButton.click(); + await expect(page.getByRole("status")).toContainText([ + "Could not copy. Try again.", + ]); await expect( page.getByText("Could not copy. Try again.", { exact: true }), ).toBeVisible(); @@ -58,6 +63,7 @@ test("clipboard denial can recover and both install commands can be copied repea await copyButton.click(); await expect(page.getByRole("status")).toContainText(["Copied!"]); + await expect(copyButton).toBeDisabled(); await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toBe(command); From f13db74f7a65314569f1964d63bbfc68dc3e8067 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 18:09:33 -0400 Subject: [PATCH 4/6] test(interactions): migrate homepage flows to Cypress Exercise native keyboard gallery selection, clipboard rejection and recovery, and repeated copying of both install commands. Addresses PR #1355 comment 4054821928. --- .../homepage-interactions.spec.mjs | 76 ------- apps/docs/e2e-playwright/homepage.spec.mjs | 193 ------------------ apps/docs/e2e/homepage/Homepage.cy.res | 8 +- .../e2e/homepage/homepage-interactions.cy.js | 57 ++++++ 4 files changed, 58 insertions(+), 276 deletions(-) delete mode 100644 apps/docs/e2e-playwright/homepage-interactions.spec.mjs delete mode 100644 apps/docs/e2e-playwright/homepage.spec.mjs create mode 100644 apps/docs/e2e/homepage/homepage-interactions.cy.js diff --git a/apps/docs/e2e-playwright/homepage-interactions.spec.mjs b/apps/docs/e2e-playwright/homepage-interactions.spec.mjs deleted file mode 100644 index c008634bb..000000000 --- a/apps/docs/e2e-playwright/homepage-interactions.spec.mjs +++ /dev/null @@ -1,76 +0,0 @@ -import { expect, test } from "playwright/test"; - -test("community gallery supports keyboard selection and wraps to the first photo", async ({ - page, -}) => { - await page.goto("/"); - - const firstPhoto = page.getByRole("button", { - name: "Show community photo 1", - }); - const thirdPhoto = page.getByRole("button", { - name: "Show community photo 3", - }); - const nextPhoto = page.getByRole("button", { name: "Next community photo" }); - - await expect(firstPhoto).toHaveAttribute("aria-pressed", "true"); - await thirdPhoto.focus(); - await thirdPhoto.press("Enter"); - await expect(thirdPhoto).toHaveAttribute("aria-pressed", "true"); - await expect(thirdPhoto).toBeFocused(); - await expect( - page.getByRole("img", { name: "ReScript community photo 3" }), - ).toBeVisible(); - - await nextPhoto.focus(); - await nextPhoto.press("Space"); - await expect(firstPhoto).toHaveAttribute("aria-pressed", "true"); - await expect(nextPhoto).toBeFocused(); - await expect( - page.getByRole("img", { name: "ReScript community photo 1" }), - ).toBeVisible(); -}); - -test("clipboard denial can recover and both install commands can be copied repeatedly", async ({ - context, - page, -}) => { - const origin = "http://127.0.0.1:4173"; - await context.grantPermissions([], { origin }); - await page.goto("/"); - - const firstCopyButton = page.getByRole("button", { - name: "Copy npm install rescript command", - }); - await expect(page.getByRole("status")).toHaveCount(2); - await expect(page.getByRole("button").getByRole("status")).toHaveCount(0); - await firstCopyButton.click(); - await expect(page.getByRole("status")).toContainText([ - "Could not copy. Try again.", - ]); - await expect( - page.getByText("Could not copy. Try again.", { exact: true }), - ).toBeVisible(); - await expect(firstCopyButton).toBeEnabled(); - await context.grantPermissions(["clipboard-read", "clipboard-write"], { - origin, - }); - - for (const command of ["npm install rescript", "npx create-rescript-app"]) { - const copyButton = page.getByRole("button", { - name: `Copy ${command} command`, - }); - - await copyButton.click(); - await expect(page.getByRole("status")).toContainText(["Copied!"]); - await expect(copyButton).toBeDisabled(); - await expect - .poll(() => page.evaluate(() => navigator.clipboard.readText())) - .toBe(command); - await expect(copyButton).toBeEnabled(); - await expect(page.getByText("Copied!", { exact: true })).toHaveCount(0); - await copyButton.click(); - await expect(page.getByText("Copied!", { exact: true })).toBeVisible(); - await expect(copyButton).toBeEnabled(); - } -}); diff --git a/apps/docs/e2e-playwright/homepage.spec.mjs b/apps/docs/e2e-playwright/homepage.spec.mjs deleted file mode 100644 index 31b52e22a..000000000 --- a/apps/docs/e2e-playwright/homepage.spec.mjs +++ /dev/null @@ -1,193 +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 - .poll(() => - page.evaluate(() => getComputedStyle(document.documentElement).opacity), - ) - .toBe("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/homepage/Homepage.cy.res b/apps/docs/e2e/homepage/Homepage.cy.res index 07b5ba42a..b283c84d1 100644 --- a/apps/docs/e2e/homepage/Homepage.cy.res +++ b/apps/docs/e2e/homepage/Homepage.cy.res @@ -1,8 +1,7 @@ open Cypress open HomepageHelpers -it("homepage hydrates with working links and copy feedback", () => { - grantClipboardPermissions() +it("homepage hydrates with working links and images", () => { visit("/") containsIn("h1", headline)->should("be.visible")->ignore containsIn("a", "Get started")->shouldAttribute("href", "/docs/manual/installation")->ignore @@ -10,11 +9,6 @@ it("homepage hydrates with working links and copy feedback", () => { ->attribute("href") ->shouldMatch(/\/try\?code=.+/) ->ignore - get(`button[aria-label="Copy npm install rescript command"]`) - ->realClick({scrollBehavior: "center"}) - ->ignore - contains("Copied!")->should("be.visible")->ignore - readClipboard()->shouldEqual("npm install rescript")->ignore get("img") ->each(image => { wrap(image) diff --git a/apps/docs/e2e/homepage/homepage-interactions.cy.js b/apps/docs/e2e/homepage/homepage-interactions.cy.js new file mode 100644 index 000000000..b69452c5b --- /dev/null +++ b/apps/docs/e2e/homepage/homepage-interactions.cy.js @@ -0,0 +1,57 @@ +import { + grantClipboardPermissions, + headline, + readClipboard, +} from "./helpers.js"; + +it("community gallery supports keyboard selection and wraps to the first photo", () => { + cy.visit("/"); + cy.contains("h1", headline).realClick(); + const first = 'button[aria-label="Show community photo 1"]'; + const third = 'button[aria-label="Show community photo 3"]'; + const next = 'button[aria-label="Next community photo"]'; + + cy.get(first).should("have.attr", "aria-pressed", "true"); + cy.get(third).scrollIntoView().focus().realPress("Enter"); + cy.get(third).should("have.attr", "aria-pressed", "true").and("be.focused"); + cy.get('img[alt="ReScript community photo 3"]').should("be.visible"); + cy.get(next).focus().realPress("Space"); + cy.get(first).should("have.attr", "aria-pressed", "true"); + cy.get(next).should("be.focused"); + cy.get('img[alt="ReScript community photo 1"]').should("be.visible"); +}); + +it("clipboard denial can recover and both install commands can be copied repeatedly", () => { + cy.then(() => + Cypress.automation("remote:debugger:protocol", { + command: "Browser.grantPermissions", + params: { + permissions: [], + origin: new URL(Cypress.config("baseUrl")).origin, + }, + }), + ); + cy.visit("/"); + const first = 'button[aria-label="Copy npm install rescript command"]'; + cy.get('[role="status"]').should("have.length", 2); + cy.get('button [role="status"]').should("not.exist"); + cy.get(first).realClick(); + cy.contains('[role="status"]', "Could not copy. Try again.").should( + "be.visible", + ); + cy.get(first).should("be.enabled"); + grantClipboardPermissions(); + + for (const command of ["npm install rescript", "npx create-rescript-app"]) { + const button = `button[aria-label="Copy ${command} command"]`; + cy.get(button).realClick(); + cy.contains('[role="status"]', "Copied!").should("be.visible"); + cy.get(button).should("be.disabled"); + readClipboard().should("equal", command); + cy.get(button).should("be.enabled"); + cy.contains("Copied!").should("not.exist"); + cy.get(button).realClick(); + cy.contains("Copied!").should("be.visible"); + cy.get(button).should("be.enabled"); + } +}); From af78c4785ce80add35fa1db6b31d83e09d48249b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 08:52:52 -0400 Subject: [PATCH 5/6] test(interactions): port Cypress spec to ReScript Replace the handwritten homepage interaction spec with a typed ReScript source and add the minimal Cypress bindings it requires. --- apps/docs/e2e/bindings/Cypress.res | 7 ++- apps/docs/e2e/homepage/HomepageHelpers.res | 9 +++ .../e2e/homepage/HomepageInteractions.cy.res | 44 ++++++++++++++ .../e2e/homepage/homepage-interactions.cy.js | 57 ------------------- 4 files changed, 57 insertions(+), 60 deletions(-) create mode 100644 apps/docs/e2e/homepage/HomepageInteractions.cy.res delete mode 100644 apps/docs/e2e/homepage/homepage-interactions.cy.js diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index ce22fada1..cf6730b36 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -7,7 +7,6 @@ type rec window = {document: Dom.document, console: console, navigator: {clipboa and clipboard type response = {status: int, body: string} type automation = {command: string, params?: {permissions: array, origin: string}} -type clickOptions = {scrollBehavior: string} type request = {url: string, resourceType: string} type routeMatcher = {resourceType?: string, pathname?: string} type url @@ -60,6 +59,7 @@ external shouldCss: (chain, @as("have.css") _, string, string) => chai "should" @send external shouldCssProperty: (chain, @as("have.css") _, string) => chain = "should" +@send external shouldInt: (chain<'a>, string, int) => chain<'a> = "should" @send external shouldEqual: (chain<'a>, @as("equal") _, 'a) => chain<'a> = "should" @send external shouldDeepEqual: (chain<'a>, @as("deep.equal") _, 'a) => chain<'a> = "should" @send external shouldMatch: (chain<'a>, @as("match") _, RegExp.t) => chain<'a> = "should" @@ -72,8 +72,9 @@ external shouldAttribute: (chain, @as("have.attr") _, string, string) @send external propertyInt: (chain<'a>, string) => chain = "its" @send external shouldSatisfy: (chain<'a>, 'a => unit) => chain<'a> = "should" @send external click: chain => chain = "click" -@send -external realClick: (chain, clickOptions) => chain = "realClick" +@send external focusElement: chain => chain = "focus" +@send external realClick: chain => chain = "realClick" +@send external realPress: (chain, string) => chain = "realPress" @send external scrollIntoView: chain => chain = "scrollIntoView" @send external each: (chain, elements => unit) => chain = "each" @send external as_: (chain<'a>, string) => chain<'a> = "as" diff --git a/apps/docs/e2e/homepage/HomepageHelpers.res b/apps/docs/e2e/homepage/HomepageHelpers.res index e016dbb5f..37a569db1 100644 --- a/apps/docs/e2e/homepage/HomepageHelpers.res +++ b/apps/docs/e2e/homepage/HomepageHelpers.res @@ -14,6 +14,15 @@ let grantClipboardPermissions = () => { )->ignore } +let denyClipboardPermissions = () => { + run(() => + automate({ + command: "Browser.grantPermissions", + params: {permissions: [], origin: baseUrl()}, + }) + )->ignore +} + let readClipboard = () => cyWindow()->thenPromise(window => window.navigator.clipboard->readText) let homepageDocument = callback => { diff --git a/apps/docs/e2e/homepage/HomepageInteractions.cy.res b/apps/docs/e2e/homepage/HomepageInteractions.cy.res new file mode 100644 index 000000000..a5e242d8e --- /dev/null +++ b/apps/docs/e2e/homepage/HomepageInteractions.cy.res @@ -0,0 +1,44 @@ +open Cypress +open HomepageHelpers + +it("community gallery supports keyboard selection and wraps to the first photo", () => { + visit("/") + containsIn("h1", headline)->realClick->ignore + let first = `button[aria-label="Show community photo 1"]` + let third = `button[aria-label="Show community photo 3"]` + let next = `button[aria-label="Next community photo"]` + + get(first)->shouldAttribute("aria-pressed", "true")->ignore + get(third)->scrollIntoView->focusElement->realPress("Enter")->ignore + get(third)->shouldAttribute("aria-pressed", "true")->should("be.focused")->ignore + get(`img[alt="ReScript community photo 3"]`)->should("be.visible")->ignore + get(next)->focusElement->realPress("Space")->ignore + get(first)->shouldAttribute("aria-pressed", "true")->ignore + get(next)->should("be.focused")->ignore + get(`img[alt="ReScript community photo 1"]`)->should("be.visible")->ignore +}) + +it("clipboard denial can recover and both install commands can be copied repeatedly", () => { + denyClipboardPermissions() + visit("/") + let first = `button[aria-label="Copy npm install rescript command"]` + get(`[role="status"]`)->shouldInt("have.length", 2)->ignore + get(`button [role="status"]`)->should("not.exist")->ignore + get(first)->realClick->ignore + containsIn(`[role="status"]`, "Could not copy. Try again.")->should("be.visible")->ignore + get(first)->should("be.enabled")->ignore + grantClipboardPermissions() + + ["npm install rescript", "npx create-rescript-app"]->Array.forEach(command => { + let button = `button[aria-label="Copy ${command} command"]` + get(button)->realClick->ignore + containsIn(`[role="status"]`, "Copied!")->should("be.visible")->ignore + get(button)->should("be.disabled")->ignore + readClipboard()->shouldEqual(command)->ignore + get(button)->should("be.enabled")->ignore + contains("Copied!")->should("not.exist")->ignore + get(button)->realClick->ignore + contains("Copied!")->should("be.visible")->ignore + get(button)->should("be.enabled")->ignore + }) +}) diff --git a/apps/docs/e2e/homepage/homepage-interactions.cy.js b/apps/docs/e2e/homepage/homepage-interactions.cy.js deleted file mode 100644 index b69452c5b..000000000 --- a/apps/docs/e2e/homepage/homepage-interactions.cy.js +++ /dev/null @@ -1,57 +0,0 @@ -import { - grantClipboardPermissions, - headline, - readClipboard, -} from "./helpers.js"; - -it("community gallery supports keyboard selection and wraps to the first photo", () => { - cy.visit("/"); - cy.contains("h1", headline).realClick(); - const first = 'button[aria-label="Show community photo 1"]'; - const third = 'button[aria-label="Show community photo 3"]'; - const next = 'button[aria-label="Next community photo"]'; - - cy.get(first).should("have.attr", "aria-pressed", "true"); - cy.get(third).scrollIntoView().focus().realPress("Enter"); - cy.get(third).should("have.attr", "aria-pressed", "true").and("be.focused"); - cy.get('img[alt="ReScript community photo 3"]').should("be.visible"); - cy.get(next).focus().realPress("Space"); - cy.get(first).should("have.attr", "aria-pressed", "true"); - cy.get(next).should("be.focused"); - cy.get('img[alt="ReScript community photo 1"]').should("be.visible"); -}); - -it("clipboard denial can recover and both install commands can be copied repeatedly", () => { - cy.then(() => - Cypress.automation("remote:debugger:protocol", { - command: "Browser.grantPermissions", - params: { - permissions: [], - origin: new URL(Cypress.config("baseUrl")).origin, - }, - }), - ); - cy.visit("/"); - const first = 'button[aria-label="Copy npm install rescript command"]'; - cy.get('[role="status"]').should("have.length", 2); - cy.get('button [role="status"]').should("not.exist"); - cy.get(first).realClick(); - cy.contains('[role="status"]', "Could not copy. Try again.").should( - "be.visible", - ); - cy.get(first).should("be.enabled"); - grantClipboardPermissions(); - - for (const command of ["npm install rescript", "npx create-rescript-app"]) { - const button = `button[aria-label="Copy ${command} command"]`; - cy.get(button).realClick(); - cy.contains('[role="status"]', "Copied!").should("be.visible"); - cy.get(button).should("be.disabled"); - readClipboard().should("equal", command); - cy.get(button).should("be.enabled"); - cy.contains("Copied!").should("not.exist"); - cy.get(button).realClick(); - cy.contains("Copied!").should("be.visible"); - cy.get(button).should("be.enabled"); - } -}); From b64eccc04a50249ee03575fe3fc7eb3bffcdc01d Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 09:22:16 -0400 Subject: [PATCH 6/6] refactor(homepage): move interaction components to src Keep app/routes limited to React Router route modules for the homepage interaction files introduced in this layer. --- .../docs/{app/routes => src/components}/LandingPageCopyButton.res | 0 .../{app/routes => src/components}/LandingPageCopyButton.resi | 0 .../routes => src/components}/LandingPageInstallInstructions.res | 0 .../routes => src/components}/LandingPageInstallInstructions.resi | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename apps/docs/{app/routes => src/components}/LandingPageCopyButton.res (100%) rename apps/docs/{app/routes => src/components}/LandingPageCopyButton.resi (100%) rename apps/docs/{app/routes => src/components}/LandingPageInstallInstructions.res (100%) rename apps/docs/{app/routes => src/components}/LandingPageInstallInstructions.resi (100%) diff --git a/apps/docs/app/routes/LandingPageCopyButton.res b/apps/docs/src/components/LandingPageCopyButton.res similarity index 100% rename from apps/docs/app/routes/LandingPageCopyButton.res rename to apps/docs/src/components/LandingPageCopyButton.res diff --git a/apps/docs/app/routes/LandingPageCopyButton.resi b/apps/docs/src/components/LandingPageCopyButton.resi similarity index 100% rename from apps/docs/app/routes/LandingPageCopyButton.resi rename to apps/docs/src/components/LandingPageCopyButton.resi diff --git a/apps/docs/app/routes/LandingPageInstallInstructions.res b/apps/docs/src/components/LandingPageInstallInstructions.res similarity index 100% rename from apps/docs/app/routes/LandingPageInstallInstructions.res rename to apps/docs/src/components/LandingPageInstallInstructions.res diff --git a/apps/docs/app/routes/LandingPageInstallInstructions.resi b/apps/docs/src/components/LandingPageInstallInstructions.resi similarity index 100% rename from apps/docs/app/routes/LandingPageInstallInstructions.resi rename to apps/docs/src/components/LandingPageInstallInstructions.resi