diff --git a/.github/actions/link-package/action.yml b/.github/actions/link-package/action.yml
index 39eb2c3..2871d26 100644
--- a/.github/actions/link-package/action.yml
+++ b/.github/actions/link-package/action.yml
@@ -49,8 +49,13 @@ runs:
working-directory: external-pkg
- name: Link JupyterLab and external package 🔗
+ # Note (--all): you must add the `--all` suffix to the yarn (jlpm) link
+ # command to link in all sub-packages of a monorepo. When this action was
+ # run without the --all option, a test failed that should have passed
+ # because some changes in a Lumino sub-package were not incorporated into
+ # the JupyterLab build.
run: |
- conda run --prefix $CONDA_PREFIX yarn link "${{ github.workspace }}/external-pkg" -p
+ conda run --prefix $CONDA_PREFIX jlpm link "${{ github.workspace }}/external-pkg" --private --all
conda run --prefix $CONDA_PREFIX jlpm run build
shell: bash -l {0}
working-directory: jupyterlab
diff --git a/testing/jupyterlab/manual-testing-scripts/visible-focus-indicator-initial-page.md b/testing/jupyterlab/manual-testing-scripts/visible-focus-indicator-initial-page.md
new file mode 100644
index 0000000..d615b2c
--- /dev/null
+++ b/testing/jupyterlab/manual-testing-scripts/visible-focus-indicator-initial-page.md
@@ -0,0 +1,68 @@
+# Visible Focus Indicator on Initial Page
+
+## Description
+
+When a user loads JupyterLab, if they use the tab key to navigate the page, each
+tab-focussable element on the page should have a visible indicator when it
+receives focus.
+
+## Applicability
+
+Which apps does this test currently apply to?
+
+- JupyterLab
+
+## Related GitHub PRs
+
+These PRs were needed to make this test pass:
+
+[Make focus visible (mostly CSS)
+#13415](https://github.com/jupyterlab/jupyterlab/pull/13415)
+
+## Related GitHub Issues
+
+[JupyterLab #9399](https://github.com/jupyterlab/jupyterlab/pull/9399) - in the
+PR description, look under "Focus", Issue Area #2.
+
+## Related Accessibility Guidelines
+
+[Understanding WCAG 2.4.7: Focus
+Visible](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html)
+
+## How to Interpret Test Results
+
+The test only checks for the existence of a focus indicator on each tab-focussed
+element. It does not check whether or not the focus indicator is a **good**
+focus indicator. For example, the test does not check the color contrast of the
+focus indicator.
+
+The test checks for a focus indicator by taking screenshots of each
+tab-focussable element both before and after the element is focussed, then
+comparing the screenshots. A single pixel difference will cause the test to
+pass. This is why the test cannot tell you if the focus indicator is good; it
+can only tell you if the visual area around the element changes when it has
+focus versus when it does not.
+
+So a test failure means that the app under test fails the WCAG 2.4.7 success
+criteria (assuming no bugs in the test itself), but a test success does not mean
+that the app fulfills the spirit of the guideline, which is to make it easy for
+all sighted users to know which element on the page has browser focus.
+
+## How to Perform the Test Manually
+
+1. Open JupyterLab in a fresh environment. Another way to say this is that you
+ should have the following parts visible: top menu bar, left side panel with
+ file browser open, the launcher in the main area, right side panel closed,
+ and status bar. Here's a screenshot: ![screenshot of JupyterLab initial
+page](assets/no-tab-trap-initial-page/jupyterlab-initial-page.png)
+2. Press the TAB key repeatedly.
+3. Each time you press the TAB key, you should be able to clearly see
+ which element (whether it is a link, a button, or some other element), has
+ focus.
+4. Continue pressing the TAB key and checking for focus indicator
+ until you cycle back around.
+5. If at any point, you lost track of which element had focus, then the test
+ fails. Another way to say this is that if at any point you pressed the
+ TAB key and you could not find the element which had focus, then
+ it means that the focus indicator was not visible (or maybe not visible
+ enough).
diff --git a/testing/jupyterlab/tests/regression-tests/visible-focus-indicator-initial-page.test.ts b/testing/jupyterlab/tests/regression-tests/visible-focus-indicator-initial-page.test.ts
new file mode 100644
index 0000000..669dc5a
--- /dev/null
+++ b/testing/jupyterlab/tests/regression-tests/visible-focus-indicator-initial-page.test.ts
@@ -0,0 +1,135 @@
+// Copyright (c) Jupyter Accessibility Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { ElementHandle, expect, Page } from "@playwright/test";
+import { test } from "@jupyterlab/galata";
+
+/**
+ * Press the tab key, return the focussed node
+ * @param page Playwright page instance
+ * @returns ElementHandle, a reference to the focussed node
+ */
+async function nextFocusNode(page: Page) {
+ await page.keyboard.press("Tab");
+ const node = await page.evaluateHandle(() => document.activeElement);
+ if ((await node.jsonValue()) === null) {
+ throw new Error("Could not get next focus node from page");
+ }
+ // If node.jsonValue() is not null, then we should have an ElementHandle.
+ return node as ElementHandle;
+}
+
+/**
+ * Generator function to iterate through the tab-focussable nodes on the page in
+ * tab-focus order.
+ *
+ * Note: when the function yields a node, that node currently has the browser
+ * focus.
+ *
+ * @param page Playwright page instance
+ * @returns an AsyncGenerator instance for iterating over the tab-focussable
+ * nodes on the page
+ */
+async function* getFocusNodes(page: Page) {
+ // Get the first node in the page's focus order.
+ const start: ElementHandle = await nextFocusNode(page);
+ let node: ElementHandle = start;
+
+ // Start a loop in order to cycle through all of the tab-focussable nodes on
+ // the page.
+ while (true) {
+ // Yield the current node to the code requesting it.
+ yield node;
+
+ // The caller may blur the node, so refocus it before getting the next node.
+ // That way we focus the nodes in the right order.
+ await node.evaluate((node: HTMLElement) => node.focus());
+
+ // Get the next node in the page's focus order.
+ const nextNode = await nextFocusNode(page);
+
+ // Break out of the loop if we have cycled back to the start.
+ if (
+ await page.evaluate(
+ ([nextNode, start]) => nextNode === start,
+ [nextNode, start],
+ )
+ ) {
+ break;
+ } else {
+ // Otherwise do another turn of the loop.
+ node = nextNode;
+ }
+ }
+}
+
+test.describe("every tab-focusable element on initial app page", () => {
+ test("should have visible focus indicator", async ({ page }, testInfo) => {
+ test.info().annotations.push({
+ type: "Manual testing script",
+ description: "visible-focus-indicator-initial-page.md",
+ });
+
+ // For each tab-focussable node, take a screenshot of the node while
+ // focussed then not focussed and then compare the screenshots.
+ for await (const node of getFocusNodes(page)) {
+ // Skip if node is body node. (This is a discrepancy between the real and
+ // test environments. Under normal usage, the body element of the
+ // JupyterLab UI is not tab-focussable.)
+ if (await node.evaluate((node) => node === document.body)) {
+ continue;
+ }
+
+ // Calculate where on the page to take the screenshot in order to capture
+ // the node. Note: we cannot use node.screenshot() because it does not
+ // reliably capture CSS-applied outlines across browsers.
+ const box = await node.boundingBox();
+ if (box === null) {
+ throw new Error("Could not get node bounding box");
+ }
+ const { x, y, width, height } = box;
+ const pad = 3; // this value is just from trial-and-error
+ const clip = {
+ x: x - pad,
+ y: y - pad,
+ width: pad + width + pad,
+ height: pad + height + pad,
+ };
+
+ // Screenshot the node; this time it's focussed.
+ const focus = await page.screenshot({ clip });
+
+ // Blur the node to remove focus.
+ await node.evaluate((node) => (node as HTMLElement).blur());
+
+ // Screenshot the node again, this time it's not focussed.
+ const noFocus = await page.screenshot({ clip });
+
+ // Attach the screenshots to the test report (can help with debugging if
+ // the test fails, among other things)
+ await testInfo.attach("focussed", {
+ body: focus,
+ contentType: "image/png",
+ });
+ await testInfo.attach("unfocussed", {
+ body: noFocus,
+ contentType: "image/png",
+ });
+
+ // Compare the screenshots. If they are equal, the test fails. Use
+ // expect.soft so that the test will iterate through all of the
+ // tab-focussable nodes on the page instead of bailing on the first node
+ // that fails the test.
+ expect
+ .soft(
+ // Buffer.equals uses bit-for-bit equality, equivalent to comparing
+ // both screenshots pixel for pixel. If the screenshots are exactly
+ // the same, we know for sure that there was no visible focus
+ // indicator, so the test fails.
+ focus.equals(noFocus),
+ `focus visible comparison failed on\n\t${node.toString()}`,
+ )
+ .toBe(false);
+ }
+ });
+});