From f75e2c7426b4cca1edbaf39290ba93447a9707d0 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:23:17 -0700 Subject: [PATCH 01/23] feat(website): parse and sort open calls for contributions Add helpers that parse the issues.jsonl list published to the website-data branch, order it for display, and build chips and GitHub links, plus a dev-only sample and node:test coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/package.json | 2 +- .../src/data/contribution-issues.sample.jsonl | 9 ++ .../src/scripts/contribution-issues.test.mjs | 98 +++++++++++++++ website/src/scripts/contribution-issues.ts | 119 ++++++++++++++++++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 website/src/data/contribution-issues.sample.jsonl create mode 100644 website/src/scripts/contribution-issues.test.mjs create mode 100644 website/src/scripts/contribution-issues.ts diff --git a/website/package.json b/website/package.json index f2d171e8a..c86b4c1fc 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ "start": "worktree-env && astro dev", "build": "astro build", "preview": "worktree-env && astro preview", - "test": "node --test src/plugins/*.test.mjs", + "test": "node --test src/plugins/*.test.mjs src/scripts/*.test.mjs", "astro": "astro", "refresh:openapi": "cd .. && pnpm --filter api generate:openapi" }, diff --git a/website/src/data/contribution-issues.sample.jsonl b/website/src/data/contribution-issues.sample.jsonl new file mode 100644 index 000000000..6a0c04a6b --- /dev/null +++ b/website/src/data/contribution-issues.sample.jsonl @@ -0,0 +1,9 @@ +{"labels":["good first issue"],"number":606,"title":"The workspace snapshot archive should include the dot files and dot folders"} +{"labels":["area: portal","difficulty: easy","good first issue"],"number":740,"title":"In the HAR view, when inspecting a row down the list, the detail informations on the right should stay visible"} +{"labels":["area: portal","difficulty: easy","good first issue","topic: ux"],"number":765,"title":"consider a different UX for the Run details UI"} +{"labels":["area: portal","good first issue","topic: ux"],"number":766,"title":"Improve UX when listing all runs"} +{"labels":["difficulty: moderate","good first issue","topic: ux","type: enhancement"],"number":912,"title":"Token Manager: introduce a structured 'endpoint' secret type returning {endpoint, apiKey, deployment}"} +{"labels":["good first issue","type: documentation"],"number":1275,"title":"Document model lifecycle and deprecation process"} +{"labels":["good first issue","topic: ui","type: documentation"],"number":1276,"title":"Clarify that prompt features only categorize prompts, don't impact runs"} +{"labels":["area: portal","good first issue","language: javascript"],"number":1386,"title":"Allow editing Azure AI Foundry model"} +{"labels":["difficulty: easy","good first issue"],"number":1455,"title":"Portal: Keys page layout shifts and list column gets squeezed when a key is selected"} diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs new file mode 100644 index 000000000..6fb16b4ac --- /dev/null +++ b/website/src/scripts/contribution-issues.test.mjs @@ -0,0 +1,98 @@ +// 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 { + chips, + contributionLabels, + countLabel, + issueUrl, + parseIssues, + searchUrl, + sortForDisplay, +} from './contribution-issues.ts'; + +const line = (issue) => JSON.stringify(issue); + +test('parses JSON Lines and ignores blank lines', () => { + const text = [ + line({ labels: ['good first issue'], number: 2, title: 'Two' }), + '', + line({ labels: ['help wanted'], number: 5, title: ' Five ' }), + '', + ].join('\n'); + const { issues, skipped } = parseIssues(text); + assert.deepEqual(issues, [ + { number: 2, title: 'Two', labels: ['good first issue'] }, + { number: 5, title: 'Five', labels: ['help wanted'] }, + ]); + assert.deepEqual(skipped, []); +}); + +test('treats an empty file as no issues', () => { + assert.deepEqual(parseIssues(''), { issues: [], skipped: [] }); +}); + +test('skips invalid, unlabeled and duplicate lines without dropping the rest', () => { + const text = [ + '{not json', + line({ labels: ['good first issue'], number: 0, title: 'Bad number' }), + line({ labels: ['good first issue'], number: 3, title: '' }), + line({ labels: ['area: portal'], number: 4, title: 'No contribution label' }), + line({ labels: 'help wanted', number: 6, title: 'Labels not an array' }), + line({ labels: ['help wanted'], number: 7, title: 'Kept' }), + line({ labels: ['help wanted'], number: 7, title: 'Duplicate' }), + ].join('\r\n'); + const { issues, skipped } = parseIssues(text); + assert.deepEqual(issues.map((issue) => issue.title), ['Kept']); + assert.deepEqual(skipped, [1, 2, 3, 4, 5, 7]); +}); + +test('sorts good first issues first, then newest', () => { + const sorted = sortForDisplay([ + { number: 10, title: 'a', labels: ['help wanted'] }, + { number: 3, title: 'b', labels: ['good first issue'] }, + { number: 12, title: 'c', labels: ['help wanted'] }, + { number: 8, title: 'd', labels: ['good first issue', 'help wanted'] }, + ]); + assert.deepEqual(sorted.map((issue) => issue.number), [8, 3, 12, 10]); +}); + +test('lists contribution labels in a fixed order', () => { + assert.deepEqual(contributionLabels({ labels: ['help wanted', 'x', 'good first issue'] }), ['good first issue', 'help wanted']); +}); + +test('builds chips for area, difficulty and type labels only', () => { + const result = chips({ + labels: ['type: documentation', 'priority: high', 'area: portal', 'good first issue', 'difficulty: easy', 'area: cli'], + }); + assert.deepEqual( + result.map(({ kind, value }) => `${kind}=${value}`), + ['area=portal', 'area=cli', 'difficulty=easy', 'type=documentation'], + ); +}); + +test('builds GitHub URLs', () => { + assert.equal(issueUrl(42), 'https://github.com/microsoft/scope/issues/42'); + const url = new URL(searchUrl('good first issue')); + assert.equal(url.searchParams.get('q'), 'is:issue is:open no:assignee label:"good first issue"'); +}); + +test('pluralizes the count label', () => { + assert.equal(countLabel(1), 'open call'); + assert.equal(countLabel(0), 'open calls'); + assert.equal(countLabel(4), 'open calls'); +}); + +test('the dev sample file is valid', () => { + const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); + const { issues, skipped } = parseIssues(text); + assert.deepEqual(skipped, []); + assert.ok(issues.length >= 4); + const numbers = issues.map((issue) => issue.number); + assert.deepEqual(numbers, [...numbers].sort((a, b) => a - b), 'sample is sorted by issue number like the published file'); +}); diff --git a/website/src/scripts/contribution-issues.ts b/website/src/scripts/contribution-issues.ts new file mode 100644 index 000000000..43ea6c97b --- /dev/null +++ b/website/src/scripts/contribution-issues.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Data helpers for the "call for contributions" issue list. 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. + +export const REPOSITORY = 'microsoft/scope'; +export const DATA_URL = `https://raw.githubusercontent.com/${REPOSITORY}/website-data/issues.jsonl`; +export const CONTRIBUTION_LABELS = ['good first issue', 'help wanted'] as const; + +export type ContributionLabel = (typeof CONTRIBUTION_LABELS)[number]; + +export interface ContributionIssue { + number: number; + title: string; + labels: string[]; +} + +export interface ParseResult { + issues: ContributionIssue[]; + /** 1-based line numbers that were skipped because they were invalid. */ + skipped: number[]; +} + +/** Label prefixes shown as chips; all other labels stay hidden. */ +const CHIP_PREFIXES = ['area', 'difficulty', 'type'] as const; +export type ChipKind = (typeof CHIP_PREFIXES)[number]; + +export interface Chip { + kind: ChipKind; + value: string; + label: string; +} + +function isIssue(value: unknown): value is ContributionIssue { + if (typeof value !== 'object' || value === null) return false; + const { number, title, labels } = value as Record; + return ( + Number.isSafeInteger(number) && + (number as number) > 0 && + typeof title === 'string' && + title.trim() !== '' && + Array.isArray(labels) && + labels.every((label) => typeof label === 'string') + ); +} + +/** + * Parses the JSON Lines payload. Invalid lines, and lines without a + * contribution label, are skipped so one bad line can't hide the list. + */ +export function parseIssues(text: string): ParseResult { + const issues: ContributionIssue[] = []; + 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 (!isIssue(value) || !contributionLabels(value).length || seen.has(value.number)) { + skipped.push(index + 1); + return; + } + seen.add(value.number); + issues.push({ number: value.number, title: value.title.trim(), labels: [...value.labels] }); + }); + return { issues, skipped }; +} + +/** The contribution labels an issue carries, `good first issue` first. */ +export function contributionLabels(issue: Pick): ContributionLabel[] { + return CONTRIBUTION_LABELS.filter((label) => issue.labels.includes(label)); +} + +/** Display order: `good first issue` first, then newest (highest number). */ +export function sortForDisplay(issues: readonly ContributionIssue[]): ContributionIssue[] { + const rank = (issue: ContributionIssue) => (issue.labels.includes('good first issue') ? 0 : 1); + return [...issues].sort((a, b) => rank(a) - rank(b) || b.number - a.number); +} + +/** `area:`, `difficulty:` and `type:` labels as chips, in that order. */ +export function chips(issue: Pick): Chip[] { + const result: Chip[] = []; + for (const kind of CHIP_PREFIXES) { + for (const label of issue.labels) { + const match = /^([a-z]+):\s*(.+)$/i.exec(label); + if (match && match[1].toLowerCase() === kind) result.push({ kind, value: match[2].trim(), label }); + } + } + return result; +} + +export function issueUrl(number: number): string { + return `https://github.com/${REPOSITORY}/issues/${number}`; +} + +/** GitHub issue search for open, unassigned issues with the given label. */ +export function searchUrl(label: ContributionLabel): string { + const query = `is:issue is:open no:assignee label:"${label}"`; + return `https://github.com/${REPOSITORY}/issues?q=${encodeURIComponent(query)}`; +} + +export function countLabel(count: number): string { + return count === 1 ? 'open call' : 'open calls'; +} + +/** Fetches and parses the published list. Throws on network or HTTP errors. */ +export async function fetchIssues(url: string, timeoutMs = 8000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`Contribution issues request failed: HTTP ${response.status}`); + return parseIssues(await response.text()); +} From 89c093aef5187cf364df50835bc794f6608f2297 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:23:17 -0700 Subject: [PATCH 02/23] feat(website): highlight open calls for contributions Add an animated "Help build Scope" section to the landing page and a Community > Contribute page listing open, unassigned good first issue and help wanted issues. Uses the Web Animations API (no library), respects reduced motion, and falls back to GitHub search links without JavaScript. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/astro.config.mjs | 5 +- website/src/components/Home.astro | 2 + .../contribute/ContributeTeaser.astro | 28 +++ .../contribute/ContributionIssues.astro | 60 +++++ .../src/content/docs/community/contribute.mdx | 28 +++ .../scripts/contribution-issues-element.ts | 231 ++++++++++++++++++ website/src/styles/contribution-issues.css | 121 +++++++++ 7 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 website/src/components/contribute/ContributeTeaser.astro create mode 100644 website/src/components/contribute/ContributionIssues.astro create mode 100644 website/src/content/docs/community/contribute.mdx create mode 100644 website/src/scripts/contribution-issues-element.ts create mode 100644 website/src/styles/contribution-issues.css diff --git a/website/astro.config.mjs b/website/astro.config.mjs index df3d89cb4..36753423e 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -99,7 +99,10 @@ export default defineConfig({ }, { label: 'Community', - items: [{ label: 'Articles & talks', slug: 'community/articles-and-talks' }], + items: [ + { label: 'Articles & talks', slug: 'community/articles-and-talks' }, + { label: 'Contribute', slug: 'community/contribute' }, + ], }, { label: 'Getting Started', diff --git a/website/src/components/Home.astro b/website/src/components/Home.astro index e25a24123..5602edd9c 100644 --- a/website/src/components/Home.astro +++ b/website/src/components/Home.astro @@ -1,5 +1,6 @@ --- import CommunityTeaser from './community/CommunityTeaser.astro'; +import ContributeTeaser from './contribute/ContributeTeaser.astro'; import FlowDemo from './FlowDemo.astro'; const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); @@ -137,6 +138,7 @@ const paths = [ +

Bring your own question

diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro new file mode 100644 index 000000000..853c27e36 --- /dev/null +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -0,0 +1,28 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import ContributionIssues from './ContributionIssues.astro'; + +const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contribute/`; +--- + +
+ +
+
+

Open source

+

Help build Scope

+
+
+

Pick an open call, then open a pull request that references it. No need to ask first.

+

+ + + +

+
+
+
+ See all open calls +
diff --git a/website/src/components/contribute/ContributionIssues.astro b/website/src/components/contribute/ContributionIssues.astro new file mode 100644 index 000000000..2e18e450d --- /dev/null +++ b/website/src/components/contribute/ContributionIssues.astro @@ -0,0 +1,60 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Lists open, unassigned issues labeled "good first issue" or "help wanted". +// The contribution-issues workflow publishes the list to the website-data +// branch, and the browser fetches it, so it stays fresh without +// redeploying the site. See website/AGENTS.md. +import '../../styles/contribution-issues.css'; +import { CONTRIBUTION_LABELS, DATA_URL, searchUrl } from '../../scripts/contribution-issues'; + +interface Props { + /** Maximum number of issues to show. Omit to show them all. */ + limit?: number; + /** `cards` for the animated landing grid, `list` for the docs page. */ + variant?: 'cards' | 'list'; +} + +const { limit, variant = 'cards' } = Astro.props; +const src = import.meta.env.PUBLIC_CONTRIBUTION_ISSUES_URL || DATA_URL; +const skeletons = Math.min(limit ?? 4, 4); +const [goodFirst, helpWanted] = CONTRIBUTION_LABELS.map((label) => ({ label, href: searchUrl(label) })); +--- + + + + +
    + { + Array.from({ length: skeletons }, () => ( + + )) + } +
+

+ Every open call is taken right now. Check back soon, or browse + open issues on GitHub (opens in a new tab). +

+

+ We couldn't load the list. Browse + good first issues (opens in a new tab) + or + help wanted issues (opens in a new tab) + on GitHub. +

+

+ Browse open calls on GitHub: + good first issues (opens in a new tab) + · + help wanted (opens in a new tab) +

+
+ + diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx new file mode 100644 index 000000000..d3f8355d3 --- /dev/null +++ b/website/src/content/docs/community/contribute.mdx @@ -0,0 +1,28 @@ +--- +title: Contribute +description: Open issues where the Scope team is calling for contributions, and how to pick one up. +--- + +import ContributionIssues from '../../../components/contribute/ContributionIssues.astro'; + +Scope is open source, and some issues are set aside for the community. +Each one below is open and nobody is assigned to it yet. + +## How to pick one up + +1. Choose an issue below. **Good first issue** marks a small, well-scoped + change. **Help wanted** marks work where an outside hand helps most. +2. Read the + [contributing guide](https://github.com/microsoft/scope/blob/main/CONTRIBUTING.md) + to set up your environment and learn the conventions. +3. Open a pull request that references the issue, for example + `Fixes #123`. You don't need to comment on the issue or ask first. + +The first time you contribute, a bot asks you to sign the Microsoft +Contributor License Agreement (CLA). You only sign it once. + +## Open calls + +The list refreshes about every hour. + + diff --git a/website/src/scripts/contribution-issues-element.ts b/website/src/scripts/contribution-issues-element.ts new file mode 100644 index 000000000..b4257593d --- /dev/null +++ b/website/src/scripts/contribution-issues-element.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + DATA_URL, + chips, + contributionLabels, + countLabel, + fetchIssues, + issueUrl, + parseIssues, + sortForDisplay, + type ContributionIssue, + type ParseResult, +} from './contribution-issues'; + +type State = 'loading' | 'ready' | 'empty' | 'error'; + +const EASE_OUT_BACK = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; +const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'; +const STAGGER_MS = 70; +const MAX_STAGGER_STEPS = 8; +const COUNT_UP_MS = 900; + +const BADGE_TEXT = { 'good first issue': 'Good first issue', 'help wanted': 'Help wanted' } as const; + +/** + * Renders the open "call for contributions" issues. Without JavaScript the + * server-rendered fallback links stay visible. Motion is skipped entirely + * when the visitor prefers reduced motion. + */ +class ScopeContributionIssues 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 total = 0; + + connectedCallback() { + this.events = new AbortController(); + this.toggleAttribute('data-animate', this.animates); + 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 async load() { + let result: ParseResult; + let sample = false; + try { + result = await fetchIssues(this.dataset.src || DATA_URL); + } catch (error) { + // In local dev the data branch may not exist yet, or there may be no + // network. Fall back to the committed sample. The DEV branch is + // removed from production builds, sample included. + if (import.meta.env.DEV) { + console.info('[contribution-issues] Live list unavailable; using the dev sample.', error); + const { default: text } = await import('../data/contribution-issues.sample.jsonl?raw'); + result = parseIssues(text); + sample = true; + } else { + console.warn('[contribution-issues] Could not load the live list.', error); + this.setState('error'); + return; + } + } + if (result.skipped.length) { + console.warn(`[contribution-issues] Skipped invalid lines: ${result.skipped.join(', ')}`); + } + this.querySelectorAll('[data-sample-tag]').forEach((tag) => { tag.hidden = !sample; }); + await this.render(result.issues); + } + + private async render(issues: ContributionIssue[]) { + const list = this.querySelector('[data-list]'); + if (!list) throw new Error('Missing contribution issues list'); + const sorted = sortForDisplay(issues); + const limit = Number(this.dataset.limit) || Infinity; + this.total = sorted.length; + + // Fade the skeleton out before swapping in real items, when on screen. + if (this.animates && this.inView) { + await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); + } + list.replaceChildren(...sorted.slice(0, limit).map((issue) => this.item(issue))); + list.removeAttribute('aria-busy'); + this.querySelectorAll('[data-count-label]').forEach((label) => { label.textContent = countLabel(this.total); }); + this.querySelectorAll('[data-count-text]').forEach((text) => { text.textContent = `${this.total} ${countLabel(this.total)}`; }); + this.loaded = true; + this.setState(this.total ? 'ready' : 'empty'); + this.reveal(); + } + + private setState(state: State) { + this.dataset.state = state; + if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); + } + + /** Runs entrance animations once the section is visible and data is in. */ + private reveal() { + if (!this.inView) return; + if (!this.headingRevealed) { + this.headingRevealed = true; + this.enter([...this.querySelectorAll('[data-reveal]')], 0, 90); + } + if (this.loaded && !this.itemsRevealed) { + this.itemsRevealed = true; + this.enter([...this.querySelectorAll('[data-reveal-item]')], 180, STAGGER_MS); + if (this.total) this.countUp(); + } + } + + private enter(elements: HTMLElement[], baseDelay: number, stagger: number) { + elements.forEach((element, index) => { + element.dataset.revealed = ''; + if (!this.animates) return; + element.animate( + [{ opacity: 0, transform: 'translateY(18px) scale(0.98)' }, { opacity: 1, transform: 'none' }], + { duration: 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; + pill.dataset.shown = ''; + if (!this.animates) { + counter.textContent = String(this.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(this.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 }); + } + + // Issue titles and labels are user-supplied, so every value is set as text. + private item(issue: ContributionIssue): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'contrib-item'; + item.dataset.revealItem = ''; + + const link = document.createElement('a'); + link.className = 'contrib-card'; + link.href = issueUrl(issue.number); + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + + this.text(link, 'span', issue.title, 'contrib-card__title'); + + const top = this.el(link, 'span', 'contrib-card__top'); + const badges = this.el(top, 'span', 'contrib-badges'); + for (const label of contributionLabels(issue)) { + this.text(badges, 'span', BADGE_TEXT[label], 'contrib-badge').dataset.label = label.replace(/\s+/g, '-'); + } + const number = this.el(top, 'span', 'contrib-number'); + this.text(number, 'span', 'Issue ', 'sr-only'); + number.append(`#${issue.number}`); + + const issueChips = chips(issue); + if (issueChips.length) { + const row = this.el(link, 'span', 'contrib-chips'); + for (const chip of issueChips) { + const element = this.text(row, 'span', chip.value, 'contrib-chip'); + element.dataset.kind = chip.kind; + element.dataset.value = chip.value.toLowerCase(); + element.title = chip.label; + } + } + + const cta = this.el(link, 'span', 'contrib-card__cta'); + cta.append('View on GitHub'); + this.text(cta, 'span', '↗', 'contrib-card__arrow').setAttribute('aria-hidden', 'true'); + this.text(link, 'span', ' (opens in a new tab)', 'sr-only'); + + item.append(link); + return item; + } + + private el(parent: Element, tag: 'span', className: string): HTMLElement { + const element = document.createElement(tag); + element.className = className; + parent.append(element); + return element; + } + + private text(parent: Element, tag: 'span', value: string, className: string): HTMLElement { + const element = this.el(parent, tag, className); + element.textContent = value; + return element; + } +} + +if (!customElements.get('scope-contribution-issues')) customElements.define('scope-contribution-issues', ScopeContributionIssues); diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css new file mode 100644 index 000000000..05c80c0ba --- /dev/null +++ b/website/src/styles/contribution-issues.css @@ -0,0 +1,121 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Open calls for contributions (). Used on the + * landing page (cards) and the Community > Contribute page (list). + */ + +.contrib { display: block; color: var(--scope-text); font-family: var(--sl-font); line-height: 1.6; } +.contrib * { box-sizing: border-box; } +.contrib :is(p, ul) { margin: 0; } +.contrib a { text-decoration: none; } +.contrib .contrib-message a, .contrib .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 links are shown. */ +.contrib-fallback, .contrib-message { display: none; } +.contrib[data-state='empty'] .contrib-list, .contrib[data-state='error'] .contrib-list { display: none; } +.contrib[data-state='empty'] .contrib-message--empty, .contrib[data-state='error'] .contrib-message--error { display: block; } +@media (scripting: none) { + .contrib-list, .contrib-count { display: none !important; } + .contrib-fallback { display: block; } +} +.contrib[data-animate] :is([data-reveal], [data-reveal-item]):not([data-revealed]) { opacity: 0; } + +.contrib-message, .contrib-fallback { padding: 1.2rem 1.4rem; border: 1px dashed var(--scope-border); border-radius: 1rem; background: var(--scope-inset); color: var(--scope-muted); font-size: 0.9rem; } + +.contrib-sample-tag { display: inline-block; margin-bottom: 0.9rem !important; padding: 0.2rem 0.6rem; border: 1px dashed var(--scope-fail); border-radius: 100px; color: var(--scope-fail); font: 600 0.65rem var(--sl-font-mono); letter-spacing: 0.1em; text-transform: uppercase; } +.contrib-sample-tag[hidden] { display: none; } + +/* Count pill (landing). */ +.contrib-count { display: inline-flex; align-items: center; gap: 0.55rem; margin-top: 1rem !important; padding: 0.35rem 0.85rem 0.35rem 0.7rem; border: 1px solid var(--scope-border); border-radius: 100px; background: var(--scope-surface); color: var(--scope-text); font-size: 0.8rem; font-weight: 600; visibility: hidden; } +.contrib-count[data-shown] { visibility: visible; } +.contrib-count__value { color: var(--scope-brand); font-family: var(--sl-font-mono); font-variant-numeric: tabular-nums; } +.contrib-count__pulse { position: relative; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--scope-pass); } +.contrib-count__pulse::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: var(--scope-pass); animation: contrib-pulse 2.2s cubic-bezier(0.22, 1, 0.36, 1) infinite; } +@keyframes contrib-pulse { from { transform: scale(1); opacity: 0.6; } to { transform: scale(3.2); opacity: 0; } } + +/* List and cards. */ +.contrib-list { display: grid; gap: 1rem; padding: 0 !important; list-style: none; } +.contrib--cards .contrib-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.contrib-item { display: flex; min-width: 0; margin: 0 !important; } + +.contrib-card { + --x: 50%; + --y: 0%; + position: relative; + isolation: isolate; + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + padding: 1.3rem 1.4rem 1.2rem; + border: 1px solid var(--scope-border); + border-radius: 1rem; + background: var(--scope-surface); + color: var(--scope-text); + overflow: hidden; + transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease, border-color 0.35s ease; +} +.contrib-card::before, .contrib-card::after { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity 0.35s ease; } +/* Border glow that follows the cursor. */ +.contrib-card::before { + padding: 1px; + background: radial-gradient(16rem circle at var(--x) var(--y), var(--scope-brand), transparent 70%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); +} +/* Soft spotlight behind the content. */ +.contrib-card::after { z-index: -1; background: radial-gradient(26rem circle at var(--x) var(--y), color-mix(in srgb, var(--scope-brand) 11%, transparent), transparent 65%); } +.contrib-card:is(:hover, :focus-visible) { transform: translateY(-4px); border-color: color-mix(in srgb, var(--scope-brand) 45%, var(--scope-border)); box-shadow: var(--scope-shadow); } +.contrib-card:is(:hover, :focus-visible)::before, .contrib-card:is(:hover, :focus-visible)::after { opacity: 1; } +.contrib-card:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 3px; } + +/* The title comes first in the DOM so it leads the link's accessible name. */ +.contrib-card__top { order: -1; display: flex; align-items: center; gap: 0.75rem; } +.contrib-card__title { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 1.02rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.4; text-wrap: pretty; } +.contrib-badges { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.contrib-badge { --badge: var(--scope-brand); padding: 0.15rem 0.55rem; border: 1px solid color-mix(in srgb, var(--badge) 40%, transparent); border-radius: 100px; background: color-mix(in srgb, var(--badge) 12%, transparent); color: var(--badge); font: 650 0.62rem/1.6 var(--sl-font-mono); letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; } +.contrib-badge[data-label='good-first-issue'] { --badge: var(--scope-pass); } +.contrib-number { margin-left: auto; color: var(--scope-muted); font: 0.75rem var(--sl-font-mono); } + +.contrib-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.contrib-chip { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.1rem 0.55rem; border: 1px solid var(--scope-border); border-radius: 100px; background: var(--scope-inset); color: var(--scope-muted); font-size: 0.72rem; } +.contrib-chip[data-kind='difficulty']::before { content: ''; width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--scope-muted); } +.contrib-chip[data-value='easy']::before { background: var(--scope-pass); } +.contrib-chip[data-value='moderate']::before { background: var(--scope-brand); } +.contrib-chip[data-value='hard']::before { background: var(--scope-fail); } + +.contrib-card__cta { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: auto; padding-top: 0.35rem; color: var(--scope-brand); font-size: 0.8rem; font-weight: 600; } +.contrib-card__arrow { display: inline-block; transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } +.contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: translate(3px, -3px); } + +/* Loading skeleton with a shimmer. */ +.contrib-skeleton { position: relative; flex-direction: column; justify-content: center; gap: 0.8rem; min-height: 10.5rem; padding: 1.4rem; border: 1px solid var(--scope-border); border-radius: 1rem; background: var(--scope-surface); overflow: hidden; } +.contrib--list .contrib-skeleton { min-height: 7.5rem; } +.contrib-skeleton__line { display: block; width: 100%; height: 0.8rem; border-radius: 0.4rem; background: var(--scope-inset); } +.contrib-skeleton__line--short { width: 35%; } +.contrib-skeleton__line--mid { width: 65%; } +.contrib-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; } +@keyframes contrib-shimmer { to { transform: translateX(100%); } } + +/* Landing section. */ +.scope-contrib__intro { max-width: 36ch; color: var(--scope-muted); font-size: 0.9rem; } +.scope-contrib__all { margin-top: 1.5rem; } + +/* Docs page list: same cards, one per row, a little tighter. */ +.contrib--list { margin-top: 1.5rem; } +.contrib--list .contrib-card { padding: 1.1rem 1.25rem; } +.contrib--list .contrib-card__title { -webkit-line-clamp: unset; } +.contrib--list .contrib-card__cta { display: none; } + +@media (max-width: 48rem) { + .contrib--cards .contrib-list { grid-template-columns: 1fr; } + .scope-contrib__intro { margin-top: 1rem; max-width: 55ch; } +} + +@media (prefers-reduced-motion: reduce) { + .contrib *, .contrib *::before, .contrib *::after { animation: none !important; transition: none !important; } + .contrib-card:is(:hover, :focus-visible), .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } +} From 00f3607fb4090d60ed4096e9d099a45d90a569e3 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:23:17 -0700 Subject: [PATCH 03/23] docs(website): document the contribution issues list Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 1 + website/AGENTS.md | 37 +++++++++++++++++++++++++++++++++++-- website/README.md | 17 ++++++++++++----- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6479819b4..390463a62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -337,6 +337,7 @@ Automations depend on these exact names: | Test Improver issues, PRs, and monthly-summary searches | `type: automation`, `topic: testing` | | Dependabot | `type: dependencies`, plus `language: javascript` or `language: rust` | | Pull Request Labeler | Area, topic, language, and type labels from `.github/labeler.yml` | +| Website open calls ([Contribute page](https://microsoft.github.io/scope/community/contribute/)) | Open, unassigned `good first issue` or `help wanted` issues | Use `area: reporting` for Scope's benchmark reporting component, not daily repository activity. Worker upgrade routing uses `type: worker-update`, not the broader `area: worker`. diff --git a/website/AGENTS.md b/website/AGENTS.md index cb56d354a..1d31a2026 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -204,6 +204,39 @@ components open them in a new tab (`target="_blank" rel="noopener noreferrer"`) with a screen-reader "(opens in a new tab)" hint; keep that pattern for any new external link. +### Contribution issues + +The landing page ("Help build Scope") and **Community → Contribute** +list open, unassigned issues labeled `good first issue` or +`help wanted`. The list is data, not content, so it isn't in the +build: + +- [`.github/workflows/contribution-issues.yml`](../.github/workflows/contribution-issues.yml) + runs every hour, on issue events, and on demand. It writes + `issues.jsonl` to the orphan `website-data` branch and commits only + when the file changes. Run it once by hand (`workflow_dispatch`) to + create the branch. +- `issues.jsonl` is deterministic so diffs stay small: one issue per + line, sorted by number, with sorted keys and only `number`, `title`, + and sorted `labels`. Don't add fields that change on every run + (timestamps, comment counts). An empty file means there are no open + calls. +- The browser fetches the file from `raw.githubusercontent.com` + (`DATA_URL` in + [`contribution-issues.ts`](src/scripts/contribution-issues.ts)). + Set `PUBLIC_CONTRIBUTION_ISSUES_URL` to point at another file. +- Display order is decided client-side: good first issues first, then + newest. `area:`, `difficulty:`, and `type:` labels show as chips. +- In `pnpm run dev`, if the fetch fails (no branch yet, offline), the + list falls back to + [`contribution-issues.sample.jsonl`](src/data/contribution-issues.sample.jsonl) + and shows a "Sample data · dev only" tag. Production builds drop + that branch and the sample. +- Titles and labels come from GitHub users. Render them with + `textContent` only, never `innerHTML`. +- Motion uses the Web Animations API, with no animation library. Gate + any new motion on `prefers-reduced-motion`. + ### Style - Hard-wrap prose at ~70–80 columns for readable diffs. @@ -220,7 +253,7 @@ tab)" hint; keep that pattern for any new external link. ```sh pnpm install -pnpm test # plugin regressions, using Node's built-in test runner +pnpm test # plugin and script tests, using Node's built-in test runner pnpm run build # writes dist/ pnpm run dev # local preview at http://localhost:4321 pnpm run refresh:openapi # generate the OpenAPI snapshot from scope-core @@ -229,7 +262,7 @@ pnpm run refresh:openapi # generate the OpenAPI snapshot from scope-core Both `pnpm test` and `pnpm run build` must pass. The public build uses `SITE=https://microsoft.github.io BASE_PATH=/scope pnpm run build`; exercise that configuration when changing links or deployment settings, -not just the local `/` default. The current snapshot produces **203 +not just the local `/` default. The current snapshot produces **215 pages**, including the generated API reference. An unexpected drop in page count can indicate a content collection file failed to parse. diff --git a/website/README.md b/website/README.md index cfed25ed4..2d853de2c 100644 --- a/website/README.md +++ b/website/README.md @@ -17,7 +17,8 @@ The product and this documentation site live in ├── src/ │ ├── assets/ │ ├── components/ # Astro landing, interactive example, header, page title -│ │ └── community/ # article/talk lists + landing teaser +│ │ ├── community/ # article/talk lists + landing teaser +│ │ └── contribute/ # open calls for contributions (landing + page) │ ├── content/ │ │ ├── docs/ # all user-facing pages (.md / .mdx) │ │ │ ├── introduction/ @@ -28,11 +29,13 @@ The product and this documentation site live in │ │ │ └── community/ │ │ ├── articles/ # one YAML per published article │ │ └── talks/ # one YAML per talk +│ ├── data/ # dev-only sample of the contribution issues list │ ├── openapi/scope-openapi.json # artifact generated from the API registry │ ├── plugins/ │ │ ├── remark-base-path.mjs # applies the deployment base to internal links │ │ └── remark-http-snippets.mjs # turns ```http blocks into multi-language tabs │ ├── scripts/flow-demo.ts # progressive-enhancement example controller +│ ├── scripts/contribution-issues*.ts # open calls: data helpers + custom element │ ├── styles/landing.css # shared brand tokens + scoped landing styles │ └── content.config.ts ├── astro.config.mjs # sidebar, plugins, starlight-openapi config @@ -51,7 +54,7 @@ Sidebar order is defined in `astro.config.mjs`, not by directory order. | `pnpm dev` | Start dev server at the worktree's `DOC_PORT` (fallback: 4300) | | `pnpm build` | Build the production site to `./dist/` | | `pnpm preview` | Preview the production build locally | -| `pnpm test` | Test site plugins with Node's built-in test runner | +| `pnpm test` | Test site plugins and scripts with Node's built-in test runner | | `pnpm refresh:openapi` | Generate the OpenAPI snapshot from `scope-core` | ## Authoring docs @@ -140,9 +143,13 @@ table of contents, code examples, and previous/next navigation. - **Onboarding:** a concrete example before terminology, followed by 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 and the interactive - example, with pause controls and reduced-motion support. The logo - and documentation chrome remain still. +- **Motion:** limited to the silent showreel, the interactive + example, and the open-calls cards (staggered entrance, count-up, + cursor glow), with pause controls where needed 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`. ### Showreel From 3eeff06e764a99bafab85145faefd9f508d43636 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:36:33 -0700 Subject: [PATCH 04/23] feat(website): rotate open calls on the landing page Cycle through all open calls 4 at a time every 7 seconds, with page dots that fill as a progress bar and a pause button. Rotation holds on hover, focus, off screen, and hidden tabs, and never autoplays under reduced motion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/AGENTS.md | 5 + website/README.md | 5 +- .../contribute/ContributeTeaser.astro | 2 +- .../contribute/ContributionIssues.astro | 6 +- .../scripts/contribution-issues-element.ts | 160 +++++++++++++++++- .../src/scripts/contribution-issues.test.mjs | 19 +++ website/src/scripts/contribution-issues.ts | 17 ++ website/src/styles/contribution-issues.css | 17 ++ 8 files changed, 225 insertions(+), 6 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 1d31a2026..b1fa317c5 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -227,6 +227,11 @@ build: Set `PUBLIC_CONTRIBUTION_ISSUES_URL` to point at another file. - Display order is decided client-side: good first issues first, then newest. `area:`, `difficulty:`, and `type:` labels show as chips. +- The landing section shows 4 issues at a time and rotates through all + of them every 7 seconds (`rotate` prop). The active page dot fills + as a progress bar. Rotation pauses on hover, on keyboard focus, when + off screen or the tab is hidden, and with the pause button. It never + autoplays under reduced motion; the dots still work. - In `pnpm run dev`, if the fetch fails (no branch yet, offline), the list falls back to [`contribution-issues.sample.jsonl`](src/data/contribution-issues.sample.jsonl) diff --git a/website/README.md b/website/README.md index 2d853de2c..a2b2a9675 100644 --- a/website/README.md +++ b/website/README.md @@ -145,8 +145,9 @@ table of contents, code examples, and previous/next navigation. 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), with pause controls where needed and reduced-motion - support. The logo and documentation chrome remain still. + cursor glow, a rotation through all open calls), 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`. diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro index 853c27e36..5f780de16 100644 --- a/website/src/components/contribute/ContributeTeaser.astro +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -8,7 +8,7 @@ const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contri ---
- +

