diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6479819b4..38af27dd7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -309,7 +309,9 @@ unblock a PR. ### 6. Close the loop -Thank the contributor and link any follow-up work. The PR owner checks the post-merge result and +Thank the contributor and link any follow-up work. Once merged, external authors appear in the +"Thank you" section of the [Contribute page](https://microsoft.github.io/scope/community/contribute/#thank-you) +and on the landing page within about an hour. The PR owner checks the post-merge result and coordinates a fix or revert if needed. If a contribution is not a fit, explain why and close it promptly. When a PR is waiting on the @@ -337,6 +339,7 @@ Automations depend on these exact names: | Test Improver issues, PRs, and monthly-summary searches | `type: automation`, `topic: testing` | | Dependabot | `type: dependencies`, plus `language: javascript` or `language: rust` | | Pull Request Labeler | Area, topic, language, and type labels from `.github/labeler.yml` | +| Website open calls ([Contribute page](https://microsoft.github.io/scope/community/contribute/)) | Open, unassigned `good first issue` or `help wanted` issues | Use `area: reporting` for Scope's benchmark reporting component, not daily repository activity. Worker upgrade routing uses `type: worker-update`, not the broader `area: worker`. diff --git a/website/AGENTS.md b/website/AGENTS.md index dfdeb5bb1..3c50ca459 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -210,6 +210,70 @@ components open them in a new tab (`target="_blank" rel="noopener noreferrer"`) with a screen-reader "(opens in a new tab)" hint; keep that pattern for any new external link. +### Contribution issues + +The landing page ("Help build Scope") and **Community → Contribute** +list open, unassigned issues labeled `good first issue` or +`help wanted`. The list is data, not content, so it isn't in the +build: + +- [`.github/workflows/contribution-issues.yml`](../.github/workflows/contribution-issues.yml) + runs every hour, on issue events, and on demand. It writes + `issues.jsonl` to the orphan `website-data` branch and commits only + when the file changes. Run it once by hand (`workflow_dispatch`) to + create the branch. +- `issues.jsonl` is deterministic so diffs stay small: one issue per + line, sorted by number, with sorted keys and only `number`, `title`, + and sorted `labels`. Don't add fields that change on every run + (timestamps, comment counts). An empty file means there are no open + calls. +- The browser fetches the file from `raw.githubusercontent.com` + (`DATA_URL` in + [`contribution-issues.ts`](src/scripts/contribution-issues.ts)). + Set `PUBLIC_CONTRIBUTION_ISSUES_URL` to point at another file. +- Display order is decided client-side: good first issues first, then + newest. `area:`, `difficulty:`, and `type:` labels show as chips. +- The landing section shows 4 issues at a time and rotates through all + of them every 7 seconds (`rotate` prop). The active page dot fills + as a progress bar. Rotation pauses on hover, on keyboard focus, when + off screen or the tab is hidden, and with the pause button. It never + autoplays under reduced motion; the dots still work. +- In `pnpm run dev`, if the fetch fails (no branch yet, offline), the + list falls back to + [`contribution-issues.sample.jsonl`](src/data/contribution-issues.sample.jsonl) + and shows a "Sample data · dev only" tag. Production builds drop + that branch and the sample. +- Titles and labels come from GitHub users. Render them with + `textContent` only, never `innerHTML`. +- Motion uses the Web Animations API, with no animation library. Gate + any new motion on `prefers-reduced-motion`. + +### Community contributors + +The landing page (an avatar wall under the open calls) and the +"Thank you" section of **Community → Contribute** (a card grid) +thank external authors of merged pull requests: + +- The same workflow writes `contributors.jsonl` to `website-data` in + the same commit as `issues.jsonl`. It counts merged PRs whose + author is a user, not a bot (`[bot]` suffix), and whose + `authorAssociation` isn't `MEMBER`, `OWNER`, or `COLLABORATOR`. +- One line per person, `{"login":"…","prs":N}`, with sorted keys and + sorted by lowercase login. No avatar URLs or timestamps; avatars come + from `https://github.com/.png`, and a failed image shows the + login's initial. +- The browser sorts by merged PRs, then login. The wall shows up to + `limit` items; the last slot becomes a "+N" bubble that links to + the full grid. Each person links to their merged PRs. +- Set `PUBLIC_CONTRIBUTORS_URL` to point at another file + ([`contributors.ts`](src/scripts/contributors.ts)). In + `pnpm run dev` a failed fetch falls back to + [`contributors.sample.jsonl`](src/data/contributors.sample.jsonl), + which lists real external contributors only; don't pad it with other + accounts. +- Logins are user data: render them with `textContent` or attributes + only. + ### Style - Hard-wrap prose at ~70–80 columns for readable diffs. @@ -226,7 +290,7 @@ tab)" hint; keep that pattern for any new external link. ```sh pnpm install -pnpm test # plugin regressions, using Node's built-in test runner +pnpm test # plugin and script tests, using Node's built-in test runner pnpm run build # writes dist/ pnpm run dev # local preview at http://localhost:4321 pnpm run refresh:openapi # generate the OpenAPI snapshot from scope-core @@ -235,7 +299,7 @@ pnpm run refresh:openapi # generate the OpenAPI snapshot from scope-core Both `pnpm test` and `pnpm run build` must pass. The public build uses `SITE=https://microsoft.github.io BASE_PATH=/scope pnpm run build`; exercise that configuration when changing links or deployment settings, -not just the local `/` default. The current snapshot produces **203 +not just the local `/` default. The current snapshot produces **215 pages**, including the generated API reference. An unexpected drop in page count can indicate a content collection file failed to parse. diff --git a/website/README.md b/website/README.md index 435621efe..312a0e199 100644 --- a/website/README.md +++ b/website/README.md @@ -17,7 +17,8 @@ The product and this documentation site live in ├── src/ │ ├── assets/ │ ├── components/ # Astro landing, interactive example, header, page title, site footer -│ │ └── community/ # article/talk lists + landing teaser +│ │ ├── community/ # article/talk lists + landing teaser +│ │ └── contribute/ # open calls + contributor thanks (landing + page) │ ├── content/ │ │ ├── docs/ # all user-facing pages (.md / .mdx) │ │ │ ├── introduction/ @@ -28,11 +29,14 @@ The product and this documentation site live in │ │ │ └── community/ │ │ ├── articles/ # one YAML per published article │ │ └── talks/ # one YAML per talk +│ ├── data/ # dev-only samples: contribution issues, contributors │ ├── openapi/scope-openapi.json # artifact generated from the API registry │ ├── plugins/ │ │ ├── remark-base-path.mjs # applies the deployment base to internal links │ │ └── remark-http-snippets.mjs # turns ```http blocks into multi-language tabs │ ├── scripts/flow-demo.ts # progressive-enhancement example controller +│ ├── scripts/contribution-issues*.ts # open calls: data helpers + custom element +│ ├── scripts/contributors*.ts # contributor thanks: data helpers + custom element │ ├── styles/landing.css # shared brand tokens + scoped landing styles │ └── content.config.ts ├── astro.config.mjs # sidebar, plugins, starlight-openapi config @@ -51,7 +55,7 @@ Sidebar order is defined in `astro.config.mjs`, not by directory order. | `pnpm dev` | Start dev server at the worktree's `DOC_PORT` (fallback: 4300) | | `pnpm build` | Build the production site to `./dist/` | | `pnpm preview` | Preview the production build locally | -| `pnpm test` | Test site plugins with Node's built-in test runner | +| `pnpm test` | Test site plugins and scripts with Node's built-in test runner | | `pnpm refresh:openapi` | Generate the OpenAPI snapshot from `scope-core` | ## Authoring docs @@ -140,9 +144,16 @@ 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, the open-calls cards (staggered entrance, count-up, + cursor glow, a rotation through all open calls), and the + contributor avatars (pop-in, hover tooltips), with pause controls + and reduced-motion support. The logo and documentation chrome remain + still. +- **Contribution:** a "Help build Scope" section lists open calls + for contributions and thanks community contributors, refreshed + hourly from a data branch without a redeploy. See "Contribution + issues" and "Community contributors" in `AGENTS.md`. ### Showreel diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 5f994c490..43f72398d 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -100,7 +100,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 f2d171e8a..c86b4c1fc 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ "start": "worktree-env && astro dev", "build": "astro build", "preview": "worktree-env && astro preview", - "test": "node --test src/plugins/*.test.mjs", + "test": "node --test src/plugins/*.test.mjs src/scripts/*.test.mjs", "astro": "astro", "refresh:openapi": "cd .. && pnpm --filter api generate:openapi" }, diff --git a/website/src/components/Home.astro b/website/src/components/Home.astro index 46c4f1b51..7c4fee870 100644 --- a/website/src/components/Home.astro +++ b/website/src/components/Home.astro @@ -1,5 +1,6 @@ --- import CommunityTeaser from './community/CommunityTeaser.astro'; +import ContributeTeaser from './contribute/ContributeTeaser.astro'; import FlowDemo from './FlowDemo.astro'; const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); @@ -137,6 +138,7 @@ const paths = [ +

