diff --git a/.github/workflows/contribution-data.yml b/.github/workflows/contribution-data.yml new file mode 100644 index 000000000..5004d3e7c --- /dev/null +++ b/.github/workflows/contribution-data.yml @@ -0,0 +1,100 @@ +# Publishes open calls and merged pull request contributors to the orphan +# `website-data` branch. The docs site fetches deterministic JSONL at runtime. +name: Publish website contribution data + +on: + schedule: + - cron: '17 3 * * *' + push: + branches: [main] + issues: + types: [opened, closed, reopened, edited, labeled, unlabeled, assigned, unassigned] + workflow_dispatch: + +permissions: + contents: write + issues: read + pull-requests: read + +concurrency: + group: website-contribution-data + cancel-in-progress: false + +jobs: + publish: + if: github.repository == 'microsoft/scope' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + DATA_BRANCH: website-data + steps: + - name: Collect open calls + run: | + set -euo pipefail + for label in 'good first issue' 'help wanted'; do + gh issue list \ + --repo "$REPO" \ + --state open \ + --search 'no:assignee' \ + --label "$label" \ + --limit 500 \ + --json number,title,labels,state,assignees + done | + jq -cS -s ' + add + | map(select(.state == "OPEN" and (.assignees | length) == 0)) + | unique_by(.number) + | sort_by(.number) + | .[] + | {number, title, labels: ([.labels[].name] | sort)}' > issues.jsonl + + - name: Collect contributors + run: | + set -euo pipefail + gh api graphql --paginate \ + -F owner="${REPO%/*}" \ + -F name="${REPO#*/}" \ + -f query=' + query($owner: String!, $name: String!, $endCursor: String) { + repository(owner: $owner, name: $name) { + pullRequests(states: MERGED, first: 100, after: $endCursor) { + pageInfo { hasNextPage endCursor } + nodes { author { __typename login } } + } + } + }' \ + --jq '.data.repository.pullRequests.nodes[]' | + jq -cS -s ' + map(select( + .author != null + and .author.__typename == "User" + and (.author.login | endswith("[bot]") | not) + )) + | group_by(.author.login) + | map({login: .[0].author.login, prs: length}) + | sort_by(.login | ascii_downcase) + | .[]' > contributors.jsonl + + - name: Publish website data + run: | + set -euo pipefail + git init -q data + cd data + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" + if git fetch -q --depth 1 origin "$DATA_BRANCH" 2>/dev/null; then + git checkout -q -B "$DATA_BRANCH" FETCH_HEAD + else + git checkout -q --orphan "$DATA_BRANCH" + fi + cp ../issues.jsonl ../contributors.jsonl . + git add issues.jsonl contributors.jsonl + if git diff --cached --quiet; then + echo 'No website data changes.' + exit 0 + fi + git commit -q -m 'Update website contribution data' + git push -q origin "HEAD:$DATA_BRANCH" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4046286dc..0ab0c777f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -311,8 +311,10 @@ 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 -coordinates a fix or revert if needed. +Thank the contributor and link any follow-up work. Once merged, the author appears in the +"Thank you" section of the [Contribute page](https://microsoft.github.io/scope/community/contribute/#thank-you) +and on the landing page after the merge-triggered contribution-data workflow completes. The PR +owner checks the post-merge result and coordinates a fix or revert if needed. If a contribution is not a fit, explain why and close it promptly. When a PR is waiting on the contributor after a clear request for information or changes, send a reminder after **two weeks @@ -340,6 +342,7 @@ Automations depend on these exact names: | Dependabot | `type: dependencies`, plus `language: javascript` or `language: rust` | | Pull Request Labeler | Area, topic, language, and type labels from `.github/labeler.yml` | | Community Contribution Labeler | `community-contribution` | +| Website open calls ([Contribute page](https://microsoft.github.io/scope/community/contribute/)) | Open, unassigned `good first issue` or `help wanted` issues | Use `area: reporting` for Scope's benchmark reporting component, not daily repository activity. Worker upgrade routing uses `type: worker-update`, not the broader `area: worker`. diff --git a/website/AGENTS.md b/website/AGENTS.md index 0c59bff28..365da0d4a 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -218,6 +218,77 @@ Published videos use a labeled Watch on YouTube link, not an iframe or remote thumbnail, so visiting a page does not load a third-party player or its scripts. +### Contribution issues + +The landing page ("Help build Scope") and **Community → Contribute** +list open, unassigned issues labeled `good first issue` or +`help wanted`. The list is data, not content, so it isn't in the +build: + +- [`.github/workflows/contribution-data.yml`](../.github/workflows/contribution-data.yml) + writes `issues.jsonl` and `contributors.jsonl` to the orphan + `website-data` branch. It runs daily at 03:17 UTC, after pushes to + `main`, on relevant issue changes, and on demand. +- `issues.jsonl` is deterministic: one issue per line, sorted by + number, with sorted keys and only `number`, `title`, and sorted + `labels`. An empty file means there are no open calls; both pages + hide the complete calls surface while preserving contributor content. +- 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. +- The landing section shows 4 issues at a time and rotates every 7 + seconds. Rotation pauses while the whole custom element—including + its dots and pause button—is hovered or focused, when off screen or + the tab is hidden, and under reduced motion. +- Check 0, 1, a partial page, exactly 4, 5, several pages, and enough + pages to exceed the viewport on desktop and mobile. Also check + reduced motion and long user-supplied titles and chip values. +- In `pnpm run dev`, a failed fetch 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`. + +### Contributors + +The landing page (an avatar wall) and the "Thank you" section of +**Community → Contribute** (a card grid) thank every author of a merged pull request, +community and Microsoft alike: + +- The publisher writes `contributors.jsonl` to `website-data`. It + counts merged PRs whose author is a user, not a bot (`[bot]` suffix). + Team members count too; don't filter on `authorAssociation`. +- The same workflow publishes this file alongside `issues.jsonl` and + updates contributor data after pushes to `main` (including merged + pull requests). Issue events and the daily reconciliation can also + republish it, but the data branch changes only when deterministic + output changes. +- One line per person, `{"login":"…","prs":N}`, with sorted keys and + sorted by lowercase login. No avatar URLs or timestamps; avatars come + from `https://github.com/.png`, and a failed image shows the + login's initial. +- The browser sorts by merged PRs, then login. The wall shows up to + `limit` items; the last slot becomes a "+N" bubble that links to + the full grid. The grid shows `limit` cards (30 on the Contribute + page) and a "Show all N contributors" button, unless only one card + would stay hidden. Each person links to their merged PRs. +- Wall tooltips get a `--tip-shift` so they stay inside the wall (a + hidden tip on an edge avatar would otherwise make narrow pages scroll + sideways). The intro and count are hidden in the empty and error + states. Check 0, 1, a few, and more contributors than fit the width, + on desktop and mobile, when changing the layout. +- Set `PUBLIC_CONTRIBUTORS_URL` to point at another file + ([`contributors.ts`](src/scripts/contributors.ts)). In + `pnpm run dev` a failed fetch falls back to + [`contributors.sample.jsonl`](src/data/contributors.sample.jsonl), + which lists real contributors only. +- 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 48592d510..64dcef0f7 100644 --- a/website/README.md +++ b/website/README.md @@ -26,7 +26,8 @@ development, contribution, and support guidance live under ├── src/ │ ├── assets/ │ ├── components/ # Astro landing, interactive example, header, page title, site footer -│ │ └── community/ # article/talk lists + landing teaser +│ │ ├── community/ # article/talk lists + landing teaser +│ │ └── contribute/ # open calls + contributor thanks │ ├── content/ │ │ ├── docs/ # all user-facing pages (.md / .mdx) │ │ │ ├── introduction/ @@ -37,13 +38,16 @@ development, contribution, and support guidance live under │ │ │ └── community/ │ │ ├── articles/ # one YAML per published article │ │ └── talks/ # one YAML per talk +│ ├── data/ # dev-only contribution samples │ ├── openapi/scope-openapi.json # artifact generated from the API registry │ ├── plugins/ │ │ ├── remark-base-path.mjs # applies the deployment base to internal links │ │ └── remark-http-snippets.mjs # turns ```http blocks into multi-language tabs │ ├── scripts/ │ │ ├── analytics.ts # consent-gated website-only 1DS analytics -│ │ └── flow-demo.ts # progressive-enhancement example controller +│ │ ├── flow-demo.ts # progressive-enhancement example controller +│ │ ├── contribution-issues*.ts # open calls: data helpers + custom element +│ │ └── 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 @@ -191,9 +195,15 @@ 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, open-call rotation, and contributor avatars, with pause + controls and reduced-motion support. The logo and documentation + chrome remain still. +- **Contribution:** the landing page and Community → Contribute list + open calls and thank merged-PR contributors. Merge and issue events + refresh `.github/workflows/contribution-data.yml` without a site + redeploy, with a daily reconciliation for missed issue updates; see + "Contribution issues" and "Contributors" in `AGENTS.md`. ### Showreel diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 246e266ca..103ddd76e 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -106,7 +106,10 @@ export default defineConfig({ }, { label: 'Community', - items: [{ label: 'Articles & talks', slug: 'community/articles-and-talks' }], + items: [ + { label: 'Articles & talks', slug: 'community/articles-and-talks' }, + { label: 'Contribute', slug: 'community/contribute' }, + ], }, { label: 'Getting Started', diff --git a/website/package.json b/website/package.json index 1c581f807..2e10a0048 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ "start": "worktree-env && astro dev", "build": "astro build", "preview": "worktree-env && astro preview", - "test": "node --test src/plugins/*.test.mjs", + "test": "node --test src/plugins/*.test.mjs src/scripts/*.test.mjs", "test:a11y": "SITE=https://microsoft.github.io BASE_PATH=/scope pnpm build && playwright test", "astro": "astro", "refresh:openapi": "cd .. && pnpm --filter api generate:openapi" diff --git a/website/src/components/Home.astro b/website/src/components/Home.astro index 878a765e9..53d2d8811 100644 --- a/website/src/components/Home.astro +++ b/website/src/components/Home.astro @@ -1,5 +1,6 @@ --- import CommunityTeaser from './community/CommunityTeaser.astro'; +import ContributeTeaser from './contribute/ContributeTeaser.astro'; import FlowDemo from './FlowDemo.astro'; const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); @@ -136,6 +137,7 @@ const paths = [ +
diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro new file mode 100644 index 000000000..91e522aaa --- /dev/null +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -0,0 +1,45 @@ +--- +// Copyright (c) Microsoft Corporation. +// 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/`; +--- + +
+
+ +
+
+

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 +
+ +
+
+

