diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68127ace10..9039269ee2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1592,6 +1592,9 @@ importers: '@nvidia-elements/themes': specifier: workspace:* version: link:../themes + '@vitest/browser': + specifier: 'catalog:' + version: 4.1.10(vite@8.1.4(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 5.0.0(vitest@5.0.0) @@ -13093,8 +13096,8 @@ packages: third-party-web@0.27.0: resolution: {integrity: sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==} - third-party-web@0.29.2: - resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} + third-party-web@0.30.0: + resolution: {integrity: sha512-p+PfyL5U0ediGzvwPzMSdD9MnC8rHW6nWJJVLIQvqN7RsSqfaZ+7n4c9GILqH36EYR0eXB9CzAeCRnKFr/9uJg==} three-forcegraph@1.43.4: resolution: {integrity: sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA==} @@ -17433,7 +17436,7 @@ snapshots: '@paulirish/trace_engine@0.0.61': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.2 + third-party-web: 0.30.0 '@pkgjs/parseargs@0.11.0': optional: true @@ -26100,7 +26103,7 @@ snapshots: third-party-web@0.27.0: {} - third-party-web@0.29.2: {} + third-party-web@0.30.0: {} three-forcegraph@1.43.4(three@0.184.0): dependencies: diff --git a/projects/core/.visual/tag.dark.png b/projects/core/.visual/tag.dark.png index 63bde8a7ab..ca04a8fb60 100644 --- a/projects/core/.visual/tag.dark.png +++ b/projects/core/.visual/tag.dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:abcdc19adee0c5e79392e5bdae56f652d91ee587e6828bfa0392db3ee24ebe6b -size 51688 +oid sha256:c0ec8900ed93b7f052a157dab2102c6453c4d18eb2faacfbb388246920514784 +size 74195 diff --git a/projects/core/.visual/tag.png b/projects/core/.visual/tag.png index 97fe2a2000..c421888eb3 100644 --- a/projects/core/.visual/tag.png +++ b/projects/core/.visual/tag.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40a3b9dda2e49d23a44174b07d68d4cbd8feae7cbf32a085c9ee83e7a4bbf2fb -size 48673 +oid sha256:058613a002a82debb61b64c59ee30809a11771d46cb6d5a65d41cd6bf4e5eb52 +size 71059 diff --git a/projects/core/src/pagination/pagination.test.lighthouse.ts b/projects/core/src/pagination/pagination.test.lighthouse.ts index 609eb990ad..0478a2a532 100644 --- a/projects/core/src/pagination/pagination.test.lighthouse.ts +++ b/projects/core/src/pagination/pagination.test.lighthouse.ts @@ -19,6 +19,6 @@ describe('pagination lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(38.96); + expect(report.payload.javascript.kb).toBeLessThan(39.5); }); }); diff --git a/projects/core/src/tag/tag.css b/projects/core/src/tag/tag.css index 25d026c110..bf5d170a1b 100644 --- a/projects/core/src/tag/tag.css +++ b/projects/core/src/tag/tag.css @@ -21,6 +21,7 @@ } [internal-host] { + --_color: var(--color); font-weight: var(--font-weight); font-size: var(--font-size); gap: var(--gap); @@ -40,7 +41,7 @@ white-space: nowrap; } -slot { +slot:not([name]) { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -51,6 +52,15 @@ slot { color: inherit !important; } +::slotted(nve-icon) { + --color: var(--_color); +} + +::slotted(nve-dot) { + --color: var(--state-color-background); + --status-color: var(--_color); +} + nve-icon { --color: inherit; cursor: var(--cursor); diff --git a/projects/core/src/tag/tag.examples.ts b/projects/core/src/tag/tag.examples.ts index 12558b4ede..e51e27856b 100644 --- a/projects/core/src/tag/tag.examples.ts +++ b/projects/core/src/tag/tag.examples.ts @@ -53,6 +53,7 @@ export const Closable = { /** * @summary Read-only tags for displaying static labels and metadata, providing visual context without user interaction capabilities. + * @tags test-case */ export const Readonly = { render: () => html` @@ -75,6 +76,7 @@ export const Group = { /** * @summary Comprehensive color palette for tag backgrounds, enabling visual categorization and brand consistency across different content types. + * @tags test-case */ export const Color = { render: () => html` @@ -108,6 +110,7 @@ export const Color = { /** * @summary High prominence tags with emphasis styling for important categories and priority labels that require visual attention. + * @tags test-case */ export const Prominence = { render: () => html` @@ -137,6 +140,40 @@ export const Prominence = { ` }; +/** + * @summary Tag with icon, showing how the icon/dot color inherits the tag color. + * @tags test-case + */ +export const SlottedColor = { + render: () => html` +
+ very words long here 10 + default 10 + red-cardinal 10 + gray-slate 10 + gray-denim 10 + blue-indigo 10 + blue-cobalt 10 + blue-sky 10 + teal-cyan 10 + green-mint 10 + teal-seafoam 10 + green-grass 10 + yellow-amber 10 + orange-pumpkin 10 + red-tomato 10 + pink-magenta 10 + purple-plum 10 + purple-violet 10 + purple-lavender 10 + pink-rose 10 + green-jade 10 + lime-pear 10 + yellow-nova 10 + brand-green 10 +
+ ` +}; /** * @summary Single tag with constrained width, with text overflow behavior and content truncation in limited space. diff --git a/projects/core/src/tag/tag.test.ts b/projects/core/src/tag/tag.test.ts index e54bb761f3..41c6fca1b2 100644 --- a/projects/core/src/tag/tag.test.ts +++ b/projects/core/src/tag/tag.test.ts @@ -82,4 +82,19 @@ describe(Tag.metadata.tag, () => { await elementIsStable(element); expect(element.getAttribute('prominence')).toBe('emphasis'); }); + + it('should provide a prefix slot', async () => { + await elementIsStable(element); + expect(element.shadowRoot.querySelector('slot[name="prefix"]')).toBeTruthy(); + }); + + it('should provide a suffix slot', async () => { + await elementIsStable(element); + expect(element.shadowRoot.querySelector('slot[name="suffix"]')).toBeTruthy(); + }); + + it('should provide a default slot', async () => { + await elementIsStable(element); + expect(element.shadowRoot.querySelector('slot:not([name])')).toBeTruthy(); + }); }); diff --git a/projects/core/src/tag/tag.test.visual.ts b/projects/core/src/tag/tag.test.visual.ts index cdd760988e..9076cc633e 100644 --- a/projects/core/src/tag/tag.test.visual.ts +++ b/projects/core/src/tag/tag.test.visual.ts @@ -20,6 +20,8 @@ function template(theme: '' | 'dark' = '') { return /* html */ ` @@ -99,5 +101,32 @@ function template(theme: '' | 'dark' = '') { •︎•︎•︎•︎•︎•︎ •︎•︎•︎•︎•︎•︎ + +
+ •︎•︎•︎•︎•︎•︎ •︎•︎•︎•︎•︎•︎ •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 + •︎•︎•︎•︎•︎•︎ 10 +
`; } diff --git a/projects/core/src/tag/tag.ts b/projects/core/src/tag/tag.ts index 5c2ecbee91..d35a1cfd47 100644 --- a/projects/core/src/tag/tag.ts +++ b/projects/core/src/tag/tag.ts @@ -23,6 +23,8 @@ import styles from './tag.css?inline'; * @since 0.10.0 * @entrypoint \@nvidia-elements/core/tag * @slot - Text or other content that identifies the category or group. + * @slot prefix - Optional prefix content, such as a status nve-icon. + * @slot suffix - Optional suffix content, such as a nve-dot counter. * @cssprop --background * @cssprop --color * @cssprop --gap @@ -71,7 +73,9 @@ export class Tag extends ButtonFormControlMixin(LitElement) { render() { return html`
+ + ${this.closable ? html` this.#typeClosableController.close()} container="flat" name="cancel" size="sm" role="img" aria-label=${ifDefined(this.i18n.close)}>` : ''}
`; diff --git a/projects/internals/metadata/src/services/adoption.service.ts b/projects/internals/metadata/src/services/adoption.service.ts index 5d1f4ca35f..8c17f9da12 100644 --- a/projects/internals/metadata/src/services/adoption.service.ts +++ b/projects/internals/metadata/src/services/adoption.service.ts @@ -24,11 +24,11 @@ export class AdoptionService { packages: [], github: { repository: '', - stars: 0, - forks: 0, - subscribers: 0, - contributors: 0, - releases: 0, + stars: null, + forks: null, + subscribers: null, + contributors: null, + releases: null, stargazers: [], errors: [] } diff --git a/projects/internals/metadata/src/tasks/adoption.ts b/projects/internals/metadata/src/tasks/adoption.ts index 7df1c67567..7c487dcf9f 100644 --- a/projects/internals/metadata/src/tasks/adoption.ts +++ b/projects/internals/metadata/src/tasks/adoption.ts @@ -6,7 +6,12 @@ import { resolve } from 'node:path'; import { getAdoptionData } from './adoption.utils.ts'; const adoption = await getAdoptionData(); +const errors = [...adoption.packages.flatMap(packageData => packageData.errors), ...adoption.github.errors]; writeFileSync(resolve(import.meta.dirname, '../../static/adoption.json'), JSON.stringify(adoption, null, 2)); -console.log('✅ Adoption metrics generated successfully.'); +if (errors.length > 0) { + console.warn(`⚠️ Adoption metrics generated with ${errors.length} unavailable source response(s).`); +} else { + console.log('✅ Adoption metrics generated successfully.'); +} diff --git a/projects/internals/metadata/src/tasks/adoption.utils.test.ts b/projects/internals/metadata/src/tasks/adoption.utils.test.ts index d18e1c0bf8..8b021d40c2 100644 --- a/projects/internals/metadata/src/tasks/adoption.utils.test.ts +++ b/projects/internals/metadata/src/tasks/adoption.utils.test.ts @@ -151,6 +151,12 @@ describe('adoption utilities', () => { forks: 4, subscribers: 3 }); + expect(parseGitHubRepository({ stargazers_count: 0, forks_count: 0, subscribers_count: 0 })).toEqual({ + stars: 0, + forks: 0, + subscribers: 0 + }); + expect(parseGitHubRepository({})).toEqual({ stars: null, forks: null, subscribers: null }); expect( parseGitHubPaginationTotal('; rel="last"', 1) ).toBe(68); @@ -317,6 +323,25 @@ describe('adoption utilities', () => { expect(metrics.stargazers).toHaveLength(2); }); + it('should distinguish unavailable GitHub metrics from real zero values', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 503, statusText: 'Service Unavailable' })) + ); + + const metrics = await getGitHubMetrics(); + + expect(metrics).toMatchObject({ + stars: null, + forks: null, + subscribers: null, + contributors: null, + releases: null, + stargazers: [] + }); + expect(metrics.errors).not.toHaveLength(0); + }); + it('should create adoption totals without counting unavailable packages as published', () => { const github = { repository: 'NVIDIA/elements', diff --git a/projects/internals/metadata/src/tasks/adoption.utils.ts b/projects/internals/metadata/src/tasks/adoption.utils.ts index c1af25111e..f0f30a950c 100644 --- a/projects/internals/metadata/src/tasks/adoption.utils.ts +++ b/projects/internals/metadata/src/tasks/adoption.utils.ts @@ -29,9 +29,9 @@ interface NpmRegistryMetadata { } interface GitHubRepositoryMetrics { - stars: number; - forks: number; - subscribers: number; + stars: number | null; + forks: number | null; + subscribers: number | null; } interface GitHubStargazersResult { @@ -301,9 +301,9 @@ export function parseJsDelivrStats(data: unknown, latestVersion: string | null): export function parseGitHubRepository(data: unknown): GitHubRepositoryMetrics { return { - stars: getNumber(data, 'stargazers_count') ?? 0, - forks: getNumber(data, 'forks_count') ?? 0, - subscribers: getNumber(data, 'subscribers_count') ?? 0 + stars: getNumber(data, 'stargazers_count'), + forks: getNumber(data, 'forks_count'), + subscribers: getNumber(data, 'subscribers_count') }; } @@ -432,22 +432,22 @@ export async function getGitHubMetrics(): Promise { const errors = [repositoryResult, contributorsResult, releasesResult].flatMap(result => result.ok ? [] : [result.error] ); - const repositoryMetrics = repositoryResult.ok - ? parseGitHubRepository(repositoryResult.data) - : parseGitHubRepository({}); + const repositoryMetrics = repositoryResult.ok ? parseGitHubRepository(repositoryResult.data) : null; const contributorsFallback = contributorsResult.ok && Array.isArray(contributorsResult.data) ? contributorsResult.data.length : 0; const releasesFallback = releasesResult.ok && Array.isArray(releasesResult.data) ? releasesResult.data.length : 0; return { repository, - stars: repositoryMetrics.stars, - forks: repositoryMetrics.forks, - subscribers: repositoryMetrics.subscribers, + stars: repositoryMetrics?.stars ?? null, + forks: repositoryMetrics?.forks ?? null, + subscribers: repositoryMetrics?.subscribers ?? null, contributors: contributorsResult.ok ? parseGitHubPaginationTotal(contributorsResult.headers.get('link'), contributorsFallback) - : 0, - releases: releasesResult.ok ? parseGitHubPaginationTotal(releasesResult.headers.get('link'), releasesFallback) : 0, + : null, + releases: releasesResult.ok + ? parseGitHubPaginationTotal(releasesResult.headers.get('link'), releasesFallback) + : null, stargazers: parseGitHubStargazers(stargazersResult.data), errors: [...errors, ...stargazersResult.errors] }; diff --git a/projects/internals/metadata/src/tasks/lighthouse.utils.ts b/projects/internals/metadata/src/tasks/lighthouse.utils.ts index 8ddb4c3659..112901e9d6 100644 --- a/projects/internals/metadata/src/tasks/lighthouse.utils.ts +++ b/projects/internals/metadata/src/tasks/lighthouse.utils.ts @@ -16,6 +16,7 @@ export interface LighthouseScores { '@nvidia-elements/code': { [key: string]: LighthouseElementReport }; '@nvidia-elements/forms': { [key: string]: LighthouseElementReport }; '@nvidia-elements/markdown': { [key: string]: LighthouseElementReport }; + '@nvidia-elements/media': { [key: string]: LighthouseElementReport }; '@nvidia-elements/monaco': { [key: string]: LighthouseElementReport }; } @@ -57,6 +58,7 @@ export async function generateLighthouseReport(): Promise { '@nvidia-elements/code': await loadLighthouseReport('../../../../code'), '@nvidia-elements/forms': await loadLighthouseReport('../../../../forms'), '@nvidia-elements/markdown': await loadLighthouseReport('../../../../markdown'), + '@nvidia-elements/media': await loadLighthouseReport('../../../../media'), '@nvidia-elements/monaco': await loadLighthouseReport('../../../../monaco') }; } diff --git a/projects/internals/metadata/src/types.ts b/projects/internals/metadata/src/types.ts index 794aa3bdc9..11014dff6a 100644 --- a/projects/internals/metadata/src/types.ts +++ b/projects/internals/metadata/src/types.ts @@ -553,11 +553,11 @@ export interface AdoptionGitHubStargazerMonth { */ export interface AdoptionGitHubMetrics { repository: string; - stars: number; - forks: number; - subscribers: number; - contributors: number; - releases: number; + stars: number | null; + forks: number | null; + subscribers: number | null; + contributors: number | null; + releases: number | null; stargazers: AdoptionGitHubStargazerMonth[]; errors: AdoptionSourceError[]; } diff --git a/projects/internals/metadata/static/adoption.json b/projects/internals/metadata/static/adoption.json index acd1ebd22d..ede0c1f81c 100644 --- a/projects/internals/metadata/static/adoption.json +++ b/projects/internals/metadata/static/adoption.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0f4650d1aabb3cdf7a7faa13abc1a3b14016d848854d2e388ccf30993d9a376 -size 201159 +oid sha256:630324999d28fb2d962f39ee7867877ca3b17175a5a017b0029ff86dab7e860e +size 261417 diff --git a/projects/internals/metadata/static/lighthouse.json b/projects/internals/metadata/static/lighthouse.json index 1e1dc57112..3fc5b80a93 100644 --- a/projects/internals/metadata/static/lighthouse.json +++ b/projects/internals/metadata/static/lighthouse.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5cd83aaad8ab9c936412b0991c376782d247d059f6cfe6e69abde3aa5af1f423 -size 61721 +oid sha256:626431290e683873d47f224cb668634c11956b60aed788299f40d4a8aaf82869 +size 67679 diff --git a/projects/internals/metadata/static/releases.json b/projects/internals/metadata/static/releases.json index b6f89fa601..fea0234cc0 100644 --- a/projects/internals/metadata/static/releases.json +++ b/projects/internals/metadata/static/releases.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9eddbbb37c1aabdfdc469ea30b62d99955afcf23dd08341297b31ad557256ad3 -size 19515 +oid sha256:62136236308eb17156d2ce572a205e19cb510a48e06e4743f287b279de19b020 +size 23960 diff --git a/projects/internals/metadata/static/tests.json b/projects/internals/metadata/static/tests.json index 089d8ea4e8..7048a73258 100644 --- a/projects/internals/metadata/static/tests.json +++ b/projects/internals/metadata/static/tests.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36a471595a611afdc932ecfd0b56d4353709f4aa66ba552a5e1ab33302c5d739 -size 2709336 +oid sha256:003bd0e158bc1fe3dce72b7ef9b0fc7da1d2fbc55a26dcf2b3c3732b979b12d9 +size 2962409 diff --git a/projects/internals/vite/src/runners/playwright.js b/projects/internals/vite/src/runners/playwright.js index 86d7c12d90..fcac3b2e43 100644 --- a/projects/internals/vite/src/runners/playwright.js +++ b/projects/internals/vite/src/runners/playwright.js @@ -117,10 +117,7 @@ export class VitePlaywrightRunner { } async close() { - const report = globSync(resolve(`${this.#root}/**/report.json`)).reduce((p, n) => { - const file = JSON.parse(fs.readFileSync(n)); - return { ...p, [file.name]: file }; - }, {}); + const report = collectReports(this.#root); fs.writeFileSync(`${this.#dist}/report.json`, JSON.stringify(report, null, 2)); await this.#page.close(); @@ -129,6 +126,14 @@ export class VitePlaywrightRunner { } } +/** Collects individual runner reports without re-reading the aggregate report. */ +export function collectReports(root) { + return globSync(path.resolve(root, 'dist/*/report.json')).reduce((reports, reportPath) => { + const report = JSON.parse(fs.readFileSync(reportPath)); + return { ...reports, [report.name]: report }; + }, {}); +} + /** * Builds a given test fixture/template for test runner environments. * diff --git a/projects/site/package.json b/projects/site/package.json index eb3de8bc1a..95d2b3d9b5 100644 --- a/projects/site/package.json +++ b/projects/site/package.json @@ -11,6 +11,8 @@ "lint": "wireit", "lint:fix": "wireit", "test": "wireit", + "test:browser": "wireit", + "test:node": "wireit", "test:lighthouse": "wireit", "preview": "wireit" }, @@ -227,11 +229,30 @@ ] }, "test": { - "command": "vitest run", + "dependencies": [ + "test:browser", + "test:node" + ] + }, + "test:browser": { + "command": "vitest run --config=vite.browser.ts", + "files": [ + "src/**/*.ts", + "vite.browser.ts" + ], + "output": [], + "dependencies": [ + "build", + "../internals/vite:ci" + ] + }, + "test:node": { + "command": "vitest run --config=vitest.config.ts", "files": [ "src/**/*.js", "src/**/*.md", "src/**/*.ts", + "!src/**/*.browser.test.ts", "vitest.config.ts" ], "output": [], @@ -290,6 +311,7 @@ "@nvidia-elements/scene": "workspace:*", "@nvidia-elements/styles": "workspace:*", "@nvidia-elements/themes": "workspace:*", + "@vitest/browser": "catalog:", "@vitest/coverage-istanbul": "catalog:", "compare-versions": "6.1.1", "eslint": "catalog:", diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index a6b4c55c94..2bc360acca 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -462,7 +462,7 @@ export function renderBasePageHeader(data) { Catalog ${ELEMENTS_PLAYGROUND_BASE_URL ? /* html */ `Playground` : ''} Starters - Repo + GitHub System Themes diff --git a/projects/site/src/_11ty/layouts/docs.ts b/projects/site/src/_11ty/layouts/docs.ts index cb70e363ec..beb7de123b 100644 --- a/projects/site/src/_11ty/layouts/docs.ts +++ b/projects/site/src/_11ty/layouts/docs.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { ResizeHandle } from '@nvidia-elements/core/resize-handle'; +import type { PagePanelContent } from '@nvidia-elements/core/page'; import type { Tree } from '@nvidia-elements/core/tree'; import { FILTER_VALUES, type DocsSearch } from '../../_internal/search/search.js'; import '../../_internal/canvas/canvas.js'; @@ -9,15 +10,11 @@ import '../../_internal/canvas/canvas.js'; void import('../../_internal/search/search.js'); // panel toggles -let loadedSystemsPanel = false; const systemOptionsPanel = globalThis.document.querySelector('#system-options-panel')!; const systemOptionsPanelBtn = globalThis.document.querySelector('#system-options-panel-btn')!; systemOptionsPanel.addEventListener('close', () => (systemOptionsPanel.hidden = true)); systemOptionsPanelBtn.addEventListener('click', async () => { - if (!loadedSystemsPanel) { - await import('../../_internal/system-settings/system-settings.js'); - loadedSystemsPanel = true; - } + await import('../../_internal/system-settings/system-settings.js'); systemOptionsPanel.hidden = !systemOptionsPanel.hidden; }); @@ -59,11 +56,9 @@ setTimeout(() => { scrollToHeading(headerId); }, 500); -// preserve scroll position between page transitions -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const content = globalThis.document.querySelector('#sidenav-panel nve-page-panel-content')!; +const content = globalThis.document.querySelector('#sidenav-panel nve-page-panel-content')!; globalThis.window.addEventListener('beforeunload', () => { - sessionStorage.setItem('sidenav-scroll-position', content.scrollTop); + sessionStorage.setItem('sidenav-scroll-position', content.scrollTop.toString()); }); const savedPosition = sessionStorage.getItem('sidenav-scroll-position'); @@ -81,11 +76,14 @@ const docsNav = globalThis.document.querySelector('#docs-nav')!; let isSearching = false; let currentFilter: string | null = null; -/** - * Syncs the current search query and active filter to URL parameters. - * Updates browser history without triggering navigation or adding new entries. - */ -function syncSearchStateToUrl(query: string, filter: string | null) { +// Listen for any search state change (query input, filter selection, or reset) +// The search component emits a single 'search-change' event for all state transitions +docsSearch.addEventListener('search-change', ((event: CustomEvent) => { + const { query, filter } = event.detail as { query: string; filter: string | null }; + + isSearching = query.length > 0; + currentFilter = filter; + const currentUrl = new URL(globalThis.location.href); let urlHasChanged = false; @@ -114,50 +112,15 @@ function syncSearchStateToUrl(query: string, filter: string | null) { const newUrl = currentUrl.pathname + currentUrl.search + currentUrl.hash; globalThis.history.replaceState(null, '', newUrl); } -} - -// Listen for any search state change (query input, filter selection, or reset) -// The search component emits a single 'search-change' event for all state transitions -docsSearch.addEventListener('search-change', ((event: CustomEvent) => { - const { query, filter } = event.detail as { query: string; filter: string | null }; - - isSearching = query.length > 0; - currentFilter = filter; - syncSearchStateToUrl(query, filter); // Restore navigation when search is fully cleared (no query and no filter) if (!isSearching && filter === null) { - toggleSideNav(true); + docsNav.hidden = false; } }) as EventListener); -// Listen for search results loaded -// Hides the navigation tree and expands the side panel to accommodate results -docsSearch.addEventListener('search-results', () => { - toggleSideNav(false); -}); - -// Listen for no results found -// Restores the navigation tree since there are no results to display -docsSearch.addEventListener('search-no-results', () => { - toggleSideNav(true); -}); - -// Listen for search input blur -// Restores navigation if user is not actively searching -docsSearch.addEventListener('search-blur', () => !isSearching && toggleSideNav(true)); - -const defaultWidth = `${handle.getAttribute('value')}px`; -const toggleSideNav = (state: boolean) => { - if (state) { - // Showing nav - shrink panel to default width - panel.style.width = defaultWidth; - } else { - // Hiding nav for search - expand panel for search results - panel.style.width = `${handle.max}px`; - } - docsNav.hidden = !state; -}; +docsSearch.addEventListener('search-results', () => (docsNav.hidden = true)); +docsSearch.addEventListener('search-no-results', () => (docsNav.hidden = false)); // Initialize search from URL parameters (if present) // This allows for shareable search links with pre-filled queries and filters @@ -196,60 +159,41 @@ if (searchQuery) { searchInputElement.focus(); // Hide navigation and show search results - toggleSideNav(false); + docsNav.hidden = true; isSearching = true; void docsSearch.search(searchQuery); } } else if (currentFilter) { - // If there's only a filter (no query), just apply the filter await customElements.whenDefined('nvd-search'); await docsSearch.updateComplete; - docsSearch.filter = currentFilter; } -// Add clickable anchor links to headings function addHeadingAnchors() { - // Find all h2 and h3 elements with mkd class const headings = globalThis.document.querySelectorAll('h2[nve-text*="mkd"], h3[nve-text*="mkd"]'); headings.forEach(heading => { - // Skip if anchor already exists if (heading.querySelector('.heading-anchor')) return; - - // Get or generate ID const id = heading.id; - - // Create anchor element const anchor = globalThis.document.createElement('a'); anchor.className = 'heading-anchor'; anchor.href = `${globalThis.window.parent.location.pathname}#${id}`; anchor.setAttribute('aria-label', 'copy permalink'); anchor.innerHTML = ''; - // Add click handler anchor.addEventListener('click', e => { e.preventDefault(); - - // Update URL hash without triggering default scroll globalThis.history.pushState(null, '', `${globalThis.window.location.pathname}#${id}`); - - // Scroll to heading using controlled scroll scrollToHeading(id); - - // Copy to clipboard if (globalThis.navigator.clipboard) { void globalThis.navigator.clipboard.writeText( `${globalThis.window.location.origin}${globalThis.window.location.pathname}#${id}` ); } }); - - // Insert anchor as first child of heading heading.insertBefore(anchor, heading.firstChild); }); } -// Run on initial load addHeadingAnchors(); diff --git a/projects/site/src/_internal/search/search.browser.test.ts b/projects/site/src/_internal/search/search.browser.test.ts new file mode 100644 index 0000000000..ce0494d41d --- /dev/null +++ b/projects/site/src/_internal/search/search.browser.test.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DocsSearch, type PagefindSearchFragment } from './search.js'; + +const results: PagefindSearchFragment[] = [ + { + url: '/elements/button/', + raw_url: '/elements/button/', + content: '', + excerpt: '', + filters: {}, + meta: { section: 'elements', title: 'Button', tag: 'nve-button' }, + anchors: [] + }, + { + url: '/patterns/card/', + raw_url: '/patterns/card/', + content: '', + excerpt: '', + filters: {}, + meta: { section: 'patterns', title: 'Card' }, + anchors: [] + } +]; + +describe('nvd-search', () => { + let element: DocsSearch; + + function shadowElement(selector: string): ElementType { + const match = element.shadowRoot?.querySelector(selector); + if (!match) throw new Error(`Expected search shadow root to contain ${selector}`); + return match; + } + + beforeEach(async () => { + element = globalThis.document.createElement('nvd-search') as DocsSearch; + element.baseUrl = '/elements/'; + globalThis.document.body.append(element); + await element.updateComplete; + }); + + afterEach(() => { + vi.useRealTimers(); + element.remove(); + }); + + it('defines the component and normalizes its base URL', () => { + expect(customElements.get('nvd-search')).toBe(DocsSearch); + expect(element.baseUrl).toBe('/elements'); + }); + + it('debounces input changes and emits the current search state', async () => { + vi.useFakeTimers(); + const search = vi.spyOn(element, 'search').mockResolvedValue(); + const searchChanges: CustomEvent[] = []; + element.addEventListener('search-change', event => { + if (event instanceof CustomEvent) searchChanges.push(event); + }); + const input = shadowElement('#search-input'); + + input.value = 'but'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + input.value = 'button'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + await vi.advanceTimersByTimeAsync(DocsSearch.SEARCH_DEBOUNCE_MS); + + expect(search).toHaveBeenCalledOnce(); + expect(search).toHaveBeenCalledWith('button'); + expect(searchChanges).toHaveLength(1); + expect(searchChanges[0]?.detail).toEqual({ query: 'button', filter: null }); + }); + + it('filters rendered results by section', async () => { + element.results = results; + await element.updateComplete; + const patternFilter = Array.from(element.shadowRoot?.querySelectorAll('nve-tag') ?? []).find(tag => + tag.textContent?.includes('Patterns') + ); + if (!patternFilter) throw new Error('Expected a Patterns filter'); + + patternFilter.click(); + await element.updateComplete; + + expect(element.filter).toBe('patterns'); + expect(Array.from(element.shadowRoot?.querySelectorAll('a') ?? []).map(link => link.textContent)).toEqual([ + expect.stringContaining('Card') + ]); + }); + + it('emits search status events for the filtered results', async () => { + element.results = results; + await element.updateComplete; + const searchResults = vi.fn(); + const searchNoResults = vi.fn(); + element.addEventListener('search-results', searchResults); + element.addEventListener('search-no-results', searchNoResults); + const filters = Array.from(element.shadowRoot?.querySelectorAll('nve-tag') ?? []); + const patternFilter = filters.find(tag => tag.textContent?.includes('Patterns')); + const foundationFilter = filters.find(tag => tag.textContent?.includes('Foundations')); + if (!patternFilter || !foundationFilter) throw new Error('Expected Patterns and Foundations filters'); + + patternFilter.click(); + foundationFilter.click(); + + expect(searchResults).toHaveBeenCalledOnce(); + expect(searchNoResults).toHaveBeenCalledOnce(); + }); + + it('clears results, the query, and the active filter when reset', async () => { + element.results = results; + await element.updateComplete; + const patternFilter = Array.from(element.shadowRoot?.querySelectorAll('nve-tag') ?? []).find(tag => + tag.textContent?.includes('Patterns') + ); + if (!patternFilter) throw new Error('Expected a Patterns filter'); + patternFilter.click(); + await element.updateComplete; + const input = shadowElement('#search-input'); + input.value = 'card'; + + shadowElement('#search-reset').click(); + await element.updateComplete; + + expect(input.value).toBe(''); + expect(element.results).toEqual([]); + expect(element.filter).toBeNull(); + }); +}); diff --git a/projects/site/src/_internal/search/search.ts b/projects/site/src/_internal/search/search.ts index 4e94e87466..1359fbd1fe 100644 --- a/projects/site/src/_internal/search/search.ts +++ b/projects/site/src/_internal/search/search.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { css, html, LitElement, unsafeCSS } from 'lit'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { query } from 'lit/decorators/query.js'; @@ -88,15 +88,20 @@ export class DocsSearch extends LitElement { nve-tag, nve-tag nve-dot { cursor: pointer; } + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } ` ]; /** Max number of search results to display */ static readonly MAX_RESULTS = 20; - /** Max size of the search cache */ - static readonly CACHE_MAX_SIZE = 50; - @property({ type: String, attribute: 'base-url' }) baseUrl!: string; @@ -113,9 +118,6 @@ export class DocsSearch extends LitElement { @state() isSearching = false; - @state() - noResults = false; - @state() private currentSearchTerm = ''; @@ -126,7 +128,6 @@ export class DocsSearch extends LitElement { static readonly SEARCH_DEBOUNCE_MS = 400; #pagefind!: Pagefind; - #searchCache = new Map(); #searchDebounceTimerId: ReturnType | undefined; async #handleChange(event: CustomEvent) { @@ -159,7 +160,6 @@ export class DocsSearch extends LitElement { this.results = []; this.searchInput.value = ''; this.isSearching = false; - this.noResults = false; this.currentSearchTerm = ''; this.activeFilter = null; this.filter = null; @@ -175,19 +175,6 @@ export class DocsSearch extends LitElement { this.dispatchEvent(new Event('search-focus', { bubbles: true, composed: true })); } - #handleBlur(event: Event) { - event.preventDefault(); - if (this.searchInput.value === '') { - this.noResults = false; - } - - this.dispatchEvent(new Event('search-blur', { bubbles: true, composed: true })); - } - - /** - * Lit lifecycle method called after property updates complete. - * Syncs the 'filter' attribute (from URL/external) to the internal activeFilter state. - */ override updated(changedProperties: Map) { super.updated(changedProperties); @@ -219,16 +206,6 @@ export class DocsSearch extends LitElement { } } - #evictOldestCacheEntryIfFull() { - if (this.#searchCache.size < DocsSearch.CACHE_MAX_SIZE) { - return; - } - const firstCacheKey = this.#searchCache.keys().next().value; - if (firstCacheKey) { - this.#searchCache.delete(firstCacheKey); - } - } - async #loadPagefind() { if (!this.#pagefind) { const url = `${this.baseUrl}/.pagefind/pagefind.js`; @@ -246,157 +223,90 @@ export class DocsSearch extends LitElement { this.currentSearchTerm = term; if (this.#pagefind) { - // Check cache first for instant results on repeated searches - const cached = this.#searchCache.get(term); - if (cached) { - this.isSearching = false; - this.results = cached; - this.noResults = this.results.length === 0; - this.dispatchEvent( - new CustomEvent(this.noResults ? 'search-no-results' : 'search-results', { - detail: this.results, - bubbles: true, - composed: true - }) - ); - return; - } - this.results = []; - this.noResults = false; const searchResult = await this.#pagefind.debouncedSearch(term); if (searchResult !== null) { - // Limit results to MAX_RESULTS and fetch data in parallel for better performance const limitedResults = searchResult.results.slice(0, DocsSearch.MAX_RESULTS); const queryResults = await Promise.all(limitedResults.map(result => result.data())); - this.isSearching = false; - - // Assign a new array reference to trigger Lit's reactive updates this.results = [...queryResults]; - // Update cache with FIFO eviction when cache is full - this.#evictOldestCacheEntryIfFull(); - this.#searchCache.set(term, this.results); - - // Notify parent components of search outcome - if (this.results.length === 0) { - this.noResults = true; - this.dispatchEvent(new Event('search-no-results', { bubbles: true, composed: true })); - } else { - this.dispatchEvent( - new CustomEvent('search-results', { - detail: this.results, - bubbles: true, - composed: true - }) - ); - } + this.#dispatchSearchStatus(); } } } - render() { - const filteredResults = this.#getFilteredResults(); + #dispatchSearchStatus() { + if (this.#filteredResults.length === 0) { + this.dispatchEvent(new Event('search-no-results', { bubbles: true, composed: true })); + } else { + this.dispatchEvent( + new CustomEvent('search-results', { + detail: this.results, + bubbles: true, + composed: true + }) + ); + } + } + + get #filteredResults(): SearchResult[] { + const searchWords = this.currentSearchTerm + .toLowerCase() + .split(/\s+/) + .filter(w => w.length > 0); + + return this.results + .filter(result => (this.activeFilter ? result.meta?.section === this.activeFilter : true)) + .flatMap(result => [this.#getSearchResult(result), ...this.#getHeadings(result)]) + .sort((a, b) => { + const aAll = searchWords.every(w => a.title.toLowerCase().includes(w)); + const bAll = searchWords.every(w => b.title.toLowerCase().includes(w)); + return aAll === bAll ? 0 : aAll ? -1 : 1; + }); + } + render() { return html` - - +
+ + + ${ + this.isSearching + ? html` + + + ` + : nothing + } + + ${ - this.isSearching + this.results.length > 0 ? html` - - - - ` - : html`` - } - - - ${ - this.results.length > 0 - ? (() => { - // Get result counts for each filter category to display in chip labels - const resultCounts = this.#getFilterCounts(); - - // Helper to determine if a filter is currently active - const isFilterActive = (filterName: string | null) => this.activeFilter === filterName; - - return html` -
- ${FILTER_OPTIONS.map(({ value, label, countKey }) => { - const active = isFilterActive(value); - return html` - this.#setFilter(value)} - > - ${label} ${resultCounts[countKey]} - - `; - })} -
- `; - })() - : '' - } - ${(() => { - const searchWords = this.currentSearchTerm - .toLowerCase() - .split(/\s+/) - .filter(w => w.length > 0); - const allResults = filteredResults.flatMap(result => [ - this.#getSearchResult(result), - ...this.#getHeadings(result) - ]); - - // For multi-word searches, promote results whose title contains ALL words - if (searchWords.length > 1) { - allResults.sort((a, b) => { - const aAll = searchWords.every(w => a.title.toLowerCase().includes(w)); - const bAll = searchWords.every(w => b.title.toLowerCase().includes(w)); - return aAll === bAll ? 0 : aAll ? -1 : 1; - }); +
+ ${FILTER_OPTIONS.map(({ value, label, countKey }) => { + const active = value === this.activeFilter; + return html` + this.#setFilter(value)}> + ${label} ${this.#getFilterCounts()[countKey]} + `; + })} +
` + : nothing } - - return allResults.map(r => this.#renderSearchResult(r)); - })()} - ${ - this.noResults || (this.results.length > 0 && filteredResults.length === 0) - ? (() => { - // Determine if we're showing "no results at all" vs "no results in this filter" - const hasUnfilteredResults = this.results.length > 0; - const isFilteredView = this.activeFilter !== null; - - // Choose appropriate message - const alertMessage = isFilteredView ? `No results in "${this.activeFilter}"` : 'No Results Found'; - - return html` -
- ${alertMessage} - - ${ - // Show "Show all results" button if there are results in other filters - isFilteredView && hasUnfilteredResults - ? html` - this.#setFilter(null)} - > - Show all results - - ` - : '' - } -
- `; - })() - : html`` - } + ${this.#filteredResults.map( + result => html` + +
+

${result.title}

+

${result.subtitle}

+
+
` + )} +
`; } @@ -408,6 +318,7 @@ export class DocsSearch extends LitElement { #setFilter(filter: string | null) { this.activeFilter = filter; this.filter = filter; + this.#dispatchSearchStatus(); // Notify parent components that the search state has changed this.dispatchEvent( @@ -419,23 +330,6 @@ export class DocsSearch extends LitElement { ); } - /** - * Filters search results based on the currently active section filter. - * @returns Filtered array of search results, or all results if no filter is active - */ - #getFilteredResults(): PagefindSearchFragment[] { - // Show all results when no filter is active - if (!this.activeFilter) { - return this.results; - } - - // Filter results by matching section metadata - return this.results.filter(result => { - const resultSection = result.meta?.section; - return resultSection === this.activeFilter; - }); - } - /** * Counts the number of visible search results in each section category. * Includes both page-level results and heading sub-results to match the rendered output. @@ -469,51 +363,24 @@ export class DocsSearch extends LitElement { }; } - #renderSearchResult(result: SearchResult) { - return html` - -
- -
-

${result.title}

-

${result.subtitle}

- -
-
-
- `; - } - #getSearchResult(result: PagefindSearchFragment): SearchResult { - const isNveTag = result.meta?.tag?.includes('nve-'); - const isApiPage = result.meta?.tab === 'api'; - - // Determine icon based on page type - let icon: IconName; - let colorToken: string; + let icon: IconName = 'book'; + let colorToken = 'yellow-amber'; - if (isApiPage && isNveTag) { - // API documentation pages for components - icon = 'code'; - colorToken = 'purple-violet'; - } else if (isNveTag) { - // Component overview pages + if (result.meta?.tag?.includes('nve-')) { icon = 'terminal'; colorToken = 'blue-cobalt'; - } else { - // General documentation pages - icon = 'book'; - colorToken = 'yellow-amber'; + } else if (result.meta?.tab === 'api') { + icon = 'code'; + colorToken = 'purple-violet'; } - const style = `--color: var(--nve-ref-color-${colorToken}-1000);`; - return { url: `${result.raw_url ?? ''}${this.#getQueryParam()}`, title: result.meta?.title, subtitle: result.raw_url || '', icon: icon, - style: style, + style: `--color: var(--nve-ref-color-${colorToken}-1000);`, excerpt: result.excerpt }; } diff --git a/projects/site/src/docs/elements/tag.md b/projects/site/src/docs/elements/tag.md index 365efa033a..3eab337b06 100644 --- a/projects/site/src/docs/elements/tag.md +++ b/projects/site/src/docs/elements/tag.md @@ -28,6 +28,10 @@ {% example '@nvidia-elements/core/tag/tag.examples.json' 'Closable' %} +## Slotted Color + +{% example '@nvidia-elements/core/tag/tag.examples.json' 'SlottedColor' %} + ## Readonly {% api 'nve-tag', 'property', 'readOnly' %} diff --git a/projects/site/src/docs/metrics/adoption.ts b/projects/site/src/docs/metrics/adoption.ts index e686dd69df..17f8fe173f 100644 --- a/projects/site/src/docs/metrics/adoption.ts +++ b/projects/site/src/docs/metrics/adoption.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Chart } from 'chart.js/auto'; +import { Chart, type ChartConfiguration } from 'chart.js/auto'; import { getThemeTokens } from '@nvidia-elements/core'; import { AdoptionService } from '@internals/metadata'; import { @@ -384,7 +384,7 @@ new Chart(getCanvas('adoption-release-overlay-chart'), { } }); -new Chart(getCanvas('adoption-github-interest-chart'), { +const githubInterestChartConfiguration: ChartConfiguration<'line'> = { type: 'line', data: { labels: adoption.github.stargazers.map(stargazer => stargazer.month), @@ -442,4 +442,18 @@ new Chart(getCanvas('adoption-github-interest-chart'), { } } } -}); +}; + +const githubInterestCanvas = globalThis.document.getElementById('adoption-github-interest-chart'); +if (githubInterestCanvas instanceof HTMLCanvasElement) { + if ('IntersectionObserver' in globalThis) { + const observer = new IntersectionObserver(entries => { + if (!entries.some(entry => entry.isIntersecting)) return; + observer.disconnect(); + new Chart(githubInterestCanvas, githubInterestChartConfiguration); + }); + observer.observe(githubInterestCanvas); + } else { + new Chart(githubInterestCanvas, githubInterestChartConfiguration); + } +} diff --git a/projects/site/src/docs/metrics/index.11ty.js b/projects/site/src/docs/metrics/index.11ty.js index f8ad815544..d405932726 100644 --- a/projects/site/src/docs/metrics/index.11ty.js +++ b/projects/site/src/docs/metrics/index.11ty.js @@ -64,6 +64,11 @@ function getTopCdnVersion(packageData) { : 'None'; } +function renderOptionalCount(value, label) { + const formattedValue = value === null ? 'unavailable' : value.toLocaleString(); + return `${formattedValue} ${label}`; +} + export function render() { return this.renderTemplate( /* html */ ` @@ -106,8 +111,8 @@ export function render() {
${formatNumber(adoption.totals.npmDownloads)} npm downloads ${formatNumber(adoption.totals.cdnRequests)} CDN requests - ${formatNumber(adoption.github.stars)} stars - ${formatNumber(adoption.github.contributors)} contributors + ${renderOptionalCount(adoption.github.stars, 'stars')} + ${renderOptionalCount(adoption.github.contributors, 'contributors')}
@@ -207,7 +212,11 @@ export function render() {

Public stargazer growth from GitHub API timestamps.

- + ${ + adoption.github.stargazers.length > 0 + ? '' + : '

GitHub star history is unavailable.

' + }
diff --git a/projects/site/vite.browser.ts b/projects/site/vite.browser.ts new file mode 100644 index 0000000000..b98b292d84 --- /dev/null +++ b/projects/site/vite.browser.ts @@ -0,0 +1,16 @@ +import { mergeConfig } from 'vitest/config'; +import { libraryTestConfig } from '@internals/vite/configs/test.js'; + +export default mergeConfig(libraryTestConfig, { + root: import.meta.dirname, + test: { + include: ['./src/**/*.browser.test.ts'], + outputFile: { + junit: './coverage/browser/junit.xml', + json: './coverage/browser/summary.json' + }, + coverage: { + reportsDirectory: './coverage/browser' + } + } +}); diff --git a/projects/site/vitest.config.ts b/projects/site/vitest.config.ts index 48e5532e49..9fdead25dc 100644 --- a/projects/site/vitest.config.ts +++ b/projects/site/vitest.config.ts @@ -4,6 +4,7 @@ import { libraryNodeTestConfig } from '@internals/vite/configs/test.node.js'; export default mergeConfig(libraryNodeTestConfig, { root: import.meta.dirname, test: { - include: ['./src/**/*.test.ts'] + include: ['./src/**/*.test.ts'], + exclude: ['./src/**/*.browser.test.ts'] } });