diff --git a/.github/ISSUE_TEMPLATE/add_event.yaml b/.github/ISSUE_TEMPLATE/add_event.yaml index 8a892db..51230d3 100644 --- a/.github/ISSUE_TEMPLATE/add_event.yaml +++ b/.github/ISSUE_TEMPLATE/add_event.yaml @@ -22,9 +22,9 @@ body: Feeds src/data/events.json and src/content/community/events/<slug>.md. - Events are grouped by year → month. Only `title`, `slug`, and `contributors` are stored in `events.json`; everything else (kind, dates, time, location, registration, description) goes into the markdown body. + Events are grouped by year → month. Event details (kind, dates, venue) are stored in `events.json` and displayed in the right-hand sidebar alongside contributors. The location field supplies the sidebar venue. The Markdown body holds time, registration, links, description, abstract, agenda, and media. Talks and posters belong here, not in the publication form. - See [`src/content/_templates/event.md`](https://github.com/simonprovost/medomicslab/blob/main/src/content/_templates/event.md) for the markdown skeleton. + See [`src/content/_templates/event.md`](https://github.com/MEDomicsLab/MEDomicsLab-website/blob/main/src/content/_templates/event.md) for the markdown skeleton. - type: checkboxes id: checks attributes: @@ -45,7 +45,7 @@ body: id: slug attributes: label: "Suggested slug" - description: Lowercase, hyphenated. Often prefixed with the date. + description: Lowercase, hyphenated. Prefix with a date only when it is known. placeholder: "2024-08-20-msc-mall" validations: required: true @@ -53,14 +53,16 @@ body: id: kind attributes: label: "Event kind" + description: Also decides the event's filter category on the Events page (talks and posters → Presentations, defenses → Thesis Defenses, and so on). options: - - "Thesis defense (Ph.D.)" - - "Thesis defense (M.Sc.)" + - "Thesis defense (PhD)" + - "Thesis defense (MSc)" - "Symposium" - "Workshop" - "Invited talk" - "Conference talk / poster" - "Lab meeting / seminar" + - "Lab life / social" - "Outreach" - "Other" validations: @@ -69,15 +71,23 @@ body: id: start-date attributes: label: "Start date" - description: ISO `YYYY-MM-DD`. Determines year/month grouping. + description: Optional. ISO `YYYY-MM-DD`, only when explicitly stated in the source text, title, or images. Leave unknown dates blank and supply a listing month below. placeholder: "2024-08-20" validations: - required: true + required: false + - type: input + id: listing-month + attributes: + label: "Listing month" + description: Required when the start date is unknown. Use `YYYY-MM` to choose the timeline group; this does not create a sidebar date. If a start date is supplied, its month is used. + placeholder: "2024-08" + validations: + required: false - type: input id: end-date attributes: label: "End date" - description: Optional. ISO `YYYY-MM-DD`. Required for multi-day events. + description: Optional. ISO `YYYY-MM-DD`, only when explicitly known. Requires a start date and must be on or after it. placeholder: "2024-08-22" validations: required: false @@ -92,15 +102,15 @@ body: id: location attributes: label: "Location" - description: Physical venue and/or virtual link. - placeholder: "Université de Sherbrooke, room D7-1010 — and on Zoom" + description: Optional. Only give a venue explicitly named in the source. Put meeting and registration links in the description below. + placeholder: "Université de Sherbrooke, room D7-1010 (hybrid)" validations: - required: true + required: false - type: input id: contributors attributes: label: "Contributors / speakers" - description: Comma-separated. Saved as a `contributors` array in `events.json`. Use `team.json` spelling for lab members so cross-links work. + description: Comma-separated. Saved as a `contributors` array in `events.json`. Use `team.json` spelling for lab members so cross-links work. These names appear in the Researchers sidebar; do not repeat them in an Authors section. placeholder: "Mahdi Loutfi" validations: required: true @@ -109,7 +119,7 @@ body: attributes: label: "Short blurb" description: Optional. The events listing shows the title only; if provided, this becomes the opening paragraph of the markdown file. - placeholder: "Mahdi Loutfi's M.Sc. thesis defense." + placeholder: "Mahdi Loutfi's MSc thesis defense." validations: required: false - type: textarea @@ -117,9 +127,9 @@ body: attributes: label: "Full description (Markdown)" description: | - Saved to `src/content/community/events/.md`. Supports inline HTML and ``. + Saved to `src/content/community/events/.md`. Supports inline HTML and standard Markdown images. Put each photo in its own paragraph for full-width images with rounded corners. Markdown image links such as `![description](https://example.com/image.jpg)` remain externally hosted: use stable, public URLs only. They are not copied or optimized by this form. - placeholder: "Abstract, agenda, speaker bios, registration link, etc." + placeholder: "Abstract, agenda, speaker bios, etc. Available date, type, venue, and contributors appear automatically in the sidebar." validations: required: true - type: input @@ -134,13 +144,14 @@ body: attributes: label: "Images / attachments for maintainer review" description: | - Attach files a maintainer should review. They are not automatically copied to the repository or published; a maintainer will add approved assets to the PR. For a gallery, include the intended album name. + Attach files a maintainer should review. They are not automatically copied to the repository or published; a maintainer will add approved assets to the PR. Include descriptive alt text and the intended photo order. validations: required: false - type: dropdown id: related attributes: label: "Companion content to publish?" + description: For maintainer review. Submit a separate form for each companion entry; these selections do not create it automatically. multiple: true options: - "News post announcing the event" diff --git a/.github/ISSUE_TEMPLATE/add_news.yaml b/.github/ISSUE_TEMPLATE/add_news.yaml index fcb6a22..f193e46 100644 --- a/.github/ISSUE_TEMPLATE/add_news.yaml +++ b/.github/ISSUE_TEMPLATE/add_news.yaml @@ -23,7 +23,7 @@ body: News items are grouped by year → month. The listing card shows the title only — all prose lives in the markdown file. - See [`src/content/_templates/news.md`](https://github.com/simonprovost/medomicslab/blob/main/src/content/_templates/news.md) for the markdown skeleton. + See [`src/content/_templates/news.md`](https://github.com/MEDomicsLab/MEDomicsLab-website/blob/main/src/content/_templates/news.md) for the markdown skeleton. - type: checkboxes id: checks attributes: @@ -45,23 +45,68 @@ body: id: slug attributes: label: "Suggested slug" - description: Lowercase, hyphenated, prefixed by ISO date. Becomes `/community/news/`. + description: Lowercase, hyphenated. An ISO date prefix is optional. Becomes `/community/news/`. placeholder: "2025-03-04-post-longitudinal-oym-article" validations: required: true + - type: dropdown + id: category + attributes: + label: "Category" + description: Drives the filter on the News page. Saved as `category` in `news.json`. + options: + - "Publications" + - "Thesis Defenses" + - "Awards" + - "Milestones" + - "Conferences" + - "Other" + validations: + required: true - type: input id: date attributes: label: "Publish date" - description: ISO `YYYY-MM-DD`. Determines year/month grouping. + description: ISO `YYYY-MM-DD`. The planned website publish date determines the timeline group only; it is not displayed as the date of the reported news or event. placeholder: "2025-03-04" validations: required: true + - type: input + id: display-date + attributes: + label: "Date to display" + description: Optional. Exact publication, milestone, or event date in `YYYY-MM-DD`, only when explicitly stated in the source text, title, or images. Leave blank when unknown; do not infer it from a filename or DOI. + placeholder: "2026-08-27" + validations: + required: false + - type: input + id: event-end-date + attributes: + label: "Event end date" + description: Optional, for a multi-day event. ISO `YYYY-MM-DD`. Requires a date to display and must be on or after it. + validations: + required: false + - type: input + id: event-kind + attributes: + label: "Event kind" + description: Optional, for news about an event. Displayed as Type in the sidebar. + placeholder: "Thesis defense (MSc), Community of practice, Workshop" + validations: + required: false + - type: input + id: event-venue + attributes: + label: "Event venue" + description: Optional. Only give a venue explicitly named in the source. Keep event and slide-deck links in the Markdown body. + placeholder: "Mila, Montréal" + validations: + required: false - type: input id: contributors attributes: label: "Contributors / people involved" - description: Comma-separated. Saved as a `contributors` array in `news.json`. Use `team.json` spelling for lab members so cross-links work. + description: Comma-separated. Saved as a `contributors` array in `news.json`. Use `team.json` spelling for lab members so cross-links work. These names appear in the Researchers sidebar; do not repeat them in an Authors section. placeholder: "Hakima Laribi, Nicolas Raymond, Martin Vallières" validations: required: true @@ -72,9 +117,9 @@ body: description: | Saved to `src/content/community/news/.md` and referenced from `news.json` via the `markdown` field. The listing card has no blurb anymore, so the opening paragraph here is what readers see first. Supports standard Markdown plus: - Inline HTML (rendered via `rehype-raw`) - - `` to embed a 3D gallery (requires entries in `src/data/albums.js`) + - Standard Markdown images, one per paragraph, displayed at full width with rounded corners. - External image Markdown such as `![description](https://example.com/image.jpg)`. These images are hotlinked, so use stable, public URLs only; they are not copied or optimized by this form. - placeholder: "Long-form announcement, paragraphs, links, images, etc." + placeholder: "Announcement, paragraphs, links, images, etc. Available dates, event details, and contributors appear in the sidebar." validations: required: true - type: textarea @@ -88,6 +133,7 @@ body: id: related attributes: label: "Related content to publish alongside?" + description: For maintainer review. Submit a separate form for each companion entry; these selections do not create it automatically. multiple: true options: - "New publication entry" diff --git a/.github/ISSUE_TEMPLATE/add_publication.yaml b/.github/ISSUE_TEMPLATE/add_publication.yaml index d28af5f..dac38f2 100644 --- a/.github/ISSUE_TEMPLATE/add_publication.yaml +++ b/.github/ISSUE_TEMPLATE/add_publication.yaml @@ -1,5 +1,5 @@ name: "Add a new publication" -description: Request that a publication (paper, preprint, talk, poster…) be added to the lab's publications list. +description: Request that a publication (journal paper, conference paper, or preprint) be added to the lab's publications list. title: "[PUB] - " labels: - "content" @@ -21,9 +21,9 @@ body: - Publications are grouped by year. The maintainer derives the slug from the title and adds it explicitly to `publications.json` — you do not need to provide one. + Publications are grouped by year. The automation derives the slug from the title and display year and saves it in `publications.json` — you do not need to provide one. - See [`src/content/_templates/publication.md`](https://github.com/simonprovost/medomicslab/blob/main/src/content/_templates/publication.md) for the markdown skeleton. + See [`src/content/_templates/publication.md`](https://github.com/MEDomicsLab/MEDomicsLab-website/blob/main/src/content/_templates/publication.md) for the markdown skeleton. - type: checkboxes id: checks attributes: @@ -62,12 +62,11 @@ body: id: type attributes: label: "Type" - description: Must be one of the four values supported by `publications.schema.json`. + description: Must be one of the three values supported by `publications.schema.json`. Talks and posters are events now; file them with the "Add an event" form instead. options: - "Journal Papers" - "Conference Papers" - "Preprints" - - "Presentations" validations: required: true - type: input @@ -97,7 +96,7 @@ body: id: redirect attributes: label: "Direct redirect?" - description: If checked by the maintainer, the publication card links straight to the primary link instead of opening a detail page on the site. Leave blank for the default behaviour. + description: Enter yes to link the publication card directly to the primary link. Leave blank or enter no to open its detail page. placeholder: "yes / no — optional" validations: required: false @@ -105,7 +104,7 @@ body: id: abstract attributes: label: "Abstract / summary (Markdown)" - description: Will be saved to `src/content/publications/.md` and referenced from `publications.json` via the `markdown` field. Follow the structure in [`src/content/_templates/publication.md`](https://github.com/simonprovost/medomicslab/blob/main/src/content/_templates/publication.md). Markdown image links remain externally hosted, so use stable, public URLs only; they are not copied or optimized by this form. + description: Will be saved to `src/content/publications/.md` and referenced from `publications.json` via the `markdown` field. Follow the structure in [`src/content/_templates/publication.md`](https://github.com/MEDomicsLab/MEDomicsLab-website/blob/main/src/content/_templates/publication.md). Markdown image links remain externally hosted, so use stable, public URLs only; they are not copied or optimized by this form. placeholder: "Paste the abstract or a short summary." validations: required: false @@ -113,7 +112,7 @@ body: id: bibtex attributes: label: "BibTeX (optional)" - description: For maintainers who want to enrich the markdown page later. + description: Optional. Saved as a BibTeX section in the publication detail page. render: bibtex validations: required: false diff --git a/.github/ISSUE_TEMPLATE/add_team_member.yaml b/.github/ISSUE_TEMPLATE/add_team_member.yaml index a8b3417..027042a 100644 --- a/.github/ISSUE_TEMPLATE/add_team_member.yaml +++ b/.github/ISSUE_TEMPLATE/add_team_member.yaml @@ -21,9 +21,9 @@ body: - Please fill **every required** field. Optional fields can be left blank — we'll skip them in the JSON. The maintainer will create the entry, generate avatar variants, and open the PR. + Please fill **every required** field. Optional fields can be left blank — we'll skip them in the JSON. The automation creates the entry, generates avatar variants, and opens a draft PR for maintainer review. - Only `name`, `position`, `slug`, and `image` are required by the schema; everything else is optional. + This form also requires a cohort, biography, expertise, and portrait so the new profile is ready to review. Leave optional fields blank. - type: checkboxes id: checks attributes: @@ -65,7 +65,7 @@ body: attributes: label: "Position" description: Short title shown under the name on listing and detail pages. Stored as the `position` field in `team.json`. - placeholder: 'e.g. "Ph. D. Computer science" or "Associate Professor, Department of Oncology"' + placeholder: 'e.g. "PhD Computer science" or "Associate Professor, Department of Oncology"' validations: required: true - type: input @@ -101,8 +101,8 @@ body: description: | One per line, in the format `course | institution | year`. Most recent first. placeholder: | - Ph.D. Computer Science | Université de Sherbrooke | 2024 - M.Sc. Medical Physics | McGill University | 2020 + PhD Computer Science | Université de Sherbrooke | 2024 + MSc Medical Physics | McGill University | 2020 validations: required: false - type: input @@ -113,6 +113,15 @@ body: placeholder: "Optional" validations: required: false + - type: textarea + id: affiliations + attributes: + label: "Affiliations" + description: Optional. One per line in the format `role | organization | official URL` (URL is optional). These appear on the member's profile. + placeholder: | + Associate Member | Dept. of Biomedical Engineering, McGill University | https://www.mcgill.ca/bme/ + validations: + required: false - type: markdown attributes: value: "### Social / academic links (all optional)" @@ -156,7 +165,7 @@ body: id: photo attributes: label: "Portrait photo" - description: Attach a square JPG, PNG, WebP, or AVIF image under 10 MB and at least 512×512px. Direct URLs must be stable and public. Responsive variants (80/128/160/256 × avif/webp/jpg) will be generated automatically. + description: Attach a square JPG, PNG, WebP, or AVIF image under 10 MB and at least 512×512px. Direct URLs must be stable and public. Responsive variants (80/128/160/256 × AVIF/WebP with JPG or PNG fallback) will be generated automatically. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 967c544..1c35c5c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -38,7 +38,7 @@ body: attributes: label: "Affected page / URL" description: Which page is broken? Paste the full URL or the route (e.g. `/publications/leveraging-patients-...-2025`). - placeholder: "https://medomics.app/team/martin-vallieres" + placeholder: "https://medomicslab.com/team/martin-vallieres" validations: required: true - type: textarea @@ -90,7 +90,7 @@ body: attributes: label: "Where did this happen?" options: - - "Production (medomics.app)" + - "Production (medomicslab.com)" - "Local dev (`npm run dev`)" - "Local preview (`npm run preview`)" - "PR preview deployment" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d097056..7b44069 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: 💬 Lab website - url: https://medomics.app/ + url: https://medomicslab.com/ about: Browse the live MEDomicsLab site before opening an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index c6b6f5f..05ca444 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -43,7 +43,7 @@ body: attributes: label: "Proposed solution" description: Describe the feature. Include user flow, UI ideas, accessibility considerations, and which files/components you think it touches. - placeholder: "Add a sticky year filter at the top of `/publications` using `MotionHighlight`. Affects `PublicationsPage.jsx`." + placeholder: "Add a sticky year filter at the top of `/publications` that stays accessible on mobile." validations: required: true - type: textarea @@ -79,7 +79,6 @@ body: - "Community — News" - "Community — Events" - "Community — Courses" - - "Visions" - "Layout / navigation / footer" - "Theme / design tokens" - "Performance / accessibility / SEO" diff --git a/.github/scripts/__tests__/fixtures/event-valid.md b/.github/scripts/__tests__/fixtures/event-valid.md index cf558fe..e6e8284 100644 --- a/.github/scripts/__tests__/fixtures/event-valid.md +++ b/.github/scripts/__tests__/fixtures/event-valid.md @@ -8,7 +8,7 @@ Test thesis defense ### Event kind -Thesis defense (Ph.D.) +Thesis defense (PhD) ### Start date @@ -32,7 +32,7 @@ Test Person ### Short blurb -Test Person's Ph.D. defense. +Test Person's PhD defense. ### Full description (Markdown) @@ -42,7 +42,7 @@ Body of the event description. _No response_ -### Poster / images / gallery +### Images / attachments for maintainer review _No response_ diff --git a/.github/scripts/__tests__/fixtures/news-valid.md b/.github/scripts/__tests__/fixtures/news-valid.md index 5f24869..f9b2a13 100644 --- a/.github/scripts/__tests__/fixtures/news-valid.md +++ b/.github/scripts/__tests__/fixtures/news-valid.md @@ -6,6 +6,10 @@ Pipeline drafted news post 2025-03-04-test-news-post +### Category + +Publications + ### Publish date 2025-03-04 @@ -18,7 +22,7 @@ Hakima Laribi, Nicolas Raymond A test news post body that becomes the markdown file. -### Images / attachments +### Images / attachments for maintainer review _No response_ diff --git a/.github/scripts/__tests__/fixtures/team-valid.md b/.github/scripts/__tests__/fixtures/team-valid.md index 1a4daeb..b9f871b 100644 --- a/.github/scripts/__tests__/fixtures/team-valid.md +++ b/.github/scripts/__tests__/fixtures/team-valid.md @@ -78,3 +78,7 @@ _No response_ ### Anything else? _No response_ + +### Affiliations + +Associate Member | Dept. of Biomedical Engineering, McGill University | https://www.mcgill.ca/bme/ diff --git a/.github/scripts/__tests__/issue-to-content.test.mjs b/.github/scripts/__tests__/issue-to-content.test.mjs index 78dc1c8..3a1bd94 100644 --- a/.github/scripts/__tests__/issue-to-content.test.mjs +++ b/.github/scripts/__tests__/issue-to-content.test.mjs @@ -12,7 +12,8 @@ import { publication } from "../lib/handlers/publication.mjs"; import { news } from "../lib/handlers/news.mjs"; import { event } from "../lib/handlers/event.mjs"; import { team } from "../lib/handlers/team.mjs"; -import { writeJson } from "../lib/data-store.mjs"; +import { validateAgainst } from "../lib/validate.mjs"; +import { writeJson, insertCommunityItem, communityOrderProblems } from "../lib/data-store.mjs"; const __filename = fileURLToPath(import.meta.url); const here = path.dirname(__filename); @@ -89,6 +90,7 @@ test("handlers reject impossible dates and empty normalized slugs", () => { const invalidNewsDate = news.buildPlan({ Headline: "Example news", "Suggested slug": "example-news", + Category: "Other", "Publish date": "2026-02-31", "Contributors / people involved": "Example Person", "Full content (Markdown)": "Example body.", @@ -196,16 +198,53 @@ test("news handler infers year/month from publish date", () => { assert.equal(plan.year, "2025"); assert.equal(plan.month, "March"); assert.equal(plan.entry.markdown, `community/news/${plan.slug}.md`); + assert.equal(plan.entry.category, "Publications"); }); -test("event handler folds metadata into the markdown body", () => { +test("event handler refuses a kind it has no filter category for", () => { + const plan = event.buildPlan({ + "Event title": "Example event", + "Suggested slug": "example-event", + "Event kind": "Something new", + "Start date": "2026-09-02", + Location: "Example room", + "Contributors / speakers": "Example Person", + "Full description (Markdown)": "Example body.", + }); + assert.equal(plan.ok, false); + assert.match(plan.errors.join("\n"), /Unknown event kind/); +}); + +test("event handler stores sidebar metadata separately from the markdown body", () => { const fields = parseIssueForm(fixture("event-valid.md")); const plan = event.buildPlan(fields); assert.equal(plan.ok, true, JSON.stringify(plan, null, 2)); assert.equal(plan.year, "2026"); assert.equal(plan.month, "May"); - assert.match(plan.markdownBody, /\*\*Kind:\*\* Thesis defense \(Ph\.D\.\)/); - assert.match(plan.markdownBody, /\*\*Location:\*\*/); + assert.equal(plan.entry.kind, "Thesis defense (PhD)"); + assert.equal(plan.entry.category, "Thesis Defenses"); + assert.equal(plan.entry.date, "2026-05-01"); + assert.equal(plan.entry.venue, "Université de Sherbrooke, room D7-1010"); + assert.equal(plan.entry.time, undefined); + assert.equal(plan.entry.endDate, undefined); + assert.equal(plan.entry.registration, undefined); + assert.equal( + plan.markdownBody, + "Test Person's PhD defense.\n\n**Time:** 14:00–16:00 EDT\n\nBody of the event description." + ); +}); + +test("event handler preserves multi-day logistics in sidebar data", () => { + const plan = event.buildPlan({ + ...parseIssueForm(fixture("event-valid.md")), + "End date": "2026-05-03", + "Registration / RSVP link": "https://example.com/register", + }); + assert.equal(plan.ok, true); + assert.equal(plan.entry.endDate, "2026-05-03"); + assert.equal(plan.entry.registration, undefined); + assert.match(plan.markdownBody, /https:\/\/example.com\/register/); + assert.doesNotMatch(plan.markdownBody, /2026-05-03/); }); test("event apply() generates a conventional PR title", async () => { @@ -279,7 +318,7 @@ test("writeJson preserves unchanged hand-formatted entries", async () => { assert.match(updated, /"contributors": \["Existing Author", "Another Author"\]/); }); -test("team handler builds a plan with parsed expertise + education", () => { +test("team handler builds a plan with parsed expertise, education, and affiliations", () => { const fields = parseIssueForm(fixture("team-valid.md")); const plan = team.buildPlan(fields); assert.equal(plan.ok, true, JSON.stringify(plan, null, 2)); @@ -291,8 +330,22 @@ test("team handler builds a plan with parsed expertise + education", () => { "Federated learning", ]); assert.equal(plan.member.education.length, 2); + assert.deepEqual(plan.member.affiliations, [ + { + role: "Associate Member", + organization: "Dept. of Biomedical Engineering, McGill University", + url: "https://www.mcgill.ca/bme/", + }, + ]); assert.equal(plan.member.socials.linkedin, "https://www.linkedin.com/in/test-member/"); assert.equal(plan.photoSource, "team-photo.png"); + + const invalidAffiliation = team.buildPlan({ + ...fields, + Affiliations: "Associate Member | Biomedical Engineering | javascript:alert(1)", + }); + assert.equal(invalidAffiliation.ok, false); + assert.match(invalidAffiliation.errors.join("\n"), /absolute HTTP\(S\) URL/); }); test("team apply() writes resized avatar variants and updates team.json", async () => { @@ -318,6 +371,7 @@ test("team apply() writes resized avatar variants and updates team.json", async const member = cohort.members.find((m) => m.slug === plan.slug); assert.ok(member, "expected new member in team.json"); assert.equal(member.image, `/images/team/${plan.slug}/avatar.png`); + assert.deepEqual(member.affiliations, plan.member.affiliations); const teamSource = fs.readFileSync(path.join(dataDir, "team.json"), "utf8"); assert.equal( await prettier.check(teamSource, { parser: "json", printWidth: 100 }), @@ -333,3 +387,97 @@ test("team apply() writes resized avatar variants and updates team.json", async } } }); + +test("insertCommunityItem keeps years and months newest first regardless of insertion order", () => { + const item = (slug) => ({ title: slug, slug, contributors: [] }); + let tree = [{ year: "2025", months: [{ month: "March", items: [item("a")] }] }]; + tree = insertCommunityItem(tree, { year: "2025", month: "February", item: item("b") }); + tree = insertCommunityItem(tree, { year: "2025", month: "November", item: item("c") }); + tree = insertCommunityItem(tree, { year: "2026", month: "January", item: item("d") }); + assert.deepEqual( + tree.map((y) => [y.year, y.months.map((m) => m.month)]), + [ + ["2026", ["January"]], + ["2025", ["November", "March", "February"]], + ] + ); + assert.deepEqual(communityOrderProblems(tree), []); +}); + +test("communityOrderProblems flags reversed months and non-month labels", () => { + const tree = [ + { + year: "2024", + months: [ + { month: "February", items: [] }, + { month: "March", items: [] }, + { month: "Symposium", items: [] }, + ], + }, + ]; + assert.deepEqual(communityOrderProblems(tree), [ + "2024: March must come before February", + '2024: "Symposium" is not a month name', + ]); + assert.throws(() => insertCommunityItem([], { year: "2024", month: "Symposium", item: {} })); +}); + +test("event submissions can omit an unknown day and venue without inventing metadata", () => { + const fields = parseIssueForm(fixture("event-valid.md")); + delete fields["Start date"]; + delete fields.Location; + fields["Listing month"] = "2026-05"; + const plan = event.buildPlan(fields); + assert.equal(plan.ok, true); + assert.equal(plan.year, "2026"); + assert.equal(plan.month, "May"); + assert.equal(plan.entry.date, undefined); + assert.equal(plan.entry.venue, undefined); + assert.equal(event.buildPlan({ ...fields, "Listing month": "2026-13" }).ok, false); + assert.equal(event.buildPlan({ ...fields, "Listing month": "" }).ok, false); + assert.equal(event.buildPlan({ ...fields, "End date": "2026-05-03" }).ok, false); + const dated = event.buildPlan({ ...fields, "Start date": "2026-06-01" }); + assert.equal(dated.month, "June"); + assert.equal(dated.entry.date, "2026-06-01"); +}); + +test("news sidebar dates are independent of the timeline publish date", () => { + const fields = parseIssueForm(fixture("news-valid.md")); + const undated = news.buildPlan(fields); + assert.equal(undated.entry.date, undefined); + assert.equal(undated.entry.event, undefined); + const dated = news.buildPlan({ ...fields, "Date to display": "2025-02-27" }); + assert.equal(dated.ok, true); + assert.equal(dated.month, "March"); + assert.equal(dated.entry.date, "2025-02-27"); + assert.equal(dated.entry.event, undefined); +}); + +test("news accepts partial event metadata and rejects invalid date ranges", () => { + const fields = parseIssueForm(fixture("news-valid.md")); + for (const details of [ + { "Event kind": "Workshop" }, + { "Event venue": "Mila, Montréal" }, + { "Event kind": "Workshop", "Date to display": "2025-02-27" }, + { "Date to display": "2025-02-27", "Event end date": "2025-02-28" }, + ]) { + const plan = news.buildPlan({ ...fields, ...details }); + assert.equal(plan.ok, true); + assert.equal(plan.entry.event.date, details["Date to display"]); + assert.equal(plan.entry.event.venue, details["Event venue"]); + assert.equal(plan.entry.event.kind, details["Event kind"]); + assert.equal(plan.entry.event.endDate, details["Event end date"]); + const result = validateAgainst("news.schema.json", [ + { year: plan.year, months: [{ month: plan.month, items: [plan.entry] }] }, + ]); + assert.equal(result.ok, true, JSON.stringify(result)); + } + for (const details of [ + { "Date to display": "2025-02-30" }, + { "Event end date": "2025-02-28" }, + { "Date to display": "2025-02-27", "Event end date": "2025-02-26" }, + { "Date to display": "2025-02-27", "Event end date": "2025-02-30" }, + ]) { + assert.equal(news.buildPlan({ ...fields, ...details }).ok, false); + } +}); diff --git a/.github/scripts/check-licenses.mjs b/.github/scripts/check-licenses.mjs index 1caf958..a7bf89b 100644 --- a/.github/scripts/check-licenses.mjs +++ b/.github/scripts/check-licenses.mjs @@ -1,10 +1,6 @@ #!/usr/bin/env node /** - * License compliance: fails if any production dependency carries a license - * that is not in the allowed list (typical for open-source web apps). - * - * "Senior practice" guard against accidentally pulling in GPL/AGPL/SSPL/etc. - * which would put obligations on the site we don't want. + * Fail if a production dependency has a license outside the allow-list. */ import { init } from "license-checker-rseidelsohn"; import path from "node:path"; diff --git a/.github/scripts/check-schemas.mjs b/.github/scripts/check-schemas.mjs index d066871..9b949bb 100644 --- a/.github/scripts/check-schemas.mjs +++ b/.github/scripts/check-schemas.mjs @@ -1,13 +1,15 @@ #!/usr/bin/env node /** * Validate every JSON file under src/data against its sibling schema in - * src/data/_schemas/.schema.json. Run via `npm run lint:schemas`. + * src/data/_schemas/.schema.json, and check article references. + * Run via `npm run lint:schemas`. */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import Ajv from "ajv"; import addFormats from "ajv-formats"; +import { communityOrderProblems } from "./lib/data-store.mjs"; const __filename = fileURLToPath(import.meta.url); const root = path.resolve(path.dirname(__filename), "..", ".."); @@ -25,13 +27,25 @@ const pairs = [ ["home.json", "home.schema.json"], ["layout.json", "layout.schema.json"], ["theme.json", "theme.schema.json"], - ["visions.json", "visions.schema.json"], + ["seo.json", "seo.schema.json"], ]; const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); let failed = false; +const markdownReferences = new Set(); +function collectMarkdownReferences(value) { + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if (key === "markdown" && typeof child === "string") { + markdownReferences.add(child.replace(/^\/src\/content\/|^\.\/?/, "")); + } else { + collectMarkdownReferences(child); + } + } +} + for (const [dataFile, schemaFile] of pairs) { const dataPath = path.join(dataDir, dataFile); const schemaPath = path.join(schemaDir, schemaFile); @@ -41,6 +55,7 @@ for (const [dataFile, schemaFile] of pairs) { continue; } const data = JSON.parse(fs.readFileSync(dataPath, "utf8")); + collectMarkdownReferences(data); const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); const validate = ajv.compile(schema); if (!validate(data)) { @@ -49,10 +64,41 @@ for (const [dataFile, schemaFile] of pairs) { for (const err of validate.errors ?? []) { console.error(` ${err.instancePath || "(root)"} ${err.message}`); } + } else if (dataFile === "news.json" || dataFile === "events.json") { + const problems = communityOrderProblems(data); + if (problems.length) { + failed = true; + console.error(`✗ ${dataFile} is out of chronological order:`); + for (const problem of problems) console.error(` ${problem}`); + } else { + console.log(`✓ ${dataFile}`); + } } else { console.log(`✓ ${dataFile}`); } } +const contentDir = path.join(root, "src/content"); +const articles = new Set( + fs + .readdirSync(contentDir, { recursive: true }) + .map((file) => file.split(path.sep).join("/")) + .filter((file) => file.endsWith(".md") && !file.startsWith("_templates/")) +); +for (const reference of markdownReferences) { + if (!articles.has(reference)) { + console.error(`Missing article referenced in JSON: ${reference}`); + failed = true; + } +} +for (const article of articles) { + if (!markdownReferences.has(article)) { + console.error(`Unused article not referenced in JSON: ${article}`); + failed = true; + } +} + if (failed) process.exit(1); -console.log(`All ${pairs.length} data files match their schemas.`); +console.log( + `All ${pairs.length} data files match their schemas; ${articles.size} articles are referenced.` +); diff --git a/.github/scripts/check-unused-assets.mjs b/.github/scripts/check-unused-assets.mjs index caaed84..5c93c80 100644 --- a/.github/scripts/check-unused-assets.mjs +++ b/.github/scripts/check-unused-assets.mjs @@ -3,7 +3,7 @@ * Flags files under public/images/** that are not referenced anywhere in * src/data, src/content, src/**.{js,jsx,css}, or index.html. * - * Smart matching: + * Reference matching: * - For files matching -., also looks for the canonical * . reference (since AvatarImage / SkeletonImage derive variants * at runtime from the canonical filename). diff --git a/.github/scripts/issue-to-content.mjs b/.github/scripts/issue-to-content.mjs index c13eaa7..c59986d 100644 --- a/.github/scripts/issue-to-content.mjs +++ b/.github/scripts/issue-to-content.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Issue → PR pipeline (Option 1: rigid, deterministic, no AI). + * Convert an Issue Form submission into a validated content change. * * Reads a GitHub Issue Form submission, parses the body into key/value * fields, hands the parsed payload to a per-template handler that @@ -21,7 +21,7 @@ * Exit codes: * 0 – success (plan + files written, output emitted) * 2 – validation failure (errors emitted to GITHUB_OUTPUT for comment) - * 1 – unexpected error (workflow should fail noisily) + * 1 – unexpected error */ import fs from "node:fs"; import path from "node:path"; diff --git a/.github/scripts/lib/data-store.mjs b/.github/scripts/lib/data-store.mjs index 12124f1..28e7e59 100644 --- a/.github/scripts/lib/data-store.mjs +++ b/.github/scripts/lib/data-store.mjs @@ -208,23 +208,53 @@ export function insertPublication(arr, year, item) { return next; } +/** + * Return a description of every place a year/months tree (news or events) + * breaks the site's ordering contract (years newest first, months real + * English month names newest first), or an empty array when it is sound. + */ +export function communityOrderProblems(arr) { + const problems = []; + arr.forEach((yearBucket, index) => { + const previous = arr[index - 1]; + if (previous && previous.year.localeCompare(yearBucket.year) <= 0) { + problems.push(`year ${yearBucket.year} must come before ${previous.year}`); + } + yearBucket.months.forEach((monthBucket, monthIndex) => { + const position = MONTH_NAMES.indexOf(monthBucket.month); + if (position === -1) { + problems.push(`${yearBucket.year}: "${monthBucket.month}" is not a month name`); + return; + } + const before = yearBucket.months[monthIndex - 1]; + if (before && MONTH_NAMES.indexOf(before.month) <= position) { + problems.push(`${yearBucket.year}: ${monthBucket.month} must come before ${before.month}`); + } + }); + }); + return problems; +} + /** * Insert a community item (news or event) into a year/months tree. - * `month` is the human-readable month name; entries are inserted in the - * most-recent-first order callers already use. + * `month` is the human-readable month name. Years and months are kept newest + * first by calendar order, not by insertion order, so content added out of + * sequence still lands in the right place. */ export function insertCommunityItem(arr, { year, month, item }) { + if (!MONTH_NAMES.includes(month)) throw new Error(`Not a month name: ${month}`); const next = JSON.parse(JSON.stringify(arr)); let yearBucket = next.find((y) => y.year === year); if (!yearBucket) { yearBucket = { year, months: [] }; - next.unshift(yearBucket); + next.push(yearBucket); next.sort((a, b) => b.year.localeCompare(a.year)); } let monthBucket = yearBucket.months.find((m) => m.month === month); if (!monthBucket) { monthBucket = { month, items: [] }; - yearBucket.months.unshift(monthBucket); + yearBucket.months.push(monthBucket); + yearBucket.months.sort((a, b) => MONTH_NAMES.indexOf(b.month) - MONTH_NAMES.indexOf(a.month)); } if (monthBucket.items.some((i) => i.slug === item.slug)) { throw new Error(`Slug already exists: ${item.slug}`); diff --git a/.github/scripts/lib/handlers/event.mjs b/.github/scripts/lib/handlers/event.mjs index efeb1f2..f47508b 100644 --- a/.github/scripts/lib/handlers/event.mjs +++ b/.github/scripts/lib/handlers/event.mjs @@ -17,6 +17,7 @@ const FIELD = { kind: "Event kind", startDate: "Start date", endDate: "End date", + listingMonth: "Listing month", time: "Time (with timezone)", location: "Location", contributors: "Contributors / speakers", @@ -26,19 +27,32 @@ const FIELD = { additional: "Additional context", }; +const CATEGORY_BY_KIND = { + "Thesis defense (PhD)": "Thesis Defenses", + "Thesis defense (MSc)": "Thesis Defenses", + Symposium: "Symposiums", + Workshop: "Workshops", + "Invited talk": "Presentations", + "Conference talk / poster": "Presentations", + "Lab meeting / seminar": "Seminars", + "Lab life / social": "Lab Life", + Outreach: "Outreach", + Other: "Other", +}; + export const event = { label: "event", emoji: "📅", buildPlan(fields) { const errors = []; - const required = ["title", "slug", "kind", "startDate", "location", "contributors", "body"]; + const required = ["title", "slug", "kind", "contributors", "body"]; for (const key of required) { if (!fields[FIELD[key]]) errors.push(`Missing required field: \`${FIELD[key]}\``); } if (errors.length) return { ok: false, errors }; - const startDate = fields[FIELD.startDate].trim(); - if (!isValidIsoDate(startDate)) { + const startDate = (fields[FIELD.startDate] || "").trim(); + if (startDate && !isValidIsoDate(startDate)) { return { ok: false, errors: [ @@ -55,19 +69,40 @@ export const event = { ], }; } + if (endDate && !startDate) { + return { ok: false, errors: ["End date requires a start date."] }; + } if (endDate && endDate < startDate) { return { ok: false, errors: [`End date cannot be before the start date.`] }; } - const year = startDate.slice(0, 4); - const month = monthName(startDate); + const listingMonth = (fields[FIELD.listingMonth] || "").trim(); + if (!startDate && !/^\d{4}-(0[1-9]|1[0-2])$/.test(listingMonth)) { + return { ok: false, errors: ["Provide a start date or a listing month in YYYY-MM format."] }; + } + const groupingDate = startDate || `${listingMonth}-01`; + const year = groupingDate.slice(0, 4); + const month = monthName(groupingDate); const slug = slugify(fields[FIELD.slug]); if (!slug) return { ok: false, errors: [`Suggested slug must contain at least one letter or number.`] }; const markdownRel = `community/events/${slug}.md`; + const kind = fields[FIELD.kind].trim(); + const category = CATEGORY_BY_KIND[kind]; + if (!category) { + return { + ok: false, + errors: [`Unknown event kind \`${kind}\`; pick one of the form's options.`], + }; + } const entry = { title: fields[FIELD.title].trim(), slug, + category, + ...(startDate ? { date: startDate } : {}), + kind, + ...(fields[FIELD.location]?.trim() ? { venue: fields[FIELD.location].trim() } : {}), + ...(endDate ? { endDate } : {}), contributors: splitList(fields[FIELD.contributors]), markdown: markdownRel, }; @@ -75,14 +110,11 @@ export const event = { const blurb = (fields[FIELD.blurb] || "").trim(); const lines = []; if (blurb) lines.push(blurb, ""); - lines.push(`**Kind:** ${fields[FIELD.kind].trim()}`); - lines.push(`**Date:** ${startDate}${endDate ? ` – ${endDate}` : ""}`); - if (fields[FIELD.time]) lines.push(`**Time:** ${fields[FIELD.time].trim()}`); - lines.push(`**Location:** ${fields[FIELD.location].trim()}`); + if (fields[FIELD.time]) lines.push(`**Time:** ${fields[FIELD.time].trim()}`, ""); if (fields[FIELD.registration]) { - lines.push(`**Registration:** ${fields[FIELD.registration].trim()}`); + lines.push(`**Registration:** ${fields[FIELD.registration].trim()}`, ""); } - lines.push("", fields[FIELD.body].trim()); + lines.push(fields[FIELD.body].trim()); if (fields[FIELD.additional]) { lines.push("", ""); } @@ -119,6 +151,7 @@ export const event = { summary: [ `**Year/month:** ${plan.year} / ${plan.month}`, `**Slug:** \`${plan.slug}\``, + `**Category:** ${plan.entry.category}`, `**Contributors:** ${plan.entry.contributors.join(", ") || "(none)"}`, ].join("\n"), }; diff --git a/.github/scripts/lib/handlers/news.mjs b/.github/scripts/lib/handlers/news.mjs index b130e4b..2541bd0 100644 --- a/.github/scripts/lib/handlers/news.mjs +++ b/.github/scripts/lib/handlers/news.mjs @@ -14,7 +14,12 @@ import { validateAgainst } from "../validate.mjs"; const FIELD = { title: "Headline", slug: "Suggested slug", + category: "Category", date: "Publish date", + displayDate: "Date to display", + endDate: "Event end date", + kind: "Event kind", + venue: "Event venue", contributors: "Contributors / people involved", body: "Full content (Markdown)", additional: "Additional context", @@ -25,7 +30,7 @@ export const news = { emoji: "📰", buildPlan(fields) { const errors = []; - const required = ["title", "slug", "date", "contributors", "body"]; + const required = ["title", "slug", "category", "date", "contributors", "body"]; for (const key of required) { if (!fields[FIELD[key]]) errors.push(`Missing required field: \`${FIELD[key]}\``); } @@ -40,6 +45,32 @@ export const news = { ], }; } + const displayDate = (fields[FIELD.displayDate] || "").trim(); + const endDate = (fields[FIELD.endDate] || "").trim(); + const kind = (fields[FIELD.kind] || "").trim(); + const venue = (fields[FIELD.venue] || "").trim(); + for (const [label, value] of [ + [FIELD.displayDate, displayDate], + [FIELD.endDate, endDate], + ]) { + if (value && !isValidIsoDate(value)) { + errors.push(`${label} must be a real calendar date in YYYY-MM-DD format.`); + } + } + if (endDate && !displayDate) errors.push("Event end date requires a date to display."); + if (endDate && displayDate && endDate < displayDate) { + errors.push("Event end date cannot be before the date to display."); + } + if (errors.length) return { ok: false, errors }; + const eventDetails = + kind || venue || endDate + ? { + ...(displayDate ? { date: displayDate } : {}), + ...(endDate ? { endDate } : {}), + ...(kind ? { kind } : {}), + ...(venue ? { venue } : {}), + } + : null; const year = date.slice(0, 4); const month = monthName(date); const slug = slugify(fields[FIELD.slug]); @@ -50,6 +81,8 @@ export const news = { const entry = { title: fields[FIELD.title].trim(), slug, + category: fields[FIELD.category].trim(), + ...(eventDetails ? { event: eventDetails } : displayDate ? { date: displayDate } : {}), contributors: splitList(fields[FIELD.contributors]), markdown: markdownRel, }; @@ -84,6 +117,7 @@ export const news = { summary: [ `**Year/month:** ${plan.year} / ${plan.month}`, `**Slug:** \`${plan.slug}\``, + `**Category:** ${plan.entry.category}`, `**Contributors:** ${plan.entry.contributors.join(", ") || "(none)"}`, ].join("\n"), }; diff --git a/.github/scripts/lib/handlers/team.mjs b/.github/scripts/lib/handlers/team.mjs index af96bb3..9cc43cf 100644 --- a/.github/scripts/lib/handlers/team.mjs +++ b/.github/scripts/lib/handlers/team.mjs @@ -15,6 +15,7 @@ const FIELD = { bio: "Short biography", expertise: "Areas of expertise", education: "Education", + affiliations: "Affiliations", note: "Special note", linkedin: "LinkedIn", orcid: "ORCID", @@ -72,6 +73,32 @@ function parseEducation(value) { return { entries, errors }; } +function parseAffiliations(value) { + const entries = []; + const errors = []; + for (const row of parseLines(value)) { + const parts = row.split("|").map((part) => part.trim()); + if (parts.length < 2 || parts.length > 3 || !parts[0] || !parts[1]) { + errors.push( + `Affiliation row must be \`role | organization | official URL (optional)\` (got \`${row}\`).` + ); + continue; + } + const [role, organization, url] = parts; + if (url) { + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("invalid protocol"); + } catch { + errors.push(`Affiliation URL must be an absolute HTTP(S) URL (got ${url}).`); + continue; + } + } + entries.push({ role, organization, ...(url ? { url } : {}) }); + } + return { entries, errors }; +} + function buildSocials(fields) { const keys = ["linkedin", "orcid", "scholar", "researchgate", "github", "stackoverflow", "cv"]; const socials = {}; @@ -190,6 +217,10 @@ export const team = { } const { entries: education, errors: eduErrors } = parseEducation(fields[FIELD.education]); if (eduErrors.length) return { ok: false, errors: eduErrors }; + const { entries: affiliations, errors: affiliationErrors } = parseAffiliations( + fields[FIELD.affiliations] + ); + if (affiliationErrors.length) return { ok: false, errors: affiliationErrors }; const photoSource = extractPhotoSource(fields[FIELD.photo]); if (!photoSource) { @@ -212,6 +243,7 @@ export const team = { if (fields[FIELD.email]) member.email = fields[FIELD.email].trim(); if (fields[FIELD.note]) member.note = fields[FIELD.note].trim(); if (education.length) member.education = education; + if (affiliations.length) member.affiliations = affiliations; const socials = buildSocials(fields); if (Object.keys(socials).length) member.socials = socials; @@ -269,6 +301,11 @@ export const team = { `**Avatar:** ${variantPaths.length} variants generated under \`public/images/team/${plan.slug}/\``, ]; if (plan.additional) summaryLines.push("", plan.additional); + if (plan.member.affiliations?.length) { + summaryLines.push( + `**Affiliations:** ${plan.member.affiliations.map(({ role, organization }) => `${role} | ${organization}`).join("; ")}` + ); + } return { ok: true, diff --git a/.unused-assets-ignore b/.unused-assets-ignore index 7bf1368..41e18b0 100644 --- a/.unused-assets-ignore +++ b/.unused-assets-ignore @@ -1,11 +1,26 @@ -# Image paths (relative to public/, leading slash) intentionally kept -# even though they aren't referenced from src/ yet. +# Live image paths (relative to public/, leading slash) assembled at runtime. # # Used by: .github/scripts/check-unused-assets.mjs -# MED3pa working diagrams kept for future content updates -/images/albums/research-MED3pa/objectives.drawio.svg -/images/albums/research-MED3pa/sl_tree.drawio.svg +# homepageContent.js derives these from the ecosystem app names. +/images/homepage-neue/ecosystem/medomics.webp +/images/homepage-neue/ecosystem/med3pa.webp +/images/homepage-neue/ecosystem/medfl.webp +/images/homepage-neue/ecosystem/mediml.webp +/images/homepage-neue/ecosystem/medprofiles.webp -# MEDomics platform working asset (alternate hero) -/images/albums/research-medomics_platform/medomicslab.jpg +# homepageContent.js derives these from research project order and slugs. +/images/homepage-neue/next/01-medomics-platform.webp +/images/homepage-neue/next/02-mediml.webp +/images/homepage-neue/next/03-med3pa.webp +/images/homepage-neue/next/04-multilevel-graphs.webp +/images/homepage-neue/next/05-medomicslab-optimization.webp +/images/homepage-neue/next/06-distributed-learning-inference.webp +/images/homepage-neue/next/07-distributed-random-forests.webp +/images/homepage-neue/next/08-energai.webp +/images/homepage-neue/next/09-kidney-small-tumors.webp +/images/homepage-neue/next/10-multitask-classification.webp +/images/homepage-neue/next/11-patient-mortality-prediction.webp +/images/homepage-neue/next/12-prostate-models.webp +/images/homepage-neue/next/13-radiomics-complexity.webp +/images/homepage-neue/next/14-vo2max-obesity.webp diff --git a/README.md b/README.md index 8ca45d6..43b4167 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ## How To Add Content (User-Friendly) > [!TIP] -> This is the simple, no-code way to add or update lab content. Choose the matching form at [**Issues → New issue**](https://github.com/simonprovost/medomicslab/issues/new/choose), fill it in, and submit it. The automation validates your details and opens a draft pull request for a maintainer to review and merge—no local setup required. +> This is the simple, no-code way to add or update lab content. Choose the matching form at [**Issues → New issue**](https://github.com/MEDomicsLab/MEDomicsLab-website/issues/new/choose), fill it in, and submit it. The automation validates your details and opens a draft pull request for a maintainer to review and merge—no local setup required.
  Add a team member @@ -58,7 +58,7 @@ The automation will: 3. Insert the entry into the right cohort in `src/data/team.json`. 4. Open a draft PR. -If a field looks confusing, leave the placeholder text in place; the parser ignores `_No response_`. +Leave optional fields blank when unknown. Fill every required field with the actual details.
@@ -72,7 +72,7 @@ Use the `Add a new publication` template. A publication entry needs: - A clear title, comma-separated authors, and the year. -- A _kind_ picked from the dropdown (`Journal Papers`, `Conference Papers`, `Preprints`, `Presentations`). +- A _kind_ picked from the dropdown (`Journal Papers`, `Conference Papers`, `Preprints`). Submit talks and posters with the event form. - A DOI or URL. The DOI is preferred when one exists; the site renders both. - A short abstract. One paragraph is enough; readers click through for the full text. @@ -94,11 +94,11 @@ Cover at minimum: - Title (e.g. `Thesis Defense: ...`). - Slug, usually prefixed with the date (`2026-05-15-msc-defense-x`). - _Event kind_ from the dropdown (thesis defense, symposium, workshop, talk, lab meeting, outreach, other). -- Start date in ISO format (`YYYY-MM-DD`). End date only for multi-day events. -- Location, even if it is just `Online` or a Zoom link. +- A sourced start date (`YYYY-MM-DD`), or a listing month (`YYYY-MM`) when the exact day is unknown. The listing month only sets the timeline group. +- Optional end date for multi-day events, and venue when explicitly known. Keep meeting links in the body. - Contributors / speakers, comma-separated, lab spelling. -Only the title, slug and contributors live in [`src/data/events.json`](./src/data/events.json); everything else (kind, dates, time, location, registration link, full description) goes into the generated markdown file at `src/content/community/events/.md`. That markdown supports HTML and `` embeds. +Event details live in [`src/data/events.json`](./src/data/events.json) and appear in the right-hand sidebar: `date`, optional `endDate`, `kind` (falling back to `category`), and `venue`. The sidebar shows a date only when an exact date is available. News entries can store an exact `date`, or use an `event` object with any known `date`, `kind`, and `venue` (plus optional `endDate`, which requires `date`) for event recaps. News dates are added only when the source text, title, or imagery states them. Contributors appear underneath. The submission form saves event dates, kind, and location (as `venue`) automatically. Keep time, registration, links, description, abstract, agenda, and media in `src/content/community/events/.md`, which supports HTML and standard Markdown images. Add each photo in its own paragraph to display full-width images with rounded corners, as in the latest Mila news post. @@ -113,7 +113,9 @@ You'll need: - A one-sentence headline. - A slug prefixed with the publish date (`2026-04-24-symposium-recap`). -- The publish date in ISO format. This decides the year and month grouping on `/community/news`. +- The planned website publish date in ISO format. This sets the timeline group only. +- An optional **Date to display**, supported by the source text, title, or images. Leave unknown dates blank. +- For event news, optional event kind, venue, and end date. These appear in the sidebar. - Comma-separated contributors. Lab spelling, again. - A markdown body. The listing card shows the title only, so the opening paragraph here is what readers see first when they click through. @@ -194,7 +196,7 @@ Year → months → items. Each item references a markdown file under `src/conte - **Template:** [`src/content/_templates/event.md`](./src/content/_templates/event.md) - **Pages:** `CommunityListPage` (`/community/events`), `CommunityItemDetailPage`. -Same shape as news. The kind, dates, time, location, and registration link live in the markdown frontmatter / body, not in the JSON. +Same year/month grouping as news. Store event logistics in JSON using the optional `date`, `endDate`, `kind`, and `venue` fields. The detail page displays them above the researchers; Markdown holds times, links, and the event's main content. diff --git a/e2e/a11y.spec.js b/e2e/a11y.spec.js index 6888396..a3eec5d 100644 --- a/e2e/a11y.spec.js +++ b/e2e/a11y.spec.js @@ -3,7 +3,6 @@ import AxeBuilder from "@axe-core/playwright"; const routes = [ "/", - "/visions", "/research", "/publications", "/team", diff --git a/e2e/community-pages.spec.js b/e2e/community-pages.spec.js new file mode 100644 index 0000000..c344dcf --- /dev/null +++ b/e2e/community-pages.spec.js @@ -0,0 +1,131 @@ +import { expect, test } from "@playwright/test"; +import news from "../src/data/news.json" with { type: "json" }; +import events from "../src/data/events.json" with { type: "json" }; +import courses from "../src/data/courses.json" with { type: "json" }; + +test.use({ viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" }); + +const timelines = [ + { title: "News", path: "/community/news", data: news }, + { title: "Events", path: "/community/events", data: events }, +]; + +for (const { title, path, data } of timelines) { + test(`${title} shares publication styling while preserving the timeline, filters and details`, async ({ + page, + }) => { + const items = data.flatMap((year) => year.months.flatMap((month) => month.items)); + const firstYear = data[0]; + const firstMonth = firstYear.months[0]; + const firstItem = firstMonth.items[0]; + await page.goto(path); + await expect(page.getByRole("heading", { name: title, exact: true })).toHaveCSS( + "font-family", + '"Homepage Neue Montreal", sans-serif' + ); + await expect(page.locator(".neue-interior-layout")).toHaveCSS( + "background-color", + "rgb(26, 26, 26)" + ); + await expect(page.locator(".entry-row")).toHaveCount(items.length); + const ticks = page.locator(".section-ticks"); + await expect(ticks).toBeVisible(); + await ticks.getByRole("button", { name: firstYear.year, exact: true }).click(); + await expect(ticks.getByRole("button", { name: firstYear.year, exact: true })).toHaveAttribute( + "aria-current", + "true" + ); + await ticks.getByRole("button", { name: firstMonth.month, exact: true }).click(); + await expect + .poll(() => + page + .getByRole("heading", { name: firstMonth.month, exact: true }) + .first() + .evaluate((el) => Math.round(el.getBoundingClientRect().top)) + ) + .toBe(128); + await expect(ticks).toHaveCSS("opacity", "1"); + + const firstChip = page + .getByRole("group", { name: `Filter ${title.toLowerCase()} by category` }) + .getByRole("button") + .nth(1); + const category = (await firstChip.textContent()).replace(/\d+$/, "").trim(); + const matching = items.filter((item) => item.category === category); + const filter = page.getByRole("button", { + name: `${category} ${matching.length}`, + exact: true, + }); + await filter.click(); + await expect(filter).toHaveAttribute("aria-pressed", "true"); + await expect(filter).toHaveCSS("color", "rgb(26, 26, 26)"); + await expect(filter).toHaveCSS("cursor", "pointer"); + await expect(page.locator(".entry-row")).toHaveCount(matching.length); + await expect(page.locator(".entry-row-title")).toHaveText(matching.map((item) => item.title)); + await page.getByRole("button", { name: "Clear filters" }).click(); + await expect(page.locator(".entry-row")).toHaveCount(items.length); + + await page.locator(`main a[href="${path}/${firstItem.slug}"]`).click(); + await expect(page).toHaveURL(`${path}/${firstItem.slug}`); + await expect(page.locator("h1")).toHaveText(firstItem.title); + await expect(page.locator(".community-detail .prose")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Researchers", exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + await page.getByRole("link", { name: `Back to ${title}`, exact: true }).click(); + await expect(page).toHaveURL(path); + }); +} + +test("Courses uses collection rows and institution filters without a sidebar", async ({ page }) => { + await page.goto("/community/courses"); + await expect(page.locator(".courses-index")).toBeVisible(); + await expect(page.locator(".section-ticks")).toHaveCount(0); + await expect(page.locator(".entry-row")).toHaveCount(courses.length); + await expect(page.locator(".collection-group-title")).toHaveText(["McGill", "Sherbrooke"]); + const mcgill = courses.filter((course) => course.institution === "McGill"); + await page.getByRole("button", { name: `McGill ${mcgill.length}`, exact: true }).click(); + await expect(page.locator(".entry-row")).toHaveCount(mcgill.length); + await expect(page.locator(".entry-row").first()).toContainText(mcgill[0].sessions.join(" · ")); + await page.locator(".entry-row").first().click(); + await expect(page.locator(".community-detail h1")).toHaveText(mcgill[0].title); + await expect(page.locator(".community-detail .prose")).toBeVisible(); + await expect(page.locator(".section-ticks")).toHaveCount(0); + await page.getByRole("link", { name: "Back to Courses", exact: true }).click(); + await expect(page.locator(".entry-row")).toHaveCount(courses.length); +}); + +test("community collections and their details fit narrow screens", async ({ page }) => { + const routes = [ + "/community/news", + `/community/news/${news[0].months[0].items[0].slug}`, + "/community/events", + `/community/events/${events[0].months[0].items[0].slug}`, + "/community/courses", + `/community/courses/${courses[0].slug}`, + ]; + for (const width of [320, 768]) { + await page.setViewportSize({ width, height: 900 }); + for (const path of routes) { + await page.goto(path); + await expect(page.locator("h1")).toBeVisible(); + await expect(page.locator(".neue-interior-layout")).toHaveCount(1); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)) + .toBeLessThanOrEqual(1); + await expect(page.locator("footer.neue-footer")).toHaveCount(1); + const more = page.getByRole("button", { name: "More", exact: true }); + if (await more.count()) { + await more.click(); + await expect(page.getByRole("group", { name: "More filters", exact: true })).toBeVisible(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth - innerWidth) + ).toBeLessThanOrEqual(1); + const bounds = await page.getByRole("group", { name: "More filters" }).boundingBox(); + expect(bounds.x).toBeGreaterThanOrEqual(16); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(width - 16); + await page.keyboard.press("Escape"); + await expect(page.getByRole("group", { name: "More filters" })).toHaveCount(0); + } + } + } +}); diff --git a/e2e/contact.spec.js b/e2e/contact.spec.js new file mode 100644 index 0000000..57f5406 --- /dev/null +++ b/e2e/contact.spec.js @@ -0,0 +1,192 @@ +import { expect, test } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import layout from "../src/data/layout.json" with { type: "json" }; + +const path = "/community/contact"; +const emailHref = layout.footer.social.reachOutLinks.find((link) => + link.href.startsWith("mailto:") +).href; +const email = emailHref.slice("mailto:".length); + +test.use({ viewport: { width: 1440, height: 1000 } }); + +test.beforeEach(async ({ page }) => { + await page.route("https://www.google.com/maps?**", (route) => + route.fulfill({ + contentType: "text/html", + body: "Location map", + }) + ); +}); + +test("minimal contact page keeps the welcome, email, address and map in the new theme", async ({ + page, +}) => { + await page.goto(path); + await expect(page).toHaveTitle("Contact | MEDomicsLab"); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + `https://medomicslab.com${path}` + ); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Contact us"); + await expect(page.locator(".contact-header > p")).toContainText("PhD, MSc, BSc"); + await expect(page.locator(".neue-interior-layout")).toHaveCSS( + "background-color", + "rgb(26, 26, 26)" + ); + await expect(page.getByRole("heading", { level: 1 })).toHaveCSS( + "font-family", + '"Homepage Neue Montreal", sans-serif' + ); + await expect( + page.locator(".contact-page").getByRole("link", { name: email, exact: true }) + ).toHaveAttribute("href", emailHref); + const address = page.locator(".contact-page address"); + await address.scrollIntoViewIfNeeded(); + for (const line of layout.footer.contact.addressLines) { + await expect(address).toContainText(line.replace(/,$/, "")); + } + const google = page.getByRole("link", { name: "Google Maps", exact: true }); + const apple = page.getByRole("link", { name: "Apple Maps", exact: true }); + await expect(google).toHaveAttribute("href", layout.footer.contact.mapLink.href); + const appleUrl = new URL(await apple.getAttribute("href")); + expect(appleUrl.origin).toBe("https://maps.apple.com"); + expect(appleUrl.searchParams.get("q")).toContain("1001 boul. Décarie"); + expect(appleUrl.searchParams.get("q")).not.toContain("Office:"); + for (const link of [google, apple]) { + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noreferrer"); + } + const map = page.getByTitle("MEDomicsLab location map", { exact: true }); + await expect(map).toHaveAttribute("loading", "lazy"); + const mapUrl = new URL(await map.getAttribute("src")); + expect(mapUrl.origin).toBe("https://www.google.com"); + expect(mapUrl.searchParams.get("q")).toBe(appleUrl.searchParams.get("q")); + expect(mapUrl.searchParams.get("output")).toBe("embed"); + await expect( + page.locator(".contact-page form, .contact-conversations, .contact-connection") + ).toHaveCount(0); + await expect(page.locator("footer.neue-footer")).toHaveCount(1); +}); + +test("copying the email reports success only after the clipboard write completes", async ({ + page, +}) => { + await page.addInitScript(() => { + window.copiedEmail = null; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (value) => + new Promise((resolve) => { + window.finishCopy = () => { + window.copiedEmail = value; + resolve(); + }; + }), + }, + }); + }); + await page.goto(path); + const copy = page.getByRole("button", { name: "Copy email address", exact: true }); + await copy.click(); + await expect(copy).toBeDisabled(); + await expect(copy).toHaveText("Copying..."); + await expect(page.getByRole("status")).toBeEmpty(); + await page.evaluate(() => window.finishCopy()); + await expect(copy).toBeEnabled(); + await expect(copy).toHaveText("Copied"); + await expect(page.getByRole("status")).toHaveText("Email address copied."); + expect(await page.evaluate(() => window.copiedEmail)).toBe(email); +}); + +for (const mode of ["denied", "unavailable"]) { + test(`clipboard ${mode} shows a useful error, keeps the email usable and allows retry`, async ({ + page, + }) => { + await page.addInitScript((mode) => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: + mode === "unavailable" + ? undefined + : { + writeText: async () => { + throw new DOMException("Clipboard permission denied", "NotAllowedError"); + }, + }, + }); + }, mode); + await page.goto(path); + const copy = page.getByRole("button", { name: "Copy email address", exact: true }); + await copy.click(); + await expect(page.getByRole("status")).toHaveText( + "Couldn't copy. Select the address or use the email link." + ); + await expect(copy).toBeEnabled(); + await expect(copy).toHaveText("Copy email"); + await expect(page).toHaveURL(path); + await expect( + page.locator(".contact-page").getByRole("link", { name: email, exact: true }) + ).toHaveAttribute("href", emailHref); + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async () => {} }, + }); + }); + await copy.click(); + await expect(page.getByRole("status")).toHaveText("Email address copied."); + }); +} + +test("keyboard navigation reaches the email and copy action with visible focus", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(path); + await page.locator(".contact-page").getByRole("link", { name: email, exact: true }).focus(); + await page.keyboard.press("Tab"); + const copy = page.getByRole("button", { name: "Copy email address", exact: true }); + await expect(copy).toBeFocused(); + await expect(copy).toHaveCSS("outline-style", "solid"); + await expect(copy).toHaveCSS("cursor", "pointer"); +}); + +test("contact layout fits mobile, tablet and desktop with reduced-motion support", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + for (const width of [320, 390, 768, 1024, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto(path); + await page.evaluate(() => document.fonts.ready); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)) + .toBeLessThanOrEqual(1); + await expect(page.locator(".contact-copy")).toHaveCSS("transition-duration", "0s"); + for (const locator of [ + page.getByRole("button", { name: "Copy email address", exact: true }), + page.getByRole("link", { name: "Google Maps", exact: true }), + page.getByRole("link", { name: "Apple Maps", exact: true }), + ]) { + const box = await locator.boundingBox(); + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(width); + } + } +}); + +test("contact content has no WCAG A/AA accessibility violations, including contrast", async ({ + page, +}) => { + await page.goto(path); + await page.evaluate(() => document.fonts.ready); + const results = await new AxeBuilder({ page }) + .include(".contact-page") + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); +}); diff --git a/e2e/event-metadata.spec.js b/e2e/event-metadata.spec.js new file mode 100644 index 0000000..041530f --- /dev/null +++ b/e2e/event-metadata.spec.js @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test"; +import events from "../src/data/events.json" with { type: "json" }; +import news from "../src/data/news.json" with { type: "json" }; + +test.use({ timezoneId: "America/Toronto", reducedMotion: "reduce" }); + +test("every event displays its available logistics in the sidebar", async ({ page }) => { + for (const year of events) { + for (const month of year.months) { + for (const item of month.items) { + await page.goto(`/community/events/${item.slug}`); + const details = page.getByRole("region", { name: "Event details" }); + await expect(details).toBeVisible(); + await expect(details).toContainText(item.kind || item.category); + if (item.date) { + await expect(details.locator("time").first()).toHaveAttribute("datetime", item.date); + await expect(details.locator("time").first()).toHaveText( + new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }).format(new Date(`${item.date}T00:00:00Z`)) + ); + } else { + await expect(details.locator("time")).toHaveCount(0); + } + if (item.venue) await expect(details).toContainText(item.venue); + await expect(details.locator("dt")).toHaveText([ + ...(item.date ? ["Date"] : []), + "Type", + ...(item.venue ? ["Venue"] : []), + ]); + await expect(details.getByRole("link")).toHaveCount(0); + await expect( + page.locator(".prose").getByRole("heading", { name: "Date", exact: true }) + ).toHaveCount(0); + } + } + } +}); + +test("event metadata fits narrow screens and omits dates without an exact day", async ({ + page, +}) => { + await page.setViewportSize({ width: 320, height: 900 }); + await page.goto("/community/events/annual-symposium-2024"); + const details = page.getByRole("region", { name: "Event details" }); + await expect(details.locator("dt")).toHaveText(["Type", "Venue"]); + await expect(details.locator("time")).toHaveCount(0); + await expect(details).toContainText("McGill University (hybrid attendance)"); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)) + .toBeLessThanOrEqual(1); +}); + +test("news about an event can display date, type, and venue", async ({ page }) => { + await page.goto("/community/news/2026-09-29-mila-digital-health"); + const details = page.getByRole("region", { name: "Event details" }); + await expect(details.locator("dt")).toHaveText(["Date", "Type", "Venue"]); + await expect(details.locator("dd")).toHaveText([ + "September 29, 2026", + "Community of practice", + "Mila, Montréal", + ]); + await expect(details.getByRole("link")).toHaveCount(0); + await expect(page.getByRole("link", { name: "Back to News", exact: true })).toBeVisible(); + await page.goto("/community/news/2026-09-22-mariem-msc"); + await expect(details.locator("dt")).toHaveText(["Date", "Type", "Venue"]); + await expect(details.locator("dd")).toHaveText([ + "August 27, 2026", + "Thesis defense (MSc)", + "Université de Sherbrooke, room D4-2011 (hybrid via Teams)", + ]); + await page.goto("/community/news/2024-08-20-mall-msc"); + await expect(page.getByRole("region", { name: "News details" })).toContainText("August 20, 2024"); +}); + +test("news shows sourced dates and omits unknown dates", async ({ page }) => { + for (const item of news.flatMap((year) => year.months.flatMap((month) => month.items))) { + await page.goto(`/community/news/${item.slug}`); + const metadata = item.event || item; + const details = page.getByRole("region", { + name: item.event ? "Event details" : "News details", + }); + if (metadata.date) { + await expect(details.locator("time").first()).toHaveAttribute("datetime", metadata.date); + await expect(details.getByRole("link")).toHaveCount(0); + } else { + await expect(details).toHaveCount(0); + } + } +}); diff --git a/e2e/homepage.spec.js b/e2e/homepage.spec.js new file mode 100644 index 0000000..a741e5e --- /dev/null +++ b/e2e/homepage.spec.js @@ -0,0 +1,642 @@ +import { test, expect } from "@playwright/test"; +import home from "../src/data/home.json" with { type: "json" }; +import layout from "../src/data/layout.json" with { type: "json" }; +import team from "../src/data/team.json" with { type: "json" }; + +test("homepage identity, appointments and shared footer use the content data", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + const pi = team.find((group) => group.year === "Lab Principal Investigator").members[0]; + const profile = page.locator(".neue-profile"); + await expect(profile.locator(".neue-profile-photo")).toHaveAttribute("alt", pi.name); + await expect(profile.locator("#lab-heading")).toHaveText( + new RegExp(`${pi.name.replaceAll(" ", "\\s*")}, ${pi.degreeSuffix}`) + ); + await expect(profile.getByRole("link", { name: /^Explore/ })).toHaveAttribute( + "href", + `/team/${pi.slug}` + ); + await expect(profile.locator(".neue-appointments > p")).toHaveCount(pi.appointments.length); + for (const [index, appointment] of pi.appointments.entries()) { + const row = profile.locator(".neue-appointments > p").nth(index); + await expect(row).toContainText(appointment.title); + await expect(row).toContainText(appointment.department); + await expect(row).toContainText(appointment.university); + await expect(row.locator("a")).toHaveAttribute("href", appointment.href); + } + const partners = profile.locator(".neue-institute-set").first().getByRole("link"); + await expect(partners).toHaveCount(pi.institutes.length); + for (const [index, institute] of pi.institutes.entries()) { + await expect(partners.nth(index)).toHaveAttribute("href", institute.url); + } + for (const path of ["/", "/team"]) { + if (path !== "/") await page.goto(path); + const footer = page.locator("footer"); + await expect(footer.locator(".neue-footer-logo img")).toHaveAttribute( + "src", + home.brand.lightLogoUrl + ); + await expect(footer.locator(".neue-footer-wordmark")).toHaveText(home.brand.name); + await expect(footer.locator(".neue-footer-office")).toHaveText(layout.footer.contact.office); + for (const affiliation of layout.footer.affiliations) { + await expect( + footer.getByRole("link", { name: affiliation.name, exact: true }) + ).toHaveAttribute("href", affiliation.href); + } + await expect( + footer.getByRole("link", { name: "Réseau santé numérique (RSN)", exact: true }) + ).toHaveAttribute("href", "https://rsn.quebec/"); + } +}); + +test.beforeEach(async ({ page }) => { + await page.route("https://api.github.com/**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ stargazers_count: 42, forks_count: 7, open_issues_count: 0 }), + }) + ); +}); + +test("all generated ecosystem and project-card artwork remains available", async ({ + page, + request, +}) => { + await page.goto("/"); + const sources = await page + .locator(".neue-ecosystem-app img, .neue-next-card img") + .evaluateAll((images) => [...new Set(images.map((image) => image.getAttribute("src")))]); + expect(sources).toHaveLength(19); + for (const source of sources) { + const response = await request.get(source); + expect(response.ok(), source).toBe(true); + expect(response.headers()["content-type"], source).toMatch(/^image\//); + } +}); + +test("the hero stays pinned while the profile covers it; navigation is centred independently of its shortcuts", async ({ + page, +}) => { + await page.setViewportSize({ width: 1706, height: 897 }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "MEDomicsLab", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "MEDomicsLab homepage" })).toBeHidden(); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "complete", { + timeout: 8000, + }); + const nav = await page.locator(".liquid-nav-anchor").boundingBox(); + const github = await page.locator(".liquid-github-anchor").boundingBox(); + const ecosystem = await page.locator(".liquid-ecosystem-anchor").boundingBox(); + expect(Math.abs(nav.x + nav.width / 2 - 853)).toBeLessThan(2); + const film = await page.locator(".neue-hero-film").boundingBox(); + const word = await page.locator(".neue-hero-medomics").boundingBox(); + const lab = await page.locator(".neue-hero-lab").boundingBox(); + const bottom = await page.locator(".neue-hero-bottom").boundingBox(); + expect((github.x + ecosystem.x + ecosystem.width) / 2).toBeCloseTo(film.x + film.width / 2, 0); + expect(github.y + github.height / 2).toBeCloseTo(film.y + film.height / 2, 0); + expect(ecosystem.y).toBeCloseTo(github.y, 0); + const navBottom = nav.y + nav.height; + const centre = (navBottom + bottom.y) / 2; + const title = await page.locator("#home-heading").boundingBox(); + expect(film.x - title.x).toBeGreaterThanOrEqual(0); + expect(film.x - title.x).toBeLessThan(16); + expect(Math.abs(film.x + film.width - title.x - title.width)).toBeLessThan(4); + expect(Math.abs(lab.y - word.y)).toBeLessThan(1); + expect(Math.abs(lab.x - word.x - word.width)).toBeLessThan(1); + expect(film.y - title.y - title.height).toBeGreaterThan(10); + expect(film.y - title.y - title.height).toBeLessThan(20); + expect(Math.abs(title.x + title.width / 2 - 853)).toBeLessThan(1); + expect(film.width).toBeGreaterThan(800); + expect(Math.abs((title.y + film.y + film.height) / 2 - (centre - 6))).toBeLessThan(2); + await expect(page.locator(".neue-hero-medomics")).toHaveCSS( + "font-size", + await page.locator(".neue-hero-lab").evaluate((el) => getComputedStyle(el).fontSize) + ); + await expect(page.locator(".neue-profile-photo")).toHaveAttribute( + "src", + "/images/team/martin-vallieres/avatar-256.avif" + ); + await expect(page.locator('[aria-current="page"]')).toHaveCSS("color", "rgb(255, 255, 255)"); + await page.evaluate(() => window.scrollTo(0, 400)); + await expect(page.getByRole("link", { name: "MEDomicsLab homepage" })).toBeVisible(); + const hero = await page.locator(".neue-hero").boundingBox(); + expect(hero.y).toBe(0); + const expanded = await page.locator(".neue-hero-film").boundingBox(); + expect(Math.abs(expanded.x)).toBeLessThan(1); + expect(Math.abs(expanded.y)).toBeLessThan(1); + expect(expanded.width).toBeCloseTo(1706, 0); + expect(expanded.height).toBeCloseTo(897, 0); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1706); + const portrait = await page.locator(".neue-profile-photo").boundingBox(); + const appointments = page.locator(".neue-appointments > p"); + const first = await appointments.first().boundingBox(); + const last = await appointments.last().boundingBox(); + expect(Math.abs(first.y - portrait.y)).toBeLessThan(1); + expect(Math.abs(last.y + last.height - portrait.y - portrait.height)).toBeLessThan(1); + const crest = page.getByRole("img", { name: "McGill University", exact: true }); + await expect(crest).toHaveAttribute( + "src", + "/images/logo/institutes/mcgill-university-crest.webp" + ); + await expect.poll(() => crest.evaluate((el) => el.naturalWidth)).toBeGreaterThan(0); + const profile = await page.locator("#lab").boundingBox(); + expect(Math.abs(profile.y - 497)).toBeLessThan(2); +}); + +test("project focus reveals statistics and publications expose four readable, linked abstracts", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto("/"); + const app = page.getByRole("link", { name: "MEDomics, website", exact: true }); + await app.focus(); + await app.scrollIntoViewIfNeeded(); + await expect(page.locator(".neue-ecosystem-stain-body")).not.toContainText("MEDomics"); + await expect(page.locator(".neue-stat-counts")).toContainText("42"); + await expect(page.locator(".neue-stat-counts")).toContainText("7"); + for (const name of ["MEDomics", "MED3pa", "MEDfl", "MEDiml", "MEDprofiles"]) { + await page.locator(`.neue-ecosystem-app[aria-label^="${name},"]`).focus(); + const logo = page.getByRole("img", { name: `${name} logo`, exact: true }); + await expect(logo).toHaveAttribute("src", `/images/homepage-neue/${name}-white.png`); + await expect.poll(() => logo.evaluate((el) => el.naturalWidth)).toBeGreaterThan(0); + } + await expect(page.locator(".neue-ecosystem-stain-body")).not.toContainText("On GitHub"); + const tabs = page.getByRole("button", { name: /^Read publication/ }); + await expect(tabs).toHaveCount(4); + for (let i = 0; i < 4; i++) { + await tabs.nth(i).scrollIntoViewIfNeeded(); + await tabs.nth(i).focus(); + await tabs.nth(i).press("Enter"); + await expect(tabs.nth(i)).toHaveAttribute("aria-expanded", "true"); + const panel = page.locator(".neue-publication-content:visible"); + await expect(panel).toHaveCount(1); + await expect(panel.locator(".neue-publication-abstract")).not.toContainText("Loading abstract"); + expect( + (await panel.locator(".neue-publication-abstract").textContent()).length + ).toBeGreaterThan(200); + await expect(panel.locator("h3")).not.toContainText("(Journal Article)"); + await expect( + panel.getByRole("link", { name: "Read publication", exact: true }) + ).toHaveAttribute("href", /^\/publications\/.+/); + if (i === 0) + await expect(panel.locator(".neue-publication-venue")).toContainText( + "Journal of the American Medical Informatics Association" + ); + } +}); + +test("postcard, footer and route changes preserve their interactions", async ({ page }) => { + await page.goto("/"); + const postcard = page.getByRole("button", { name: "Turn postcard to read our mission" }); + await postcard.scrollIntoViewIfNeeded(); + await expect(postcard).toBeVisible(); + await postcard.click(); + await expect( + page.getByRole("button", { name: "Turn postcard to the team photograph" }) + ).toHaveAttribute("aria-pressed", "true"); + await page.getByRole("button", { name: "Turn the postcard", exact: true }).click(); + await expect(postcard).toHaveAttribute("aria-pressed", "false"); + const footer = page.locator(".neue-footer"); + await footer.scrollIntoViewIfNeeded(); + await expect(footer.getByRole("link", { name: "Contact us", exact: true })).toHaveAttribute( + "href", + "/community/contact" + ); + await expect(footer.getByRole("link", { name: "Google Scholar" })).toHaveAttribute( + "href", + /user=fRkjFK4AAAAJ/ + ); + await expect(footer.locator(".shiny-text")).toHaveText("Designed by Simon"); + await expect(footer.getByRole("link", { name: "Maintained by Lab members" })).toHaveAttribute( + "href", + "https://github.com/MEDomicsLab/MEDomicsLab-website" + ); + await footer.getByRole("link", { name: "Publications", exact: true }).click(); + await expect(page).toHaveURL(/\/publications$/); + await expect(page.locator(".neue-home-layout")).toHaveCount(0); + await expect(page.locator(".neue-footer")).toHaveCount(1); + await page + .getByRole("navigation", { name: "Main navigation" }) + .getByRole("link", { name: "Home", exact: true }) + .click(); + await expect(page.locator(".neue-home-layout")).toBeVisible(); + await expect(page.getByRole("link", { name: "MEDomicsLab homepage" })).toBeHidden(); +}); + +for (const width of [320, 390, 768]) { + test(`homepage fits a ${width}px viewport, including its navigation and project links`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 844 }); + await page.goto("/"); + await page.evaluate(() => document.fonts.ready); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + await expect + .poll(async () => { + const bounds = await page.locator(".liquid-github-anchor").boundingBox(); + return bounds.x + bounds.width; + }) + .toBeLessThanOrEqual(width); + const nav = await page.locator(".liquid-nav-anchor").boundingBox(); + const github = await page.locator(".liquid-github-anchor").boundingBox(); + expect(nav.x).toBeGreaterThanOrEqual(0); + expect(github.x + github.width).toBeLessThanOrEqual(width); + expect(Math.abs(nav.x + nav.width / 2 - width / 2)).toBeLessThan(2); + expect(github.x >= nav.x + nav.width || github.y >= nav.y + nav.height).toBe(true); + await page.locator(".neue-ecosystem").scrollIntoViewIfNeeded(); + if (width < 768) { + for (const app of await page.locator(".neue-ecosystem-app").all()) { + const rect = await app.boundingBox(); + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.width).toBeLessThanOrEqual(width); + } + } + await page.locator(".neue-footer").scrollIntoViewIfNeeded(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); + }); +} + +test("reduced motion keeps every subject visible without waiting for scroll animation", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect( + page.getByRole("button", { name: "Play background videos and animations" }) + ).toHaveAttribute("aria-pressed", "true"); + await page.locator(".neue-reel").scrollIntoViewIfNeeded(); + await expect(page.locator(".neue-reel-reduced")).toHaveText("Publications · News · Events"); + await expect(page.locator(".neue-reel-reduced")).toBeVisible(); + await page.locator(".neue-ecosystem").scrollIntoViewIfNeeded(); + await expect(page.getByRole("heading", { name: "The MEDomics Ecosystem." })).toBeVisible(); +}); + +test("homepage navigation keeps its text colour on hover across backgrounds", async ({ page }) => { + await page.setViewportSize({ width: 1706, height: 897 }); + await page.goto("/"); + const link = page + .locator(".liquid-nav-anchor nav") + .getByRole("link", { name: "Research", exact: true }); + const capsule = page.locator(".liquid-nav-anchor"); + const background = await capsule.evaluate((el) => getComputedStyle(el).backgroundColor); + await expect(link).toHaveCSS("color", "rgb(255, 255, 255)"); + await link.hover(); + await expect(link).toHaveCSS("color", "rgb(255, 255, 255)"); + await expect(link).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(capsule).toHaveCSS("background-color", background); + await page.evaluate(() => window.scrollTo(0, document.querySelector("#lab").offsetTop + 100)); + await expect(link).toHaveCSS("color", "rgb(255, 255, 255)"); + expect(await link.evaluate((el) => el.style.getPropertyValue("--nav-hover-color"))).toBe(""); + await page.mouse.move(10, 200); + await expect(link).toHaveCSS("color", "rgb(255, 255, 255)"); +}); + +test("rolling subjects keep their weight; publication hover resets and footer arrows turn right", async ({ + page, +}) => { + await page.setViewportSize({ width: 1706, height: 897 }); + await page.goto("/"); + const weights = []; + for (const [index, progress] of [ + [0, 0.1], + [0, 0.2], + [1, 0.43], + [1, 0.56], + [2, 0.83], + [2, 0.95], + ]) { + await page.evaluate((progress) => { + const reel = document.querySelector(".neue-reel"); + window.scrollTo(0, reel.offsetTop + (reel.offsetHeight - innerHeight) * progress); + }, progress); + const word = page.locator(".neue-reel-word").nth(index); + await expect(word).toHaveCSS("opacity", "1"); + weights.push(await word.evaluate((el) => Number(getComputedStyle(el).fontWeight))); + } + expect(weights[0]).toBe(weights[1]); + expect(weights[2]).toBe(weights[3]); + expect(weights[4]).toBe(weights[5]); + expect(weights[0]).toBeLessThan(weights[2]); + expect(weights[2]).toBeLessThan(weights[4]); + const tabs = page.getByRole("button", { name: /^Read publication/ }); + await tabs.nth(2).hover(); + await expect(tabs.nth(2)).toHaveAttribute("aria-expanded", "true"); + await page.locator(".neue-publications-intro").hover(); + await expect(tabs.nth(0)).toHaveAttribute("aria-expanded", "true"); + const maps = page.getByRole("link", { name: "Open Maps" }); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect(page.locator(".neue-footer-column").last()).toHaveCSS( + "transform", + "matrix(1, 0, 0, 1, 0, 0)" + ); + await maps.hover(); + await expect(maps.locator("svg")).toHaveCSS("rotate", "45deg"); +}); + +test("partner logos and ecosystem orbit move continuously and honour the pause control", async ({ + page, +}) => { + await page.setViewportSize({ width: 1706, height: 897 }); + await page.goto("/"); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "complete", { + timeout: 8000, + }); + const credit = page.locator(".neue-hero-credit"); + await credit.hover(); + await expect(credit.locator(".lucide-arrow-down")).toHaveCSS("rotate", "none"); + const partner = page.getByRole("link", { + name: "Research Institute of the McGill University Health Centre (RI-MUHC)", + exact: true, + }); + await expect(partner).toHaveAttribute("href", "https://rimuhc.ca/"); + const marquee = page.locator(".neue-marquee"); + await marquee.hover(); + await expect(page.locator(".neue-marquee-track")).toHaveCSS("animation-play-state", "running"); + const orbit = page.locator(".neue-ecosystem-orbit"); + const track = page.locator(".neue-institute-track"); + for (const element of [orbit, track]) { + const initial = await element.evaluate((el) => getComputedStyle(el).transform); + await expect + .poll(() => element.evaluate((el) => getComputedStyle(el).transform)) + .not.toBe(initial); + } + await page.evaluate(() => window.scrollTo(0, 0)); + await page.getByRole("button", { name: "Pause background videos and animations" }).click(); + await expect(orbit).toHaveCSS("animation-play-state", "paused"); + await expect(track).toHaveCSS("animation-play-state", "paused"); +}); + +test("homepage wordmark rises gently behind a video that is visible and stationary from first entry", async ({ + page, +}) => { + await page.setViewportSize({ width: 1706, height: 897 }); + await page.route("https://stream.mux.com/**", (route) => route.abort()); + await page.addInitScript(() => { + const animate = Element.prototype.animate; + Element.prototype.animate = function (...args) { + const animation = animate.apply(this, args); + if (document.querySelector(".neue-home")?.dataset.heroIntro === "revealing") { + animation.pause(); + } + return animation; + }; + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const film = page.locator(".neue-hero-film"); + await expect(film).toHaveCSS("opacity", "1"); + await expect(film).toHaveCSS("translate", "none"); + const firstFilm = await film.boundingBox(); + await page.locator(".neue-video-source mux-player").evaluate((el) => { + el.dataset.entranceIdentity = "original-player"; + }); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "revealing"); + const durations = await page.evaluate(() => { + window.heroEntrance = document.getAnimations().filter((a) => a.constructor === Animation); + return window.heroEntrance.map((animation) => { + animation.pause(); + return animation.effect.getComputedTiming().endTime; + }); + }); + expect(Math.max(...durations)).toBe(900); + const selectors = [ + ".neue-hero-medomics", + ".neue-hero-lab", + ".neue-hero-description", + ".neue-hero-credit", + ".liquid-nav-anchor", + ".neue-hero-film", + ]; + const positionsAt = (time) => + page.evaluate( + ([selectors, time]) => { + for (const animation of window.heroEntrance) animation.currentTime = time; + return selectors.map((selector) => { + const rect = document.querySelector(selector).getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }); + }, + [selectors, time] + ); + const initialPositions = await positionsAt(0); + const reveal = []; + for (const time of [150, 300, 450, 600, 750, 899]) reveal.push(await positionsAt(time)); + await page.evaluate(() => window.heroEntrance.forEach((animation) => animation.finish())); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "complete"); + for (const [index, sample] of reveal.entries()) { + const previous = index ? reveal[index - 1] : initialPositions; + expect(sample[0].y).toBeLessThan(previous[0].y); + expect(sample[5].x).toBeCloseTo(initialPositions[5].x, 0); + expect(sample[5].y).toBeCloseTo(initialPositions[5].y, 0); + } + const bounds = await film.boundingBox(); + expect(Math.abs(bounds.x + bounds.width / 2 - 853)).toBeLessThan(12); + const navigation = await page.locator(".liquid-nav-anchor").boundingBox(); + const bottom = await page.locator(".neue-hero-bottom").boundingBox(); + const centre = (navigation.y + navigation.height + bottom.y) / 2; + const title = await page.locator("#home-heading").boundingBox(); + expect(Math.abs((title.y + bounds.y + bounds.height) / 2 - (centre - 6))).toBeLessThan(2); + expect(bounds.width).toBeGreaterThan(800); + expect(bounds.width).toBeCloseTo(firstFilm.width, 0); + expect(bounds.height).toBeCloseTo(firstFilm.height, 0); + const finalPositions = await page.evaluate( + (selectors) => + selectors.map((selector) => { + const rect = document.querySelector(selector).getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }), + selectors + ); + for (const i of [0, 1]) { + expect(finalPositions[i].x).toBeCloseTo(initialPositions[i].x, 0); + expect(finalPositions[i].y).toBeLessThan(initialPositions[i].y - 80); + } + expect(finalPositions[5].x).toBeCloseTo(initialPositions[5].x, 0); + expect(finalPositions[5].y).toBeCloseTo(initialPositions[5].y, 0); + expect(finalPositions[2].x).toBeGreaterThan(initialPositions[2].x + 80); + expect(finalPositions[3].x).toBeLessThan(initialPositions[3].x - 80); + for (const i of [2, 3]) expect(finalPositions[i].y).toBeCloseTo(initialPositions[i].y, 0); + expect(finalPositions[4].y).toBeGreaterThan(initialPositions[4].y + 70); + await expect(page.locator(".neue-video-source mux-player")).toHaveAttribute( + "data-entrance-identity", + "original-player" + ); + await expect(page.getByRole("navigation", { name: "Main navigation" })).toBeVisible(); + await page.evaluate(() => window.scrollTo(0, 400)); + await expect(page.getByRole("link", { name: "GitHub", exact: true })).toBeVisible(); + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "complete"); + await expect( + page.locator(".site-shortcuts").getByRole("link", { name: "GitHub Organization", exact: true }) + ).toBeVisible(); + await expect(page.locator(".neue-hero-medomics")).toHaveCSS("translate", "none"); +}); + +test("homepage entrance can be skipped and is omitted for reduced motion", async ({ page }) => { + await page.route("https://stream.mux.com/**", (route) => route.abort()); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.keyboard.press("Escape"); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "complete"); + await expect(page.getByRole("navigation", { name: "Main navigation" })).toBeVisible(); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.reload(); + await expect(page.locator(".neue-home")).not.toHaveAttribute( + "data-hero-intro", + /waiting|revealing/ + ); + const bounds = await page.locator(".neue-hero-film").boundingBox(); + expect(bounds.x).toBeGreaterThan(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(page.viewportSize().width); + await expect(page.getByRole("navigation", { name: "Main navigation" })).toBeVisible(); +}); + +test("all film surfaces share one uninterrupted source and one pause control", async ({ page }) => { + await page.route("https://stream.mux.com/**", (route) => route.abort()); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.keyboard.press("Escape"); + const player = page.locator(".neue-video-source mux-player"); + await expect(page.locator("mux-player")).toHaveCount(1); + await expect.poll(() => player.evaluate((el) => Boolean(el.media?.nativeEl))).toBe(true); + await player.evaluate(async (el) => { + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 180; + const context = canvas.getContext("2d"); + const draw = () => { + context.fillStyle = "rgb(40, 130, 210)"; + context.fillRect(0, 0, 320, 180); + requestAnimationFrame(draw); + }; + draw(); + const video = el.media.nativeEl; + video.dataset.sharedIdentity = "original-film"; + video.srcObject = canvas.captureStream(24); + await video.play(); + }); + let previousTime = 0; + for (const selector of [".neue-hero-film", ".neue-reel", ".neue-events", ".neue-hero-film"]) { + if (selector === ".neue-hero-film") await page.evaluate(() => window.scrollTo(0, 0)); + else await page.locator(selector).evaluate((el) => el.scrollIntoView()); + const surface = page.locator(`${selector} .neue-video-frame`); + await expect(surface).toHaveAttribute("data-ready", "true"); + await expect + .poll(() => + surface.evaluate((el) => { + const pixel = el.getContext("2d").getImageData(el.width / 2, el.height / 2, 1, 1).data; + return pixel[2]; + }) + ) + .toBeGreaterThan(180); + await expect + .poll(() => player.evaluate((el) => el.media.nativeEl.currentTime)) + .toBeGreaterThan(previousTime); + previousTime = await player.evaluate((el) => el.media.nativeEl.currentTime); + expect(await player.evaluate((el) => el.media.nativeEl.dataset.sharedIdentity)).toBe( + "original-film" + ); + await expect(page.locator("mux-player")).toHaveCount(1); + } + await page.evaluate(() => window.scrollTo(0, 0)); + await page.getByRole("button", { name: "Pause background videos and animations" }).click(); + await expect.poll(() => player.evaluate((el) => el.media.nativeEl.paused)).toBe(true); + const pausedAt = await player.evaluate((el) => el.media.nativeEl.currentTime); + await page.locator(".neue-events").evaluate((el) => el.scrollIntoView()); + await expect(page.locator(".neue-events canvas")).toBeInViewport(); + expect(await player.evaluate((el) => el.media.nativeEl.currentTime)).toBe(pausedAt); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.getByRole("button", { name: "Play background videos and animations" }).click(); + await expect + .poll(() => player.evaluate((el) => el.media.nativeEl.currentTime)) + .toBeGreaterThan(pausedAt); +}); + +test("dropdown links underline and the navbar stays centred on other routes", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/research"); + await expect + .poll(async () => { + const nav = await page.locator(".liquid-nav-anchor").boundingBox(); + return Math.abs(nav.x + nav.width / 2 - 720); + }) + .toBeLessThan(2); + await page.getByRole("button", { name: "Community", exact: true }).hover(); + const news = page.getByRole("menuitem", { name: "News", exact: true }); + await news.hover(); + await expect(news).toHaveCSS("text-decoration-line", "underline"); + const github = page.locator(".liquid-github-anchor a"); + await github.hover(); + await expect(github.locator(".nav-action-slide__arrow")).toHaveCSS("opacity", "1"); + await expect(github).toHaveAttribute("href", "https://github.com/MEDomicsLab"); + await expect(page.getByRole("menu", { name: "MEDomicsLab apps" })).toHaveCount(0); + await page.goto("/"); + await page.keyboard.press("Escape"); + await expect(page.locator('[aria-current="page"]')).toHaveCSS("color", "rgb(255, 255, 255)"); + await page.locator("#lab").evaluate((el) => el.scrollIntoView()); + await expect(page.locator('[aria-current="page"]')).toHaveCSS("color", "rgb(240, 231, 212)"); +}); + +for (const width of [390, 1440]) { + test(`the hero wordmark travels into the header and reverses cleanly at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/"); + await page.keyboard.press("Escape"); + await page.evaluate(() => document.fonts.ready); + const title = page.locator("#home-heading"); + const brand = page.getByRole("link", { name: "MEDomicsLab homepage", exact: true }); + const start = await title.boundingBox(); + await expect(brand).toBeHidden(); + await page.evaluate(() => window.scrollTo(0, 160)); + await expect + .poll(async () => (await title.boundingBox()).width) + .toBeLessThan(start.width * 0.8); + const middle = await title.boundingBox(); + expect(middle.y).toBeLessThan(start.y); + expect(middle.width).toBeGreaterThan(150); + await page.evaluate(() => window.scrollTo(0, 400)); + await expect(page.locator(".neue-home-brand")).toHaveCSS("opacity", "1"); + const landed = await title.boundingBox(); + const destination = await page.locator(".site-brand-wordmark").boundingBox(); + expect(landed.x).toBeCloseTo(destination.x, 0); + expect(landed.y).toBeCloseTo(destination.y, 0); + expect(landed.width).toBeCloseTo(destination.width, 0); + await expect(title.locator("span").first()).toHaveCSS("opacity", "0"); + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(brand).toBeHidden(); + await expect.poll(async () => (await title.boundingBox()).width).toBeCloseTo(start.width, 0); + await expect(title.locator("span").first()).toHaveCSS("opacity", "1"); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.evaluate(() => window.scrollTo(0, 160)); + await expect.poll(async () => (await title.boundingBox()).width).toBeCloseTo(start.width, 0); + }); +} + +for (const width of [390, 1440]) { + test(`the footer reaches the viewport bottom without animation overflow at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + for (const path of ["/", "/research"]) { + await page.goto(path); + await page.keyboard.press("Escape"); + await page.evaluate(() => document.fonts.ready); + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect + .poll(() => + page + .locator(".neue-footer") + .evaluate((el) => + Math.abs( + document.documentElement.scrollHeight - + (el.getBoundingClientRect().bottom + window.scrollY) + ) + ) + ) + .toBeLessThan(1); + } + }); +} diff --git a/e2e/interior-pages.spec.js b/e2e/interior-pages.spec.js new file mode 100644 index 0000000..4d54720 --- /dev/null +++ b/e2e/interior-pages.spec.js @@ -0,0 +1,222 @@ +import { expect, test } from "@playwright/test"; +import teamData from "../src/data/team.json" with { type: "json" }; +import publicationsData from "../src/data/publications.json" with { type: "json" }; + +test.use({ viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" }); + +test("publication authors retain their order and use shared team profile lookup", async ({ + page, +}) => { + const publication = publicationsData + .flatMap((group) => group.items) + .find( + (item) => !item.redirect && item.contributors?.includes("Martin Vallières") && item.markdown + ); + await page.goto(`/publications/${publication.slug}`); + const authors = page.locator(".publication-detail h1 + p"); + await expect(authors).toHaveText(publication.contributors.join(", ")); + await expect( + authors.getByRole("link", { name: "Martin Vallières", exact: true }) + ).toHaveAttribute("href", "/team/martin-vallieres"); +}); + +test("detail pages share consistent missing-record states and return links", async ({ page }) => { + for (const [collection, title, label] of [ + ["/research", "Project", "Return to Index"], + ["/team", "Member", "Return to Team"], + ["/publications", "Publication", "Return to Publications"], + ["/community/news", "News", "Return to Index"], + ["/community/events", "Events", "Return to Index"], + ["/community/courses", "Course", "Return to Courses"], + ]) { + await page.goto(`${collection}/missing-record`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(`${title} Not Found`); + const back = page.locator("main").getByRole("link", { name: label, exact: true }); + await expect(back).toHaveAttribute("href", collection); + await back.click(); + await expect(page).toHaveURL(collection); + } +}); + +test("research filters retain their results and project Markdown and metadata", async ({ + page, +}) => { + await page.goto("/research"); + await expect(page.locator(".research-project")).toHaveCount(14); + const doctorate = page.getByRole("button", { name: "Doctorate 2", exact: true }); + await expect(doctorate).toHaveCSS("cursor", "pointer"); + await doctorate.click(); + await expect(doctorate).toHaveCSS("background-color", "rgb(229, 218, 195)"); + await expect(doctorate).toHaveCSS("color", "rgb(26, 26, 26)"); + await expect(page.locator(".research-project")).toHaveCount(2); + await expect(page.getByRole("button", { name: "Clear filters" })).toHaveCSS("cursor", "pointer"); + await page.getByRole("button", { name: "Clear filters" }).click(); + await expect(page.locator(".research-project")).toHaveCount(14); + await page.locator('main a[href="/research/medomics-platform"]').click(); + await expect(page.locator(".project-markdown")).toContainText("In progress (2020-today)"); + await expect( + page.locator(".project-metadata").getByRole("heading", { name: "Status", exact: true }) + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Researchers", exact: true })).toBeVisible(); +}); + +test("team keeps its cohort order and colour distinction with working profile links", async ({ + page, +}) => { + await page.goto("/team"); + const labels = await page.locator(".team-divider > span").allTextContents(); + expect(labels.slice(0, 2)).toEqual(["Lab Principal Investigator", "Current"]); + await expect(page.locator(".team-card-pi img")).toHaveCSS("filter", "none"); + const current = page.locator('[id="year-Current"] img').first(); + await expect(current).toHaveCSS("filter", "none"); + const formerCard = page.locator('[id="year-2025"] .team-card').first(); + await expect(formerCard.locator("img")).toHaveCSS("filter", "grayscale(1)"); + await formerCard.hover(); + await expect(formerCard.locator("img")).toHaveCSS("filter", "grayscale(0)"); + await formerCard.locator(".team-profile-link").click(); + await expect(page.locator(".member-detail h1")).toHaveText("Mariem Kallel"); + await expect(page.locator(".member-portrait img")).toHaveCSS("filter", "none"); +}); + +test("profiles keep every cohort portrait in color and abbreviate Martin's department", async ({ + page, +}) => { + for (const slug of ["martin-vallieres", "hakima-laribi", "mariem-kallel"]) { + await page.goto(`/team/${slug}`); + await expect(page.locator(".member-portrait img")).toHaveCSS("filter", "none"); + } + await page.setViewportSize({ width: 1706, height: 1000 }); + await page.goto("/team/martin-vallieres"); + await page.evaluate(() => document.fonts.ready); + const department = page.getByText("Dept. of Biomedical Engineering, McGill University", { + exact: true, + }); + await expect(department).toBeVisible(); + const affiliation = department.locator(".."); + expect( + await affiliation.evaluate( + (el) => el.getBoundingClientRect().height / parseFloat(getComputedStyle(el).lineHeight) + ) + ).toBe(1); +}); + +test("affiliation links come from member JSON and open official destinations in a new tab", async ({ + page, + context, +}) => { + const member = teamData + .flatMap((group) => group.members) + .find((member) => member.slug === "martin-vallieres"); + await page.goto(`/team/${member.slug}`); + const affiliations = page.locator(".member-affiliations"); + await expect(affiliations.getByRole("link")).toHaveCount(member.affiliations.length); + for (const affiliation of member.affiliations) { + const link = affiliations.getByRole("link", { name: affiliation.organization, exact: true }); + await expect(link).toHaveAttribute("href", affiliation.url); + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noreferrer"); + await expect(link).toHaveCSS("text-decoration-line", "underline"); + await link.focus(); + await expect(link).toHaveCSS("outline-style", "solid"); + await link.hover(); + await expect(link).toHaveCSS("color", "rgb(240, 231, 212)"); + } + const destination = member.affiliations[0].url; + await context.route(destination, (route) => + route.fulfill({ contentType: "text/html", body: "Official affiliation" }) + ); + const popupPromise = page.waitForEvent("popup"); + await affiliations.getByRole("link").first().click(); + const popup = await popupPromise; + await expect(popup).toHaveURL(destination); + await expect(page).toHaveURL(`/team/${member.slug}`); + await popup.close(); + + await page.goto("/team/hakima-laribi"); + await expect(page.locator(".member-affiliations")).toHaveCount(0); +}); + +test("member links use larger icons, wrap only when needed and keep missing links inert", async ({ + page, +}) => { + await page.goto("/team/martin-vallieres"); + const socials = page.locator(".member-socials"); + await expect(socials.locator("svg")).toHaveCount(6); + await expect(socials.getByRole("link")).toHaveCount(5); + await expect(socials.getByRole("img", { name: /ResearchGate.*not available/ })).toHaveCSS( + "cursor", + "auto" + ); + for (const icon of await socials.locator("svg").all()) { + await expect(icon).toHaveCSS("width", "32px"); + await expect(icon).toHaveCSS("height", "32px"); + } + for (const link of await socials.getByRole("link").all()) { + await expect(link).toHaveCSS("width", "44px"); + await expect(link).toHaveCSS("height", "44px"); + await expect(link).toHaveCSS("cursor", "pointer"); + } + await expect( + socials.getByRole("link", { name: "Martin Vallières — GitHub", exact: true }) + ).toHaveAttribute("href", "https://github.com/mvallieres"); + await expect( + socials.getByRole("link", { name: "Martin Vallières — Email", exact: true }) + ).toHaveAttribute("href", "mailto:martin.vallieres@mcgill.ca"); + await expect(page.getByRole("button", { name: "Copy email to clipboard" })).toHaveCSS( + "cursor", + "pointer" + ); + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 900 }); + const rows = await socials + .locator("li") + .evaluateAll((items) => new Set(items.map((item) => item.getBoundingClientRect().top)).size); + expect(rows).toBe(width === 320 ? 2 : 1); + expect( + await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) + ).toBeLessThanOrEqual(1); + } +}); + +test("publications keeps the year sidebar visible and enlarges headings after scroll", async ({ + page, +}) => { + await page.goto("/publications"); + const title = page.locator(".publication-year-title").first(); + await expect(title).toHaveCSS("font-weight", "600"); + const initialSize = await title.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + await page.locator(".section-ticks").getByRole("button", { name: "2024", exact: true }).click(); + await expect(page.locator(".section-ticks")).toHaveCSS("opacity", "1"); + await expect + .poll(() => title.evaluate((el) => parseFloat(getComputedStyle(el).fontSize))) + .toBeGreaterThan(initialSize); + await page.getByRole("button", { name: /^Preprints/ }).click(); + await expect(page.getByRole("button", { name: /^Preprints/ })).toHaveAttribute( + "aria-pressed", + "true" + ); + await expect(page.locator(".entry-row").first()).toBeVisible(); +}); + +test("shared footer, disabled Vision and narrow layouts work across routes", async ({ page }) => { + for (const path of [ + "/research", + "/team", + "/publications", + "/research/medomics-platform", + "/team/martin-vallieres", + "/community/news", + ]) { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(path); + await expect(page.locator("footer")).toHaveCount(1); + await expect(page.locator("footer")).toHaveClass(/neue-footer/); + await expect(page.getByRole("button", { name: "Visions", exact: true })).toBeDisabled(); + await expect(page.locator('a[href="/visions"]')).toHaveCount(0); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)) + .toBeLessThanOrEqual(1); + } + await page.goto("/visions"); + await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible(); +}); diff --git a/e2e/navbar.spec.js b/e2e/navbar.spec.js index c741cfa..6268741 100644 --- a/e2e/navbar.spec.js +++ b/e2e/navbar.spec.js @@ -2,7 +2,6 @@ import { expect, test } from "@playwright/test"; const mainLinks = [ ["Home", "/"], - ["Visions", "/visions"], ["Research", "/research"], ["Team", "/team"], ["Publications", "/publications"], @@ -15,7 +14,7 @@ const communityLinks = [ ]; test.describe("main navigation", () => { - test.use({ viewport: { width: 1440, height: 900 } }); + test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "reduce" }); test("renders the configured primary links and keeps the bar fixed while scrolling", async ({ page, @@ -29,8 +28,15 @@ test.describe("main navigation", () => { await expect(navigation.getByRole("link", { name: label })).toHaveAttribute("href", href); } - await navigation.getByRole("link", { name: "Visions" }).hover(); + await expect(navigation.getByRole("button", { name: "Visions" })).toBeDisabled(); + await expect(navigation.getByRole("button", { name: "Visions" })).toHaveCSS( + "cursor", + "not-allowed" + ); + await navigation.getByRole("link", { name: "Research" }).hover(); await expect(navigation.locator('[data-slot="motion-highlight"]')).toBeVisible(); + await page.mouse.move(0, 200); + await expect.poll(async () => (await navigation.boundingBox())?.y).toBeCloseTo(30, 1); const navBox = await navigation.boundingBox(); expect(navBox).not.toBeNull(); @@ -61,6 +67,8 @@ test.describe("main navigation", () => { await page.setViewportSize({ width, height: 667 }); await page.goto("/"); + await page.evaluate(() => window.scrollTo(0, 400)); + const navigation = page.getByRole("navigation", { name: "Main navigation" }); const brand = page.getByRole("link", { name: "MEDomicsLab homepage", exact: true }); await expect(navigation).toBeVisible(); @@ -69,12 +77,14 @@ test.describe("main navigation", () => { await expect(navigation.getByRole("link", { name: label })).toBeVisible(); } await expect(page.getByRole("button", { name: "Community" })).toBeVisible(); + await expect.poll(async () => (await brand.boundingBox())?.y).toBeCloseTo(16, 1); const navBox = await navigation.boundingBox(); const brandBox = await brand.boundingBox(); expect(navBox).not.toBeNull(); expect(brandBox).not.toBeNull(); expect(navBox.x + navBox.width / 2).toBeCloseTo(width / 2, 1); + expect(brandBox.x + brandBox.width / 2).toBeCloseTo(width / 2, 0); expect(brandBox.y + brandBox.height).toBeLessThanOrEqual(navBox.y); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); @@ -108,11 +118,12 @@ test.describe("main navigation", () => { test("supports keyboard control and retains the animated contact label", async ({ page }) => { await page.goto("/"); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "complete"); const communityButton = page.getByRole("button", { name: "Community" }); const menu = page.getByRole("menu"); - await communityButton.focus(); - await page.keyboard.press("ArrowDown"); + await communityButton.press("ArrowDown"); await expect(menu).toBeVisible(); await expect(menu.getByRole("menuitem").first()).toBeFocused(); @@ -125,4 +136,286 @@ test.describe("main navigation", () => { await expect(menu).toBeHidden(); await expect(communityButton).toBeFocused(); }); + + test("Community opens immediately and preserves its trigger and menu close delays", async ({ + page, + }) => { + await page.goto("/research"); + await page.evaluate(() => document.fonts.ready); + await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); + await page.clock.pauseAt(new Date("2026-01-01T00:00:10Z")); + + const trigger = page.getByRole("button", { name: "Community", exact: true }); + const menu = page.getByRole("menu", { name: "Community", exact: true }); + await trigger.hover(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await page.clock.runFor(60); + await page.mouse.move(0, 700); + await page.clock.runFor(999); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await page.clock.runFor(1); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + + await trigger.hover(); + await page.clock.runFor(60); + await menu.hover(); + await page.clock.runFor(1000); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await page.mouse.move(0, 700); + await page.clock.runFor(399); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await page.clock.runFor(1); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + for (const path of ["/", "/research", "/community/news"]) { + test(`dropdown links use homepage beige for hover and keyboard focus on ${path}`, async ({ + page, + }) => { + await page.goto(path); + const trigger = page.getByRole("button", { name: "Community", exact: true }); + await expect(trigger).toHaveCSS("cursor", "pointer"); + await trigger.hover(); + const menu = page.getByRole("menu", { name: "Community", exact: true }); + const item = menu.getByRole("menuitem").first(); + await item.hover(); + await expect(item).toHaveCSS("color", "rgb(240, 231, 212)"); + await expect(item).toHaveCSS("cursor", "pointer"); + await page.mouse.move(0, 300); + await trigger.press("ArrowDown"); + await expect(item).toBeFocused(); + await expect(item).toHaveCSS("color", "rgb(240, 231, 212)"); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + }); + } +}); + +test.describe("navigation shortcuts", () => { + test.use({ reducedMotion: "reduce" }); + + for (const width of [320, 390, 768, 1200, 1440]) { + test(`both shortcuts fit and GitHub opens on the first press at ${width}px`, async ({ + page, + context, + }) => { + await page.setViewportSize({ width, height: 900 }); + await context.route("https://github.com/**", (route) => + route.fulfill({ contentType: "text/html", body: "GitHub organization" }) + ); + for (const path of ["/", "/research"]) { + await page.goto(path); + if (path === "/") await page.keyboard.press("Escape"); + await page.evaluate(() => document.fonts.ready); + await page.mouse.move(0, 600); + const github = page.locator(".site-shortcuts").getByRole("link", { + name: path === "/" ? "GitHub Organization" : "GitHub", + exact: true, + }); + const ecosystem = page.getByRole("link", { name: "MEDomics Ecosystem", exact: true }); + await expect(ecosystem).toHaveAttribute("href", "/#ecosystem"); + await expect(github).not.toHaveAttribute("aria-haspopup"); + await expect + .poll(async () => { + const last = await page.locator(".liquid-ecosystem-anchor").boundingBox(); + return last.x + last.width; + }) + .toBeLessThanOrEqual(width); + const nav = await page.locator(".liquid-nav-anchor").boundingBox(); + const first = await page.locator(".liquid-github-anchor").boundingBox(); + const last = await page.locator(".liquid-ecosystem-anchor").boundingBox(); + expect(first.height).toBeGreaterThanOrEqual(44); + expect(nav.x + nav.width / 2).toBeCloseTo(width / 2, 0); + if (path === "/") { + const film = await page.locator(".neue-hero-film").boundingBox(); + expect((first.x + last.x + last.width) / 2).toBeCloseTo(film.x + film.width / 2, 0); + expect(last.y).toBeCloseTo(first.y, 0); + expect(first.x + first.width).toBeLessThan(last.x); + if (width <= 767) { + expect(first.y).toBeCloseTo(film.y + film.height + 16, 0); + expect(first.x).toBeGreaterThan(0); + expect(first.height).toBeGreaterThanOrEqual(54); + } else { + expect(first.y + first.height / 2).toBeCloseTo(film.y + film.height / 2, 0); + expect(first.x).toBeGreaterThan(film.x); + expect(last.x + last.width).toBeLessThan(film.x + film.width); + expect(first.height).toBeGreaterThanOrEqual(64); + } + } else if (width <= 767) { + expect(first.x + first.width / 2).toBeCloseTo(width / 2, 0); + expect(last.x + last.width / 2).toBeCloseTo(width / 2, 0); + expect(last.y + last.height).toBeCloseTo(900 - 16, 0); + expect(last.y - first.y - first.height).toBeCloseTo(8, 0); + expect(first.width).toBeLessThan(95); + } else { + expect(first.x + first.width).toBeCloseTo(width - 24, 0); + expect(last.x + last.width).toBeCloseTo(width - 24, 0); + expect(last.y - first.y - first.height).toBeCloseTo(8, 0); + } + await github.hover(); + await expect(page.getByRole("menu", { name: "MEDomicsLab apps" })).toHaveCount(0); + const popupPromise = page.waitForEvent("popup"); + await github.click(); + const popup = await popupPromise; + await expect(popup).toHaveURL("https://github.com/MEDomicsLab"); + await popup.close(); + } + }); + } }); + +test.describe("phone shortcut activity", () => { + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + + test("fades both shortcuts to 40% when idle and restores them on scroll or focus", async ({ + page, + context, + }) => { + await context.route("https://github.com/**", (route) => + route.fulfill({ contentType: "text/html", body: "GitHub organization" }) + ); + test.setTimeout(60_000); + await page.goto("/research"); + const shortcuts = page.locator(".liquid-action-anchor"); + for (const shortcut of await shortcuts.all()) { + await expect(shortcut).toHaveCSS("--glass-opacity", "0.4", { timeout: 8000 }); + } + await page.evaluate(() => window.scrollTo(0, 300)); + for (const shortcut of await shortcuts.all()) + await expect(shortcut).toHaveCSS("--glass-opacity", "1"); + for (const shortcut of await shortcuts.all()) { + await expect(shortcut).toHaveCSS("--glass-opacity", "0.4", { timeout: 8000 }); + } + const ecosystem = page.getByRole("link", { name: "MEDomics Ecosystem", exact: true }); + await ecosystem.focus(); + await expect(page.locator(".liquid-action-anchor:has(.liquid-ecosystem-anchor)")).toHaveCSS( + "--glass-opacity", + "1" + ); + await page.waitForTimeout(7100); + await expect(page.locator(".liquid-action-anchor:has(.liquid-ecosystem-anchor)")).toHaveCSS( + "--glass-opacity", + "1" + ); + await ecosystem.blur(); + await expect(page.locator(".liquid-action-anchor:has(.liquid-github-anchor)")).toHaveCSS( + "--glass-opacity", + "0.4", + { + timeout: 8000, + } + ); + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("link", { name: "GitHub", exact: true }).tap(); + const popup = await popupPromise; + await expect(popup).toHaveURL("https://github.com/MEDomicsLab"); + await popup.close(); + await ecosystem.tap(); + await expect(page).toHaveURL(/\/#ecosystem$/); + await expect + .poll(() => + page.locator("#ecosystem").evaluate((el) => Math.abs(el.getBoundingClientRect().top)) + ) + .toBeLessThan(2); + for (const shortcut of await shortcuts.all()) { + await expect(shortcut).toHaveCSS("--glass-opacity", "0.4", { timeout: 8000 }); + } + }); +}); + +for (const width of [390, 768, 1440]) { + test(`shortcuts wait five seconds before fading at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/research"); + await page.evaluate(() => document.fonts.ready); + await page.clock.install({ time: new Date("2026-10-02T12:00:00Z") }); + await page.clock.pauseAt(new Date("2026-10-02T12:00:01Z")); + await page.evaluate(() => window.dispatchEvent(new Event("scroll"))); + const shortcuts = page.locator(".liquid-action-anchor"); + await page.clock.fastForward(4999); + for (const shortcut of await shortcuts.all()) await expect(shortcut).not.toHaveClass(/is-idle/); + await page.clock.fastForward(1); + for (const shortcut of await shortcuts.all()) + await expect(shortcut).toHaveCSS("--glass-opacity", "0.4"); + await page.evaluate(() => window.dispatchEvent(new Event("scroll"))); + for (const shortcut of await shortcuts.all()) + await expect(shortcut).toHaveCSS("--glass-opacity", "1"); + }); +} + +test("both shortcuts use the same icon, label and arrow hover animation", async ({ page }) => { + await page.goto("/research"); + for (const label of ["GitHub", "MEDomics Ecosystem"]) { + const shortcut = page.getByRole("link", { name: label, exact: true }); + await shortcut.hover(); + await expect(shortcut.locator(".nav-action-slide__icon")).toHaveCSS("opacity", "0"); + await expect(shortcut.locator(".nav-action-slide__arrow")).toHaveCSS("opacity", "1"); + await expect(shortcut.locator(".nav-action-slide__text")).not.toHaveCSS("transform", "none"); + await page.mouse.move(0, 600); + await expect(shortcut.locator(".nav-action-slide__icon")).toHaveCSS("opacity", "1"); + await expect(shortcut.locator(".nav-action-slide__arrow")).toHaveCSS("opacity", "0"); + } +}); + +for (const width of [390, 1100, 1440]) { + test(`hero shortcuts stay visible, dock on scroll and return to the film at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/"); + await page.keyboard.press("Escape"); + await page.evaluate(() => document.fonts.ready); + await page.mouse.move(0, 600); + await page.clock.install({ time: new Date("2026-10-02T12:00:00Z") }); + await page.clock.pauseAt(new Date("2026-10-02T12:00:01Z")); + const shortcuts = page.locator(".liquid-action-anchor"); + await page.clock.fastForward(8000); + for (const shortcut of await shortcuts.all()) { + await expect(shortcut).not.toHaveClass(/is-idle/); + await expect(shortcut).toHaveCSS("--glass-opacity", "1"); + } + const initial = await page.locator(".liquid-github-anchor").boundingBox(); + await page.evaluate(() => window.scrollTo(0, 160)); + await page.clock.runFor(100); + const midway = await page.locator(".liquid-github-anchor").boundingBox(); + const mobile = width <= 767; + expect(midway.x).toBeGreaterThan(initial.x); + if (mobile) expect(midway.y).toBeGreaterThan(initial.y); + else expect(midway.y).toBeLessThan(initial.y); + await page.evaluate(() => + window.scrollTo(0, document.querySelector(".neue-hero").offsetHeight * 0.38 - 1) + ); + await page.clock.runFor(100); + const arriving = await page.locator(".liquid-github-anchor").boundingBox(); + expect(arriving.height).toBeLessThan(initial.height); + await page.evaluate(() => window.scrollTo(0, 400)); + await page.clock.runFor(100); + await expect(page.getByRole("link", { name: "GitHub", exact: true })).toBeVisible(); + const first = await page.locator(".liquid-github-anchor").boundingBox(); + const last = await page.locator(".liquid-ecosystem-anchor").boundingBox(); + expect(first.y).toBeCloseTo(arriving.y, 0); + if (mobile) { + expect(first.x + first.width / 2).toBeCloseTo(width / 2, 0); + expect(last.x + last.width / 2).toBeCloseTo(width / 2, 0); + expect(last.y + last.height).toBeCloseTo(900 - 16, 0); + } else { + expect(first.x + first.width).toBeCloseTo(width - 24, 0); + expect(last.x + last.width).toBeCloseTo(first.x + first.width, 0); + } + expect(last.y - first.y - first.height).toBeCloseTo(8, 0); + await page.clock.fastForward(5100); + for (const shortcut of await shortcuts.all()) await expect(shortcut).toHaveClass(/is-idle/); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.clock.runFor(200); + await expect( + page + .locator(".site-shortcuts") + .getByRole("link", { name: "GitHub Organization", exact: true }) + ).toBeVisible(); + const returned = await page.locator(".liquid-github-anchor").boundingBox(); + expect(returned.x).toBeCloseTo(initial.x, 0); + expect(returned.y).toBeCloseTo(initial.y, 0); + for (const shortcut of await shortcuts.all()) await expect(shortcut).not.toHaveClass(/is-idle/); + }); +} diff --git a/e2e/not-found.spec.js b/e2e/not-found.spec.js new file mode 100644 index 0000000..a8a135f --- /dev/null +++ b/e2e/not-found.spec.js @@ -0,0 +1,161 @@ +import { expect, test } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import sharp from "sharp"; +import theme from "../src/data/theme.json" with { type: "json" }; + +const path = "/this-route-does-not-exist"; +const blue = theme.cssVars["--tertiary"]; + +test.use({ viewport: { width: 1440, height: 1000 } }); + +test("404 keeps the full-screen pointer trail with homepage-blue digits", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(path); + const trail = page.locator(".text-trail"); + await expect(trail).toHaveAttribute("data-animated", "true"); + await expect(page.locator(".neue-interior-layout")).toHaveCSS( + "background-color", + "rgb(26, 26, 26)" + ); + await expect(page.getByRole("heading", { level: 1 })).toHaveCSS( + "font-family", + '"Homepage Neue Montreal", sans-serif' + ); + await expect(page.getByRole("link", { name: "Return home" })).toHaveCSS( + "color", + "rgb(240, 231, 212)" + ); + const box = await trail.boundingBox(); + expect(box.x).toBe(0); + expect(box.y).toBe(0); + expect(box.width).toBe(1440); + expect(box.height).toBeGreaterThanOrEqual(1000); + expect(await trail.evaluate((el) => el.style.color)).toBe("rgb(60, 82, 128)"); + + const first = await sharp(await trail.screenshot()) + .removeAlpha() + .raw() + .toBuffer(); + const rgb = blue.match(/\w\w/g).map((value) => Number.parseInt(value, 16)); + let bluePixels = 0; + for (let i = 0; i < first.length; i += 3) { + if (rgb.every((value, channel) => first[i + channel] === value)) bluePixels += 1; + } + expect(bluePixels).toBeGreaterThan(50000); + await page.mouse.move(box.width - 60, 160); + await page.waitForTimeout(700); + const second = await sharp(await trail.screenshot()) + .removeAlpha() + .raw() + .toBuffer(); + let changedPixels = 0; + for (let i = 0; i < first.length; i += 3) { + if (Math.abs(first[i + 2] - second[i + 2]) > 8) changedPixels += 1; + } + expect(changedPixels).toBeGreaterThan(1000); + expect(errors).toEqual([]); +}); + +test("reduced motion uses static blue digits and supports live preference changes", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(path); + const trail = page.locator(".text-trail"); + await expect(trail.locator("canvas")).toHaveCount(0); + await expect(trail.locator(".text-trail-static")).toBeVisible(); + await expect(trail).toHaveCSS("color", "rgb(60, 82, 128)"); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await expect(trail).toHaveAttribute("data-animated", "true"); + await expect(trail.locator("canvas")).toHaveCount(1); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(trail.locator("canvas")).toHaveCount(0); + await expect(trail.locator(".text-trail-static")).toBeVisible(); +}); + +test("unavailable WebGL keeps a usable static 404 and reports the fallback", async ({ page }) => { + const messages = []; + page.on("console", (message) => messages.push(message.text())); + await page.addInitScript(() => { + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (type, ...args) { + if (type === "webgl" || type === "webgl2" || type === "experimental-webgl") return null; + return getContext.call(this, type, ...args); + }; + }); + await page.goto(path); + await expect(page.locator(".text-trail-static")).toBeVisible(); + await expect(page.locator(".text-trail canvas")).toHaveCount(0); + await expect + .poll(() => messages.some((message) => message.includes("Showing static text."))) + .toBe(true); + await page.getByRole("link", { name: "Return home" }).click(); + await expect(page).toHaveURL("/"); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); +}); + +test("404 fits small screens, has an accessible home link and survives history navigation", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + for (const width of [320, 390, 768, 1024, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto(path); + await page.evaluate(() => document.fonts.ready); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)) + .toBeLessThanOrEqual(1); + const home = page.getByRole("link", { name: "Return home" }); + const box = await home.boundingBox(); + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(width); + await home.focus(); + await expect(home).toHaveCSS("outline-style", "solid"); + await expect(home).toHaveCSS("cursor", "pointer"); + } + await expect(page.locator(".not-found-art")).toHaveAttribute("aria-hidden", "true"); + const results = await new AxeBuilder({ page }) + .include(".not-found-copy") + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); + await page.getByRole("link", { name: "Return home" }).click(); + await expect(page).toHaveURL("/"); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.goBack(); + await expect(page).toHaveURL(path); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible(); +}); + +test("leaving before font loading finishes does not revive a disposed WebGL canvas", async ({ + page, +}) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.addInitScript(() => { + const load = document.fonts.load.bind(document.fonts); + window.finishTrailFont = () => {}; + document.fonts.load = (font, text) => { + if (font === '600 64px "Homepage Neue Montreal"') { + return new Promise((resolve) => { + window.finishTrailFont = () => load(font, text).then(resolve); + }); + } + return load(font, text); + }; + }); + await page.goto(path); + await expect(page.locator(".text-trail canvas")).toHaveCount(1); + await page + .getByRole("navigation", { name: "Main navigation" }) + .getByRole("link", { name: "Research", exact: true }) + .click(); + await page.evaluate(() => window.finishTrailFont()); + await expect(page).toHaveURL("/research"); + await expect(page.locator(".text-trail")).toHaveCount(0); + expect(errors).toEqual([]); +}); diff --git a/e2e/route-scroll.spec.js b/e2e/route-scroll.spec.js new file mode 100644 index 0000000..0404897 --- /dev/null +++ b/e2e/route-scroll.spec.js @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.route("https://api.github.com/**", (route) => + route.fulfill({ json: { stargazers_count: 1, forks_count: 1, open_issues_count: 0 } }) + ); +}); + +async function openHome(page) { + await page.goto("/"); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator(".neue-home")).toHaveAttribute("data-hero-intro", "complete", { + timeout: 8000, + }); +} + +async function scrollToBottom(page) { + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(500); + await page.waitForTimeout(300); +} + +async function expectAtTop(page, path) { + await expect(page).toHaveURL(new RegExp(`${path}$`)); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + await page.waitForTimeout(800); + expect(await page.evaluate(() => window.scrollY)).toBe(0); +} + +for (const reducedMotion of ["no-preference", "reduce"]) { + test.describe(`route scrolling with motion: ${reducedMotion}`, () => { + test.use({ viewport: { width: 1440, height: 1000 }, reducedMotion }); + + test("footer links open every new page at the top", async ({ page }) => { + await openHome(page); + for (const [label, path] of [ + ["Research", "/research"], + ["Team", "/team"], + ["Publications", "/publications"], + ["News", "/community/news"], + ["Contact us", "/community/contact"], + ["Home", "/"], + ]) { + await scrollToBottom(page); + await page.locator("footer").getByRole("link", { name: label, exact: true }).click(); + await expectAtTop(page, path); + } + }); + + for (const width of [1440, 375]) { + test(`navbar and back/forward reset settled bottom positions at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await openHome(page); + const navigation = page.getByRole("navigation", { name: "Main navigation" }); + for (const [label, path] of [ + ["Research", "/research"], + ["Team", "/team"], + ]) { + await scrollToBottom(page); + await navigation.getByRole("link", { name: label, exact: true }).click(); + await expectAtTop(page, path); + } + for (const path of ["/research", "/"]) { + await scrollToBottom(page); + await page.goBack(); + await expectAtTop(page, path); + } + for (const path of ["/research", "/team"]) { + await scrollToBottom(page); + await page.goForward(); + await expectAtTop(page, path); + } + }); + } + + test("navigation interrupts a smooth scroll without dragging the next page down", async ({ + page, + }) => { + await openHome(page); + await page.mouse.move(100, 400); + await page.mouse.wheel(0, 3000); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + await page + .getByRole("navigation", { name: "Main navigation" }) + .getByRole("link", { name: "Research", exact: true }) + .click(); + await expectAtTop(page, "/research"); + }); + + test("detail links reset the page without breaking in-page section navigation", async ({ + page, + }) => { + await page.goto("/team"); + await page + .locator(".section-ticks") + .getByRole("button", { name: "2025", exact: true }) + .click(); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(500); + await page.locator('[id="year-2025"] .team-profile-link').first().click(); + await expectAtTop(page, "/team/mariem-kallel"); + await page.getByRole("link", { name: "Back to Team", exact: true }).click(); + await expectAtTop(page, "/team"); + }); + + test("the ecosystem shortcut reaches its section from another route and on repeat visits", async ({ + page, + }) => { + for (const path of ["/research", "/"]) { + await page.goto(path); + if (path === "/") await page.keyboard.press("Escape"); + const shortcut = page.getByRole("link", { name: "MEDomics Ecosystem", exact: true }); + await shortcut.click(); + await expect(page).toHaveURL(/\/#ecosystem$/); + const distance = () => + page.locator("#ecosystem").evaluate((el) => Math.abs(el.getBoundingClientRect().top)); + await expect.poll(distance).toBeLessThan(2); + await page.waitForTimeout(800); + expect(await distance()).toBeLessThan(2); + await page.evaluate(() => window.scrollTo(0, 0)); + await shortcut.press("Enter"); + await expect.poll(distance).toBeLessThan(2); + } + await page.reload(); + await expect + .poll(() => + page.locator("#ecosystem").evaluate((el) => Math.abs(el.getBoundingClientRect().top)) + ) + .toBeLessThan(2); + }); + + test("the homepage anchor still reaches the lab before navigating to a new page", async ({ + page, + }) => { + await openHome(page); + await page.getByRole("link", { name: /Discover the laboratory/ }).click(); + await expect(page).toHaveURL(/\/#lab$/); + await expect + .poll(() => page.locator("#lab").evaluate((el) => el.getBoundingClientRect().top)) + .toBe(0); + await page.waitForTimeout(800); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(500); + await page + .getByRole("navigation", { name: "Main navigation" }) + .getByRole("link", { name: "Research", exact: true }) + .click(); + await expectAtTop(page, "/research"); + }); + }); +} diff --git a/e2e/seo.spec.js b/e2e/seo.spec.js index d1ff8c7..20526b5 100644 --- a/e2e/seo.spec.js +++ b/e2e/seo.spec.js @@ -1,40 +1,264 @@ import { expect, test } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { indexableRoutes, siteUrl, getPageMetadata, getSiteRoute } from "../src/lib/seo.js"; +import seo from "../src/data/seo.json" with { type: "json" }; +import layout from "../src/data/layout.json" with { type: "json" }; -const cases = [ - { - path: "/", - title: /Home \| MEDomicsLab/, - canonical: "https://medomicslab.com/", - schemaType: "Organization", - }, - { - path: "/research/medomics-platform", - title: /Development of the MEDomics platform \| MEDomicsLab/, - canonical: "https://medomicslab.com/research/medomics-platform", - schemaType: "ResearchProject", - }, - { - path: "/publications/medfl-a-collaborative-framework-for-federated-learning-in-medicine-2025", - title: /MEDfl: A Collaborative Framework for Federated Learning in Medicine \| MEDomicsLab/, - canonical: - "https://medomicslab.com/publications/medfl-a-collaborative-framework-for-federated-learning-in-medicine-2025", - schemaType: "ScholarlyArticle", - }, -]; - -for (const { path, title, canonical, schemaType } of cases) { - test(`SEO metadata: ${path}`, async ({ page }) => { - await page.goto(path, { waitUntil: "domcontentloaded" }); - - await expect(page).toHaveTitle(title); - await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", canonical); - await expect(page.locator('meta[name="description"]')).toHaveAttribute("content", /\S+/); - await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( - "content", - "https://medomicslab.com/images/homepage.jpg" +async function parseHtml(page, html) { + return page.evaluate((source) => { + const doc = new DOMParser().parseFromString(source, "text/html"); + return { + titles: [...doc.querySelectorAll("title")].map((node) => node.textContent), + descriptions: [...doc.querySelectorAll('meta[name="description"]')].map( + (node) => node.content + ), + canonicals: [...doc.querySelectorAll('link[rel="canonical"]')].map((node) => + node.getAttribute("href") + ), + robots: doc.querySelector('meta[name="robots"]')?.content, + image: doc.querySelector('meta[property="og:image"]')?.content, + schema: [...doc.querySelectorAll('script[type="application/ld+json"]')].map((node) => + JSON.parse(node.textContent) + ), + headings: [...doc.querySelectorAll("#root h1")].map((node) => node.textContent), + article: doc.querySelector(".prose")?.textContent, + root: doc.querySelector("#root")?.textContent, + pageData: JSON.parse(doc.querySelector("#page-data")?.textContent ?? "{}"), + refresh: doc.querySelector('meta[http-equiv="refresh"]')?.content, + }; + }, html); +} + +test("every indexable URL ships unique metadata and its content without running JavaScript", async ({ + request, + page, +}) => { + test.slow(); + const titles = new Set(); + const descriptions = new Set(); + const images = new Set(); + for (const route of indexableRoutes) { + const response = await request.get(route.path); + expect(response.status(), route.path).toBe(200); + const html = await response.text(); + expect(html, route.path).not.toContain(" - - - -
+
+ diff --git a/knip.json b/knip.json index 7bda875..6104d01 100644 --- a/knip.json +++ b/knip.json @@ -1,19 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/main.jsx"], + "entry": ["src/prerender.jsx", "src/index.css"], "project": ["src/**/*.{js,jsx}"], - "ignore": ["src/components/ui/**", "src/data/**", "src/content/**"], - "ignoreDependencies": [ - "tw-animate-css", - "@radix-ui/react-accordion", - "@radix-ui/react-menubar", - "@radix-ui/react-navigation-menu", - "@radix-ui/react-scroll-area", - "@radix-ui/react-separator", - "@radix-ui/react-slot", - "@radix-ui/react-tabs", - "class-variance-authority", - "sharp" - ], "ignoreExportsUsedInFile": true } diff --git a/package-lock.json b/package-lock.json index 6c52899..d103c7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,6 @@ "license": "MIT", "dependencies": { "@mux/mux-player-react": "^3.10.2", - "@radix-ui/react-accordion": "^1.2.2", - "@radix-ui/react-menubar": "^1.1.2", - "@radix-ui/react-navigation-menu": "^1.2.2", - "@radix-ui/react-scroll-area": "^1.2.1", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", - "@use-gesture/react": "^10.3.1", - "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "framer-motion": "^11.15.0", "gsap": "^3.14.2", @@ -60,10 +51,8 @@ "sharp": "^0.34.5", "size-limit": "^12.1.0", "tailwindcss": "^4.0.3", - "tw-animate-css": "^1.2.0", "vite": "^5.4.2", - "vite-bundle-visualizer": "^1.2.1", - "vite-plugin-sitemap": "^0.8.2" + "vite-bundle-visualizer": "^1.2.1" } }, "node_modules/@axe-core/playwright": { @@ -967,44 +956,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.4" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -2664,966 +2615,128 @@ "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@puppeteer/browsers/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@puppeteer/browsers/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@puppeteer/browsers/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/@puppeteer/browsers/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", - "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", - "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", - "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=10" } }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "node_modules/@puppeteer/browsers/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "ansi-regex": "^5.0.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=10" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "node_modules/@puppeteer/browsers/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/@remix-run/router": { "version": "1.23.2", @@ -4545,24 +3658,6 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", - "license": "MIT" - }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", - "license": "MIT", - "dependencies": { - "@use-gesture/core": "10.3.1" - }, - "peerDependencies": { - "react": ">= 16.8.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -4742,18 +3837,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -5506,18 +4589,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, "node_modules/cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -6052,12 +5123,6 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -7436,15 +6501,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -12228,53 +11284,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -12307,28 +11316,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/read-installed-packages": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/read-installed-packages/-/read-installed-packages-2.0.1.tgz", @@ -14236,16 +13223,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tw-animate-css": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", - "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Wombosvideo" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -14575,49 +13552,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14797,12 +13731,6 @@ "node": ">=14.14" } }, - "node_modules/vite-plugin-sitemap": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/vite-plugin-sitemap/-/vite-plugin-sitemap-0.8.2.tgz", - "integrity": "sha512-bqIw6NVOXg6je81lzX8Lm0vjf8/QSAp8di8fYQzZ3ZdVicOm8+6idBGALJiy1R1FiXNIK8rgORO6HBqXyHW+iQ==", - "dev": true - }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", diff --git a/package.json b/package.json index fd5280a..578900f 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "license": "MIT", "scripts": { "dev": "vite", - "build": "vite build", - "build:strict": "vite build --logLevel info", + "build": "node scripts/build.mjs", + "build:strict": "npm run build", "build:analyze": "vite-bundle-visualizer -o dist/stats.html", "preview": "vite preview", "lint": "eslint .", @@ -26,6 +26,7 @@ "audit:deps": "npm audit --omit=dev --audit-level=high", "test:e2e": "playwright test", "test:seo": "playwright test e2e/seo.spec.js", + "check:seo": "npm run build && npm run test:seo -- --workers=2 && node scripts/check-seo.mjs", "test:e2e:install": "playwright install --with-deps chromium", "test:a11y": "playwright test e2e/a11y.spec.js", "lhci": "lhci autorun --config=.github/lighthouserc.json", @@ -47,15 +48,6 @@ ], "dependencies": { "@mux/mux-player-react": "^3.10.2", - "@radix-ui/react-accordion": "^1.2.2", - "@radix-ui/react-menubar": "^1.1.2", - "@radix-ui/react-navigation-menu": "^1.2.2", - "@radix-ui/react-scroll-area": "^1.2.1", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", - "@use-gesture/react": "^10.3.1", - "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "framer-motion": "^11.15.0", "gsap": "^3.14.2", @@ -97,9 +89,7 @@ "sharp": "^0.34.5", "size-limit": "^12.1.0", "tailwindcss": "^4.0.3", - "tw-animate-css": "^1.2.0", "vite": "^5.4.2", - "vite-bundle-visualizer": "^1.2.1", - "vite-plugin-sitemap": "^0.8.2" + "vite-bundle-visualizer": "^1.2.1" } } diff --git a/public/fonts/NeueMontreal/PPNeueMontreal-Extrabold.otf b/public/fonts/NeueMontreal/PPNeueMontreal-Extrabold.otf new file mode 100644 index 0000000..250ad64 Binary files /dev/null and b/public/fonts/NeueMontreal/PPNeueMontreal-Extrabold.otf differ diff --git a/public/fonts/NeueMontreal/PPNeueMontreal-Hairline.otf b/public/fonts/NeueMontreal/PPNeueMontreal-Hairline.otf new file mode 100644 index 0000000..a21360e Binary files /dev/null and b/public/fonts/NeueMontreal/PPNeueMontreal-Hairline.otf differ diff --git a/public/fonts/NeueMontreal/PPNeueMontreal-Light.otf b/public/fonts/NeueMontreal/PPNeueMontreal-Light.otf new file mode 100644 index 0000000..8553b0d Binary files /dev/null and b/public/fonts/NeueMontreal/PPNeueMontreal-Light.otf differ diff --git a/public/fonts/NeueMontreal/PPNeueMontreal-LightItalic.otf b/public/fonts/NeueMontreal/PPNeueMontreal-LightItalic.otf new file mode 100644 index 0000000..13dac0d Binary files /dev/null and b/public/fonts/NeueMontreal/PPNeueMontreal-LightItalic.otf differ diff --git a/public/fonts/NeueMontreal/PPNeueMontreal-Regular.otf b/public/fonts/NeueMontreal/PPNeueMontreal-Regular.otf new file mode 100644 index 0000000..9f384c5 Binary files /dev/null and b/public/fonts/NeueMontreal/PPNeueMontreal-Regular.otf differ diff --git a/public/fonts/NeueMontreal/PPNeueMontreal-Semibold.otf b/public/fonts/NeueMontreal/PPNeueMontreal-Semibold.otf new file mode 100644 index 0000000..be6a49b Binary files /dev/null and b/public/fonts/NeueMontreal/PPNeueMontreal-Semibold.otf differ diff --git a/public/fonts/Roobert/Roobert-Bold.otf b/public/fonts/Roobert/Roobert-Bold.otf deleted file mode 100644 index e4e6839..0000000 Binary files a/public/fonts/Roobert/Roobert-Bold.otf and /dev/null differ diff --git a/public/fonts/Roobert/Roobert-Medium.otf b/public/fonts/Roobert/Roobert-Medium.otf deleted file mode 100644 index c219443..0000000 Binary files a/public/fonts/Roobert/Roobert-Medium.otf and /dev/null differ diff --git a/public/fonts/Roobert/Roobert-Regular.otf b/public/fonts/Roobert/Roobert-Regular.otf deleted file mode 100644 index f7d65a4..0000000 Binary files a/public/fonts/Roobert/Roobert-Regular.otf and /dev/null differ diff --git a/public/fonts/Roobert/Roobert-SemiBold.otf b/public/fonts/Roobert/Roobert-SemiBold.otf deleted file mode 100644 index a8c9463..0000000 Binary files a/public/fonts/Roobert/Roobert-SemiBold.otf and /dev/null differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/about-mila.jpg b/public/images/albums/2026-09-29-mila-digital-health/about-mila.jpg new file mode 100644 index 0000000..80a1efd Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/about-mila.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/entrance-event.jpg b/public/images/albums/2026-09-29-mila-digital-health/entrance-event.jpg new file mode 100644 index 0000000..be6d40b Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/entrance-event.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/featured.jpg b/public/images/albums/2026-09-29-mila-digital-health/featured.jpg new file mode 100644 index 0000000..fb20810 Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/featured.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/honourable-mention-to-martin-vallieres.jpg b/public/images/albums/2026-09-29-mila-digital-health/honourable-mention-to-martin-vallieres.jpg new file mode 100644 index 0000000..83b68b0 Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/honourable-mention-to-martin-vallieres.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/promotion-1.jpg b/public/images/albums/2026-09-29-mila-digital-health/promotion-1.jpg new file mode 100644 index 0000000..00a5179 Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/promotion-1.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/round-table-1.jpg b/public/images/albums/2026-09-29-mila-digital-health/round-table-1.jpg new file mode 100644 index 0000000..78631d5 Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/round-table-1.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/round-table-2.jpg b/public/images/albums/2026-09-29-mila-digital-health/round-table-2.jpg new file mode 100644 index 0000000..8392186 Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/round-table-2.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/talk-1.jpg b/public/images/albums/2026-09-29-mila-digital-health/talk-1.jpg new file mode 100644 index 0000000..573eede Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/talk-1.jpg differ diff --git a/public/images/albums/2026-09-29-mila-digital-health/talk-2.jpg b/public/images/albums/2026-09-29-mila-digital-health/talk-2.jpg new file mode 100644 index 0000000..0cd1b14 Binary files /dev/null and b/public/images/albums/2026-09-29-mila-digital-health/talk-2.jpg differ diff --git a/public/images/albums/research-MED3pa/objectives.drawio.svg b/public/images/albums/research-MED3pa/objectives.drawio.svg deleted file mode 100644 index 657d5b0..0000000 --- a/public/images/albums/research-MED3pa/objectives.drawio.svg +++ /dev/null @@ -1,753 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - -TRAIN set - - -Train - - -Imbalance - - -correction - - - - - - - - -TEST set - - -Test - - - - -Decision tree - - - - - - - - - - - - - - - - - - - - -Profil 1 - - - - - - - - - - - - - - -Profil 2 - - - - -1.a - - - - -2.a - - - - - -SL - - -TRAIN - - - - - - - - - -Train - - -Imbalance - - -correction - - - - - - - - -Test - - - - -Decision tree - - - - - - - - - - - - - - - - - - - - -Profil 1 - - - - - - - - - - - - - - -Profil 2 - - - - -1.a - - - - -1.b - - - - -2.b - - - - -Decision tree - - - - - - - - - - - - - - - - - - - - -Profil 1 - - - - - - - - - - - - - - -Profil 2 - - - - - -... - - -Node 1 - - -Node K - - -... - - - - - - - - - - - - - - - - -Distributed network - - - - - -SL - - -DistTRAIN - - - - - - - - - - - - - - - - -Distributed network - - - - - - - - - -Train - - -Imbalance - - -correction - - - - - - - - -Test - - - - -Decision tree - - - - - - - - - - - - - - - - - - -Profil 1 - - - - - - - - - - - - - - -Profil 2 - - - - -1.a - - - - -1.b - - - - -2.b - - - - -Decision tree - - - - - - - - - - - - - - - - - - - - -Profil 1 - - - - - - - - - - - - - - -Profil 2 - - - - - -... - - -Node 1 - - -Node K - - -... - - - - - - - - - - - - - - - - -Distributed network - - - - - -SL - - -DistTRAIN - - - - - - - - - - - - - - - - -Distributed network - - - - - - -3. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -1.b - - - diff --git a/public/images/albums/research-MED3pa/sl_tree.drawio.svg b/public/images/albums/research-MED3pa/sl_tree.drawio.svg deleted file mode 100644 index d9e81d6..0000000 --- a/public/images/albums/research-MED3pa/sl_tree.drawio.svg +++ /dev/null @@ -1,215 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -Train set - - - - - -Test set - - - - - - -TEST - - - - -Super - - -learner - - -Mortality - - - - - - -TRAIN - - - - -1. - - - - -2. - - - - -Predicted labels - - -of Test set - - - - - - - -Misclassified - - - - -Predicted labels - - -of Test set - - - - -True labels of - - -Test set - - - -0 if predicted label == true - - -label - - -1 if predicted label <> true - - -label - - - - - -3. - - - - - -Test set - - - - - - -TRAIN - - - - - - - -Decision - - - - -tree - - - - - - - -Misclassified - - - - -Decision tree - - - - -High - - -misclassified - - -ratio - - - - - -Low - - -misclassified - - -ratio - - - - - - - - - - - - - - - -Profil - - -1 - - - - - - - - - - - - - - -Profil - - -2 - - - diff --git a/public/images/albums/research-medomics_platform/medomicslab.jpg b/public/images/albums/research-medomics_platform/medomicslab.jpg deleted file mode 100644 index c6c8e72..0000000 Binary files a/public/images/albums/research-medomics_platform/medomicslab.jpg and /dev/null differ diff --git a/public/images/albums/visions/axis_1.png b/public/images/albums/visions/axis_1.png deleted file mode 100644 index a44616b..0000000 Binary files a/public/images/albums/visions/axis_1.png and /dev/null differ diff --git a/public/images/albums/visions/axis_2.png b/public/images/albums/visions/axis_2.png deleted file mode 100644 index 26f1744..0000000 Binary files a/public/images/albums/visions/axis_2.png and /dev/null differ diff --git a/public/images/albums/visions/axis_3.png b/public/images/albums/visions/axis_3.png deleted file mode 100644 index 3d6596e..0000000 Binary files a/public/images/albums/visions/axis_3.png and /dev/null differ diff --git a/public/images/albums/visions/main_context.png b/public/images/albums/visions/main_context.png deleted file mode 100644 index 193afa9..0000000 Binary files a/public/images/albums/visions/main_context.png and /dev/null differ diff --git a/public/images/albums/visions/mission.png b/public/images/albums/visions/mission.png deleted file mode 100644 index 1138461..0000000 Binary files a/public/images/albums/visions/mission.png and /dev/null differ diff --git a/public/images/homepage-neue/MED3pa-white.png b/public/images/homepage-neue/MED3pa-white.png new file mode 100644 index 0000000..52a006f Binary files /dev/null and b/public/images/homepage-neue/MED3pa-white.png differ diff --git a/public/images/homepage-neue/MEDfl-white.png b/public/images/homepage-neue/MEDfl-white.png new file mode 100644 index 0000000..d0a24d2 Binary files /dev/null and b/public/images/homepage-neue/MEDfl-white.png differ diff --git a/public/images/homepage-neue/MEDiml-white.png b/public/images/homepage-neue/MEDiml-white.png new file mode 100644 index 0000000..2eba736 Binary files /dev/null and b/public/images/homepage-neue/MEDiml-white.png differ diff --git a/public/images/homepage-neue/MEDomics-white.png b/public/images/homepage-neue/MEDomics-white.png new file mode 100644 index 0000000..0e54ac1 Binary files /dev/null and b/public/images/homepage-neue/MEDomics-white.png differ diff --git a/public/images/homepage-neue/MEDomicsLab-white.png b/public/images/homepage-neue/MEDomicsLab-white.png new file mode 100644 index 0000000..5975deb Binary files /dev/null and b/public/images/homepage-neue/MEDomicsLab-white.png differ diff --git a/public/images/homepage-neue/MEDprofiles-white.png b/public/images/homepage-neue/MEDprofiles-white.png new file mode 100644 index 0000000..82ef63b Binary files /dev/null and b/public/images/homepage-neue/MEDprofiles-white.png differ diff --git a/public/images/homepage-neue/branding-blue-texture.png b/public/images/homepage-neue/branding-blue-texture.png new file mode 100644 index 0000000..a853243 Binary files /dev/null and b/public/images/homepage-neue/branding-blue-texture.png differ diff --git a/public/images/homepage-neue/ecosystem/med3pa.webp b/public/images/homepage-neue/ecosystem/med3pa.webp new file mode 100644 index 0000000..e032a80 Binary files /dev/null and b/public/images/homepage-neue/ecosystem/med3pa.webp differ diff --git a/public/images/homepage-neue/ecosystem/medfl.webp b/public/images/homepage-neue/ecosystem/medfl.webp new file mode 100644 index 0000000..216efb8 Binary files /dev/null and b/public/images/homepage-neue/ecosystem/medfl.webp differ diff --git a/public/images/homepage-neue/ecosystem/mediml.webp b/public/images/homepage-neue/ecosystem/mediml.webp new file mode 100644 index 0000000..1bdefe6 Binary files /dev/null and b/public/images/homepage-neue/ecosystem/mediml.webp differ diff --git a/public/images/homepage-neue/ecosystem/medomics.webp b/public/images/homepage-neue/ecosystem/medomics.webp new file mode 100644 index 0000000..3257f50 Binary files /dev/null and b/public/images/homepage-neue/ecosystem/medomics.webp differ diff --git a/public/images/homepage-neue/ecosystem/medprofiles.webp b/public/images/homepage-neue/ecosystem/medprofiles.webp new file mode 100644 index 0000000..2c03bd7 Binary files /dev/null and b/public/images/homepage-neue/ecosystem/medprofiles.webp differ diff --git a/public/images/homepage-neue/next/01-medomics-platform.webp b/public/images/homepage-neue/next/01-medomics-platform.webp new file mode 100644 index 0000000..709956c Binary files /dev/null and b/public/images/homepage-neue/next/01-medomics-platform.webp differ diff --git a/public/images/homepage-neue/next/02-mediml.webp b/public/images/homepage-neue/next/02-mediml.webp new file mode 100644 index 0000000..354d673 Binary files /dev/null and b/public/images/homepage-neue/next/02-mediml.webp differ diff --git a/public/images/homepage-neue/next/03-med3pa.webp b/public/images/homepage-neue/next/03-med3pa.webp new file mode 100644 index 0000000..1d57631 Binary files /dev/null and b/public/images/homepage-neue/next/03-med3pa.webp differ diff --git a/public/images/homepage-neue/next/04-multilevel-graphs.webp b/public/images/homepage-neue/next/04-multilevel-graphs.webp new file mode 100644 index 0000000..2c7f1b0 Binary files /dev/null and b/public/images/homepage-neue/next/04-multilevel-graphs.webp differ diff --git a/public/images/homepage-neue/next/05-medomicslab-optimization.webp b/public/images/homepage-neue/next/05-medomicslab-optimization.webp new file mode 100644 index 0000000..d2a7dea Binary files /dev/null and b/public/images/homepage-neue/next/05-medomicslab-optimization.webp differ diff --git a/public/images/homepage-neue/next/06-distributed-learning-inference.webp b/public/images/homepage-neue/next/06-distributed-learning-inference.webp new file mode 100644 index 0000000..408b593 Binary files /dev/null and b/public/images/homepage-neue/next/06-distributed-learning-inference.webp differ diff --git a/public/images/homepage-neue/next/07-distributed-random-forests.webp b/public/images/homepage-neue/next/07-distributed-random-forests.webp new file mode 100644 index 0000000..8904d92 Binary files /dev/null and b/public/images/homepage-neue/next/07-distributed-random-forests.webp differ diff --git a/public/images/homepage-neue/next/08-energai.webp b/public/images/homepage-neue/next/08-energai.webp new file mode 100644 index 0000000..0d87bba Binary files /dev/null and b/public/images/homepage-neue/next/08-energai.webp differ diff --git a/public/images/homepage-neue/next/09-kidney-small-tumors.webp b/public/images/homepage-neue/next/09-kidney-small-tumors.webp new file mode 100644 index 0000000..53bf3a3 Binary files /dev/null and b/public/images/homepage-neue/next/09-kidney-small-tumors.webp differ diff --git a/public/images/homepage-neue/next/10-multitask-classification.webp b/public/images/homepage-neue/next/10-multitask-classification.webp new file mode 100644 index 0000000..1515c40 Binary files /dev/null and b/public/images/homepage-neue/next/10-multitask-classification.webp differ diff --git a/public/images/homepage-neue/next/11-patient-mortality-prediction.webp b/public/images/homepage-neue/next/11-patient-mortality-prediction.webp new file mode 100644 index 0000000..843ac68 Binary files /dev/null and b/public/images/homepage-neue/next/11-patient-mortality-prediction.webp differ diff --git a/public/images/homepage-neue/next/12-prostate-models.webp b/public/images/homepage-neue/next/12-prostate-models.webp new file mode 100644 index 0000000..07ae954 Binary files /dev/null and b/public/images/homepage-neue/next/12-prostate-models.webp differ diff --git a/public/images/homepage-neue/next/13-radiomics-complexity.webp b/public/images/homepage-neue/next/13-radiomics-complexity.webp new file mode 100644 index 0000000..b9d795c Binary files /dev/null and b/public/images/homepage-neue/next/13-radiomics-complexity.webp differ diff --git a/public/images/homepage-neue/next/14-vo2max-obesity.webp b/public/images/homepage-neue/next/14-vo2max-obesity.webp new file mode 100644 index 0000000..5f1481c Binary files /dev/null and b/public/images/homepage-neue/next/14-vo2max-obesity.webp differ diff --git a/public/images/homepage-neue/paper-texture.webp b/public/images/homepage-neue/paper-texture.webp new file mode 100644 index 0000000..ffe7002 Binary files /dev/null and b/public/images/homepage-neue/paper-texture.webp differ diff --git a/public/images/logo/institutes/ladydavis.png b/public/images/logo/institutes/ladydavis.png new file mode 100644 index 0000000..181363e Binary files /dev/null and b/public/images/logo/institutes/ladydavis.png differ diff --git a/public/images/logo/institutes/mcgill-university-crest.webp b/public/images/logo/institutes/mcgill-university-crest.webp new file mode 100644 index 0000000..09a0e2c Binary files /dev/null and b/public/images/logo/institutes/mcgill-university-crest.webp differ diff --git a/public/images/logo/institutes/mila-mark.svg b/public/images/logo/institutes/mila-mark.svg new file mode 100644 index 0000000..fc85fc9 --- /dev/null +++ b/public/images/logo/institutes/mila-mark.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/logo/institutes/ri-muhc.svg b/public/images/logo/institutes/ri-muhc.svg new file mode 100644 index 0000000..28b7a66 --- /dev/null +++ b/public/images/logo/institutes/ri-muhc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/logo/institutes/rsn.png b/public/images/logo/institutes/rsn.png new file mode 100644 index 0000000..d6ca9be Binary files /dev/null and b/public/images/logo/institutes/rsn.png differ diff --git a/public/images/logo/institutes/universite-sherbrooke-crest.svg b/public/images/logo/institutes/universite-sherbrooke-crest.svg new file mode 100644 index 0000000..a630e62 --- /dev/null +++ b/public/images/logo/institutes/universite-sherbrooke-crest.svg @@ -0,0 +1,5306 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/public/images/logo/medomicslab.png b/public/images/logo/medomicslab.png index f6006bc..a96dee3 100644 Binary files a/public/images/logo/medomicslab.png and b/public/images/logo/medomicslab.png differ diff --git a/public/images/noise.svg b/public/images/noise.svg deleted file mode 100644 index a4611fe..0000000 --- a/public/images/noise.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..bb3cabd --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,64 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "vite"; +import theme from "../src/data/theme.json" with { type: "json" }; +import { indexableRoutes, redirects, siteUrl } from "../src/lib/seo.js"; + +process.env.NODE_ENV = "production"; +await build(); +const cacheDir = resolve("node_modules/.cache"); +await mkdir(cacheDir, { recursive: true }); +const serverDir = await mkdtemp(resolve(cacheDir, "medomics-prerender-")); +try { + await build({ + build: { + ssr: "src/prerender.jsx", + copyPublicDir: false, + outDir: serverDir, + rollupOptions: { output: { entryFileNames: "prerender.mjs" } }, + }, + }); + const { render } = await import(pathToFileURL(resolve(serverDir, "prerender.mjs"))); + const template = await readFile("dist/index.html", "utf8"); + for (const marker of ["", "", ""]) { + if (!template.includes(marker)) throw new Error(`Missing prerender template marker: ${marker}`); + } + const variables = Object.entries(theme.cssVars) + .map(([name, value]) => `${name}:${value}`) + .join(";"); + for (const path of [ + ...indexableRoutes.map((route) => route.path), + ...redirects.map((route) => route.path), + "/404", + ]) { + const { html, head, markdown } = await render(path); + const data = JSON.stringify(markdown).replace(/", () => `${head}\n`) + .replace("", () => html) + .replace( + "", + () => `` + ); + const file = resolve("dist", path === "/" ? "index.html" : `${path.slice(1)}.html`); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, page); + } + const urls = indexableRoutes.map( + ({ path }) => ` ${new URL(path, siteUrl).href.replace(/&/g, "&")}` + ); + await writeFile( + "dist/sitemap.xml", + `\n\n${urls.join("\n")}\n\n` + ); + await writeFile( + "dist/robots.txt", + `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n` + ); + console.log( + `Prerendered ${indexableRoutes.length} public pages, ${redirects.length} redirects, and the 404 page.` + ); +} finally { + await rm(serverDir, { recursive: true, force: true }); +} diff --git a/scripts/check-seo.mjs b/scripts/check-seo.mjs new file mode 100644 index 0000000..3d42ae3 --- /dev/null +++ b/scripts/check-seo.mjs @@ -0,0 +1,50 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { chromium } from "@playwright/test"; +import { preview } from "vite"; +import { indexableRoutes } from "../src/lib/seo.js"; + +const server = await preview({ preview: { host: "127.0.0.1", port: 0, open: false } }); +const { port } = server.httpServer.address(); +const sample = indexableRoutes.filter((route) => !route.record); +for (const type of new Set(indexableRoutes.map((route) => route.type).filter(Boolean))) { + sample.push(indexableRoutes.find((route) => route.type === type)); +} + +async function lighthouse(command, args = []) { + await new Promise((done, reject) => { + const child = spawn( + process.execPath, + [ + resolve("node_modules/@lhci/cli/src/cli.js"), + command, + "--config=scripts/lighthouse-seo.json", + ...args, + ], + { stdio: "inherit" } + ); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) done(); + else + reject( + new Error(`Lighthouse ${command} failed (exit ${code}). See .lighthouseci/ for reports.`) + ); + }); + }); +} + +try { + await lighthouse("collect", [ + `--chromePath=${chromium.executablePath()}`, + ...sample.map(({ path }) => `--url=http://127.0.0.1:${port}${path}`), + ]); + await lighthouse("assert"); + console.log( + `Lighthouse SEO: 100/100 on ${sample.length} representative pages. Reports stay in .lighthouseci/.` + ); +} finally { + await new Promise((done, reject) => + server.httpServer.close((error) => (error ? reject(error) : done())) + ); +} diff --git a/scripts/lighthouse-seo.json b/scripts/lighthouse-seo.json new file mode 100644 index 0000000..da3e775 --- /dev/null +++ b/scripts/lighthouse-seo.json @@ -0,0 +1,17 @@ +{ + "ci": { + "collect": { + "numberOfRuns": 1, + "settings": { + "preset": "desktop", + "onlyCategories": ["seo"], + "chromeFlags": "--headless --no-sandbox" + } + }, + "assert": { + "assertions": { + "categories:seo": ["error", { "minScore": 1 }] + } + } + } +} diff --git a/src/App.jsx b/src/App.jsx index e432624..fbc2c10 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { Route, Routes } from "react-router-dom"; import Lenis from "lenis"; +import { setLenis } from "./lib/lenis"; import Layout from "./components/Layout/Layout.jsx"; import HomePage from "./pages/HomePage/HomePage.jsx"; import ResearchPage from "./pages/ResearchPage/ResearchPage.jsx"; @@ -9,7 +10,6 @@ import PublicationsPage from "./pages/PublicationsPage/PublicationsPage.jsx"; import PublicationDetailPage from "./pages/PublicationDetailPage/PublicationDetailPage.jsx"; import TeamPage from "./pages/TeamPage/TeamPage.jsx"; import TeamMemberDetail from "./pages/TeamMemberDetail/TeamMemberDetail.jsx"; -import VisionsPage from "./pages/VisionsPage/VisionsPage.jsx"; import NewsPage from "./pages/NewsPage/NewsPage.jsx"; import EventsPage from "./pages/EventsPage/EventsPage.jsx"; import CommunityContactPage from "./pages/CommunityContactPage/CommunityContactPage.jsx"; @@ -26,6 +26,7 @@ const App = () => { smoothTouch: false, lerp: 0.08, }); + setLenis(lenis); let rafId = 0; @@ -38,6 +39,7 @@ const App = () => { return () => { cancelAnimationFrame(rafId); + setLenis(null); lenis.destroy(); }; }, []); @@ -46,7 +48,6 @@ const App = () => { }> } /> - } /> } /> } /> } /> diff --git a/src/components/AvatarImage/AvatarImage.jsx b/src/components/AvatarImage/AvatarImage.jsx index f891c6f..a2ebe90 100644 --- a/src/components/AvatarImage/AvatarImage.jsx +++ b/src/components/AvatarImage/AvatarImage.jsx @@ -1,22 +1,12 @@ import { useMemo, useState } from "react"; import { cn } from "../../lib/utils"; import { Skeleton } from "../ui/skeleton"; - -const buildVariant = (src, size, format) => { - if (!src) return src; - const dotIndex = src.lastIndexOf("."); - if (dotIndex === -1) return src; - const base = src.slice(0, dotIndex); - return `${base}-${size}.${format}`; -}; +import { imageSrcSet, imageVariant } from "../../lib/images"; const AVATAR_VARIANT_SIZES = [80, 128, 160, 256]; -const buildSrcSet = (src, sizes, format) => - sizes.map((size) => `${buildVariant(src, size, format)} ${size}w`).join(", "); - export default function AvatarImage({ src, alt, size, className, imgClassName, loading = "lazy" }) { - const [isLoaded, setIsLoaded] = useState(false); + const [isLoaded, setIsLoaded] = useState(import.meta.env.SSR); const pixelSizes = useMemo( () => AVATAR_VARIANT_SIZES.filter((variantSize) => variantSize >= size), [size] @@ -35,16 +25,16 @@ export default function AvatarImage({ src, alt, size, className, imgClassName, l {alt} +