From 0a7502c9baee28180e52799d344c6d9d63596a97 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:26:17 -0500 Subject: [PATCH 1/3] Refine Primer site discovery, search, and responsive reading layouts Prioritize learning, reuse branded artwork, align responsive frames and controls, scope and deduplicate search, compact reading headers, and resolve documentation images with shared helpers and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/README.md | 126 +++++++ website/scripts/markdown-images.test.mjs | 219 +++++++++++ website/scripts/mobile-nav-regression.mjs | 122 ++++++ .../scripts/reading-catalog-regression.mjs | 229 ++++++++++++ website/scripts/reading-header-regression.mjs | 134 +++++++ .../resource-card-layout-regression.mjs | 83 +++++ website/scripts/search-browser.test.mjs | 321 ++++++++++++++++ website/scripts/search.test.mjs | 265 +++++++++++++ .../scripts/site-refinement-regression.mjs | 347 ++++++++++++++++++ website/src/assets/github-mark.svg | 2 + website/src/components/brand/AgentDetail.tsx | 5 + .../src/components/brand/AgentsCatalog.tsx | 65 +--- website/src/components/brand/AgentsIcon.tsx | 52 +++ .../src/components/brand/CookbookRecipe.tsx | 12 +- website/src/components/brand/Custom.tsx | 3 +- .../src/components/brand/DetailChassis.tsx | 104 ++---- .../src/components/brand/ExtensionDetail.tsx | 5 + .../components/brand/ExtensionsCatalog.tsx | 35 +- website/src/components/brand/HomePage.tsx | 79 ++-- .../components/brand/InstructionDetail.tsx | 5 + .../components/brand/InstructionsCatalog.tsx | 167 +-------- .../src/components/brand/InstructionsIcon.tsx | 168 +++++++++ website/src/components/brand/LargeFooter.tsx | 3 +- .../brand/LearningArticleLayout.tsx | 227 ++---------- .../src/components/brand/LearningHubIcon.tsx | 7 +- .../src/components/brand/LearningHubIndex.tsx | 5 +- website/src/components/brand/PageShell.tsx | 4 +- website/src/components/brand/PluginDetail.tsx | 5 + .../src/components/brand/PluginsCatalog.tsx | 20 +- .../src/components/brand/ReadingHeader.tsx | 28 ++ website/src/components/brand/SkillDetail.tsx | 20 +- .../src/components/brand/SkillsCatalog.tsx | 6 +- website/src/components/brand/SkillsIcon.tsx | 7 +- website/src/components/brand/TopNav.tsx | 33 +- website/src/components/brand/TopNavSearch.tsx | 124 ++++--- website/src/components/brand/navigation.ts | 31 +- .../src/components/brand/pagefindSearch.ts | 95 ++--- website/src/components/brand/searchIndex.ts | 79 +++- .../components/brand/styles/TopNav.module.css | 65 ++-- .../brand/styles/TopNavSearch.module.css | 82 +++-- .../components/brand/styles/agents.module.css | 4 + .../styles/catalog-pagination.module.css | 6 + .../brand/styles/dotnet-upgrade.module.css | 58 ++- .../brand/styles/extensions.module.css | 4 + .../components/brand/styles/footer.module.css | 16 + .../styles/github-copilot-app.module.css | 64 ++-- .../brand/styles/instructions.module.css | 4 + .../learning-hub-copilot-app.module.css | 4 + .../brand/styles/plugins.module.css | 4 + .../brand/styles/reading-header.module.css | 70 ++++ .../components/brand/styles/skills.module.css | 4 + .../components/brand/styles/styles.module.css | 161 +++++--- .../components/brand/useAgentDetailScroll.ts | 190 +++++++--- .../components/brand/useCatalogPageFocus.ts | 17 + .../src/integrations/pagefind-resources.ts | 147 ++++++-- website/src/lib/detail-page.ts | 11 +- website/src/lib/markdown-images.ts | 89 +++++ website/src/lib/sanitize-html.ts | 73 +++- website/src/pages/extension/[id].astro | 7 +- website/src/pages/extensions.astro | 2 +- website/src/pages/plugin/[id].astro | 2 +- website/src/styles/base.css | 50 +++ 62 files changed, 3435 insertions(+), 941 deletions(-) create mode 100644 website/scripts/markdown-images.test.mjs create mode 100644 website/scripts/mobile-nav-regression.mjs create mode 100644 website/scripts/reading-catalog-regression.mjs create mode 100644 website/scripts/reading-header-regression.mjs create mode 100644 website/scripts/resource-card-layout-regression.mjs create mode 100644 website/scripts/search-browser.test.mjs create mode 100644 website/scripts/search.test.mjs create mode 100644 website/scripts/site-refinement-regression.mjs create mode 100644 website/src/assets/github-mark.svg create mode 100644 website/src/components/brand/AgentsIcon.tsx create mode 100644 website/src/components/brand/InstructionsIcon.tsx create mode 100644 website/src/components/brand/ReadingHeader.tsx create mode 100644 website/src/components/brand/styles/catalog-pagination.module.css create mode 100644 website/src/components/brand/styles/footer.module.css create mode 100644 website/src/components/brand/styles/reading-header.module.css create mode 100644 website/src/components/brand/useCatalogPageFocus.ts create mode 100644 website/src/lib/markdown-images.ts diff --git a/website/README.md b/website/README.md index 546e26eb7a..14cb348fc6 100644 --- a/website/README.md +++ b/website/README.md @@ -20,12 +20,138 @@ CI blocks on critical and serious violations. Minor and moderate best-practice i Authoring conventions: resource cards use `div[role="listitem"]` wrappers, not `
`; only add `role="list"` to containers whose direct children are list items; do not nest interactive controls inside another focusable element; `.btn-primary` and ToC links must meet WCAG AA (4.5:1) contrast in both light and dark themes. +## Discovery and visual conventions + +The landing page prioritizes the Learning Hub, with the repository as its +secondary destination. Resource cards and navigation reuse their catalog pages' +branded icons, with visible text labels. Resources without branded artwork keep +their Octicons. Repeated SVGs use instance-specific clipping IDs. +Resource-card CTAs reveal their border, expand with the arrow moving right, then +fade the label into the padded space. Labels fade out before collapse rather +than sliding through the border. Touch and narrow layouts keep labels visible; +reduced-motion preferences skip the transitions. +Expanded card buttons inset their labels past the rounded ends; the hero +button keeps Primer's default padding. +The secondary hero CTA uses Primer's theme-aware background tokens for its +rest, hover, and pressed states instead of fixed gray fills. +The resource grid stays single-column below 768px, uses two columns up to +1280px, then three columns. Nested card padding decreases on narrower layouts +so full CTA labels remain on one line without shrinking their text. +At 1920px and wider, the homepage header and content share the catalogs' +centered 1296px frame; smaller viewports retain the same responsive gutters. +The landing hero concentrates a green-tinted square grid and soft glow around +the lower center, fading toward the headline and side edges rather than covering +the panel uniformly. Other heading panels use a static theme-token texture. Both treatments +are disabled in forced-colors mode. +The bottom learning panel links to YouTube and the GitHub Copilot docs; the +primary hero remains the entry point to the Learning Hub. Its outer frame shares +the banner's background token so the color reaches both side edges. +The shared footer pairs the GitHub wordmark with the official GitHub mark, +bundled locally as a theme-colored mask while retaining Primer's footer link. + +The catalog's user-facing label is **Extensions**, including navigation, +breadcrumbs, and search result types. A canvas is the interactive panel an +extension can supply, and a plugin can package it with other resources. +Existing `/extensions/` and `/extension//` URLs, schema fields, and install +identifiers remain unchanged. Menu links and search group headings use the +same artwork as the cards, at a compact 16px size. Individual search results +omit redundant type labels and icons. The desktop Learning Hub +link stays text-only; its icon appears on the primary hero CTA instead. +Search groups use stronger headings and solid dividers between groups. Hover +and keyboard selection fill the entire result-row width while text remains inset. +Arrow, Home, and End navigation keep the selected result visible inside scrollable +panels without moving focus away from the search field. +Dropdown links that leave the site have a trailing external-link arrow, including +Hooks, Workflows, and Tools (local redirects to GitHub), and the mobile Contribute button. + +Run the landing/navigation and reported image regressions against a built site: + +```bash +node website/scripts/site-refinement-regression.mjs +node website/scripts/resource-card-layout-regression.mjs +node website/scripts/mobile-nav-regression.mjs +``` + +These browser regressions default to Astro's `http://127.0.0.1:4321`. +Set `SITE_BASE_URL` to target another development or production-preview server. +On PowerShell, use `$env:SITE_BASE_URL = 'http://127.0.0.1:4331'`. + +The mobile menu spans the actual header width (excluding scrollbars), with +content aligned to the wordmark and a scrollable panel on short screens. +The mobile navigation regression checks menu bounds, padding, icon consistency, +and keyboard behavior. + +## Reading headers and catalog pagination + +Resource details and learning articles let their expanded title, description, +and actions scroll away. On roomy desktop viewports, an observer reveals a +compact title with a small, right-aligned primary install action where available. +Its right edge aligns with the top bar's trailing actions. +The full install menu stays in the expanded hero. The strip fades in as the +remaining hero reaches the strip's height, without changing document height or +briefly exposing a bare progress line. Hidden install actions are inert. +The title releases on mobile, short viewports, and enlarged +text; anchor and sidebar offsets share its measured height. Back to top restores +heading focus and respects reduced motion. The green progress line celebrates +when the article's bottom is visible, consistently across resource details and +learning articles, rather than waiting for the footer or stopping at a heading. + +All six catalogs share the pagination control alignment correction, preserving +Primer's current-page styling and the keyboard scroll/focus handoff to `#catalog`. +With the website running locally, run the focused browser regressions: + +```bash +node website/scripts/reading-catalog-regression.mjs +node website/scripts/reading-header-regression.mjs +``` + +These use the same `SITE_BASE_URL` setting and default as the landing-page regressions. + ## Security hardening notes - The site ships with a baseline meta CSP and `referrer` policy in `src/components/Head.astro`. - Because the site is hosted on GitHub Pages, response headers are not controllable in-repo. For stricter enforcement (for example, header-based CSP with nonce/hashes), place the site behind infrastructure that can set HTTP security headers. - Markdown rendered for detail/file-browser experiences is sanitized with the shared `sanitizeHtml()` helper before insertion. +## Search locale + +Both catalog and Pagefind results are scoped to the rendered page's ``, +before grouping and result limits. English fallback pages search English even +when their URL has another locale prefix. Real translated pages do not mix in +English catalog records; without Pagefind, they show no results rather than +silently searching another language. + +Run `node --test website/scripts/search.test.mjs` for adapter coverage and +`node --test website/scripts/search-browser.test.mjs` after a production build +for desktop/mobile locale and search interaction coverage. + +## Embedded markdown images + +Standalone documentation images receive 24px of vertical margin through a +shared reading-content rule. The markdown sanitizer marks image-only blocks, +including linked images and themed pictures; inline icons and badge rows retain +their existing layout. Component-owned hero and gallery images are unaffected. + +Resource details and skill/cookbook file previews resolve embedded `src` and +picture/image `srcset` URLs from the originating repository document directory +and revision, not the website route or locale. Canvas READMEs use the same +generated commit ref as their preview image; other curated documents use their +existing `main` source. Site-root `/images/...` assets and HTTP(S) images remain +valid, and ordinary image hyperlinks remain links. + +External catalog entries do not currently fetch README bodies. Any future +cached external README must supply its actual raw repository base, revision, +and document path to `readResourceMarkdown`; its local cache filename is not +source provenance. Missing or invalid provenance for relative images is +reported and the image URL is removed rather than guessed. Sanitization still +runs on the resolved attributes. + +Run focused media regressions from the repository root with Node 24 or later: + +```bash +node --test website/scripts/markdown-images.test.mjs +``` + ## Social preview cards (LinkedIn, etc.) Shared links render as large preview cards driven by Open Graph / Twitter meta tags. diff --git a/website/scripts/markdown-images.test.mjs b/website/scripts/markdown-images.test.mjs new file mode 100644 index 0000000000..d6f94b6659 --- /dev/null +++ b/website/scripts/markdown-images.test.mjs @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { marked } from "marked"; + +// Node's native TS support needs extensions; Astro/Vite imports omit them. +const lib = new URL("../src/lib/", import.meta.url).href; +registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL?.startsWith(lib) && specifier.startsWith(".")) { + const candidate = new URL(`${specifier}.ts`, context.parentURL); + if (existsSync(candidate)) return nextResolve(candidate.href, context); + } + return nextResolve(specifier, context); + }, +}); + +process.chdir(fileURLToPath(new URL("..", import.meta.url))); +const { resolveMarkdownImage, resolveMarkdownSrcset } = + await import("../src/lib/markdown-images.ts"); +const { sanitizeHtml } = await import("../src/lib/sanitize-html.ts"); +const { readResourceMarkdown } = await import("../src/lib/detail-page.ts"); + +const revision = "a".repeat(40); +const rawBase = `https://raw.githubusercontent.com/github/awesome-copilot/${revision}`; +const source = { + rawBase, + filePath: "extensions/pr-artifact-explorer/README.md", +}; +const render = (markdown, provenance = source) => + sanitizeHtml(marked.parse(markdown, { async: false }), provenance); + +test("standalone documentation images receive the shared spacing marker", () => { + for (const markdown of [ + "![Preview](assets/preview.png)", + "[![Preview](assets/preview.png)](https://example.com)", + 'Preview', + 'Preview', + ]) { + const html = render(markdown); + assert.match(html, /]*data-markdown-block-image=""/); + assert.equal(sanitizeHtml(html), html, "Repeated sanitization preserves block classification"); + } + assert.match(readResourceMarkdown(source.filePath, source).markdownHtml, + /]*data-markdown-block-image=""/); +}); + +test("inline icons, text, and badge rows do not receive block image spacing", () => { + for (const markdown of [ + "Click ![Icon](assets/preview.png) to continue.", + "**Before** ![Icon](assets/preview.png) **after**", + "[![Build](assets/preview.png)](https://example.com) [![Version](assets/preview.png)](https://example.com)", + 'Icon', + '
Click Icon to continue.
', + '

