diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cadc4cf42..71af8cbf7 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, 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. 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..0a80521a0 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -262,6 +262,40 @@ build: - Motion uses the Web Animations API, with no animation library. Gate any new motion on `prefers-reduced-motion`. +### Contributors + +The landing page (an avatar wall under the open calls) and the +"Thank you" section of **Community → Contribute** (a card grid) +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). 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 + 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. 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 + [`contributors.sample.jsonl`](src/data/contributors.sample.jsonl), + which lists real 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..124e13407 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 every contributor, refreshed + hourly from a data branch without a redeploy. See "Contribution + issues" and "Contributors" in `AGENTS.md`. ### Showreel 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..478d5fcb8 --- /dev/null +++ b/website/src/components/contribute/Contributors.astro @@ -0,0 +1,65 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// 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'; +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'; + /** + * Wall: maximum number of items, including the "+N" bubble. + * Grid: cards shown before the "Show all" button. + */ + 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; +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. +

+

+ 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..726437488 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 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 new file mode 100644 index 000000000..d2fee8679 --- /dev/null +++ b/website/src/data/contributors.sample.jsonl @@ -0,0 +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 new file mode 100644 index 000000000..cf09f9bf1 --- /dev/null +++ b/website/src/scripts/contributors-element.ts @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CONTRIBUTORS_DATA_URL, + avatarUrl, + contributorsLabel, + fetchContributors, + firstPage, + parseContributors, + prsLabel, + prsUrl, + showAllLabel, + sortForDisplay, + tipShift, + 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; +// Matches the hover/focus scale of `.thanks-avatar` in contributors.css. +const LIFT_SCALE = 1.14; +const MAX_STAGGER_STEPS = 24; + +/** + * 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. + */ +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 expanded = false; + private resize: ResizeObserver | undefined; + 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); + 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(); + } + + 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 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) => (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); + 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 updateShowAll(hidden: number) { + const button = this.querySelector('[data-show-all]'); + if (!button) return; + button.hidden = !hidden; + const label = button.querySelector('[data-show-all-label]'); + if (label) label.textContent = showAllLabel(this.contributors.length); + } + + /** 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 shift = tipShift(center, tip.offsetWidth / 2, bounds.left, bounds.right, LIFT_SCALE); + 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'); + } + + 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/scripts/contributors.test.mjs b/website/src/scripts/contributors.test.mjs new file mode 100644 index 000000000..5d69ff5ca --- /dev/null +++ b/website/src/scripts/contributors.test.mjs @@ -0,0 +1,139 @@ +// 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, + firstPage, + parseContributors, + prsLabel, + prsUrl, + showAllLabel, + sortForDisplay, + tipShift, + 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('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('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); + 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..ed4cb71d2 --- /dev/null +++ b/website/src/scripts/contributors.ts @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// 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. + +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 }; +} + +/** + * 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); +} + +/** 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) }); + if (!response.ok) throw new Error(`Contributors request failed: HTTP ${response.status}`); + return parseContributors(await response.text()); +} diff --git a/website/src/styles/contributors.css b/website/src/styles/contributors.css new file mode 100644 index 000000000..88734fdba --- /dev/null +++ b/website/src/styles/contributors.css @@ -0,0 +1,118 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * 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: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; } + .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; + /* --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: 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(calc(-50% + var(--tip-shift, 0px)), 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); } +.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-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); } +} 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 {