From f75e2c7426b4cca1edbaf39290ba93447a9707d0 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 1 Oct 2026 16:23:17 -0700 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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