Thank you, contributors

+
+

+ + + +

+
+

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

+
+ See all contributors +
diff --git a/website/src/components/contribute/ContributionIssues.astro b/website/src/components/contribute/ContributionIssues.astro new file mode 100644 index 000000000..6bb0e2f2b --- /dev/null +++ b/website/src/components/contribute/ContributionIssues.astro @@ -0,0 +1,63 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Lists open, unassigned issues labeled "good first issue" or "help wanted". +// The contribution-data 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/contributions.css'; +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'; + /** Cycle through all issues, `limit` at a time. Needs `limit`. */ + rotate?: boolean; +} + +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) })); +--- + + + + +
    + { + 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/components/contribute/Contributors.astro b/website/src/components/contribute/Contributors.astro new file mode 100644 index 000000000..f0337a26b --- /dev/null +++ b/website/src/components/contribute/Contributors.astro @@ -0,0 +1,63 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Thanks contributors: everyone (bots aside) who got a pull request merged. +// The contribution-data workflow publishes the list to the website-data +// branch, and the browser fetches it. See website/AGENTS.md. +import '../../styles/contributions.css'; +import '../../styles/contributors.css'; +import { CONTRIBUTORS_DATA_URL, CONTRIBUTORS_GRAPH_URL } from '../../scripts/contributors'; + +interface Props { + /** `wall` for the landing avatar wall, `grid` for the docs page cards. */ + variant?: 'wall' | 'grid'; + /** + * Wall: maximum number of items, including the "+N" bubble. + * Grid: cards shown before the "Show all" button. + */ + limit?: number; +} + +const { variant = 'wall', limit } = Astro.props; +const src = import.meta.env.PUBLIC_CONTRIBUTORS_URL || CONTRIBUTORS_DATA_URL; +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); +const moreHref = `${base}/community/contribute/`; +const skeletons = variant === 'wall' ? 8 : 3; +const listId = `thanks-list-${variant}`; +--- + + + + +
    + { + Array.from({ length: skeletons }, () => ( +
+ { + variant === 'grid' && ( + + ) + } +

+ No pull request contributors are available yet. +

+

+ 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 new file mode 100644 index 000000000..586b70b61 --- /dev/null +++ b/website/src/content/docs/community/contribute.mdx @@ -0,0 +1,42 @@ +--- +title: Contribute +description: Open calls for contributions and the people whose pull requests have been merged into Scope. +--- + +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. + +## 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 automatically when issues change, with a daily +reconciliation. + + + +
+ +## Thank you + +Thank you to everyone who got a pull request merged, from the community +and the Scope team alike. Select a name to see their merged pull requests. This list also +refreshes automatically after pull requests merge. + + diff --git a/website/src/data/contribution-issues.sample.jsonl b/website/src/data/contribution-issues.sample.jsonl new file mode 100644 index 000000000..de291c76f --- /dev/null +++ b/website/src/data/contribution-issues.sample.jsonl @@ -0,0 +1,12 @@ +{"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","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/data/contributors.sample.jsonl b/website/src/data/contributors.sample.jsonl new file mode 100644 index 000000000..d2fee8679 --- /dev/null +++ b/website/src/data/contributors.sample.jsonl @@ -0,0 +1,9 @@ +{"login":"cedricvidal","prs":11} +{"login":"chanezon","prs":1} +{"login":"cmaneu","prs":2} +{"login":"duffney","prs":3} +{"login":"Gile987","prs":1} +{"login":"idhanth","prs":2} +{"login":"itsmeasadali","prs":1} +{"login":"manekinekko","prs":14} +{"login":"mnkj0021","prs":1} diff --git a/website/src/scripts/contribution-issues-element.ts b/website/src/scripts/contribution-issues-element.ts new file mode 100644 index 000000000..4b26792c6 --- /dev/null +++ b/website/src/scripts/contribution-issues-element.ts @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + DATA_URL, + chips, + contributionLabels, + countLabel, + fetchIssues, + issueUrl, + pageCount, + pageItems, + 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 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; + +/** + * 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; + 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(); + 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); + // 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(); + } + + 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; + 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(...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'); + } + + /** 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); + } + + /** + * 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(); + }; + this.addEventListener('pointerenter', () => hold('hover', true), { signal }); + this.addEventListener('pointerleave', () => hold('hover', false), { signal }); + this.addEventListener('focusin', () => hold('focus', true), { signal }); + this.addEventListener('focusout', (event) => { + if (!this.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) => { + 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/scripts/contribution-issues.test.mjs b/website/src/scripts/contribution-issues.test.mjs new file mode 100644 index 000000000..e2a8166e0 --- /dev/null +++ b/website/src/scripts/contribution-issues.test.mjs @@ -0,0 +1,137 @@ +// 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, + pageCount, + pageItems, + 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('counts rotation pages', () => { + assert.equal(pageCount(0, 4), 0); + assert.equal(pageCount(1, 4), 1); + assert.equal(pageCount(3, 4), 1); + assert.equal(pageCount(4, 4), 1); + assert.equal(pageCount(5, 4), 2); + assert.equal(pageCount(9, 4), 3); + assert.equal(pageCount(60, 4), 15); + assert.equal(pageCount(300, 4), 75); + assert.equal(pageCount(9, 0), 0); +}); + +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('keeps boundary and high-volume rotation pages full', () => { + for (const total of [5, 9, 60, 300]) { + const items = Array.from({ length: total }, (_, index) => index + 1); + const pages = pageCount(total, 4); + const rendered = Array.from({ length: pages }, (_, page) => pageItems(items, page, 4)); + assert.ok(rendered.every((page) => page.length === 4), `${total} issues has a short page`); + for (const item of items) { + assert.ok(rendered.some((page) => page.includes(item)), `${total} issues never renders item ${item}`); + } + } +}); + +test('the dev sample file is valid', () => { + const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); + const { issues, skipped } = parseIssues(text); + 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'); + 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}`); + } +}); diff --git a/website/src/scripts/contribution-issues.ts b/website/src/scripts/contribution-issues.ts new file mode 100644 index 000000000..e8d16555e --- /dev/null +++ b/website/src/scripts/contribution-issues.ts @@ -0,0 +1,136 @@ +// 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-data.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'; +} + +/** 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) }); + if (!response.ok) throw new Error(`Contribution issues request failed: HTTP ${response.status}`); + return parseIssues(await response.text()); +} diff --git a/website/src/scripts/contributors-element.ts b/website/src/scripts/contributors-element.ts new file mode 100644 index 000000000..e39795a62 --- /dev/null +++ b/website/src/scripts/contributors-element.ts @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CONTRIBUTORS_DATA_URL, + avatarUrl, + contributorsLabel, + fetchContributors, + firstPage, + parseContributors, + prsLabel, + prsUrl, + showAllLabel, + sortForDisplay, + tipShift, + totalPrs, + visible, + type Contributor, + type ContributorsResult, +} from './contributors'; + +type State = 'loading' | 'ready' | 'empty' | 'error'; + +const EASE_OUT_BACK = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; +const COUNT_UP_MS = 900; +// Matches the hover/focus scale of `.thanks-avatar` in contributors.css. +const LIFT_SCALE = 1.14; +const MAX_STAGGER_STEPS = 24; + +/** + * Thanks contributors: authors of merged pull requests, bots aside. Renders + * an avatar wall (landing) or a card grid (Contribute page). Without + * JavaScript a link to the GitHub contributors graph stays visible. Motion + * is skipped entirely when the visitor prefers reduced motion. + */ +class ScopeContributors extends HTMLElement { + private motion = window.matchMedia('(prefers-reduced-motion: reduce)'); + private observer: IntersectionObserver | undefined; + private events: AbortController | undefined; + private inView = false; + private loaded = false; + private headingRevealed = false; + private itemsRevealed = false; + private expanded = false; + private resize: ResizeObserver | undefined; + private contributors: Contributor[] = []; + + connectedCallback() { + this.events = new AbortController(); + this.toggleAttribute('data-animate', this.animates); + this.motion.addEventListener('change', () => this.toggleAttribute('data-animate', this.animates), { signal: this.events.signal }); + this.observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + this.inView = true; + this.observer?.disconnect(); + this.reveal(); + }, { rootMargin: '0px 0px -12% 0px' }); + this.observer.observe(this); + this.trackPointer(this.events.signal); + this.watchTips(this.events.signal); + this.querySelector('[data-show-all]')?.addEventListener('click', () => this.showAll(), { signal: this.events.signal }); + void this.load(); + } + + disconnectedCallback() { + this.observer?.disconnect(); + this.resize?.disconnect(); + this.events?.abort(); + } + + private get animates() { + return !this.motion.matches && typeof Element.prototype.animate === 'function'; + } + + private get variant(): 'wall' | 'grid' { + return this.dataset.variant === 'grid' ? 'grid' : 'wall'; + } + + private async load() { + let result: ContributorsResult; + let sample = false; + try { + result = await fetchContributors(this.dataset.src || CONTRIBUTORS_DATA_URL); + } catch (error) { + // Development fallback only; production builds omit the sample. + if (import.meta.env.DEV) { + console.info('[contributors] Live list unavailable; using the dev sample.', error); + const { default: text } = await import('../data/contributors.sample.jsonl?raw'); + result = parseContributors(text); + sample = true; + } else { + console.warn('[contributors] Could not load the live list.', error); + this.setState('error'); + return; + } + } + if (result.skipped.length) console.warn(`[contributors] Skipped invalid lines: ${result.skipped.join(', ')}`); + this.querySelectorAll('[data-sample-tag]').forEach((tag) => { tag.hidden = !sample; }); + await this.render(result.contributors); + } + + private async render(contributors: Contributor[]) { + const list = this.querySelector('[data-list]'); + if (!list) throw new Error('Missing contributors list'); + this.contributors = sortForDisplay(contributors); + const limit = Number(this.dataset.limit) || Infinity; + const grid = this.variant === 'grid'; + const { shown, hidden } = grid + ? (this.expanded ? { shown: this.contributors, hidden: 0 } : firstPage(this.contributors, limit)) + : visible(this.contributors, limit); + + if (this.animates && this.inView) { + await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); + } + const items = shown.map((contributor) => (grid ? this.card(contributor) : this.avatar(contributor))); + if (hidden && !grid) items.push(this.more(hidden)); + list.replaceChildren(...items); + this.placeTips(); + this.updateShowAll(grid ? hidden : 0); + + const people = this.contributors.length; + const prs = totalPrs(this.contributors); + this.querySelectorAll('[data-count-label]').forEach((label) => { label.textContent = contributorsLabel(people); }); + this.querySelectorAll('[data-prs]').forEach((element) => { element.textContent = prsLabel(prs); }); + this.querySelectorAll('[data-count-text]').forEach((text) => { text.textContent = `${people} ${contributorsLabel(people)}, ${prsLabel(prs)}`; }); + this.loaded = true; + this.setState(people ? 'ready' : 'empty'); + this.reveal(); + } + + private updateShowAll(hidden: number) { + const button = this.querySelector('[data-show-all]'); + if (!button) return; + button.hidden = !hidden; + const label = button.querySelector('[data-show-all-label]'); + if (label) label.textContent = showAllLabel(this.contributors.length); + } + + /** Appends the rest of the grid and moves focus to the first new card. */ + private showAll() { + const list = this.querySelector('[data-list]'); + const button = this.querySelector('[data-show-all]'); + if (!list || !button || this.expanded) return; + this.expanded = true; + const rest = this.contributors.slice(list.children.length).map((contributor) => this.card(contributor)); + list.append(...rest); + button.hidden = true; + this.enter(rest, 0, 40, 'rise'); + rest[0]?.querySelector('a')?.focus(); + } + + /** + * Keeps every tooltip, hidden or lifted on hover, inside the wall: a tip + * centered on an edge avatar would otherwise stick out and make the page + * scroll sideways on narrow screens. The arrow keeps pointing at the + * avatar. Transforms on the avatars never move their center, so this is + * safe to run mid-animation. + */ + private placeTips(links: Iterable = this.querySelectorAll('.thanks-avatar')) { + if (this.variant !== 'wall') return; + const bounds = this.getBoundingClientRect(); + if (!bounds.width) return; + for (const link of links) { + const tip = link.querySelector('.thanks-tip'); + if (!tip) continue; + const rect = link.getBoundingClientRect(); + const center = rect.left + rect.width / 2; + const shift = tipShift(center, tip.offsetWidth / 2, bounds.left, bounds.right, LIFT_SCALE); + tip.style.setProperty('--tip-shift', `${Math.round(shift)}px`); + } + } + + private watchTips(signal: AbortSignal) { + if (this.variant !== 'wall') return; + this.resize = new ResizeObserver(() => this.placeTips()); + this.resize.observe(this); + void document.fonts?.ready.then(() => { if (!signal.aborted) this.placeTips(); }); + const one = (event: Event) => { + const link = event.target instanceof Element ? event.target.closest('.thanks-avatar') : null; + if (link) this.placeTips([link]); + }; + this.addEventListener('pointerover', one, { signal, passive: true }); + this.addEventListener('focusin', one, { signal }); + } + + private setState(state: State) { + this.dataset.state = state; + if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); + } + + private reveal() { + if (!this.inView) return; + if (!this.headingRevealed) { + this.headingRevealed = true; + this.enter([...this.querySelectorAll('[data-reveal]')], 0, 90, 'rise'); + } + if (this.loaded && !this.itemsRevealed) { + this.itemsRevealed = true; + const items = [...this.querySelectorAll('[data-reveal-item]')]; + if (this.variant === 'wall') this.enter(items, 200, 45, 'pop'); + else this.enter(items, 180, 70, 'rise'); + if (this.contributors.length) this.countUp(); + } + } + + private enter(elements: HTMLElement[], baseDelay: number, stagger: number, kind: 'rise' | 'pop') { + elements.forEach((element, index) => { + element.dataset.revealed = ''; + if (!this.animates) return; + const from = kind === 'pop' + ? { opacity: 0, transform: `translateY(16px) scale(0.35) rotate(${index % 2 ? 14 : -14}deg)` } + : { opacity: 0, transform: 'translateY(18px) scale(0.98)' }; + element.animate([from, { opacity: 1, transform: 'none' }], { + duration: kind === 'pop' ? 720 : 650, + delay: baseDelay + Math.min(index, MAX_STAGGER_STEPS) * stagger, + easing: EASE_OUT_BACK, + fill: 'backwards', + }); + }); + } + + private countUp() { + const pill = this.querySelector('[data-count-pill]'); + const counter = this.querySelector('[data-count]'); + if (!pill || !counter) return; + const total = this.contributors.length; + pill.dataset.shown = ''; + if (!this.animates) { + counter.textContent = String(total); + return; + } + pill.animate([{ opacity: 0, transform: 'scale(0.85)' }, { opacity: 1, transform: 'none' }], { duration: 500, easing: EASE_OUT_BACK, fill: 'backwards' }); + const start = performance.now(); + const tick = (now: number) => { + const progress = Math.min((now - start) / COUNT_UP_MS, 1); + counter.textContent = String(Math.round(total * (1 - (1 - progress) ** 3))); + if (progress < 1) requestAnimationFrame(tick); + }; + counter.textContent = '0'; + requestAnimationFrame(tick); + } + + /** Feeds the cursor position to the card glow through CSS variables. */ + private trackPointer(signal: AbortSignal) { + this.addEventListener('pointermove', (event) => { + if (this.motion.matches || !(event.target instanceof Element)) return; + const card = event.target.closest('.contrib-card'); + if (!card || !this.contains(card)) return; + const rect = card.getBoundingClientRect(); + card.style.setProperty('--x', `${event.clientX - rect.left}px`); + card.style.setProperty('--y', `${event.clientY - rect.top}px`); + }, { signal, passive: true }); + } + + // Logins come from published data, so every value is set as text or attribute. + private avatar(contributor: Contributor): HTMLLIElement { + const item = this.item(); + const link = this.link(item, 'thanks-avatar', prsUrl(contributor.login)); + link.append(this.image(contributor, 56)); + const tip = this.span(link, 'thanks-tip'); + this.span(tip, 'thanks-tip__login', `@${contributor.login}`); + this.span(tip, 'thanks-tip__prs', prsLabel(contributor.prs)); + this.span(link, 'sr-only', ' (opens in a new tab)'); + return item; + } + + private card(contributor: Contributor): HTMLLIElement { + const item = this.item(); + const link = this.link(item, 'contrib-card thanks-card', prsUrl(contributor.login)); + link.append(this.image(contributor, 96)); + const body = this.span(link, 'thanks-card__body'); + this.span(body, 'thanks-card__login', `@${contributor.login}`).title = `@${contributor.login}`; + this.span(body, 'thanks-card__prs', prsLabel(contributor.prs)); + this.span(link, 'contrib-card__arrow thanks-card__arrow', '↗').setAttribute('aria-hidden', 'true'); + this.span(link, 'sr-only', ' (opens in a new tab)'); + return item; + } + + private more(hidden: number): HTMLLIElement { + const item = this.item(); + const link = document.createElement('a'); + link.className = 'thanks-more'; + link.href = this.dataset.moreHref || '#'; + this.span(link, '', `+${hidden}`).setAttribute('aria-hidden', 'true'); + this.span(link, 'sr-only', `${hidden} more ${contributorsLabel(hidden)}`); + item.append(link); + return item; + } + + private item(): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'thanks-item'; + item.dataset.revealItem = ''; + return item; + } + + private link(parent: HTMLElement, className: string, href: string): HTMLAnchorElement { + const link = document.createElement('a'); + link.className = className; + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + parent.append(link); + return link; + } + + private image(contributor: Contributor, size: number): HTMLElement { + const frame = document.createElement('span'); + frame.className = 'thanks-photo'; + frame.dataset.initial = contributor.login.charAt(0).toUpperCase(); + const image = document.createElement('img'); + image.src = avatarUrl(contributor.login, size * 2); + image.width = size; + image.height = size; + image.alt = ''; + image.loading = 'lazy'; + image.decoding = 'async'; + // A deleted or renamed account has no avatar; show the initial instead. + image.addEventListener('error', () => { frame.dataset.broken = ''; image.remove(); }, { once: true }); + frame.append(image); + return frame; + } + + private span(parent: Element, className: string, text?: string): HTMLElement { + const element = document.createElement('span'); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + parent.append(element); + return element; + } +} + +if (!customElements.get('scope-contributors')) customElements.define('scope-contributors', ScopeContributors); diff --git a/website/src/scripts/contributors.test.mjs b/website/src/scripts/contributors.test.mjs new file mode 100644 index 000000000..5d69ff5ca --- /dev/null +++ b/website/src/scripts/contributors.test.mjs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Runs under Node's built-in test runner (`pnpm test`), which strips the +// TypeScript types from the imported module. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + avatarUrl, + contributorsLabel, + firstPage, + parseContributors, + prsLabel, + prsUrl, + showAllLabel, + sortForDisplay, + tipShift, + totalPrs, + visible, +} from './contributors.ts'; + +const line = (value) => JSON.stringify(value); + +test('parses JSON Lines and ignores blank lines', () => { + const text = [line({ login: 'ada', prs: 2 }), '', line({ login: 'grace-h', prs: 1 }), ''].join('\n'); + assert.deepEqual(parseContributors(text), { + contributors: [{ login: 'ada', prs: 2 }, { login: 'grace-h', prs: 1 }], + skipped: [], + }); +}); + +test('treats an empty file as no contributors', () => { + assert.deepEqual(parseContributors(''), { contributors: [], skipped: [] }); +}); + +test('skips invalid logins, counts and duplicates without dropping the rest', () => { + const text = [ + '{not json', + line({ login: 'dependabot[bot]', prs: 9 }), + line({ login: '-leading', prs: 1 }), + line({ login: 'double--hyphen', prs: 1 }), + line({ login: '../evil', prs: 1 }), + line({ login: 'zero', prs: 0 }), + line({ login: 'fraction', prs: 1.5 }), + line({ login: 'Kept', prs: 3 }), + line({ login: 'kept', prs: 1 }), + ].join('\r\n'); + const { contributors, skipped } = parseContributors(text); + assert.deepEqual(contributors, [{ login: 'Kept', prs: 3 }]); + assert.deepEqual(skipped, [1, 2, 3, 4, 5, 6, 7, 9]); +}); + +test('sorts by merged PRs, then login case-insensitively', () => { + const sorted = sortForDisplay([ + { login: 'zed', prs: 1 }, + { login: 'Bea', prs: 1 }, + { login: 'max', prs: 4 }, + { login: 'alex', prs: 1 }, + ]); + assert.deepEqual(sorted.map((c) => c.login), ['max', 'alex', 'Bea', 'zed']); +}); + +test('builds avatar and merged PR URLs', () => { + assert.equal(avatarUrl('ada'), 'https://github.com/ada.png?size=96'); + assert.equal(avatarUrl('ada', 160), 'https://github.com/ada.png?size=160'); + const url = new URL(prsUrl('grace-h')); + assert.equal(url.pathname, '/microsoft/scope/pulls'); + assert.equal(url.searchParams.get('q'), 'is:pr is:merged author:grace-h'); +}); + +test('pluralizes labels and totals PRs', () => { + assert.equal(prsLabel(1), '1 merged PR'); + assert.equal(prsLabel(3), '3 merged PRs'); + assert.equal(contributorsLabel(1), 'contributor'); + assert.equal(contributorsLabel(2), 'contributors'); + assert.equal(totalPrs([{ login: 'a', prs: 2 }, { login: 'b', prs: 3 }]), 5); + assert.equal(totalPrs([]), 0); +}); + +test('keeps a slot for the overflow bubble', () => { + const items = [1, 2, 3, 4, 5, 6]; + assert.deepEqual(visible(items, 10), { shown: items, hidden: 0 }); + assert.deepEqual(visible(items, 6), { shown: items, hidden: 0 }); + assert.deepEqual(visible(items, 4), { shown: [1, 2, 3], hidden: 3 }); + assert.deepEqual(visible(items, Infinity), { shown: items, hidden: 0 }); +}); + +test('pages the grid without hiding a single card', () => { + const items = [1, 2, 3, 4, 5, 6]; + assert.deepEqual(firstPage(items, 3), { shown: [1, 2, 3], hidden: 3 }); + assert.deepEqual(firstPage(items, 4), { shown: [1, 2, 3, 4], hidden: 2 }); + assert.deepEqual(firstPage(items, 5), { shown: items, hidden: 0 }); + assert.deepEqual(firstPage(items, Infinity), { shown: items, hidden: 0 }); + assert.deepEqual(firstPage([], 30), { shown: [], hidden: 0 }); +}); + +test('labels the show all button', () => { + assert.equal(showAllLabel(31), 'Show all 31 contributors'); + assert.equal(showAllLabel(1), 'Show all 1 contributor'); +}); + +test('keeps a centered tooltip in place', () => { + assert.equal(tipShift(500, 40, 0, 1000, 1.14), 0); + assert.equal(tipShift(500, 40, 0, 1000), 0); +}); + +test('shifts edge tooltips inside the bounds, even when scaled', () => { + const left = 10; + const right = 375; + const scale = 1.14; + const half = 60; + for (const center of [12, 30, 70, 190, 330, 360, 373]) { + const shift = tipShift(center, half, left, right, scale); + const tipLeft = center + scale * (shift - half); + const tipRight = center + scale * (shift + half); + assert.ok(tipLeft >= left - 1e-9, `left edge at center ${center}`); + assert.ok(tipRight <= right + 1e-9, `right edge at center ${center}`); + } + assert.ok(tipShift(12, half, left, right, scale) > 0); + assert.ok(tipShift(373, half, left, right, scale) < 0); +}); + +test('aligns a tooltip wider than the bounds to the left edge', () => { + const shift = tipShift(50, 80, 0, 100, 1); + assert.equal(50 + shift - 80, 0); +}); + +test('the dev sample file is valid and sorted like the published file', () => { + const text = readFileSync(new URL('../data/contributors.sample.jsonl', import.meta.url), 'utf8'); + const { contributors, skipped } = parseContributors(text); + assert.deepEqual(skipped, []); + assert.ok(contributors.length >= 1); + const logins = contributors.map((c) => c.login.toLowerCase()); + assert.deepEqual(logins, [...logins].sort()); + for (const raw of text.trim().split('\n')) { + assert.deepEqual(Object.keys(JSON.parse(raw)), ['login', 'prs'], 'keys are sorted and nothing else is published'); + } +}); diff --git a/website/src/scripts/contributors.ts b/website/src/scripts/contributors.ts new file mode 100644 index 000000000..aae7281c4 --- /dev/null +++ b/website/src/scripts/contributors.ts @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Data helpers for the contributors thank-you section. The list is +// published as JSON Lines on the `website-data` branch by +// .github/workflows/contribution-data.yml and fetched in the browser. +// This module has no DOM dependencies so it can be unit-tested in Node. + +const REPOSITORY = 'microsoft/scope'; +export const CONTRIBUTORS_DATA_URL = `https://raw.githubusercontent.com/${REPOSITORY}/website-data/contributors.jsonl`; +export const CONTRIBUTORS_GRAPH_URL = `https://github.com/${REPOSITORY}/graphs/contributors`; + +export interface Contributor { + login: string; + /** Number of merged pull requests. */ + prs: number; +} + +export interface ContributorsResult { + contributors: Contributor[]; + /** 1-based line numbers that were skipped because they were invalid. */ + skipped: number[]; +} + +// GitHub logins: alphanumerics and single hyphens, up to 39 characters. +const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +function isContributor(value: unknown): value is Contributor { + if (typeof value !== 'object' || value === null) return false; + const { login, prs } = value as Record; + return typeof login === 'string' && LOGIN.test(login) && Number.isSafeInteger(prs) && (prs as number) > 0; +} + +/** + * Parses the JSON Lines payload. Invalid lines, bot accounts and duplicate + * logins are skipped so one bad line can't hide the list. + */ +export function parseContributors(text: string): ContributorsResult { + const contributors: Contributor[] = []; + const skipped: number[] = []; + const seen = new Set(); + text.split(/\r?\n/).forEach((line, index) => { + if (line.trim() === '') return; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + skipped.push(index + 1); + return; + } + if (!isContributor(value) || seen.has(value.login.toLowerCase())) { + skipped.push(index + 1); + return; + } + seen.add(value.login.toLowerCase()); + contributors.push({ login: value.login, prs: value.prs }); + }); + return { contributors, skipped }; +} + +/** Display order: most merged PRs first, then by login. */ +export function sortForDisplay(contributors: readonly Contributor[]): Contributor[] { + return [...contributors].sort((a, b) => b.prs - a.prs || a.login.localeCompare(b.login, 'en', { sensitivity: 'base' })); +} + +export function avatarUrl(login: string, size = 96): string { + return `https://github.com/${encodeURIComponent(login)}.png?size=${size}`; +} + +/** The contributor's merged pull requests in this repository. */ +export function prsUrl(login: string): string { + const query = `is:pr is:merged author:${login}`; + return `https://github.com/${REPOSITORY}/pulls?q=${encodeURIComponent(query)}`; +} + +export function prsLabel(count: number): string { + return count === 1 ? '1 merged PR' : `${count} merged PRs`; +} + +export function contributorsLabel(count: number): string { + return count === 1 ? 'contributor' : 'contributors'; +} + +export function totalPrs(contributors: readonly Contributor[]): number { + return contributors.reduce((sum, contributor) => sum + contributor.prs, 0); +} + +/** Splits the list into what fits under `limit` and how many are left over. */ +export function visible(items: readonly T[], limit: number): { shown: T[]; hidden: number } { + if (!Number.isFinite(limit) || limit <= 0 || items.length <= limit) return { shown: [...items], hidden: 0 }; + // Keep a slot for the "+N" bubble so the wall never exceeds `limit` items. + const shown = items.slice(0, limit - 1); + return { shown, hidden: items.length - shown.length }; +} + +/** + * First page of the grid: `limit` items, or all of them when at most one + * more would remain, so "Show all" never reveals a single card. + */ +export function firstPage(items: readonly T[], limit: number): { shown: T[]; hidden: number } { + return visible(items, limit + 1); +} + +/** Label of the grid's "Show all" button. */ +export function showAllLabel(total: number): string { + return `Show all ${total} ${contributorsLabel(total)}`; +} + +/** + * Horizontal shift that keeps a tooltip, centered on an avatar and scaled + * by `scale` on hover, inside `[left, right]`. A tip wider than the bounds + * stays aligned to the left edge. + */ +export function tipShift(center: number, halfWidth: number, left: number, right: number, scale = 1): number { + const min = (left - center) / scale + halfWidth; + const max = (right - center) / scale - halfWidth; + return min > 0 ? min : max < 0 ? max : 0; +} + +/** Fetches and parses the published list. Throws on network or HTTP errors. */ +export async function fetchContributors(url: string, timeoutMs = 8000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`Contributors request failed: HTTP ${response.status}`); + return parseContributors(await response.text()); +} diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css new file mode 100644 index 000000000..7100b28d8 --- /dev/null +++ b/website/src/styles/contribution-issues.css @@ -0,0 +1,90 @@ +/* + * 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[data-state='empty'] .contrib-list, .contrib[data-state='error'] .contrib-list { display: none; } +.contrib[data-state='empty'] .contrib-message--empty, .contrib[data-state='error'] .contrib-message--error { display: block; } +/* Empty published data means there is no feature to promote. Hide the whole + * surface, including headings and adjacent links supplied by the host page. */ +[data-contribution-calls]:has(.contrib[data-state='empty']) { display: none; } +@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; } + +/* 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; } + +/* 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; } +/* 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; } + +/* Rotation controls (landing): one dot per set, the active one fills as a progress bar. */ +.contrib-rotation { display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.1rem; } +.contrib-dots { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: center; min-width: 0; } +.contrib-dot, .contrib-toggle { display: inline-grid; place-items: center; min-width: 1.5rem; height: 1.5rem; padding: 0 0.15rem; border: 0; border-radius: 100px; background: none; color: var(--scope-muted); cursor: pointer; } +.contrib-dot:focus-visible, .contrib-toggle:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 1px; } +.contrib-dot__track { position: relative; display: block; width: 0.5rem; height: 0.5rem; border-radius: 100px; background: var(--scope-border); overflow: hidden; transition: width 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.3s ease; } +.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 > scope-contributors { margin-top: 4rem; padding-top: 2.5rem; border-top: 1px solid var(--scope-border); } + +/* 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; } +} diff --git a/website/src/styles/contributions.css b/website/src/styles/contributions.css new file mode 100644 index 000000000..6aa468603 --- /dev/null +++ b/website/src/styles/contributions.css @@ -0,0 +1,132 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Shared presentation for website contribution data: status messages, + * count pills, cards, and development-only sample labels. + */ + +.contrib-fallback, .contrib-message { display: none; } +.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; } + +.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; } +} + +.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; +} +.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); +} +.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; } +.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); } + +.scope-contrib__all { display: inline-block; margin-top: 1.5rem; } + +@keyframes contrib-shimmer { to { transform: translateX(100%); } } + +@media (prefers-reduced-motion: reduce) { + .contrib-count__pulse::after, .contrib-card, .contrib-card::before, .contrib-card::after, .contrib-card__arrow { + animation: none !important; + transition: none !important; + } + .contrib-card:is(:hover, :focus-visible), .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } +} diff --git a/website/src/styles/contributors.css b/website/src/styles/contributors.css new file mode 100644 index 000000000..5e75ab0ed --- /dev/null +++ b/website/src/styles/contributors.css @@ -0,0 +1,118 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Contributors (). An avatar wall on the + * landing page and a card grid on the Community > Contribute page. Shares + * the card, message and sample-tag styles in contributions.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, h2, h3) { margin: 0; } +.thanks a { text-decoration: none; } +.thanks .contrib-message a, .thanks .contrib-fallback a { color: var(--scope-brand); font-weight: 600; text-decoration: underline; text-underline-offset: 0.2em; } + +/* States. Without JavaScript only the GitHub fallback link is shown. */ +.thanks[data-state='empty'] .thanks-list, .thanks[data-state='error'] .thanks-list { display: none; } +.thanks:is([data-state='empty'], [data-state='error']) :is(.thanks-intro, [data-count-pill]) { display: none; } +.thanks[data-state='empty'] .thanks-message--empty, .thanks[data-state='error'] .thanks-message--error { display: block; } +@media (scripting: none) { + .thanks-list, .thanks .contrib-count { display: none !important; } + .thanks .contrib-fallback { display: block; } +} +.thanks[data-animate] :is([data-reveal], [data-reveal-item]):not([data-revealed]) { opacity: 0; } + +.thanks-list { display: flex; flex-wrap: wrap; padding: 0 !important; list-style: none; } +.thanks-item { position: relative; display: flex; margin: 0 !important; } + +/* Avatar photo with an initial as fallback. */ +.thanks-photo { position: relative; display: grid; flex: none; place-items: center; width: var(--size); height: var(--size); border-radius: 50%; background: linear-gradient(135deg, var(--scope-brand), color-mix(in srgb, var(--scope-brand) 45%, var(--scope-pass))); overflow: hidden; } +.thanks-photo img { display: block; width: 100%; height: 100%; object-fit: cover; } +.thanks-photo[data-broken]::after { content: attr(data-initial); color: var(--scope-on-brand); font-weight: 700; font-size: calc(var(--size) * 0.42); } + +/* Landing heading. */ +.thanks-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1rem; margin-bottom: 1.25rem; } +.thanks-head :is(h2, 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; } +.thanks--wall .thanks-list { row-gap: 0.75rem; padding-left: 0.6rem !important; } +.thanks--wall .thanks-item { margin-left: -0.6rem !important; } +.thanks--wall .thanks-item:hover, .thanks--wall .thanks-item:focus-within { z-index: 2; } +.thanks-avatar, .thanks-more { + position: relative; + display: grid; + place-items: center; + width: var(--size); + height: var(--size); + border-radius: 50%; + box-shadow: 0 0 0 3px var(--scope-bg); + transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease; +} +.thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible) { transform: translateY(-6px) scale(1.14); box-shadow: 0 0 0 3px var(--scope-bg), 0 0 0 5px var(--scope-brand), var(--scope-shadow); } +.thanks-avatar:focus-visible, .thanks-more:focus-visible { outline: none; } +.thanks-more { background: var(--scope-surface); border: 1px dashed var(--scope-border); color: var(--scope-brand); font: 650 0.85rem var(--sl-font-mono); } + +/* Tooltip; also the link's accessible name. */ +.thanks-tip { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.4rem 0.7rem; + border-radius: 0.6rem; + background: var(--scope-text); + color: var(--scope-bg); + font-size: 0.72rem; + line-height: 1.35; + white-space: nowrap; + pointer-events: none; + opacity: 0; + /* --tip-shift is set by the element to keep the tip inside the wall. */ + transform: translate(calc(-50% + var(--tip-shift, 0px)), 6px) scale(0.92); + transition: opacity 0.2s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.thanks-tip::after { content: ''; position: absolute; top: 100%; left: calc(50% - var(--tip-shift, 0px)); border: 5px solid transparent; border-top-color: var(--scope-text); transform: translateX(-50%); } +.thanks-tip__login { font-weight: 650; } +.thanks-tip__prs { opacity: 0.75; } +.thanks-avatar:is(:hover, :focus-visible) .thanks-tip { opacity: 1; transform: translate(calc(-50% + var(--tip-shift, 0px)), 0) scale(1); } + +/* Skeletons. */ +.thanks-skeleton { position: relative; overflow: hidden; background: var(--scope-inset); } +.thanks--wall .thanks-skeleton { width: var(--size); height: var(--size); border-radius: 50%; box-shadow: 0 0 0 3px var(--scope-bg); } +.thanks--grid .thanks-skeleton { height: 5.5rem; border: 1px solid var(--scope-border); border-radius: 1rem; } +.thanks-skeleton::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--scope-brand) 9%, transparent) 50%, transparent 80%); transform: translateX(-100%); animation: contrib-shimmer 1.6s ease-in-out infinite; } + +/* Grid (docs page): cards with the shared pointer glow. */ +.thanks--grid { --size: 3rem; margin-top: 1.5rem; } +.thanks--grid .thanks-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 1rem; } +.thanks--grid .thanks-item { display: block; } +.thanks-card.contrib-card { flex-direction: row; align-items: center; gap: 0.9rem; height: 100%; padding: 1rem 1.1rem; } +.thanks-card .thanks-photo { box-shadow: 0 0 0 2px var(--scope-surface), 0 0 0 3px var(--scope-border); transition: box-shadow 0.3s ease; } +.thanks-card:is(:hover, :focus-visible) .thanks-photo { box-shadow: 0 0 0 2px var(--scope-surface), 0 0 0 4px var(--scope-brand); } +.thanks-card__body { display: flex; flex-direction: column; min-width: 0; } +.thanks-card__login { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.thanks-card__prs { color: var(--scope-muted); font-size: 0.78rem; } +.thanks-card__arrow { margin-left: auto; color: var(--scope-brand); } +.thanks-show-all { display: flex; align-items: center; gap: 0.5rem; margin: 1.5rem auto 0 !important; padding: 0.6rem 1.25rem; border: 1px solid var(--scope-border); border-radius: 999px; background: var(--scope-surface); color: var(--scope-brand); font: 600 0.9rem var(--sl-font); cursor: pointer; transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } +.thanks-show-all:is(:hover, :focus-visible) { border-color: var(--scope-brand); box-shadow: var(--scope-shadow); transform: translateY(-2px); } +.thanks-show-all:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 3px; } +.thanks-show-all[hidden] { display: none; } + +@media (prefers-reduced-motion: reduce) { + .thanks *, .thanks *::before, .thanks *::after { animation: none !important; transition: none !important; } + .thanks-show-all:is(:hover, :focus-visible), .thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } + .thanks-avatar:is(:hover, :focus-visible) .thanks-tip { transform: translate(calc(-50% + var(--tip-shift, 0px)), 0); } +} diff --git a/website/tests/accessibility.spec.ts b/website/tests/accessibility.spec.ts index 8f0ac67c3..2b7fa881a 100644 --- a/website/tests/accessibility.spec.ts +++ b/website/tests/accessibility.spec.ts @@ -4,6 +4,11 @@ import { AxeBuilder } from '@axe-core/playwright'; import { expect, test, type Page } from '@playwright/test'; +const PUBLISHED_DATA_URLS = new Set([ + 'https://raw.githubusercontent.com/microsoft/scope/website-data/issues.jsonl', + 'https://raw.githubusercontent.com/microsoft/scope/website-data/contributors.jsonl', +]); + async function expectAccessible(page: Page) { const results = await new AxeBuilder({ page }).analyze(); expect(results.violations.map(({ id, nodes }) => ({ @@ -53,6 +58,27 @@ test('landing page and interactive example are accessible', async ({ page }) => .toBeVisible(); }); +test('focused contribution controls pause carousel rotation', async ({ page }) => { + await page.route( + 'https://raw.githubusercontent.com/microsoft/scope/website-data/issues.jsonl', + (route) => route.fulfill({ + contentType: 'application/x-ndjson', + body: Array.from({ length: 5 }, (_, index) => JSON.stringify({ + number: index + 1, + title: `Contribution call ${index + 1}`, + labels: ['help wanted'], + })).join('\n'), + }), + ); + await page.goto('/scope/'); + const dots = page.locator('.contrib-dot'); + await expect(dots).toHaveCount(2); + await dots.nth(1).click(); + await expect(dots.nth(1)).toHaveAttribute('aria-current', 'true'); + await page.waitForTimeout(7_500); + await expect(dots.nth(1)).toHaveAttribute('aria-current', 'true'); +}); + test('infrastructure feature tiles align their graphics and copy', async ({ page }) => { await page.goto('/scope/'); const features = page.locator('.scope-kubernetes-features'); @@ -97,11 +123,14 @@ test('infrastructure feature tiles align their graphics and copy', async ({ page } }); -for (const path of ['/', '/introduction/what-is-scope/', '/community/articles-and-talks/', '/reference/api/']) { +for (const path of ['/', '/introduction/what-is-scope/', '/community/articles-and-talks/', '/community/contribute/', '/reference/api/']) { test(`privacy links and landmarks on ${path}`, async ({ page }) => { const externalRequests: string[] = []; page.on('request', (request) => { - if (new URL(request.url()).origin !== 'http://127.0.0.1:14321') { + if ( + new URL(request.url()).origin !== 'http://127.0.0.1:14321' && + !PUBLISHED_DATA_URLS.has(request.url()) + ) { externalRequests.push(request.url()); } }); diff --git a/website/tests/analytics.spec.ts b/website/tests/analytics.spec.ts index c9cf3902e..b08f9770b 100644 --- a/website/tests/analytics.spec.ts +++ b/website/tests/analytics.spec.ts @@ -3,6 +3,11 @@ import { expect, test, type Page } from '@playwright/test'; +const PUBLISHED_DATA_URLS = new Set([ + 'https://raw.githubusercontent.com/microsoft/scope/website-data/issues.jsonl', + 'https://raw.githubusercontent.com/microsoft/scope/website-data/contributors.jsonl', +]); + interface AnalyticsTestState { granted: boolean; consentRequired: boolean; @@ -298,7 +303,12 @@ test('blocked analytics scripts leave consent management and documentation usabl test('local production previews make no consent or analytics requests', async ({ page }) => { const externalRequests: string[] = []; page.on('request', (request) => { - if (new URL(request.url()).origin !== 'http://127.0.0.1:14321') externalRequests.push(request.url()); + if ( + new URL(request.url()).origin !== 'http://127.0.0.1:14321' && + !PUBLISHED_DATA_URLS.has(request.url()) + ) { + externalRequests.push(request.url()); + } }); await page.goto('/scope/'); await expect(page.getByRole('main')).toBeVisible();