From 3a950427ccce6fb577dab9020cddbe9f1587868f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:14:12 +0000 Subject: [PATCH 1/2] Update dashboard presenter to clone CAO styling with GitHub Primer and Octicons Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pages/dashboard/PLAN.md | 11 + pages/dashboard/README.md | 3 + pages/dashboard/playwright.config.mjs | 7 +- pages/dashboard/src/components/badge.js | 49 +++ pages/dashboard/src/components/data-state.js | 55 ++++ pages/dashboard/src/dom.js | 20 +- pages/dashboard/src/octicons.js | 124 ++++++++ pages/dashboard/src/presenter.js | 310 ++++++++++++++----- pages/dashboard/src/styles.js | 179 +++++++++++ pages/dashboard/test/e2e/smoke.spec.js | 140 ++++++++- pages/dashboard/test/unit/presenter.test.js | 73 ++++- 11 files changed, 882 insertions(+), 89 deletions(-) create mode 100644 pages/dashboard/src/components/badge.js create mode 100644 pages/dashboard/src/components/data-state.js create mode 100644 pages/dashboard/src/octicons.js create mode 100644 pages/dashboard/src/styles.js diff --git a/pages/dashboard/PLAN.md b/pages/dashboard/PLAN.md index 16e3e38f..f673115a 100644 --- a/pages/dashboard/PLAN.md +++ b/pages/dashboard/PLAN.md @@ -40,6 +40,17 @@ ## Run log +### 2026-08-29 (GitHub Primer brand styling & presentation component slice) + +- Updated the dashboard presenter to clone the style of the current JavaScript dashboard implemented in CAO (`.github/scripts/pages-report/report.mjs`), generating dashboards that are GitHub brand-aligned using GitHub Primer CSS tokens and elements. +- Added GitHub Primer design tokens and stylesheet module (`src/styles.js`) supporting dark, light, contrast, and high-contrast color schemes. +- Added GitHub Octicons and CAO brand mark SVG helpers (`src/octicons.js`) with SVG namespace support in the DOM builder (`src/dom.js`). +- Created reusable presentation components for Primer status/mode badges (`src/components/badge.js`) and data-state metrics card grids (`src/components/data-state.js`). +- Updated `src/presenter.js` to render the Primer `.app-shell` layout with `.org-sidebar`, brand mark, `.primary-nav` with Octicons, breadcrumbs, `.overview-header`, `.table-region` data tables, and `.report-footer`. +- Configured Playwright runner to use the system Chromium binary and expanded unit and E2E test suites to verify Primer styling, brand elements, sidebar navigation, and data badges. +- Verified all quality gates pass: `npm run typecheck`, `npm run lint`, `npm test`, and `npm run test:e2e`. +- Next milestone: Built-in pages, next slice for rendering remaining Section 10 built-in pages (such as overview or tasks) or custom page views. + ### 2026-08-29 (built-in workflows render slice) - Extended the Built-in pages milestone with a narrow `DLS-PAGE-005` and `DLS-PAGE-014` presenter increment for the `workflows` built-in page, rendering workflow inventory with active state, rollout mode, run counts, run conclusion summaries, downstream outcome counts, available AIC totals, finding counts, operational value counts, and independent data-state summaries. diff --git a/pages/dashboard/README.md b/pages/dashboard/README.md index 3eb04d8a..ba003dc8 100644 --- a/pages/dashboard/README.md +++ b/pages/dashboard/README.md @@ -29,3 +29,6 @@ The current built-in-pages slice adds a conservative implementation-local built- The latest built-in-pages increment also adds a conservative implementation-local `definition.data-state` marker for `DLS-PAGE-014`, requiring declarative independent coverage of `availability`, `completeness`, and `freshness` on built-in pages. The current built-in-pages slice extends Section 10 rendering for `runs`, adding a visible browser prototype for status and conclusion counts, downstream outcome counts, scope/model/time columns, run links, and independent freshness/completeness/availability summaries derived from runtime source metadata. + +The latest presenter slice updates the presentation layer to clone the style of the current JavaScript dashboard in CAO (`.github/scripts/pages-report/report.mjs`), rendering dashboards that are GitHub brand-aligned using GitHub Primer CSS tokens, Octicons, sidebar navigation, responsive layout, and status/mode badges. + diff --git a/pages/dashboard/playwright.config.mjs b/pages/dashboard/playwright.config.mjs index 0232fb5f..7b8972ad 100644 --- a/pages/dashboard/playwright.config.mjs +++ b/pages/dashboard/playwright.config.mjs @@ -1,5 +1,9 @@ +import { existsSync } from 'node:fs'; import { defineConfig } from '@playwright/test'; +const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || (existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined); + export default defineConfig({ testMatch: ['**/*.spec.js'], testDir: './test/e2e', @@ -7,7 +11,8 @@ export default defineConfig({ use: { headless: true, launchOptions: { - args: ['--no-sandbox'] + args: ['--no-sandbox'], + ...(executablePath ? { executablePath } : {}) } } }); diff --git a/pages/dashboard/src/components/badge.js b/pages/dashboard/src/components/badge.js new file mode 100644 index 00000000..edb89b63 --- /dev/null +++ b/pages/dashboard/src/components/badge.js @@ -0,0 +1,49 @@ +/** + * Reusable GitHub Primer status and mode badges. + */ + +import { h } from '../dom.js'; + +/** + * @param {unknown} status + * @returns {HTMLElement} + */ +export function renderStatusBadge(status) { + const text = status == null || status === '' ? 'unknown' : String(status); + const normalized = text.toLowerCase(); + let statusClass = 'status-muted'; + + if (['success', 'completed', 'active', 'true', 'fresh', 'available', 'complete', 'accepted'].includes(normalized)) { + statusClass = 'status-success'; + } else if (['in-progress', 'running', 'pending', 'review', 'partial', 'stale', 'attention', 'warning'].includes(normalized)) { + statusClass = 'status-attention'; + } else if (['failure', 'failed', 'rejected', 'danger', 'unavailable', 'critical'].includes(normalized)) { + statusClass = 'status-danger'; + } + + return h('span', { className: `status ${statusClass}` }, text); +} + +/** + * @param {unknown} mode + * @returns {HTMLElement} + */ +export function renderModeBadge(mode) { + const text = mode == null || mode === '' ? 'unknown' : String(mode); + const normalized = text.toLowerCase(); + const modeClass = normalized === 'live' ? 'mode-live' : normalized === 'review' ? 'mode-review' : ''; + + return h('span', { className: `mode-badge ${modeClass}`.trim() }, text); +} + +/** + * @param {unknown} active + * @returns {HTMLElement} + */ +export function renderActiveStateBadge(active) { + const text = String(active); + const isActive = text === 'true' || text === 'active'; + const statusClass = isActive ? 'status-success' : 'status-muted'; + + return h('span', { className: `status ${statusClass}` }, text); +} diff --git a/pages/dashboard/src/components/data-state.js b/pages/dashboard/src/components/data-state.js new file mode 100644 index 00000000..d99cad3a --- /dev/null +++ b/pages/dashboard/src/components/data-state.js @@ -0,0 +1,55 @@ +/** + * GitHub Primer data-state metrics card grid component. + */ + +import { h } from '../dom.js'; +import { renderStatusBadge } from './badge.js'; + +/** + * @typedef {import("../presenter.js").DataState} EffectiveDataState + */ + +/** + * @param {EffectiveDataState | undefined} effectiveState + * @returns {HTMLElement} + */ +export function renderDataStateMetrics(effectiveState) { + const availability = effectiveState?.availability ?? 'available'; + const completeness = effectiveState?.completeness ?? 'complete'; + const freshness = effectiveState?.freshness ?? 'fresh'; + + return h( + 'dl', + { className: 'data-state-summary metrics' }, + h( + 'div', + { className: 'metric-card' }, + h('dt', { className: 'metric-label' }, 'Availability'), + h( + 'dd', + { className: 'metric-value', 'data-state-axis': 'availability' }, + renderStatusBadge(availability), + ), + ), + h( + 'div', + { className: 'metric-card' }, + h('dt', { className: 'metric-label' }, 'Completeness'), + h( + 'dd', + { className: 'metric-value', 'data-state-axis': 'completeness' }, + renderStatusBadge(completeness), + ), + ), + h( + 'div', + { className: 'metric-card' }, + h('dt', { className: 'metric-label' }, 'Freshness'), + h( + 'dd', + { className: 'metric-value', 'data-state-axis': 'freshness' }, + renderStatusBadge(freshness), + ), + ), + ); +} diff --git a/pages/dashboard/src/dom.js b/pages/dashboard/src/dom.js index 126be54a..afcaadf5 100644 --- a/pages/dashboard/src/dom.js +++ b/pages/dashboard/src/dom.js @@ -76,6 +76,22 @@ export function keyed(items, renderItem, key) { return descriptor; } +const SVG_TAGS = new Set([ + 'svg', + 'path', + 'symbol', + 'use', + 'g', + 'defs', + 'line', + 'circle', + 'rect', + 'polyline', + 'polygon', + 'text', + 'tspan' +]); + /** * @param {string} name * @param {Record | null | undefined} [props] @@ -83,7 +99,9 @@ export function keyed(items, renderItem, key) { * @returns {HTMLElement} */ export function h(name, props, ...children) { - const element = document.createElement(name); + const element = SVG_TAGS.has(name) + ? /** @type {HTMLElement} */ (/** @type {unknown} */ (document.createElementNS('http://www.w3.org/2000/svg', name))) + : document.createElement(name); applyProps(element, props ?? {}); appendChildren(element, flattenChildren(children)); return element; diff --git a/pages/dashboard/src/octicons.js b/pages/dashboard/src/octicons.js new file mode 100644 index 00000000..9a24559c --- /dev/null +++ b/pages/dashboard/src/octicons.js @@ -0,0 +1,124 @@ +/** + * GitHub Octicon SVG icons and brand elements. + */ + +import { h } from './dom.js'; + +/** @type {Record }>} */ +const OCTICON_DATA = { + 'mark-github': { + paths: [ + { d: 'M8 0C3.58 0 0 3.64 0 8.13c0 3.59 2.29 6.64 5.47 7.71.4.08.55-.18.55-.39 0-.19-.01-.82-.01-1.49-2.01.44-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.59 1.23.83.72 1.22 1.87.88 2.33.67.07-.53.28-.88.51-1.08-1.78-.21-3.64-.91-3.64-4.02 0-.89.31-1.62.82-2.19-.08-.2-.36-1.04.08-2.16 0 0 .67-.22 2.2.84A7.5 7.5 0 0 1 8 3.85a7.5 7.5 0 0 1 2 .27c1.53-1.06 2.2-.84 2.2-.84.44 1.12.16 1.96.08 2.16.51.57.82 1.3.82 2.19 0 3.12-1.87 3.81-3.65 4.02.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.47.55.39A8.01 8.01 0 0 0 16 8.13C16 3.64 12.42 0 8 0Z' } + ] + }, + server: { + paths: [ + { d: 'M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v4c0 .372-.116.717-.314 1 .198.283.314.628.314 1v4a1.75 1.75 0 0 1-1.75 1.75H1.75A1.75 1.75 0 0 1 0 12.75v-4c0-.358.109-.707.314-1a1.739 1.739 0 0 1-.314-1v-4C0 1.784.784 1 1.75 1ZM1.5 2.75v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Zm.25 5.75a.25.25 0 0 0-.25.25v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25ZM7 4.75A.75.75 0 0 1 7.75 4h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 7 4.75ZM7.75 10h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM3 4.75A.75.75 0 0 1 3.75 4h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 4.75ZM3.75 10h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5Z' } + ] + }, + workflow: { + paths: [ + { d: 'M0 1.75C0 .784.784 0 1.75 0h3.5C6.216 0 7 .784 7 1.75v3.5A1.75 1.75 0 0 1 5.25 7H4v4a1 1 0 0 0 1 1h4v-1.25C9 9.784 9.784 9 10.75 9h3.5c.966 0 1.75.784 1.75 1.75v3.5A1.75 1.75 0 0 1 14.25 16h-3.5A1.75 1.75 0 0 1 9 14.25v-.75H5A2.5 2.5 0 0 1 2.5 11V7h-.75A1.75 1.75 0 0 1 0 5.25Zm1.75-.25a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Zm9 9a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Z' } + ] + }, + play: { + paths: [ + { d: 'M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm6.25-2.11a.75.75 0 0 1 1.14-.64l3 1.86a.75.75 0 0 1 0 1.28l-3 1.86a.75.75 0 0 1-1.14-.64Z' } + ] + }, + repo: { + paths: [ + { d: 'M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z' } + ] + }, + package: { + paths: [ + { d: 'm8.88.49 5.75 2.88c.23.11.37.34.37.59v8.08c0 .25-.14.48-.37.59l-5.75 2.88a1.97 1.97 0 0 1-1.76 0l-5.75-2.88A.66.66 0 0 1 1 12.04V3.96c0-.25.14-.48.37-.59L7.12.49a1.97 1.97 0 0 1 1.76 0ZM8 1.83 3.02 4.32 8 6.81l4.98-2.49L8 1.83Zm-5.5 3.7v6.11l4.75 2.38V7.91L2.5 5.53Zm6.25 8.49 4.75-2.38V5.53L8.75 7.91v6.11Z' } + ] + }, + issue: { + paths: [ + { d: 'M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 12.5a5.5 5.5 0 1 1 0-11 5.5 5.5 0 0 1 0 11Zm-.75-9.25a.75.75 0 0 1 1.5 0v3a.75.75 0 0 1-1.5 0ZM8 9.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z' } + ] + }, + 'pull-request': { + paths: [ + { d: 'M3.25 1.75a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5ZM2.5 6.75v5.19a1.75 1.75 0 1 0 1.5 0V6.75a.75.75 0 0 0-1.5 0Zm10.25 4a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5ZM8.5 2.5a.75.75 0 0 0 0 1.5h1.75A1.75 1.75 0 0 1 12 5.75v3a.75.75 0 0 0 1.5 0v-3a3.25 3.25 0 0 0-3.25-3.25Z' } + ] + }, + 'check-circle': { + paths: [ + { d: 'M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 1.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm3.03 2.97a.75.75 0 0 1 0 1.06l-3.5 3.5a.75.75 0 0 1-1.06 0l-1.5-1.5a.75.75 0 0 1 1.06-1.06L7 8.44l2.97-2.97a.75.75 0 0 1 1.06 0Z' } + ] + }, + meter: { + paths: [ + { d: 'M8 1.5a6.5 6.5 0 1 0 6.016 4.035.75.75 0 0 1 1.388-.57 8 8 0 1 1-4.37-4.37.75.75 0 1 1-.569 1.389A6.473 6.473 0 0 0 8 1.5Zm6.28.22a.75.75 0 0 1 0 1.06l-4.063 4.064a2.5 2.5 0 1 1-1.06-1.06L13.22 1.72a.75.75 0 0 1 1.06 0ZM7 8a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z' } + ] + }, + graph: { + paths: [ + { d: 'M1.5 1.75V13.5h13.75a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75V1.75a.75.75 0 0 1 1.5 0Zm14.28 2.53-5.25 5.25a.75.75 0 0 1-1.06 0L7 7.06 4.28 9.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.25-3.25a.75.75 0 0 1 1.06 0L10 7.94l4.72-4.72a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z' } + ] + }, + eye: { + paths: [ + { d: 'M8 2c3.7 0 6.5 3.2 7.5 5.3a1.6 1.6 0 0 1 0 1.4C14.5 10.8 11.7 14 8 14S1.5 10.8.5 8.7a1.6 1.6 0 0 1 0-1.4C1.5 5.2 4.3 2 8 2Zm0 1.5c-2.9 0-5.3 2.6-6.1 4.4a.2.2 0 0 0 0 .2c.8 1.8 3.2 4.4 6.1 4.4s5.3-2.6 6.1-4.4a.2.2 0 0 0 0-.2C13.3 6.1 10.9 3.5 8 3.5Zm0 1.75a2.75 2.75 0 1 1 0 5.5 2.75 2.75 0 0 1 0-5.5Zm0 1.5a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z' } + ] + }, + 'external-link': { + paths: [ + { d: 'M3.75 2h3a.75.75 0 0 1 0 1.5h-3a.25.25 0 0 0-.25.25v8.5c0 .14.11.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3a.75.75 0 0 1 1.5 0v3A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.78 2.78 2 3.75 2Zm5.5-.75A.75.75 0 0 1 10 0h5.25c.41 0 .75.34.75.75V6a.75.75 0 0 1-1.5 0V2.56L8.78 8.28a.75.75 0 0 1-1.06-1.06l5.72-5.72H10a.75.75 0 0 1-.75-.75Z' } + ] + } +}; + +/** + * @param {string} name + * @param {string} [className] + * @returns {SVGElement} + */ +export function octicon(name, className = '') { + const data = OCTICON_DATA[name]; + const paths = data ? data.paths : []; + + return /** @type {SVGElement} */ (/** @type {unknown} */ (h( + 'svg', + { + className: `octicon octicon-${name}${className ? ` ${className}` : ''}`, + viewBox: data?.viewBox ?? '0 0 16 16', + 'aria-hidden': 'true', + focusable: 'false' + }, + ...paths.map((p) => h('path', { + d: p.d, + fill: p.fill ?? 'currentColor', + ...(p.stroke ? { stroke: p.stroke, 'stroke-width': p.strokeWidth ?? '1' } : {}) + })) + ))); +} + +/** + * @returns {SVGElement} + */ +export function agenticWorkflowMark() { + return /** @type {SVGElement} */ (/** @type {unknown} */ (h( + 'svg', + { + className: 'sidebar-brand-mark', + viewBox: '0 0 24 24', + 'aria-hidden': 'true', + focusable: 'false' + }, + h('path', { + d: 'M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5Zm11.5 11.5a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z', + fill: 'currentColor' + }), + h('path', { + d: 'm17.143 3.15c.083-.222.406-.222.49 0l.58 1.545c.18.48.565.855 1.049 1.023l1.584.566c.228.081.228.396 0 .477l-1.584.566a1.763 1.719 0 0 0-1.05 1.023l-.58 1.545c-.083.223-.406.223-.489 0l-.58-1.545a1.763 1.719 0 0 0-1.049-1.023l-1.584-.566c-.228-.081-.228-.396 0-.477l1.584-.566a1.763 1.719 0 0 0 1.05-1.023Z', + fill: '#c06eff', + stroke: '#c06eff', + 'stroke-width': '.717' + }) + ))); +} diff --git a/pages/dashboard/src/presenter.js b/pages/dashboard/src/presenter.js index 3441a74b..3b81f6f7 100644 --- a/pages/dashboard/src/presenter.js +++ b/pages/dashboard/src/presenter.js @@ -1,8 +1,12 @@ /** - * Tiny presenter prototype for built-in and custom dashboard pages. + * Presenter for built-in and custom dashboard pages using GitHub Primer styling and elements. */ import { h, keyed } from './dom.js'; +import { getPrimerStyles } from './styles.js'; +import { octicon, agenticWorkflowMark } from './octicons.js'; +import { renderStatusBadge, renderModeBadge, renderActiveStateBadge } from './components/badge.js'; +import { renderDataStateMetrics } from './components/data-state.js'; /** * @typedef {{ availability: 'available'|'empty'|'unavailable', completeness: 'complete'|'partial'|'unknown', freshness: 'fresh'|'stale'|'unknown' }} DataState @@ -39,15 +43,168 @@ import { h, keyed } from './dom.js'; export function renderDashboard(input) { const { document, sources } = input; const title = document.dashboard.title; + const description = document.dashboard.description; + const pages = document.dashboard.pages; + const orgName = inferOrganizationName(sources) || 'GitHub'; + + const styleEl = h('style', null, getPrimerStyles()); + const skipLink = h('a', { href: '#main-content', className: 'skip-link' }, 'Skip to main content'); + + const sidebar = renderSidebar(document, pages, orgName); + const mainContent = renderMainContent(document, title, description, pages, sources, orgName); return h( - 'main', + 'div', { className: 'dashboard-prototype' }, - h('h1', null, title), + styleEl, + skipLink, + h( + 'div', + { className: 'app-shell' }, + sidebar, + mainContent + ) + ); +} + +/** + * @param {Record} sources + * @returns {string | null} + */ +function inferOrganizationName(sources) { + for (const source of Object.values(sources)) { + if (Array.isArray(source?.rows)) { + for (const row of source.rows) { + if (typeof row?.organization === 'string' && row.organization.length > 0) { + return row.organization; + } + } + } + } + return null; +} + +/** + * @param {PresentationDocument} document + * @param {Array} pages + * @param {string} orgName + * @returns {HTMLElement} + */ +function renderSidebar(document, pages, orgName) { + return h( + 'aside', + { className: 'org-sidebar', role: 'region', 'aria-label': 'Organization navigation' }, + h( + 'div', + { className: 'brand' }, + h('div', { className: 'brand-mark' }, agenticWorkflowMark()), + h( + 'div', + { className: 'brand-meta' }, + h('span', { className: 'brand-title' }, document.dashboard.title), + h('span', { className: 'brand-org' }, orgName) + ) + ), + h( + 'nav', + { className: 'primary-nav', 'aria-label': 'Primary navigation' }, + pages.map((page, index) => renderNavItem(page, index === 0)) + ), + h( + 'div', + { className: 'sidebar-footer' }, + `CAO Dashboard • Lang v${document.languageVersion}` + ) + ); +} + +/** + * @param {PresentableBuiltInPage | PresentableCustomPage} page + * @param {boolean} isActive + * @returns {HTMLElement} + */ +function renderNavItem(page, isActive) { + const iconName = getPageIcon(page); + const title = typeof page.title === 'string' && page.title.length > 0 + ? page.title + : titleCase(page.id); + + return h( + 'a', + { + href: `#page-${page.id}`, + className: `nav-item${isActive ? ' active' : ''}`, + 'aria-current': isActive ? 'page' : undefined, + 'data-nav-page-id': page.id + }, + h('span', { className: 'nav-icon' }, octicon(iconName)), + h('span', { className: 'nav-label' }, title) + ); +} + +/** + * @param {PresentableBuiltInPage | PresentableCustomPage} page + * @returns {string} + */ +function getPageIcon(page) { + if (page.kind === 'built-in') { + if (page.page === 'workflows') return 'workflow'; + if (page.page === 'runs') return 'play'; + if (page.page === 'tasks') return 'issue'; + if (page.page === 'repositories') return 'repo'; + } + const id = page.id.toLowerCase(); + if (id.includes('workflow')) return 'workflow'; + if (id.includes('run')) return 'play'; + if (id.includes('metric') || id.includes('usage')) return 'graph'; + if (id.includes('task') || id.includes('issue')) return 'issue'; + if (id.includes('repo')) return 'repo'; + if (id.includes('package')) return 'package'; + return 'server'; +} + +/** + * @param {PresentationDocument} document + * @param {string} title + * @param {string | undefined} description + * @param {Array} pages + * @param {Record} sources + * @param {string} orgName + * @returns {HTMLElement} + */ +function renderMainContent(document, title, description, pages, sources, orgName) { + return h( + 'main', + { className: 'app-main', id: 'main-content', tabIndex: -1 }, + h( + 'nav', + { className: 'top-nav', 'aria-label': 'Breadcrumb' }, + h( + 'ol', + { className: 'breadcrumb' }, + h('li', null, h('a', { href: '#/' }, orgName)), + h('li', null, h('a', { href: '#/dashboard' }, title)) + ) + ), + h( + 'header', + { className: 'overview-header' }, + h('h1', null, title), + description ? h('p', null, description) : null + ), h( 'div', - { className: 'dashboard-pages' }, - document.dashboard.pages.map((page) => renderPage(page, sources)) + { className: 'report-body' }, + h( + 'div', + { className: 'dashboard-pages' }, + pages.map((page) => renderPage(page, sources)) + ) + ), + h( + 'footer', + { className: 'report-footer' }, + h('p', null, 'Generated by Central Agentic Ops • GitHub Primer Design System') ) ); } @@ -110,18 +267,9 @@ function renderBuiltInPage(page, title, sources) { return h( 'section', - { className: 'dashboard-page', 'data-page-kind': 'built-in', 'data-page-name': page.page, 'data-page-id': page.id }, + { className: 'dashboard-page', id: `page-${page.id}`, 'data-page-kind': 'built-in', 'data-page-name': page.page, 'data-page-id': page.id }, h('h2', null, title), - h( - 'dl', - { className: 'data-state-summary' }, - h('dt', null, 'Availability'), - h('dd', { 'data-state-axis': 'availability' }, effectiveState.availability), - h('dt', null, 'Completeness'), - h('dd', { 'data-state-axis': 'completeness' }, effectiveState.completeness), - h('dt', null, 'Freshness'), - h('dd', { 'data-state-axis': 'freshness' }, effectiveState.freshness) - ), + renderDataStateMetrics(effectiveState), builtInBody, h('h3', null, 'Provenance'), h( @@ -182,39 +330,43 @@ function renderRunsPage(pageSources) { renderSummaryList('run-outcome-counts', outcomeCounts), h('h3', null, 'Runs'), h( - 'table', - { className: 'runs-table' }, + 'div', + { className: 'table-region' }, h( - 'thead', - null, + 'table', + { className: 'runs-table' }, h( - 'tr', + 'thead', null, - h('th', null, 'Run'), - h('th', null, 'Status'), - h('th', null, 'Conclusion'), - h('th', null, 'Organization'), - h('th', null, 'Repository'), - h('th', null, 'Workflow'), - h('th', null, 'Rollout Mode'), - h('th', null, 'Engine'), - h('th', null, 'Requested Model'), - h('th', null, 'Resolved Model'), - h('th', null, 'Started At'), - h('th', null, 'Outcome Count'), - h('th', null, 'Run Link') - ) - ), - h( - 'tbody', - null, - items.length > 0 - ? keyed( - items, - (item) => renderRunRow(/** @type {{ key: string, run: Record, outcomeCount: number }} */ (item)), - (item) => /** @type {{ key: string }} */ (item).key + h( + 'tr', + null, + h('th', null, 'Run'), + h('th', null, 'Status'), + h('th', null, 'Conclusion'), + h('th', null, 'Organization'), + h('th', null, 'Repository'), + h('th', null, 'Workflow'), + h('th', null, 'Rollout Mode'), + h('th', null, 'Engine'), + h('th', null, 'Requested Model'), + h('th', null, 'Resolved Model'), + h('th', null, 'Started At'), + h('th', null, 'Outcome Count'), + h('th', null, 'Run Link') ) - : h('tr', null, h('td', { colSpan: 13 }, 'No runs available.')) + ), + h( + 'tbody', + null, + items.length > 0 + ? keyed( + items, + (item) => renderRunRow(/** @type {{ key: string, run: Record, outcomeCount: number }} */ (item)), + (item) => /** @type {{ key: string }} */ (item).key + ) + : h('tr', null, h('td', { colSpan: 13 }, 'No runs available.')) + ) ) ) ); @@ -232,12 +384,12 @@ function renderRunRow(item) { 'tr', { 'data-run-id': String(run.run ?? item.key) }, h('td', null, toText(run.run)), - h('td', null, toText(run['run-status'])), - h('td', null, toText(run['run-conclusion'])), + h('td', null, renderStatusBadge(run['run-status'])), + h('td', null, renderStatusBadge(run['run-conclusion'])), h('td', null, toText(run.organization)), h('td', null, toText(run.repository)), h('td', null, toText(run.workflow)), - h('td', null, toText(run['rollout-mode'])), + h('td', null, renderModeBadge(run['rollout-mode'])), h('td', null, toText(run.engine)), h('td', null, toText(run['requested-model'])), h('td', null, toText(run['resolved-model'])), @@ -288,37 +440,41 @@ function renderWorkflowsPage(pageSources) { { className: 'workflows-page' }, h('h3', null, 'Workflow Inventory'), h( - 'table', - { className: 'workflows-table' }, + 'div', + { className: 'table-region' }, h( - 'thead', - null, + 'table', + { className: 'workflows-table' }, h( - 'tr', + 'thead', null, - h('th', null, 'Workflow'), - h('th', null, 'Organization'), - h('th', null, 'Repository'), - h('th', null, 'Active State'), - h('th', null, 'Rollout Mode'), - h('th', null, 'Run Count'), - h('th', null, 'Run Conclusions'), - h('th', null, 'Outcome Count'), - h('th', null, 'Available AIC'), - h('th', null, 'Finding Count'), - h('th', null, 'Operational Value Count') - ) - ), - h( - 'tbody', - null, - workflowItems.length > 0 - ? keyed( - workflowItems, - (item) => renderWorkflowRow(/** @type {{ key: string, workflow: Record, runCount: number, conclusionCounts: Map, outcomeCount: number, aicTotal: number, findingCount: number, operationalValueCount: number }} */ (item)), - (item) => /** @type {{ key: string }} */ (item).key + h( + 'tr', + null, + h('th', null, 'Workflow'), + h('th', null, 'Organization'), + h('th', null, 'Repository'), + h('th', null, 'Active State'), + h('th', null, 'Rollout Mode'), + h('th', null, 'Run Count'), + h('th', null, 'Run Conclusions'), + h('th', null, 'Outcome Count'), + h('th', null, 'Available AIC'), + h('th', null, 'Finding Count'), + h('th', null, 'Operational Value Count') ) - : h('tr', null, h('td', { colSpan: 11 }, 'No workflows available.')) + ), + h( + 'tbody', + null, + workflowItems.length > 0 + ? keyed( + workflowItems, + (item) => renderWorkflowRow(/** @type {{ key: string, workflow: Record, runCount: number, conclusionCounts: Map, outcomeCount: number, aicTotal: number, findingCount: number, operationalValueCount: number }} */ (item)), + (item) => /** @type {{ key: string }} */ (item).key + ) + : h('tr', null, h('td', { colSpan: 11 }, 'No workflows available.')) + ) ) ) ); @@ -337,8 +493,8 @@ function renderWorkflowRow(item) { h('td', null, toText(workflow.workflow)), h('td', null, toText(workflow.organization)), h('td', null, toText(workflow.repository)), - h('td', null, toText(workflow['workflow-active'])), - h('td', null, toText(workflow['rollout-mode'])), + h('td', null, renderActiveStateBadge(workflow['workflow-active'])), + h('td', null, renderModeBadge(workflow['rollout-mode'])), h('td', null, String(item.runCount)), h('td', null, formatCounts(item.conclusionCounts)), h('td', null, String(item.outcomeCount)), diff --git a/pages/dashboard/src/styles.js b/pages/dashboard/src/styles.js new file mode 100644 index 00000000..289590ba --- /dev/null +++ b/pages/dashboard/src/styles.js @@ -0,0 +1,179 @@ +/** + * GitHub Primer CSS tokens and element styles cloned from CAO dashboard. + */ + +/** + * @returns {string} + */ +export function primerStylesheet() { + return `:root { + --canvas: #0d1117; + --canvas-subtle: #151b23; + --canvas-inset: #010409; + --header: #010409; + --fg: #f0f6fc; + --muted: #9198a1; + --border: #3d444d; + --border-muted: #21262d; + --accent: #58a6ff; + --accent-muted: #121d2f; + --success: #3fb950; + --success-muted: #12261e; + --danger: #f85149; + --cancelled: #8c959f; + --attention: #d29922; + --attention-muted: #272115; + --neutral-muted: #6e768166; + --focus: #58a6ff; +} +@media (prefers-color-scheme: light) { + :root { + --canvas: #ffffff; + --canvas-subtle: #f6f8fa; + --canvas-inset: #f6f8fa; + --header: #f6f8fa; + --fg: #1f2328; + --muted: #59636e; + --border: #d1d9e0; + --border-muted: #d8dee4; + --accent: #0969da; + --accent-muted: #ddf4ff; + --success: #1a7f37; + --success-muted: #dafbe1; + --danger: #cf222e; + --cancelled: #656d76; + --attention: #9a6700; + --attention-muted: #fff8c5; + --neutral-muted: #afb8c133; + --focus: #0969da; + } +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; background: var(--canvas); color: var(--fg); font: .875rem/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; } +.dashboard-root { min-height: 100vh; background: var(--canvas); color: var(--fg); font: .875rem/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +.octicon-sprite { width: 0; height: 0; position: absolute; overflow: hidden; } +.octicon { width: 16px; height: 16px; flex: 0 0 16px; fill: currentColor; vertical-align: text-bottom; } +a { color: var(--accent); text-decoration: none; text-underline-offset: 2px; } +a:hover { text-decoration: underline; text-decoration-thickness: 2px; } +a:focus-visible, [tabindex]:focus-visible, button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.skip-link { position: fixed; z-index: 10; top: -80px; left: 12px; padding: 7px 12px; border: 1px solid var(--focus); border-radius: 6px; background: var(--canvas); color: var(--accent); font-weight: 600; text-decoration: none; } +.skip-link:focus { top: 8px; } +.app-shell { min-height: 100vh; display: grid; grid-template-columns: 232px minmax(0, 1fr); } +.org-sidebar { min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 24px 16px 16px; border-right: 1px solid var(--border); background: var(--canvas-subtle); } +.sidebar-brand { display: flex; align-items: center; gap: 8px; margin: 0 8px 10px; overflow: hidden; color: var(--fg); font-size: 1rem; font-weight: 600; text-decoration: none; white-space: nowrap; } +.sidebar-brand-mark { width: 24px; height: 24px; flex: 0 0 24px; overflow: visible; } +.sidebar-brand > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.primary-nav { display: flex; flex-direction: column; gap: 2px; } +.primary-nav a, .nav-parent { min-height: 32px; display: flex; align-items: center; gap: 10px; position: relative; padding: 6px 8px; border-radius: 6px; color: var(--fg); font-weight: 500; text-decoration: none; } +.primary-nav :is(a, .nav-parent) > .octicon { color: var(--muted); } +.primary-nav a:hover { background: var(--neutral-muted); } +.primary-nav a[aria-current="page"] { background: var(--neutral-muted); font-weight: 600; } +.primary-nav a[aria-current="page"]::before { content: ""; width: 3px; position: absolute; top: 5px; bottom: 5px; left: -16px; border-radius: 0 4px 4px 0; background: var(--accent); } +.app-main { min-width: 0; display: flex; flex-direction: column; } +.app-main > nav { border-bottom: 1px solid var(--border); background: var(--canvas); } +.app-main > nav .shell { display: flex; align-items: center; gap: 8px; max-width: 1280px; margin: auto; padding: 10px 24px; } +.app-main > nav .shell > a { min-height: 24px; display: inline-flex; align-items: center; } +.app-main > nav .shell > * + *:not(.report-actions)::before { content: "/"; margin-right: 8px; color: var(--muted); } +.report-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.freshness { max-width: none; flex: none; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .75rem; white-space: nowrap; } +.repository-link { width: 28px; height: 28px; display: grid; flex: 0 0 28px; place-items: center; border-radius: 6px; color: var(--muted); text-decoration: none; transition: background-color 120ms ease, color 120ms ease; } +.repository-link:hover { background: var(--neutral-muted); color: var(--fg); } +.repository-link .octicon { width: 18px; height: 18px; } +main.dashboard-prototype { width: min(1280px, 100%); flex: 1; margin: 0 auto; padding: 0 20px 40px; } +.overview-header { min-height: 88px; display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; padding: 18px 0 14px; border-bottom: 1px solid var(--border); margin-bottom: 20px; } +.overview-header h1 { margin: 0; font-size: 1.5rem; line-height: 1.25; font-weight: 600; } +.overview-header .lede { max-width: 760px; margin: 6px 0 0; color: var(--muted); font-size: .875rem; } +.title-area { display: flex; align-items: center; gap: 8px; } +.dashboard-pages { display: flex; flex-direction: column; gap: 24px; } +.dashboard-page { padding: 0; } +.dashboard-page > h2 { margin: 0 0 14px; font-size: 1.25rem; font-weight: 600; } +h3 { margin: 16px 0 8px; font-size: 1rem; font-weight: 600; } +.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 0 0 20px; overflow: visible; } +.metrics div, .data-state-summary > div { min-width: 0; min-height: 90px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--canvas-subtle); } +.data-state-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 0 0 20px; } +.data-state-summary dt, .metrics dt { color: var(--muted); font-size: .75rem; font-weight: 600; text-transform: uppercase; margin: 0; } +.data-state-summary dd, .metrics dd { margin: 4px 0 0; font-size: 1.375rem; font-weight: 600; font-variant-numeric: tabular-nums; text-transform: capitalize; } +.data-state-summary dd[data-state-axis="availability"], +.data-state-summary dd[data-state-axis="completeness"], +.data-state-summary dd[data-state-axis="freshness"] { color: var(--fg); } +.summary-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 20px; } +.summary-card { padding: 14px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--canvas-subtle); } +.summary-card h4 { margin: 0 0 8px; font-size: .875rem; color: var(--muted); font-weight: 600; text-transform: uppercase; } +.summary-list, .run-status-counts, .run-conclusion-counts, .run-outcome-counts { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; } +.summary-list li, .run-status-counts li, .run-conclusion-counts li, .run-outcome-counts li { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 2em; background: var(--canvas-subtle); font-size: .75rem; font-weight: 600; } +.table-region { overflow-x: auto; border: 1px solid var(--border); border-radius: 6px; margin: 12px 0 20px; background: var(--canvas); } +table { width: 100%; min-width: 600px; border-collapse: collapse; font-size: .875rem; } +caption { padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--canvas-subtle); color: var(--muted); text-align: left; font-weight: 600; font-size: .8125rem; } +th, td { padding: 10px 14px; border-bottom: 1px solid var(--border-muted); text-align: left; font-variant-numeric: tabular-nums; } +thead th { background: var(--canvas-subtle); color: var(--muted); font-size: .75rem; font-weight: 600; border-bottom: 1px solid var(--border); white-space: nowrap; } +tbody tr:last-child > * { border-bottom: 0; } +tbody tr:hover { background: var(--canvas-subtle); } +.kind, .status, .mode-badge, .workflow-badge { display: inline-flex; align-items: center; min-height: 20px; padding: 0 7px; border: 1px solid var(--border); border-radius: 2em; color: var(--muted); font-size: .6875rem; font-weight: 600; text-transform: capitalize; white-space: nowrap; } +.status-success { border-color: color-mix(in srgb, var(--success) 45%, var(--border)); background: var(--success-muted); color: var(--success); } +.status-attention { border-color: color-mix(in srgb, var(--attention) 45%, var(--border)); background: var(--attention-muted); color: var(--attention); } +.status-danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); background: var(--danger-muted, #f851491a); color: var(--danger); } +.status-muted { background: var(--neutral-muted); } +.mode-live { border-color: color-mix(in srgb, var(--success) 45%, var(--border)); background: var(--success-muted); color: var(--success); } +.mode-review { border-color: color-mix(in srgb, var(--attention) 45%, var(--border)); background: var(--attention-muted); color: var(--attention); } +.mode-indicator { min-height: 22px; display: inline-flex; flex: none; align-items: center; gap: 5px; padding: 1px 7px; border: 1px solid var(--border); border-radius: 2em; font-size: .6875rem; font-weight: 600; text-transform: none; white-space: nowrap; } +.mode-indicator .octicon { width: 13px; height: 13px; flex-basis: 13px; } +.provenance-section { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-muted); } +.provenance-list { margin: 8px 0 0; padding-left: 20px; color: var(--muted); font-size: .8125rem; } +.provenance-list li + li { margin-top: 4px; } +code { padding: 2px 4px; border-radius: 4px; background: var(--neutral-muted); font: .75rem ui-monospace, SFMono-Regular, Consolas, monospace; } +footer { padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: .75rem; margin-top: 40px; } +.empty, .page-placeholder { margin: 0; padding: 28px 16px; color: var(--muted); text-align: center; } +@media (max-width: 700px) { + .app-shell { display: block; } + .org-sidebar { display: block; padding: 14px 12px 10px; border-right: 0; border-bottom: 1px solid var(--border); } + .sidebar-brand { margin-bottom: 8px; font-size: 1rem; } + .primary-nav { width: 100%; flex-direction: row; overflow-x: auto; } + .primary-nav a { min-height: 44px; flex: none; } + .overview-header { min-height: 0; padding: 24px 0 20px; flex-direction: column; gap: 12px; } + main.dashboard-prototype { padding: 0 14px 28px; } + .data-state-summary, .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 420px) { + .data-state-summary, .metrics { grid-template-columns: 1fr; } +} +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } +@media (prefers-contrast: more) { + :root { + --border: var(--fg); + --border-muted: var(--muted); + } + a:focus-visible, [tabindex]:focus-visible { outline-width: 3px; } +} +@media (forced-colors: active) { + :root { + --canvas: Canvas; + --canvas-subtle: Canvas; + --canvas-inset: Canvas; + --header: Canvas; + --fg: CanvasText; + --muted: CanvasText; + --border: ButtonBorder; + --border-muted: ButtonBorder; + --accent: LinkText; + --accent-muted: Canvas; + --success: CanvasText; + --success-muted: Canvas; + --danger: CanvasText; + --cancelled: CanvasText; + --attention: CanvasText; + --attention-muted: Canvas; + --neutral-muted: Canvas; + --focus: Highlight; + } +} +@media print { + .org-sidebar, .app-main > nav, .skip-link { display: none; } + .app-shell { display: block; } + main.dashboard-prototype { width: 100%; padding: 0; } + a { color: inherit; text-decoration: underline; } +}`; +} + +export const getPrimerStyles = primerStylesheet; + diff --git a/pages/dashboard/test/e2e/smoke.spec.js b/pages/dashboard/test/e2e/smoke.spec.js index dc865c5e..d509d7aa 100644 --- a/pages/dashboard/test/e2e/smoke.spec.js +++ b/pages/dashboard/test/e2e/smoke.spec.js @@ -1,11 +1,38 @@ import { readFileSync } from 'node:fs'; import { test, expect } from '@playwright/test'; -test('DLS-PAGE-005 DLS-PAGE-014 built-in workflows page renders inventory, active state, rollout mode, run conclusions, outcomes, usage, findings, operational value, and independent data state in browser', async ({ page }) => { +function buildPresenterModuleUrl() { const domSource = readFileSync(new URL('../../src/dom.js', import.meta.url), 'utf8'); - const presenterSource = readFileSync(new URL('../../src/presenter.js', import.meta.url), 'utf8'); const domModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(domSource)}`; - const presenterModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(presenterSource.replace("'./dom.js'", JSON.stringify(domModuleUrl)))}`; + + const stylesSource = readFileSync(new URL('../../src/styles.js', import.meta.url), 'utf8'); + const stylesModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(stylesSource)}`; + + const octiconsSource = readFileSync(new URL('../../src/octicons.js', import.meta.url), 'utf8') + .replace("'./dom.js'", JSON.stringify(domModuleUrl)); + const octiconsModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(octiconsSource)}`; + + const badgeSource = readFileSync(new URL('../../src/components/badge.js', import.meta.url), 'utf8') + .replace("'../dom.js'", JSON.stringify(domModuleUrl)); + const badgeModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(badgeSource)}`; + + const dataStateSource = readFileSync(new URL('../../src/components/data-state.js', import.meta.url), 'utf8') + .replace("'../dom.js'", JSON.stringify(domModuleUrl)) + .replace("'./badge.js'", JSON.stringify(badgeModuleUrl)); + const dataStateModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(dataStateSource)}`; + + const presenterSource = readFileSync(new URL('../../src/presenter.js', import.meta.url), 'utf8') + .replace("'./dom.js'", JSON.stringify(domModuleUrl)) + .replace("'./styles.js'", JSON.stringify(stylesModuleUrl)) + .replace("'./octicons.js'", JSON.stringify(octiconsModuleUrl)) + .replace("'./components/badge.js'", JSON.stringify(badgeModuleUrl)) + .replace("'./components/data-state.js'", JSON.stringify(dataStateModuleUrl)); + + return `data:text/javascript;charset=utf-8,${encodeURIComponent(presenterSource)}`; +} + +test('DLS-PAGE-005 DLS-PAGE-014 built-in workflows page renders inventory, active state, rollout mode, run conclusions, outcomes, usage, findings, operational value, and independent data state in browser', async ({ page }) => { + const presenterModuleUrl = buildPresenterModuleUrl(); await page.setContent(`
@@ -168,7 +195,7 @@ test('DLS-PAGE-005 DLS-PAGE-014 built-in workflows page renders inventory, activ await expect(page.locator('[data-state-axis="completeness"]')).toHaveText('partial'); await expect(page.locator('[data-state-axis="freshness"]')).toHaveText('stale'); await expect(page.locator('.workflows-table tbody tr')).toHaveCount(2); - await expect(page.locator('.workflows-table tbody tr').first()).toContainText([ + await expect(page.locator('.workflows-table tbody tr').first().locator('td')).toContainText([ 'dashboard.yml', 'githubnext', 'central-agentic-ops', @@ -181,7 +208,7 @@ test('DLS-PAGE-005 DLS-PAGE-014 built-in workflows page renders inventory, activ '1', '1' ]); - await expect(page.locator('.workflows-table tbody tr').nth(1)).toContainText([ + await expect(page.locator('.workflows-table tbody tr').nth(1).locator('td')).toContainText([ 'release.yml', 'false', 'live', @@ -203,10 +230,7 @@ test('DLS-PAGE-005 DLS-PAGE-014 built-in workflows page renders inventory, activ }); test('DLS-PAGE-006 DLS-PAGE-014 built-in runs page renders status counts, outcomes, scope, models, time, run links, and independent data state in browser', async ({ page }) => { - const domSource = readFileSync(new URL('../../src/dom.js', import.meta.url), 'utf8'); - const presenterSource = readFileSync(new URL('../../src/presenter.js', import.meta.url), 'utf8'); - const domModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(domSource)}`; - const presenterModuleUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(presenterSource.replace("'./dom.js'", JSON.stringify(domModuleUrl)))}`; + const presenterModuleUrl = buildPresenterModuleUrl(); await page.setContent(`
@@ -364,3 +388,101 @@ test('DLS-PAGE-006 DLS-PAGE-014 built-in runs page renders status counts, outcom 'outcomes: outcomes-fixture (fixture) — as of 2026-08-29T12:10:00Z' ]); }); + +test('DLS-PRES-001 GitHub Primer brand-aligned app shell, sidebar navigation, and Octicon elements render correctly in browser', async ({ page }) => { + const presenterModuleUrl = buildPresenterModuleUrl(); + + await page.setContent(` +
+ + `); + + await expect(page.locator('.skip-link')).toHaveAttribute('href', '#main-content'); + await expect(page.locator('.org-sidebar')).toBeVisible(); + await expect(page.locator('.sidebar-brand-mark')).toBeVisible(); + await expect(page.locator('.brand-title')).toHaveText('Agentic Operations Dashboard'); + await expect(page.locator('.brand-org')).toHaveText('githubnext'); + await expect(page.locator('.primary-nav .nav-item')).toHaveCount(2); + await expect(page.locator('.primary-nav .nav-item').first()).toHaveClass(/active/); + await expect(page.locator('.primary-nav .nav-item .octicon-workflow')).toBeVisible(); + await expect(page.locator('.primary-nav .nav-item .octicon-play')).toBeVisible(); + await expect(page.locator('.breadcrumb')).toContainText('githubnext'); + await expect(page.locator('.overview-header h1')).toHaveText('Agentic Operations Dashboard'); + await expect(page.locator('.overview-header p')).toHaveText('Unified operational health, workflows, and execution telemetry.'); + await expect(page.locator('.status.status-success').first()).toBeVisible(); + await expect(page.locator('.mode-badge.mode-live')).toHaveText('live'); + await expect(page.locator('.report-footer')).toContainText('GitHub Primer Design System'); +}); + diff --git a/pages/dashboard/test/unit/presenter.test.js b/pages/dashboard/test/unit/presenter.test.js index 1a275ba3..8994f23e 100644 --- a/pages/dashboard/test/unit/presenter.test.js +++ b/pages/dashboard/test/unit/presenter.test.js @@ -292,6 +292,77 @@ describe('presenter built-in pages', () => { expect(rendered.querySelector('tbody tr')?.textContent).toContain('Run 1001'); expect(rendered.querySelector('tbody tr')?.textContent).toContain('2'); expect(rendered.querySelectorAll('tbody tr')[1]?.textContent).toContain('Unavailable'); - expect(rendered.querySelector('a')?.getAttribute('href')).toBe('https://example.com/runs/1001'); + expect(rendered.querySelector('.runs-table a')?.getAttribute('href')).toBe('https://example.com/runs/1001'); + }); + + it('DLS-PRES-001 renders GitHub Primer brand-aligned app shell, sidebar navigation, octicons, and metric badges', () => { + /** @type {import('../../src/presenter.js').PresentationInput['document']} */ + const document = { + languageVersion: '0.1.0', + dashboard: { + id: 'primer-dashboard', + title: 'Primer Dashboard', + pages: [ + { + id: 'workflows', + kind: /** @type {'built-in'} */ ('built-in'), + page: 'workflows', + title: 'Workflows', + definition: { + views: [ + { id: 'workflows-source', data: { source: 'workflows' } } + ] + } + }, + { + id: 'runs', + kind: /** @type {'built-in'} */ ('built-in'), + page: 'runs', + title: 'Runs' + } + ] + } + }; + + const rendered = renderDashboard({ + document, + sources: { + workflows: { + source: 'workflows', + rows: [ + { + organization: 'githubnext', + repository: 'central-agentic-ops', + workflow: 'dashboard.yml', + 'workflow-active': 'true', + 'rollout-mode': 'live' + } + ], + metadata: { + 'source-id': 'workflows-fixture', + 'source-kind': 'fixture', + 'as-of': '2026-08-29T13:00:00Z', + 'retrieved-at': '2026-08-29T13:01:00Z', + completeness: 'complete', + freshness: 'fresh', + availability: 'available' + } + } + } + }); + + expect(rendered.querySelector('style')?.textContent).toContain('--canvas'); + expect(rendered.querySelector('.skip-link')?.getAttribute('href')).toBe('#main-content'); + expect(rendered.querySelector('.app-shell')).not.toBeNull(); + expect(rendered.querySelector('.org-sidebar')).not.toBeNull(); + expect(rendered.querySelector('.sidebar-brand-mark')).not.toBeNull(); + expect(rendered.querySelectorAll('.primary-nav .nav-item')).toHaveLength(2); + expect(rendered.querySelector('.primary-nav .nav-item.active')?.getAttribute('data-nav-page-id')).toBe('workflows'); + expect(rendered.querySelector('.octicon-workflow')).not.toBeNull(); + expect(rendered.querySelector('.octicon-play')).not.toBeNull(); + expect(rendered.querySelector('.breadcrumb')?.textContent).toContain('Primer Dashboard'); + expect(rendered.querySelector('.status-success')?.textContent).toBe('available'); + expect(rendered.querySelector('.mode-live')?.textContent).toBe('live'); + expect(rendered.querySelector('.report-footer')?.textContent).toContain('GitHub Primer'); }); }); From 99398508acabbd1e7c2fe43ffbea7ee4faa04508 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:17:20 +0000 Subject: [PATCH 2/2] Refine presenter layout wrapper and overview lede styling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pages/dashboard/src/presenter.js | 54 ++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/pages/dashboard/src/presenter.js b/pages/dashboard/src/presenter.js index 3b81f6f7..31e20cc6 100644 --- a/pages/dashboard/src/presenter.js +++ b/pages/dashboard/src/presenter.js @@ -55,7 +55,7 @@ export function renderDashboard(input) { return h( 'div', - { className: 'dashboard-prototype' }, + { className: 'dashboard-root' }, styleEl, skipLink, h( @@ -174,37 +174,45 @@ function getPageIcon(page) { */ function renderMainContent(document, title, description, pages, sources, orgName) { return h( - 'main', - { className: 'app-main', id: 'main-content', tabIndex: -1 }, + 'div', + { className: 'app-main' }, h( 'nav', - { className: 'top-nav', 'aria-label': 'Breadcrumb' }, + { className: 'top-nav breadcrumb', 'aria-label': 'Breadcrumb' }, h( - 'ol', - { className: 'breadcrumb' }, - h('li', null, h('a', { href: '#/' }, orgName)), - h('li', null, h('a', { href: '#/dashboard' }, title)) + 'div', + { className: 'shell' }, + h('a', { href: '#/' }, orgName), + h('a', { href: '#/dashboard' }, title) ) ), h( - 'header', - { className: 'overview-header' }, - h('h1', null, title), - description ? h('p', null, description) : null - ), - h( - 'div', - { className: 'report-body' }, + 'main', + { id: 'main-content', className: 'dashboard-prototype', tabIndex: -1 }, + h( + 'header', + { className: 'overview-header', 'aria-labelledby': 'page-title' }, + h( + 'div', + null, + h('div', { className: 'title-area' }, h('h1', { id: 'page-title' }, title)), + description ? h('p', { className: 'lede' }, description) : null + ) + ), h( 'div', - { className: 'dashboard-pages' }, - pages.map((page) => renderPage(page, sources)) + { className: 'report-body' }, + h( + 'div', + { className: 'dashboard-pages' }, + pages.map((page) => renderPage(page, sources)) + ) + ), + h( + 'footer', + { className: 'report-footer' }, + h('p', null, 'Generated by Central Agentic Ops • GitHub Primer Design System') ) - ), - h( - 'footer', - { className: 'report-footer' }, - h('p', null, 'Generated by Central Agentic Ops • GitHub Primer Design System') ) ); }