Click Icon to continue.

', + ]) { + assert.doesNotMatch(render(markdown), /data-markdown-block-image/); + } +}); + +test("relative images resolve against the document directory and revision", () => { + for (const [input, expected] of [ + ["assets/preview.png", "extensions/pr-artifact-explorer/assets/preview.png"], + ["./assets/preview.png", "extensions/pr-artifact-explorer/assets/preview.png"], + ["../shared/image.png", "extensions/shared/image.png"], + ["../../shared/image.png", "shared/image.png"], + ["assets/a b.png?raw=true#dark", "extensions/pr-artifact-explorer/assets/a%20b.png?raw=true#dark"], + ["assets/a%20b.png", "extensions/pr-artifact-explorer/assets/a%20b.png"], + ]) { + assert.equal(resolveMarkdownImage(input, source), `${rawBase}/${expected}`); + } +}); + +test("site-root, absolute, and already pinned assets retain their destinations", () => { + for (const url of [ + "/images/learning-hub/example.png", + "https://example.com/image.png?q=1#preview", + "http://example.com/image.png", + `${rawBase}/extensions/pr-artifact-explorer/assets/preview.png`, + "https://github.com/user-attachments/assets/image-id", + "https://github.com/owner/repo/blob/feature/branch/image.png?raw=true", + ]) { + assert.equal(resolveMarkdownImage(url, null), url); + } + assert.equal(resolveMarkdownImage("//example.com/image.png", null), "https://example.com/image.png"); +}); + +test("only unambiguous commit-pinned embedded blob URLs become raw URLs", () => { + const blob = `https://github.com/owner/repo/blob/${revision}/docs/image.png?raw=true#dark`; + assert.equal( + resolveMarkdownImage(blob, source), + `https://raw.githubusercontent.com/owner/repo/${revision}/docs/image.png?raw=true#dark`, + ); + const html = render(`[An image link](${blob})\n\n![An embedded image](${blob})`); + assert.ok(html.includes(`href="${blob}"`)); + assert.equal((html.match(/ { + const external = { + rawBase: `https://raw.githubusercontent.com/third-party/plugin/${revision}`, + filePath: "packages/canvas/docs/README.md", + }; + const result = readResourceMarkdown(source.filePath, external); + assert.ok(result.markdownHtml.includes( + `src="${external.rawBase}/packages/canvas/docs/assets/preview.png"`, + )); + assert.ok(!/src(?:set)?="https:\/\/raw\.githubusercontent\.com\/github\/awesome-copilot/.test( + result.markdownHtml, + )); + assert.equal( + resolveMarkdownImage("../image.png", { + ...external, + rawBase: "https://raw.githubusercontent.com/third-party/plugin/release%2Fv1", + }), + "https://raw.githubusercontent.com/third-party/plugin/release%2Fv1/packages/canvas/image.png", + ); +}); + +test("Artifact Explorer README rewrites img and both themed picture sources", () => { + const { markdownHtml } = readResourceMarkdown(source.filePath, source); + const assets = `${rawBase}/extensions/pr-artifact-explorer/assets`; + assert.ok(markdownHtml.includes(`src="${assets}/preview.png"`)); + assert.ok(markdownHtml.includes(`srcset="${assets}/preview-dark.png"`)); + assert.ok(markdownHtml.includes(`srcset="${assets}/preview.png"`)); + assert.ok(markdownHtml.includes('alt="Artifact Explorer showing pull request workflow artifacts')); + assert.ok(!markdownHtml.includes(" { + const { markdownHtml } = readResourceMarkdown("plugins/napkin/README.md"); + assert.ok(markdownHtml.includes( + 'src="https://raw.githubusercontent.com/github/awesome-copilot/main/skills/napkin/assets/step1-activate.svg"', + )); + assert.ok(render('![A & B](assets/preview.png "Preview")').includes( + `src="${rawBase}/extensions/pr-artifact-explorer/assets/preview.png" alt="A & B" title="Preview"`, + )); +}); + +test("file previews use each selected file path even when contents are identical", () => { + for (const filePath of [ + "skills/example/docs/guide.md", + "skills/example/other/guide.md", + "cookbook/copilot-sdk/nodejs/guide.md", + ]) { + const directory = filePath.slice(0, filePath.lastIndexOf("/")); + assert.ok(render("![Diagram](./diagram.png)", { rawBase, filePath }).includes( + `src="${rawBase}/${directory}/diagram.png"`, + )); + } +}); + +test("srcset handles descriptors, whitespace, parent paths, and commas in URLs", () => { + assert.equal( + resolveMarkdownSrcset("assets/a.png 1x, ../b.png 2x", source), + `${rawBase}/extensions/pr-artifact-explorer/assets/a.png 1x, ${rawBase}/extensions/b.png 2x`, + ); + assert.equal( + resolveMarkdownSrcset("/images/a.png 320w, https://example.com/a,b.png 640w", source), + "/images/a.png 320w, https://example.com/a,b.png 640w", + ); + assert.equal( + resolveMarkdownSrcset("assets/a.png, \n assets/b.png", source), + `${rawBase}/extensions/pr-artifact-explorer/assets/a.png, ${rawBase}/extensions/pr-artifact-explorer/assets/b.png`, + ); +}); + +test("unsafe URLs and ambiguous or escaping provenance fail explicitly", () => { + for (const url of [ + "javascript:alert(1)", "data:image/png;base64,AA", "file:///tmp/a.png", + "vbscript:bad", "java\nscript:bad", "\\\\evil.test\\a.png", "", + "../../../outside.png", "%2e%2e/%2e%2e/%2e%2e/outside.png", + ]) { + assert.throws(() => resolveMarkdownImage(url, source)); + } + assert.throws(() => resolveMarkdownImage("image.png", null), /no document source/); + for (const invalidSource of [ + { ...source, rawBase: "https://github.com/owner/repo/tree/main" }, + { ...source, rawBase: "https://raw.githubusercontent.com/owner/repo" }, + { ...source, rawBase: "https://raw.githubusercontent.com/owner/repo/feature/branch" }, + { ...source, filePath: "../README.md" }, + ]) { + assert.throws(() => resolveMarkdownImage("image.png", invalidSource)); + } +}); + +test("final markup stays sanitized, preserves alt/links, and isolates render sources", (t) => { + const warnings = []; + t.mock.method(console, "warn", (message) => warnings.push(message)); + const html = render(` +Keep me +No data + +Unsafe link +Ordinary image link`); + assert.ok(!/javascript|data:image|onerror|