From 0049200ef5e259e69aefb75edff61b9eef5f6bf9 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Mon, 20 Apr 2026 10:47:22 -0600 Subject: [PATCH] chore(#3844): add runtime V1/V2 token toggle to PR playgrounds Replaces the comment-out-the-import workflow with a "Switch to V1/V2 tokens" item in the side menu of both the React and Angular playgrounds. Default is V2. Mode persists per-tab via sessionStorage, and ?tokens=v1 in the URL overrides by link. Also removes the per-page useEffect / ngOnInit token injections on the six React pages and three Angular pages that were using them, along with the :root stylesheet-rule-removal hack on feat3229. All redundant under the runtime toggle. No library code touched. Playground infrastructure only. --- apps/prs/angular/project.json | 5 +- apps/prs/angular/src/app/app.component.html | 9 ++++ apps/prs/angular/src/app/app.component.ts | 15 ++++++ .../src/app/token-version/token-version.ts | 47 +++++++++++++++++++ apps/prs/angular/src/main.ts | 13 +++++ .../features/feat2885/feat2885.component.ts | 21 +-------- .../features/feat3229/feat3229.component.ts | 19 +------- .../features/feat3344/feat3344.component.ts | 20 +------- apps/prs/angular/src/styles.css | 1 - apps/prs/react/src/app/app.tsx | 37 +++++++++++++-- apps/prs/react/src/app/tokenVersion.ts | 47 +++++++++++++++++++ apps/prs/react/src/routes/bugs/bug3548.tsx | 14 +----- .../react/src/routes/features/feat2469.tsx | 41 +--------------- .../react/src/routes/features/feat2885.tsx | 17 +------ .../react/src/routes/features/feat3229.tsx | 46 +----------------- .../react/src/routes/features/feat3344.tsx | 14 +----- .../routes/features/feat3407StackOnMobile.tsx | 16 +------ 17 files changed, 177 insertions(+), 205 deletions(-) create mode 100644 apps/prs/angular/src/app/token-version/token-version.ts create mode 100644 apps/prs/react/src/app/tokenVersion.ts diff --git a/apps/prs/angular/project.json b/apps/prs/angular/project.json index 0616e2eac4..3f5e9b6cf7 100644 --- a/apps/prs/angular/project.json +++ b/apps/prs/angular/project.json @@ -32,10 +32,7 @@ "output": "/v2-tokens" } ], - "styles": [ - "apps/prs/angular/src/styles.css", - "node_modules/@abgov/design-tokens-v2/dist/tokens.css" - ], + "styles": ["apps/prs/angular/src/styles.css"], "scripts": [] }, "configurations": { diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index 780a3016ff..fb572efa9d 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -15,9 +15,18 @@ [open]="true" (onNavigate)="handleNavigate($event)" [primaryContent]="primaryTemplate" + [secondaryContent]="tokenToggleTemplate" > + + + + so cascade resolves V2 over V1 unambiguously. Remove any existing + // node first, then append so the fresh node lands last. + document.getElementById(LINK_ID)?.remove(); + + if (mode === "v2") { + const link = document.createElement("link"); + link.id = LINK_ID; + link.rel = "stylesheet"; + link.href = V2_TOKENS_URL; + document.head.appendChild(link); + } + + sessionStorage.setItem(STORAGE_KEY, mode); + + // Only sync URL param if already present; don't add clutter on first toggle. + const url = new URL(window.location.href); + if (url.searchParams.has(URL_PARAM)) { + url.searchParams.set(URL_PARAM, mode); + window.history.replaceState({}, "", url); + } +} + +// Eager side effect: resolve and apply at module load so V2 is in +// before Angular bootstraps. Import this module once from main.ts. +applyTokenVersion(resolveTokenVersion()); diff --git a/apps/prs/angular/src/main.ts b/apps/prs/angular/src/main.ts index c8de31031e..91e46900ae 100644 --- a/apps/prs/angular/src/main.ts +++ b/apps/prs/angular/src/main.ts @@ -1,6 +1,19 @@ import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; import { AppModule } from "./app/app.module"; +// This import has a side effect: token-version.ts calls applyTokenVersion at +// module load, which puts the V2 stylesheet link in before Angular +// bootstraps. Without this, the page would flash V1 on first paint. +import { + applyTokenVersion, + resolveTokenVersion, +} from "./app/token-version/token-version"; platformBrowserDynamic() .bootstrapModule(AppModule) + .then(() => { + // Re-apply after Angular's bundled styles.css is in , so the V2 + // link lands last in the cascade. Without this, the bundled V1 @import + // overrides V2 and the toggle appears to do nothing. + applyTokenVersion(resolveTokenVersion()); + }) .catch((err) => console.error(err)); diff --git a/apps/prs/angular/src/routes/features/feat2885/feat2885.component.ts b/apps/prs/angular/src/routes/features/feat2885/feat2885.component.ts index 7701daffc0..de047f5bdb 100644 --- a/apps/prs/angular/src/routes/features/feat2885/feat2885.component.ts +++ b/apps/prs/angular/src/routes/features/feat2885/feat2885.component.ts @@ -1,4 +1,4 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, OnInit, OnDestroy } from "@angular/core"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; import { GoabIconButton, GoabWorkSideMenu, @@ -46,25 +46,8 @@ type Notification = { ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) -export class Feat2885Component implements OnInit, OnDestroy { +export class Feat2885Component { menuOpen = true; - private v2TokensLink: HTMLLinkElement | null = null; - - ngOnInit() { - // Dynamically load v2 design tokens only while this page is mounted, - // so they don't leak into other routes in the SPA. - this.v2TokensLink = document.createElement("link"); - this.v2TokensLink.rel = "stylesheet"; - this.v2TokensLink.href = "/v2-tokens/tokens.css"; - document.head.appendChild(this.v2TokensLink); - } - - ngOnDestroy() { - if (this.v2TokensLink) { - document.head.removeChild(this.v2TokensLink); - this.v2TokensLink = null; - } - } notifications: Notification[] = [ { diff --git a/apps/prs/angular/src/routes/features/feat3229/feat3229.component.ts b/apps/prs/angular/src/routes/features/feat3229/feat3229.component.ts index 7886af2d9d..a712e5d823 100644 --- a/apps/prs/angular/src/routes/features/feat3229/feat3229.component.ts +++ b/apps/prs/angular/src/routes/features/feat3229/feat3229.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component } from "@angular/core"; import { GoabBlock, GoabDivider, @@ -22,24 +22,9 @@ import { GoabMenuButtonOnActionDetail } from "@abgov/ui-components-common"; GoabText, ], }) -export class Feat3229Component implements OnInit, OnDestroy { - private v2TokensLink: HTMLLinkElement | null = null; +export class Feat3229Component { lastAction = ""; - ngOnInit() { - this.v2TokensLink = document.createElement("link"); - this.v2TokensLink.rel = "stylesheet"; - this.v2TokensLink.href = "/v2-tokens/tokens.css"; - document.head.appendChild(this.v2TokensLink); - } - - ngOnDestroy() { - if (this.v2TokensLink) { - document.head.removeChild(this.v2TokensLink); - this.v2TokensLink = null; - } - } - handleAction(detail: GoabMenuButtonOnActionDetail, label?: string) { const source = label ? ` (${label})` : ""; this.lastAction = `Action "${detail.action}"${source}`; diff --git a/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts index a9c12eb34d..bf2f531072 100644 --- a/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts +++ b/apps/prs/angular/src/routes/features/feat3344/feat3344.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component } from "@angular/core"; import { GoabBlock, GoabDivider, @@ -20,9 +20,7 @@ import { GoabTableOnSortDetail, GoabTableOnMultiSortDetail, GoabTableSortEntry } GoabTableSortHeader, ], }) -export class Feat3344Component implements OnInit, OnDestroy { - private v2TokensLink: HTMLLinkElement | null = null; - +export class Feat3344Component { currentSorts: GoabTableSortEntry[] = []; multiSorts: GoabTableSortEntry[] = []; test3Sorts: GoabTableSortEntry[] = []; @@ -39,20 +37,6 @@ export class Feat3344Component implements OnInit, OnDestroy { multiSorted = [...this.data]; test3Sorted = [...this.data]; - ngOnInit() { - this.v2TokensLink = document.createElement("link"); - this.v2TokensLink.rel = "stylesheet"; - this.v2TokensLink.href = "/v2-tokens/tokens.css"; - document.head.appendChild(this.v2TokensLink); - } - - ngOnDestroy() { - if (this.v2TokensLink) { - document.head.removeChild(this.v2TokensLink); - this.v2TokensLink = null; - } - } - onSingleSortChange(detail: GoabTableOnSortDetail) { this.currentSorts = [{ column: detail.sortBy, direction: detail.sortDir === 1 ? "asc" : "desc" }]; this.singleSorted = this.sortData(this.data, this.currentSorts); diff --git a/apps/prs/angular/src/styles.css b/apps/prs/angular/src/styles.css index 33cd2fa85e..963ef40b19 100644 --- a/apps/prs/angular/src/styles.css +++ b/apps/prs/angular/src/styles.css @@ -1,6 +1,5 @@ /* You can add global styles to this file, and also import other style files */ @import "../../../../dist/libs/web-components/index.css"; -@import "@abgov/design-tokens-v2/dist/tokens.css"; :root { --goa-space-fill: 32ch; diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index af954286b6..80a7b407ba 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import { useState, type CSSProperties } from "react"; import { Outlet, useNavigate } from "react-router-dom"; import { GoabAppFooter, @@ -14,7 +14,20 @@ import { docsRouteDefinitions, featureRouteDefinitions, } from "./route-manifest"; -import "@abgov/design-tokens-v2/dist/tokens.css"; // Production tokens. Comment out to test with legacy V1 token values. + +import "@abgov/style"; +// Runtime V1/V2 token switching. Importing this module applies the currently +// selected token set (default V2) to before the app renders. The +// playground's work-side-menu exposes a secondary item that flips between +// V1 and V2 at runtime without editing source or restarting the dev server. +import { + applyTokenVersion, + resolveTokenVersion, + type TokenVersion, +} from "./tokenVersion"; + +// Sentinel URL handled by onNavigate below to toggle tokens instead of routing. +const TOKEN_TOGGLE_URL = "#tokens"; const appContentStyle: CSSProperties = { display: "flex", @@ -25,8 +38,17 @@ const appContentStyle: CSSProperties = { export function App() { const navigate = useNavigate(); const baseUrl = import.meta.env.BASE_URL; + const [tokenMode, setTokenMode] = useState(() => + resolveTokenVersion(), + ); - const handleNavigate = (path: string) => { + const handleSideMenuNavigate = (path: string) => { + if (path === TOKEN_TOGGLE_URL) { + const next: TokenVersion = tokenMode === "v1" ? "v2" : "v1"; + setTokenMode(next); + applyTokenVersion(next); + return; + } const internal = path.startsWith(baseUrl) ? "/" + path.slice(baseUrl.length) : path; navigate(internal); }; @@ -42,7 +64,14 @@ export function App() { heading="Testing Playground" url={baseUrl} open={true} - onNavigate={handleNavigate} + onNavigate={handleSideMenuNavigate} + secondaryContent={ + + } primaryContent={ <> diff --git a/apps/prs/react/src/app/tokenVersion.ts b/apps/prs/react/src/app/tokenVersion.ts new file mode 100644 index 0000000000..942685c6af --- /dev/null +++ b/apps/prs/react/src/app/tokenVersion.ts @@ -0,0 +1,47 @@ +// ?url tells Vite to resolve the asset path without injecting the CSS. +import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; + +export type TokenVersion = "v1" | "v2"; + +const STORAGE_KEY = "goa-token-version"; +const LINK_ID = "goa-tokens-v2"; +const URL_PARAM = "tokens"; + +export function resolveTokenVersion(): TokenVersion { + const params = new URLSearchParams(window.location.search); + const fromUrl = params.get(URL_PARAM); + if (fromUrl === "v1" || fromUrl === "v2") return fromUrl; + + const fromSession = sessionStorage.getItem(STORAGE_KEY); + if (fromSession === "v1" || fromSession === "v2") return fromSession; + + return "v2"; +} + +export function applyTokenVersion(mode: TokenVersion): void { + // Link-ordering invariant: V2 stylesheet must be the LAST stylesheet in + // so cascade resolves V2 over V1 unambiguously. Remove any existing + // node first, then append so the fresh node lands last. + document.getElementById(LINK_ID)?.remove(); + + if (mode === "v2") { + const link = document.createElement("link"); + link.id = LINK_ID; + link.rel = "stylesheet"; + link.href = v2TokensUrl; + document.head.appendChild(link); + } + + sessionStorage.setItem(STORAGE_KEY, mode); + + // Only sync URL param if it's already in the URL; don't add clutter on first toggle. + const url = new URL(window.location.href); + if (url.searchParams.has(URL_PARAM)) { + url.searchParams.set(URL_PARAM, mode); + window.history.replaceState({}, "", url); + } +} + +// Eager side effect: resolve and apply at module load so V2 is in +// before React's first paint. Importing this module once from app.tsx runs this. +applyTokenVersion(resolveTokenVersion()); diff --git a/apps/prs/react/src/routes/bugs/bug3548.tsx b/apps/prs/react/src/routes/bugs/bug3548.tsx index 810d6e15b9..25ff92ab80 100644 --- a/apps/prs/react/src/routes/bugs/bug3548.tsx +++ b/apps/prs/react/src/routes/bugs/bug3548.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { GoabBlock, GoabText, @@ -10,21 +10,9 @@ import { GoabWorkSideMenuItem, } from "@abgov/react-components"; -import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; - export function Bug3548Route() { const [open, setOpen] = useState(true); - useEffect(() => { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = v2TokensUrl; - document.head.appendChild(link); - return () => { - document.head.removeChild(link); - }; - }, []); - function onToggle() { setOpen((prev) => !prev); } diff --git a/apps/prs/react/src/routes/features/feat2469.tsx b/apps/prs/react/src/routes/features/feat2469.tsx index 2cddee7512..514939e4f7 100644 --- a/apps/prs/react/src/routes/features/feat2469.tsx +++ b/apps/prs/react/src/routes/features/feat2469.tsx @@ -1,4 +1,4 @@ -import { JSX, useState, useEffect } from "react"; +import { JSX, useState } from "react"; import { GoabButton, GoabFormItem, @@ -9,7 +9,6 @@ import { } from "@abgov/react-components"; import { GoabInputOnChangeDetail } from "@abgov/ui-components-common"; -import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; type DataTableFields = { firstName: string; @@ -32,44 +31,6 @@ function generateFakeData(numRows: number): DataTableFields[] { } export function Feat2469Route(): JSX.Element { - useEffect(() => { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = v2TokensUrl; - document.head.appendChild(link); - - const deletedRules: Array<{ sheet: CSSStyleSheet; index: number; cssText: string }> = - []; - - link.onload = () => { - [...document.styleSheets].forEach((ss) => { - if (ss.ownerNode === link) return; - try { - for (let i = ss.cssRules.length - 1; i >= 0; i--) { - const rule = ss.cssRules[i]; - if (rule instanceof CSSStyleRule && rule.selectorText === ":root") { - deletedRules.push({ sheet: ss, index: i, cssText: rule.cssText }); - ss.deleteRule(i); - } - } - } catch (e) { - // skip cross-origin sheets - } - }); - }; - - return () => { - link.remove(); - deletedRules.forEach(({ sheet, index, cssText }) => { - try { - sheet.insertRule(cssText, Math.min(index, sheet.cssRules.length)); - } catch (e) { - // skip if sheet is no longer available - } - }); - }; - }, []); - const smallDrawerControlSet = (

Drawer

diff --git a/apps/prs/react/src/routes/features/feat2885.tsx b/apps/prs/react/src/routes/features/feat2885.tsx index bed09d78b3..c5863f9a5b 100644 --- a/apps/prs/react/src/routes/features/feat2885.tsx +++ b/apps/prs/react/src/routes/features/feat2885.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { GoabIconButton, GoabWorkSideMenu, @@ -7,9 +7,6 @@ import { GoabWorkSideNotificationPanel, } from "@abgov/react-components"; -// ?url suffix tells Vite to resolve the path without injecting the CSS -import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; - type Notification = { id: string; title: string; @@ -23,18 +20,6 @@ type Notification = { export function Feat2885Route() { const [menuOpen, setMenuOpen] = useState(true); - // Dynamically load v2 design tokens only while this page is mounted, - // so they don't leak into other routes in the SPA. - useEffect(() => { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = v2TokensUrl; - document.head.appendChild(link); - return () => { - document.head.removeChild(link); - }; - }, []); - // Helper to get date at specific days ago const daysAgo = (days: number, hours = 0) => { const date = new Date(); diff --git a/apps/prs/react/src/routes/features/feat3229.tsx b/apps/prs/react/src/routes/features/feat3229.tsx index a84e8c5285..3d380f26f9 100644 --- a/apps/prs/react/src/routes/features/feat3229.tsx +++ b/apps/prs/react/src/routes/features/feat3229.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { GoabBadge, GoabBlock, @@ -9,7 +9,6 @@ import { } from "@abgov/react-components"; import { GoabMenuButtonOnActionDetail } from "@abgov/ui-components-common"; -import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; // Menu actions with icon + text const menuActionsWithIcons = ( @@ -34,49 +33,6 @@ const menuActionsTextOnly = ( export function Feat3229Route() { const [lastAction, setLastAction] = useState(""); - useEffect(() => { - // Load V2 tokens - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = v2TokensUrl; - document.head.appendChild(link); - - // Save deleted rules so we can restore them on cleanup - const deletedRules: Array<{ sheet: CSSStyleSheet; index: number; cssText: string }> = - []; - - // Remove V1 token definitions (:root rules) from all other stylesheets, - // keeping component styles intact - link.onload = () => { - [...document.styleSheets].forEach((ss) => { - if (ss.ownerNode === link) return; // skip V2 stylesheet - try { - for (let i = ss.cssRules.length - 1; i >= 0; i--) { - const rule = ss.cssRules[i]; - if (rule instanceof CSSStyleRule && rule.selectorText === ":root") { - deletedRules.push({ sheet: ss, index: i, cssText: rule.cssText }); - ss.deleteRule(i); - } - } - } catch (e) { - // skip cross-origin sheets - } - }); - }; - - return () => { - document.head.removeChild(link); - // Restore V1 :root rules in reverse order to maintain correct indices - deletedRules.reverse().forEach(({ sheet, index, cssText }) => { - try { - sheet.insertRule(cssText, index); - } catch (e) { - console.log(e); - } - }); - }; - }, []); - const handleAction = (detail: GoabMenuButtonOnActionDetail, label?: string) => { const source = label ? ` (${label})` : ""; setLastAction(`Action "${detail.action}"${source}`); diff --git a/apps/prs/react/src/routes/features/feat3344.tsx b/apps/prs/react/src/routes/features/feat3344.tsx index c39cd44f37..f75a9e7062 100644 --- a/apps/prs/react/src/routes/features/feat3344.tsx +++ b/apps/prs/react/src/routes/features/feat3344.tsx @@ -9,7 +9,7 @@ * - V2 focus ring on icon only (not whole button) */ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { GoabBlock, GoabDetails, @@ -17,8 +17,6 @@ import { GoabTable, GoabTableSortHeader, } from "@abgov/react-components"; -// ?url suffix tells Vite to resolve the path without injecting the CSS -import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; import type { GoabTableSortEntry } from "@abgov/ui-components-common"; @@ -51,16 +49,6 @@ function sortData(data: RowData[], sorts: GoabTableSortEntry[]): RowData[] { } export function Feat3344Route() { - useEffect(() => { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = v2TokensUrl; - document.head.appendChild(link); - return () => { - document.head.removeChild(link); - }; - }, []); - const [currentSorts, setCurrentSorts] = useState([]); const [multiSorts, setMultiSorts] = useState([]); const [test3Sorts, setTest3Sorts] = useState([]); diff --git a/apps/prs/react/src/routes/features/feat3407StackOnMobile.tsx b/apps/prs/react/src/routes/features/feat3407StackOnMobile.tsx index 7065463bcd..004b6c821c 100644 --- a/apps/prs/react/src/routes/features/feat3407StackOnMobile.tsx +++ b/apps/prs/react/src/routes/features/feat3407StackOnMobile.tsx @@ -6,23 +6,9 @@ import { GoabText, } from "@abgov/react-components"; -import { useEffect, type JSX } from "react"; -// ?url suffix tells Vite to resolve the path without injecting the CSS -import v2TokensUrl from "@abgov/design-tokens-v2/dist/tokens.css?url"; +import { type JSX } from "react"; export function Feat3407StackOnMobileRoute(): JSX.Element { - // Dynamically load v2 design tokens only while this page is mounted, - // so they don't leak into other routes in the SPA. - useEffect(() => { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = v2TokensUrl; - document.head.appendChild(link); - return () => { - document.head.removeChild(link); - }; - }, []); - return (