Open source

diff --git a/website/src/components/contribute/ContributionIssues.astro b/website/src/components/contribute/ContributionIssues.astro index 2e18e450d..118dd5e98 100644 --- a/website/src/components/contribute/ContributionIssues.astro +++ b/website/src/components/contribute/ContributionIssues.astro @@ -14,15 +14,17 @@ interface Props { limit?: number; /** `cards` for the animated landing grid, `list` for the docs page. */ variant?: 'cards' | 'list'; + /** Cycle through all issues, `limit` at a time. Needs `limit`. */ + rotate?: boolean; } -const { limit, variant = 'cards' } = Astro.props; +const { limit, variant = 'cards', rotate = false } = Astro.props; const src = import.meta.env.PUBLIC_CONTRIBUTION_ISSUES_URL || DATA_URL; const skeletons = Math.min(limit ?? 4, 4); const [goodFirst, helpWanted] = CONTRIBUTION_LABELS.map((label) => ({ label, href: searchUrl(label) })); --- - +
    diff --git a/website/src/scripts/contribution-issues-element.ts b/website/src/scripts/contribution-issues-element.ts index b4257593d..1098482d9 100644 --- a/website/src/scripts/contribution-issues-element.ts +++ b/website/src/scripts/contribution-issues-element.ts @@ -8,6 +8,8 @@ import { countLabel, fetchIssues, issueUrl, + pageCount, + pageItems, parseIssues, sortForDisplay, type ContributionIssue, @@ -21,6 +23,10 @@ const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'; const STAGGER_MS = 70; const MAX_STAGGER_STEPS = 8; const COUNT_UP_MS = 900; +const ROTATE_MS = 7000; + +/** Reasons the rotation is on hold. It advances only when there are none. */ +type Hold = 'user' | 'hover' | 'focus' | 'offscreen' | 'hidden'; const BADGE_TEXT = { 'good first issue': 'Good first issue', 'help wanted': 'Help wanted' } as const; @@ -38,6 +44,16 @@ class ScopeContributionIssues extends HTMLElement { private headingRevealed = false; private itemsRevealed = false; private total = 0; + private issues: ContributionIssue[] = []; + private size = Infinity; + private page = 0; + private pages = 1; + private switching = false; + private holds = new Set(['offscreen']); + private timer: Animation | undefined; + private visibility: IntersectionObserver | undefined; + private dots: HTMLButtonElement[] = []; + private toggle: HTMLButtonElement | undefined; connectedCallback() { this.events = new AbortController(); @@ -50,11 +66,17 @@ class ScopeContributionIssues extends HTMLElement { }, { rootMargin: '0px 0px -12% 0px' }); this.observer.observe(this); this.trackPointer(this.events.signal); + // Card heights change with the viewport; drop the rotation's height lock. + window.addEventListener('resize', () => { + this.querySelector('[data-list]')?.style.removeProperty('min-height'); + }, { signal: this.events.signal, passive: true }); void this.load(); } disconnectedCallback() { this.observer?.disconnect(); + this.visibility?.disconnect(); + this.timer?.cancel(); this.events?.abort(); } @@ -95,20 +117,28 @@ class ScopeContributionIssues extends HTMLElement { const sorted = sortForDisplay(issues); const limit = Number(this.dataset.limit) || Infinity; this.total = sorted.length; + this.issues = sorted; + this.size = limit; + this.pages = Number.isFinite(limit) ? Math.max(pageCount(sorted.length, limit), 1) : 1; // Fade the skeleton out before swapping in real items, when on screen. if (this.animates && this.inView) { await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); } - list.replaceChildren(...sorted.slice(0, limit).map((issue) => this.item(issue))); + list.replaceChildren(...this.pageIssues(0).map((issue) => this.item(issue))); list.removeAttribute('aria-busy'); this.querySelectorAll('[data-count-label]').forEach((label) => { label.textContent = countLabel(this.total); }); this.querySelectorAll('[data-count-text]').forEach((text) => { text.textContent = `${this.total} ${countLabel(this.total)}`; }); this.loaded = true; this.setState(this.total ? 'ready' : 'empty'); + if (this.hasAttribute('data-rotate') && this.pages > 1) this.setupRotation(list); this.reveal(); } + private pageIssues(page: number): ContributionIssue[] { + return Number.isFinite(this.size) ? pageItems(this.issues, page, this.size) : this.issues; + } + private setState(state: State) { this.dataset.state = state; if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); @@ -159,6 +189,134 @@ class ScopeContributionIssues extends HTMLElement { requestAnimationFrame(tick); } + /** + * Cycles through pages of issues. Controls: one dot per page (the active + * one fills as a progress bar) and a pause button. The rotation holds + * while the list is hovered or focused, off screen, or the tab is hidden, + * and never autoplays under reduced motion. + */ + private setupRotation(list: HTMLElement) { + const signal = this.events!.signal; + const controls = document.createElement('div'); + controls.className = 'contrib-rotation'; + + const dots = document.createElement('div'); + dots.className = 'contrib-dots'; + dots.setAttribute('role', 'group'); + dots.setAttribute('aria-label', 'Open call sets'); + this.dots = Array.from({ length: this.pages }, (_, page) => { + const dot = document.createElement('button'); + dot.type = 'button'; + dot.className = 'contrib-dot'; + dot.setAttribute('aria-label', `Show set ${page + 1} of ${this.pages}`); + const track = this.el(dot, 'span', 'contrib-dot__track'); + this.el(track, 'span', 'contrib-dot__fill'); + dot.addEventListener('click', () => void this.goTo(page), { signal }); + dots.append(dot); + return dot; + }); + + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'contrib-toggle'; + this.el(toggle, 'span', 'contrib-toggle__icon').setAttribute('aria-hidden', 'true'); + this.text(toggle, 'span', '', 'sr-only'); + toggle.addEventListener('click', () => { + if (this.holds.has('user')) this.holds.delete('user'); + else this.holds.add('user'); + this.sync(); + }, { signal }); + this.toggle = toggle; + + controls.append(dots, toggle); + list.after(controls); + + const hold = (reason: Hold, on: boolean) => { + if (on) this.holds.add(reason); + else this.holds.delete(reason); + this.sync(); + }; + list.addEventListener('pointerenter', () => hold('hover', true), { signal }); + list.addEventListener('pointerleave', () => hold('hover', false), { signal }); + list.addEventListener('focusin', () => hold('focus', true), { signal }); + list.addEventListener('focusout', (event) => { + if (!list.contains(event.relatedTarget as Node | null)) hold('focus', false); + }, { signal }); + document.addEventListener('visibilitychange', () => hold('hidden', document.hidden), { signal }); + this.motion.addEventListener('change', () => { + this.toggleAttribute('data-animate', this.animates); + this.restartTimer(); + }, { signal }); + this.visibility = new IntersectionObserver((entries) => { + hold('offscreen', !entries.some((entry) => entry.isIntersecting)); + }, { threshold: 0.25 }); + this.visibility.observe(list); + + this.holds.add('offscreen'); + if (document.hidden) this.holds.add('hidden'); + this.updateDots(); + this.restartTimer(); + } + + /** Restarts the progress fill on the active dot; its end advances the page. */ + private restartTimer() { + this.timer?.cancel(); + this.timer = undefined; + const autoplay = this.animates; + if (this.toggle) this.toggle.hidden = !autoplay; + const fill = this.dots[this.page]?.querySelector('.contrib-dot__fill'); + if (autoplay && fill) { + this.timer = fill.animate([{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], { duration: ROTATE_MS, easing: 'linear' }); + this.timer.onfinish = () => void this.goTo((this.page + 1) % this.pages); + } + this.sync(); + } + + /** Plays or pauses the timer from the current holds. */ + private sync() { + const paused = this.holds.size > 0; + if (this.timer) { + if (paused) this.timer.pause(); + else this.timer.play(); + } + if (this.toggle) { + const userPaused = this.holds.has('user'); + this.toggle.dataset.paused = String(userPaused); + this.toggle.lastElementChild!.textContent = userPaused ? 'Resume rotating open calls' : 'Pause rotating open calls'; + } + } + + private updateDots() { + this.dots.forEach((dot, page) => { + if (page === this.page) dot.setAttribute('aria-current', 'true'); + else dot.removeAttribute('aria-current'); + }); + } + + private async goTo(page: number) { + const list = this.querySelector('[data-list]'); + if (!list || this.switching || page === this.page) return; + this.switching = true; + this.timer?.cancel(); + this.page = page; + this.updateDots(); + + const outgoing = [...list.children] as HTMLElement[]; + if (this.animates) { + await Promise.all(outgoing.map((element, index) => element.animate( + [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(-12px) scale(0.98)' }], + { duration: 240, delay: index * 45, easing: EASE_OUT, fill: 'forwards' }, + ).finished.catch(() => undefined))); + } + // Keep the grid from collapsing while card heights change between sets. + list.style.minHeight = `${Math.max(parseFloat(list.style.minHeight) || 0, list.offsetHeight)}px`; + const incoming = this.pageIssues(page).map((issue) => this.item(issue)); + list.replaceChildren(...incoming); + this.enter(incoming, 0, STAGGER_MS); + this.switching = false; + this.restartTimer(); + } + /** Feeds the cursor position to the card glow through CSS variables. */ private trackPointer(signal: AbortSignal) { this.addEventListener('pointermove', (event) => { diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs index 6fb16b4ac..09dd976d8 100644 --- a/website/src/scripts/contribution-issues.test.mjs +++ b/website/src/scripts/contribution-issues.test.mjs @@ -11,6 +11,8 @@ import { contributionLabels, countLabel, issueUrl, + pageCount, + pageItems, parseIssues, searchUrl, sortForDisplay, @@ -88,6 +90,23 @@ test('pluralizes the count label', () => { assert.equal(countLabel(4), 'open calls'); }); +test('counts rotation pages', () => { + assert.equal(pageCount(0, 4), 0); + assert.equal(pageCount(3, 4), 1); + assert.equal(pageCount(4, 4), 1); + assert.equal(pageCount(9, 4), 3); + assert.equal(pageCount(9, 0), 0); +}); + +test('fills every rotation page, wrapping the last one', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + assert.deepEqual(pageItems(items, 0, 4), [1, 2, 3, 4]); + assert.deepEqual(pageItems(items, 1, 4), [5, 6, 7, 8]); + assert.deepEqual(pageItems(items, 2, 4), [9, 1, 2, 3]); + assert.deepEqual(pageItems([1, 2], 0, 4), [1, 2]); + assert.deepEqual(pageItems([], 0, 4), []); +}); + test('the dev sample file is valid', () => { const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); const { issues, skipped } = parseIssues(text); diff --git a/website/src/scripts/contribution-issues.ts b/website/src/scripts/contribution-issues.ts index 43ea6c97b..b0988ea79 100644 --- a/website/src/scripts/contribution-issues.ts +++ b/website/src/scripts/contribution-issues.ts @@ -111,6 +111,23 @@ export function countLabel(count: number): string { return count === 1 ? 'open call' : 'open calls'; } +/** Number of rotation pages needed to show `total` items `size` at a time. */ +export function pageCount(total: number, size: number): number { + if (total <= 0 || size <= 0) return 0; + return Math.ceil(total / size); +} + +/** + * Items on a rotation page. A short last page wraps around to the start, + * so every page is full when there are at least `size` items. + */ +export function pageItems(items: readonly T[], page: number, size: number): T[] { + if (!items.length || size <= 0) return []; + if (items.length <= size) return [...items]; + const start = page * size; + return Array.from({ length: size }, (_, index) => items[(start + index) % items.length]!); +} + /** Fetches and parses the published list. Throws on network or HTTP errors. */ export async function fetchIssues(url: string, timeoutMs = 8000): Promise { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css index 05c80c0ba..920bead0a 100644 --- a/website/src/styles/contribution-issues.css +++ b/website/src/styles/contribution-issues.css @@ -100,6 +100,23 @@ .contrib-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; } @keyframes contrib-shimmer { to { transform: translateX(100%); } } +/* Rotation controls (landing): one dot per set, the active one fills as a progress bar. */ +.contrib-rotation { display: flex; align-items: center; gap: 0.5rem; margin-top: 1.1rem; } +.contrib-dots { display: flex; align-items: center; } +.contrib-dot, .contrib-toggle { display: inline-grid; place-items: center; min-width: 1.5rem; height: 1.5rem; padding: 0 0.15rem; border: 0; border-radius: 100px; background: none; color: var(--scope-muted); cursor: pointer; } +.contrib-dot:focus-visible, .contrib-toggle:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 1px; } +.contrib-dot__track { position: relative; display: block; width: 0.5rem; height: 0.5rem; border-radius: 100px; background: var(--scope-border); overflow: hidden; transition: width 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.3s ease; } +.contrib-dot:hover .contrib-dot__track { background: var(--scope-muted); } +.contrib-dot[aria-current] .contrib-dot__track { width: 2.25rem; background: color-mix(in srgb, var(--scope-brand) 22%, var(--scope-border)); } +.contrib-dot__fill { position: absolute; inset: 0; border-radius: inherit; background: var(--scope-brand); transform: scaleX(0); transform-origin: left; } +.contrib-dot[aria-current] .contrib-dot__fill { transform: scaleX(1); } +.contrib-toggle { margin-left: 0.25rem; border: 1px solid var(--scope-border); width: 1.75rem; height: 1.75rem; background: var(--scope-surface); transition: border-color 0.3s ease, color 0.3s ease; } +.contrib-toggle:hover { border-color: var(--scope-brand); color: var(--scope-brand); } +.contrib-toggle[hidden] { display: none; } +/* Pause: two bars. Play: a triangle. */ +.contrib-toggle__icon { width: 0.55rem; height: 0.6rem; border-inline: 0.17rem solid currentColor; } +.contrib-toggle[data-paused='true'] .contrib-toggle__icon { width: 0; height: 0; margin-left: 0.15rem; border-block: 0.32rem solid transparent; border-inline: 0.5rem solid currentColor; border-right-width: 0; } + /* Landing section. */ .scope-contrib__intro { max-width: 36ch; color: var(--scope-muted); font-size: 0.9rem; } .scope-contrib__all { margin-top: 1.5rem; } From a8fb3857f0a091187b0ca77791de533de704de06 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:47:36 -0700 Subject: [PATCH 05/23] test(website): cover help wanted issues in the dev sample Add help wanted and dual-labeled issues to the sample, and assert it covers every contribution label combination. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/src/data/contribution-issues.sample.jsonl | 3 +++ website/src/scripts/contribution-issues.test.mjs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/website/src/data/contribution-issues.sample.jsonl b/website/src/data/contribution-issues.sample.jsonl index 6a0c04a6b..de291c76f 100644 --- a/website/src/data/contribution-issues.sample.jsonl +++ b/website/src/data/contribution-issues.sample.jsonl @@ -5,5 +5,8 @@ {"labels":["difficulty: moderate","good first issue","topic: ux","type: enhancement"],"number":912,"title":"Token Manager: introduce a structured 'endpoint' secret type returning {endpoint, apiKey, deployment}"} {"labels":["good first issue","type: documentation"],"number":1275,"title":"Document model lifecycle and deprecation process"} {"labels":["good first issue","topic: ui","type: documentation"],"number":1276,"title":"Clarify that prompt features only categorize prompts, don't impact runs"} +{"labels":["area: portal","difficulty: hard","help wanted","topic: ux","type: enhancement"],"number":1311,"title":"Portal: scale the project switcher and first-run picker to many projects (search/pagination)"} {"labels":["area: portal","good first issue","language: javascript"],"number":1386,"title":"Allow editing Azure AI Foundry model"} +{"labels":["good first issue","help wanted"],"number":1419,"title":"Write a quick guide to help Windows devs setup their env with WSL"} +{"labels":["help wanted"],"number":1420,"title":"Ensure we can run the project from a DevContainer/Codespaces"} {"labels":["difficulty: easy","good first issue"],"number":1455,"title":"Portal: Keys page layout shifts and list column gets squeezed when a key is selected"} diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs index 09dd976d8..548ef4fb4 100644 --- a/website/src/scripts/contribution-issues.test.mjs +++ b/website/src/scripts/contribution-issues.test.mjs @@ -114,4 +114,8 @@ test('the dev sample file is valid', () => { assert.ok(issues.length >= 4); const numbers = issues.map((issue) => issue.number); assert.deepEqual(numbers, [...numbers].sort((a, b) => a - b), 'sample is sorted by issue number like the published file'); + const labelSets = issues.map((issue) => contributionLabels(issue).join('+')); + for (const combination of ['good first issue', 'help wanted', 'good first issue+help wanted']) { + assert.ok(labelSets.includes(combination), `sample covers issues labeled ${combination}`); + } }); From 69a328b09efcefc48d0e46c13ba137dc3a7f4c6b Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:19:28 -0700 Subject: [PATCH 06/23] 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 7426b566dce7df05897e605221c34f36d5b35c56 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:23:10 -0700 Subject: [PATCH 07/23] 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 5f780de16..55f58ed09 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/`; --- @@ -25,4 +26,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 d3f8355d3..ed0eb66fe 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'; Scope is open source, and some issues are set aside for the community. Each one below is open and nobody is assigned to it yet. @@ -26,3 +27,11 @@ Contributor License Agreement (CLA). You only sign it once. 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 2411155e3a6b20a4b4eb78b85a6e59d2c8269425 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:23:48 -0700 Subject: [PATCH 08/23] 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 | 15 +++++++++------ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 390463a62..38af27dd7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -309,7 +309,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 fee557f29..3c50ca459 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -248,6 +248,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 d887c2f64..312a0e199 100644 --- a/website/README.md +++ b/website/README.md @@ -18,7 +18,7 @@ The product and this documentation site live in │ ├── 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/ @@ -29,13 +29,14 @@ The product and this documentation site live in │ │ │ └── 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 │ │ └── remark-http-snippets.mjs # turns ```http blocks into multi-language tabs │ ├── scripts/flow-demo.ts # progressive-enhancement example controller │ ├── scripts/contribution-issues*.ts # open calls: data helpers + custom element +│ ├── scripts/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 @@ -144,13 +145,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 e0fe7cc7fb17715b0e0365012a7df4c9b1b9eba2 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 18:51:51 -0700 Subject: [PATCH 09/23] 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 38af27dd7..e9210e13c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -309,7 +309,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 3c50ca459..996d8fc63 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -248,16 +248,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 @@ -269,7 +270,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 312a0e199..2cc33f50e 100644 --- a/website/README.md +++ b/website/README.md @@ -151,9 +151,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 ed0eb66fe..19aed33b8 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -30,8 +30,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 6ffbbe88fe4f1417628e0a44b37dcd337400d0fb Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 19:16:27 -0700 Subject: [PATCH 10/23] 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 996d8fc63..769f0ceb6 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -265,7 +265,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 19aed33b8..5d00bdc54 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -34,4 +34,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 e67f7aca502652403ffa3f647fd9153ac1360fc6 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 19:26:23 -0700 Subject: [PATCH 11/23] 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 e17beb36fc318aedab3a74eaab84728cf75da257 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 20 Oct 2026 12:23:23 -0700 Subject: [PATCH 12/23] fix(website): cover contribution call edge cases Keep large sets of rotation controls within narrow viewports and add pagination boundary coverage for 0 through 300 calls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/AGENTS.md | 7 ++++++- website/src/scripts/contribution-issues.test.mjs | 16 ++++++++++++++++ website/src/styles/contribution-issues.css | 4 ++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 769f0ceb6..045cb1de0 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -237,7 +237,12 @@ build: of them every 7 seconds (`rotate` prop). The active page dot fills as a progress bar. Rotation pauses on hover, on keyboard focus, when off screen or the tab is hidden, and with the pause button. It never - autoplays under reduced motion; the dots still work. + autoplays under reduced motion; the dots still work. Page dots wrap + rather than widening narrow pages when there are many sets. +- Check 0, 1, a partial page, exactly 4, 5 (the first wrapped page), + several pages, and enough pages to exceed the viewport, on desktop + and mobile. Also check reduced motion and long user-supplied titles + and chip values. - In `pnpm run dev`, if the fetch fails (no branch yet, offline), the list falls back to [`contribution-issues.sample.jsonl`](src/data/contribution-issues.sample.jsonl) diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs index 548ef4fb4..e2a8166e0 100644 --- a/website/src/scripts/contribution-issues.test.mjs +++ b/website/src/scripts/contribution-issues.test.mjs @@ -92,9 +92,13 @@ test('pluralizes the count label', () => { test('counts rotation pages', () => { assert.equal(pageCount(0, 4), 0); + assert.equal(pageCount(1, 4), 1); assert.equal(pageCount(3, 4), 1); assert.equal(pageCount(4, 4), 1); + assert.equal(pageCount(5, 4), 2); assert.equal(pageCount(9, 4), 3); + assert.equal(pageCount(60, 4), 15); + assert.equal(pageCount(300, 4), 75); assert.equal(pageCount(9, 0), 0); }); @@ -107,6 +111,18 @@ test('fills every rotation page, wrapping the last one', () => { assert.deepEqual(pageItems([], 0, 4), []); }); +test('keeps boundary and high-volume rotation pages full', () => { + for (const total of [5, 9, 60, 300]) { + const items = Array.from({ length: total }, (_, index) => index + 1); + const pages = pageCount(total, 4); + const rendered = Array.from({ length: pages }, (_, page) => pageItems(items, page, 4)); + assert.ok(rendered.every((page) => page.length === 4), `${total} issues has a short page`); + for (const item of items) { + assert.ok(rendered.some((page) => page.includes(item)), `${total} issues never renders item ${item}`); + } + } +}); + test('the dev sample file is valid', () => { const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); const { issues, skipped } = parseIssues(text); diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css index 920bead0a..f02dc2d8c 100644 --- a/website/src/styles/contribution-issues.css +++ b/website/src/styles/contribution-issues.css @@ -101,8 +101,8 @@ @keyframes contrib-shimmer { to { transform: translateX(100%); } } /* Rotation controls (landing): one dot per set, the active one fills as a progress bar. */ -.contrib-rotation { display: flex; align-items: center; gap: 0.5rem; margin-top: 1.1rem; } -.contrib-dots { display: flex; align-items: center; } +.contrib-rotation { display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.1rem; } +.contrib-dots { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: center; min-width: 0; } .contrib-dot, .contrib-toggle { display: inline-grid; place-items: center; min-width: 1.5rem; height: 1.5rem; padding: 0 0.15rem; border: 0; border-radius: 100px; background: none; color: var(--scope-muted); cursor: pointer; } .contrib-dot:focus-visible, .contrib-toggle:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 1px; } .contrib-dot__track { position: relative; display: block; width: 0.5rem; height: 0.5rem; border-radius: 100px; background: var(--scope-border); overflow: hidden; transition: width 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.3s ease; } From 5dc67685481215112bfe48a587aacf49f1d44660 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Fri, 23 Oct 2026 12:45:34 -0700 Subject: [PATCH 13/23] fix(website): hide contribution calls when empty Hide the complete calls surface, including headings, instructions, and adjacent links, when the published issue list is empty. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/AGENTS.md | 3 +- .../contribute/ContributeTeaser.astro | 36 ++++++++++--------- .../src/content/docs/community/contribute.mdx | 4 +++ website/src/styles/contribution-issues.css | 6 ++++ 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 045cb1de0..1669d1ce3 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -226,7 +226,8 @@ build: line, sorted by number, with sorted keys and only `number`, `title`, and sorted `labels`. Don't add fields that change on every run (timestamps, comment counts). An empty file means there are no open - calls. + calls; both pages hide the complete calls surface, including its + heading and adjacent links, while leaving the rest of the page intact. - The browser fetches the file from `raw.githubusercontent.com` (`DATA_URL` in [`contribution-issues.ts`](src/scripts/contribution-issues.ts)). diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro index 55f58ed09..24fdd7fff 100644 --- a/website/src/components/contribute/ContributeTeaser.astro +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -8,24 +8,26 @@ import Contributors from './Contributors.astro'; const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contribute/`; --- -

      - -
      -
      -

      Open source

      -

      Help build Scope

      +
      +
      + +
      +
      +

      Open source

      +

      Help build Scope

      +
      +
      +

      Pick an open call, then open a pull request that references it. No need to ask first.

      +

      + + + +

      +
      -
      -

      Pick an open call, then open a pull request that references it. No need to ask first.

      -

      - - - -

      -
      -
      - - See all open calls + + See all open calls +

      Thank you, contributors

      diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx index 5d00bdc54..726437488 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -6,6 +6,8 @@ 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'; +
      + Scope is open source, and some issues are set aside for the community. Each one below is open and nobody is assigned to it yet. @@ -28,6 +30,8 @@ The list refreshes about every hour. +
      + ## Thank you Thank you to everyone who got a pull request merged, from the community diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css index f02dc2d8c..9e467e5ef 100644 --- a/website/src/styles/contribution-issues.css +++ b/website/src/styles/contribution-issues.css @@ -16,6 +16,12 @@ .contrib-fallback, .contrib-message { display: none; } .contrib[data-state='empty'] .contrib-list, .contrib[data-state='error'] .contrib-list { display: none; } .contrib[data-state='empty'] .contrib-message--empty, .contrib[data-state='error'] .contrib-message--error { display: block; } +/* Empty published data means there is no feature to promote. Hide the whole + * surface, including headings and adjacent links supplied by the host page. */ +[data-contribution-calls]:has(.contrib[data-state='empty']) { display: none; } +/* Before the stacked contributors feature lands, the landing section contains + * only this surface; avoid leaving an empty section behind. */ +.scope-contrib:has(> [data-contribution-calls] .contrib[data-state='empty']):not(:has(scope-contributors)) { display: none; } @media (scripting: none) { .contrib-list, .contrib-count { display: none !important; } .contrib-fallback { display: block; } From 476dc1a958950813cf41ac0ee81338d3da77ac27 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:23:17 -0700 Subject: [PATCH 14/23] feat(website): parse and sort open calls for contributions Add helpers that parse the issues.jsonl list published to the website-data branch, order it for display, and build chips and GitHub links, plus a dev-only sample and node:test coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/package.json | 2 +- .../src/data/contribution-issues.sample.jsonl | 9 ++ .../src/scripts/contribution-issues.test.mjs | 98 +++++++++++++++ website/src/scripts/contribution-issues.ts | 119 ++++++++++++++++++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 website/src/data/contribution-issues.sample.jsonl create mode 100644 website/src/scripts/contribution-issues.test.mjs create mode 100644 website/src/scripts/contribution-issues.ts diff --git a/website/package.json b/website/package.json index 1c581f807..2e10a0048 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ "start": "worktree-env && astro dev", "build": "astro build", "preview": "worktree-env && astro preview", - "test": "node --test src/plugins/*.test.mjs", + "test": "node --test src/plugins/*.test.mjs src/scripts/*.test.mjs", "test:a11y": "SITE=https://microsoft.github.io BASE_PATH=/scope pnpm build && playwright test", "astro": "astro", "refresh:openapi": "cd .. && pnpm --filter api generate:openapi" diff --git a/website/src/data/contribution-issues.sample.jsonl b/website/src/data/contribution-issues.sample.jsonl new file mode 100644 index 000000000..6a0c04a6b --- /dev/null +++ b/website/src/data/contribution-issues.sample.jsonl @@ -0,0 +1,9 @@ +{"labels":["good first issue"],"number":606,"title":"The workspace snapshot archive should include the dot files and dot folders"} +{"labels":["area: portal","difficulty: easy","good first issue"],"number":740,"title":"In the HAR view, when inspecting a row down the list, the detail informations on the right should stay visible"} +{"labels":["area: portal","difficulty: easy","good first issue","topic: ux"],"number":765,"title":"consider a different UX for the Run details UI"} +{"labels":["area: portal","good first issue","topic: ux"],"number":766,"title":"Improve UX when listing all runs"} +{"labels":["difficulty: moderate","good first issue","topic: ux","type: enhancement"],"number":912,"title":"Token Manager: introduce a structured 'endpoint' secret type returning {endpoint, apiKey, deployment}"} +{"labels":["good first issue","type: documentation"],"number":1275,"title":"Document model lifecycle and deprecation process"} +{"labels":["good first issue","topic: ui","type: documentation"],"number":1276,"title":"Clarify that prompt features only categorize prompts, don't impact runs"} +{"labels":["area: portal","good first issue","language: javascript"],"number":1386,"title":"Allow editing Azure AI Foundry model"} +{"labels":["difficulty: easy","good first issue"],"number":1455,"title":"Portal: Keys page layout shifts and list column gets squeezed when a key is selected"} diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs new file mode 100644 index 000000000..6fb16b4ac --- /dev/null +++ b/website/src/scripts/contribution-issues.test.mjs @@ -0,0 +1,98 @@ +// 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 { + chips, + contributionLabels, + countLabel, + issueUrl, + parseIssues, + searchUrl, + sortForDisplay, +} from './contribution-issues.ts'; + +const line = (issue) => JSON.stringify(issue); + +test('parses JSON Lines and ignores blank lines', () => { + const text = [ + line({ labels: ['good first issue'], number: 2, title: 'Two' }), + '', + line({ labels: ['help wanted'], number: 5, title: ' Five ' }), + '', + ].join('\n'); + const { issues, skipped } = parseIssues(text); + assert.deepEqual(issues, [ + { number: 2, title: 'Two', labels: ['good first issue'] }, + { number: 5, title: 'Five', labels: ['help wanted'] }, + ]); + assert.deepEqual(skipped, []); +}); + +test('treats an empty file as no issues', () => { + assert.deepEqual(parseIssues(''), { issues: [], skipped: [] }); +}); + +test('skips invalid, unlabeled and duplicate lines without dropping the rest', () => { + const text = [ + '{not json', + line({ labels: ['good first issue'], number: 0, title: 'Bad number' }), + line({ labels: ['good first issue'], number: 3, title: '' }), + line({ labels: ['area: portal'], number: 4, title: 'No contribution label' }), + line({ labels: 'help wanted', number: 6, title: 'Labels not an array' }), + line({ labels: ['help wanted'], number: 7, title: 'Kept' }), + line({ labels: ['help wanted'], number: 7, title: 'Duplicate' }), + ].join('\r\n'); + const { issues, skipped } = parseIssues(text); + assert.deepEqual(issues.map((issue) => issue.title), ['Kept']); + assert.deepEqual(skipped, [1, 2, 3, 4, 5, 7]); +}); + +test('sorts good first issues first, then newest', () => { + const sorted = sortForDisplay([ + { number: 10, title: 'a', labels: ['help wanted'] }, + { number: 3, title: 'b', labels: ['good first issue'] }, + { number: 12, title: 'c', labels: ['help wanted'] }, + { number: 8, title: 'd', labels: ['good first issue', 'help wanted'] }, + ]); + assert.deepEqual(sorted.map((issue) => issue.number), [8, 3, 12, 10]); +}); + +test('lists contribution labels in a fixed order', () => { + assert.deepEqual(contributionLabels({ labels: ['help wanted', 'x', 'good first issue'] }), ['good first issue', 'help wanted']); +}); + +test('builds chips for area, difficulty and type labels only', () => { + const result = chips({ + labels: ['type: documentation', 'priority: high', 'area: portal', 'good first issue', 'difficulty: easy', 'area: cli'], + }); + assert.deepEqual( + result.map(({ kind, value }) => `${kind}=${value}`), + ['area=portal', 'area=cli', 'difficulty=easy', 'type=documentation'], + ); +}); + +test('builds GitHub URLs', () => { + assert.equal(issueUrl(42), 'https://github.com/microsoft/scope/issues/42'); + const url = new URL(searchUrl('good first issue')); + assert.equal(url.searchParams.get('q'), 'is:issue is:open no:assignee label:"good first issue"'); +}); + +test('pluralizes the count label', () => { + assert.equal(countLabel(1), 'open call'); + assert.equal(countLabel(0), 'open calls'); + assert.equal(countLabel(4), 'open calls'); +}); + +test('the dev sample file is valid', () => { + const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); + const { issues, skipped } = parseIssues(text); + assert.deepEqual(skipped, []); + assert.ok(issues.length >= 4); + const numbers = issues.map((issue) => issue.number); + assert.deepEqual(numbers, [...numbers].sort((a, b) => a - b), 'sample is sorted by issue number like the published file'); +}); diff --git a/website/src/scripts/contribution-issues.ts b/website/src/scripts/contribution-issues.ts new file mode 100644 index 000000000..43ea6c97b --- /dev/null +++ b/website/src/scripts/contribution-issues.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Data helpers for the "call for contributions" issue list. 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. + +export const REPOSITORY = 'microsoft/scope'; +export const DATA_URL = `https://raw.githubusercontent.com/${REPOSITORY}/website-data/issues.jsonl`; +export const CONTRIBUTION_LABELS = ['good first issue', 'help wanted'] as const; + +export type ContributionLabel = (typeof CONTRIBUTION_LABELS)[number]; + +export interface ContributionIssue { + number: number; + title: string; + labels: string[]; +} + +export interface ParseResult { + issues: ContributionIssue[]; + /** 1-based line numbers that were skipped because they were invalid. */ + skipped: number[]; +} + +/** Label prefixes shown as chips; all other labels stay hidden. */ +const CHIP_PREFIXES = ['area', 'difficulty', 'type'] as const; +export type ChipKind = (typeof CHIP_PREFIXES)[number]; + +export interface Chip { + kind: ChipKind; + value: string; + label: string; +} + +function isIssue(value: unknown): value is ContributionIssue { + if (typeof value !== 'object' || value === null) return false; + const { number, title, labels } = value as Record; + return ( + Number.isSafeInteger(number) && + (number as number) > 0 && + typeof title === 'string' && + title.trim() !== '' && + Array.isArray(labels) && + labels.every((label) => typeof label === 'string') + ); +} + +/** + * Parses the JSON Lines payload. Invalid lines, and lines without a + * contribution label, are skipped so one bad line can't hide the list. + */ +export function parseIssues(text: string): ParseResult { + const issues: ContributionIssue[] = []; + 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 (!isIssue(value) || !contributionLabels(value).length || seen.has(value.number)) { + skipped.push(index + 1); + return; + } + seen.add(value.number); + issues.push({ number: value.number, title: value.title.trim(), labels: [...value.labels] }); + }); + return { issues, skipped }; +} + +/** The contribution labels an issue carries, `good first issue` first. */ +export function contributionLabels(issue: Pick): ContributionLabel[] { + return CONTRIBUTION_LABELS.filter((label) => issue.labels.includes(label)); +} + +/** Display order: `good first issue` first, then newest (highest number). */ +export function sortForDisplay(issues: readonly ContributionIssue[]): ContributionIssue[] { + const rank = (issue: ContributionIssue) => (issue.labels.includes('good first issue') ? 0 : 1); + return [...issues].sort((a, b) => rank(a) - rank(b) || b.number - a.number); +} + +/** `area:`, `difficulty:` and `type:` labels as chips, in that order. */ +export function chips(issue: Pick): Chip[] { + const result: Chip[] = []; + for (const kind of CHIP_PREFIXES) { + for (const label of issue.labels) { + const match = /^([a-z]+):\s*(.+)$/i.exec(label); + if (match && match[1].toLowerCase() === kind) result.push({ kind, value: match[2].trim(), label }); + } + } + return result; +} + +export function issueUrl(number: number): string { + return `https://github.com/${REPOSITORY}/issues/${number}`; +} + +/** GitHub issue search for open, unassigned issues with the given label. */ +export function searchUrl(label: ContributionLabel): string { + const query = `is:issue is:open no:assignee label:"${label}"`; + return `https://github.com/${REPOSITORY}/issues?q=${encodeURIComponent(query)}`; +} + +export function countLabel(count: number): string { + return count === 1 ? 'open call' : 'open calls'; +} + +/** Fetches and parses the published list. Throws on network or HTTP errors. */ +export async function fetchIssues(url: string, timeoutMs = 8000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`Contribution issues request failed: HTTP ${response.status}`); + return parseIssues(await response.text()); +} From 8cc7b9aff57c4d0fb20e5e7ccbacfc547d9c3100 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:23:17 -0700 Subject: [PATCH 15/23] feat(website): highlight open calls for contributions Add an animated "Help build Scope" section to the landing page and a Community > Contribute page listing open, unassigned good first issue and help wanted issues. Uses the Web Animations API (no library), respects reduced motion, and falls back to GitHub search links without JavaScript. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/astro.config.mjs | 5 +- website/src/components/Home.astro | 2 + .../contribute/ContributeTeaser.astro | 28 +++ .../contribute/ContributionIssues.astro | 60 +++++ .../src/content/docs/community/contribute.mdx | 28 +++ .../scripts/contribution-issues-element.ts | 231 ++++++++++++++++++ website/src/styles/contribution-issues.css | 121 +++++++++ 7 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 website/src/components/contribute/ContributeTeaser.astro create mode 100644 website/src/components/contribute/ContributionIssues.astro create mode 100644 website/src/content/docs/community/contribute.mdx create mode 100644 website/src/scripts/contribution-issues-element.ts create mode 100644 website/src/styles/contribution-issues.css diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 246e266ca..103ddd76e 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -106,7 +106,10 @@ export default defineConfig({ }, { label: 'Community', - items: [{ label: 'Articles & talks', slug: 'community/articles-and-talks' }], + items: [ + { label: 'Articles & talks', slug: 'community/articles-and-talks' }, + { label: 'Contribute', slug: 'community/contribute' }, + ], }, { label: 'Getting Started', diff --git a/website/src/components/Home.astro b/website/src/components/Home.astro index 878a765e9..53d2d8811 100644 --- a/website/src/components/Home.astro +++ b/website/src/components/Home.astro @@ -1,5 +1,6 @@ --- import CommunityTeaser from './community/CommunityTeaser.astro'; +import ContributeTeaser from './contribute/ContributeTeaser.astro'; import FlowDemo from './FlowDemo.astro'; const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); @@ -136,6 +137,7 @@ const paths = [
      +
      diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro new file mode 100644 index 000000000..853c27e36 --- /dev/null +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -0,0 +1,28 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import ContributionIssues from './ContributionIssues.astro'; + +const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contribute/`; +--- + +
      + +
      +
      +

      Open source

      +

      Help build Scope

      +
      +
      +

      Pick an open call, then open a pull request that references it. No need to ask first.

      +

      + + + +

      +
      +
      +
      + See all open calls +
      diff --git a/website/src/components/contribute/ContributionIssues.astro b/website/src/components/contribute/ContributionIssues.astro new file mode 100644 index 000000000..2e18e450d --- /dev/null +++ b/website/src/components/contribute/ContributionIssues.astro @@ -0,0 +1,60 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Lists open, unassigned issues labeled "good first issue" or "help wanted". +// The contribution-issues workflow publishes the list to the website-data +// branch, and the browser fetches it, so it stays fresh without +// redeploying the site. See website/AGENTS.md. +import '../../styles/contribution-issues.css'; +import { CONTRIBUTION_LABELS, DATA_URL, searchUrl } from '../../scripts/contribution-issues'; + +interface Props { + /** Maximum number of issues to show. Omit to show them all. */ + limit?: number; + /** `cards` for the animated landing grid, `list` for the docs page. */ + variant?: 'cards' | 'list'; +} + +const { limit, variant = 'cards' } = Astro.props; +const src = import.meta.env.PUBLIC_CONTRIBUTION_ISSUES_URL || DATA_URL; +const skeletons = Math.min(limit ?? 4, 4); +const [goodFirst, helpWanted] = CONTRIBUTION_LABELS.map((label) => ({ label, href: searchUrl(label) })); +--- + + + + +
        + { + Array.from({ length: skeletons }, () => ( + + )) + } +
      +

      + Every open call is taken right now. Check back soon, or browse + open issues on GitHub (opens in a new tab). +

      +

      + We couldn't load the list. Browse + good first issues (opens in a new tab) + or + help wanted issues (opens in a new tab) + on GitHub. +

      +

      + Browse open calls on GitHub: + good first issues (opens in a new tab) + · + help wanted (opens in a new tab) +

      +
      + + diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx new file mode 100644 index 000000000..d3f8355d3 --- /dev/null +++ b/website/src/content/docs/community/contribute.mdx @@ -0,0 +1,28 @@ +--- +title: Contribute +description: Open issues where the Scope team is calling for contributions, and how to pick one up. +--- + +import ContributionIssues from '../../../components/contribute/ContributionIssues.astro'; + +Scope is open source, and some issues are set aside for the community. +Each one below is open and nobody is assigned to it yet. + +## How to pick one up + +1. Choose an issue below. **Good first issue** marks a small, well-scoped + change. **Help wanted** marks work where an outside hand helps most. +2. Read the + [contributing guide](https://github.com/microsoft/scope/blob/main/CONTRIBUTING.md) + to set up your environment and learn the conventions. +3. Open a pull request that references the issue, for example + `Fixes #123`. You don't need to comment on the issue or ask first. + +The first time you contribute, a bot asks you to sign the Microsoft +Contributor License Agreement (CLA). You only sign it once. + +## Open calls + +The list refreshes about every hour. + + diff --git a/website/src/scripts/contribution-issues-element.ts b/website/src/scripts/contribution-issues-element.ts new file mode 100644 index 000000000..b4257593d --- /dev/null +++ b/website/src/scripts/contribution-issues-element.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + DATA_URL, + chips, + contributionLabels, + countLabel, + fetchIssues, + issueUrl, + parseIssues, + sortForDisplay, + type ContributionIssue, + type ParseResult, +} from './contribution-issues'; + +type State = 'loading' | 'ready' | 'empty' | 'error'; + +const EASE_OUT_BACK = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; +const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'; +const STAGGER_MS = 70; +const MAX_STAGGER_STEPS = 8; +const COUNT_UP_MS = 900; + +const BADGE_TEXT = { 'good first issue': 'Good first issue', 'help wanted': 'Help wanted' } as const; + +/** + * Renders the open "call for contributions" issues. Without JavaScript the + * server-rendered fallback links stay visible. Motion is skipped entirely + * when the visitor prefers reduced motion. + */ +class ScopeContributionIssues 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 total = 0; + + connectedCallback() { + this.events = new AbortController(); + this.toggleAttribute('data-animate', this.animates); + 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 async load() { + let result: ParseResult; + let sample = false; + try { + result = await fetchIssues(this.dataset.src || DATA_URL); + } catch (error) { + // In local dev the data branch may not exist yet, or there may be no + // network. Fall back to the committed sample. The DEV branch is + // removed from production builds, sample included. + if (import.meta.env.DEV) { + console.info('[contribution-issues] Live list unavailable; using the dev sample.', error); + const { default: text } = await import('../data/contribution-issues.sample.jsonl?raw'); + result = parseIssues(text); + sample = true; + } else { + console.warn('[contribution-issues] Could not load the live list.', error); + this.setState('error'); + return; + } + } + if (result.skipped.length) { + console.warn(`[contribution-issues] Skipped invalid lines: ${result.skipped.join(', ')}`); + } + this.querySelectorAll('[data-sample-tag]').forEach((tag) => { tag.hidden = !sample; }); + await this.render(result.issues); + } + + private async render(issues: ContributionIssue[]) { + const list = this.querySelector('[data-list]'); + if (!list) throw new Error('Missing contribution issues list'); + const sorted = sortForDisplay(issues); + const limit = Number(this.dataset.limit) || Infinity; + this.total = sorted.length; + + // Fade the skeleton out before swapping in real items, when on screen. + if (this.animates && this.inView) { + await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); + } + list.replaceChildren(...sorted.slice(0, limit).map((issue) => this.item(issue))); + list.removeAttribute('aria-busy'); + this.querySelectorAll('[data-count-label]').forEach((label) => { label.textContent = countLabel(this.total); }); + this.querySelectorAll('[data-count-text]').forEach((text) => { text.textContent = `${this.total} ${countLabel(this.total)}`; }); + this.loaded = true; + this.setState(this.total ? 'ready' : 'empty'); + this.reveal(); + } + + private setState(state: State) { + this.dataset.state = state; + if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); + } + + /** Runs entrance animations once the section is visible and data is in. */ + private reveal() { + if (!this.inView) return; + if (!this.headingRevealed) { + this.headingRevealed = true; + this.enter([...this.querySelectorAll('[data-reveal]')], 0, 90); + } + if (this.loaded && !this.itemsRevealed) { + this.itemsRevealed = true; + this.enter([...this.querySelectorAll('[data-reveal-item]')], 180, STAGGER_MS); + if (this.total) this.countUp(); + } + } + + private enter(elements: HTMLElement[], baseDelay: number, stagger: number) { + elements.forEach((element, index) => { + element.dataset.revealed = ''; + if (!this.animates) return; + element.animate( + [{ opacity: 0, transform: 'translateY(18px) scale(0.98)' }, { opacity: 1, transform: 'none' }], + { duration: 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; + pill.dataset.shown = ''; + if (!this.animates) { + counter.textContent = String(this.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(this.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 }); + } + + // Issue titles and labels are user-supplied, so every value is set as text. + private item(issue: ContributionIssue): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'contrib-item'; + item.dataset.revealItem = ''; + + const link = document.createElement('a'); + link.className = 'contrib-card'; + link.href = issueUrl(issue.number); + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + + this.text(link, 'span', issue.title, 'contrib-card__title'); + + const top = this.el(link, 'span', 'contrib-card__top'); + const badges = this.el(top, 'span', 'contrib-badges'); + for (const label of contributionLabels(issue)) { + this.text(badges, 'span', BADGE_TEXT[label], 'contrib-badge').dataset.label = label.replace(/\s+/g, '-'); + } + const number = this.el(top, 'span', 'contrib-number'); + this.text(number, 'span', 'Issue ', 'sr-only'); + number.append(`#${issue.number}`); + + const issueChips = chips(issue); + if (issueChips.length) { + const row = this.el(link, 'span', 'contrib-chips'); + for (const chip of issueChips) { + const element = this.text(row, 'span', chip.value, 'contrib-chip'); + element.dataset.kind = chip.kind; + element.dataset.value = chip.value.toLowerCase(); + element.title = chip.label; + } + } + + const cta = this.el(link, 'span', 'contrib-card__cta'); + cta.append('View on GitHub'); + this.text(cta, 'span', '↗', 'contrib-card__arrow').setAttribute('aria-hidden', 'true'); + this.text(link, 'span', ' (opens in a new tab)', 'sr-only'); + + item.append(link); + return item; + } + + private el(parent: Element, tag: 'span', className: string): HTMLElement { + const element = document.createElement(tag); + element.className = className; + parent.append(element); + return element; + } + + private text(parent: Element, tag: 'span', value: string, className: string): HTMLElement { + const element = this.el(parent, tag, className); + element.textContent = value; + return element; + } +} + +if (!customElements.get('scope-contribution-issues')) customElements.define('scope-contribution-issues', ScopeContributionIssues); diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css new file mode 100644 index 000000000..05c80c0ba --- /dev/null +++ b/website/src/styles/contribution-issues.css @@ -0,0 +1,121 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Open calls for contributions (). Used on the + * landing page (cards) and the Community > Contribute page (list). + */ + +.contrib { display: block; color: var(--scope-text); font-family: var(--sl-font); line-height: 1.6; } +.contrib * { box-sizing: border-box; } +.contrib :is(p, ul) { margin: 0; } +.contrib a { text-decoration: none; } +.contrib .contrib-message a, .contrib .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 links are shown. */ +.contrib-fallback, .contrib-message { display: none; } +.contrib[data-state='empty'] .contrib-list, .contrib[data-state='error'] .contrib-list { display: none; } +.contrib[data-state='empty'] .contrib-message--empty, .contrib[data-state='error'] .contrib-message--error { display: block; } +@media (scripting: none) { + .contrib-list, .contrib-count { display: none !important; } + .contrib-fallback { display: block; } +} +.contrib[data-animate] :is([data-reveal], [data-reveal-item]):not([data-revealed]) { opacity: 0; } + +.contrib-message, .contrib-fallback { padding: 1.2rem 1.4rem; border: 1px dashed var(--scope-border); border-radius: 1rem; background: var(--scope-inset); color: var(--scope-muted); font-size: 0.9rem; } + +.contrib-sample-tag { display: inline-block; margin-bottom: 0.9rem !important; padding: 0.2rem 0.6rem; border: 1px dashed var(--scope-fail); border-radius: 100px; color: var(--scope-fail); font: 600 0.65rem var(--sl-font-mono); letter-spacing: 0.1em; text-transform: uppercase; } +.contrib-sample-tag[hidden] { display: none; } + +/* Count pill (landing). */ +.contrib-count { display: inline-flex; align-items: center; gap: 0.55rem; margin-top: 1rem !important; padding: 0.35rem 0.85rem 0.35rem 0.7rem; border: 1px solid var(--scope-border); border-radius: 100px; background: var(--scope-surface); color: var(--scope-text); font-size: 0.8rem; font-weight: 600; visibility: hidden; } +.contrib-count[data-shown] { visibility: visible; } +.contrib-count__value { color: var(--scope-brand); font-family: var(--sl-font-mono); font-variant-numeric: tabular-nums; } +.contrib-count__pulse { position: relative; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--scope-pass); } +.contrib-count__pulse::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: var(--scope-pass); animation: contrib-pulse 2.2s cubic-bezier(0.22, 1, 0.36, 1) infinite; } +@keyframes contrib-pulse { from { transform: scale(1); opacity: 0.6; } to { transform: scale(3.2); opacity: 0; } } + +/* List and cards. */ +.contrib-list { display: grid; gap: 1rem; padding: 0 !important; list-style: none; } +.contrib--cards .contrib-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.contrib-item { display: flex; min-width: 0; margin: 0 !important; } + +.contrib-card { + --x: 50%; + --y: 0%; + position: relative; + isolation: isolate; + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + padding: 1.3rem 1.4rem 1.2rem; + border: 1px solid var(--scope-border); + border-radius: 1rem; + background: var(--scope-surface); + color: var(--scope-text); + overflow: hidden; + transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease, border-color 0.35s ease; +} +.contrib-card::before, .contrib-card::after { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity 0.35s ease; } +/* Border glow that follows the cursor. */ +.contrib-card::before { + padding: 1px; + background: radial-gradient(16rem circle at var(--x) var(--y), var(--scope-brand), transparent 70%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); +} +/* Soft spotlight behind the content. */ +.contrib-card::after { z-index: -1; background: radial-gradient(26rem circle at var(--x) var(--y), color-mix(in srgb, var(--scope-brand) 11%, transparent), transparent 65%); } +.contrib-card:is(:hover, :focus-visible) { transform: translateY(-4px); border-color: color-mix(in srgb, var(--scope-brand) 45%, var(--scope-border)); box-shadow: var(--scope-shadow); } +.contrib-card:is(:hover, :focus-visible)::before, .contrib-card:is(:hover, :focus-visible)::after { opacity: 1; } +.contrib-card:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 3px; } + +/* The title comes first in the DOM so it leads the link's accessible name. */ +.contrib-card__top { order: -1; display: flex; align-items: center; gap: 0.75rem; } +.contrib-card__title { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 1.02rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.4; text-wrap: pretty; } +.contrib-badges { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.contrib-badge { --badge: var(--scope-brand); padding: 0.15rem 0.55rem; border: 1px solid color-mix(in srgb, var(--badge) 40%, transparent); border-radius: 100px; background: color-mix(in srgb, var(--badge) 12%, transparent); color: var(--badge); font: 650 0.62rem/1.6 var(--sl-font-mono); letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; } +.contrib-badge[data-label='good-first-issue'] { --badge: var(--scope-pass); } +.contrib-number { margin-left: auto; color: var(--scope-muted); font: 0.75rem var(--sl-font-mono); } + +.contrib-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.contrib-chip { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.1rem 0.55rem; border: 1px solid var(--scope-border); border-radius: 100px; background: var(--scope-inset); color: var(--scope-muted); font-size: 0.72rem; } +.contrib-chip[data-kind='difficulty']::before { content: ''; width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--scope-muted); } +.contrib-chip[data-value='easy']::before { background: var(--scope-pass); } +.contrib-chip[data-value='moderate']::before { background: var(--scope-brand); } +.contrib-chip[data-value='hard']::before { background: var(--scope-fail); } + +.contrib-card__cta { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: auto; padding-top: 0.35rem; color: var(--scope-brand); font-size: 0.8rem; font-weight: 600; } +.contrib-card__arrow { display: inline-block; transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } +.contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: translate(3px, -3px); } + +/* Loading skeleton with a shimmer. */ +.contrib-skeleton { position: relative; flex-direction: column; justify-content: center; gap: 0.8rem; min-height: 10.5rem; padding: 1.4rem; border: 1px solid var(--scope-border); border-radius: 1rem; background: var(--scope-surface); overflow: hidden; } +.contrib--list .contrib-skeleton { min-height: 7.5rem; } +.contrib-skeleton__line { display: block; width: 100%; height: 0.8rem; border-radius: 0.4rem; background: var(--scope-inset); } +.contrib-skeleton__line--short { width: 35%; } +.contrib-skeleton__line--mid { width: 65%; } +.contrib-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; } +@keyframes contrib-shimmer { to { transform: translateX(100%); } } + +/* Landing section. */ +.scope-contrib__intro { max-width: 36ch; color: var(--scope-muted); font-size: 0.9rem; } +.scope-contrib__all { margin-top: 1.5rem; } + +/* Docs page list: same cards, one per row, a little tighter. */ +.contrib--list { margin-top: 1.5rem; } +.contrib--list .contrib-card { padding: 1.1rem 1.25rem; } +.contrib--list .contrib-card__title { -webkit-line-clamp: unset; } +.contrib--list .contrib-card__cta { display: none; } + +@media (max-width: 48rem) { + .contrib--cards .contrib-list { grid-template-columns: 1fr; } + .scope-contrib__intro { margin-top: 1rem; max-width: 55ch; } +} + +@media (prefers-reduced-motion: reduce) { + .contrib *, .contrib *::before, .contrib *::after { animation: none !important; transition: none !important; } + .contrib-card:is(:hover, :focus-visible), .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } +} From 238e23d23d650433150280aab225bc3ff6f0f8c2 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:23:17 -0700 Subject: [PATCH 16/23] docs(website): document the contribution issues list Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 1 + website/AGENTS.md | 37 +++++++++++++++++++++++++++++++++++-- website/README.md | 19 +++++++++++++------ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4046286dc..cadc4cf42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -340,6 +340,7 @@ Automations depend on these exact names: | Dependabot | `type: dependencies`, plus `language: javascript` or `language: rust` | | Pull Request Labeler | Area, topic, language, and type labels from `.github/labeler.yml` | | Community Contribution Labeler | `community-contribution` | +| Website open calls ([Contribute page](https://microsoft.github.io/scope/community/contribute/)) | Open, unassigned `good first issue` or `help wanted` issues | Use `area: reporting` for Scope's benchmark reporting component, not daily repository activity. Worker upgrade routing uses `type: worker-update`, not the broader `area: worker`. diff --git a/website/AGENTS.md b/website/AGENTS.md index 0c59bff28..1caabac75 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -218,6 +218,39 @@ Published videos use a labeled Watch on YouTube link, not an iframe or remote thumbnail, so visiting a page does not load a third-party player or its scripts. +### Contribution issues + +The landing page ("Help build Scope") and **Community → Contribute** +list open, unassigned issues labeled `good first issue` or +`help wanted`. The list is data, not content, so it isn't in the +build: + +- [`.github/workflows/contribution-issues.yml`](../.github/workflows/contribution-issues.yml) + runs every hour, on issue events, and on demand. It writes + `issues.jsonl` to the orphan `website-data` branch and commits only + when the file changes. Run it once by hand (`workflow_dispatch`) to + create the branch. +- `issues.jsonl` is deterministic so diffs stay small: one issue per + line, sorted by number, with sorted keys and only `number`, `title`, + and sorted `labels`. Don't add fields that change on every run + (timestamps, comment counts). An empty file means there are no open + calls. +- The browser fetches the file from `raw.githubusercontent.com` + (`DATA_URL` in + [`contribution-issues.ts`](src/scripts/contribution-issues.ts)). + Set `PUBLIC_CONTRIBUTION_ISSUES_URL` to point at another file. +- Display order is decided client-side: good first issues first, then + newest. `area:`, `difficulty:`, and `type:` labels show as chips. +- In `pnpm run dev`, if the fetch fails (no branch yet, offline), the + list falls back to + [`contribution-issues.sample.jsonl`](src/data/contribution-issues.sample.jsonl) + and shows a "Sample data · dev only" tag. Production builds drop + that branch and the sample. +- Titles and labels come from GitHub users. Render them with + `textContent` only, never `innerHTML`. +- Motion uses the Web Animations API, with no animation library. Gate + any new motion on `prefers-reduced-motion`. + ### Style - Hard-wrap prose at ~70–80 columns for readable diffs. @@ -234,7 +267,7 @@ player or its scripts. ```sh pnpm install -pnpm test # plugin regressions, using Node's built-in test runner +pnpm test # plugin and script tests, using Node's built-in test runner pnpm test:a11y # production build + Playwright/axe privacy and accessibility checks pnpm run build # writes dist/ pnpm run dev # local preview at http://localhost:4321 @@ -256,7 +289,7 @@ or authenticated Website Management Center compliance reviews. Both `pnpm test` and `pnpm run build` must pass. The public build uses `SITE=https://microsoft.github.io BASE_PATH=/scope pnpm run build`; exercise that configuration when changing links or deployment settings, -not just the local `/` default. The current snapshot produces **203 +not just the local `/` default. The current snapshot produces **215 pages**, including the generated API reference. An unexpected drop in page count can indicate a content collection file failed to parse. diff --git a/website/README.md b/website/README.md index 48592d510..dfeb3fe14 100644 --- a/website/README.md +++ b/website/README.md @@ -26,7 +26,8 @@ development, contribution, and support guidance live under ├── src/ │ ├── assets/ │ ├── components/ # Astro landing, interactive example, header, page title, site footer -│ │ └── community/ # article/talk lists + landing teaser +│ │ ├── community/ # article/talk lists + landing teaser +│ │ └── contribute/ # open calls for contributions (landing + page) │ ├── content/ │ │ ├── docs/ # all user-facing pages (.md / .mdx) │ │ │ ├── introduction/ @@ -37,13 +38,15 @@ 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 │ ├── openapi/scope-openapi.json # artifact generated from the API registry │ ├── plugins/ │ │ ├── remark-base-path.mjs # applies the deployment base to internal links │ │ └── remark-http-snippets.mjs # turns ```http blocks into multi-language tabs │ ├── scripts/ │ │ ├── analytics.ts # consent-gated website-only 1DS analytics -│ │ └── flow-demo.ts # progressive-enhancement example controller +│ │ ├── flow-demo.ts # progressive-enhancement example controller +│ │ └── contribution-issues*.ts # open calls: data helpers + custom element │ ├── styles/landing.css # shared brand tokens + scoped landing styles │ └── content.config.ts ├── astro.config.mjs # sidebar, plugins, starlight-openapi config @@ -97,7 +100,7 @@ from the root pnpm workspace. | `pnpm dev` | Start dev server at the worktree's `DOC_PORT` (fallback: 4300) | | `pnpm build` | Build the production site to `./dist/` | | `pnpm preview` | Preview the production build locally | -| `pnpm test` | Test site plugins with Node's built-in test runner | +| `pnpm test` | Test site plugins and scripts with Node's built-in test runner | | `pnpm test:a11y` | Build and check accessibility and privacy links in Chromium | | `pnpm refresh:openapi` | Generate the OpenAPI snapshot from this monorepo's API | @@ -191,9 +194,13 @@ table of contents, code examples, and previous/next navigation. - **Onboarding:** a concrete example before terminology, followed by 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 and the interactive - example, with pause controls and reduced-motion support. The logo - and documentation chrome remain still. +- **Motion:** limited to the silent showreel, the interactive + example, and the open-calls cards (staggered entrance, count-up, + cursor glow), with pause controls where needed 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`. ### Showreel From 7631181c425b1d7a7e56a5e75271bc77558884e7 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:36:33 -0700 Subject: [PATCH 17/23] feat(website): rotate open calls on the landing page Cycle through all open calls 4 at a time every 7 seconds, with page dots that fill as a progress bar and a pause button. Rotation holds on hover, focus, off screen, and hidden tabs, and never autoplays under reduced motion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/AGENTS.md | 5 + website/README.md | 5 +- .../contribute/ContributeTeaser.astro | 2 +- .../contribute/ContributionIssues.astro | 6 +- .../scripts/contribution-issues-element.ts | 160 +++++++++++++++++- .../src/scripts/contribution-issues.test.mjs | 19 +++ website/src/scripts/contribution-issues.ts | 17 ++ website/src/styles/contribution-issues.css | 17 ++ 8 files changed, 225 insertions(+), 6 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 1caabac75..4b1559e32 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -241,6 +241,11 @@ build: Set `PUBLIC_CONTRIBUTION_ISSUES_URL` to point at another file. - Display order is decided client-side: good first issues first, then newest. `area:`, `difficulty:`, and `type:` labels show as chips. +- The landing section shows 4 issues at a time and rotates through all + of them every 7 seconds (`rotate` prop). The active page dot fills + as a progress bar. Rotation pauses on hover, on keyboard focus, when + off screen or the tab is hidden, and with the pause button. It never + autoplays under reduced motion; the dots still work. - In `pnpm run dev`, if the fetch fails (no branch yet, offline), the list falls back to [`contribution-issues.sample.jsonl`](src/data/contribution-issues.sample.jsonl) diff --git a/website/README.md b/website/README.md index dfeb3fe14..f79fd0485 100644 --- a/website/README.md +++ b/website/README.md @@ -196,8 +196,9 @@ table of contents, code examples, and previous/next navigation. 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), with pause controls where needed and reduced-motion - support. The logo and documentation chrome remain still. + cursor glow, a rotation through all open calls), 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`. diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro index 853c27e36..5f780de16 100644 --- a/website/src/components/contribute/ContributeTeaser.astro +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -8,7 +8,7 @@ const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contri ---
      - +

      Open source

      diff --git a/website/src/components/contribute/ContributionIssues.astro b/website/src/components/contribute/ContributionIssues.astro index 2e18e450d..118dd5e98 100644 --- a/website/src/components/contribute/ContributionIssues.astro +++ b/website/src/components/contribute/ContributionIssues.astro @@ -14,15 +14,17 @@ interface Props { limit?: number; /** `cards` for the animated landing grid, `list` for the docs page. */ variant?: 'cards' | 'list'; + /** Cycle through all issues, `limit` at a time. Needs `limit`. */ + rotate?: boolean; } -const { limit, variant = 'cards' } = Astro.props; +const { limit, variant = 'cards', rotate = false } = Astro.props; const src = import.meta.env.PUBLIC_CONTRIBUTION_ISSUES_URL || DATA_URL; const skeletons = Math.min(limit ?? 4, 4); const [goodFirst, helpWanted] = CONTRIBUTION_LABELS.map((label) => ({ label, href: searchUrl(label) })); --- - +
        diff --git a/website/src/scripts/contribution-issues-element.ts b/website/src/scripts/contribution-issues-element.ts index b4257593d..1098482d9 100644 --- a/website/src/scripts/contribution-issues-element.ts +++ b/website/src/scripts/contribution-issues-element.ts @@ -8,6 +8,8 @@ import { countLabel, fetchIssues, issueUrl, + pageCount, + pageItems, parseIssues, sortForDisplay, type ContributionIssue, @@ -21,6 +23,10 @@ const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)'; const STAGGER_MS = 70; const MAX_STAGGER_STEPS = 8; const COUNT_UP_MS = 900; +const ROTATE_MS = 7000; + +/** Reasons the rotation is on hold. It advances only when there are none. */ +type Hold = 'user' | 'hover' | 'focus' | 'offscreen' | 'hidden'; const BADGE_TEXT = { 'good first issue': 'Good first issue', 'help wanted': 'Help wanted' } as const; @@ -38,6 +44,16 @@ class ScopeContributionIssues extends HTMLElement { private headingRevealed = false; private itemsRevealed = false; private total = 0; + private issues: ContributionIssue[] = []; + private size = Infinity; + private page = 0; + private pages = 1; + private switching = false; + private holds = new Set(['offscreen']); + private timer: Animation | undefined; + private visibility: IntersectionObserver | undefined; + private dots: HTMLButtonElement[] = []; + private toggle: HTMLButtonElement | undefined; connectedCallback() { this.events = new AbortController(); @@ -50,11 +66,17 @@ class ScopeContributionIssues extends HTMLElement { }, { rootMargin: '0px 0px -12% 0px' }); this.observer.observe(this); this.trackPointer(this.events.signal); + // Card heights change with the viewport; drop the rotation's height lock. + window.addEventListener('resize', () => { + this.querySelector('[data-list]')?.style.removeProperty('min-height'); + }, { signal: this.events.signal, passive: true }); void this.load(); } disconnectedCallback() { this.observer?.disconnect(); + this.visibility?.disconnect(); + this.timer?.cancel(); this.events?.abort(); } @@ -95,20 +117,28 @@ class ScopeContributionIssues extends HTMLElement { const sorted = sortForDisplay(issues); const limit = Number(this.dataset.limit) || Infinity; this.total = sorted.length; + this.issues = sorted; + this.size = limit; + this.pages = Number.isFinite(limit) ? Math.max(pageCount(sorted.length, limit), 1) : 1; // Fade the skeleton out before swapping in real items, when on screen. if (this.animates && this.inView) { await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); } - list.replaceChildren(...sorted.slice(0, limit).map((issue) => this.item(issue))); + list.replaceChildren(...this.pageIssues(0).map((issue) => this.item(issue))); list.removeAttribute('aria-busy'); this.querySelectorAll('[data-count-label]').forEach((label) => { label.textContent = countLabel(this.total); }); this.querySelectorAll('[data-count-text]').forEach((text) => { text.textContent = `${this.total} ${countLabel(this.total)}`; }); this.loaded = true; this.setState(this.total ? 'ready' : 'empty'); + if (this.hasAttribute('data-rotate') && this.pages > 1) this.setupRotation(list); this.reveal(); } + private pageIssues(page: number): ContributionIssue[] { + return Number.isFinite(this.size) ? pageItems(this.issues, page, this.size) : this.issues; + } + private setState(state: State) { this.dataset.state = state; if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); @@ -159,6 +189,134 @@ class ScopeContributionIssues extends HTMLElement { requestAnimationFrame(tick); } + /** + * Cycles through pages of issues. Controls: one dot per page (the active + * one fills as a progress bar) and a pause button. The rotation holds + * while the list is hovered or focused, off screen, or the tab is hidden, + * and never autoplays under reduced motion. + */ + private setupRotation(list: HTMLElement) { + const signal = this.events!.signal; + const controls = document.createElement('div'); + controls.className = 'contrib-rotation'; + + const dots = document.createElement('div'); + dots.className = 'contrib-dots'; + dots.setAttribute('role', 'group'); + dots.setAttribute('aria-label', 'Open call sets'); + this.dots = Array.from({ length: this.pages }, (_, page) => { + const dot = document.createElement('button'); + dot.type = 'button'; + dot.className = 'contrib-dot'; + dot.setAttribute('aria-label', `Show set ${page + 1} of ${this.pages}`); + const track = this.el(dot, 'span', 'contrib-dot__track'); + this.el(track, 'span', 'contrib-dot__fill'); + dot.addEventListener('click', () => void this.goTo(page), { signal }); + dots.append(dot); + return dot; + }); + + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'contrib-toggle'; + this.el(toggle, 'span', 'contrib-toggle__icon').setAttribute('aria-hidden', 'true'); + this.text(toggle, 'span', '', 'sr-only'); + toggle.addEventListener('click', () => { + if (this.holds.has('user')) this.holds.delete('user'); + else this.holds.add('user'); + this.sync(); + }, { signal }); + this.toggle = toggle; + + controls.append(dots, toggle); + list.after(controls); + + const hold = (reason: Hold, on: boolean) => { + if (on) this.holds.add(reason); + else this.holds.delete(reason); + this.sync(); + }; + list.addEventListener('pointerenter', () => hold('hover', true), { signal }); + list.addEventListener('pointerleave', () => hold('hover', false), { signal }); + list.addEventListener('focusin', () => hold('focus', true), { signal }); + list.addEventListener('focusout', (event) => { + if (!list.contains(event.relatedTarget as Node | null)) hold('focus', false); + }, { signal }); + document.addEventListener('visibilitychange', () => hold('hidden', document.hidden), { signal }); + this.motion.addEventListener('change', () => { + this.toggleAttribute('data-animate', this.animates); + this.restartTimer(); + }, { signal }); + this.visibility = new IntersectionObserver((entries) => { + hold('offscreen', !entries.some((entry) => entry.isIntersecting)); + }, { threshold: 0.25 }); + this.visibility.observe(list); + + this.holds.add('offscreen'); + if (document.hidden) this.holds.add('hidden'); + this.updateDots(); + this.restartTimer(); + } + + /** Restarts the progress fill on the active dot; its end advances the page. */ + private restartTimer() { + this.timer?.cancel(); + this.timer = undefined; + const autoplay = this.animates; + if (this.toggle) this.toggle.hidden = !autoplay; + const fill = this.dots[this.page]?.querySelector('.contrib-dot__fill'); + if (autoplay && fill) { + this.timer = fill.animate([{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], { duration: ROTATE_MS, easing: 'linear' }); + this.timer.onfinish = () => void this.goTo((this.page + 1) % this.pages); + } + this.sync(); + } + + /** Plays or pauses the timer from the current holds. */ + private sync() { + const paused = this.holds.size > 0; + if (this.timer) { + if (paused) this.timer.pause(); + else this.timer.play(); + } + if (this.toggle) { + const userPaused = this.holds.has('user'); + this.toggle.dataset.paused = String(userPaused); + this.toggle.lastElementChild!.textContent = userPaused ? 'Resume rotating open calls' : 'Pause rotating open calls'; + } + } + + private updateDots() { + this.dots.forEach((dot, page) => { + if (page === this.page) dot.setAttribute('aria-current', 'true'); + else dot.removeAttribute('aria-current'); + }); + } + + private async goTo(page: number) { + const list = this.querySelector('[data-list]'); + if (!list || this.switching || page === this.page) return; + this.switching = true; + this.timer?.cancel(); + this.page = page; + this.updateDots(); + + const outgoing = [...list.children] as HTMLElement[]; + if (this.animates) { + await Promise.all(outgoing.map((element, index) => element.animate( + [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(-12px) scale(0.98)' }], + { duration: 240, delay: index * 45, easing: EASE_OUT, fill: 'forwards' }, + ).finished.catch(() => undefined))); + } + // Keep the grid from collapsing while card heights change between sets. + list.style.minHeight = `${Math.max(parseFloat(list.style.minHeight) || 0, list.offsetHeight)}px`; + const incoming = this.pageIssues(page).map((issue) => this.item(issue)); + list.replaceChildren(...incoming); + this.enter(incoming, 0, STAGGER_MS); + this.switching = false; + this.restartTimer(); + } + /** Feeds the cursor position to the card glow through CSS variables. */ private trackPointer(signal: AbortSignal) { this.addEventListener('pointermove', (event) => { diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs index 6fb16b4ac..09dd976d8 100644 --- a/website/src/scripts/contribution-issues.test.mjs +++ b/website/src/scripts/contribution-issues.test.mjs @@ -11,6 +11,8 @@ import { contributionLabels, countLabel, issueUrl, + pageCount, + pageItems, parseIssues, searchUrl, sortForDisplay, @@ -88,6 +90,23 @@ test('pluralizes the count label', () => { assert.equal(countLabel(4), 'open calls'); }); +test('counts rotation pages', () => { + assert.equal(pageCount(0, 4), 0); + assert.equal(pageCount(3, 4), 1); + assert.equal(pageCount(4, 4), 1); + assert.equal(pageCount(9, 4), 3); + assert.equal(pageCount(9, 0), 0); +}); + +test('fills every rotation page, wrapping the last one', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + assert.deepEqual(pageItems(items, 0, 4), [1, 2, 3, 4]); + assert.deepEqual(pageItems(items, 1, 4), [5, 6, 7, 8]); + assert.deepEqual(pageItems(items, 2, 4), [9, 1, 2, 3]); + assert.deepEqual(pageItems([1, 2], 0, 4), [1, 2]); + assert.deepEqual(pageItems([], 0, 4), []); +}); + test('the dev sample file is valid', () => { const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); const { issues, skipped } = parseIssues(text); diff --git a/website/src/scripts/contribution-issues.ts b/website/src/scripts/contribution-issues.ts index 43ea6c97b..b0988ea79 100644 --- a/website/src/scripts/contribution-issues.ts +++ b/website/src/scripts/contribution-issues.ts @@ -111,6 +111,23 @@ export function countLabel(count: number): string { return count === 1 ? 'open call' : 'open calls'; } +/** Number of rotation pages needed to show `total` items `size` at a time. */ +export function pageCount(total: number, size: number): number { + if (total <= 0 || size <= 0) return 0; + return Math.ceil(total / size); +} + +/** + * Items on a rotation page. A short last page wraps around to the start, + * so every page is full when there are at least `size` items. + */ +export function pageItems(items: readonly T[], page: number, size: number): T[] { + if (!items.length || size <= 0) return []; + if (items.length <= size) return [...items]; + const start = page * size; + return Array.from({ length: size }, (_, index) => items[(start + index) % items.length]!); +} + /** Fetches and parses the published list. Throws on network or HTTP errors. */ export async function fetchIssues(url: string, timeoutMs = 8000): Promise { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css index 05c80c0ba..920bead0a 100644 --- a/website/src/styles/contribution-issues.css +++ b/website/src/styles/contribution-issues.css @@ -100,6 +100,23 @@ .contrib-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; } @keyframes contrib-shimmer { to { transform: translateX(100%); } } +/* Rotation controls (landing): one dot per set, the active one fills as a progress bar. */ +.contrib-rotation { display: flex; align-items: center; gap: 0.5rem; margin-top: 1.1rem; } +.contrib-dots { display: flex; align-items: center; } +.contrib-dot, .contrib-toggle { display: inline-grid; place-items: center; min-width: 1.5rem; height: 1.5rem; padding: 0 0.15rem; border: 0; border-radius: 100px; background: none; color: var(--scope-muted); cursor: pointer; } +.contrib-dot:focus-visible, .contrib-toggle:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 1px; } +.contrib-dot__track { position: relative; display: block; width: 0.5rem; height: 0.5rem; border-radius: 100px; background: var(--scope-border); overflow: hidden; transition: width 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.3s ease; } +.contrib-dot:hover .contrib-dot__track { background: var(--scope-muted); } +.contrib-dot[aria-current] .contrib-dot__track { width: 2.25rem; background: color-mix(in srgb, var(--scope-brand) 22%, var(--scope-border)); } +.contrib-dot__fill { position: absolute; inset: 0; border-radius: inherit; background: var(--scope-brand); transform: scaleX(0); transform-origin: left; } +.contrib-dot[aria-current] .contrib-dot__fill { transform: scaleX(1); } +.contrib-toggle { margin-left: 0.25rem; border: 1px solid var(--scope-border); width: 1.75rem; height: 1.75rem; background: var(--scope-surface); transition: border-color 0.3s ease, color 0.3s ease; } +.contrib-toggle:hover { border-color: var(--scope-brand); color: var(--scope-brand); } +.contrib-toggle[hidden] { display: none; } +/* Pause: two bars. Play: a triangle. */ +.contrib-toggle__icon { width: 0.55rem; height: 0.6rem; border-inline: 0.17rem solid currentColor; } +.contrib-toggle[data-paused='true'] .contrib-toggle__icon { width: 0; height: 0; margin-left: 0.15rem; border-block: 0.32rem solid transparent; border-inline: 0.5rem solid currentColor; border-right-width: 0; } + /* Landing section. */ .scope-contrib__intro { max-width: 36ch; color: var(--scope-muted); font-size: 0.9rem; } .scope-contrib__all { margin-top: 1.5rem; } From bcad324f5ee02fe8bf8ce06340cbbeab348f7eab Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:47:36 -0700 Subject: [PATCH 18/23] test(website): cover help wanted issues in the dev sample Add help wanted and dual-labeled issues to the sample, and assert it covers every contribution label combination. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/src/data/contribution-issues.sample.jsonl | 3 +++ website/src/scripts/contribution-issues.test.mjs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/website/src/data/contribution-issues.sample.jsonl b/website/src/data/contribution-issues.sample.jsonl index 6a0c04a6b..de291c76f 100644 --- a/website/src/data/contribution-issues.sample.jsonl +++ b/website/src/data/contribution-issues.sample.jsonl @@ -5,5 +5,8 @@ {"labels":["difficulty: moderate","good first issue","topic: ux","type: enhancement"],"number":912,"title":"Token Manager: introduce a structured 'endpoint' secret type returning {endpoint, apiKey, deployment}"} {"labels":["good first issue","type: documentation"],"number":1275,"title":"Document model lifecycle and deprecation process"} {"labels":["good first issue","topic: ui","type: documentation"],"number":1276,"title":"Clarify that prompt features only categorize prompts, don't impact runs"} +{"labels":["area: portal","difficulty: hard","help wanted","topic: ux","type: enhancement"],"number":1311,"title":"Portal: scale the project switcher and first-run picker to many projects (search/pagination)"} {"labels":["area: portal","good first issue","language: javascript"],"number":1386,"title":"Allow editing Azure AI Foundry model"} +{"labels":["good first issue","help wanted"],"number":1419,"title":"Write a quick guide to help Windows devs setup their env with WSL"} +{"labels":["help wanted"],"number":1420,"title":"Ensure we can run the project from a DevContainer/Codespaces"} {"labels":["difficulty: easy","good first issue"],"number":1455,"title":"Portal: Keys page layout shifts and list column gets squeezed when a key is selected"} diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs index 09dd976d8..548ef4fb4 100644 --- a/website/src/scripts/contribution-issues.test.mjs +++ b/website/src/scripts/contribution-issues.test.mjs @@ -114,4 +114,8 @@ test('the dev sample file is valid', () => { assert.ok(issues.length >= 4); const numbers = issues.map((issue) => issue.number); assert.deepEqual(numbers, [...numbers].sort((a, b) => a - b), 'sample is sorted by issue number like the published file'); + const labelSets = issues.map((issue) => contributionLabels(issue).join('+')); + for (const combination of ['good first issue', 'help wanted', 'good first issue+help wanted']) { + assert.ok(labelSets.includes(combination), `sample covers issues labeled ${combination}`); + } }); From 318c1bc5a156721a1737ea926cdea3779cce884f Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Tue, 20 Oct 2026 12:23:23 -0700 Subject: [PATCH 19/23] fix(website): cover contribution call edge cases Keep large sets of rotation controls within narrow viewports and add pagination boundary coverage for 0 through 300 calls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/AGENTS.md | 7 ++++++- website/src/scripts/contribution-issues.test.mjs | 16 ++++++++++++++++ website/src/styles/contribution-issues.css | 4 ++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 4b1559e32..9ef7f7675 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -245,7 +245,12 @@ build: of them every 7 seconds (`rotate` prop). The active page dot fills as a progress bar. Rotation pauses on hover, on keyboard focus, when off screen or the tab is hidden, and with the pause button. It never - autoplays under reduced motion; the dots still work. + autoplays under reduced motion; the dots still work. Page dots wrap + rather than widening narrow pages when there are many sets. +- Check 0, 1, a partial page, exactly 4, 5 (the first wrapped page), + several pages, and enough pages to exceed the viewport, on desktop + and mobile. Also check reduced motion and long user-supplied titles + and chip values. - In `pnpm run dev`, if the fetch fails (no branch yet, offline), the list falls back to [`contribution-issues.sample.jsonl`](src/data/contribution-issues.sample.jsonl) diff --git a/website/src/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs index 548ef4fb4..e2a8166e0 100644 --- a/website/src/scripts/contribution-issues.test.mjs +++ b/website/src/scripts/contribution-issues.test.mjs @@ -92,9 +92,13 @@ test('pluralizes the count label', () => { test('counts rotation pages', () => { assert.equal(pageCount(0, 4), 0); + assert.equal(pageCount(1, 4), 1); assert.equal(pageCount(3, 4), 1); assert.equal(pageCount(4, 4), 1); + assert.equal(pageCount(5, 4), 2); assert.equal(pageCount(9, 4), 3); + assert.equal(pageCount(60, 4), 15); + assert.equal(pageCount(300, 4), 75); assert.equal(pageCount(9, 0), 0); }); @@ -107,6 +111,18 @@ test('fills every rotation page, wrapping the last one', () => { assert.deepEqual(pageItems([], 0, 4), []); }); +test('keeps boundary and high-volume rotation pages full', () => { + for (const total of [5, 9, 60, 300]) { + const items = Array.from({ length: total }, (_, index) => index + 1); + const pages = pageCount(total, 4); + const rendered = Array.from({ length: pages }, (_, page) => pageItems(items, page, 4)); + assert.ok(rendered.every((page) => page.length === 4), `${total} issues has a short page`); + for (const item of items) { + assert.ok(rendered.some((page) => page.includes(item)), `${total} issues never renders item ${item}`); + } + } +}); + test('the dev sample file is valid', () => { const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); const { issues, skipped } = parseIssues(text); diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css index 920bead0a..f02dc2d8c 100644 --- a/website/src/styles/contribution-issues.css +++ b/website/src/styles/contribution-issues.css @@ -101,8 +101,8 @@ @keyframes contrib-shimmer { to { transform: translateX(100%); } } /* Rotation controls (landing): one dot per set, the active one fills as a progress bar. */ -.contrib-rotation { display: flex; align-items: center; gap: 0.5rem; margin-top: 1.1rem; } -.contrib-dots { display: flex; align-items: center; } +.contrib-rotation { display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.1rem; } +.contrib-dots { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: center; min-width: 0; } .contrib-dot, .contrib-toggle { display: inline-grid; place-items: center; min-width: 1.5rem; height: 1.5rem; padding: 0 0.15rem; border: 0; border-radius: 100px; background: none; color: var(--scope-muted); cursor: pointer; } .contrib-dot:focus-visible, .contrib-toggle:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 1px; } .contrib-dot__track { position: relative; display: block; width: 0.5rem; height: 0.5rem; border-radius: 100px; background: var(--scope-border); overflow: hidden; transition: width 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.3s ease; } From a2b79c4afc8e531f2a9bbf564a34540d7e6e1f2d Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Fri, 23 Oct 2026 12:45:34 -0700 Subject: [PATCH 20/23] fix(website): hide contribution calls when empty Hide the complete calls surface, including headings, instructions, and adjacent links, when the published issue list is empty. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/AGENTS.md | 3 +- .../contribute/ContributeTeaser.astro | 36 ++++++++++--------- .../src/content/docs/community/contribute.mdx | 4 +++ website/src/styles/contribution-issues.css | 6 ++++ 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 9ef7f7675..09d99a8a2 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -234,7 +234,8 @@ build: line, sorted by number, with sorted keys and only `number`, `title`, and sorted `labels`. Don't add fields that change on every run (timestamps, comment counts). An empty file means there are no open - calls. + calls; both pages hide the complete calls surface, including its + heading and adjacent links, while leaving the rest of the page intact. - The browser fetches the file from `raw.githubusercontent.com` (`DATA_URL` in [`contribution-issues.ts`](src/scripts/contribution-issues.ts)). diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro index 5f780de16..67626cb20 100644 --- a/website/src/components/contribute/ContributeTeaser.astro +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -7,22 +7,24 @@ import ContributionIssues from './ContributionIssues.astro'; const allHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/community/contribute/`; --- -
        - -
        -
        -

        Open source

        -

        Help build Scope

        +
        +
        + +
        +
        +

        Open source

        +

        Help build Scope

        +
        +
        +

        Pick an open call, then open a pull request that references it. No need to ask first.

        +

        + + + +

        +
        -
        -

        Pick an open call, then open a pull request that references it. No need to ask first.

        -

        - - - -

        -
        -
        - - See all open calls + + See all open calls +
        diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx index d3f8355d3..9f042b404 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -5,6 +5,8 @@ description: Open issues where the Scope team is calling for contributions, and import ContributionIssues from '../../../components/contribute/ContributionIssues.astro'; +
        + Scope is open source, and some issues are set aside for the community. Each one below is open and nobody is assigned to it yet. @@ -26,3 +28,5 @@ Contributor License Agreement (CLA). You only sign it once. The list refreshes about every hour. + +
        diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css index f02dc2d8c..9e467e5ef 100644 --- a/website/src/styles/contribution-issues.css +++ b/website/src/styles/contribution-issues.css @@ -16,6 +16,12 @@ .contrib-fallback, .contrib-message { display: none; } .contrib[data-state='empty'] .contrib-list, .contrib[data-state='error'] .contrib-list { display: none; } .contrib[data-state='empty'] .contrib-message--empty, .contrib[data-state='error'] .contrib-message--error { display: block; } +/* Empty published data means there is no feature to promote. Hide the whole + * surface, including headings and adjacent links supplied by the host page. */ +[data-contribution-calls]:has(.contrib[data-state='empty']) { display: none; } +/* Before the stacked contributors feature lands, the landing section contains + * only this surface; avoid leaving an empty section behind. */ +.scope-contrib:has(> [data-contribution-calls] .contrib[data-state='empty']):not(:has(scope-contributors)) { display: none; } @media (scripting: none) { .contrib-list, .contrib-count { display: none !important; } .contrib-fallback { display: block; } From 3e368d41f66df9850cb2930addf159782d137785 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Fri, 23 Oct 2026 12:58:33 -0700 Subject: [PATCH 21/23] test(website): allow published contribution data requests Keep privacy assertions focused on consent and analytics traffic while allowing the website's expected read-only issues data request. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- website/tests/accessibility.spec.ts | 9 ++++++++- website/tests/analytics.spec.ts | 11 ++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/website/tests/accessibility.spec.ts b/website/tests/accessibility.spec.ts index 8f0ac67c3..9ce00f1ae 100644 --- a/website/tests/accessibility.spec.ts +++ b/website/tests/accessibility.spec.ts @@ -4,6 +4,10 @@ import { AxeBuilder } from '@axe-core/playwright'; import { expect, test, type Page } from '@playwright/test'; +const PUBLISHED_DATA_URLS = new Set([ + 'https://raw.githubusercontent.com/microsoft/scope/website-data/issues.jsonl', +]); + async function expectAccessible(page: Page) { const results = await new AxeBuilder({ page }).analyze(); expect(results.violations.map(({ id, nodes }) => ({ @@ -101,7 +105,10 @@ for (const path of ['/', '/introduction/what-is-scope/', '/community/articles-an test(`privacy links and landmarks on ${path}`, async ({ page }) => { const externalRequests: string[] = []; page.on('request', (request) => { - if (new URL(request.url()).origin !== 'http://127.0.0.1:14321') { + if ( + new URL(request.url()).origin !== 'http://127.0.0.1:14321' && + !PUBLISHED_DATA_URLS.has(request.url()) + ) { externalRequests.push(request.url()); } }); diff --git a/website/tests/analytics.spec.ts b/website/tests/analytics.spec.ts index c9cf3902e..580672281 100644 --- a/website/tests/analytics.spec.ts +++ b/website/tests/analytics.spec.ts @@ -3,6 +3,10 @@ import { expect, test, type Page } from '@playwright/test'; +const PUBLISHED_DATA_URLS = new Set([ + 'https://raw.githubusercontent.com/microsoft/scope/website-data/issues.jsonl', +]); + interface AnalyticsTestState { granted: boolean; consentRequired: boolean; @@ -298,7 +302,12 @@ test('blocked analytics scripts leave consent management and documentation usabl test('local production previews make no consent or analytics requests', async ({ page }) => { const externalRequests: string[] = []; page.on('request', (request) => { - if (new URL(request.url()).origin !== 'http://127.0.0.1:14321') externalRequests.push(request.url()); + if ( + new URL(request.url()).origin !== 'http://127.0.0.1:14321' && + !PUBLISHED_DATA_URLS.has(request.url()) + ) { + externalRequests.push(request.url()); + } }); await page.goto('/scope/'); await expect(page.getByRole('main')).toBeVisible(); From ea53eb379afbf92791208a44426a946bf2386c80 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Fri, 23 Oct 2026 16:44:44 -0700 Subject: [PATCH 22/23] chore(website): publish contributor data on merges Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/contribution-data.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/contribution-data.yml b/.github/workflows/contribution-data.yml index 5c2878fb6..d0748e6ad 100644 --- a/.github/workflows/contribution-data.yml +++ b/.github/workflows/contribution-data.yml @@ -3,8 +3,6 @@ name: Publish website contribution data on: - schedule: - - cron: '17 * * * *' push: branches: [main] workflow_dispatch: From 34d2944b31042bbc6906784f68a1d4999a0d373d Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Fri, 23 Oct 2026 16:50:47 -0700 Subject: [PATCH 23/23] docs(website): describe merge-triggered contributor refresh Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 4 ++-- website/AGENTS.md | 5 +++-- website/README.md | 6 +++--- website/src/content/docs/community/contribute.mdx | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 402b2456c..a90006f0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -313,8 +313,8 @@ unblock a PR. 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. +and on the landing page after the merge-triggered contribution-data workflow completes. 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 contributor after a clear request for information or changes, send a reminder after **two weeks diff --git a/website/AGENTS.md b/website/AGENTS.md index 984d9119f..4ed0ee1f6 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -228,8 +228,9 @@ community and Microsoft alike: counts merged PRs whose author is a user, not a bot (`[bot]` suffix). Team members count too; don't filter on `authorAssociation`. - [`.github/workflows/contribution-data.yml`](../.github/workflows/contribution-data.yml) - runs hourly, after pushes to `main`, and on demand. It updates the - orphan data branch only when the deterministic file changes. + runs after pushes to `main` (including merged pull requests) and on + demand. It updates the orphan data branch only when the deterministic + file changes. - 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 diff --git a/website/README.md b/website/README.md index 3cfcd6487..570e9a19f 100644 --- a/website/README.md +++ b/website/README.md @@ -199,9 +199,9 @@ table of contents, code examples, and previous/next navigation. pause controls and reduced-motion support. The logo and documentation chrome remain still. - **Contribution:** a landing-page wall and full grid thank merged-PR - contributors, refreshed hourly from a data branch without a redeploy. - The publisher is `.github/workflows/contribution-data.yml`; see - "Contributors" in `AGENTS.md`. + contributors, refreshed after merges from a data branch without a + redeploy. The publisher is `.github/workflows/contribution-data.yml`; + see "Contributors" in `AGENTS.md`. ### Showreel diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx index 1c7356a43..3e51d5adb 100644 --- a/website/src/content/docs/community/contribute.mdx +++ b/website/src/content/docs/community/contribute.mdx @@ -7,6 +7,6 @@ import Contributors from '../../../components/contribute/Contributors.astro'; 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. +refreshes automatically after pull requests merge.