diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 2033aa8090..969da32107 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -13,12 +13,26 @@ on:
jobs:
build:
runs-on: ubuntu-latest
+ timeout-minutes: 30
+ container:
+ image: mcr.microsoft.com/playwright:v1.59.0-noble
+
+ env:
+ # the playwright npm postinstall not to download them during `npm ci`.
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
+ # The container runs as root but GitHub sets HOME=/github/home, which the
+ # image owns as `pwuser`. Firefox refuses to launch when $HOME is owned by a
+ # different user, so point HOME at root's own home.
+ HOME: /root
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
+ - name: Allow git to use the container workspace
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
- uses: actions/setup-node@v6
with:
node-version: "24"
@@ -39,6 +53,5 @@ jobs:
- name: Test
run: |
if [ -d "./dist" ]; then
- npx playwright install --with-deps
npm run test:pr
fi
diff --git a/.gitignore b/.gitignore
index 0c7fd8dc05..961c263c11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@ NOTES.md
/test-results
__screenshots__
docs/.astro
+docs/generated/mcp/
+docs/generated/md-bundle/
# environment
.env
diff --git a/apps/prs/angular/src/routes/bugs/3679/bug3679.component.html b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.html
new file mode 100644
index 0000000000..9aaa8d00ab
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.html
@@ -0,0 +1,95 @@
+
+
Bug 3679: Table: 1px horizontal overflow from v2 border
+
+ The V2 table container has width: 100% (inline style) and
+ border: 1px solid but no box-sizing: border-box. The border adds 2px to the total rendered
+ width, causing 1px horizontal scroll on both sides.
+
+
+ Test inside GoabContainer
+
+
+
+ First Name
+ Last Name
+ Job
+
+
+
+
+ Joey
+ Tribbiani
+ Actor
+
+
+ Chandler
+ Bing
+ Statistical Analysis and Data Reconfiguration
+
+
+ Ross
+ Geller
+ Paleontologist
+
+
+
+
+
+
Test inside div with width 40%
+
+
+
+ First Name
+ Last Name
+ Job
+
+
+
+
+ Rachel
+ Green
+ Barrista
+
+
+ Monica
+ Geller
+ Chef
+
+
+ Phoebe
+ Buffay
+ Musician
+
+
+
+
+
+
Test inside fixed 600px container (table should fill container exactly)
+
+
+
+ First Name
+ Last Name
+ Job
+
+
+
+
+ Carol
+ Willick
+ Teacher
+
+
+ Gunther
+ Centralperk
+ Barista
+
+
+ Janice
+ Litman
+ Sales
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/prs/angular/src/routes/bugs/3679/bug3679.component.ts b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.ts
new file mode 100644
index 0000000000..28926dfa70
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.ts
@@ -0,0 +1,10 @@
+import { Component } from "@angular/core";
+import { GoabContainer, GoabTable } from "@abgov/angular-components";
+
+@Component({
+ standalone: true,
+ selector: "abgov-bug3679",
+ templateUrl: "./bug3679.component.html",
+ imports: [GoabContainer, GoabTable],
+})
+export class Bug3679Component {}
\ No newline at end of file
diff --git a/apps/prs/angular/src/routes/bugs/3679/bug3679.route.json b/apps/prs/angular/src/routes/bugs/3679/bug3679.route.json
new file mode 100644
index 0000000000..627a9f3ed3
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3679/bug3679.route.json
@@ -0,0 +1,6 @@
+{
+ "title": "Table: 1px horizontal overflow from v2 border",
+ "path": "bugs/3679",
+ "id": "3679",
+ "type": "bug"
+}
\ No newline at end of file
diff --git a/apps/prs/angular/src/routes/bugs/3854/bug3854.component.html b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.html
new file mode 100644
index 0000000000..7a6013912d
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.html
@@ -0,0 +1,44 @@
+Bug 3854 - Work Side Menu without extra sections
+
+ This test page renders a Work Side Menu with only primary content. Confirm the
+ following:
+
+ The primary menu is visible.
+ The secondary and account menu is hidden.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/prs/angular/src/routes/bugs/3854/bug3854.component.ts b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.ts
new file mode 100644
index 0000000000..fffe75426a
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.ts
@@ -0,0 +1,18 @@
+import { Component } from "@angular/core";
+import {
+ GoabText,
+ GoabWorkSideMenu,
+ GoabWorkSideMenuItem,
+} from "@abgov/angular-components";
+
+@Component({
+ standalone: true,
+ selector: "abgov-bug3854",
+ templateUrl: "./bug3854.component.html",
+ imports: [GoabText, GoabWorkSideMenu, GoabWorkSideMenuItem],
+})
+export class Bug3854Component {
+ handleNavigate(_path: string): void {
+ // Intentionally no-op for visual verification.
+ }
+}
diff --git a/apps/prs/angular/src/routes/bugs/3854/bug3854.route.json b/apps/prs/angular/src/routes/bugs/3854/bug3854.route.json
new file mode 100644
index 0000000000..b2155a2707
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3854/bug3854.route.json
@@ -0,0 +1,6 @@
+{
+ "type": "bug",
+ "id": "3854",
+ "path": "bugs/3854",
+ "title": "Work Side Menu without secondary/account"
+}
diff --git a/apps/prs/angular/src/routes/bugs/3932/bug3932.component.html b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.html
new file mode 100644
index 0000000000..e503da5c80
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.html
@@ -0,0 +1,50 @@
+
+
+ Bug #3932: Work Side Menu tooltips
+
+ This page renders a closed Work Side Menu so tooltip behavior can be tested
+ manually.
+
+
+ Move between multiple items and confirm each tooltip appears in the correct
+ position and shows the matching label.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/prs/angular/src/routes/bugs/3932/bug3932.component.ts b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.ts
new file mode 100644
index 0000000000..f227db7e2f
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.ts
@@ -0,0 +1,22 @@
+import { Component } from "@angular/core";
+import {
+ GoabBlock,
+ GoabText,
+ GoabWorkSideMenu,
+ GoabWorkSideMenuGroup,
+ GoabWorkSideMenuItem,
+} from "@abgov/angular-components";
+
+@Component({
+ standalone: true,
+ selector: "abgov-bug3932",
+ templateUrl: "./bug3932.component.html",
+ imports: [
+ GoabBlock,
+ GoabText,
+ GoabWorkSideMenu,
+ GoabWorkSideMenuGroup,
+ GoabWorkSideMenuItem,
+ ],
+})
+export class Bug3932Component {}
\ No newline at end of file
diff --git a/apps/prs/angular/src/routes/bugs/3932/bug3932.route.json b/apps/prs/angular/src/routes/bugs/3932/bug3932.route.json
new file mode 100644
index 0000000000..ea5a18eeaf
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/3932/bug3932.route.json
@@ -0,0 +1,6 @@
+{
+ "type": "bug",
+ "id": "3932",
+ "path": "bugs/3932",
+ "title": "Work Side Menu tooltips"
+}
\ No newline at end of file
diff --git a/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.html b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.html
new file mode 100644
index 0000000000..3f086a55a8
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.html
@@ -0,0 +1,46 @@
+Fix 3323 - Tooltip with fixed positioning
+
+ CSS anchor positioning support in this browser:
+ {{ supportsCssAnchorPositioning ? "Supported" : "Not supported" }} .
+
+
+Tooltip in callout
+
+
+ Hover me
+
+
+
+Tooltip in container
+
+
+
+
+
+
+Basic tooltip
+
+ Hover me
+
+
+Horizontal alignment
+
+
+
+
+
+
+
+
+
+
+
+
+ Long Tooltip
+
+ Long tooltip
+
diff --git a/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.ts b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.ts
new file mode 100644
index 0000000000..5d316f3590
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.ts
@@ -0,0 +1,31 @@
+import { Component } from "@angular/core";
+import {
+ GoabButton,
+ GoabCallout,
+ GoabContainer,
+ GoabIcon,
+ GoabTooltip,
+ GoabText,
+ GoabGrid,
+} from "@abgov/angular-components";
+
+@Component({
+ standalone: true,
+ selector: "abgov-bug3323",
+ templateUrl: "./bug3323.component.html",
+ imports: [
+ GoabButton,
+ GoabCallout,
+ GoabContainer,
+ GoabIcon,
+ GoabTooltip,
+ GoabText,
+ GoabGrid,
+ ],
+})
+export class Bug3323Component {
+ readonly supportsCssAnchorPositioning =
+ typeof CSS !== "undefined" &&
+ typeof CSS.supports === "function" &&
+ CSS.supports("anchor-name: --goa-tooltip-target");
+}
diff --git a/apps/prs/angular/src/routes/bugs/bug3323/bug3323.route.json b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.route.json
new file mode 100644
index 0000000000..b6a94b642b
--- /dev/null
+++ b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.route.json
@@ -0,0 +1,6 @@
+{
+ "type": "bug",
+ "id": "3323",
+ "path": "bugs/bug3323",
+ "title": "Tooltip with fixed positioning"
+}
diff --git a/apps/prs/react/src/app/routes/bugs/bug3323.route.ts b/apps/prs/react/src/app/routes/bugs/bug3323.route.ts
new file mode 100644
index 0000000000..9fa55ba480
--- /dev/null
+++ b/apps/prs/react/src/app/routes/bugs/bug3323.route.ts
@@ -0,0 +1,10 @@
+import { Bug3323Route } from "../../../routes/bugs/bug3323";
+import type { PrRouteDefinition } from "../../route-manifest";
+
+export default {
+ type: "bug",
+ id: "3323",
+ path: "bugs/bug3323",
+ title: "Tooltip with fixed positioning",
+ component: Bug3323Route,
+} satisfies PrRouteDefinition;
diff --git a/apps/prs/react/src/app/routes/bugs/bug3679.route.ts b/apps/prs/react/src/app/routes/bugs/bug3679.route.ts
new file mode 100644
index 0000000000..23e5fc678e
--- /dev/null
+++ b/apps/prs/react/src/app/routes/bugs/bug3679.route.ts
@@ -0,0 +1,10 @@
+import { Bug3679Route } from "../../../routes/bugs/bug3679";
+import type { PrRouteDefinition } from "../../route-manifest";
+
+export default {
+ type: "bug",
+ id: "3679",
+ path: "bugs/3679",
+ title: "Table: 1px horizontal overflow from v2 border",
+ component: Bug3679Route,
+} satisfies PrRouteDefinition;
diff --git a/apps/prs/react/src/app/routes/bugs/bug3932.route.ts b/apps/prs/react/src/app/routes/bugs/bug3932.route.ts
new file mode 100644
index 0000000000..9f8b9e3117
--- /dev/null
+++ b/apps/prs/react/src/app/routes/bugs/bug3932.route.ts
@@ -0,0 +1,10 @@
+import Bug3932Route from "../../../routes/bugs/bug3932";
+import type { PrRouteDefinition } from "../../route-manifest";
+
+export default {
+ type: "bug",
+ id: "3932",
+ path: "bugs/3932",
+ title: "Work Side Menu tooltips",
+ component: Bug3932Route,
+} satisfies PrRouteDefinition;
\ No newline at end of file
diff --git a/apps/prs/react/src/routes/bugs/bug3323.tsx b/apps/prs/react/src/routes/bugs/bug3323.tsx
new file mode 100644
index 0000000000..222897e165
--- /dev/null
+++ b/apps/prs/react/src/routes/bugs/bug3323.tsx
@@ -0,0 +1,83 @@
+import type { CSSProperties } from "react";
+import {
+ GoabButton,
+ GoabCallout,
+ GoabTooltip,
+ GoabIcon,
+ GoabContainer,
+ GoabText,
+ GoabGrid,
+} from "@abgov/react-components";
+
+const supportsCssAnchorPositioning =
+ typeof CSS !== "undefined" &&
+ typeof CSS.supports === "function" &&
+ CSS.supports("anchor-name: --goa-tooltip-target");
+
+export function Bug3323Route() {
+ return (
+ <>
+
+ Fix 3323 - Tooltip with fixed positioning
+
+
+ CSS anchor positioning support in this browser:{" "}
+ {supportsCssAnchorPositioning ? "Supported" : "Not supported"} .
+
+
+
+ Tooltip in callout
+
+
+
+
+ Hover me
+
+
+
+
+
+ Tooltip in container
+
+
+
+
+
+
+
+
+ Basic tooltip
+
+
+ Hover me
+
+
+
+ Horizontal alignment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Long Tooltip
+
+
+
+ Long tooltip
+
+
+ >
+ );
+}
diff --git a/apps/prs/react/src/routes/bugs/bug3679.tsx b/apps/prs/react/src/routes/bugs/bug3679.tsx
new file mode 100644
index 0000000000..6414bd3777
--- /dev/null
+++ b/apps/prs/react/src/routes/bugs/bug3679.tsx
@@ -0,0 +1,111 @@
+import { GoabContainer, GoabTable } from "@abgov/react-components";
+
+export function Bug3679Route() {
+ return (
+
+
Bug 3679: Table: 1px horizontal overflow from v2 border
+
+ The V2 table container has width: 100% (inline style) and
+ border: 1px solid but no
+ box-sizing: border-box. The border adds 2px to the total rendered
+ width, causing 1px horizontal scroll on both sides.
+
+
+ Test inside GoabContainer
+
+
+
+ First Name
+ Last Name
+ Job
+
+
+
+
+ Joey
+ Tribbiani
+ Actor
+
+
+ Chandler
+ Bing
+ Statistical Analysis and Data Reconfiguration
+
+
+ Ross
+ Geller
+ Paleontologist
+
+
+
+
+
+
Test inside div with width 40%
+
+
+
+ First Name
+ Last Name
+ Job
+
+
+
+
+ Rachel
+ Green
+ Barrista
+
+
+ Monica
+ Geller
+ Chef
+
+
+ Phoebe
+ Buffay
+ Musician
+
+
+
+
+
+
+ Test inside fixed 600px container (table should fill container exactly)
+
+
+
+
+ First Name
+ Last Name
+ Job
+
+
+
+
+ Carol
+ Willick
+ Teacher
+
+
+ Gunther
+ Centralperk
+ Barista
+
+
+ Janice
+ Litman
+ Sales
+
+
+
+
+
+ );
+}
diff --git a/apps/prs/react/src/routes/bugs/bug3932.tsx b/apps/prs/react/src/routes/bugs/bug3932.tsx
new file mode 100644
index 0000000000..4de259ceb0
--- /dev/null
+++ b/apps/prs/react/src/routes/bugs/bug3932.tsx
@@ -0,0 +1,62 @@
+import {
+ GoabBlock,
+ GoabText,
+ GoabWorkSideMenu,
+ GoabWorkSideMenuGroup,
+ GoabWorkSideMenuItem,
+} from "@abgov/react-components";
+
+export function Bug3932Route() {
+ const menuHeight = { "--goa-work-side-menu-height": "500px" } as React.CSSProperties;
+ return (
+
+
+
+ Bug #3932: Work Side Menu tooltips
+
+
+ This page renders a closed Work Side Menu so tooltip behavior can be tested
+ manually.
+
+
+ Move between multiple items and confirm each tooltip appears in the correct
+ position and shows the matching label.
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ secondaryContent={
+ <>
+
+
+ >
+ }
+ />
+
+
+ );
+}
+
+export default Bug3932Route;
diff --git a/docs/generated/component-apis/temporary-notification.json b/docs/generated/component-apis/temporary-notification.json
index c186f7fa79..e90e9bb432 100644
--- a/docs/generated/component-apis/temporary-notification.json
+++ b/docs/generated/component-apis/temporary-notification.json
@@ -1,6 +1,6 @@
{
"componentSlug": "temporary-notification",
- "extractedFrom": "libs/web-components/src/components/temporary-notification/TemporaryNotification.svelte",
+ "extractedFrom": "libs/web-components/src/components/temporary-notification/TemporaryNotificationCtrl.svelte",
"frameworks": {
"react": {
"props": [
@@ -59,64 +59,34 @@
"webComponents": {
"props": [
{
- "name": "action-text",
- "type": "string",
- "required": false,
- "default": "",
- "description": "Text for the optional action button. When provided, displays a clickable link button."
- },
- {
- "name": "animation-direction",
- "type": "\"up\" | \"down\"",
+ "name": "horizontal-position",
+ "type": "\"left\" | \"center\" | \"right\"",
"values": [
- "up",
- "down"
+ "left",
+ "center",
+ "right"
],
"required": false,
- "default": "down",
- "description": "Direction the notification animates from when appearing or disappearing."
- },
- {
- "name": "message",
- "type": "string",
- "required": false,
- "default": "",
- "description": "The notification message text to display."
- },
- {
- "name": "progress",
- "type": "number",
- "required": false,
- "default": "-1",
- "description": "Progress value from 0-100. Use -1 to hide the progress bar. Only applies when type is \"progress\"."
+ "default": "center",
+ "description": "Horizontal position of the notification container."
},
{
"name": "testid",
"type": "string",
- "required": false,
- "default": "",
+ "required": true,
+ "default": null,
"description": "Sets a data-testid attribute for automated testing."
},
{
- "name": "type",
- "type": "\"basic\" | \"success\" | \"failure\" | \"indeterminate\" | \"progress\"",
+ "name": "vertical-position",
+ "type": "\"top\" | \"bottom\"",
"values": [
- "basic",
- "success",
- "failure",
- "indeterminate",
- "progress"
+ "top",
+ "bottom"
],
"required": false,
- "default": "basic",
- "description": "The notification type which determines the visual style and icon."
- },
- {
- "name": "visible",
- "type": "boolean",
- "required": false,
- "default": "true",
- "description": "Controls whether the notification is visible."
+ "default": "bottom",
+ "description": "Vertical position of the notification container."
}
],
"events": [],
diff --git a/docs/public/images/home/dark-mode-feature-animation.svg b/docs/public/images/home/dark-mode-feature-animation.svg
new file mode 100644
index 0000000000..092d7b6876
--- /dev/null
+++ b/docs/public/images/home/dark-mode-feature-animation.svg
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/public/search-index.json b/docs/public/search-index.json
index be01a451f5..f5c71b4195 100644
--- a/docs/public/search-index.json
+++ b/docs/public/search-index.json
@@ -2372,8 +2372,8 @@
{
"type": "page",
"id": "designers/designing-with-ds",
- "title": "Designing with Design System 2.0",
- "description": "How to start new designs and update existing projects to Design System 2.0",
+ "title": "Designing with the updated design system",
+ "description": "How to start using the updated design system in your designs",
"status": "published",
"category": "get started",
"tags": [
diff --git a/docs/src/content/components/table-sort-header.mdx b/docs/src/content/components/table-sort-header.mdx
new file mode 100644
index 0000000000..6f30276dfa
--- /dev/null
+++ b/docs/src/content/components/table-sort-header.mdx
@@ -0,0 +1,10 @@
+---
+name: Table Sort Header
+description: Sortable column header used inside a table to let users sort rows by that column.
+status: stable
+category: content-layout
+hidden: true
+subcomponent: true
+relatedComponents:
+ - table
+---
diff --git a/docs/src/content/components/table.mdx b/docs/src/content/components/table.mdx
index aa4aeb23c0..8afb082611 100644
--- a/docs/src/content/components/table.mdx
+++ b/docs/src/content/components/table.mdx
@@ -11,5 +11,6 @@ tags:
relatedComponents:
- data-grid
- pagination
+ - table-sort-header
figmaUrl: https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/Component-library?node-id=27343-613386
---
diff --git a/docs/src/content/get-started/designers/designing-with-ds.mdx b/docs/src/content/get-started/designers/designing-with-ds.mdx
index 3be1641e5b..5a4cd0ccf2 100644
--- a/docs/src/content/get-started/designers/designing-with-ds.mdx
+++ b/docs/src/content/get-started/designers/designing-with-ds.mdx
@@ -1,63 +1,56 @@
---
id: designers/designing-with-ds
-title: Designing with Design System 2.0
-description: How to start new designs and update existing projects to Design System 2.0
+title: Designing with the updated design system
+description: How to start using the updated design system in your designs
section: designers
order: 2
status: published
---
+import { withBase } from "@/lib/base-url";
import DropInCallout from "../../../components/DropInCallout.astro";
-Designing with Design System 2.0
-What's new in DS 2.0 and how to start using it in your designs.
-
- The Digital Design and Delivery (DDD) Design System has evolved to better support how service teams
- design and build digital government services. Design System 2.0 includes an updated design
- language, new tokens, refreshed components, and ready-to-use templates.
-
+Designing with the updated design system
+How to start using the updated design system in your designs.
Starting a new design
- Use DS 2.0 templates as your starting point. Templates package layout, navigation, spacing, and
- example content that you can adapt to your service. Some templates are built for specific service types
- (for example, public-facing forms and workspace applications), and others are more general-purpose
+ Use updated design system templates as your starting point. Templates package layout, navigation, spacing, and
+ example content that you can adapt to your service. Some templates are built for specific service types —
+ for example, public-facing forms and workspace applications. Others are general-purpose
starting points you can use across many kinds of pages and flows.
-Starting from an existing design
+Updating an existing design file
+Choose one of two approaches depending on how much control you need over the process.
Library swap
- Use a library swap if
- your goal is to convert most of the file to DS 2.0 in one pass.
+ Use a library swap if
+ your goal is to convert most of the file to the updated design system in one pass.
Make a copy of your file before you start.
- Expect mixed results. Some screens may need cleanup after the swap.
+ Expect mixed results — some screens may need cleanup after the swap.
Manual rebuild
-Use a manual rebuild if you need more control.
+Use a manual rebuild if you need more control over the process.
Rebuild your highest value screens first.
- Consider using DS 2.0 templates as a base to speed things up.
+ Use updated design system templates as a base to speed things up.
-Update an existing project to the new design library
-Approach 1: Developer-first
-Use this approach in most cases.
-
- A developer creates a new branch and completes the upgrade steps.
- The team runs the upgraded application to see what changed.
- Designers review the upgraded screens and identify what needs design updates.
-
-When reviewing the upgraded branch, watch for:
-
- Spacing and layout rhythm
- Typography scale and weight usage
- Dense or constrained screens (these often have the highest risk during an update)
-
+Updating an existing product
+Choose your approach based on whether design or development work is leading the update.
+
+Developer-first approach
+
+ Use this approach in most cases. A developer creates a branch and runs the update so your team
+ can see what changed in the real product before committing to design work. Designers then review
+ the updated screens and identify what needs follow-up.
+
+See Updating your product for the full steps.
-Approach 2: Design-first
+Design-first approach
Use this approach when you want design work to move ahead of development work.
Identify custom elements that support your users' needs.
@@ -66,6 +59,6 @@ import DropInCallout from "../../../components/DropInCallout.astro";
- In most cases, start with the developer-first approach. It helps you quickly see what changed in your real product, so you can focus design effort where it matters most.
+ Use this approach in most cases. It helps you focus design effort on what actually changed in your product rather than redesigning based on assumptions.
diff --git a/docs/src/content/get-started/migration-guide.mdx b/docs/src/content/get-started/migration-guide.mdx
index 27692407b0..9df4953113 100644
--- a/docs/src/content/get-started/migration-guide.mdx
+++ b/docs/src/content/get-started/migration-guide.mdx
@@ -101,7 +101,10 @@ import { withBase } from "@/lib/base-url";
Is the team prepared to accept reduced support and higher maintenance risk if migration is delayed?
-
-
- Review the setup steps for developers
-
+Next steps by role
+
+Designers
+Designing with the updated design system — how to update your Figma files, the library swap process, and how design and dev migration work together
+
+Developers and technical leads
+Updating your product — the developer-first upgrade approach, step-by-step instructions, and what to review after the update
diff --git a/docs/src/content/guidance/badge-arialabel-icon-only.mdx b/docs/src/content/guidance/badge-arialabel-icon-only.mdx
index c9e38f3214..575c8cc1a0 100644
--- a/docs/src/content/guidance/badge-arialabel-icon-only.mdx
+++ b/docs/src/content/guidance/badge-arialabel-icon-only.mdx
@@ -12,6 +12,6 @@ appliesTo:
- badge
relatedProps:
- ariaLabel
- - icon
+ - iconType
status: published
---
diff --git a/docs/src/content/guidance/datepicker-calendar-variant.mdx b/docs/src/content/guidance/datepicker-calendar-variant.mdx
index ab2db37658..1489476a2e 100644
--- a/docs/src/content/guidance/datepicker-calendar-variant.mdx
+++ b/docs/src/content/guidance/datepicker-calendar-variant.mdx
@@ -10,7 +10,7 @@ appliesTo:
components:
- date-picker
relatedProps:
- - variant
+ - type
status: published
---
diff --git a/docs/src/content/guidance/datepicker-input-variant.mdx b/docs/src/content/guidance/datepicker-input-variant.mdx
index ff53ce7945..d8e8cbb7fe 100644
--- a/docs/src/content/guidance/datepicker-input-variant.mdx
+++ b/docs/src/content/guidance/datepicker-input-variant.mdx
@@ -10,12 +10,12 @@ appliesTo:
components:
- date-picker
relatedProps:
- - variant
+ - type
status: published
---
-
+
diff --git a/docs/src/content/guidance/page-requires-one-column-layout.mdx b/docs/src/content/guidance/page-requires-one-column-layout.mdx
index 903afc9b47..91c9d32457 100644
--- a/docs/src/content/guidance/page-requires-one-column-layout.mdx
+++ b/docs/src/content/guidance/page-requires-one-column-layout.mdx
@@ -6,9 +6,6 @@ topic: other
tags:
- layout
- page-structure
-appliesTo:
- components:
- - one-column-layout
status: published
---
diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro
index d925a33b14..2331338fb5 100644
--- a/docs/src/pages/index.astro
+++ b/docs/src/pages/index.astro
@@ -76,6 +76,36 @@ import CardLite from '../components/CardLite.astro';
/>
+
+
+
+
Dark mode
+
+ Dark mode is available as an experimental capability in the design system. Try it in your service and share what you learn — your feedback helps shape how we develop it further.
+
+
+
+
+
+
+
+
Understand your service
@@ -280,6 +310,15 @@ import CardLite from '../components/CardLite.astro';
margin: 0 0 var(--goa-space-m);
}
+ .dark-mode-links {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--goa-space-s);
+ }
+
.cta-image {
flex: 2;
min-width: 0;
@@ -384,6 +423,10 @@ import CardLite from '../components/CardLite.astro';
.cta-section {
flex-direction: column;
}
+
+ .dark-mode-section .cta-image {
+ width: 100%;
+ }
}
/* Mobile breakpoint - 767px */
diff --git a/docs/src/scripts/content-generators/checks/component-refs.ts b/docs/src/scripts/content-generators/checks/component-refs.ts
new file mode 100644
index 0000000000..ab3227115a
--- /dev/null
+++ b/docs/src/scripts/content-generators/checks/component-refs.ts
@@ -0,0 +1,37 @@
+import type { AnyRecord, Finding } from "../types";
+import { collectIdsAndAliases, pushBrokenRefs } from "./lib";
+
+/**
+ * Validates the four cross-references that target component ids:
+ * - components.relatedComponents
+ * - examples.components
+ * - guidance.appliesTo.components
+ * - productTypes.components
+ *
+ * All produce error-severity findings when a referenced id is not in the
+ * components collection (either as a canonical id or as an alias).
+ */
+export function checkComponentRefs(records: AnyRecord[]): Finding[] {
+ const findings: Finding[] = [];
+ const componentIds = collectIdsAndAliases(records, "components");
+
+ for (const r of records) {
+ if (r.collection === "components") {
+ pushBrokenRefs(findings, r, "relatedComponents", r.relatedComponents, componentIds);
+ } else if (r.collection === "examples") {
+ pushBrokenRefs(findings, r, "components", r.components, componentIds);
+ } else if (r.collection === "guidance") {
+ pushBrokenRefs(
+ findings,
+ r,
+ "appliesTo.components",
+ r.appliesTo?.components,
+ componentIds,
+ );
+ } else if (r.collection === "productTypes") {
+ pushBrokenRefs(findings, r, "components", r.components, componentIds);
+ }
+ }
+
+ return findings;
+}
diff --git a/docs/src/scripts/content-generators/checks/id-refs.ts b/docs/src/scripts/content-generators/checks/id-refs.ts
new file mode 100644
index 0000000000..c8240677ba
--- /dev/null
+++ b/docs/src/scripts/content-generators/checks/id-refs.ts
@@ -0,0 +1,26 @@
+import type { AnyRecord, Finding } from "../types";
+import { collectIds, collectIdsAndAliases, pushBrokenRefs } from "./lib";
+
+/**
+ * Validates cross-references against guidance and example ids:
+ * - components.relatedGuidance → guidance ids
+ * - examples.relatedExamples → example ids (and aliases, since examples
+ * carry old-slug aliases via EXAMPLE_SLUG_ALIASES)
+ *
+ * Both produce error-severity findings.
+ */
+export function checkIdRefs(records: AnyRecord[]): Finding[] {
+ const findings: Finding[] = [];
+ const guidanceIds = collectIds(records, "guidance");
+ const exampleIds = collectIdsAndAliases(records, "examples");
+
+ for (const r of records) {
+ if (r.collection === "components") {
+ pushBrokenRefs(findings, r, "relatedGuidance", r.relatedGuidance, guidanceIds);
+ } else if (r.collection === "examples") {
+ pushBrokenRefs(findings, r, "relatedExamples", r.relatedExamples, exampleIds);
+ }
+ }
+
+ return findings;
+}
diff --git a/docs/src/scripts/content-generators/checks/index.ts b/docs/src/scripts/content-generators/checks/index.ts
new file mode 100644
index 0000000000..e568aafdb8
--- /dev/null
+++ b/docs/src/scripts/content-generators/checks/index.ts
@@ -0,0 +1,15 @@
+import type { AnyRecord, Finding } from "../types";
+import { checkComponentRefs } from "./component-refs";
+import { checkIdRefs } from "./id-refs";
+import { checkPropRefs } from "./prop-refs";
+
+/**
+ * Run all cross-validation checks against the loaded records.
+ */
+export function runChecks(records: AnyRecord[]): Finding[] {
+ return [
+ ...checkComponentRefs(records),
+ ...checkIdRefs(records),
+ ...checkPropRefs(records),
+ ];
+}
diff --git a/docs/src/scripts/content-generators/checks/lib.ts b/docs/src/scripts/content-generators/checks/lib.ts
new file mode 100644
index 0000000000..c50b68a5a0
--- /dev/null
+++ b/docs/src/scripts/content-generators/checks/lib.ts
@@ -0,0 +1,81 @@
+import { distance } from "fastest-levenshtein";
+import type { AnyRecord, Finding } from "../types";
+
+const MAX_EDIT_DISTANCE = 2;
+
+/** Returns the set of ids for records in the given collection. */
+export function collectIds(
+ records: AnyRecord[],
+ collection: AnyRecord["collection"],
+): Set {
+ const ids = new Set();
+ for (const r of records) {
+ if (r.collection === collection) ids.add(r.id);
+ }
+ return ids;
+}
+
+/**
+ * Returns the set of ids plus aliases for records in the given collection.
+ * Lets cross-references match either the canonical id or any legacy/sub-component
+ * name surfaced on the record's aliases array.
+ */
+export function collectIdsAndAliases(
+ records: AnyRecord[],
+ collection: AnyRecord["collection"],
+): Set {
+ const set = new Set();
+ for (const r of records) {
+ if (r.collection !== collection) continue;
+ set.add(r.id);
+ const aliases = (r as { aliases?: string[] }).aliases;
+ if (Array.isArray(aliases)) for (const a of aliases) set.add(a);
+ }
+ return set;
+}
+
+/**
+ * For each ref in `refs` that does not appear in `validIds`, push an
+ * error-severity Finding describing the broken reference. Populates
+ * `hint` when a close-by valid id exists.
+ */
+export function pushBrokenRefs(
+ findings: Finding[],
+ source: AnyRecord,
+ field: string,
+ refs: string[] | undefined,
+ validIds: Set,
+): void {
+ if (!Array.isArray(refs)) return;
+ for (const ref of refs) {
+ if (validIds.has(ref)) continue;
+ findings.push({
+ severity: "error",
+ source: { collection: source.collection, id: source.id },
+ field,
+ brokenRef: ref,
+ hint: computeHint(ref, validIds),
+ });
+ }
+}
+
+/**
+ * If any id in `validIds` is within MAX_EDIT_DISTANCE of `ref`, return
+ * `did you mean ""?`. If multiple ids tie, take the first by
+ * sort order. Returns undefined if no id is close enough.
+ */
+export function computeHint(ref: string, validIds: Set): string | undefined {
+ let best: string | undefined;
+ let bestDistance = MAX_EDIT_DISTANCE + 1;
+
+ const sorted = [...validIds].sort();
+ for (const candidate of sorted) {
+ const d = distance(ref, candidate);
+ if (d < bestDistance) {
+ bestDistance = d;
+ best = candidate;
+ }
+ }
+
+ return best ? `did you mean "${best}"?` : undefined;
+}
diff --git a/docs/src/scripts/content-generators/checks/prop-refs.ts b/docs/src/scripts/content-generators/checks/prop-refs.ts
new file mode 100644
index 0000000000..fe09aa550e
--- /dev/null
+++ b/docs/src/scripts/content-generators/checks/prop-refs.ts
@@ -0,0 +1,100 @@
+import type { AnyRecord, ComponentRecord, Finding } from "../types";
+import { computeHint } from "./lib";
+import { buildComponentLookup } from "../transforms/component-lookup";
+
+/**
+ * Validates guidance.relatedProps against the extracted prop names on the
+ * components named in guidance.appliesTo.components.
+ *
+ * Warn-level. A relatedProps entry is valid if it exists on AT LEAST ONE
+ * of the linked components, matching the semantic "this guidance is about
+ * this prop, which lives on one of these components."
+ *
+ * Component slugs resolve through aliases, so a guidance atom that points
+ * at `text-area` validates against the props of the canonical `textarea`
+ * record.
+ *
+ * Skips guidance atoms whose appliesTo.components references components
+ * that do not exist; component-refs.ts reports those separately.
+ */
+export function checkPropRefs(records: AnyRecord[]): Finding[] {
+ const findings: Finding[] = [];
+ const components = records.filter(
+ (r): r is ComponentRecord => r.collection === "components",
+ );
+ const lookup = buildComponentLookup(components);
+
+ for (const r of records) {
+ if (r.collection !== "guidance") continue;
+ if (!Array.isArray(r.relatedProps) || r.relatedProps.length === 0) continue;
+ const linkedComponents = r.appliesTo?.components;
+ if (!Array.isArray(linkedComponents) || linkedComponents.length === 0) {
+ continue;
+ }
+
+ // Build the union of props across linked components that exist.
+ // Missing components are reported by component-refs.ts; skip them here
+ // to avoid double-reports.
+ const allValidProps = new Set();
+ const knownComponents: string[] = [];
+ for (const componentId of linkedComponents) {
+ const component = lookup.get(componentId);
+ if (!component) continue;
+ knownComponents.push(componentId);
+ for (const propName of extractPropNames(component)) {
+ allValidProps.add(propName);
+ }
+ }
+ if (knownComponents.length === 0) continue;
+
+ for (const propRef of r.relatedProps) {
+ if (allValidProps.has(propRef)) continue;
+ findings.push({
+ severity: "warning",
+ source: { collection: "guidance", id: r.id },
+ field: "relatedProps",
+ brokenRef: propRef,
+ context:
+ knownComponents.length === 1
+ ? `on component "${knownComponents[0]}"`
+ : `on components ${knownComponents.map((c) => `"${c}"`).join(", ")}`,
+ notFoundMessage: "not in extracted props",
+ hint: computeHint(propRef, allValidProps),
+ });
+ }
+ }
+
+ return findings;
+}
+
+/**
+ * Walks the component's api blob (frameworks.{react,angular,webComponents}.props[])
+ * and returns the union of all prop names declared across frameworks.
+ */
+function extractPropNames(component: ComponentRecord): Set {
+ const names = new Set();
+ const api = component.api;
+ if (!api || typeof api !== "object") return names;
+
+ const frameworks = (api as Record).frameworks;
+ if (!frameworks || typeof frameworks !== "object") return names;
+
+ for (const key of ["react", "angular", "webComponents"]) {
+ const framework = (frameworks as Record)[key];
+ if (!framework || typeof framework !== "object") continue;
+ const props = (framework as Record).props;
+ if (!Array.isArray(props)) continue;
+ for (const prop of props) {
+ if (
+ typeof prop === "object" &&
+ prop !== null &&
+ "name" in prop &&
+ typeof (prop as { name: unknown }).name === "string"
+ ) {
+ names.add((prop as { name: string }).name);
+ }
+ }
+ }
+
+ return names;
+}
diff --git a/docs/src/scripts/content-generators/checks/render.ts b/docs/src/scripts/content-generators/checks/render.ts
new file mode 100644
index 0000000000..6ee7094f67
--- /dev/null
+++ b/docs/src/scripts/content-generators/checks/render.ts
@@ -0,0 +1,54 @@
+import type { Finding } from "../types";
+
+/**
+ * Render the findings to stderr. Errors first, then warnings, then a summary.
+ * No-op when there are zero findings.
+ */
+export function renderFindings(findings: Finding[]): void {
+ const errors = findings.filter((f) => f.severity === "error");
+ const warnings = findings.filter((f) => f.severity === "warning");
+
+ if (errors.length === 0 && warnings.length === 0) return;
+
+ if (errors.length > 0) {
+ process.stderr.write(`\n✗ ERRORS (${errors.length})\n`);
+ renderGroup(errors);
+ }
+
+ if (warnings.length > 0) {
+ process.stderr.write(`\n⚠ WARNINGS (${warnings.length})\n`);
+ renderGroup(warnings);
+ }
+
+ process.stderr.write(
+ `\nSummary: ${errors.length} ${plural("error", errors.length)}, ` +
+ `${warnings.length} ${plural("warning", warnings.length)}.\n\n`,
+ );
+}
+
+function renderGroup(findings: Finding[]): void {
+ const bySource = new Map();
+ for (const f of findings) {
+ const key = `${f.source.collection}/${f.source.id}`;
+ if (!bySource.has(key)) bySource.set(key, []);
+ bySource.get(key)!.push(f);
+ }
+
+ for (const [key, group] of bySource) {
+ process.stderr.write(`\n ${key}\n`);
+ for (const f of group) {
+ process.stderr.write(` ${renderLine(f)}\n`);
+ }
+ }
+}
+
+function renderLine(f: Finding): string {
+ const ref = f.context ? `"${f.brokenRef}" ${f.context}` : `"${f.brokenRef}"`;
+ const notFound = f.notFoundMessage ?? "not found";
+ const tail = f.hint ? `${notFound}, ${f.hint}` : notFound;
+ return `${f.field} references ${ref} (${tail})`;
+}
+
+function plural(word: string, n: number): string {
+ return n === 1 ? word : `${word}s`;
+}
diff --git a/docs/src/scripts/content-generators/config.ts b/docs/src/scripts/content-generators/config.ts
new file mode 100644
index 0000000000..acf0c7b344
--- /dev/null
+++ b/docs/src/scripts/content-generators/config.ts
@@ -0,0 +1,44 @@
+import * as path from "path";
+import { fileURLToPath } from "url";
+
+const WORKSPACE_ROOT = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../..",
+);
+
+export const paths = {
+ workspaceRoot: WORKSPACE_ROOT,
+ content: {
+ components: path.join(WORKSPACE_ROOT, "docs/src/content/components"),
+ examples: path.join(WORKSPACE_ROOT, "docs/src/content/examples"),
+ guidance: path.join(WORKSPACE_ROOT, "docs/src/content/guidance"),
+ foundations: path.join(WORKSPACE_ROOT, "docs/src/content/foundations"),
+ getStarted: path.join(WORKSPACE_ROOT, "docs/src/content/get-started"),
+ productTypes: path.join(WORKSPACE_ROOT, "docs/src/content/productTypes"),
+ },
+ code: {
+ componentApis: path.join(WORKSPACE_ROOT, "docs/generated/component-apis"),
+ libs: path.join(WORKSPACE_ROOT, "libs"),
+ },
+ output: {
+ mcp: path.join(WORKSPACE_ROOT, "docs/generated/mcp"),
+ mdBundle: path.join(WORKSPACE_ROOT, "docs/generated/md-bundle"),
+ },
+} as const;
+
+export type CollectionName =
+ | "components"
+ | "examples"
+ | "guidance"
+ | "foundations"
+ | "get-started"
+ | "productTypes";
+
+export const collectionNames: CollectionName[] = [
+ "components",
+ "examples",
+ "guidance",
+ "foundations",
+ "get-started",
+ "productTypes",
+];
diff --git a/docs/src/scripts/content-generators/index.ts b/docs/src/scripts/content-generators/index.ts
new file mode 100644
index 0000000000..be26838afb
--- /dev/null
+++ b/docs/src/scripts/content-generators/index.ts
@@ -0,0 +1,100 @@
+import { loadComponents } from "./loaders/components";
+import { loadExamples } from "./loaders/examples";
+import { loadGuidance } from "./loaders/guidance";
+import { loadFoundations } from "./loaders/foundations";
+import { loadGetStarted } from "./loaders/get-started";
+import { loadProductTypes } from "./loaders/productTypes";
+import { loadComponentApis } from "./loaders/component-apis";
+import { loadFrameworkIdentifiers } from "./loaders/framework-identifiers";
+import { addComponentAliases, addExampleAliases } from "./transforms/aliases";
+import { linkGuidanceToComponents } from "./transforms/link-guidance";
+import { writeMcpJson } from "./outputs/mcp-json";
+import { runChecks } from "./checks";
+import { renderFindings } from "./checks/render";
+import type { AnyRecord, ComponentRecord } from "./types";
+
+function main(): void {
+ const start = performance.now();
+
+ // 1. Load raw data from all sources.
+ const componentsRaw = loadComponents();
+ const examplesRaw = loadExamples();
+ const guidance = loadGuidance();
+ const foundations = loadFoundations();
+ const getStarted = loadGetStarted();
+ const productTypes = loadProductTypes();
+ const apis = loadComponentApis();
+ const fwIds = loadFrameworkIdentifiers();
+
+ // 2. Merge code-derived facts (framework ids, API blob) into components.
+ let components: ComponentRecord[] = componentsRaw.map((c) => {
+ const fw = fwIds.get(c.id);
+ const api = apis.get(c.id);
+ let apiBlob: Record | undefined;
+ if (api) {
+ const { componentSlug: _slug, ...rest } = api;
+ void _slug;
+ apiBlob = rest;
+ }
+ return {
+ ...c,
+ webComponentTag: fw?.webComponentTag,
+ reactClassName: fw?.reactClassName,
+ angularSelector: fw?.angularSelector,
+ // Carry extra react class names as a starting point for aliases.
+ aliases: fw && fw.reactClassNames.length > 1 ? fw.reactClassNames.slice(1) : [],
+ api: apiBlob,
+ };
+ });
+
+ // 3. Transforms.
+ components = addComponentAliases(components);
+ components = linkGuidanceToComponents(components, guidance);
+ const examples = addExampleAliases(examplesRaw);
+
+ // 4. Cross-validation checks.
+ const records: AnyRecord[] = [
+ ...components,
+ ...examples,
+ ...guidance,
+ ...foundations,
+ ...getStarted,
+ ...productTypes,
+ ];
+ const findings = runChecks(records);
+ renderFindings(findings);
+ const errorCount = findings.filter((f) => f.severity === "error").length;
+ if (errorCount > 0) {
+ process.stderr.write(
+ `[content-generators] skipped output because of ${errorCount} ${errorCount === 1 ? "error" : "errors"}. ` +
+ `Existing files in docs/generated/mcp/ are unchanged.\n`,
+ );
+ process.exit(1);
+ }
+
+ // 5. Output.
+ const result = writeMcpJson(records);
+ const elapsed = Math.round(performance.now() - start);
+
+ // 6. Report.
+ const withFw = components.filter((c) => c.webComponentTag).length;
+ const withApi = components.filter((c) => c.api).length;
+ const withAliases = components.filter((c) => c.aliases && c.aliases.length > 0).length;
+ const withGuidance = components.filter(
+ (c) => c.relatedGuidance && c.relatedGuidance.length > 0,
+ ).length;
+ const examplesWithAliases = examples.filter((e) => e.aliases.length > 0).length;
+ const examplesWithProductType = examples.filter((e) => e.productType).length;
+
+ process.stdout.write(
+ `[content-generators] wrote ${result.written} records in ${elapsed}ms\n` +
+ ` components: ${components.length} (${withFw} with fw ids, ${withApi} with api, ${withAliases} with aliases, ${withGuidance} with linked guidance)\n` +
+ ` examples: ${examples.length} (${examplesWithAliases} with aliases, ${examplesWithProductType} with productType)\n` +
+ ` guidance: ${guidance.length}\n` +
+ ` foundations: ${foundations.length}\n` +
+ ` get-started: ${getStarted.length}\n` +
+ ` productTypes: ${productTypes.length}\n`,
+ );
+}
+
+main();
diff --git a/docs/src/scripts/content-generators/loaders/component-apis.ts b/docs/src/scripts/content-generators/loaders/component-apis.ts
new file mode 100644
index 0000000000..bb4ca0ade4
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/component-apis.ts
@@ -0,0 +1,32 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+
+export interface ComponentApi {
+ componentSlug: string;
+ extractedFrom?: string;
+ frameworks?: Record;
+ events?: unknown[];
+ slots?: unknown[];
+ [key: string]: unknown;
+}
+
+export function loadComponentApis(): Map {
+ const map = new Map();
+ const dir = paths.code.componentApis;
+ if (!fs.existsSync(dir)) return map;
+
+ for (const file of fs.readdirSync(dir)) {
+ if (!file.endsWith(".json")) continue;
+ try {
+ const raw = fs.readFileSync(path.join(dir, file), "utf8");
+ const data = JSON.parse(raw) as ComponentApi;
+ const slug = data.componentSlug ?? file.replace(/\.json$/, "");
+ map.set(slug, { ...data, componentSlug: slug });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ process.stderr.write(`[loadComponentApis] failed to parse ${file}: ${message}\n`);
+ }
+ }
+ return map;
+}
diff --git a/docs/src/scripts/content-generators/loaders/components.ts b/docs/src/scripts/content-generators/loaders/components.ts
new file mode 100644
index 0000000000..a45a5668ef
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/components.ts
@@ -0,0 +1,35 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import { parseFrontmatter } from "./frontmatter";
+import { asString, asStringArray, listMdxFiles } from "./lib";
+import type { ComponentRecord } from "../types";
+
+export function loadComponents(): ComponentRecord[] {
+ const records: ComponentRecord[] = [];
+
+ for (const filePath of listMdxFiles(paths.content.components)) {
+ const slug = path.basename(filePath, ".mdx");
+ const raw = fs.readFileSync(filePath, "utf8");
+ const { data, body } = parseFrontmatter(raw);
+
+ records.push({
+ id: asString(data.id) ?? slug,
+ collection: "components",
+ name: asString(data.name) ?? slug,
+ description: asString(data.description),
+ status: asString(data.status) ?? "stable",
+ category: asString(data.category) ?? "utilities",
+ tags: asStringArray(data.tags),
+ aliases: [],
+ relatedComponents: asStringArray(data.relatedComponents),
+ figmaUrl: asString(data.figmaUrl),
+ hidden: data.hidden === true ? true : undefined,
+ subcomponent: data.subcomponent === true ? true : undefined,
+ body: body.trim(),
+ });
+ }
+
+ records.sort((a, b) => a.id.localeCompare(b.id));
+ return records;
+}
diff --git a/docs/src/scripts/content-generators/loaders/examples.ts b/docs/src/scripts/content-generators/loaders/examples.ts
new file mode 100644
index 0000000000..1c60b26cfc
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/examples.ts
@@ -0,0 +1,95 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import { parseFrontmatter } from "./frontmatter";
+import { asString, asStringArray, findIndexMdxFolders } from "./lib";
+import type { ExampleRecord } from "../types";
+
+const PAGE_LIKE_SIZES = new Set(["page", "task", "product"]);
+
+const VALID_SIZES = new Set([
+ "interaction",
+ "section",
+ "page",
+ "task",
+ "product",
+]);
+
+export function loadExamples(): ExampleRecord[] {
+ const records: ExampleRecord[] = [];
+
+ for (const folder of findIndexMdxFolders(paths.content.examples)) {
+ const slug = path.basename(folder);
+ const indexPath = path.join(folder, "index.mdx");
+
+ const raw = fs.readFileSync(indexPath, "utf8");
+ const { data, body } = parseFrontmatter(raw);
+
+ const sizeRaw = asString(data.size);
+ if (!sizeRaw || !VALID_SIZES.has(sizeRaw as ExampleRecord["size"])) {
+ // Schema enforces this on the docs site; skip malformed entries here
+ // rather than emitting garbage downstream.
+ continue;
+ }
+ const size = sizeRaw as ExampleRecord["size"];
+
+ const productType = asString(data.productType);
+ const productTypeNarrowed =
+ productType === "workspace" || productType === "public-form"
+ ? productType
+ : undefined;
+
+ const frameworks = pickFrameworks(folder, data.frameworks, size);
+
+ records.push({
+ id: asString(data.id) ?? slug,
+ collection: "examples",
+ title: asString(data.title) ?? slug,
+ description: asString(data.description),
+ size,
+ tags: asStringArray(data.tags),
+ components: asStringArray(data.components),
+ relatedExamples: asStringArray(data.relatedExamples),
+ aliases: asStringArray(data.aliases),
+ status: asString(data.status) ?? "published",
+ productType: productTypeNarrowed,
+ frameworks,
+ previewImage: asString(data.previewImage),
+ figmaUrl: asString(data.figmaUrl),
+ accessibilityNotes: asString(data.accessibilityNotes),
+ hidden: data.hidden === true ? true : undefined,
+ previewUrl: asString(data.previewUrl),
+ reactSourceUrl: asString(data.reactSourceUrl),
+ angularSourceUrl: asString(data.angularSourceUrl),
+ sourceUrl: asString(data.sourceUrl),
+ stackblitzUrl: asString(data.stackblitzUrl),
+ body: body.trim(),
+ });
+ }
+
+ records.sort((a, b) => a.id.localeCompare(b.id));
+ return records;
+}
+
+function pickFrameworks(
+ folder: string,
+ declared: unknown,
+ size: ExampleRecord["size"],
+): string[] | undefined {
+ // Trust the frontmatter when it's set (page-like sizes can declare this).
+ const declaredArr = asStringArray(declared);
+ if (declaredArr.length > 0) return declaredArr;
+
+ // Page-like sizes are meant to declare frameworks in frontmatter; if absent,
+ // don't infer from sibling files (page-scale entries often live outside the
+ // example folder).
+ if (PAGE_LIKE_SIZES.has(size)) return undefined;
+
+ // Interaction/section entries: detect from sibling files as before.
+ const detected: string[] = [];
+ if (fs.existsSync(path.join(folder, "react.tsx"))) detected.push("react");
+ if (fs.existsSync(path.join(folder, "angular.html"))) detected.push("angular");
+ if (fs.existsSync(path.join(folder, "web-components.html")))
+ detected.push("web-components");
+ return detected.length > 0 ? detected : undefined;
+}
diff --git a/docs/src/scripts/content-generators/loaders/foundations.ts b/docs/src/scripts/content-generators/loaders/foundations.ts
new file mode 100644
index 0000000000..c9243f2d16
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/foundations.ts
@@ -0,0 +1,30 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import { parseFrontmatter } from "./frontmatter";
+import { asString, asStringArray, listMdxFiles } from "./lib";
+import type { FoundationRecord } from "../types";
+
+export function loadFoundations(): FoundationRecord[] {
+ const records: FoundationRecord[] = [];
+
+ for (const filePath of listMdxFiles(paths.content.foundations)) {
+ const slug = path.basename(filePath, ".mdx");
+ const raw = fs.readFileSync(filePath, "utf8");
+ const { data, body } = parseFrontmatter(raw);
+
+ records.push({
+ id: asString(data.id) ?? slug,
+ collection: "foundations",
+ title: asString(data.title) ?? slug,
+ description: asString(data.description) ?? "",
+ category: asString(data.category) ?? "design",
+ tags: asStringArray(data.tags),
+ status: asString(data.status) ?? "published",
+ body: body.trim(),
+ });
+ }
+
+ records.sort((a, b) => a.id.localeCompare(b.id));
+ return records;
+}
diff --git a/docs/src/scripts/content-generators/loaders/framework-identifiers.ts b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts
new file mode 100644
index 0000000000..c5341367b3
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts
@@ -0,0 +1,209 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import { loadComponentApis } from "./component-apis";
+
+export interface FrameworkIdentifiers {
+ webComponentTag?: string;
+ reactClassName?: string;
+ angularSelector?: string;
+ // All Goab class exports from the React wrapper file. First entry is the
+ // canonical/primary name; the rest (rare in practice) ride along.
+ reactClassNames: string[];
+}
+
+const REACT_LIB_DIR = "react-components/src/lib";
+const ANGULAR_LIB_DIR = "angular-components/src/lib/components";
+
+interface ReactWrapperInfo {
+ file: string;
+ classNames: string[];
+}
+
+/**
+ * Derives framework identifiers per docs slug:
+ * - canonical Svelte file comes from extract-api's `extractedFrom`
+ * (already resolved via React-wrapper auto-derivation; see extract-api.ts)
+ * - that Svelte file's `` tag is the truth
+ * for `webComponentTag`
+ * - the React wrapper that declares that tag in its IntrinsicElements
+ * supplies the React class names
+ * - the Angular wrapper with the matching selector (`goab-`)
+ * supplies the Angular selector
+ *
+ * Replaces the previous folder-conflation approach where every React/Angular
+ * file inside `/` landed on a single slug derived from the folder
+ * name. That caused sibling sub-components (e.g. `GoabMenuAction` next to
+ * `GoabMenuButton`, or `GoabTableSortHeader` next to `GoabTable`) to leak
+ * their tags/selectors/classes onto the parent slug.
+ */
+export function loadFrameworkIdentifiers(): Map {
+ const bySlug = new Map();
+ const apis = loadComponentApis();
+ const tagToReact = buildReactWrapperIndex();
+ const tagToAngular = buildAngularWrapperIndex();
+
+ for (const [slug, api] of apis) {
+ if (!api.extractedFrom) continue;
+ const svelteFile = path.join(paths.workspaceRoot, api.extractedFrom);
+ const tag = readSvelteCustomElementTag(svelteFile);
+ if (!tag) continue;
+
+ const react = tagToReact.get(tag);
+ const angularSelector = tagToAngular.get(tag);
+
+ bySlug.set(slug, {
+ webComponentTag: tag,
+ reactClassName: pickReactClassName(tag, react?.classNames ?? []),
+ reactClassNames: react?.classNames ?? [],
+ angularSelector,
+ });
+ }
+
+ return bySlug;
+}
+
+function buildReactWrapperIndex(): Map {
+ const byTag = new Map();
+ const root = path.join(paths.code.libs, REACT_LIB_DIR);
+ if (!fs.existsSync(root)) return byTag;
+
+ for (const tsxFile of walkSourceFiles(root, ".tsx")) {
+ const content = fs.readFileSync(tsxFile, "utf8");
+ const tags = extractIntrinsicTags(content);
+ if (tags.length === 0) continue;
+ const classNames = extractGoabComponentExports(content);
+ if (classNames.length === 0) continue;
+ for (const tag of tags) {
+ // First wrapper found wins; siblings on the same tag would be a
+ // structural anomaly to flag separately.
+ if (!byTag.has(tag)) {
+ byTag.set(tag, { file: tsxFile, classNames });
+ }
+ }
+ }
+
+ return byTag;
+}
+
+function buildAngularWrapperIndex(): Map {
+ const byTag = new Map();
+ const root = path.join(paths.code.libs, ANGULAR_LIB_DIR);
+ if (!fs.existsSync(root)) return byTag;
+
+ for (const tsFile of walkSourceFiles(root, ".ts")) {
+ const content = fs.readFileSync(tsFile, "utf8");
+ const selectorMatch = content.match(/selector\s*:\s*["']goab-([a-z0-9-]+)["']/);
+ if (!selectorMatch) continue;
+ const stem = selectorMatch[1];
+ const selector = `goab-${stem}`;
+ // Read the web component tag the template actually renders rather than
+ // deriving it from the selector stem. They diverge for wrappers like
+ // goab-temporary-notification-ctrl, whose tag is goa-temp-notification-ctrl.
+ const tagMatch = content.match(/<(goa-[a-z0-9-]+)/);
+ if (!tagMatch) continue;
+ const tag = tagMatch[1];
+ // One tag can have several wrappers (goa-input <- goab-input and
+ // goab-input-number). Prefer the wrapper whose selector stem matches the
+ // tag stem; otherwise keep the first seen, which covers the divergent
+ // goab-temporary-notification-ctrl -> goa-temp-notification-ctrl case.
+ const tagStem = tag.replace(/^goa-/, "");
+ if (!byTag.has(tag) || stem === tagStem) {
+ byTag.set(tag, selector);
+ }
+ }
+
+ return byTag;
+}
+
+function readSvelteCustomElementTag(svelteFile: string): string | undefined {
+ if (!fs.existsSync(svelteFile)) return undefined;
+ const content = fs.readFileSync(svelteFile, "utf8");
+ // Two `customElement` forms in this codebase:
+ //
+ //
+ const simple = content.match(/customElement\s*=\s*["'](goa-[a-z0-9-]+)["']/);
+ if (simple) return simple[1];
+ const object = content.match(/customElement\s*=\s*\{\{\s*tag\s*:\s*["'](goa-[a-z0-9-]+)["']/);
+ if (object) return object[1];
+ return undefined;
+}
+
+function extractIntrinsicTags(src: string): string[] {
+ return [...src.matchAll(/IntrinsicElements\s*\{[\s\S]*?\}/g)]
+ .flatMap((m) => [...m[0].matchAll(/["'](goa-[a-z0-9-]+)["']\s*:/g)])
+ .map((m) => m[1]);
+}
+
+function extractGoabComponentExports(src: string): string[] {
+ // Goab classes that are components (functions or constants), not types.
+ // Filters out *Props/*Detail/*Event type exports.
+ const names = new Set();
+ for (const m of src.matchAll(/export\s+function\s+(Goab[A-Z][A-Za-z0-9]*)/g)) {
+ names.add(m[1]);
+ }
+ for (const m of src.matchAll(/export\s+const\s+(Goab[A-Z][A-Za-z0-9]*)\s*[:=]/g)) {
+ names.add(m[1]);
+ }
+ for (const block of src.matchAll(/export\s*\{([^}]+)\}/g)) {
+ for (const part of block[1].split(",")) {
+ const trimmed = part.trim();
+ if (!trimmed) continue;
+ const asMatch = trimmed.match(/[A-Za-z0-9_]+\s+as\s+(Goab[A-Z][A-Za-z0-9]*)/);
+ if (asMatch) {
+ names.add(asMatch[1]);
+ continue;
+ }
+ const direct = trimmed.match(/^(Goab[A-Z][A-Za-z0-9]*)$/);
+ if (direct) names.add(direct[1]);
+ }
+ }
+ return [...names].filter(
+ (n) => !n.endsWith("Props") && !n.endsWith("Detail") && !n.endsWith("Event"),
+ );
+}
+
+/** Canonical React class for a tag: `goa-dropdown-item` -> `GoabDropdownItem`. */
+function tagToReactClassName(tag: string): string {
+ const pascal = tag
+ .replace(/^goa-/, "")
+ .split("-")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join("");
+ return `Goab${pascal}`;
+}
+
+/**
+ * Pick the React class for a tag. A wrapper file can export more than one Goab
+ * component (e.g. a deprecated alias declared before the real one), so prefer
+ * the class whose name matches the tag and fall back to the first export.
+ */
+function pickReactClassName(
+ tag: string,
+ classNames: string[],
+): string | undefined {
+ if (classNames.length === 0) return undefined;
+ const canonical = tagToReactClassName(tag);
+ return classNames.includes(canonical) ? canonical : classNames[0];
+}
+
+function walkSourceFiles(root: string, ext: string): string[] {
+ const out: string[] = [];
+ if (!fs.existsSync(root)) return out;
+ const stack = [root];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ if (!current) continue;
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ stack.push(full);
+ } else if (entry.isFile() && entry.name.endsWith(ext)) {
+ if (entry.name.endsWith(`.spec${ext}`)) continue;
+ if (entry.name.endsWith(`.test${ext}`)) continue;
+ out.push(full);
+ }
+ }
+ }
+ return out;
+}
diff --git a/docs/src/scripts/content-generators/loaders/frontmatter.ts b/docs/src/scripts/content-generators/loaders/frontmatter.ts
new file mode 100644
index 0000000000..a03cd6d1b5
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/frontmatter.ts
@@ -0,0 +1,210 @@
+// Minimal YAML-frontmatter parser tuned for the shapes used in this repo's
+// content collections: scalars, arrays of strings, single-level nested
+// objects, inline `[a, b]` arrays. Recursive descent: easier to reason about
+// than the stack-based approach for nested objects.
+
+export interface ParsedFrontmatter {
+ data: Record;
+ body: string;
+}
+
+export function parseFrontmatter(raw: string): ParsedFrontmatter {
+ const lines = raw.split("\n");
+ if (lines[0] !== "---") return { data: {}, body: raw };
+
+ let endIdx = -1;
+ for (let i = 1; i < lines.length; i++) {
+ if (lines[i] === "---") {
+ endIdx = i;
+ break;
+ }
+ }
+ if (endIdx === -1) return { data: {}, body: raw };
+
+ const yamlLines = lines.slice(1, endIdx);
+ const body = lines.slice(endIdx + 1).join("\n");
+ const [data] = parseObject(yamlLines, 0, 0);
+ return { data, body };
+}
+
+function leadingSpaces(line: string): number {
+ return line.length - line.trimStart().length;
+}
+
+function isBlankOrComment(line: string | undefined): boolean {
+ if (line === undefined) return true;
+ const trimmed = line.trim();
+ return trimmed === "" || trimmed.startsWith("#");
+}
+
+function nextContentLine(lines: string[], startIdx: number): number {
+ let i = startIdx;
+ while (i < lines.length && isBlankOrComment(lines[i])) i++;
+ return i;
+}
+
+function parseObject(
+ lines: string[],
+ startIdx: number,
+ expectedIndent: number,
+): [Record, number] {
+ const obj: Record = {};
+ let i = startIdx;
+
+ while (i < lines.length) {
+ if (isBlankOrComment(lines[i])) {
+ i++;
+ continue;
+ }
+ const indent = leadingSpaces(lines[i]);
+ if (indent < expectedIndent) break;
+ if (indent > expectedIndent) {
+ // Unexpected deeper indent at this scope. Skip defensively.
+ i++;
+ continue;
+ }
+
+ const trimmed = lines[i].trimStart();
+ // Top-level array item shouldn't appear here (parseArray handles those).
+ if (trimmed.startsWith("- ") || trimmed === "-") break;
+
+ const colon = trimmed.indexOf(":");
+ if (colon === -1) {
+ i++;
+ continue;
+ }
+ const key = trimmed.slice(0, colon).trim();
+ const valueText = trimmed.slice(colon + 1).trim();
+
+ if (valueText === "") {
+ // Open scope: peek at next content line to decide array vs object.
+ const nextIdx = nextContentLine(lines, i + 1);
+ if (nextIdx >= lines.length) {
+ i++;
+ continue;
+ }
+ const nextIndent = leadingSpaces(lines[nextIdx]);
+ if (nextIndent <= expectedIndent) {
+ // Empty scope.
+ i++;
+ continue;
+ }
+ const nextTrimmed = lines[nextIdx].trimStart();
+ if (nextTrimmed.startsWith("- ") || nextTrimmed === "-") {
+ const [arr, consumed] = parseArray(lines, nextIdx, nextIndent);
+ obj[key] = arr;
+ i = consumed;
+ } else {
+ const [sub, consumed] = parseObject(lines, nextIdx, nextIndent);
+ obj[key] = sub;
+ i = consumed;
+ }
+ } else if (/^[|>][+-]?$/.test(valueText)) {
+ // YAML block scalar: `|` keeps newlines, `>` folds them into spaces.
+ const [text, consumed] = parseBlockScalar(
+ lines,
+ i + 1,
+ expectedIndent,
+ valueText[0] as "|" | ">",
+ );
+ obj[key] = text;
+ i = consumed;
+ } else if (valueText.startsWith("[") && valueText.endsWith("]")) {
+ obj[key] = parseInlineArray(valueText);
+ i++;
+ } else {
+ obj[key] = parseScalar(valueText);
+ i++;
+ }
+ }
+
+ return [obj, i];
+}
+
+function parseArray(
+ lines: string[],
+ startIdx: number,
+ expectedIndent: number,
+): [unknown[], number] {
+ const arr: unknown[] = [];
+ let i = startIdx;
+
+ while (i < lines.length) {
+ if (isBlankOrComment(lines[i])) {
+ i++;
+ continue;
+ }
+ const indent = leadingSpaces(lines[i]);
+ if (indent < expectedIndent) break;
+ if (indent > expectedIndent) {
+ // Deeper than array item indent. Shouldn't happen for our shapes; skip.
+ i++;
+ continue;
+ }
+ const trimmed = lines[i].trimStart();
+ if (!trimmed.startsWith("- ") && trimmed !== "-") break;
+
+ const valueText = trimmed === "-" ? "" : trimmed.slice(2).trim();
+ arr.push(parseScalar(valueText));
+ i++;
+ }
+
+ return [arr, i];
+}
+
+function parseInlineArray(text: string): unknown[] {
+ const inner = text.slice(1, -1).trim();
+ if (!inner) return [];
+ return inner.split(",").map((s) => parseScalar(s.trim()));
+}
+
+function parseBlockScalar(
+ lines: string[],
+ startIdx: number,
+ parentIndent: number,
+ style: "|" | ">",
+): [string, number] {
+ const collected: string[] = [];
+ let blockIndent: number | null = null;
+ let i = startIdx;
+
+ while (i < lines.length) {
+ const line = lines[i];
+ if (line.trim() === "") {
+ collected.push("");
+ i++;
+ continue;
+ }
+ const indent = leadingSpaces(line);
+ if (indent <= parentIndent) break;
+ if (blockIndent === null) blockIndent = indent;
+ collected.push(line.slice(blockIndent));
+ i++;
+ }
+
+ // Clip chomping (YAML default): drop trailing blank lines.
+ while (collected.length > 0 && collected[collected.length - 1] === "") {
+ collected.pop();
+ }
+
+ const text =
+ style === ">"
+ ? collected.join(" ").replace(/\s+/g, " ").trim()
+ : collected.join("\n");
+ return [text, i];
+}
+
+function parseScalar(text: string): unknown {
+ if (text === "") return "";
+ if (text === "true") return true;
+ if (text === "false") return false;
+ if (text === "null" || text === "~") return null;
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
+ if (
+ (text.startsWith('"') && text.endsWith('"')) ||
+ (text.startsWith("'") && text.endsWith("'"))
+ ) {
+ return text.slice(1, -1);
+ }
+ return text;
+}
diff --git a/docs/src/scripts/content-generators/loaders/get-started.ts b/docs/src/scripts/content-generators/loaders/get-started.ts
new file mode 100644
index 0000000000..a6cbcb552c
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/get-started.ts
@@ -0,0 +1,32 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import { parseFrontmatter } from "./frontmatter";
+import { asNumber, asString, walkMdxFiles } from "./lib";
+import type { GetStartedRecord } from "../types";
+
+export function loadGetStarted(): GetStartedRecord[] {
+ const records: GetStartedRecord[] = [];
+
+ for (const filePath of walkMdxFiles(paths.content.getStarted)) {
+ const rel = path.relative(paths.content.getStarted, filePath);
+ const slug = rel.replace(/\.mdx$/, "");
+ const raw = fs.readFileSync(filePath, "utf8");
+ const { data, body } = parseFrontmatter(raw);
+
+ records.push({
+ id: asString(data.id) ?? slug,
+ collection: "get-started",
+ title: asString(data.title) ?? slug,
+ navLabel: asString(data.navLabel),
+ description: asString(data.description),
+ section: asString(data.section) ?? "intro",
+ order: asNumber(data.order, 0),
+ status: asString(data.status) ?? "published",
+ body: body.trim(),
+ });
+ }
+
+ records.sort((a, b) => a.id.localeCompare(b.id));
+ return records;
+}
diff --git a/docs/src/scripts/content-generators/loaders/guidance.ts b/docs/src/scripts/content-generators/loaders/guidance.ts
new file mode 100644
index 0000000000..dd8702ae4a
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/guidance.ts
@@ -0,0 +1,42 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import { parseFrontmatter } from "./frontmatter";
+import { asString, asStringArray, listMdxFiles } from "./lib";
+import type { GuidanceRecord } from "../types";
+
+export function loadGuidance(): GuidanceRecord[] {
+ const records: GuidanceRecord[] = [];
+
+ for (const filePath of listMdxFiles(paths.content.guidance)) {
+ const slug = path.basename(filePath, ".mdx");
+ const raw = fs.readFileSync(filePath, "utf8");
+ const { data, body } = parseFrontmatter(raw);
+
+ const appliesToRaw = data.appliesTo as
+ | { components?: unknown; contexts?: unknown }
+ | undefined;
+ const appliesTo = appliesToRaw
+ ? {
+ components: asStringArray(appliesToRaw.components),
+ contexts: asStringArray(appliesToRaw.contexts),
+ }
+ : undefined;
+
+ records.push({
+ id: asString(data.id) ?? slug,
+ collection: "guidance",
+ type: asString(data.type) ?? "info",
+ description: asString(data.description) ?? "",
+ topic: asString(data.topic) ?? "other",
+ tags: asStringArray(data.tags),
+ appliesTo,
+ relatedProps: asStringArray(data.relatedProps),
+ status: asString(data.status) ?? "published",
+ body: body.trim(),
+ });
+ }
+
+ records.sort((a, b) => a.id.localeCompare(b.id));
+ return records;
+}
diff --git a/docs/src/scripts/content-generators/loaders/lib.ts b/docs/src/scripts/content-generators/loaders/lib.ts
new file mode 100644
index 0000000000..46da97b432
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/lib.ts
@@ -0,0 +1,63 @@
+import * as fs from "fs";
+import * as path from "path";
+
+export function asString(v: unknown): string | undefined {
+ return typeof v === "string" && v.length > 0 ? v : undefined;
+}
+
+export function asStringArray(v: unknown): string[] {
+ if (!Array.isArray(v)) return [];
+ return v.filter((x): x is string => typeof x === "string");
+}
+
+export function asNumber(v: unknown, fallback = 0): number {
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
+}
+
+export function listMdxFiles(dir: string): string[] {
+ if (!fs.existsSync(dir)) return [];
+ return fs
+ .readdirSync(dir, { withFileTypes: true })
+ .filter((e) => e.isFile() && e.name.endsWith(".mdx"))
+ .map((e) => path.join(dir, e.name));
+}
+
+export function walkMdxFiles(dir: string, baseDir = dir): string[] {
+ if (!fs.existsSync(dir)) return [];
+ const out: string[] = [];
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ out.push(...walkMdxFiles(full, baseDir));
+ } else if (entry.isFile() && entry.name.endsWith(".mdx")) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+export function listDirectories(dir: string): string[] {
+ if (!fs.existsSync(dir)) return [];
+ return fs
+ .readdirSync(dir, { withFileTypes: true })
+ .filter((e) => e.isDirectory())
+ .map((e) => path.join(dir, e.name));
+}
+
+// Walks the tree for index.mdx files and returns the folder containing each.
+// Used by example collections that nest by productType (workspace/case-detail).
+export function findIndexMdxFolders(dir: string): string[] {
+ if (!fs.existsSync(dir)) return [];
+ const folders: string[] = [];
+ function walk(current: string): void {
+ if (fs.existsSync(path.join(current, "index.mdx"))) {
+ folders.push(current);
+ return;
+ }
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ if (entry.isDirectory()) walk(path.join(current, entry.name));
+ }
+ }
+ walk(dir);
+ return folders;
+}
diff --git a/docs/src/scripts/content-generators/loaders/productTypes.ts b/docs/src/scripts/content-generators/loaders/productTypes.ts
new file mode 100644
index 0000000000..0182a77a73
--- /dev/null
+++ b/docs/src/scripts/content-generators/loaders/productTypes.ts
@@ -0,0 +1,36 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import { parseFrontmatter } from "./frontmatter";
+import { asString, asStringArray, listDirectories } from "./lib";
+import type { ProductTypeRecord } from "../types";
+
+export function loadProductTypes(): ProductTypeRecord[] {
+ const records: ProductTypeRecord[] = [];
+
+ for (const folder of listDirectories(paths.content.productTypes)) {
+ const slug = path.basename(folder);
+ const indexPath = path.join(folder, "index.mdx");
+ if (!fs.existsSync(indexPath)) continue;
+
+ const raw = fs.readFileSync(indexPath, "utf8");
+ const { data, body } = parseFrontmatter(raw);
+
+ records.push({
+ id: asString(data.id) ?? slug,
+ collection: "productTypes",
+ title: asString(data.title) ?? slug,
+ summary: asString(data.summary) ?? "",
+ heroImage: asString(data.heroImage),
+ demoUrl: asString(data.demoUrl),
+ sourceUrl: asString(data.sourceUrl),
+ tags: asStringArray(data.tags),
+ components: asStringArray(data.components),
+ status: asString(data.status) ?? "published",
+ body: body.trim(),
+ });
+ }
+
+ records.sort((a, b) => a.id.localeCompare(b.id));
+ return records;
+}
diff --git a/docs/src/scripts/content-generators/outputs/mcp-json.ts b/docs/src/scripts/content-generators/outputs/mcp-json.ts
new file mode 100644
index 0000000000..6db96cbd53
--- /dev/null
+++ b/docs/src/scripts/content-generators/outputs/mcp-json.ts
@@ -0,0 +1,57 @@
+import * as fs from "fs";
+import * as path from "path";
+import { paths } from "../config";
+import type { AnyRecord } from "../types";
+
+export function writeMcpJson(records: AnyRecord[]): { written: number } {
+ const byCollection = new Map();
+ for (const rec of records) {
+ const list = byCollection.get(rec.collection) ?? [];
+ list.push(rec);
+ byCollection.set(rec.collection, list);
+ }
+
+ fs.mkdirSync(paths.output.mcp, { recursive: true });
+
+ // Clear stale files in each collection folder before writing. Renamed or
+ // removed records would otherwise leave orphan JSON behind. Errors block
+ // the orchestrator before this point (see index.ts), so the previous
+ // output stays as last-known-good on a failed run.
+ let written = 0;
+ for (const [collection, list] of byCollection) {
+ const dir = path.join(paths.output.mcp, collection);
+ if (fs.existsSync(dir)) {
+ for (const entry of fs.readdirSync(dir)) {
+ if (entry.endsWith(".json")) fs.unlinkSync(path.join(dir, entry));
+ }
+ } else {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ for (const rec of list) {
+ // Flatten nested ids (e.g. "designers/designing-with-ds") into a
+ // filesystem-safe filename. The canonical id lives in the JSON body.
+ const filename = rec.id.replace(/\//g, "__") + ".json";
+ const file = path.join(dir, filename);
+ const payload = sortKeys(rec);
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", "utf8");
+ written++;
+ }
+ }
+ return { written };
+}
+
+// Deterministic key order so byte-identical reruns are possible. Lets a
+// downstream freshness check detect content changes via file hash.
+function sortKeys(value: T): T {
+ if (Array.isArray(value)) {
+ return value.map(sortKeys) as unknown as T;
+ }
+ if (value && typeof value === "object") {
+ const sorted: Record = {};
+ for (const key of Object.keys(value).sort()) {
+ sorted[key] = sortKeys((value as Record)[key]);
+ }
+ return sorted as unknown as T;
+ }
+ return value;
+}
diff --git a/docs/src/scripts/content-generators/sync-to-dcp.sh b/docs/src/scripts/content-generators/sync-to-dcp.sh
new file mode 100755
index 0000000000..6262f3c085
--- /dev/null
+++ b/docs/src/scripts/content-generators/sync-to-dcp.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# Sync the content-generators output into the dcp-monorepo MCP's data/.
+# Manual invocation, no idempotency checks, no PR generation.
+# Phase 4 replaces this with an automated CI step.
+#
+# Set DCP_MONOREPO_DATA to the local path of dcp-monorepo's design-system-mcp
+# data folder. Example:
+# export DCP_MONOREPO_DATA=/path/to/dcp-monorepo/apps/design-system-mcp/data
+# bash docs/src/scripts/content-generators/sync-to-dcp.sh
+
+set -euo pipefail
+
+SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../generated/mcp" && pwd)"
+DEST="${DCP_MONOREPO_DATA:-}"
+
+if [[ -z "$DEST" ]]; then
+ echo "[sync] DCP_MONOREPO_DATA env var not set." >&2
+ echo "[sync] Set it to the path of dcp-monorepo's design-system-mcp data folder." >&2
+ echo "[sync] Example: export DCP_MONOREPO_DATA=/path/to/dcp-monorepo/apps/design-system-mcp/data" >&2
+ exit 1
+fi
+
+if [[ ! -d "$SRC" ]]; then
+ echo "[sync] Source not found: $SRC" >&2
+ echo "[sync] Run: npx tsx docs/src/scripts/content-generators/index.ts" >&2
+ exit 1
+fi
+
+if [[ ! -d "$DEST" ]]; then
+ echo "[sync] Destination not found: $DEST" >&2
+ exit 1
+fi
+
+echo "[sync] Clearing old subfolders in $DEST..."
+rm -rf \
+ "$DEST/components" \
+ "$DEST/examples" \
+ "$DEST/guidance" \
+ "$DEST/foundations" \
+ "$DEST/get-started" \
+ "$DEST/productTypes" \
+ "$DEST/design" \
+ "$DEST/development"
+
+echo "[sync] Copying $SRC -> $DEST"
+cp -R "$SRC/." "$DEST/"
+
+for c in components examples guidance foundations get-started productTypes; do
+ if [[ -d "$DEST/$c" ]]; then
+ count=$(find "$DEST/$c" -type f -name '*.json' | wc -l | tr -d ' ')
+ echo " $c: $count files"
+ fi
+done
+
+echo "[sync] Done."
diff --git a/docs/src/scripts/content-generators/transforms/aliases.ts b/docs/src/scripts/content-generators/transforms/aliases.ts
new file mode 100644
index 0000000000..8583ebd1d1
--- /dev/null
+++ b/docs/src/scripts/content-generators/transforms/aliases.ts
@@ -0,0 +1,69 @@
+import type { ComponentRecord, ExampleRecord } from "../types";
+
+// Manually curated old → new slug renames. Until the docs site catches up,
+// the OLD slug is the current id and there's nothing to alias. Once the docs
+// rename a record (e.g. PR #3888 batches), the NEW slug becomes the id and
+// the OLD slug becomes the alias here. This list is the bridge during the
+// transition.
+const EXAMPLE_SLUG_ALIASES: Record = {
+ // newSlug: [oldSlug1, oldSlug2, ...]
+ "result-page": ["confirm-that-an-application-was-submitted"],
+ "question-page": [
+ "ask-a-user-one-question-at-a-time",
+ "give-more-information-before-asking-a-question-a",
+ ],
+};
+
+// Legacy or sub-component names that don't show up from code derivation
+// (no matching React class, web component tag, or Angular selector). Used
+// when a renamed component still appears in older content references.
+const COMPONENT_SLUG_ALIASES: Record = {
+ // newSlug: [oldSlug1, oldSlug2, ...]
+ "text-area": ["textarea"],
+ notification: ["notification-banner"],
+ "menu-button": ["multi-action-button"],
+};
+
+export function addComponentAliases(components: ComponentRecord[]): ComponentRecord[] {
+ return components.map((c) => {
+ const aliases = new Set(c.aliases);
+
+ if (c.reactClassName && c.reactClassName !== expectedReactName(c.id)) {
+ // Stale wrapper name that doesn't match the doc slug.
+ // e.g. GoabAppFooter on footer's aliases.
+ aliases.add(c.reactClassName);
+ }
+ if (c.webComponentTag) {
+ const tagSlug = c.webComponentTag.replace(/^goa-/, "");
+ if (tagSlug !== c.id) aliases.add(tagSlug);
+ }
+ if (c.angularSelector) {
+ const selSlug = c.angularSelector.replace(/^goab-/, "");
+ if (selSlug !== c.id) aliases.add(selSlug);
+ }
+ const manual = COMPONENT_SLUG_ALIASES[c.id];
+ if (manual) for (const s of manual) aliases.add(s);
+
+ return { ...c, aliases: [...aliases].sort() };
+ });
+}
+
+export function addExampleAliases(examples: ExampleRecord[]): ExampleRecord[] {
+ return examples.map((ex) => {
+ const aliases = new Set(ex.aliases);
+ const oldSlugs = EXAMPLE_SLUG_ALIASES[ex.id];
+ if (oldSlugs) for (const s of oldSlugs) aliases.add(s);
+ return { ...ex, aliases: [...aliases].sort() };
+ });
+}
+
+function expectedReactName(slug: string): string {
+ // Convention: GoabPascalCase(slug). e.g. "form-item" → "GoabFormItem".
+ return (
+ "Goab" +
+ slug
+ .split("-")
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
+ .join("")
+ );
+}
diff --git a/docs/src/scripts/content-generators/transforms/component-lookup.ts b/docs/src/scripts/content-generators/transforms/component-lookup.ts
new file mode 100644
index 0000000000..b6f5b66c40
--- /dev/null
+++ b/docs/src/scripts/content-generators/transforms/component-lookup.ts
@@ -0,0 +1,21 @@
+import type { ComponentRecord } from "../types";
+
+// Builds a slug → component lookup keyed by both canonical id and every alias.
+// Lets cross-references that point at legacy slugs (app-footer, text-area,
+// notification-banner, multi-action-button) resolve to the current canonical
+// record without forcing source MDX edits.
+//
+// Run after addComponentAliases so aliases are populated.
+export function buildComponentLookup(
+ components: ComponentRecord[],
+): Map {
+ const lookup = new Map();
+ for (const c of components) {
+ lookup.set(c.id, c);
+ for (const alias of c.aliases ?? []) {
+ // Don't overwrite a canonical id with an alias collision.
+ if (!lookup.has(alias)) lookup.set(alias, c);
+ }
+ }
+ return lookup;
+}
diff --git a/docs/src/scripts/content-generators/transforms/link-guidance.ts b/docs/src/scripts/content-generators/transforms/link-guidance.ts
new file mode 100644
index 0000000000..0520aeffee
--- /dev/null
+++ b/docs/src/scripts/content-generators/transforms/link-guidance.ts
@@ -0,0 +1,33 @@
+import type { ComponentRecord, GuidanceRecord } from "../types";
+import { buildComponentLookup } from "./component-lookup";
+
+// For each component, attach the list of guidance entry ids that target it
+// via guidance.appliesTo.components. The MCP `get` on a component can then
+// surface guidance inline, so consumers retrieve a component and its related
+// guidance in a single call instead of separate searches.
+//
+// guidance.appliesTo.components is alias-aware: legacy slugs like "app-footer"
+// resolve through the alias map to the canonical "footer" record.
+export function linkGuidanceToComponents(
+ components: ComponentRecord[],
+ guidance: GuidanceRecord[],
+): ComponentRecord[] {
+ const lookup = buildComponentLookup(components);
+ const byCanonicalId = new Map>();
+ for (const g of guidance) {
+ const targets = g.appliesTo?.components ?? [];
+ for (const slug of targets) {
+ const canonical = lookup.get(slug);
+ if (!canonical) continue;
+ const set = byCanonicalId.get(canonical.id) ?? new Set();
+ set.add(g.id);
+ byCanonicalId.set(canonical.id, set);
+ }
+ }
+
+ return components.map((c) => {
+ const linked = byCanonicalId.get(c.id);
+ if (!linked || linked.size === 0) return c;
+ return { ...c, relatedGuidance: [...linked].sort() };
+ });
+}
diff --git a/docs/src/scripts/content-generators/types.ts b/docs/src/scripts/content-generators/types.ts
new file mode 100644
index 0000000000..4072e162c4
--- /dev/null
+++ b/docs/src/scripts/content-generators/types.ts
@@ -0,0 +1,119 @@
+import type { CollectionName } from "./config";
+
+export interface BaseRecord {
+ id: string;
+ collection: CollectionName;
+ body: string;
+}
+
+export interface ComponentRecord extends BaseRecord {
+ collection: "components";
+ name: string;
+ description?: string;
+ status: string;
+ category: string;
+ tags: string[];
+ aliases: string[];
+ relatedComponents: string[];
+ figmaUrl?: string;
+ hidden?: boolean;
+ subcomponent?: boolean;
+ webComponentTag?: string;
+ reactClassName?: string;
+ angularSelector?: string;
+ // Full API blob from generated/component-apis/*.json (minus componentSlug).
+ // Contains per-framework props/events/slots. Consumers navigate it.
+ api?: Record;
+ // Guidance ids that target this component via guidance.appliesTo.components.
+ relatedGuidance?: string[];
+}
+
+export interface ExampleRecord extends BaseRecord {
+ collection: "examples";
+ title: string;
+ description?: string;
+ size: "interaction" | "section" | "page" | "task" | "product";
+ tags: string[];
+ components: string[];
+ relatedExamples: string[];
+ aliases: string[];
+ status: string;
+ productType?: "workspace" | "public-form";
+ frameworks?: string[];
+ previewImage?: string;
+ figmaUrl?: string;
+ accessibilityNotes?: string;
+ hidden?: boolean;
+ // Page-like fields (page, task, product sizes only)
+ previewUrl?: string;
+ reactSourceUrl?: string;
+ angularSourceUrl?: string;
+ sourceUrl?: string;
+ stackblitzUrl?: string;
+}
+
+export interface GuidanceRecord extends BaseRecord {
+ collection: "guidance";
+ type: string;
+ description: string;
+ topic: string;
+ tags: string[];
+ appliesTo?: {
+ components?: string[];
+ contexts?: string[];
+ };
+ relatedProps: string[];
+ status: string;
+}
+
+export interface FoundationRecord extends BaseRecord {
+ collection: "foundations";
+ title: string;
+ description: string;
+ category: string;
+ tags: string[];
+ status: string;
+}
+
+export interface GetStartedRecord extends BaseRecord {
+ collection: "get-started";
+ title: string;
+ navLabel?: string;
+ description?: string;
+ section: string;
+ order: number;
+ status: string;
+}
+
+export interface ProductTypeRecord extends BaseRecord {
+ collection: "productTypes";
+ title: string;
+ summary: string;
+ heroImage?: string;
+ demoUrl?: string;
+ sourceUrl?: string;
+ tags: string[];
+ components: string[];
+ status: string;
+}
+
+export type AnyRecord =
+ | ComponentRecord
+ | ExampleRecord
+ | GuidanceRecord
+ | FoundationRecord
+ | GetStartedRecord
+ | ProductTypeRecord;
+
+export interface Finding {
+ severity: "error" | "warning";
+ source: { collection: CollectionName; id: string };
+ field: string;
+ brokenRef: string;
+ /** Extra context appended after the broken ref, e.g. `on component "checkbox"`. */
+ context?: string;
+ /** Override the default "not found" suffix, e.g. `not in extracted props`. */
+ notFoundMessage?: string;
+ /** Optional "did you mean..." string populated by the check. */
+ hint?: string;
+}
diff --git a/docs/src/scripts/extract-api.ts b/docs/src/scripts/extract-api.ts
index 6eb01bdedc..f239f2bb06 100644
--- a/docs/src/scripts/extract-api.ts
+++ b/docs/src/scripts/extract-api.ts
@@ -1966,6 +1966,116 @@ function removeStaleApiFiles(validComponentNames: string[]): void {
}
}
+// =============================================================================
+// Slug → Svelte resolution
+// =============================================================================
+
+/**
+ * Resolves a docs slug to its canonical Svelte file via the React wrapper's
+ * IntrinsicElements declaration. The wrapper is the source of truth for
+ * which custom element it presents to the consuming app, so its tag points
+ * to the matching Svelte file even when the docs slug, the wrapper filename,
+ * and the Svelte filename don't agree (e.g. docs slug `temporary-notification`
+ * but the public element lives in `TemporaryNotificationCtrl.svelte`).
+ *
+ * Returns undefined when the slug has no resolvable React wrapper, so the
+ * caller can fall back to the naming-convention heuristic.
+ */
+function resolveSvelteViaReactWrapper(slug: string): string | undefined {
+ const reactRoot = path.join(WORKSPACE_ROOT, "libs/react-components/src/lib");
+ if (!fs.existsSync(reactRoot)) return undefined;
+
+ const reactFile = findReactWrapperForSlug(slug, reactRoot);
+ if (!reactFile) return undefined;
+
+ const tag = extractIntrinsicElementTag(fs.readFileSync(reactFile, "utf-8"));
+ if (!tag) return undefined;
+
+ return findSvelteDeclaringTag(tag);
+}
+
+/**
+ * Two attempts in order:
+ * 1. Direct: `.tsx` somewhere under reactRoot.
+ * 2. Discovery: a Svelte file inside `libs/...//` declares a tag,
+ * and exactly one React wrapper anywhere wraps that tag. This handles
+ * `temporary-notification` (no `temporary-notification.tsx` exists, but
+ * `temporary-notification-ctrl.tsx` wraps `goa-temp-notification-ctrl`,
+ * which is declared in the slug's Svelte folder).
+ */
+function findReactWrapperForSlug(slug: string, reactRoot: string): string | undefined {
+ const direct = findFirstFileByName(reactRoot, `${slug}.tsx`);
+ if (direct) return direct;
+
+ const componentPath = path.join(UI_COMPONENTS_PATH, slug);
+ if (!fs.existsSync(componentPath) || !fs.statSync(componentPath).isDirectory()) {
+ return undefined;
+ }
+
+ const wrappedFiles = new Set();
+ for (const file of fs.readdirSync(componentPath)) {
+ if (!file.endsWith(".svelte") || file.includes(".test.")) continue;
+ const tag = extractTagName(fs.readFileSync(path.join(componentPath, file), "utf-8"));
+ if (!tag) continue;
+ const wrapper = findReactWrapperForTag(tag, reactRoot);
+ if (wrapper) wrappedFiles.add(wrapper);
+ }
+
+ if (wrappedFiles.size === 1) return [...wrappedFiles][0];
+ return undefined;
+}
+
+function findReactWrapperForTag(tag: string, reactRoot: string): string | undefined {
+ const declRegex = new RegExp(`["']${escapeRegExp(tag)}["']\\s*:`);
+ const stack = [reactRoot];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ if (!current) continue;
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ stack.push(full);
+ } else if (
+ entry.isFile() &&
+ entry.name.endsWith(".tsx") &&
+ !entry.name.endsWith(".spec.tsx") &&
+ !entry.name.endsWith(".test.tsx")
+ ) {
+ if (declRegex.test(fs.readFileSync(full, "utf-8"))) return full;
+ }
+ }
+ }
+ return undefined;
+}
+
+function extractIntrinsicElementTag(content: string): string | undefined {
+ // declare module "react" { ... interface IntrinsicElements { "goa-foo": ... } }
+ const match = content.match(/IntrinsicElements\s*\{[\s\S]*?["'](goa-[a-z0-9-]+)["']\s*:/);
+ return match ? match[1] : undefined;
+}
+
+function findSvelteDeclaringTag(tag: string): string | undefined {
+ const stack = [UI_COMPONENTS_PATH];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ if (!current) continue;
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ stack.push(full);
+ } else if (
+ entry.isFile() &&
+ entry.name.endsWith(".svelte") &&
+ !entry.name.includes(".test.")
+ ) {
+ const content = fs.readFileSync(full, "utf-8");
+ if (extractTagName(content) === tag) return full;
+ }
+ }
+ }
+ return undefined;
+}
+
// =============================================================================
// Main Extraction
// =============================================================================
@@ -1973,11 +2083,21 @@ function removeStaleApiFiles(validComponentNames: string[]): void {
function extractComponentAPI(componentName: string): ExtractedComponentAPI | null {
const componentPath = path.join(UI_COMPONENTS_PATH, componentName);
- // Find the Svelte file by the component's public name first, then fall back
- // to any .svelte file inside a matching directory.
- const svelteFileName = `${capitalize(toCamelCase(componentName))}.svelte`;
- let svelteFilePath = findFirstFileByName(UI_COMPONENTS_PATH, svelteFileName);
+ // 1. Auto-derive via the React wrapper. The React wrapper's
+ // IntrinsicElements declaration is the source of truth for which
+ // custom element it wraps, so its tag points at the canonical
+ // svelte file. Handles the menu-button / temporary-notification
+ // case where the docs slug and the public element's filename
+ // diverge.
+ let svelteFilePath = resolveSvelteViaReactWrapper(componentName);
+
+ // 2. Fall back to the naming convention (works for the majority).
+ if (!svelteFilePath) {
+ const svelteFileName = `${capitalize(toCamelCase(componentName))}.svelte`;
+ svelteFilePath = findFirstFileByName(UI_COMPONENTS_PATH, svelteFileName) ?? undefined;
+ }
+ // 3. Final fallback: any svelte inside a directory matching the slug.
if (!svelteFilePath && fs.existsSync(componentPath) && fs.statSync(componentPath).isDirectory()) {
const files = fs.readdirSync(componentPath);
const svelteFile = files.find((f) => f.endsWith(".svelte"));
@@ -2174,7 +2294,6 @@ function mergeItemArray(
context: string,
): T[] {
const existingByName = new Map(existing.map((item) => [item.name, item]));
- const nextNames = new Set(next.map((item) => item.name));
// Backfill non-empty descriptions from existing into new items that have empty ones
const merged: T[] = next.map((item) => {
@@ -2185,18 +2304,6 @@ function mergeItemArray(
return item;
});
- // Preserve items from existing that are absent in the new extraction
- // (but skip deprecated entries — they are intentionally excluded)
- for (const existingItem of existing) {
- if (!nextNames.has(existingItem.name)) {
- if ((existingItem as Record).deprecated) continue;
- console.warn(
- ` Preserved manually-added entry "${existingItem.name}" in ${context} (not found in extraction — check source)`,
- );
- merged.push(existingItem);
- }
- }
-
return merged.sort((a, b) => a.name.localeCompare(b.name));
}
@@ -2236,8 +2343,8 @@ function saveComponentAPI(api: ExtractedComponentAPI): void {
const filePath = path.join(OUTPUT_PATH, `${api.componentSlug}.json`);
- // Merge with existing file to preserve manually-added entries that the extractor
- // cannot see (e.g. props defined in function-level intersections, or wrapper gaps).
+ // Merge with existing file to backfill descriptions when the new extraction
+ // has none for an entry.
let merged = api;
if (fs.existsSync(filePath)) {
try {
diff --git a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts
index 4d75f26cfe..fcd050e73f 100644
--- a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts
+++ b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed, tick, fakeAsync } from "@angular/core/testing";
import { GoabWorkSideMenu } from "./work-side-menu";
-import { Component } from "@angular/core";
+import { Component, TemplateRef } from "@angular/core";
import { By } from "@angular/platform-browser";
@Component({
@@ -44,13 +44,40 @@ class TestWorkSideMenuComponent {
this.navigatedUrl = path;
}
}
+
+@Component({
+ standalone: true,
+ imports: [GoabWorkSideMenu],
+ template: `
+
+
+
+ Primary content
+
+ `,
+})
+class TestWorkSideMenuEmptySlotsComponent {
+ open = true;
+ heading = "Test heading";
+ url = "/test";
+ secondaryContent?: TemplateRef;
+ accountContent?: TemplateRef;
+}
+
describe("GoabBWorkSideMenu", () => {
let fixture: ComponentFixture;
let component: TestWorkSideMenuComponent;
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
- imports: [TestWorkSideMenuComponent],
+ imports: [TestWorkSideMenuComponent, TestWorkSideMenuEmptySlotsComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestWorkSideMenuComponent);
@@ -83,4 +110,18 @@ describe("GoabBWorkSideMenu", () => {
expect(component.navigatedUrl).toBe("/dashboard");
}));
+
+ it("should not render secondary or account slot content when those inputs are empty", fakeAsync(() => {
+ const emptyFixture = TestBed.createComponent(TestWorkSideMenuEmptySlotsComponent);
+ emptyFixture.detectChanges();
+ tick(); // Wait for setTimeout in ngOnInit
+ emptyFixture.detectChanges();
+
+ const menuElement = emptyFixture.debugElement.query(By.css("goa-work-side-menu"))
+ .nativeElement as HTMLElement;
+
+ expect(menuElement.querySelector('[slot="secondary"]')).toBeNull();
+ expect(menuElement.querySelector('[slot="account"]')).toBeNull();
+ }));
+
});
diff --git a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts
index 7218e747ea..e7655615e5 100644
--- a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts
+++ b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts
@@ -27,15 +27,21 @@ import { NgTemplateOutlet } from "@angular/common";
(_toggle)="_onToggle()"
(_navigate)="_onNavigate($event)"
>
-
-
-
-
-
-
-
-
-
+ @if (primaryContent) {
+
+
+
+ }
+ @if (secondaryContent) {
+
+
+
+ }
+ @if (accountContent) {
+
+
+
+ }
}
`,
@@ -58,11 +64,11 @@ export class GoabWorkSideMenu implements OnInit {
/** Sets a data-testid attribute for automated testing. */
@Input() testId?: string;
/** Template reference for the primary navigation slot content. */
- @Input() primaryContent!: TemplateRef;
+ @Input() primaryContent!: TemplateRef;
/** Template reference for the secondary navigation slot content. */
- @Input() secondaryContent!: TemplateRef;
+ @Input() secondaryContent!: TemplateRef;
/** Template reference for the account slot content. */
- @Input() accountContent!: TemplateRef;
+ @Input() accountContent!: TemplateRef;
/** Emits when the side menu is toggled open or closed. */
@Output() onToggle = new EventEmitter();
/** Emits when a navigation link is clicked. Emits the URL as a string. */
diff --git a/libs/react-components/specs/tooltip.browser.spec.tsx b/libs/react-components/specs/tooltip.browser.spec.tsx
index 81ba9db820..165f7e8716 100644
--- a/libs/react-components/specs/tooltip.browser.spec.tsx
+++ b/libs/react-components/specs/tooltip.browser.spec.tsx
@@ -1,4 +1,5 @@
import { render } from "vitest-browser-react";
+import { page } from "@vitest/browser/context";
import { GoabTooltip, GoabButton } from "../src";
import { expect, describe, it, vi } from "vitest";
@@ -324,7 +325,9 @@ describe("Tooltip Browser Tests", () => {
);
});
- it("should apply maxWidth using CSS custom property", async () => {
+ it("should apply maxWidth for long tooltip content", async () => {
+ await page.viewport(1280, 800);
+
const Component = () => {
return (
@@ -338,8 +341,10 @@ describe("Tooltip Browser Tests", () => {
}
maxWidth="300px"
+ hAlign="right"
+ testId="test-tooltip"
>
- Constrained tooltip
+ Tooltip
);
@@ -347,34 +352,41 @@ describe("Tooltip Browser Tests", () => {
const result = render( );
const container = result.getByTestId("container");
+ const host = container.element().querySelector("goa-tooltip") as HTMLElement | null;
- await vi.waitFor(
- () => {
- const tooltipEl = container
- .element()
- .querySelector("goa-tooltip") as HTMLElement | null;
- expect(tooltipEl).toBeTruthy();
+ vi.useFakeTimers();
+ try {
+ expect(host).toBeTruthy();
- tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true }));
+ await vi.waitFor(() => {
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ expect(hoverTarget).toBeTruthy();
+ });
- const tooltipTextEl =
- tooltipEl?.shadowRoot?.querySelector(".tooltip-text");
- expect(tooltipTextEl).toBeTruthy();
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ hoverTarget?.dispatchEvent(new MouseEvent("mouseenter"));
- const inlineWidth = tooltipTextEl?.style.width;
- expect(inlineWidth).toBeTruthy();
+ await vi.advanceTimersByTimeAsync(350);
- const numericWidth = parseInt(inlineWidth || "0", 10);
- expect(numericWidth).toBeGreaterThan(0);
+ await vi.waitFor(() => {
+ const tooltipTextEl = host?.shadowRoot?.querySelector(
+ ".tooltip-text",
+ ) as HTMLElement | null;
- // Should not exceed declared maxWidth of 300px (padding/internal adjustments already applied)
- expect(numericWidth).toBeLessThanOrEqual(300);
- },
- { timeout: 3000 },
- );
+ expect(tooltipTextEl?.classList.contains("show")).toBe(true);
+
+ const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0;
+
+ expect(renderedWidth).toBeGreaterThan(200);
+ expect(renderedWidth).toBeLessThanOrEqual(301);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
});
it("should accept valid px units for maxWidth", async () => {
+ await page.viewport(1280, 800);
const Component = () => {
return (
@@ -390,34 +402,41 @@ describe("Tooltip Browser Tests", () => {
const result = render(
);
const container = result.getByTestId("container");
+ const host = container.element().querySelector("goa-tooltip") as HTMLElement | null;
- await vi.waitFor(
- () => {
- const tooltipEl = container
- .element()
- .querySelector("goa-tooltip") as HTMLElement | null;
- expect(tooltipEl).toBeTruthy();
+ vi.useFakeTimers();
+ try {
+ expect(host).toBeTruthy();
- // Trigger tooltip visibility
- tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true }));
+ await vi.waitFor(() => {
+ const hoverTarget = host?.shadowRoot?.querySelector
(".tooltip");
+ expect(hoverTarget).toBeTruthy();
+ });
- const tooltipTextEl =
- tooltipEl?.shadowRoot?.querySelector(".tooltip-text");
- expect(tooltipTextEl).toBeTruthy();
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ hoverTarget?.dispatchEvent(new MouseEvent("mouseenter"));
- // Check that width is constrained by maxWidth
- const inlineWidth = tooltipTextEl?.style.width;
- expect(inlineWidth).toBeTruthy();
+ await vi.advanceTimersByTimeAsync(350);
- const numericWidth = parseInt(inlineWidth || "0", 10);
- expect(numericWidth).toBeGreaterThan(0);
- expect(numericWidth).toBeLessThanOrEqual(250);
- },
- { timeout: 3000 },
- );
+ await vi.waitFor(() => {
+ const tooltipTextEl = host?.shadowRoot?.querySelector(
+ ".tooltip-text",
+ ) as HTMLElement | null;
+
+ expect(tooltipTextEl?.classList.contains("show")).toBe(true);
+
+ const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0;
+
+ expect(renderedWidth).toBeGreaterThan(200);
+ expect(renderedWidth).toBeLessThanOrEqual(251);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
});
it("should use default width when maxWidth has invalid percentage unit", async () => {
+ await page.viewport(1280, 800);
const Component = () => {
return (
@@ -433,37 +452,41 @@ describe("Tooltip Browser Tests", () => {
const result = render(
);
const container = result.getByTestId("container");
+ const host = container.element().querySelector("goa-tooltip") as HTMLElement | null;
- await vi.waitFor(
- () => {
- const tooltipEl = container
- .element()
- .querySelector("goa-tooltip") as HTMLElement | null;
- expect(tooltipEl).toBeTruthy();
+ vi.useFakeTimers();
+ try {
+ expect(host).toBeTruthy();
- // Trigger tooltip visibility
- tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true }));
+ await vi.waitFor(() => {
+ const hoverTarget = host?.shadowRoot?.querySelector
(".tooltip");
+ expect(hoverTarget).toBeTruthy();
+ });
- const tooltipTextEl =
- tooltipEl?.shadowRoot?.querySelector(".tooltip-text");
- expect(tooltipTextEl).toBeTruthy();
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ hoverTarget?.dispatchEvent(new MouseEvent("mouseenter"));
- // Should use default width (400px) since percentage is invalid
- const inlineWidth = tooltipTextEl?.style.width;
- expect(inlineWidth).toBeTruthy();
+ await vi.advanceTimersByTimeAsync(350);
- const numericWidth = parseInt(inlineWidth || "0", 10);
- expect(numericWidth).toBeGreaterThan(0);
+ await vi.waitFor(() => {
+ const tooltipTextEl = host?.shadowRoot?.querySelector(
+ ".tooltip-text",
+ ) as HTMLElement | null;
- // Should use default maxWidth of 400px (minus padding adjustments)
- // The actual width will be constrained by the default 400px maxWidth
- expect(numericWidth).toBeLessThanOrEqual(400);
- },
- { timeout: 3000 },
- );
+ expect(tooltipTextEl?.classList.contains("show")).toBe(true);
+
+ const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0;
+
+ expect(renderedWidth).toBeGreaterThan(300);
+ expect(renderedWidth).toBeLessThanOrEqual(401);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
});
it("should use default width when maxWidth has invalid em unit", async () => {
+ await page.viewport(1280, 800);
const Component = () => {
return (
@@ -479,36 +502,41 @@ describe("Tooltip Browser Tests", () => {
const result = render(
);
const container = result.getByTestId("container");
+ const host = container.element().querySelector("goa-tooltip") as HTMLElement | null;
- await vi.waitFor(
- () => {
- const tooltipEl = container
- .element()
- .querySelector("goa-tooltip") as HTMLElement | null;
- expect(tooltipEl).toBeTruthy();
+ vi.useFakeTimers();
+ try {
+ expect(host).toBeTruthy();
- // Trigger tooltip visibility
- tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true }));
+ await vi.waitFor(() => {
+ const hoverTarget = host?.shadowRoot?.querySelector
(".tooltip");
+ expect(hoverTarget).toBeTruthy();
+ });
- const tooltipTextEl =
- tooltipEl?.shadowRoot?.querySelector(".tooltip-text");
- expect(tooltipTextEl).toBeTruthy();
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ hoverTarget?.dispatchEvent(new MouseEvent("mouseenter"));
- // Should use default width (400px) since em unit is invalid
- const inlineWidth = tooltipTextEl?.style.width;
- expect(inlineWidth).toBeTruthy();
+ await vi.advanceTimersByTimeAsync(350);
- const numericWidth = parseInt(inlineWidth || "0", 10);
- expect(numericWidth).toBeGreaterThan(0);
+ await vi.waitFor(() => {
+ const tooltipTextEl = host?.shadowRoot?.querySelector(
+ ".tooltip-text",
+ ) as HTMLElement | null;
- // Should use default maxWidth of 400px (minus padding adjustments)
- expect(numericWidth).toBeLessThanOrEqual(400);
- },
- { timeout: 3000 },
- );
+ expect(tooltipTextEl?.classList.contains("show")).toBe(true);
+
+ const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0;
+
+ expect(renderedWidth).toBeGreaterThan(300);
+ expect(renderedWidth).toBeLessThanOrEqual(401);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
});
it("should use default width when maxWidth has invalid ch unit", async () => {
+ await page.viewport(1280, 800);
const Component = () => {
return (
@@ -524,36 +552,41 @@ describe("Tooltip Browser Tests", () => {
const result = render(
);
const container = result.getByTestId("container");
+ const host = container.element().querySelector("goa-tooltip") as HTMLElement | null;
- await vi.waitFor(
- () => {
- const tooltipEl = container
- .element()
- .querySelector("goa-tooltip") as HTMLElement | null;
- expect(tooltipEl).toBeTruthy();
+ vi.useFakeTimers();
+ try {
+ expect(host).toBeTruthy();
- // Trigger tooltip visibility
- tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true }));
+ await vi.waitFor(() => {
+ const hoverTarget = host?.shadowRoot?.querySelector
(".tooltip");
+ expect(hoverTarget).toBeTruthy();
+ });
- const tooltipTextEl =
- tooltipEl?.shadowRoot?.querySelector(".tooltip-text");
- expect(tooltipTextEl).toBeTruthy();
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ hoverTarget?.dispatchEvent(new MouseEvent("mouseenter"));
- // Should use default width (400px) since ch unit is invalid
- const inlineWidth = tooltipTextEl?.style.width;
- expect(inlineWidth).toBeTruthy();
+ await vi.advanceTimersByTimeAsync(350);
- const numericWidth = parseInt(inlineWidth || "0", 10);
- expect(numericWidth).toBeGreaterThan(0);
+ await vi.waitFor(() => {
+ const tooltipTextEl = host?.shadowRoot?.querySelector(
+ ".tooltip-text",
+ ) as HTMLElement | null;
- // Should use default maxWidth of 400px (minus padding adjustments)
- expect(numericWidth).toBeLessThanOrEqual(400);
- },
- { timeout: 3000 },
- );
+ expect(tooltipTextEl?.classList.contains("show")).toBe(true);
+
+ const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0;
+
+ expect(renderedWidth).toBeGreaterThan(200);
+ expect(renderedWidth).toBeLessThanOrEqual(401);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
});
it("should use default width when maxWidth has completely invalid value", async () => {
+ await page.viewport(1280, 800);
const Component = () => {
return (
@@ -569,32 +602,36 @@ describe("Tooltip Browser Tests", () => {
const result = render( );
const container = result.getByTestId("container");
+ const host = container.element().querySelector("goa-tooltip") as HTMLElement | null;
- await vi.waitFor(
- () => {
- const tooltipEl = container
- .element()
- .querySelector("goa-tooltip") as HTMLElement | null;
- expect(tooltipEl).toBeTruthy();
+ vi.useFakeTimers();
+ try {
+ expect(host).toBeTruthy();
- // Trigger tooltip visibility
- tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true }));
+ await vi.waitFor(() => {
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ expect(hoverTarget).toBeTruthy();
+ });
- const tooltipTextEl =
- tooltipEl?.shadowRoot?.querySelector(".tooltip-text");
- expect(tooltipTextEl).toBeTruthy();
+ const hoverTarget = host?.shadowRoot?.querySelector(".tooltip");
+ hoverTarget?.dispatchEvent(new MouseEvent("mouseenter"));
- // Should use default width (400px) since value is completely invalid
- const inlineWidth = tooltipTextEl?.style.width;
- expect(inlineWidth).toBeTruthy();
+ await vi.advanceTimersByTimeAsync(350);
- const numericWidth = parseInt(inlineWidth || "0", 10);
- expect(numericWidth).toBeGreaterThan(0);
+ await vi.waitFor(() => {
+ const tooltipTextEl = host?.shadowRoot?.querySelector(
+ ".tooltip-text",
+ ) as HTMLElement | null;
- // Should use default maxWidth of 400px (minus padding adjustments)
- expect(numericWidth).toBeLessThanOrEqual(400);
- },
- { timeout: 3000 },
- );
+ expect(tooltipTextEl?.classList.contains("show")).toBe(true);
+
+ const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0;
+
+ expect(renderedWidth).toBeGreaterThan(200);
+ expect(renderedWidth).toBeLessThanOrEqual(401);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
});
-});
\ No newline at end of file
+});
diff --git a/libs/react-components/specs/work-side-menu.browser.spec.tsx b/libs/react-components/specs/work-side-menu.browser.spec.tsx
index 31a605f5c9..1def59baae 100644
--- a/libs/react-components/specs/work-side-menu.browser.spec.tsx
+++ b/libs/react-components/specs/work-side-menu.browser.spec.tsx
@@ -110,6 +110,173 @@ describe("WorkSideMenu", () => {
expect(menu.element().classList.contains("closed")).toBeFalsy();
});
+ it("should show and hide tooltip for item", async () => {
+ await page.viewport(1024, 768);
+
+ const Component = () => {
+ return (
+
+ }
+ open={false}
+ />
+ );
+ };
+
+ const result = render( );
+ const menu = result.getByTestId("menu");
+ const menuItem = result.getByTestId("hover-item");
+
+ vi.useFakeTimers();
+ try {
+ await menuItem.hover();
+ await vi.advanceTimersByTimeAsync(400);
+
+ await vi.waitFor(() => {
+ const tooltipEl = menu
+ .element()
+ .querySelector(".tooltip") as HTMLElement | null;
+ expect(tooltipEl).toBeTruthy();
+ expect(tooltipEl?.classList.contains("show")).toBe(true);
+ expect(tooltipEl?.textContent?.trim()).toBe("Search");
+ expect(tooltipEl?.style.left).not.toBe("");
+ expect(tooltipEl?.style.top).not.toBe("");
+ });
+
+ await menuItem.unhover();
+ await vi.advanceTimersByTimeAsync(400);
+
+ await vi.waitFor(() => {
+ const tooltipEl = menu
+ .element()
+ .querySelector(".tooltip") as HTMLElement | null;
+ expect(tooltipEl).toBeTruthy();
+ expect(tooltipEl?.classList.contains("show")).toBe(false);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("should show and hide tooltip for group", async () => {
+ await page.viewport(1024, 768);
+
+ const Component = () => {
+ return (
+
+
+
+ }
+ open={false}
+ />
+ );
+ };
+
+ const result = render( );
+ const menu = result.getByTestId("menu");
+ const group = result.getByTestId("hover-group");
+
+ vi.useFakeTimers();
+ try {
+ await group.hover();
+ await vi.advanceTimersByTimeAsync(400);
+
+ await vi.waitFor(() => {
+ const tooltipEl = menu
+ .element()
+ .querySelector(".tooltip") as HTMLElement | null;
+ expect(tooltipEl).toBeTruthy();
+ expect(tooltipEl?.classList.contains("show")).toBe(true);
+ expect(tooltipEl?.textContent?.trim()).toBe("Applications");
+ expect(tooltipEl?.style.left).not.toBe("");
+ expect(tooltipEl?.style.top).not.toBe("");
+ });
+
+ await group.unhover();
+ await vi.advanceTimersByTimeAsync(400);
+
+ await vi.waitFor(() => {
+ const tooltipEl = menu
+ .element()
+ .querySelector(".tooltip") as HTMLElement | null;
+ expect(tooltipEl).toBeTruthy();
+ expect(tooltipEl?.classList.contains("show")).toBe(false);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("should show and hide tooltip for toggle button", async () => {
+ await page.viewport(1024, 768);
+
+ const Component = () => {
+ return (
+
+ }
+ open={false}
+ />
+ );
+ };
+
+ const result = render( );
+ const menu = result.getByTestId("work-side-menu");
+ const toggle = result.getByTestId("toggle-menu");
+
+ vi.useFakeTimers();
+ try {
+ await toggle.hover();
+ await vi.advanceTimersByTimeAsync(400);
+
+ await vi.waitFor(() => {
+ const tooltipEl = menu
+ .element()
+ .querySelector(".tooltip") as HTMLElement | null;
+ expect(tooltipEl).toBeTruthy();
+ expect(tooltipEl?.classList.contains("show")).toBe(true);
+ expect(tooltipEl?.textContent?.trim()).toBe("Expand menu");
+ expect(tooltipEl?.style.left).not.toBe("");
+ expect(tooltipEl?.style.top).not.toBe("");
+ });
+
+ await toggle.unhover();
+ await vi.advanceTimersByTimeAsync(400);
+
+ await vi.waitFor(() => {
+ const tooltipEl = menu
+ .element()
+ .querySelector(".tooltip") as HTMLElement | null;
+ expect(tooltipEl).toBeTruthy();
+ expect(tooltipEl?.classList.contains("show")).toBe(false);
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("should call onNavigate and prevent default navigation when menu item is clicked", async () => {
await page.viewport(1024, 768);
const onNavigate = vi.fn();
diff --git a/libs/web-components/src/assets/css/fonts.css b/libs/web-components/src/assets/css/fonts.css
index f82576743e..79509e475a 100644
--- a/libs/web-components/src/assets/css/fonts.css
+++ b/libs/web-components/src/assets/css/fonts.css
@@ -51,3 +51,14 @@
font-style: normal;
font-weight: 400;
}
+
+@font-face {
+ font-family: "acumin-variable";
+ src: url("https://use.typekit.net/af/80814c/0000000000000000774be2a6/31/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff2"),
+ url("https://use.typekit.net/af/80814c/0000000000000000774be2a6/31/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff"),
+ url("https://use.typekit.net/af/80814c/0000000000000000774be2a6/31/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("opentype");
+ font-display: auto;
+ font-style: normal;
+ font-weight: 100 900;
+ font-stretch: normal;
+}
diff --git a/libs/web-components/src/components/table/Table.svelte b/libs/web-components/src/components/table/Table.svelte
index c185459427..a313d96eca 100644
--- a/libs/web-components/src/components/table/Table.svelte
+++ b/libs/web-components/src/components/table/Table.svelte
@@ -139,7 +139,10 @@
_headings?.forEach((heading) => {
const name = heading.getAttribute("name");
- const direction = heading.getAttribute("direction") as SortDirection | "none" | null;
+ const direction = heading.getAttribute("direction") as
+ | SortDirection
+ | "none"
+ | null;
const sortOrder = Number(heading.getAttribute("sort-order")) || 0;
if (name && direction && direction !== "none") {
entries.push({ column: name, direction, order: sortOrder });
@@ -154,8 +157,8 @@
if (withoutSortOrder.length > 0) {
console.warn(
`[goa-table] Multiple headers have initial sort direction but no sort-order set ` +
- `[${withoutSortOrder.map((e) => e.column).join(", ")}]. ` +
- `Falling back to DOM order. Add sort-order="1", sort-order="2" to set explicit priority.`,
+ `[${withoutSortOrder.map((e) => e.column).join(", ")}]. ` +
+ `Falling back to DOM order. Add sort-order="1", sort-order="2" to set explicit priority.`,
);
}
}
@@ -241,12 +244,7 @@
function dispatchSortEvent() {
if (sortMode === "multi") {
- dispatch(
- _rootEl,
- "_multisort",
- { sorts: _sorts },
- { bubbles: true },
- );
+ dispatch(_rootEl, "_multisort", { sorts: _sorts }, { bubbles: true });
} else {
const firstSort = _sorts[0];
dispatch(
@@ -284,6 +282,10 @@
diff --git a/libs/web-components/src/components/tooltip/Tooltip.svelte b/libs/web-components/src/components/tooltip/Tooltip.svelte
index 51632a6ce5..ebf2d10ae6 100644
--- a/libs/web-components/src/components/tooltip/Tooltip.svelte
+++ b/libs/web-components/src/components/tooltip/Tooltip.svelte
@@ -50,7 +50,7 @@
if (!maxwidth) return "";
// Check for 'px' unit
- if (!maxwidth.endsWith('px')) {
+ if (!maxwidth.endsWith("px")) {
return "";
}
@@ -78,10 +78,18 @@
let _screenSize = 0;
let _rootEl: HTMLElement;
let _tooltipEl: HTMLElement;
+ let _targetEl: HTMLElement;
let _initialPosition: Position;
+ let _computedAlign: Alignment = "center";
let _tooltipVisible = false;
let _showTooltipTimeout: ReturnType | undefined;
let _hideTooltipTimeout: ReturnType | undefined;
+ let _positionRafId: number | null = null;
+ const _needsManualPositioning =
+ typeof document !== "undefined" &&
+ !("anchorName" in document.documentElement.style);
+ const _manualGap = 12;
+ const _manualOffset = 16;
// Use a unique id for each tooltip instance.
// So screen readers can identify the tooltip instance when
@@ -91,17 +99,7 @@
// Reactive
$: {
- if (_rootEl && _tooltipEl) {
- _rootEl.style.setProperty(
- "--target-width",
- `${_rootEl.getBoundingClientRect().width / 2}px`,
- );
- }
- }
-
- // call checkAndAdjustPosition function when content changes
- $: {
- content && checkAndAdjustPosition();
+ content && reconcileTooltipLayout(); // Check position when content changes.
}
// Hooks
@@ -113,24 +111,39 @@
maxwidth = validateMaxWidth(maxwidth);
_initialPosition = position;
+ _computedAlign = halign;
_tooltipInstanceId = Math.random().toString(36);
- window.addEventListener("resize", checkAndAdjustPosition);
- checkAndAdjustPosition();
+ window.addEventListener("resize", onWindowResize);
});
onDestroy(() => {
- window.removeEventListener("resize", checkAndAdjustPosition);
+ window.removeEventListener("resize", onWindowResize);
+
clearTimeout(_showTooltipTimeout);
clearTimeout(_hideTooltipTimeout);
+
+ if (_needsManualPositioning || _positionRafId !== null) {
+ stopManualPositioning();
+ }
});
// Functions
const showTooltip = () => {
_showTooltipTimeout = setTimeout(() => {
+ // Measure target width here — layout is guaranteed to be available on hover
+ updateTargetWidth();
_tooltipVisible = true;
- checkAndAdjustPosition();
+
+ // Popover promotion to the top layer can require one paint before
+ // dimensions are stable, so size/position on the next frame.
+ requestAnimationFrame(() => {
+ reconcileTooltipLayout();
+ if (_needsManualPositioning) {
+ startManualPositioning();
+ }
+ });
}, 300);
};
@@ -140,9 +153,37 @@
_hideTooltipTimeout = setTimeout(() => {
_tooltipVisible = false;
position = _initialPosition;
+ _computedAlign = halign;
+ if (_needsManualPositioning || _positionRafId !== null) {
+ stopManualPositioning();
+ }
}, 500);
};
+ function startManualPositioning() {
+ if (!_needsManualPositioning || _positionRafId !== null) {
+ return;
+ }
+
+ const loop = () => {
+ updateManualPopoverCoordinates();
+ _positionRafId = requestAnimationFrame(loop);
+ };
+
+ _positionRafId = requestAnimationFrame(loop);
+ }
+
+ function stopManualPositioning() {
+ if (_positionRafId !== null) {
+ cancelAnimationFrame(_positionRafId);
+ _positionRafId = null;
+ }
+ }
+
+ function onWindowResize() {
+ reconcileTooltipLayout();
+ }
+
// Mouse click also fires on:focus on a tabindex=0 element, but we only want
// to reveal the tooltip on keyboard-driven focus so JS state stays aligned
// with the CSS :focus-visible rule that drives opacity.
@@ -153,84 +194,203 @@
showTooltip();
}
- async function checkAndAdjustPosition() {
- // angular needs time to render the _tooltipEl
- await tick();
-
- if (!_tooltipEl || !_rootEl) {
- return;
- }
-
- // determine the bounding rectangle of the tooltip and root element
- const tooltipRect = _tooltipEl.getBoundingClientRect();
- const rootRect = _rootEl.getBoundingClientRect();
-
- const spaceTop = rootRect.top;
- const spaceBottom = window.innerHeight - rootRect.bottom;
- const spaceLeft = rootRect.left;
- const spaceRight = window.innerWidth - rootRect.right;
-
- const calculatedMaxWidth = maxwidth && maxwidth.endsWith('px') ? parseFloat(maxwidth) : 400;
+ function applyTooltipWidth(
+ tooltipRect: DOMRect,
+ targetRect: DOMRect,
+ spaceLeft: number,
+ spaceRight: number,
+ ) {
+ const viewportWidth = _screenSize || window.innerWidth;
+ const calculatedMaxWidth =
+ maxwidth && maxwidth.endsWith("px") ? parseFloat(maxwidth) : 400;
const newWidth = Math.min(
- _screenSize * 0.8,
+ viewportWidth * 0.8,
calculatedMaxWidth,
tooltipRect.width,
Math.max(spaceLeft, spaceRight) - 10,
);
const shouldWrapContent =
- (maxwidth && maxwidth.endsWith('px')) ||
- newWidth > rootRect.width ||
+ (maxwidth && maxwidth.endsWith("px")) ||
+ newWidth > targetRect.width ||
newWidth > spaceLeft ||
newWidth > spaceRight;
- _tooltipEl.style.width = `${newWidth - 32}px`;
- if (shouldWrapContent) {
- _tooltipEl.style.whiteSpace = "normal";
- } else {
- _tooltipEl.style.whiteSpace = "nowrap";
+ _tooltipEl.style.width = `${Math.ceil(Math.max(newWidth - 32, 1))}px`;
+ _tooltipEl.style.whiteSpace = shouldWrapContent ? "normal" : "nowrap";
+ }
+
+ function getAdjustedPosition(
+ currentPosition: Position,
+ tooltipRect: DOMRect,
+ spaceTop: number,
+ spaceBottom: number,
+ spaceLeft: number,
+ spaceRight: number,
+ ): Position {
+ let adjustedPosition = currentPosition;
+
+ if (currentPosition === "bottom" && tooltipRect.height > spaceBottom) {
+ adjustedPosition = "top";
+ } else if (currentPosition === "top" && tooltipRect.height > spaceTop) {
+ adjustedPosition = "bottom";
}
- let newPosition = position; // use a local variable to determine the new position
- let newAlign = halign; // use a local variable to determine the new position
+ if (currentPosition === "right" && tooltipRect.width > spaceRight) {
+ adjustedPosition = "left";
+ } else if (currentPosition === "left" && tooltipRect.width > spaceLeft) {
+ adjustedPosition = "right";
+ }
- // check if there is enough space for the tooltip in the initial position
- if (position === "bottom" && tooltipRect.height > spaceBottom) {
- newPosition = "top";
- } else if (position === "top" && tooltipRect.height > spaceTop) {
- newPosition = "bottom";
+ return adjustedPosition;
+ }
+
+ function resolveTopBottomAlign(
+ requestedAlign: Alignment,
+ targetRect: DOMRect,
+ tooltipRect: DOMRect,
+ offset: number,
+ ): Alignment {
+ const viewportWidth = window.innerWidth;
+ const centerLeft =
+ targetRect.left + (targetRect.width - tooltipRect.width) / 2;
+ const rightLeft = targetRect.left - offset;
+ const leftLeft = targetRect.right - tooltipRect.width + offset;
+
+ const projectedLeftByAlign = (align: Alignment): number => {
+ if (align === "left") return leftLeft;
+ if (align === "right") return rightLeft;
+ return centerLeft;
+ };
+
+ const overflowsViewport = (left: number): boolean => {
+ const right = left + tooltipRect.width;
+ return left < 0 || right > viewportWidth;
+ };
+
+ const overflowAmount = (left: number): number => {
+ const right = left + tooltipRect.width;
+ return Math.max(0, -left) + Math.max(0, right - viewportWidth);
+ };
+
+ const requestedLeft = projectedLeftByAlign(requestedAlign);
+ const requestedOverflows = overflowsViewport(requestedLeft);
+
+ if (!requestedOverflows) {
+ return requestedAlign;
}
- // similar check for left and right position
- if (position === "right" && tooltipRect.width > spaceRight) {
- newPosition = "left";
- } else if (position === "left" && tooltipRect.width > spaceLeft) {
- newPosition = "right";
+ if (requestedAlign === "right" || requestedAlign === "left") {
+ const oppositeAlign: Alignment =
+ requestedAlign === "right" ? "left" : "right";
+ const oppositeLeft = projectedLeftByAlign(oppositeAlign);
+
+ if (!overflowsViewport(oppositeLeft)) {
+ return oppositeAlign;
+ }
+
+ if (overflowAmount(oppositeLeft) < overflowAmount(requestedLeft)) {
+ return oppositeAlign;
+ }
+
+ return requestedAlign;
}
- // similar check for left and right alignmewnt
- if (halign === "right" && tooltipRect.width > spaceRight) {
- newAlign = "left";
- } else if (halign === "left" && tooltipRect.width > spaceLeft) {
- newAlign = "right";
- } else if (
- halign === "center" &&
- (position === "top" || position === "bottom") &&
- (tooltipRect.width / 2 > spaceLeft || tooltipRect.width / 2 > spaceRight)
- ) {
- newAlign = spaceLeft > spaceRight ? "left" : "right";
+ const leftOverflow = overflowAmount(leftLeft);
+ const rightOverflow = overflowAmount(rightLeft);
+ return leftOverflow <= rightOverflow ? "left" : "right";
+ }
+
+ async function reconcileTooltipLayout() {
+ // angular needs time to render the _tooltipEl
+ await tick();
+
+ if (!_tooltipEl || !_rootEl || !_targetEl || !_tooltipVisible) {
+ return;
}
+ // determine the bounding rectangle of the tooltip and target element
+ let tooltipRect = _tooltipEl.getBoundingClientRect();
+ const targetRect = _targetEl.getBoundingClientRect();
+
+ const spaceTop = targetRect.top;
+ const spaceBottom = window.innerHeight - targetRect.bottom;
+ const spaceLeft = targetRect.left;
+ const spaceRight = window.innerWidth - targetRect.right;
+ applyTooltipWidth(tooltipRect, targetRect, spaceLeft, spaceRight);
+
+ // Re-measure the tooltip rect after applying width/white-space
+ // since text wrapping can change the tooltip's dimensions
+ tooltipRect = _tooltipEl.getBoundingClientRect();
+
+ const newPosition = getAdjustedPosition(
+ position,
+ tooltipRect,
+ spaceTop,
+ spaceBottom,
+ spaceLeft,
+ spaceRight,
+ );
+
+ const newAlign =
+ newPosition === "top" || newPosition === "bottom"
+ ? resolveTopBottomAlign(halign, targetRect, tooltipRect, _manualOffset)
+ : "center";
+
// update tooltip position
position = newPosition;
- halign = newAlign;
+ _computedAlign = newAlign;
+ }
+
+ function updateManualPopoverCoordinates() {
+ if (!_tooltipVisible || !_tooltipEl || !_targetEl) return;
+
+ const targetRect = _targetEl.getBoundingClientRect();
+ const tooltipRect = _tooltipEl.getBoundingClientRect();
+ const gap = _manualGap;
+ const offset = _manualOffset;
+
+ if (position === "top") {
+ _tooltipEl.style.top = `${targetRect.top - tooltipRect.height - gap}px`;
+ _tooltipEl.style.left = `${targetRect.left + (targetRect.width - tooltipRect.width) / 2}px`;
+ } else if (position === "bottom") {
+ _tooltipEl.style.top = `${targetRect.bottom + gap}px`;
+ _tooltipEl.style.left = `${targetRect.left + (targetRect.width - tooltipRect.width) / 2}px`;
+ } else if (position === "left") {
+ _tooltipEl.style.top = `${targetRect.top + (targetRect.height - tooltipRect.height) / 2}px`;
+ _tooltipEl.style.left = `${targetRect.left - tooltipRect.width - gap}px`;
+ } else if (position === "right") {
+ _tooltipEl.style.top = `${targetRect.top + (targetRect.height - tooltipRect.height) / 2}px`;
+ _tooltipEl.style.left = `${targetRect.right + gap}px`;
+ }
+
+ // Adjust horizontal alignment for top/bottom positions
+ if (position === "top" || position === "bottom") {
+ if (_computedAlign === "left") {
+ // tooltip's right edge aligns with target's right edge
+ _tooltipEl.style.left = `${targetRect.right - tooltipRect.width + offset}px`;
+ } else if (_computedAlign === "right") {
+ // tooltip's left edge aligns with target's left edge
+ _tooltipEl.style.left = `${targetRect.left - offset}px`;
+ }
+ }
+ }
+
+ function updateTargetWidth() {
+ if (!_rootEl || !_tooltipEl || !_targetEl) return;
+
+ const targetHalfWidth = `${_targetEl.getBoundingClientRect().width / 2}px`;
+ _rootEl.style.setProperty("--target-width", targetHalfWidth);
}
+