Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion website/src/content/docs/resources/data-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
20 changes: 17 additions & 3 deletions website/src/scripts/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,10 +79,17 @@ function loadScript(src: string): Promise<void> {
async function initializeConsent(allowAnalytics: boolean): Promise<void> {
const manageCookies = document.querySelector<HTMLButtonElement>('[data-manage-cookies]');
if (!manageCookies) throw new Error('Missing Scope cookie-management control');
const skipLink = document.querySelector<HTMLAnchorElement>('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');
Expand Down Expand Up @@ -128,7 +135,14 @@ async function initializeConsent(allowAnalytics: boolean): Promise<void> {
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;
Expand Down
11 changes: 10 additions & 1 deletion website/src/styles/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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; }
Expand Down
62 changes: 60 additions & 2 deletions website/tests/analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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' },
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading