diff --git a/website/README.md b/website/README.md index 0a7fb051..0c74b242 100644 --- a/website/README.md +++ b/website/README.md @@ -264,8 +264,13 @@ local browser's consent choices; use **Manage cookies** to review them again after dismissing the banner. Microsoft WCP provides worldwide opt-in consent and a **Manage cookies** -footer control. The consent placeholder is appended to the body so the -existing skip link remains first. The banner follows Starlight's theme. +footer control. The consent placeholder follows the skip link at the top +of the body, keeping that link first in keyboard order. The banner sits +above the fixed site header and follows Starlight's theme. Its measured +height updates Starlight's navigation offsets as it wraps, resizes, or +disappears, keeping the header, mobile menu, sidebars, and page content +clear of the banner. + The 1DS CDN script is requested only after explicit Analytics consent; implicit/non-required consent, Global Privacy Control, and Do Not Track do not enable analytics. Script or initialization failures log an error @@ -283,6 +288,23 @@ channels before unloading, and telemetry initializers reject events without current consent. The SDK owns ingestion retries; the site does not add another retry layer. +Each SDK instance receives `propertyConfiguration.userConsented` from the +same current WCP Analytics consent gate before initialization, including +stored consent on subsequent page visits and reinitialization after +renewed consent. Delaying SDK initialization alone does not set +`ext.web.userConsent`. In SDK 3.2.18, omitting this property with the SDK's +cookie manager disabled produces `userConsent: false` even after accepting +WCP. The `cookieEnabled` field describes browser cookie capability, not +Analytics consent; enabling analytics cookies is not the fix. + +Keep the registered `scope` App ID and existing browser-ingestion +identifier unchanged. After deployment, an opted-in `Ms.Web.PageView` +should retain `iKey: "o:d73f4c6d7bfd4f54ab769e2baaf3156b"` and +`ext.app.env: "PROD"`, with `ext.web.userConsent: true`. A successful +collector response confirms delivery, not visibility in Customer Journey +Analytics (CJA). Downstream processing and data-view configuration must +be checked separately in CJA. + This is not Scope application telemetry and does not modify the Portal, CLI, API, or workers. The [data-collection notice](src/content/docs/resources/data-collection.md) @@ -308,9 +330,10 @@ representative documentation, community and API pages, privacy links, landmarks, horizontal reflow, keyboard navigation, reduced motion, and absence of third-party requests on local previews. Published-origin analytics checks mock the Microsoft scripts to cover opt-in, withdrawal, -stored consent, blocked scripts, privacy signals, and loading races without -sending real analytics. CI runs the same -browser suite against its existing production build before deployment. +stored consent, explicit SDK consent metadata, blocked scripts, privacy +signals, loading races, and responsive top-banner placement without +sending real analytics. CI runs the same browser suite against its +existing production build before deployment. Automated checks do not certify full accessibility or policy compliance. Retain manual keyboard, screen-reader, zoom, and diff --git a/website/src/content/docs/resources/data-collection.md b/website/src/content/docs/resources/data-collection.md index f9b1ead7..4c6920a1 100644 --- a/website/src/content/docs/resources/data-collection.md +++ b/website/src/content/docs/resources/data-collection.md @@ -3,7 +3,7 @@ title: Data collection and privacy description: What data Scope handles, why it is needed, and where it may be sent. --- -Last updated: October 14, 2026 +Last updated: October 16, 2026 Scope is deployable software. The organization operating your Scope deployment controls its data, chooses its connected services, and sets its @@ -27,6 +27,7 @@ that site's privacy practices. The published documentation website uses Microsoft One Observability (1DS) to understand website usage and reliability. Microsoft Web Cookie Policy (WCP) provides the consent banner and saves your cookie choices. +The banner appears at the top of the page, above the site header. The WCP consent script loads when you visit the published site; the 1DS script loads only after you opt in to the **Analytics** category. The same opt-in requirement applies to visitors worldwide, without @@ -36,6 +37,7 @@ When enabled, 1DS automatically captures page views, page-load and page-unload events, clicks, scrolling, resizing, JavaScript errors, and browser context. This information is sent to Microsoft's 1DS collector to understand how the documentation is used and identify problems. +Your Analytics choice is also passed to 1DS as consent metadata. The SDK is configured not to use analytics cookies and not to collect query strings or fragments in page URLs. WCP's consent-preference cookie is separate from analytics. diff --git a/website/src/scripts/analytics.ts b/website/src/scripts/analytics.ts index 2dbb60f6..abe2635f 100644 --- a/website/src/scripts/analytics.ts +++ b/website/src/scripts/analytics.ts @@ -40,7 +40,7 @@ const configuration = { instrumentationKey: 'd73f4c6d7bfd4f54ab769e2baaf3156b-3241346c-4fe3-4bd7-a8b6-7ca73da60140-7462', disableCookiesUsage: true, channelConfiguration: { eventsLimitInMem: 50 }, - propertyConfiguration: { env: 'PROD' }, + propertyConfiguration: { env: 'PROD', userConsented: false }, webAnalyticsConfiguration: { urlCollectQuery: false, urlCollectHash: false, @@ -79,10 +79,17 @@ function loadScript(src: string): Promise { async function initializeConsent(allowAnalytics: boolean): Promise { const manageCookies = document.querySelector('[data-manage-cookies]'); if (!manageCookies) throw new Error('Missing Scope cookie-management control'); + const skipLink = document.querySelector('body > a[href="#_top"]'); + if (!skipLink) throw new Error('Missing Scope skip link'); const banner = document.createElement('div'); banner.id = 'scope-cookie-banner'; - document.body.append(banner); + skipLink.after(banner); + new ResizeObserver((entries) => { + for (const { contentRect } of entries) { + document.documentElement.style.setProperty('--scope-cookie-banner-height', `${contentRect.height}px`); + } + }).observe(banner); await loadScript(wcpScript); const wcp = window.WcpConsent; if (!wcp) throw new Error('Microsoft WCP consent API is unavailable'); @@ -128,7 +135,14 @@ async function initializeConsent(allowAnalytics: boolean): Promise { current.addTelemetryInitializer(() => current === analytics && hasAnalyticsConsent()); analytics = current; try { - current.initialize(configuration, []); + // Delaying initialization until opt-in does not set the SDK's consent metadata. + current.initialize({ + ...configuration, + propertyConfiguration: { + ...configuration.propertyConfiguration, + userConsented: hasAnalyticsConsent(), + }, + }, []); } catch (error) { analytics = undefined; throw error; diff --git a/website/src/styles/landing.css b/website/src/styles/landing.css index 72616290..c13bffb5 100644 --- a/website/src/styles/landing.css +++ b/website/src/styles/landing.css @@ -21,6 +21,9 @@ --scope-pass: #83dcb0; --scope-fail: #ffa498; --scope-shadow: 0 24px 70px -36px #0009; + --scope-header-height: 3.5rem; + --scope-cookie-banner-height: 0px; + --sl-nav-height: calc(var(--scope-header-height) + var(--scope-cookie-banner-height)); } :root[data-theme='light'] { --sl-color-accent-low: #eeedff; @@ -40,8 +43,14 @@ --scope-shadow: 0 24px 70px -36px #30376240; } +/* Keep Starlight's fixed navigation offsets aligned with the responsive consent banner. */ +header.header { --sl-nav-height: var(--scope-header-height); inset-block-start: var(--scope-cookie-banner-height); border-bottom-color: var(--scope-border); } +starlight-menu-button button { top: calc(var(--scope-cookie-banner-height) + (var(--scope-header-height) - var(--sl-menu-button-size)) / 2); } +#scope-cookie-banner { position: fixed; inset-block-start: 0; inset-inline: 0; z-index: calc(var(--sl-z-index-navbar) + 1); max-height: calc(100dvh - var(--scope-header-height)); overflow-y: auto; } +@media (min-width: 50em) { :root { --scope-header-height: 4rem; } } +@media print { :root { --scope-cookie-banner-height: 0px !important; } #scope-cookie-banner { display: none; } } + /* Keep the reading experience native to Starlight, with shared brand accents. */ -header.header { border-bottom-color: var(--scope-border); } body:not(:has(.scope-home)) .sidebar-content a[aria-current='page'] { border-radius: 0.4rem; } body:not(:has(.scope-home)) .sl-markdown-content { line-height: 1.8; } body:not(:has(.scope-home)) .pagination-links a { border-radius: 0.75rem; } diff --git a/website/tests/analytics.spec.ts b/website/tests/analytics.spec.ts index 6df61ba7..c9cf3902 100644 --- a/website/tests/analytics.spec.ts +++ b/website/tests/analytics.spec.ts @@ -27,7 +27,7 @@ function consentStub() { window.WcpConsent = { consentCategories: { Analytics: 'Analytics' }, init(_language, placeholder, callback, onConsentChanged) { - placeholder.style.cssText = 'position:fixed;bottom:0;right:0;z-index:10000;background:white'; + placeholder.style.cssText = 'background:white;color:black'; const accept = document.createElement('button'); accept.textContent = 'Accept analytics'; accept.onclick = () => { @@ -128,7 +128,7 @@ test('analytics waits for consent and uses the Scope configuration', async ({ pa instrumentationKey: 'd73f4c6d7bfd4f54ab769e2baaf3156b-3241346c-4fe3-4bd7-a8b6-7ca73da60140-7462', disableCookiesUsage: true, channelConfiguration: { eventsLimitInMem: 50 }, - propertyConfiguration: { env: 'PROD' }, + propertyConfiguration: { env: 'PROD', userConsented: true }, webAnalyticsConfiguration: { urlCollectQuery: false, urlCollectHash: false, @@ -156,6 +156,16 @@ test('stored consent initializes once and can be withdrawn and granted again', a expect(await page.evaluate(() => window.scopeAnalyticsTest.initializer?.())).toBe(false); await page.getByRole('button', { name: 'Accept analytics' }).click(); await expect.poll(() => page.evaluate(() => window.scopeAnalyticsTest.configurations.length)).toBe(2); + expect(await page.evaluate(() => window.scopeAnalyticsTest.configurations)).toMatchObject([ + { propertyConfiguration: { userConsented: true } }, + { propertyConfiguration: { userConsented: true } }, + ]); + + await page.goto('https://microsoft.github.io/scope/getting-started/access/'); + await expect.poll(() => page.evaluate(() => window.scopeAnalyticsTest.configurations.length)).toBe(1); + expect(await page.evaluate(() => window.scopeAnalyticsTest.configurations[0])).toMatchObject({ + propertyConfiguration: { userConsented: true }, + }); }); test('consent UI follows Starlight theme changes', async ({ page }) => { @@ -175,6 +185,49 @@ test('consent UI follows Starlight theme changes', async ({ page }) => { await expect.poll(() => page.evaluate(() => window.scopeAnalyticsTest.appliedThemes)).toEqual([nextTheme, initialTheme]); }); +for (const path of ['/', '/introduction/what-is-scope/', '/community/articles-and-talks/', '/reference/api/']) { + test(`cookie banner stays above the header on ${path}`, async ({ page }) => { + await mockPublishedSite(page); + await page.goto(`https://microsoft.github.io/scope${path}`); + const banner = page.locator('#scope-cookie-banner'); + await expect(banner.getByRole('button', { name: 'Accept analytics' })).toBeVisible(); + expect(await page.evaluate(() => document.body.firstElementChild?.textContent?.trim())).toBe('Skip to content'); + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'Skip to content' })).toBeFocused(); + + const expectBannerLayout = async () => { + await expect.poll(() => page.evaluate(() => { + const banner = document.querySelector('#scope-cookie-banner')?.getBoundingClientRect(); + const header = document.querySelector('header.header')?.getBoundingClientRect(); + const main = document.querySelector('main')?.getBoundingClientRect(); + return Boolean(banner && header && main && banner.height > 0 && + Math.abs(banner.top) < 1 && Math.abs(header.top - banner.bottom) < 1 && + main.top >= header.bottom - 1 && + document.documentElement.scrollWidth <= document.documentElement.clientWidth); + })).toBe(true); + const menu = page.locator('starlight-menu-button button'); + if (await menu.isVisible()) { + const menuBox = await menu.boundingBox(); + const headerBox = await page.locator('header.header').boundingBox(); + expect(menuBox && headerBox && menuBox.y >= headerBox.y && + menuBox.y + menuBox.height <= headerBox.y + headerBox.height).toBe(true); + } + }; + await expectBannerLayout(); + await banner.evaluate((element) => { + const message = document.createElement('p'); + message.textContent = 'Review your optional cookie choices. '.repeat(10); + element.prepend(message); + }); + await expectBannerLayout(); + + await banner.evaluate((element) => element.replaceChildren()); + await expect.poll(() => page.locator('header.header').evaluate( + (element) => element.getBoundingClientRect().top, + )).toBe(0); + }); +} + for (const options of [ { globalPrivacyControl: true }, { doNotTrack: '1' }, @@ -210,6 +263,11 @@ test('withdrawal while the SDK loads prevents initialization', async ({ page }) releaseSdk?.(); await page.waitForFunction(() => window.oneDS !== undefined); expect(await page.evaluate(() => window.scopeAnalyticsTest.configurations)).toEqual([]); + await page.getByRole('button', { name: 'Accept analytics' }).click(); + await expect.poll(() => page.evaluate(() => window.scopeAnalyticsTest.configurations.length)).toBe(1); + expect(await page.evaluate(() => window.scopeAnalyticsTest.configurations[0])).toMatchObject({ + propertyConfiguration: { userConsented: true }, + }); }); test('blocked consent scripts fail closed without breaking documentation', async ({ page }) => {