Bring your own question

diff --git a/website/src/components/contribute/ContributeTeaser.astro b/website/src/components/contribute/ContributeTeaser.astro new file mode 100644 index 000000000..55f58ed09 --- /dev/null +++ b/website/src/components/contribute/ContributeTeaser.astro @@ -0,0 +1,40 @@ +--- +// 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.

+
+
diff --git a/website/src/components/contribute/ContributionIssues.astro b/website/src/components/contribute/ContributionIssues.astro new file mode 100644 index 000000000..118dd5e98 --- /dev/null +++ b/website/src/components/contribute/ContributionIssues.astro @@ -0,0 +1,62 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Lists open, unassigned issues labeled "good first issue" or "help wanted". +// The contribution-issues workflow publishes the list to the website-data +// branch, and the browser fetches it, so it stays fresh without +// redeploying the site. See website/AGENTS.md. +import '../../styles/contribution-issues.css'; +import { CONTRIBUTION_LABELS, DATA_URL, searchUrl } from '../../scripts/contribution-issues'; + +interface Props { + /** Maximum number of issues to show. Omit to show them all. */ + limit?: number; + /** `cards` for the animated landing grid, `list` for the docs page. */ + variant?: 'cards' | 'list'; + /** 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..bd44ac1f8 --- /dev/null +++ b/website/src/components/contribute/Contributors.astro @@ -0,0 +1,53 @@ +--- +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Thanks community contributors: external authors of merged pull requests. +// The contribution-issues workflow publishes the list to the website-data +// branch, and the browser fetches it. See website/AGENTS.md. +import '../../styles/contribution-issues.css'; +import '../../styles/contributors.css'; +import { CONTRIBUTORS_DATA_URL, CONTRIBUTORS_GRAPH_URL } from '../../scripts/contributors'; + +interface Props { + /** `wall` for the landing avatar wall, `grid` for the docs page cards. */ + variant?: 'wall' | 'grid'; + /** Maximum number of items on the wall, including the "+N" bubble. */ + limit?: number; +} + +const { variant = 'wall', limit } = Astro.props; +const src = import.meta.env.PUBLIC_CONTRIBUTORS_URL || CONTRIBUTORS_DATA_URL; +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); +const moreHref = `${base}/community/contribute/#thank-you`; +const openCallsHref = `${base}/community/contribute/#open-calls`; +const skeletons = variant === 'wall' ? 8 : 3; +--- + + + + +
    + { + Array.from({ length: skeletons }, () => ( +
+

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

+

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

+

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

+
+ + diff --git a/website/src/content/docs/community/contribute.mdx b/website/src/content/docs/community/contribute.mdx new file mode 100644 index 000000000..ed0eb66fe --- /dev/null +++ b/website/src/content/docs/community/contribute.mdx @@ -0,0 +1,37 @@ +--- +title: Contribute +description: Open issues where the Scope team is calling for contributions, and how to pick one up. +--- + +import ContributionIssues from '../../../components/contribute/ContributionIssues.astro'; +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 about every hour. + + + +## Thank you + +Thank you to everyone outside the Scope team who got a pull request +merged. Select a name to see their merged pull requests. This list also +refreshes about every hour. + + diff --git a/website/src/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..0a7de7f71 --- /dev/null +++ b/website/src/data/contributors.sample.jsonl @@ -0,0 +1,4 @@ +{"login":"Gile987","prs":1} +{"login":"idhanth","prs":2} +{"login":"itsmeasadali","prs":1} +{"login":"mnkj0021","prs":1} diff --git a/website/src/scripts/contribution-issues-element.ts b/website/src/scripts/contribution-issues-element.ts new file mode 100644 index 000000000..1098482d9 --- /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(); + }; + list.addEventListener('pointerenter', () => hold('hover', true), { signal }); + list.addEventListener('pointerleave', () => hold('hover', false), { signal }); + list.addEventListener('focusin', () => hold('focus', true), { signal }); + list.addEventListener('focusout', (event) => { + if (!list.contains(event.relatedTarget as Node | null)) hold('focus', false); + }, { signal }); + document.addEventListener('visibilitychange', () => hold('hidden', document.hidden), { signal }); + this.motion.addEventListener('change', () => { + this.toggleAttribute('data-animate', this.animates); + this.restartTimer(); + }, { signal }); + this.visibility = new IntersectionObserver((entries) => { + hold('offscreen', !entries.some((entry) => entry.isIntersecting)); + }, { threshold: 0.25 }); + this.visibility.observe(list); + + this.holds.add('offscreen'); + if (document.hidden) this.holds.add('hidden'); + this.updateDots(); + this.restartTimer(); + } + + /** Restarts the progress fill on the active dot; its end advances the page. */ + private restartTimer() { + this.timer?.cancel(); + this.timer = undefined; + const autoplay = this.animates; + if (this.toggle) this.toggle.hidden = !autoplay; + const fill = this.dots[this.page]?.querySelector('.contrib-dot__fill'); + if (autoplay && fill) { + this.timer = fill.animate([{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], { duration: ROTATE_MS, easing: 'linear' }); + this.timer.onfinish = () => void this.goTo((this.page + 1) % this.pages); + } + this.sync(); + } + + /** Plays or pauses the timer from the current holds. */ + private sync() { + const paused = this.holds.size > 0; + if (this.timer) { + if (paused) this.timer.pause(); + else this.timer.play(); + } + if (this.toggle) { + const userPaused = this.holds.has('user'); + this.toggle.dataset.paused = String(userPaused); + this.toggle.lastElementChild!.textContent = userPaused ? 'Resume rotating open calls' : 'Pause rotating open calls'; + } + } + + private updateDots() { + this.dots.forEach((dot, page) => { + if (page === this.page) dot.setAttribute('aria-current', 'true'); + else dot.removeAttribute('aria-current'); + }); + } + + private async goTo(page: number) { + const list = this.querySelector('[data-list]'); + if (!list || this.switching || page === this.page) return; + this.switching = true; + this.timer?.cancel(); + this.page = page; + this.updateDots(); + + const outgoing = [...list.children] as HTMLElement[]; + if (this.animates) { + await Promise.all(outgoing.map((element, index) => element.animate( + [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(-12px) scale(0.98)' }], + { duration: 240, delay: index * 45, easing: EASE_OUT, fill: 'forwards' }, + ).finished.catch(() => undefined))); + } + // Keep the grid from collapsing while card heights change between sets. + list.style.minHeight = `${Math.max(parseFloat(list.style.minHeight) || 0, list.offsetHeight)}px`; + const incoming = this.pageIssues(page).map((issue) => this.item(issue)); + list.replaceChildren(...incoming); + this.enter(incoming, 0, STAGGER_MS); + this.switching = false; + this.restartTimer(); + } + + /** Feeds the cursor position to the card glow through CSS variables. */ + private trackPointer(signal: AbortSignal) { + this.addEventListener('pointermove', (event) => { + 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..548ef4fb4 --- /dev/null +++ b/website/src/scripts/contribution-issues.test.mjs @@ -0,0 +1,121 @@ +// 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(3, 4), 1); + assert.equal(pageCount(4, 4), 1); + assert.equal(pageCount(9, 4), 3); + assert.equal(pageCount(9, 0), 0); +}); + +test('fills every rotation page, wrapping the last one', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + assert.deepEqual(pageItems(items, 0, 4), [1, 2, 3, 4]); + assert.deepEqual(pageItems(items, 1, 4), [5, 6, 7, 8]); + assert.deepEqual(pageItems(items, 2, 4), [9, 1, 2, 3]); + assert.deepEqual(pageItems([1, 2], 0, 4), [1, 2]); + assert.deepEqual(pageItems([], 0, 4), []); +}); + +test('the dev sample file is valid', () => { + const text = readFileSync(new URL('../data/contribution-issues.sample.jsonl', import.meta.url), 'utf8'); + const { issues, skipped } = parseIssues(text); + 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..b0988ea79 --- /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-issues.yml and fetched in the browser. +// This module has no DOM dependencies so it can be unit-tested in Node. + +export const REPOSITORY = 'microsoft/scope'; +export const DATA_URL = `https://raw.githubusercontent.com/${REPOSITORY}/website-data/issues.jsonl`; +export const CONTRIBUTION_LABELS = ['good first issue', 'help wanted'] as const; + +export type ContributionLabel = (typeof CONTRIBUTION_LABELS)[number]; + +export interface ContributionIssue { + number: number; + title: string; + labels: string[]; +} + +export interface ParseResult { + issues: ContributionIssue[]; + /** 1-based line numbers that were skipped because they were invalid. */ + skipped: number[]; +} + +/** Label prefixes shown as chips; all other labels stay hidden. */ +const CHIP_PREFIXES = ['area', 'difficulty', 'type'] as const; +export type ChipKind = (typeof CHIP_PREFIXES)[number]; + +export interface Chip { + kind: ChipKind; + value: string; + label: string; +} + +function isIssue(value: unknown): value is ContributionIssue { + if (typeof value !== 'object' || value === null) return false; + const { number, title, labels } = value as Record; + return ( + Number.isSafeInteger(number) && + (number as number) > 0 && + typeof title === 'string' && + title.trim() !== '' && + Array.isArray(labels) && + labels.every((label) => typeof label === 'string') + ); +} + +/** + * Parses the JSON Lines payload. Invalid lines, and lines without a + * contribution label, are skipped so one bad line can't hide the list. + */ +export function parseIssues(text: string): ParseResult { + const issues: ContributionIssue[] = []; + const skipped: number[] = []; + const seen = new Set(); + text.split(/\r?\n/).forEach((line, index) => { + if (line.trim() === '') return; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + skipped.push(index + 1); + return; + } + if (!isIssue(value) || !contributionLabels(value).length || seen.has(value.number)) { + skipped.push(index + 1); + return; + } + seen.add(value.number); + issues.push({ number: value.number, title: value.title.trim(), labels: [...value.labels] }); + }); + return { issues, skipped }; +} + +/** The contribution labels an issue carries, `good first issue` first. */ +export function contributionLabels(issue: Pick): ContributionLabel[] { + return CONTRIBUTION_LABELS.filter((label) => issue.labels.includes(label)); +} + +/** Display order: `good first issue` first, then newest (highest number). */ +export function sortForDisplay(issues: readonly ContributionIssue[]): ContributionIssue[] { + const rank = (issue: ContributionIssue) => (issue.labels.includes('good first issue') ? 0 : 1); + return [...issues].sort((a, b) => rank(a) - rank(b) || b.number - a.number); +} + +/** `area:`, `difficulty:` and `type:` labels as chips, in that order. */ +export function chips(issue: Pick): Chip[] { + const result: Chip[] = []; + for (const kind of CHIP_PREFIXES) { + for (const label of issue.labels) { + const match = /^([a-z]+):\s*(.+)$/i.exec(label); + if (match && match[1].toLowerCase() === kind) result.push({ kind, value: match[2].trim(), label }); + } + } + return result; +} + +export function issueUrl(number: number): string { + return `https://github.com/${REPOSITORY}/issues/${number}`; +} + +/** GitHub issue search for open, unassigned issues with the given label. */ +export function searchUrl(label: ContributionLabel): string { + const query = `is:issue is:open no:assignee label:"${label}"`; + return `https://github.com/${REPOSITORY}/issues?q=${encodeURIComponent(query)}`; +} + +export function countLabel(count: number): string { + return count === 1 ? 'open call' : 'open calls'; +} + +/** 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..c39d037f4 --- /dev/null +++ b/website/src/scripts/contributors-element.ts @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CONTRIBUTORS_DATA_URL, + avatarUrl, + contributorsLabel, + fetchContributors, + parseContributors, + prsLabel, + prsUrl, + sortForDisplay, + totalPrs, + visible, + type Contributor, + type ContributorsResult, +} from './contributors'; + +type State = 'loading' | 'ready' | 'empty' | 'error'; + +const EASE_OUT_BACK = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; +const COUNT_UP_MS = 900; +const MAX_STAGGER_STEPS = 24; + +/** + * Thanks the community: external authors of merged pull requests. Renders + * an avatar wall (landing) or a card grid (Contribute page). Without + * JavaScript a link to the GitHub contributors graph stays visible. Motion + * is skipped entirely when the visitor prefers reduced motion. + */ +class ScopeContributors extends HTMLElement { + private motion = window.matchMedia('(prefers-reduced-motion: reduce)'); + private observer: IntersectionObserver | undefined; + private events: AbortController | undefined; + private inView = false; + private loaded = false; + private headingRevealed = false; + private itemsRevealed = false; + private contributors: Contributor[] = []; + + connectedCallback() { + this.events = new AbortController(); + this.toggleAttribute('data-animate', this.animates); + this.motion.addEventListener('change', () => this.toggleAttribute('data-animate', this.animates), { signal: this.events.signal }); + this.observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + this.inView = true; + this.observer?.disconnect(); + this.reveal(); + }, { rootMargin: '0px 0px -12% 0px' }); + this.observer.observe(this); + this.trackPointer(this.events.signal); + void this.load(); + } + + disconnectedCallback() { + this.observer?.disconnect(); + this.events?.abort(); + } + + private get animates() { + return !this.motion.matches && typeof Element.prototype.animate === 'function'; + } + + private get variant(): 'wall' | 'grid' { + return this.dataset.variant === 'grid' ? 'grid' : 'wall'; + } + + private async load() { + let result: ContributorsResult; + let sample = false; + try { + result = await fetchContributors(this.dataset.src || CONTRIBUTORS_DATA_URL); + } catch (error) { + // Same dev fallback as the open calls; removed from production builds. + if (import.meta.env.DEV) { + console.info('[contributors] Live list unavailable; using the dev sample.', error); + const { default: text } = await import('../data/contributors.sample.jsonl?raw'); + result = parseContributors(text); + sample = true; + } else { + console.warn('[contributors] Could not load the live list.', error); + this.setState('error'); + return; + } + } + if (result.skipped.length) console.warn(`[contributors] Skipped invalid lines: ${result.skipped.join(', ')}`); + this.querySelectorAll('[data-sample-tag]').forEach((tag) => { tag.hidden = !sample; }); + await this.render(result.contributors); + } + + private async render(contributors: Contributor[]) { + const list = this.querySelector('[data-list]'); + if (!list) throw new Error('Missing contributors list'); + this.contributors = sortForDisplay(contributors); + const { shown, hidden } = visible(this.contributors, Number(this.dataset.limit) || Infinity); + + if (this.animates && this.inView) { + await list.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 160, easing: 'ease-out' }).finished.catch(() => undefined); + } + const items = shown.map((contributor) => (this.variant === 'grid' ? this.card(contributor) : this.avatar(contributor))); + if (hidden) items.push(this.more(hidden)); + list.replaceChildren(...items); + + const people = this.contributors.length; + const prs = totalPrs(this.contributors); + this.querySelectorAll('[data-count-label]').forEach((label) => { label.textContent = contributorsLabel(people); }); + this.querySelectorAll('[data-prs]').forEach((element) => { element.textContent = prsLabel(prs); }); + this.querySelectorAll('[data-count-text]').forEach((text) => { text.textContent = `${people} ${contributorsLabel(people)}, ${prsLabel(prs)}`; }); + this.loaded = true; + this.setState(people ? 'ready' : 'empty'); + this.reveal(); + } + + private setState(state: State) { + this.dataset.state = state; + if (state !== 'loading') this.querySelector('[data-list]')?.removeAttribute('aria-busy'); + } + + private reveal() { + if (!this.inView) return; + if (!this.headingRevealed) { + this.headingRevealed = true; + this.enter([...this.querySelectorAll('[data-reveal]')], 0, 90, 'rise'); + } + if (this.loaded && !this.itemsRevealed) { + this.itemsRevealed = true; + const items = [...this.querySelectorAll('[data-reveal-item]')]; + if (this.variant === 'wall') this.enter(items, 200, 45, 'pop'); + else this.enter(items, 180, 70, 'rise'); + if (this.contributors.length) this.countUp(); + } + } + + private enter(elements: HTMLElement[], baseDelay: number, stagger: number, kind: 'rise' | 'pop') { + elements.forEach((element, index) => { + element.dataset.revealed = ''; + if (!this.animates) return; + const from = kind === 'pop' + ? { opacity: 0, transform: `translateY(16px) scale(0.35) rotate(${index % 2 ? 14 : -14}deg)` } + : { opacity: 0, transform: 'translateY(18px) scale(0.98)' }; + element.animate([from, { opacity: 1, transform: 'none' }], { + duration: kind === 'pop' ? 720 : 650, + delay: baseDelay + Math.min(index, MAX_STAGGER_STEPS) * stagger, + easing: EASE_OUT_BACK, + fill: 'backwards', + }); + }); + } + + private countUp() { + const pill = this.querySelector('[data-count-pill]'); + const counter = this.querySelector('[data-count]'); + if (!pill || !counter) return; + const total = this.contributors.length; + pill.dataset.shown = ''; + if (!this.animates) { + counter.textContent = String(total); + return; + } + pill.animate([{ opacity: 0, transform: 'scale(0.85)' }, { opacity: 1, transform: 'none' }], { duration: 500, easing: EASE_OUT_BACK, fill: 'backwards' }); + const start = performance.now(); + const tick = (now: number) => { + const progress = Math.min((now - start) / COUNT_UP_MS, 1); + counter.textContent = String(Math.round(total * (1 - (1 - progress) ** 3))); + if (progress < 1) requestAnimationFrame(tick); + }; + counter.textContent = '0'; + requestAnimationFrame(tick); + } + + /** Feeds the cursor position to the card glow through CSS variables. */ + private trackPointer(signal: AbortSignal) { + this.addEventListener('pointermove', (event) => { + if (this.motion.matches || !(event.target instanceof Element)) return; + const card = event.target.closest('.contrib-card'); + if (!card || !this.contains(card)) return; + const rect = card.getBoundingClientRect(); + card.style.setProperty('--x', `${event.clientX - rect.left}px`); + card.style.setProperty('--y', `${event.clientY - rect.top}px`); + }, { signal, passive: true }); + } + + // Logins come from published data, so every value is set as text or attribute. + private avatar(contributor: Contributor): HTMLLIElement { + const item = this.item(); + const link = this.link(item, 'thanks-avatar', prsUrl(contributor.login)); + link.append(this.image(contributor, 56)); + const tip = this.span(link, 'thanks-tip'); + this.span(tip, 'thanks-tip__login', `@${contributor.login}`); + this.span(tip, 'thanks-tip__prs', prsLabel(contributor.prs)); + this.span(link, 'sr-only', ' (opens in a new tab)'); + return item; + } + + private card(contributor: Contributor): HTMLLIElement { + const item = this.item(); + const link = this.link(item, 'contrib-card thanks-card', prsUrl(contributor.login)); + link.append(this.image(contributor, 96)); + const body = this.span(link, 'thanks-card__body'); + this.span(body, 'thanks-card__login', `@${contributor.login}`).title = `@${contributor.login}`; + this.span(body, 'thanks-card__prs', prsLabel(contributor.prs)); + this.span(link, 'contrib-card__arrow thanks-card__arrow', '↗').setAttribute('aria-hidden', 'true'); + this.span(link, 'sr-only', ' (opens in a new tab)'); + return item; + } + + private more(hidden: number): HTMLLIElement { + const item = this.item(); + const link = document.createElement('a'); + link.className = 'thanks-more'; + link.href = this.dataset.moreHref || '#'; + this.span(link, '', `+${hidden}`).setAttribute('aria-hidden', 'true'); + this.span(link, 'sr-only', `${hidden} more ${contributorsLabel(hidden)}`); + item.append(link); + return item; + } + + private item(): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'thanks-item'; + item.dataset.revealItem = ''; + return item; + } + + private link(parent: HTMLElement, className: string, href: string): HTMLAnchorElement { + const link = document.createElement('a'); + link.className = className; + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + parent.append(link); + return link; + } + + private image(contributor: Contributor, size: number): HTMLElement { + const frame = document.createElement('span'); + frame.className = 'thanks-photo'; + frame.dataset.initial = contributor.login.charAt(0).toUpperCase(); + const image = document.createElement('img'); + image.src = avatarUrl(contributor.login, size * 2); + image.width = size; + image.height = size; + image.alt = ''; + image.loading = 'lazy'; + image.decoding = 'async'; + // A deleted or renamed account has no avatar; show the initial instead. + image.addEventListener('error', () => { frame.dataset.broken = ''; image.remove(); }, { once: true }); + frame.append(image); + return frame; + } + + private span(parent: Element, className: string, text?: string): HTMLElement { + const element = document.createElement('span'); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + parent.append(element); + return element; + } +} + +if (!customElements.get('scope-contributors')) customElements.define('scope-contributors', ScopeContributors); diff --git a/website/src/scripts/contributors.test.mjs b/website/src/scripts/contributors.test.mjs new file mode 100644 index 000000000..e5bcae5d7 --- /dev/null +++ b/website/src/scripts/contributors.test.mjs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Runs under Node's built-in test runner (`pnpm test`), which strips the +// TypeScript types from the imported module. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + avatarUrl, + contributorsLabel, + parseContributors, + prsLabel, + prsUrl, + sortForDisplay, + totalPrs, + visible, +} from './contributors.ts'; + +const line = (value) => JSON.stringify(value); + +test('parses JSON Lines and ignores blank lines', () => { + const text = [line({ login: 'ada', prs: 2 }), '', line({ login: 'grace-h', prs: 1 }), ''].join('\n'); + assert.deepEqual(parseContributors(text), { + contributors: [{ login: 'ada', prs: 2 }, { login: 'grace-h', prs: 1 }], + skipped: [], + }); +}); + +test('treats an empty file as no contributors', () => { + assert.deepEqual(parseContributors(''), { contributors: [], skipped: [] }); +}); + +test('skips invalid logins, counts and duplicates without dropping the rest', () => { + const text = [ + '{not json', + line({ login: 'dependabot[bot]', prs: 9 }), + line({ login: '-leading', prs: 1 }), + line({ login: 'double--hyphen', prs: 1 }), + line({ login: '../evil', prs: 1 }), + line({ login: 'zero', prs: 0 }), + line({ login: 'fraction', prs: 1.5 }), + line({ login: 'Kept', prs: 3 }), + line({ login: 'kept', prs: 1 }), + ].join('\r\n'); + const { contributors, skipped } = parseContributors(text); + assert.deepEqual(contributors, [{ login: 'Kept', prs: 3 }]); + assert.deepEqual(skipped, [1, 2, 3, 4, 5, 6, 7, 9]); +}); + +test('sorts by merged PRs, then login case-insensitively', () => { + const sorted = sortForDisplay([ + { login: 'zed', prs: 1 }, + { login: 'Bea', prs: 1 }, + { login: 'max', prs: 4 }, + { login: 'alex', prs: 1 }, + ]); + assert.deepEqual(sorted.map((c) => c.login), ['max', 'alex', 'Bea', 'zed']); +}); + +test('builds avatar and merged PR URLs', () => { + assert.equal(avatarUrl('ada'), 'https://github.com/ada.png?size=96'); + assert.equal(avatarUrl('ada', 160), 'https://github.com/ada.png?size=160'); + const url = new URL(prsUrl('grace-h')); + assert.equal(url.pathname, '/microsoft/scope/pulls'); + assert.equal(url.searchParams.get('q'), 'is:pr is:merged author:grace-h'); +}); + +test('pluralizes labels and totals PRs', () => { + assert.equal(prsLabel(1), '1 merged PR'); + assert.equal(prsLabel(3), '3 merged PRs'); + assert.equal(contributorsLabel(1), 'contributor'); + assert.equal(contributorsLabel(2), 'contributors'); + assert.equal(totalPrs([{ login: 'a', prs: 2 }, { login: 'b', prs: 3 }]), 5); + assert.equal(totalPrs([]), 0); +}); + +test('keeps a slot for the overflow bubble', () => { + const items = [1, 2, 3, 4, 5, 6]; + assert.deepEqual(visible(items, 10), { shown: items, hidden: 0 }); + assert.deepEqual(visible(items, 6), { shown: items, hidden: 0 }); + assert.deepEqual(visible(items, 4), { shown: [1, 2, 3], hidden: 3 }); + assert.deepEqual(visible(items, Infinity), { shown: items, hidden: 0 }); +}); + +test('the dev sample file is valid and sorted like the published file', () => { + const text = readFileSync(new URL('../data/contributors.sample.jsonl', import.meta.url), 'utf8'); + const { contributors, skipped } = parseContributors(text); + assert.deepEqual(skipped, []); + assert.ok(contributors.length >= 1); + const logins = contributors.map((c) => c.login.toLowerCase()); + assert.deepEqual(logins, [...logins].sort()); + for (const raw of text.trim().split('\n')) { + assert.deepEqual(Object.keys(JSON.parse(raw)), ['login', 'prs'], 'keys are sorted and nothing else is published'); + } +}); diff --git a/website/src/scripts/contributors.ts b/website/src/scripts/contributors.ts new file mode 100644 index 000000000..b5def8e9c --- /dev/null +++ b/website/src/scripts/contributors.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Data helpers for the community contributors thank-you section. The list is +// published as JSON Lines on the `website-data` branch by +// .github/workflows/contribution-issues.yml and fetched in the browser. +// This module has no DOM dependencies so it can be unit-tested in Node. + +const REPOSITORY = 'microsoft/scope'; +export const CONTRIBUTORS_DATA_URL = `https://raw.githubusercontent.com/${REPOSITORY}/website-data/contributors.jsonl`; +export const CONTRIBUTORS_GRAPH_URL = `https://github.com/${REPOSITORY}/graphs/contributors`; + +export interface Contributor { + login: string; + /** Number of merged pull requests. */ + prs: number; +} + +export interface ContributorsResult { + contributors: Contributor[]; + /** 1-based line numbers that were skipped because they were invalid. */ + skipped: number[]; +} + +// GitHub logins: alphanumerics and single hyphens, up to 39 characters. +const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +function isContributor(value: unknown): value is Contributor { + if (typeof value !== 'object' || value === null) return false; + const { login, prs } = value as Record; + return typeof login === 'string' && LOGIN.test(login) && Number.isSafeInteger(prs) && (prs as number) > 0; +} + +/** + * Parses the JSON Lines payload. Invalid lines, bot accounts and duplicate + * logins are skipped so one bad line can't hide the list. + */ +export function parseContributors(text: string): ContributorsResult { + const contributors: Contributor[] = []; + const skipped: number[] = []; + const seen = new Set(); + text.split(/\r?\n/).forEach((line, index) => { + if (line.trim() === '') return; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + skipped.push(index + 1); + return; + } + if (!isContributor(value) || seen.has(value.login.toLowerCase())) { + skipped.push(index + 1); + return; + } + seen.add(value.login.toLowerCase()); + contributors.push({ login: value.login, prs: value.prs }); + }); + return { contributors, skipped }; +} + +/** Display order: most merged PRs first, then by login. */ +export function sortForDisplay(contributors: readonly Contributor[]): Contributor[] { + return [...contributors].sort((a, b) => b.prs - a.prs || a.login.localeCompare(b.login, 'en', { sensitivity: 'base' })); +} + +export function avatarUrl(login: string, size = 96): string { + return `https://github.com/${encodeURIComponent(login)}.png?size=${size}`; +} + +/** The contributor's merged pull requests in this repository. */ +export function prsUrl(login: string): string { + const query = `is:pr is:merged author:${login}`; + return `https://github.com/${REPOSITORY}/pulls?q=${encodeURIComponent(query)}`; +} + +export function prsLabel(count: number): string { + return count === 1 ? '1 merged PR' : `${count} merged PRs`; +} + +export function contributorsLabel(count: number): string { + return count === 1 ? 'contributor' : 'contributors'; +} + +export function totalPrs(contributors: readonly Contributor[]): number { + return contributors.reduce((sum, contributor) => sum + contributor.prs, 0); +} + +/** Splits the list into what fits under `limit` and how many are left over. */ +export function visible(items: readonly T[], limit: number): { shown: T[]; hidden: number } { + if (!Number.isFinite(limit) || limit <= 0 || items.length <= limit) return { shown: [...items], hidden: 0 }; + // Keep a slot for the "+N" bubble so the wall never exceeds `limit` items. + const shown = items.slice(0, limit - 1); + return { shown, hidden: items.length - shown.length }; +} + +/** Fetches and parses the published list. Throws on network or HTTP errors. */ +export async function fetchContributors(url: string, timeoutMs = 8000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`Contributors request failed: HTTP ${response.status}`); + return parseContributors(await response.text()); +} diff --git a/website/src/styles/contribution-issues.css b/website/src/styles/contribution-issues.css new file mode 100644 index 000000000..920bead0a --- /dev/null +++ b/website/src/styles/contribution-issues.css @@ -0,0 +1,138 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Open calls for contributions (). Used on the + * landing page (cards) and the Community > Contribute page (list). + */ + +.contrib { display: block; color: var(--scope-text); font-family: var(--sl-font); line-height: 1.6; } +.contrib * { box-sizing: border-box; } +.contrib :is(p, ul) { margin: 0; } +.contrib a { text-decoration: none; } +.contrib .contrib-message a, .contrib .contrib-fallback a { color: var(--scope-brand); font-weight: 600; text-decoration: underline; text-underline-offset: 0.2em; } + +/* States. Without JavaScript only the GitHub fallback links are shown. */ +.contrib-fallback, .contrib-message { display: none; } +.contrib[data-state='empty'] .contrib-list, .contrib[data-state='error'] .contrib-list { display: none; } +.contrib[data-state='empty'] .contrib-message--empty, .contrib[data-state='error'] .contrib-message--error { display: block; } +@media (scripting: none) { + .contrib-list, .contrib-count { display: none !important; } + .contrib-fallback { display: block; } +} +.contrib[data-animate] :is([data-reveal], [data-reveal-item]):not([data-revealed]) { opacity: 0; } + +.contrib-message, .contrib-fallback { padding: 1.2rem 1.4rem; border: 1px dashed var(--scope-border); border-radius: 1rem; background: var(--scope-inset); color: var(--scope-muted); font-size: 0.9rem; } + +.contrib-sample-tag { display: inline-block; margin-bottom: 0.9rem !important; padding: 0.2rem 0.6rem; border: 1px dashed var(--scope-fail); border-radius: 100px; color: var(--scope-fail); font: 600 0.65rem var(--sl-font-mono); letter-spacing: 0.1em; text-transform: uppercase; } +.contrib-sample-tag[hidden] { display: none; } + +/* Count pill (landing). */ +.contrib-count { display: inline-flex; align-items: center; gap: 0.55rem; margin-top: 1rem !important; padding: 0.35rem 0.85rem 0.35rem 0.7rem; border: 1px solid var(--scope-border); border-radius: 100px; background: var(--scope-surface); color: var(--scope-text); font-size: 0.8rem; font-weight: 600; visibility: hidden; } +.contrib-count[data-shown] { visibility: visible; } +.contrib-count__value { color: var(--scope-brand); font-family: var(--sl-font-mono); font-variant-numeric: tabular-nums; } +.contrib-count__pulse { position: relative; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--scope-pass); } +.contrib-count__pulse::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: var(--scope-pass); animation: contrib-pulse 2.2s cubic-bezier(0.22, 1, 0.36, 1) infinite; } +@keyframes contrib-pulse { from { transform: scale(1); opacity: 0.6; } to { transform: scale(3.2); opacity: 0; } } + +/* List and cards. */ +.contrib-list { display: grid; gap: 1rem; padding: 0 !important; list-style: none; } +.contrib--cards .contrib-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.contrib-item { display: flex; min-width: 0; margin: 0 !important; } + +.contrib-card { + --x: 50%; + --y: 0%; + position: relative; + isolation: isolate; + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + padding: 1.3rem 1.4rem 1.2rem; + border: 1px solid var(--scope-border); + border-radius: 1rem; + background: var(--scope-surface); + color: var(--scope-text); + overflow: hidden; + transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease, border-color 0.35s ease; +} +.contrib-card::before, .contrib-card::after { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity 0.35s ease; } +/* Border glow that follows the cursor. */ +.contrib-card::before { + padding: 1px; + background: radial-gradient(16rem circle at var(--x) var(--y), var(--scope-brand), transparent 70%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); +} +/* Soft spotlight behind the content. */ +.contrib-card::after { z-index: -1; background: radial-gradient(26rem circle at var(--x) var(--y), color-mix(in srgb, var(--scope-brand) 11%, transparent), transparent 65%); } +.contrib-card:is(:hover, :focus-visible) { transform: translateY(-4px); border-color: color-mix(in srgb, var(--scope-brand) 45%, var(--scope-border)); box-shadow: var(--scope-shadow); } +.contrib-card:is(:hover, :focus-visible)::before, .contrib-card:is(:hover, :focus-visible)::after { opacity: 1; } +.contrib-card:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 3px; } + +/* The title comes first in the DOM so it leads the link's accessible name. */ +.contrib-card__top { order: -1; display: flex; align-items: center; gap: 0.75rem; } +.contrib-card__title { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 1.02rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.4; text-wrap: pretty; } +.contrib-badges { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.contrib-badge { --badge: var(--scope-brand); padding: 0.15rem 0.55rem; border: 1px solid color-mix(in srgb, var(--badge) 40%, transparent); border-radius: 100px; background: color-mix(in srgb, var(--badge) 12%, transparent); color: var(--badge); font: 650 0.62rem/1.6 var(--sl-font-mono); letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; } +.contrib-badge[data-label='good-first-issue'] { --badge: var(--scope-pass); } +.contrib-number { margin-left: auto; color: var(--scope-muted); font: 0.75rem var(--sl-font-mono); } + +.contrib-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.contrib-chip { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.1rem 0.55rem; border: 1px solid var(--scope-border); border-radius: 100px; background: var(--scope-inset); color: var(--scope-muted); font-size: 0.72rem; } +.contrib-chip[data-kind='difficulty']::before { content: ''; width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--scope-muted); } +.contrib-chip[data-value='easy']::before { background: var(--scope-pass); } +.contrib-chip[data-value='moderate']::before { background: var(--scope-brand); } +.contrib-chip[data-value='hard']::before { background: var(--scope-fail); } + +.contrib-card__cta { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: auto; padding-top: 0.35rem; color: var(--scope-brand); font-size: 0.8rem; font-weight: 600; } +.contrib-card__arrow { display: inline-block; transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } +.contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: translate(3px, -3px); } + +/* Loading skeleton with a shimmer. */ +.contrib-skeleton { position: relative; flex-direction: column; justify-content: center; gap: 0.8rem; min-height: 10.5rem; padding: 1.4rem; border: 1px solid var(--scope-border); border-radius: 1rem; background: var(--scope-surface); overflow: hidden; } +.contrib--list .contrib-skeleton { min-height: 7.5rem; } +.contrib-skeleton__line { display: block; width: 100%; height: 0.8rem; border-radius: 0.4rem; background: var(--scope-inset); } +.contrib-skeleton__line--short { width: 35%; } +.contrib-skeleton__line--mid { width: 65%; } +.contrib-skeleton::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--scope-brand) 9%, transparent) 50%, transparent 80%); transform: translateX(-100%); animation: contrib-shimmer 1.6s ease-in-out infinite; } +@keyframes contrib-shimmer { to { transform: translateX(100%); } } + +/* Rotation controls (landing): one dot per set, the active one fills as a progress bar. */ +.contrib-rotation { display: flex; align-items: center; gap: 0.5rem; margin-top: 1.1rem; } +.contrib-dots { display: flex; align-items: center; } +.contrib-dot, .contrib-toggle { display: inline-grid; place-items: center; min-width: 1.5rem; height: 1.5rem; padding: 0 0.15rem; border: 0; border-radius: 100px; background: none; color: var(--scope-muted); cursor: pointer; } +.contrib-dot:focus-visible, .contrib-toggle:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 1px; } +.contrib-dot__track { position: relative; display: block; width: 0.5rem; height: 0.5rem; border-radius: 100px; background: var(--scope-border); overflow: hidden; transition: width 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), background-color 0.3s ease; } +.contrib-dot:hover .contrib-dot__track { background: var(--scope-muted); } +.contrib-dot[aria-current] .contrib-dot__track { width: 2.25rem; background: color-mix(in srgb, var(--scope-brand) 22%, var(--scope-border)); } +.contrib-dot__fill { position: absolute; inset: 0; border-radius: inherit; background: var(--scope-brand); transform: scaleX(0); transform-origin: left; } +.contrib-dot[aria-current] .contrib-dot__fill { transform: scaleX(1); } +.contrib-toggle { margin-left: 0.25rem; border: 1px solid var(--scope-border); width: 1.75rem; height: 1.75rem; background: var(--scope-surface); transition: border-color 0.3s ease, color 0.3s ease; } +.contrib-toggle:hover { border-color: var(--scope-brand); color: var(--scope-brand); } +.contrib-toggle[hidden] { display: none; } +/* Pause: two bars. Play: a triangle. */ +.contrib-toggle__icon { width: 0.55rem; height: 0.6rem; border-inline: 0.17rem solid currentColor; } +.contrib-toggle[data-paused='true'] .contrib-toggle__icon { width: 0; height: 0; margin-left: 0.15rem; border-block: 0.32rem solid transparent; border-inline: 0.5rem solid currentColor; border-right-width: 0; } + +/* Landing section. */ +.scope-contrib__intro { max-width: 36ch; color: var(--scope-muted); font-size: 0.9rem; } +.scope-contrib__all { margin-top: 1.5rem; } + +/* Docs page list: same cards, one per row, a little tighter. */ +.contrib--list { margin-top: 1.5rem; } +.contrib--list .contrib-card { padding: 1.1rem 1.25rem; } +.contrib--list .contrib-card__title { -webkit-line-clamp: unset; } +.contrib--list .contrib-card__cta { display: none; } + +@media (max-width: 48rem) { + .contrib--cards .contrib-list { grid-template-columns: 1fr; } + .scope-contrib__intro { margin-top: 1rem; max-width: 55ch; } +} + +@media (prefers-reduced-motion: reduce) { + .contrib *, .contrib *::before, .contrib *::after { animation: none !important; transition: none !important; } + .contrib-card:is(:hover, :focus-visible), .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } +} diff --git a/website/src/styles/contributors.css b/website/src/styles/contributors.css new file mode 100644 index 000000000..854cd64ec --- /dev/null +++ b/website/src/styles/contributors.css @@ -0,0 +1,112 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * Community contributors (). An avatar wall on the + * landing page and a card grid on the Community > Contribute page. Shares + * the card, message and sample-tag styles in contribution-issues.css. + */ + +.thanks { display: block; color: var(--scope-text); font-family: var(--sl-font); line-height: 1.6; } +.thanks * { box-sizing: border-box; } +.thanks :is(p, ul, h3) { margin: 0; } +.thanks a { text-decoration: none; } +.thanks .contrib-message a, .thanks .contrib-fallback a { color: var(--scope-brand); font-weight: 600; text-decoration: underline; text-underline-offset: 0.2em; } + +/* States. Without JavaScript only the GitHub fallback link is shown. */ +.thanks[data-state='empty'] .thanks-list, .thanks[data-state='error'] .thanks-list { display: none; } +.thanks[data-state='empty'] .thanks-message--empty, .thanks[data-state='error'] .thanks-message--error { display: block; } +@media (scripting: none) { + .thanks-list, .thanks .contrib-count { display: none !important; } + .thanks .contrib-fallback { display: block; } +} +.thanks[data-animate] :is([data-reveal], [data-reveal-item]):not([data-revealed]) { opacity: 0; } + +.thanks-list { display: flex; flex-wrap: wrap; padding: 0 !important; list-style: none; } +.thanks-item { position: relative; display: flex; margin: 0 !important; } + +/* Avatar photo with an initial as fallback. */ +.thanks-photo { position: relative; display: grid; flex: none; place-items: center; width: var(--size); height: var(--size); border-radius: 50%; background: linear-gradient(135deg, var(--scope-brand), color-mix(in srgb, var(--scope-brand) 45%, var(--scope-pass))); overflow: hidden; } +.thanks-photo img { display: block; width: 100%; height: 100%; object-fit: cover; } +.thanks-photo[data-broken]::after { content: attr(data-initial); color: var(--scope-on-brand); font-weight: 700; font-size: calc(var(--size) * 0.42); } + +/* Landing heading. */ +.thanks-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1rem; margin-bottom: 1.25rem; } +.thanks-head h3 { display: inline-flex; align-items: center; gap: 0.6rem; color: var(--scope-text); font-size: 1.35rem; font-weight: 600; letter-spacing: -0.02em; } +.thanks-head .contrib-count { margin-top: 0 !important; } +.thanks-heart { display: inline-block; color: #e5484d; transform-origin: center; animation: thanks-beat 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; } +@keyframes thanks-beat { + 0%, 60%, 100% { transform: scale(1); } + 10% { transform: scale(1.28); } + 22% { transform: scale(0.96); } + 34% { transform: scale(1.16); } +} +.thanks-intro { max-width: 60ch; margin-bottom: 1.25rem !important; color: var(--scope-muted); font-size: 0.9rem; } + +/* Wall: overlapping avatars with a ring in the page background colour. */ +.thanks--wall { --size: 3.5rem; margin-top: 4rem; padding-top: 2.5rem; border-top: 1px solid var(--scope-border); } +.thanks--wall .thanks-list { row-gap: 0.75rem; padding-left: 0.6rem !important; } +.thanks--wall .thanks-item { margin-left: -0.6rem !important; } +.thanks--wall .thanks-item:hover, .thanks--wall .thanks-item:focus-within { z-index: 2; } +.thanks-avatar, .thanks-more { + position: relative; + display: grid; + place-items: center; + width: var(--size); + height: var(--size); + border-radius: 50%; + box-shadow: 0 0 0 3px var(--scope-bg); + transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease; +} +.thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible) { transform: translateY(-6px) scale(1.14); box-shadow: 0 0 0 3px var(--scope-bg), 0 0 0 5px var(--scope-brand), var(--scope-shadow); } +.thanks-avatar:focus-visible, .thanks-more:focus-visible { outline: none; } +.thanks-more { background: var(--scope-surface); border: 1px dashed var(--scope-border); color: var(--scope-brand); font: 650 0.85rem var(--sl-font-mono); } + +/* Tooltip; also the link's accessible name. */ +.thanks-tip { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.4rem 0.7rem; + border-radius: 0.6rem; + background: var(--scope-text); + color: var(--scope-bg); + font-size: 0.72rem; + line-height: 1.35; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translate(-50%, 6px) scale(0.92); + transition: opacity 0.2s ease, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.thanks-tip::after { content: ''; position: absolute; top: 100%; left: 50%; border: 5px solid transparent; border-top-color: var(--scope-text); transform: translateX(-50%); } +.thanks-tip__login { font-weight: 650; } +.thanks-tip__prs { opacity: 0.75; } +.thanks-avatar:is(:hover, :focus-visible) .thanks-tip { opacity: 1; transform: translate(-50%, 0) scale(1); } + +/* Skeletons. */ +.thanks-skeleton { position: relative; overflow: hidden; background: var(--scope-inset); } +.thanks--wall .thanks-skeleton { width: var(--size); height: var(--size); border-radius: 50%; box-shadow: 0 0 0 3px var(--scope-bg); } +.thanks--grid .thanks-skeleton { height: 5.5rem; border: 1px solid var(--scope-border); border-radius: 1rem; } +.thanks-skeleton::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--scope-brand) 9%, transparent) 50%, transparent 80%); transform: translateX(-100%); animation: contrib-shimmer 1.6s ease-in-out infinite; } + +/* Grid (docs page): cards with the shared pointer glow. */ +.thanks--grid { --size: 3rem; margin-top: 1.5rem; } +.thanks--grid .thanks-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 1rem; } +.thanks--grid .thanks-item { display: block; } +.thanks-card.contrib-card { flex-direction: row; align-items: center; gap: 0.9rem; height: 100%; padding: 1rem 1.1rem; } +.thanks-card .thanks-photo { box-shadow: 0 0 0 2px var(--scope-surface), 0 0 0 3px var(--scope-border); transition: box-shadow 0.3s ease; } +.thanks-card:is(:hover, :focus-visible) .thanks-photo { box-shadow: 0 0 0 2px var(--scope-surface), 0 0 0 4px var(--scope-brand); } +.thanks-card__body { display: flex; flex-direction: column; min-width: 0; } +.thanks-card__login { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.thanks-card__prs { color: var(--scope-muted); font-size: 0.78rem; } +.thanks-card__arrow { margin-left: auto; color: var(--scope-brand); } + +@media (prefers-reduced-motion: reduce) { + .thanks *, .thanks *::before, .thanks *::after { animation: none !important; transition: none !important; } + .thanks-avatar:is(:hover, :focus-visible), .thanks-more:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible), .thanks .contrib-card:is(:hover, :focus-visible) .contrib-card__arrow { transform: none; } + .thanks-avatar:is(:hover, :focus-visible) .thanks-tip { transform: translate(-50%, 0); } +}