From 8968af7361d1dd20e0515ead4caed434f051202b Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:19:28 -0700 Subject: [PATCH 1/7] feat(website): parse and sort community contributors Add data helpers and tests for the contributors.jsonl file published on the website-data branch, plus a dev sample with the current external contributors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/src/data/contributors.sample.jsonl | 4 + website/src/scripts/contributors.test.mjs | 96 ++++++++++++++++++++ website/src/scripts/contributors.ts | 101 +++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 website/src/data/contributors.sample.jsonl create mode 100644 website/src/scripts/contributors.test.mjs create mode 100644 website/src/scripts/contributors.ts diff --git a/website/src/data/contributors.sample.jsonl b/website/src/data/contributors.sample.jsonl new file mode 100644 index 000000000..0a7de7f71 --- /dev/null +++ b/website/src/data/contributors.sample.jsonl @@ -0,0 +1,4 @@ +{"login":"Gile987","prs":1} +{"login":"idhanth","prs":2} +{"login":"itsmeasadali","prs":1} +{"login":"mnkj0021","prs":1} diff --git a/website/src/scripts/contributors.test.mjs b/website/src/scripts/contributors.test.mjs new file mode 100644 index 000000000..e5bcae5d7 --- /dev/null +++ b/website/src/scripts/contributors.test.mjs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Runs under Node's built-in test runner (`pnpm test`), which strips the +// TypeScript types from the imported module. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + avatarUrl, + contributorsLabel, + parseContributors, + prsLabel, + prsUrl, + sortForDisplay, + totalPrs, + visible, +} from './contributors.ts'; + +const line = (value) => JSON.stringify(value); + +test('parses JSON Lines and ignores blank lines', () => { + const text = [line({ login: 'ada', prs: 2 }), '', line({ login: 'grace-h', prs: 1 }), ''].join('\n'); + assert.deepEqual(parseContributors(text), { + contributors: [{ login: 'ada', prs: 2 }, { login: 'grace-h', prs: 1 }], + skipped: [], + }); +}); + +test('treats an empty file as no contributors', () => { + assert.deepEqual(parseContributors(''), { contributors: [], skipped: [] }); +}); + +test('skips invalid logins, counts and duplicates without dropping the rest', () => { + const text = [ + '{not json', + line({ login: 'dependabot[bot]', prs: 9 }), + line({ login: '-leading', prs: 1 }), + line({ login: 'double--hyphen', prs: 1 }), + line({ login: '../evil', prs: 1 }), + line({ login: 'zero', prs: 0 }), + line({ login: 'fraction', prs: 1.5 }), + line({ login: 'Kept', prs: 3 }), + line({ login: 'kept', prs: 1 }), + ].join('\r\n'); + const { contributors, skipped } = parseContributors(text); + assert.deepEqual(contributors, [{ login: 'Kept', prs: 3 }]); + assert.deepEqual(skipped, [1, 2, 3, 4, 5, 6, 7, 9]); +}); + +test('sorts by merged PRs, then login case-insensitively', () => { + const sorted = sortForDisplay([ + { login: 'zed', prs: 1 }, + { login: 'Bea', prs: 1 }, + { login: 'max', prs: 4 }, + { login: 'alex', prs: 1 }, + ]); + assert.deepEqual(sorted.map((c) => c.login), ['max', 'alex', 'Bea', 'zed']); +}); + +test('builds avatar and merged PR URLs', () => { + assert.equal(avatarUrl('ada'), 'https://github.com/ada.png?size=96'); + assert.equal(avatarUrl('ada', 160), 'https://github.com/ada.png?size=160'); + const url = new URL(prsUrl('grace-h')); + assert.equal(url.pathname, '/microsoft/scope/pulls'); + assert.equal(url.searchParams.get('q'), 'is:pr is:merged author:grace-h'); +}); + +test('pluralizes labels and totals PRs', () => { + assert.equal(prsLabel(1), '1 merged PR'); + assert.equal(prsLabel(3), '3 merged PRs'); + assert.equal(contributorsLabel(1), 'contributor'); + assert.equal(contributorsLabel(2), 'contributors'); + assert.equal(totalPrs([{ login: 'a', prs: 2 }, { login: 'b', prs: 3 }]), 5); + assert.equal(totalPrs([]), 0); +}); + +test('keeps a slot for the overflow bubble', () => { + const items = [1, 2, 3, 4, 5, 6]; + assert.deepEqual(visible(items, 10), { shown: items, hidden: 0 }); + assert.deepEqual(visible(items, 6), { shown: items, hidden: 0 }); + assert.deepEqual(visible(items, 4), { shown: [1, 2, 3], hidden: 3 }); + assert.deepEqual(visible(items, Infinity), { shown: items, hidden: 0 }); +}); + +test('the dev sample file is valid and sorted like the published file', () => { + const text = readFileSync(new URL('../data/contributors.sample.jsonl', import.meta.url), 'utf8'); + const { contributors, skipped } = parseContributors(text); + assert.deepEqual(skipped, []); + assert.ok(contributors.length >= 1); + const logins = contributors.map((c) => c.login.toLowerCase()); + assert.deepEqual(logins, [...logins].sort()); + for (const raw of text.trim().split('\n')) { + assert.deepEqual(Object.keys(JSON.parse(raw)), ['login', 'prs'], 'keys are sorted and nothing else is published'); + } +}); diff --git a/website/src/scripts/contributors.ts b/website/src/scripts/contributors.ts new file mode 100644 index 000000000..b5def8e9c --- /dev/null +++ b/website/src/scripts/contributors.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Data helpers for the community contributors thank-you section. The list is +// published as JSON Lines on the `website-data` branch by +// .github/workflows/contribution-issues.yml and fetched in the browser. +// This module has no DOM dependencies so it can be unit-tested in Node. + +const REPOSITORY = 'microsoft/scope'; +export const CONTRIBUTORS_DATA_URL = `https://raw.githubusercontent.com/${REPOSITORY}/website-data/contributors.jsonl`; +export const CONTRIBUTORS_GRAPH_URL = `https://github.com/${REPOSITORY}/graphs/contributors`; + +export interface Contributor { + login: string; + /** Number of merged pull requests. */ + prs: number; +} + +export interface ContributorsResult { + contributors: Contributor[]; + /** 1-based line numbers that were skipped because they were invalid. */ + skipped: number[]; +} + +// GitHub logins: alphanumerics and single hyphens, up to 39 characters. +const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +function isContributor(value: unknown): value is Contributor { + if (typeof value !== 'object' || value === null) return false; + const { login, prs } = value as Record; + return typeof login === 'string' && LOGIN.test(login) && Number.isSafeInteger(prs) && (prs as number) > 0; +} + +/** + * Parses the JSON Lines payload. Invalid lines, bot accounts and duplicate + * logins are skipped so one bad line can't hide the list. + */ +export function parseContributors(text: string): ContributorsResult { + const contributors: Contributor[] = []; + const skipped: number[] = []; + const seen = new Set(); + text.split(/\r?\n/).forEach((line, index) => { + if (line.trim() === '') return; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + skipped.push(index + 1); + return; + } + if (!isContributor(value) || seen.has(value.login.toLowerCase())) { + skipped.push(index + 1); + return; + } + seen.add(value.login.toLowerCase()); + contributors.push({ login: value.login, prs: value.prs }); + }); + return { contributors, skipped }; +} + +/** Display order: most merged PRs first, then by login. */ +export function sortForDisplay(contributors: readonly Contributor[]): Contributor[] { + return [...contributors].sort((a, b) => b.prs - a.prs || a.login.localeCompare(b.login, 'en', { sensitivity: 'base' })); +} + +export function avatarUrl(login: string, size = 96): string { + return `https://github.com/${encodeURIComponent(login)}.png?size=${size}`; +} + +/** The contributor's merged pull requests in this repository. */ +export function prsUrl(login: string): string { + const query = `is:pr is:merged author:${login}`; + return `https://github.com/${REPOSITORY}/pulls?q=${encodeURIComponent(query)}`; +} + +export function prsLabel(count: number): string { + return count === 1 ? '1 merged PR' : `${count} merged PRs`; +} + +export function contributorsLabel(count: number): string { + return count === 1 ? 'contributor' : 'contributors'; +} + +export function totalPrs(contributors: readonly Contributor[]): number { + return contributors.reduce((sum, contributor) => sum + contributor.prs, 0); +} + +/** Splits the list into what fits under `limit` and how many are left over. */ +export function visible(items: readonly T[], limit: number): { shown: T[]; hidden: number } { + if (!Number.isFinite(limit) || limit <= 0 || items.length <= limit) return { shown: [...items], hidden: 0 }; + // Keep a slot for the "+N" bubble so the wall never exceeds `limit` items. + const shown = items.slice(0, limit - 1); + return { shown, hidden: items.length - shown.length }; +} + +/** Fetches and parses the published list. Throws on network or HTTP errors. */ +export async function fetchContributors(url: string, timeoutMs = 8000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`Contributors request failed: HTTP ${response.status}`); + return parseContributors(await response.text()); +} From 650259955b36623bab6a42a07daf6f4f5dbe4562 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:23:10 -0700 Subject: [PATCH 2/7] feat(website): thank community contributors Add a element: an avatar wall with tooltips and a +N bubble on the landing page, and a card grid in a new Thank you section on Community > Contribute. Avatars pop in with WAAPI, the count pill counts up, and motion is skipped when reduced motion is preferred. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../contribute/ContributeTeaser.astro | 12 + .../components/contribute/Contributors.astro | 53 ++++ .../src/content/docs/community/contribute.mdx | 9 + website/src/scripts/contributors-element.ts | 262 ++++++++++++++++++ website/src/styles/contributors.css | 112 ++++++++ 5 files changed, 448 insertions(+) create mode 100644 website/src/components/contribute/Contributors.astro create mode 100644 website/src/scripts/contributors-element.ts create mode 100644 website/src/styles/contributors.css diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro index 67626cb20..24fdd7fff 100644 --- a/website/src/components/contribute/ContributeTeaser.astro +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -3,6 +3,7 @@ // Licensed under the MIT License. import ContributionIssues from './ContributionIssues.astro'; +import Contributors from './Contributors.astro'; const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contribute/`; --- @@ -27,4 +28,15 @@ const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contri See all open calls + +
+

Thank you, contributors

+

+ + + +

+
+

Everyone below got a pull request merged into Scope. Hover an avatar to see how many.

+
diff --git a/website/src/components/contribute/Contributors.astro b/website/src/components/contribute/Contributors.astro new file mode 100644 index 000000000..bd44ac1f8 --- /dev/null +++ b/website/src/components/contribute/Contributors.astro @@ -0,0 +1,53 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Thanks community contributors: external authors of merged pull requests. +// The contribution-issues workflow publishes the list to the website-data +// branch, and the browser fetches it. See website/AGENTS.md. +import '../../styles/contribution-issues.css'; +import '../../styles/contributors.css'; +import { CONTRIBUTORS_DATA_URL, CONTRIBUTORS_GRAPH_URL } from '../../scripts/contributors'; + +interface Props { + /** `wall` for the landing avatar wall, `grid` for the docs page cards. */ + variant?: 'wall' | 'grid'; + /** Maximum number of items on the wall, including the "+N" bubble. */ + limit?: number; +} + +const { variant = 'wall', limit } = Astro.props; +const src = import.meta.env.PUBLIC_CONTRIBUTORS_URL || CONTRIBUTORS_DATA_URL; +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); +const moreHref = `${base}/community/contribute/#thank-you`; +const openCallsHref = `${base}/community/contribute/#open-calls`; +const skeletons = variant === 'wall' ? 8 : 3; +--- + + + + +
    + { + Array.from({ length: skeletons }, () => ( +
+

+ No community pull request has been merged yet. Be the first: + pick an open call. +

+

+ We couldn't load the list. See + contributors on GitHub (opens in a new tab). +

+

+ See + contributors on GitHub (opens in a new tab). +

+
+ + diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx index 9f042b404..882368a01 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -4,6 +4,7 @@ description: Open issues where the Scope team is calling for contributions, and --- import ContributionIssues from '../../../components/contribute/ContributionIssues.astro'; +import Contributors from '../../../components/contribute/Contributors.astro';
@@ -30,3 +31,11 @@ The list refreshes about every hour.
+ +## Thank you + +Thank you to everyone outside the Scope team who got a pull request +merged. Select a name to see their merged pull requests. This list also +refreshes about every hour. + + diff --git a/website/src/scripts/contributors-element.ts b/website/src/scripts/contributors-element.ts new file mode 100644 index 000000000..c39d037f4 --- /dev/null +++ b/website/src/scripts/contributors-element.ts @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CONTRIBUTORS_DATA_URL, + avatarUrl, + contributorsLabel, + fetchContributors, + parseContributors, + prsLabel, + prsUrl, + sortForDisplay, + totalPrs, + visible, + type Contributor, + type ContributorsResult, +} from './contributors'; + +type State = 'loading' | 'ready' | 'empty' | 'error'; + +const EASE_OUT_BACK = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; +const COUNT_UP_MS = 900; +const MAX_STAGGER_STEPS = 24; + +/** + * Thanks the community: external authors of merged pull requests. Renders + * an avatar wall (landing) or a card grid (Contribute page). Without + * JavaScript a link to the GitHub contributors graph stays visible. Motion + * is skipped entirely when the visitor prefers reduced motion. + */ +class ScopeContributors extends HTMLElement { + private motion = window.matchMedia('(prefers-reduced-motion: reduce)'); + private observer: IntersectionObserver | undefined; + private events: AbortController | undefined; + private inView = false; + private loaded = false; + private headingRevealed = false; + private itemsRevealed = false; + private contributors: Contributor[] = []; + + connectedCallback() { + this.events = new AbortController(); + this.toggleAttribute('data-animate', this.animates); + this.motion.addEventListener('change', () => this.toggleAttribute('data-animate', this.animates), { signal: this.events.signal }); + this.observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + this.inView = true; + this.observer?.disconnect(); + this.reveal(); + }, { rootMargin: '0px 0px -12% 0px' }); + this.observer.observe(this); + this.trackPointer(this.events.signal); + void this.load(); + } + + disconnectedCallback() { + this.observer?.disconnect(); + this.events?.abort(); + } + + private get animates() { + return !this.motion.matches && typeof Element.prototype.animate === 'function'; + } + + private get variant(): 'wall' | 'grid' { + return this.dataset.variant === 'grid' ? 'grid' : 'wall'; + } + + private async load() { + let result: ContributorsResult; + let sample = false; + try { + result = await fetchContributors(this.dataset.src || CONTRIBUTORS_DATA_URL); + } catch (error) { + // Same dev fallback as the open calls; removed from production builds. + if (import.meta.env.DEV) { + console.info('[contributors] Live list unavailable; using the dev sample.', error); + const { default: text } = await import('../data/contributors.sample.jsonl?raw'); + result = parseContributors(text); + sample = true; + } else { + console.warn('[contributors] Could not load the live list.', error); + this.setState('error'); + return; + } + } + if (result.skipped.length) console.warn(`[contributors] Skipped invalid lines: ${result.skipped.join(', ')}`); + this.querySelectorAll('[data-sample-tag]').forEach((tag) => { tag.hidden = !sample; }); + await this.render(result.contributors); + } + + private async render(contributors: Contributor[]) { + const list = this.querySelector('[data-list]'); + if (!list) throw new Error('Missing contributors list'); + this.contributors = sortForDisplay(contributors); + const { shown, hidden } = visible(this.contributors, Number(this.dataset.limit) || Infinity); + + if (this.animates && this.inView) { + await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); + } + const items = shown.map((contributor) => (this.variant === 'grid' ? this.card(contributor) : this.avatar(contributor))); + if (hidden) items.push(this.more(hidden)); + list.replaceChildren(...items); + + const people = this.contributors.length; + const prs = totalPrs(this.contributors); + this.querySelectorAll('[data-count-label]').forEach((label) => { label.textContent = contributorsLabel(people); }); + this.querySelectorAll('[data-prs]').forEach((element) => { element.textContent = prsLabel(prs); }); + this.querySelectorAll('[data-count-text]').forEach((text) => { text.textContent = `${people} ${contributorsLabel(people)}, ${prsLabel(prs)}`; }); + this.loaded = true; + this.setState(people ? 'ready' : 'empty'); + this.reveal(); + } + + private setState(state: State) { + this.dataset.state = state; + if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); + } + + private reveal() { + if (!this.inView) return; + if (!this.headingRevealed) { + this.headingRevealed = true; + this.enter([...this.querySelectorAll('[data-reveal]')], 0, 90, 'rise'); + } + if (this.loaded && !this.itemsRevealed) { + this.itemsRevealed = true; + const items = [...this.querySelectorAll('[data-reveal-item]')]; + if (this.variant === 'wall') this.enter(items, 200, 45, 'pop'); + else this.enter(items, 180, 70, 'rise'); + if (this.contributors.length) this.countUp(); + } + } + + private enter(elements: HTMLElement[], baseDelay: number, stagger: number, kind: 'rise' | 'pop') { + elements.forEach((element, index) => { + element.dataset.revealed = ''; + if (!this.animates) return; + const from = kind === 'pop' + ? { opacity: 0, transform: `translateY(16px) scale(0.35) rotate(${index % 2 ? 14 : -14}deg)` } + : { opacity: 0, transform: 'translateY(18px) scale(0.98)' }; + element.animate([from, { opacity: 1, transform: 'none' }], { + duration: kind === 'pop' ? 720 : 650, + delay: baseDelay + Math.min(index, MAX_STAGGER_STEPS) * stagger, + easing: EASE_OUT_BACK, + fill: 'backwards', + }); + }); + } + + private countUp() { + const pill = this.querySelector('[data-count-pill]'); + const counter = this.querySelector('[data-count]'); + if (!pill || !counter) return; + const total = this.contributors.length; + pill.dataset.shown = ''; + if (!this.animates) { + counter.textContent = String(total); + return; + } + pill.animate([{ opacity: 0, transform: 'scale(0.85)' }, { opacity: 1, transform: 'none' }], { duration: 500, easing: EASE_OUT_BACK, fill: 'backwards' }); + const start = performance.now(); + const tick = (now: number) => { + const progress = Math.min((now - start) / COUNT_UP_MS, 1); + counter.textContent = String(Math.round(total * (1 - (1 - progress) ** 3))); + if (progress < 1) requestAnimationFrame(tick); + }; + counter.textContent = '0'; + requestAnimationFrame(tick); + } + + /** Feeds the cursor position to the card glow through CSS variables. */ + private trackPointer(signal: AbortSignal) { + this.addEventListener('pointermove', (event) => { + if (this.motion.matches || !(event.target instanceof Element)) return; + const card = event.target.closest('.contrib-card'); + if (!card || !this.contains(card)) return; + const rect = card.getBoundingClientRect(); + card.style.setProperty('--x', `${event.clientX - rect.left}px`); + card.style.setProperty('--y', `${event.clientY - rect.top}px`); + }, { signal, passive: true }); + } + + // Logins come from published data, so every value is set as text or attribute. + private avatar(contributor: Contributor): HTMLLIElement { + const item = this.item(); + const link = this.link(item, 'thanks-avatar', prsUrl(contributor.login)); + link.append(this.image(contributor, 56)); + const tip = this.span(link, 'thanks-tip'); + this.span(tip, 'thanks-tip__login', `@${contributor.login}`); + this.span(tip, 'thanks-tip__prs', prsLabel(contributor.prs)); + this.span(link, 'sr-only', ' (opens in a new tab)'); + return item; + } + + private card(contributor: Contributor): HTMLLIElement { + const item = this.item(); + const link = this.link(item, 'contrib-card thanks-card', prsUrl(contributor.login)); + link.append(this.image(contributor, 96)); + const body = this.span(link, 'thanks-card__body'); + this.span(body, 'thanks-card__login', `@${contributor.login}`).title = `@${contributor.login}`; + this.span(body, 'thanks-card__prs', prsLabel(contributor.prs)); + this.span(link, 'contrib-card__arrow thanks-card__arrow', '↗').setAttribute('aria-hidden', 'true'); + this.span(link, 'sr-only', ' (opens in a new tab)'); + return item; + } + + private more(hidden: number): HTMLLIElement { + const item = this.item(); + const link = document.createElement('a'); + link.className = 'thanks-more'; + link.href = this.dataset.moreHref || '#'; + this.span(link, '', `+${hidden}`).setAttribute('aria-hidden', 'true'); + this.span(link, 'sr-only', `${hidden} more ${contributorsLabel(hidden)}`); + item.append(link); + return item; + } + + private item(): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'thanks-item'; + item.dataset.revealItem = ''; + return item; + } + + private link(parent: HTMLElement, className: string, href: string): HTMLAnchorElement { + const link = document.createElement('a'); + link.className = className; + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + parent.append(link); + return link; + } + + private image(contributor: Contributor, size: number): HTMLElement { + const frame = document.createElement('span'); + frame.className = 'thanks-photo'; + frame.dataset.initial = contributor.login.charAt(0).toUpperCase(); + const image = document.createElement('img'); + image.src = avatarUrl(contributor.login, size * 2); + image.width = size; + image.height = size; + image.alt = ''; + image.loading = 'lazy'; + image.decoding = 'async'; + // A deleted or renamed account has no avatar; show the initial instead. + image.addEventListener('error', () => { frame.dataset.broken = ''; image.remove(); }, { once: true }); + frame.append(image); + return frame; + } + + private span(parent: Element, className: string, text?: string): HTMLElement { + const element = document.createElement('span'); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + parent.append(element); + return element; + } +} + +if (!customElements.get('scope-contributors')) customElements.define('scope-contributors', ScopeContributors); diff --git a/website/src/styles/contributors.css b/website/src/styles/contributors.css new file mode 100644 index 000000000..854cd64ec --- /dev/null +++ b/website/src/styles/contributors.css @@ -0,0 +1,112 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Community contributors (). An avatar wall on the + * landing page and a card grid on the Community > Contribute page. Shares + * the card, message and sample-tag styles in contribution-issues.css. + */ + +.thanks { display: block; color: var(--scope-text); font-family: var(--sl-font); line-height: 1.6; } +.thanks * { box-sizing: border-box; } +.thanks :is(p, ul, h3) { margin: 0; } +.thanks a { text-decoration: none; } +.thanks .contrib-message a, .thanks .contrib-fallback a { color: var(--scope-brand); font-weight: 600; text-decoration: underline; text-underline-offset: 0.2em; } + +/* States. Without JavaScript only the GitHub fallback link is shown. */ +.thanks[data-state='empty'] .thanks-list, .thanks[data-state='error'] .thanks-list { display: none; } +.thanks[data-state='empty'] .thanks-message--empty, .thanks[data-state='error'] .thanks-message--error { display: block; } +@media (scripting: none) { + .thanks-list, .thanks .contrib-count { display: none !important; } + .thanks .contrib-fallback { display: block; } +} +.thanks[data-animate] :is([data-reveal], [data-reveal-item]):not([data-revealed]) { opacity: 0; } + +.thanks-list { display: flex; flex-wrap: wrap; padding: 0 !important; list-style: none; } +.thanks-item { position: relative; display: flex; margin: 0 !important; } + +/* Avatar photo with an initial as fallback. */ +.thanks-photo { position: relative; display: grid; flex: none; place-items: center; width: var(--size); height: var(--size); border-radius: 50%; background: linear-gradient(135deg, var(--scope-brand), color-mix(in srgb, var(--scope-brand) 45%, var(--scope-pass))); overflow: hidden; } +.thanks-photo img { display: block; width: 100%; height: 100%; object-fit: cover; } +.thanks-photo[data-broken]::after { content: attr(data-initial); color: var(--scope-on-brand); font-weight: 700; font-size: calc(var(--size) * 0.42); } + +/* Landing heading. */ +.thanks-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1rem; margin-bottom: 1.25rem; } +.thanks-head h3 { display: inline-flex; align-items: center; gap: 0.6rem; color: var(--scope-text); font-size: 1.35rem; font-weight: 600; letter-spacing: -0.02em; } +.thanks-head .contrib-count { margin-top: 0 !important; } +.thanks-heart { display: inline-block; color: #e5484d; transform-origin: center; animation: thanks-beat 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; } +@keyframes thanks-beat { + 0%, 60%, 100% { transform: scale(1); } + 10% { transform: scale(1.28); } + 22% { transform: scale(0.96); } + 34% { transform: scale(1.16); } +} +.thanks-intro { max-width: 60ch; margin-bottom: 1.25rem !important; color: var(--scope-muted); font-size: 0.9rem; } + +/* Wall: overlapping avatars with a ring in the page background colour. */ +.thanks--wall { --size: 3.5rem; margin-top: 4rem; padding-top: 2.5rem; border-top: 1px solid var(--scope-border); } +.thanks--wall .thanks-list { row-gap: 0.75rem; padding-left: 0.6rem !important; } +.thanks--wall .thanks-item { margin-left: -0.6rem !important; } +.thanks--wall .thanks-item:hover, .thanks--wall .thanks-item:focus-within { z-index: 2; } +.thanks-avatar, .thanks-more { + position: relative; + display: grid; + place-items: center; + width: var(--size); + height: var(--size); + border-radius: 50%; + box-shadow: 0 0 0 3px var(--scope-bg); + transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease; +} +.thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible) { transform: translateY(-6px) scale(1.14); box-shadow: 0 0 0 3px var(--scope-bg), 0 0 0 5px var(--scope-brand), var(--scope-shadow); } +.thanks-avatar:focus-visible, .thanks-more:focus-visible { outline: none; } +.thanks-more { background: var(--scope-surface); border: 1px dashed var(--scope-border); color: var(--scope-brand); font: 650 0.85rem var(--sl-font-mono); } + +/* Tooltip; also the link's accessible name. */ +.thanks-tip { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.4rem 0.7rem; + border-radius: 0.6rem; + background: var(--scope-text); + color: var(--scope-bg); + font-size: 0.72rem; + line-height: 1.35; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translate(-50%, 6px) scale(0.92); + transition: opacity 0.2s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.thanks-tip::after { content: ''; position: absolute; top: 100%; left: 50%; border: 5px solid transparent; border-top-color: var(--scope-text); transform: translateX(-50%); } +.thanks-tip__login { font-weight: 650; } +.thanks-tip__prs { opacity: 0.75; } +.thanks-avatar:is(:hover, :focus-visible) .thanks-tip { opacity: 1; transform: translate(-50%, 0) scale(1); } + +/* Skeletons. */ +.thanks-skeleton { position: relative; overflow: hidden; background: var(--scope-inset); } +.thanks--wall .thanks-skeleton { width: var(--size); height: var(--size); border-radius: 50%; box-shadow: 0 0 0 3px var(--scope-bg); } +.thanks--grid .thanks-skeleton { height: 5.5rem; border: 1px solid var(--scope-border); border-radius: 1rem; } +.thanks-skeleton::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--scope-brand) 9%, transparent) 50%, transparent 80%); transform: translateX(-100%); animation: contrib-shimmer 1.6s ease-in-out infinite; } + +/* Grid (docs page): cards with the shared pointer glow. */ +.thanks--grid { --size: 3rem; margin-top: 1.5rem; } +.thanks--grid .thanks-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 1rem; } +.thanks--grid .thanks-item { display: block; } +.thanks-card.contrib-card { flex-direction: row; align-items: center; gap: 0.9rem; height: 100%; padding: 1rem 1.1rem; } +.thanks-card .thanks-photo { box-shadow: 0 0 0 2px var(--scope-surface), 0 0 0 3px var(--scope-border); transition: box-shadow 0.3s ease; } +.thanks-card:is(:hover, :focus-visible) .thanks-photo { box-shadow: 0 0 0 2px var(--scope-surface), 0 0 0 4px var(--scope-brand); } +.thanks-card__body { display: flex; flex-direction: column; min-width: 0; } +.thanks-card__login { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.thanks-card__prs { color: var(--scope-muted); font-size: 0.78rem; } +.thanks-card__arrow { margin-left: auto; color: var(--scope-brand); } + +@media (prefers-reduced-motion: reduce) { + .thanks *, .thanks *::before, .thanks *::after { animation: none !important; transition: none !important; } + .thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } + .thanks-avatar:is(:hover, :focus-visible) .thanks-tip { transform: translate(-50%, 0); } +} From fa8477094d4d29e4a019858886ad85da02d8d525 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:23:48 -0700 Subject: [PATCH 3/7] docs(website): document the community contributors section Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 4 +++- website/AGENTS.md | 26 ++++++++++++++++++++++++++ website/README.md | 17 ++++++++++------- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cadc4cf42..77a0c36dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -311,7 +311,9 @@ unblock a PR. ### 6. Close the loop -Thank the contributor and link any follow-up work. The PR owner checks the post-merge result and +Thank the contributor and link any follow-up work. Once merged, external authors appear in the +"Thank you" section of the [Contribute page](https://microsoft.github.io/scope/community/contribute/#thank-you) +and on the landing page within about an hour. The PR owner checks the post-merge result and coordinates a fix or revert if needed. If a contribution is not a fit, explain why and close it promptly. When a PR is waiting on the diff --git a/website/AGENTS.md b/website/AGENTS.md index 09d99a8a2..9bd6a8f46 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -262,6 +262,32 @@ build: - Motion uses the Web Animations API, with no animation library. Gate any new motion on `prefers-reduced-motion`. +### Community contributors + +The landing page (an avatar wall under the open calls) and the +"Thank you" section of **Community → Contribute** (a card grid) +thank external authors of merged pull requests: + +- The same workflow writes `contributors.jsonl` to `website-data` in + the same commit as `issues.jsonl`. It counts merged PRs whose + author is a user, not a bot (`[bot]` suffix), and whose + `authorAssociation` isn't `MEMBER`, `OWNER`, or `COLLABORATOR`. +- One line per person, `{"login":"…","prs":N}`, with sorted keys and + sorted by lowercase login. No avatar URLs or timestamps; avatars come + from `https://github.com/.png`, and a failed image shows the + login's initial. +- The browser sorts by merged PRs, then login. The wall shows up to + `limit` items; the last slot becomes a "+N" bubble that links to + the full grid. Each person links to their merged PRs. +- Set `PUBLIC_CONTRIBUTORS_URL` to point at another file + ([`contributors.ts`](src/scripts/contributors.ts)). In + `pnpm run dev` a failed fetch falls back to + [`contributors.sample.jsonl`](src/data/contributors.sample.jsonl), + which lists real external contributors only; don't pad it with other + accounts. +- Logins are user data: render them with `textContent` or attributes + only. + ### Style - Hard-wrap prose at ~70–80 columns for readable diffs. diff --git a/website/README.md b/website/README.md index f79fd0485..c502f922b 100644 --- a/website/README.md +++ b/website/README.md @@ -27,7 +27,7 @@ development, contribution, and support guidance live under │ ├── assets/ │ ├── components/ # Astro landing, interactive example, header, page title, site footer │ │ ├── community/ # article/talk lists + landing teaser -│ │ └── contribute/ # open calls for contributions (landing + page) +│ │ └── contribute/ # open calls + contributor thanks (landing + page) │ ├── content/ │ │ ├── docs/ # all user-facing pages (.md / .mdx) │ │ │ ├── introduction/ @@ -38,7 +38,7 @@ development, contribution, and support guidance live under │ │ │ └── community/ │ │ ├── articles/ # one YAML per published article │ │ └── talks/ # one YAML per talk -│ ├── data/ # dev-only sample of the contribution issues list +│ ├── data/ # dev-only samples: contribution issues, contributors │ ├── openapi/scope-openapi.json # artifact generated from the API registry │ ├── plugins/ │ │ ├── remark-base-path.mjs # applies the deployment base to internal links @@ -46,7 +46,8 @@ development, contribution, and support guidance live under │ ├── scripts/ │ │ ├── analytics.ts # consent-gated website-only 1DS analytics │ │ ├── flow-demo.ts # progressive-enhancement example controller -│ │ └── contribution-issues*.ts # open calls: data helpers + custom element +│ │ ├── contribution-issues*.ts # open calls: data helpers + custom element +│ │ └── contributors*.ts # contributor thanks: data helpers + custom element │ ├── styles/landing.css # shared brand tokens + scoped landing styles │ └── content.config.ts ├── astro.config.mjs # sidebar, plugins, starlight-openapi config @@ -195,13 +196,15 @@ table of contents, code examples, and previous/next navigation. separate Portal, CLI, and API entry points. Access requirements are explicit rather than promising instant access to a hosted service. - **Motion:** limited to the silent showreel, the interactive - example, and the open-calls cards (staggered entrance, count-up, - cursor glow, a rotation through all open calls), with pause controls + example, the open-calls cards (staggered entrance, count-up, + cursor glow, a rotation through all open calls), and the + contributor avatars (pop-in, hover tooltips), with pause controls and reduced-motion support. The logo and documentation chrome remain still. - **Contribution:** a "Help build Scope" section lists open calls - for contributions, refreshed hourly from a data branch without a - redeploy. See "Contribution issues" in `AGENTS.md`. + for contributions and thanks community contributors, refreshed + hourly from a data branch without a redeploy. See "Contribution + issues" and "Community contributors" in `AGENTS.md`. ### Showreel From 9b4c387de0bcc620f6a838acbed789313f155c9b Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:51:51 -0700 Subject: [PATCH 4/7] feat(website): thank all contributors, Microsoft and community Drop the authorAssociation filter so team members are thanked too (bots remain excluded). Refresh the dev sample from live data and update copy and docs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- website/AGENTS.md | 11 ++++++----- website/README.md | 4 ++-- website/src/components/contribute/Contributors.astro | 6 +++--- website/src/content/docs/community/contribute.mdx | 4 ++-- website/src/data/contributors.sample.jsonl | 5 +++++ website/src/scripts/contributors-element.ts | 2 +- website/src/scripts/contributors.ts | 2 +- website/src/styles/contributors.css | 2 +- 9 files changed, 22 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77a0c36dc..71af8cbf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -311,7 +311,7 @@ unblock a PR. ### 6. Close the loop -Thank the contributor and link any follow-up work. Once merged, external authors appear in the +Thank the contributor and link any follow-up work. Once merged, the author appears in the "Thank you" section of the [Contribute page](https://microsoft.github.io/scope/community/contribute/#thank-you) and on the landing page within about an hour. The PR owner checks the post-merge result and coordinates a fix or revert if needed. diff --git a/website/AGENTS.md b/website/AGENTS.md index 9bd6a8f46..d47b2f8ab 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -262,16 +262,17 @@ build: - Motion uses the Web Animations API, with no animation library. Gate any new motion on `prefers-reduced-motion`. -### Community contributors +### Contributors The landing page (an avatar wall under the open calls) and the "Thank you" section of **Community → Contribute** (a card grid) -thank external authors of merged pull requests: +thank every author of a merged pull request, community and +Microsoft alike: - The same workflow writes `contributors.jsonl` to `website-data` in the same commit as `issues.jsonl`. It counts merged PRs whose - author is a user, not a bot (`[bot]` suffix), and whose - `authorAssociation` isn't `MEMBER`, `OWNER`, or `COLLABORATOR`. + author is a user, not a bot (`[bot]` suffix). Team members count + too; don't filter on `authorAssociation`. - One line per person, `{"login":"…","prs":N}`, with sorted keys and sorted by lowercase login. No avatar URLs or timestamps; avatars come from `https://github.com/.png`, and a failed image shows the @@ -283,7 +284,7 @@ thank external authors of merged pull requests: ([`contributors.ts`](src/scripts/contributors.ts)). In `pnpm run dev` a failed fetch falls back to [`contributors.sample.jsonl`](src/data/contributors.sample.jsonl), - which lists real external contributors only; don't pad it with other + which lists real contributors only; don't pad it with other accounts. - Logins are user data: render them with `textContent` or attributes only. diff --git a/website/README.md b/website/README.md index c502f922b..124e13407 100644 --- a/website/README.md +++ b/website/README.md @@ -202,9 +202,9 @@ table of contents, code examples, and previous/next navigation. and reduced-motion support. The logo and documentation chrome remain still. - **Contribution:** a "Help build Scope" section lists open calls - for contributions and thanks community contributors, refreshed + for contributions and thanks every contributor, refreshed hourly from a data branch without a redeploy. See "Contribution - issues" and "Community contributors" in `AGENTS.md`. + issues" and "Contributors" in `AGENTS.md`. ### Showreel diff --git a/website/src/components/contribute/Contributors.astro b/website/src/components/contribute/Contributors.astro index bd44ac1f8..d433d5576 100644 --- a/website/src/components/contribute/Contributors.astro +++ b/website/src/components/contribute/Contributors.astro @@ -2,7 +2,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Thanks community contributors: external authors of merged pull requests. +// Thanks contributors: everyone (bots aside) who got a pull request merged. // The contribution-issues workflow publishes the list to the website-data // branch, and the browser fetches it. See website/AGENTS.md. import '../../styles/contribution-issues.css'; @@ -27,7 +27,7 @@ const skeletons = variant === 'wall' ? 8 : 3; -
    +
      { Array.from({ length: skeletons }, () => (

    - No community pull request has been merged yet. Be the first: + No pull request has been merged yet. Be the first: pick an open call.

    diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx index 882368a01..59ecc5da8 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -34,8 +34,8 @@ The list refreshes about every hour. ## Thank you -Thank you to everyone outside the Scope team who got a pull request -merged. Select a name to see their merged pull requests. This list also +Thank you to everyone who got a pull request merged, from the community +and the Scope team alike. Select a name to see their merged pull requests. This list also refreshes about every hour. diff --git a/website/src/data/contributors.sample.jsonl b/website/src/data/contributors.sample.jsonl index 0a7de7f71..d2fee8679 100644 --- a/website/src/data/contributors.sample.jsonl +++ b/website/src/data/contributors.sample.jsonl @@ -1,4 +1,9 @@ +{"login":"cedricvidal","prs":11} +{"login":"chanezon","prs":1} +{"login":"cmaneu","prs":2} +{"login":"duffney","prs":3} {"login":"Gile987","prs":1} {"login":"idhanth","prs":2} {"login":"itsmeasadali","prs":1} +{"login":"manekinekko","prs":14} {"login":"mnkj0021","prs":1} diff --git a/website/src/scripts/contributors-element.ts b/website/src/scripts/contributors-element.ts index c39d037f4..bed6680e8 100644 --- a/website/src/scripts/contributors-element.ts +++ b/website/src/scripts/contributors-element.ts @@ -23,7 +23,7 @@ const COUNT_UP_MS = 900; const MAX_STAGGER_STEPS = 24; /** - * Thanks the community: external authors of merged pull requests. Renders + * Thanks contributors: authors of merged pull requests, bots aside. Renders * an avatar wall (landing) or a card grid (Contribute page). Without * JavaScript a link to the GitHub contributors graph stays visible. Motion * is skipped entirely when the visitor prefers reduced motion. diff --git a/website/src/scripts/contributors.ts b/website/src/scripts/contributors.ts index b5def8e9c..6f301f869 100644 --- a/website/src/scripts/contributors.ts +++ b/website/src/scripts/contributors.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Data helpers for the community contributors thank-you section. The list is +// Data helpers for the contributors thank-you section. The list is // published as JSON Lines on the `website-data` branch by // .github/workflows/contribution-issues.yml and fetched in the browser. // This module has no DOM dependencies so it can be unit-tested in Node. diff --git a/website/src/styles/contributors.css b/website/src/styles/contributors.css index 854cd64ec..cb866502a 100644 --- a/website/src/styles/contributors.css +++ b/website/src/styles/contributors.css @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. * Licensed under the MIT License. * - * Community contributors (). An avatar wall on the + * Contributors (). An avatar wall on the * landing page and a card grid on the Community > Contribute page. Shares * the card, message and sample-tag styles in contribution-issues.css. */ From b2872b1a102bf1a08b4d95c13f0e8a4c9e19d90a Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 19:16:27 -0700 Subject: [PATCH 5/7] fix(website): contributors edge cases (tooltip overflow, empty state, grid paging) - Clamp wall tooltips inside the wall so edge avatars never make narrow pages scroll sideways. - Hide the intro and count pill in the empty and error states. - Page the grid at 30 cards with a 'Show all N contributors' button that focuses the first new card and never hides a single card. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/AGENTS.md | 9 ++- .../components/contribute/Contributors.astro | 16 +++- .../src/content/docs/community/contribute.mdx | 2 +- website/src/scripts/contributors-element.ts | 80 ++++++++++++++++++- website/src/scripts/contributors.test.mjs | 10 +++ website/src/scripts/contributors.ts | 8 ++ website/src/styles/contributors.css | 16 ++-- 7 files changed, 129 insertions(+), 12 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index d47b2f8ab..0a80521a0 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -279,7 +279,14 @@ Microsoft alike: login's initial. - The browser sorts by merged PRs, then login. The wall shows up to `limit` items; the last slot becomes a "+N" bubble that links to - the full grid. Each person links to their merged PRs. + the full grid. The grid shows `limit` cards (30 on the Contribute + page) and a "Show all N contributors" button, unless only one card + would stay hidden. Each person links to their merged PRs. +- Wall tooltips get a `--tip-shift` so they stay inside the wall (a + hidden tip on an edge avatar would otherwise make narrow pages scroll + sideways). The intro and count are hidden in the empty and error + states. Check 0, 1, a few, and more contributors than fit the width, + on desktop and mobile, when changing the layout. - Set `PUBLIC_CONTRIBUTORS_URL` to point at another file ([`contributors.ts`](src/scripts/contributors.ts)). In `pnpm run dev` a failed fetch falls back to diff --git a/website/src/components/contribute/Contributors.astro b/website/src/components/contribute/Contributors.astro index d433d5576..478d5fcb8 100644 --- a/website/src/components/contribute/Contributors.astro +++ b/website/src/components/contribute/Contributors.astro @@ -12,7 +12,10 @@ import { CONTRIBUTORS_DATA_URL, CONTRIBUTORS_GRAPH_URL } from '../../scripts/con interface Props { /** `wall` for the landing avatar wall, `grid` for the docs page cards. */ variant?: 'wall' | 'grid'; - /** Maximum number of items on the wall, including the "+N" bubble. */ + /** + * Wall: maximum number of items, including the "+N" bubble. + * Grid: cards shown before the "Show all" button. + */ limit?: number; } @@ -22,18 +25,27 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, ''); const moreHref = `${base}/community/contribute/#thank-you`; const openCallsHref = `${base}/community/contribute/#open-calls`; const skeletons = variant === 'wall' ? 8 : 3; +const listId = `thanks-list-${variant}`; ---

    -
      +
        { Array.from({ length: skeletons }, () => (
      + { + variant === 'grid' && ( + + ) + }

      No pull request has been merged yet. Be the first: pick an open call. diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx index 59ecc5da8..726437488 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -38,4 +38,4 @@ Thank you to everyone who got a pull request merged, from the community and the Scope team alike. Select a name to see their merged pull requests. This list also refreshes about every hour. - + diff --git a/website/src/scripts/contributors-element.ts b/website/src/scripts/contributors-element.ts index bed6680e8..d5c13f312 100644 --- a/website/src/scripts/contributors-element.ts +++ b/website/src/scripts/contributors-element.ts @@ -6,6 +6,7 @@ import { avatarUrl, contributorsLabel, fetchContributors, + firstPage, parseContributors, prsLabel, prsUrl, @@ -20,6 +21,8 @@ type State = 'loading' | 'ready' | 'empty' | 'error'; const EASE_OUT_BACK = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; const COUNT_UP_MS = 900; +// Matches the hover/focus scale of `.thanks-avatar` in contributors.css. +const LIFT_SCALE = 1.14; const MAX_STAGGER_STEPS = 24; /** @@ -36,6 +39,8 @@ class ScopeContributors extends HTMLElement { private loaded = false; private headingRevealed = false; private itemsRevealed = false; + private expanded = false; + private resize: ResizeObserver | undefined; private contributors: Contributor[] = []; connectedCallback() { @@ -50,11 +55,14 @@ class ScopeContributors extends HTMLElement { }, { rootMargin: '0px 0px -12% 0px' }); this.observer.observe(this); this.trackPointer(this.events.signal); + this.watchTips(this.events.signal); + this.querySelector('[data-show-all]')?.addEventListener('click', () => this.showAll(), { signal: this.events.signal }); void this.load(); } disconnectedCallback() { this.observer?.disconnect(); + this.resize?.disconnect(); this.events?.abort(); } @@ -93,14 +101,20 @@ class ScopeContributors extends HTMLElement { const list = this.querySelector('[data-list]'); if (!list) throw new Error('Missing contributors list'); this.contributors = sortForDisplay(contributors); - const { shown, hidden } = visible(this.contributors, Number(this.dataset.limit) || Infinity); + const limit = Number(this.dataset.limit) || Infinity; + const grid = this.variant === 'grid'; + const { shown, hidden } = grid + ? (this.expanded ? { shown: this.contributors, hidden: 0 } : firstPage(this.contributors, limit)) + : visible(this.contributors, limit); if (this.animates && this.inView) { await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); } - const items = shown.map((contributor) => (this.variant === 'grid' ? this.card(contributor) : this.avatar(contributor))); - if (hidden) items.push(this.more(hidden)); + const items = shown.map((contributor) => (grid ? this.card(contributor) : this.avatar(contributor))); + if (hidden && !grid) items.push(this.more(hidden)); list.replaceChildren(...items); + this.placeTips(); + this.updateShowAll(grid ? hidden : 0); const people = this.contributors.length; const prs = totalPrs(this.contributors); @@ -112,6 +126,66 @@ class ScopeContributors extends HTMLElement { this.reveal(); } + private updateShowAll(hidden: number) { + const button = this.querySelector('[data-show-all]'); + if (!button) return; + button.hidden = !hidden; + const label = button.querySelector('[data-show-all-label]'); + const total = this.contributors.length; + if (label) label.textContent = `Show all ${total} ${contributorsLabel(total)}`; + } + + /** Appends the rest of the grid and moves focus to the first new card. */ + private showAll() { + const list = this.querySelector('[data-list]'); + const button = this.querySelector('[data-show-all]'); + if (!list || !button || this.expanded) return; + this.expanded = true; + const rest = this.contributors.slice(list.children.length).map((contributor) => this.card(contributor)); + list.append(...rest); + button.hidden = true; + this.enter(rest, 0, 40, 'rise'); + rest[0]?.querySelector('a')?.focus(); + } + + /** + * Keeps every tooltip, hidden or lifted on hover, inside the wall: a tip + * centered on an edge avatar would otherwise stick out and make the page + * scroll sideways on narrow screens. The arrow keeps pointing at the + * avatar. Transforms on the avatars never move their center, so this is + * safe to run mid-animation. + */ + private placeTips(links: Iterable = this.querySelectorAll('.thanks-avatar')) { + if (this.variant !== 'wall') return; + const bounds = this.getBoundingClientRect(); + if (!bounds.width) return; + for (const link of links) { + const tip = link.querySelector('.thanks-tip'); + if (!tip) continue; + const rect = link.getBoundingClientRect(); + const center = rect.left + rect.width / 2; + const half = tip.offsetWidth / 2; + // Shift range that keeps the lifted (scaled) tip within the bounds. + const min = (bounds.left - center) / LIFT_SCALE + half; + const max = (bounds.right - center) / LIFT_SCALE - half; + const shift = min > 0 ? min : max < 0 ? max : 0; + tip.style.setProperty('--tip-shift', `${Math.round(shift)}px`); + } + } + + private watchTips(signal: AbortSignal) { + if (this.variant !== 'wall') return; + this.resize = new ResizeObserver(() => this.placeTips()); + this.resize.observe(this); + void document.fonts?.ready.then(() => { if (!signal.aborted) this.placeTips(); }); + const one = (event: Event) => { + const link = event.target instanceof Element ? event.target.closest('.thanks-avatar') : null; + if (link) this.placeTips([link]); + }; + this.addEventListener('pointerover', one, { signal, passive: true }); + this.addEventListener('focusin', one, { signal }); + } + private setState(state: State) { this.dataset.state = state; if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); diff --git a/website/src/scripts/contributors.test.mjs b/website/src/scripts/contributors.test.mjs index e5bcae5d7..a406a5f21 100644 --- a/website/src/scripts/contributors.test.mjs +++ b/website/src/scripts/contributors.test.mjs @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs'; import { avatarUrl, contributorsLabel, + firstPage, parseContributors, prsLabel, prsUrl, @@ -83,6 +84,15 @@ test('keeps a slot for the overflow bubble', () => { assert.deepEqual(visible(items, Infinity), { shown: items, hidden: 0 }); }); +test('pages the grid without hiding a single card', () => { + const items = [1, 2, 3, 4, 5, 6]; + assert.deepEqual(firstPage(items, 3), { shown: [1, 2, 3], hidden: 3 }); + assert.deepEqual(firstPage(items, 4), { shown: [1, 2, 3, 4], hidden: 2 }); + assert.deepEqual(firstPage(items, 5), { shown: items, hidden: 0 }); + assert.deepEqual(firstPage(items, Infinity), { shown: items, hidden: 0 }); + assert.deepEqual(firstPage([], 30), { shown: [], hidden: 0 }); +}); + test('the dev sample file is valid and sorted like the published file', () => { const text = readFileSync(new URL('../data/contributors.sample.jsonl', import.meta.url), 'utf8'); const { contributors, skipped } = parseContributors(text); diff --git a/website/src/scripts/contributors.ts b/website/src/scripts/contributors.ts index 6f301f869..f3535683e 100644 --- a/website/src/scripts/contributors.ts +++ b/website/src/scripts/contributors.ts @@ -93,6 +93,14 @@ export function visible(items: readonly T[], limit: number): { shown: T[]; hi return { shown, hidden: items.length - shown.length }; } +/** + * First page of the grid: `limit` items, or all of them when at most one + * more would remain, so "Show all" never reveals a single card. + */ +export function firstPage(items: readonly T[], limit: number): { shown: T[]; hidden: number } { + return visible(items, limit + 1); +} + /** Fetches and parses the published list. Throws on network or HTTP errors. */ export async function fetchContributors(url: string, timeoutMs = 8000): Promise { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); diff --git a/website/src/styles/contributors.css b/website/src/styles/contributors.css index cb866502a..88734fdba 100644 --- a/website/src/styles/contributors.css +++ b/website/src/styles/contributors.css @@ -15,6 +15,7 @@ /* States. Without JavaScript only the GitHub fallback link is shown. */ .thanks[data-state='empty'] .thanks-list, .thanks[data-state='error'] .thanks-list { display: none; } +.thanks:is([data-state='empty'], [data-state='error']) :is(.thanks-intro, [data-count-pill]) { display: none; } .thanks[data-state='empty'] .thanks-message--empty, .thanks[data-state='error'] .thanks-message--error { display: block; } @media (scripting: none) { .thanks-list, .thanks .contrib-count { display: none !important; } @@ -79,13 +80,14 @@ white-space: nowrap; pointer-events: none; opacity: 0; - transform: translate(-50%, 6px) scale(0.92); + /* --tip-shift is set by the element to keep the tip inside the wall. */ + transform: translate(calc(-50% + var(--tip-shift, 0px)), 6px) scale(0.92); transition: opacity 0.2s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } -.thanks-tip::after { content: ''; position: absolute; top: 100%; left: 50%; border: 5px solid transparent; border-top-color: var(--scope-text); transform: translateX(-50%); } +.thanks-tip::after { content: ''; position: absolute; top: 100%; left: calc(50% - var(--tip-shift, 0px)); border: 5px solid transparent; border-top-color: var(--scope-text); transform: translateX(-50%); } .thanks-tip__login { font-weight: 650; } .thanks-tip__prs { opacity: 0.75; } -.thanks-avatar:is(:hover, :focus-visible) .thanks-tip { opacity: 1; transform: translate(-50%, 0) scale(1); } +.thanks-avatar:is(:hover, :focus-visible) .thanks-tip { opacity: 1; transform: translate(calc(-50% + var(--tip-shift, 0px)), 0) scale(1); } /* Skeletons. */ .thanks-skeleton { position: relative; overflow: hidden; background: var(--scope-inset); } @@ -104,9 +106,13 @@ .thanks-card__login { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .thanks-card__prs { color: var(--scope-muted); font-size: 0.78rem; } .thanks-card__arrow { margin-left: auto; color: var(--scope-brand); } +.thanks-show-all { display: flex; align-items: center; gap: 0.5rem; margin: 1.5rem auto 0 !important; padding: 0.6rem 1.25rem; border: 1px solid var(--scope-border); border-radius: 999px; background: var(--scope-surface); color: var(--scope-brand); font: 600 0.9rem var(--sl-font); cursor: pointer; transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } +.thanks-show-all:is(:hover, :focus-visible) { border-color: var(--scope-brand); box-shadow: var(--scope-shadow); transform: translateY(-2px); } +.thanks-show-all:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 3px; } +.thanks-show-all[hidden] { display: none; } @media (prefers-reduced-motion: reduce) { .thanks *, .thanks *::before, .thanks *::after { animation: none !important; transition: none !important; } - .thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } - .thanks-avatar:is(:hover, :focus-visible) .thanks-tip { transform: translate(-50%, 0); } + .thanks-show-all:is(:hover, :focus-visible), .thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } + .thanks-avatar:is(:hover, :focus-visible) .thanks-tip { transform: translate(calc(-50% + var(--tip-shift, 0px)), 0); } } From a9344c19803f02e7d43ec892d274c2904d0aa51f Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 19:26:23 -0700 Subject: [PATCH 6/7] test(website): unit test tooltip clamping and the show all label Move the tooltip shift math and the 'Show all' label out of the element into pure helpers so they can be unit tested. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/src/scripts/contributors-element.ts | 11 +++---- website/src/scripts/contributors.test.mjs | 33 +++++++++++++++++++++ website/src/scripts/contributors.ts | 16 ++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/website/src/scripts/contributors-element.ts b/website/src/scripts/contributors-element.ts index d5c13f312..cf09f9bf1 100644 --- a/website/src/scripts/contributors-element.ts +++ b/website/src/scripts/contributors-element.ts @@ -10,7 +10,9 @@ import { parseContributors, prsLabel, prsUrl, + showAllLabel, sortForDisplay, + tipShift, totalPrs, visible, type Contributor, @@ -131,8 +133,7 @@ class ScopeContributors extends HTMLElement { if (!button) return; button.hidden = !hidden; const label = button.querySelector('[data-show-all-label]'); - const total = this.contributors.length; - if (label) label.textContent = `Show all ${total} ${contributorsLabel(total)}`; + if (label) label.textContent = showAllLabel(this.contributors.length); } /** Appends the rest of the grid and moves focus to the first new card. */ @@ -164,11 +165,7 @@ class ScopeContributors extends HTMLElement { if (!tip) continue; const rect = link.getBoundingClientRect(); const center = rect.left + rect.width / 2; - const half = tip.offsetWidth / 2; - // Shift range that keeps the lifted (scaled) tip within the bounds. - const min = (bounds.left - center) / LIFT_SCALE + half; - const max = (bounds.right - center) / LIFT_SCALE - half; - const shift = min > 0 ? min : max < 0 ? max : 0; + const shift = tipShift(center, tip.offsetWidth / 2, bounds.left, bounds.right, LIFT_SCALE); tip.style.setProperty('--tip-shift', `${Math.round(shift)}px`); } } diff --git a/website/src/scripts/contributors.test.mjs b/website/src/scripts/contributors.test.mjs index a406a5f21..5d69ff5ca 100644 --- a/website/src/scripts/contributors.test.mjs +++ b/website/src/scripts/contributors.test.mjs @@ -13,7 +13,9 @@ import { parseContributors, prsLabel, prsUrl, + showAllLabel, sortForDisplay, + tipShift, totalPrs, visible, } from './contributors.ts'; @@ -93,6 +95,37 @@ test('pages the grid without hiding a single card', () => { assert.deepEqual(firstPage([], 30), { shown: [], hidden: 0 }); }); +test('labels the show all button', () => { + assert.equal(showAllLabel(31), 'Show all 31 contributors'); + assert.equal(showAllLabel(1), 'Show all 1 contributor'); +}); + +test('keeps a centered tooltip in place', () => { + assert.equal(tipShift(500, 40, 0, 1000, 1.14), 0); + assert.equal(tipShift(500, 40, 0, 1000), 0); +}); + +test('shifts edge tooltips inside the bounds, even when scaled', () => { + const left = 10; + const right = 375; + const scale = 1.14; + const half = 60; + for (const center of [12, 30, 70, 190, 330, 360, 373]) { + const shift = tipShift(center, half, left, right, scale); + const tipLeft = center + scale * (shift - half); + const tipRight = center + scale * (shift + half); + assert.ok(tipLeft >= left - 1e-9, `left edge at center ${center}`); + assert.ok(tipRight <= right + 1e-9, `right edge at center ${center}`); + } + assert.ok(tipShift(12, half, left, right, scale) > 0); + assert.ok(tipShift(373, half, left, right, scale) < 0); +}); + +test('aligns a tooltip wider than the bounds to the left edge', () => { + const shift = tipShift(50, 80, 0, 100, 1); + assert.equal(50 + shift - 80, 0); +}); + test('the dev sample file is valid and sorted like the published file', () => { const text = readFileSync(new URL('../data/contributors.sample.jsonl', import.meta.url), 'utf8'); const { contributors, skipped } = parseContributors(text); diff --git a/website/src/scripts/contributors.ts b/website/src/scripts/contributors.ts index f3535683e..ed4cb71d2 100644 --- a/website/src/scripts/contributors.ts +++ b/website/src/scripts/contributors.ts @@ -101,6 +101,22 @@ export function firstPage(items: readonly T[], limit: number): { shown: T[]; return visible(items, limit + 1); } +/** Label of the grid's "Show all" button. */ +export function showAllLabel(total: number): string { + return `Show all ${total} ${contributorsLabel(total)}`; +} + +/** + * Horizontal shift that keeps a tooltip, centered on an avatar and scaled + * by `scale` on hover, inside `[left, right]`. A tip wider than the bounds + * stays aligned to the left edge. + */ +export function tipShift(center: number, halfWidth: number, left: number, right: number, scale = 1): number { + const min = (left - center) / scale + halfWidth; + const max = (right - center) / scale - halfWidth; + return min > 0 ? min : max < 0 ? max : 0; +} + /** Fetches and parses the published list. Throws on network or HTTP errors. */ export async function fetchContributors(url: string, timeoutMs = 8000): Promise { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); From 2f3c4709a1a1f3a8732ddb00e605c2ec8ff44eac Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Fri, 23 Oct 2026 13:03:21 -0700 Subject: [PATCH 7/7] test(website): allow published contributor data requests Extend the privacy test allowlist for the expected read-only contributor data request added by the stacked feature. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/tests/accessibility.spec.ts | 1 + website/tests/analytics.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/website/tests/accessibility.spec.ts b/website/tests/accessibility.spec.ts index 9ce00f1ae..736941dd8 100644 --- a/website/tests/accessibility.spec.ts +++ b/website/tests/accessibility.spec.ts @@ -6,6 +6,7 @@ import { expect, test, type Page } from '@playwright/test'; const PUBLISHED_DATA_URLS = new Set([ 'https://raw.githubusercontent.com/microsoft/scope/website-data/issues.jsonl', + 'https://raw.githubusercontent.com/microsoft/scope/website-data/contributors.jsonl', ]); async function expectAccessible(page: Page) { diff --git a/website/tests/analytics.spec.ts b/website/tests/analytics.spec.ts index 580672281..b08f9770b 100644 --- a/website/tests/analytics.spec.ts +++ b/website/tests/analytics.spec.ts @@ -5,6 +5,7 @@ import { expect, test, type Page } from '@playwright/test'; const PUBLISHED_DATA_URLS = new Set([ 'https://raw.githubusercontent.com/microsoft/scope/website-data/issues.jsonl', + 'https://raw.githubusercontent.com/microsoft/scope/website-data/contributors.jsonl', ]); interface AnalyticsTestState {