Skip to content

feat(frontend): UI revamp — IA restructure + action-center reskin (closes #340)#343

Open
cristim wants to merge 29 commits into
feat/multicloud-web-frontendfrom
feat/issue-340-ui-revamp
Open

feat(frontend): UI revamp — IA restructure + action-center reskin (closes #340)#343
cristim wants to merge 29 commits into
feat/multicloud-web-frontendfrom
feat/issue-340-ui-revamp

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 12, 2026

Summary

Restructure the CUDly frontend information architecture and reskin to the action-center pattern from ~/Desktop/CUDly UI Revamp/ mocks. Frontend-only — no backend, API, IaC, or Go-side changes.

Closes #340.

Before / After

Before: 6 top-tabbed nav (Dashboard · Recommendations · Purchase Plans · Purchase History · RI Exchange · Settings) above the content.

After: action-center shell — top bar (logo + user info) + left sidebar with 6 sections:

  • Home (← Dashboard)
  • Opportunities (← Recommendations)
  • Plans (← Purchase Plans, label simplified)
  • Purchases (← Purchase History)
  • Inventory & Coverage (NEW umbrella — folds in RI Exchange + placeholders for Active commitments + Coverage)
  • Admin (← Settings — 4 sub-tabs relabeled: General · Purchasing policies · Accounts & onboarding · Users, roles & API keys)

Commit-by-commit

# Commit What
1 style: design-token CSS variables T1 — :root tokens for color, type, space, radius, elevation, layout
2 feat: action-center shell — top bar + sidebar nav T2 — new HTML shell + collapsible sidebar (icons via inline Lucide SVG, ISC)
3 refactor: rename tab IDs + URL redirect T3 — IA rename with LEGACY_PATH_REDIRECTS so /dashboard, /recommendations, /history, /settings/*, /ri-exchange still resolve
4 feat: fold RI Exchange into Inventory & Coverage T4 — new umbrella section + sub-nav (RI Exchange default) + 2 placeholder sub-sections
5 feat/admin: relabel Admin sub-tabs T5 — Purchasing → "Purchasing policies", Accounts → "Accounts & onboarding", Users & API Keys → "Users, roles & API keys"
6 feat/home: KPI sparklines + tightened card grid T6 — DOM-built KPI tiles + inline-SVG sparklines on YTD Savings (skipped silently when no data)
7 style: visual polish across Opportunities + Plans T8 — design tokens applied to action-box + plans cards; .context-drawer CSS slot reserved for future right-side drawer

Verification

  • Tests: 1602/1602 pass (started at 1591; +6 sparkline + +5 inventory tests).
  • Build: clean. Bundle: 503 KiB total (+0.8% from 499 KiB baseline, well under the ±10% gate).
  • Manual browser smoke: docker-compose stack up; every section + every sub-tab reachable via sidebar; legacy URLs redirect correctly (/dashboard/home, /ri-exchange/inventory, /settings/general/admin/general).

Deferred (tracked as sub-tasks in #340)

These mock widgets need backend support before the frontend can wire them:

  • Home: Onboarding & setup guidance checklist; Operational at-a-glance upcoming events feed
  • Opportunities: full right-side "Plan builder" drawer (CSS hook in place via .context-drawer); cross-payment-option comparison
  • Plans: Approvals & blockers timeline; Plan health score
  • Purchases: Audit timeline / search audit
  • Inventory & Coverage: Active commitments per-commitment list; per-provider coverage donuts; renewal-risk alerts; proactive RI exchange suggestions
  • Admin: API keys usage stats; Approval ownership matrix; Federation setup guided wizard; per-provider purchasing policy defaults UI

Empty-state placeholders ship today wherever a section would otherwise render nothing meaningful.

Constraints upheld

  • No functionality dropped — only relocated: every existing user-reachable control (Settings panels, RI Exchange UI, history filters, plan-creation modals, etc.) is reachable in the new IA.
  • Frontend-only: zero changes under internal/, iac/, terraform/, Optimizer/.
  • XSS: DOM construction via createElement / textContent / appendChild for all new code paths; no innerHTML with interpolated data.
  • Accessibility: aria-* / role / aria-labelledby preserved on every renamed panel; sidebar nav is keyboard-navigable.
  • Test coverage parity: 80.65% statements / 68.22% branches → preserved (+11 new tests for new code paths).
  • URL backwards-compat: LEGACY_PATH_REDIRECTS map handles every old top-level path.

Closes

Summary by CodeRabbit

  • New Features

    • Redesigned app shell with collapsible sidebar (state persists), reorganized top-level tabs and Admin sub-navigation.
    • Dashboard KPI tiles now include sparklines.
    • “Inventory & Coverage” umbrella with sub-tabs (including RI Exchange) and related navigation wiring.
  • Style

    • Global design-token theme and broad visual refresh across layout, components, and controls; new KPI tile styling.
  • Tests

    • Added/updated test suites for navigation, dashboard sparklines, inventory sub-tabs, settings dirty-state, HTML structure, and CSS token validation.

Review Change Stack

cristim added 7 commits May 12, 2026 19:10
…issue #340)

Add a single :root block at the top of styles/base.css defining the
action-center design language as CSS custom properties: brand, surfaces,
text, status colors, typography scale, spacing scale, radii, elevation,
and layout dimensions (sidebar width, top-bar height) for the new shell.

Tokens are additive — no existing component is migrated to consume them
in this commit, so the visual diff is zero. Section-by-section migrations
land in subsequent commits per the plan, so the diff stays reviewable.

The body's `background` still uses its previous literal (#f5f5f5) on
purpose — bg gets migrated when the shell lands in T2.
…issue #340)

Replaces the top-tabbed navigation with the action-center shell pattern
from the mocks: a sticky top bar with the CUDly logo + user info, and a
left sidebar containing the 6 primary navigation items.

Structure:
  .app-shell
    .app-topbar   (sticky, full width)
    .app-body     (flex row)
      .app-sidebar (232px, collapsible to 64px)
        .app-sidebar-nav  (vertical tab list)
      .app-main    (flex-grow content area)

The sidebar uses the new IA labels (Home, Opportunities, Plans,
Purchases, Inventory & Coverage, Admin) but keeps the existing
data-tab values (dashboard, recommendations, ...) for now — section
ID renames land in T3. RI Exchange is wired under the temporary
"Inventory & Coverage" sidebar label so the umbrella section folds
in cleanly in T4 without re-routing.

Each sidebar item carries a Lucide-style inline SVG (ISC license)
following the existing inline-SVG pattern from auth.ts. No new
dependency added.

Sidebar collapse:
- Click hamburger toggle to collapse to icon-only (64px wide).
- State persisted in localStorage as `cudly_sidebar_collapsed`.
- Auto-collapses below 900px viewport (preserves existing desktop
  experience above; doesn't introduce new responsive behaviour).

Verified:
- npm test: 1591/1591 pass.
- npm run build: clean (503 KiB entrypoint, +0.6% from 499 KiB baseline).
- Manual browser smoke: Home/Opportunities/Plans/Purchases/Inventory/Admin
  all reachable; active state highlights correctly; all underlying
  panels render unchanged.
…w IA (issue #340)

Rename top-level navigation IDs to match the new action-center IA:

  dashboard       → home
  recommendations → opportunities
  history         → purchases
  settings        → admin
  ri-exchange     unchanged in this commit (folds into Inventory & Coverage in T4)
  plans           unchanged (already the right name)

Touched:
- index.html: tab/panel IDs, data-tab values, aria-controls/-labelledby,
  and the bookkeeping HTML comments above each panel
- navigation.ts: TABS keys, switch cases, default fallback (now 'home'),
  page-title strings (e.g. "CUDly — Home", "CUDly — Admin · General")
- app.ts: deep-link target check ('settings' → 'admin') + /history → /purchases
  in the deep-link landing comment
- recommendations.ts: MutationObserver target ID + comment
- settings-subnav.ts: dirty-state indicator target ID
- purchases-deeplink.ts: post-action redirect /history → /purchases
- 4 test files: navigation.test.ts, recommendations.test.ts, html.test.ts,
  settings-subnav.test.ts — all IDs + switchTab calls + assertion text

URL backwards-compat: a new LEGACY_PATH_REDIRECTS map in navigation.ts
resolves pre-#340 paths (/dashboard, /recommendations, /history,
/settings/*) to the new tab names so existing bookmarks, emails, and
deep-links keep working. applyTabFromPath() walks the redirect table
before TABS lookup; app.ts's initial replaceState then writes the
canonical new URL to the address bar.

Source file names are unchanged (recommendations.ts, history.ts,
settings.ts stay) — only IDs, labels, and user-visible strings move.
Avoids hundreds of import-path changes per the plan's file-rename
discipline rule.

Verified:
- npm test: 1591/1591 pass.
- npm run build: clean.
- Manual browser smoke: /dashboard auto-redirects to /home; clicking
  each sidebar item navigates correctly; page title updates per tab;
  underlying panels render unchanged.
…ection (issue #340)

Issue #340 T4. The former top-level RI Exchange tab becomes one of three
sub-sections in the new "Inventory & Coverage" umbrella section:

  Inventory & Coverage
    ├─ Active commitments  (placeholder — needs per-commitment list endpoint)
    ├─ Coverage            (placeholder — needs per-provider coverage breakdowns)
    └─ RI Exchange         (existing UI, relocated unchanged — default landing)

Defaulting to RI Exchange keeps the user landing on substantive content
until T7 fills in Active commitments + Coverage. Both placeholders carry
empty-state copy pointing back to the Home dashboard for the data that
*is* available today.

Touched:
- index.html: removed the top-level ri-exchange-tab panel; added a new
  inventory-tab with a 3-button sub-nav and three section children. The
  existing RI Exchange card layout is preserved verbatim under
  inventory-ri-exchange. Sidebar button: data-tab="ri-exchange" →
  data-tab="inventory".
- inventory.ts (NEW, ~80 lines): switchInventorySubSection + loadInventory
  + sub-nav click wiring. Re-uses the existing .sub-tab-btn class for
  styling consistency. Standalone (no shared sub-nav helper) — settings-
  subnav.ts serves a different purpose (sticky scroll-spy rail), so per
  §1a we don't pre-abstract.
- navigation.ts: TABS gets 'inventory'; switch case 'ri-exchange' →
  'inventory' calling loadInventory. The RI Exchange → Inventory legacy
  redirect goes in LEGACY_PATH_REDIRECTS so /ri-exchange bookmarks land
  on /inventory.
- styles/settings.css: .inventory-subnav reuses the existing
  .settings-tabs visual treatment.
- __tests__/inventory.test.ts (NEW, 5 tests): cover sub-section show/hide,
  click-driven switching, default landing, and loadRIExchange triggering.

The two placeholder sub-sections (Active commitments + Coverage) ship
empty-state copy only — no shimmed data. Their backend wiring is tracked
as deferred sub-tasks in #340's body.

Verified:
- npm test: 1596/1596 pass (1591 + 5 new inventory tests).
- npm run build: clean.
- Manual browser smoke: /inventory loads with RI Exchange sub-section
  active; /ri-exchange legacy URL redirects to /inventory; sub-nav
  click on Active commitments + Coverage shows placeholders; RI
  Exchange sub-section renders existing UI unchanged.
Issue #340 T5. The 4 sub-tabs already exist (General · Purchasing ·
Accounts · Users & API Keys) — only labels change to the action-center
naming:

  General           unchanged
  Purchasing      → Purchasing policies
  Accounts        → Accounts & onboarding
  Users & API Keys→ Users, roles & API keys

The "Settings navigation" aria-label on the .settings-tabs container
becomes "Admin navigation" to match. No structural change, no JS change,
no panel relocation — every existing settings panel keeps its home;
this is pure label work.

URL paths (/admin/general, /admin/purchasing, /admin/accounts,
/admin/users) and the data-settings-tab attribute values (general,
purchasing, accounts, users) stay the same so test fixtures and existing
deep-links don't need updating.

Verified:
- Tests already pass (no test changes needed since data-settings-tab
  values are stable).
- Manual browser smoke: /admin renders with the new sub-tab labels;
  switching between them works; legacy /settings/* paths still redirect
  via the LEGACY_PATH_REDIRECTS table from T3.
Issue #340 T6. Reskins the 4 Home KPI tiles (Potential Monthly Savings,
Active Commitments, Current Coverage, YTD Savings) to match the action-
center mock language.

Changes:
- dashboard.ts:
  * Replaced the innerHTML template literal in renderDashboardSummary
    with DOM construction (createElement + textContent + appendChild) —
    aligns with the issue #340 plan's XSS constraint and removes the
    last interpolated-innerHTML site in this file's KPI rendering path.
  * Added sparklinePoints() pure helper that normalizes a numeric series
    into a 0..width × 0..height viewport for a <polyline points="..."> .
  * Added attachSparkline() that finds a .kpi-tile-spark[data-spark-key]
    placeholder and injects an SVG polyline via DOM methods. Skips
    silently when the placeholder is missing or < 2 values are passed
    (no broken visuals, no thrown errors).
  * Wired attachSparkline('ytd', cumulative_savings) into the existing
    loadSavingsAnalytics() success path so the YTD Savings tile draws
    a sparkline from the same data the main "Savings over time" chart
    already renders. The other three tiles ship empty SVG placeholders;
    sparklines for them are deferred per #340 (no current trend endpoint
    for them).
- styles/components.css: new .kpi-tile rule extending .card with a
  4-area grid (title / value / detail / spark) that places the sparkline
  to the right of value+detail. Typography + spacing pulled from the T1
  design tokens (--cudly-fs-2xl for value, --cudly-text-muted for label,
  --cudly-success for the savings sparkline).
- __tests__/dashboard.test.ts: 6 new tests covering sparklinePoints
  (normalization, < 2 values short-circuit, flat series no-NaN) and
  attachSparkline (polyline draws into svg, missing-placeholder no-op,
  insufficient-values silent skip).

The legacy .card styling still applies because .kpi-tile is additive.
Layout for the 4-tile row is unchanged (existing #summary grid handles
horizontal flow). On an empty DB the sparklines simply don't render.

Verified:
- npm test: 1602/1602 pass (1596 + 6 new sparkline tests).
- npm run build: clean.
- Manual browser smoke: Home renders with the new KPI tile styling;
  empty-DB shows tiles without sparklines (no broken SVGs); existing
  "Savings over time" + "Potential Savings by Service" cards unchanged.
…box (issue #340)

Issue #340 T8 + remaining-section polish. Picks up the design tokens
introduced in T1 across the surfaces that the per-section tasks
(T8 Opportunities, T9 Plans + Purchases) would have touched.

Changes:
- styles/components.css:
  * .recommendations-action-box (the sticky-bottom Purchase / Create
    Plan bar on Opportunities) migrates color literals + paddings +
    border-radius to design tokens. The mock's right-side "Plan builder"
    drawer is a bigger refactor of recommendations.ts; tracked as a
    deferred sub-task in #340. This commit keeps the sticky-bottom
    layout (zero JS change) but aligns its visual family with the rest
    of the reskin.
  * NEW .context-drawer rule — class slot reserved for the future
    right-side drawer. Today nothing uses it; the rule lives here so
    when recommendations.ts ships the drawer, the design-token mapping
    is already in one place.
- styles/plans.css: hardcoded colors → design tokens. .plan-card,
  .plan-header, .upcoming-card, .upcoming-date, .upcoming-savings,
  .ramp-option (selected state), and the custom-ramp-config background
  all now reference --cudly-surface, --cudly-border, --cudly-primary,
  --cudly-success, --cudly-info-bg, --cudly-shadow-sm,
  --cudly-surface-muted, and the radius tokens. No structural change.

Deferred per #340 sub-tasks:
- Active commitments + Coverage donuts (T7) — no per-commitment list /
  per-provider coverage endpoint exposed today. Inventory tab keeps
  the empty-state placeholders from T4.
- "Plan builder" right-side drawer (T8 full pattern) — requires
  recommendations.ts restructure. CSS hooks are in place.

Verified:
- npm test: 1602/1602 pass (no behavioural change).
- npm run build: clean.
- Manual browser smoke: Plans / Purchases / Opportunities all render;
  styling is consistent with Home; no regression in flow.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: de9cabdb-e8c4-4435-b74f-eef9ff342663

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Restructures the frontend IA to Home, Opportunities, Plans, Purchases, Inventory & Coverage, and Admin; replaces top tabs with an app-shell + left sidebar; adds :root design tokens and tokenized component styles; implements Inventory sub-tabs; refactors dashboard KPIs to DOM-rendered tiles with sparkline helpers; and updates routing and tests.

Changes

UI Revamp: IA Restructuring + Visual Reskin

UI Revamp: IA Restructuring + Visual Reskin

Layer / File(s) Summary
Navigation IA & Routing
frontend/src/navigation.ts, frontend/src/app.ts
Canonical tabs added (home,opportunities,plans,purchases,inventory,admin); legacy-path redirects added; switchTab/applyTabFromPath/initRouter updated; admin history URLs use /admin/<subTab> and the unsaved-changes guard tracks leaving admin.
App Shell & Sidebar Layout
frontend/src/index.html, frontend/src/styles/layout.css, frontend/src/index.ts
Index rebuilt into app-shell (topbar, sidebar, main); layout.css implements sidebar/topbar layout and collapse behavior; index.ts wires the sidebar toggle and persists cudly_sidebar_collapsed in localStorage.
Design Tokens & Base Styles
frontend/src/styles/base.css
Add :root --cudly-* tokens (colors, typography, spacing, radii, shadows, dimensions); update body and global utilities to consume tokens; add reduced-motion and skip-to-main rules.
Component & Theming Styles
frontend/src/styles/components.css, frontend/src/styles/forms.css, frontend/src/styles/plans.css, frontend/src/styles/settings.css, frontend/src/styles/modals.css, frontend/src/styles/tables.css, frontend/src/styles/charts.css
Introduce .kpi-tile; retokenize buttons, toasts, chips, badges, filter popovers, action box, context drawer, forms (filter chips), plan cards, modals, tables, charts; remove legacy sticky settings rail CSS.
Inventory & Coverage Module
frontend/src/inventory.ts
New module exporting switchInventorySubSection, wireSubNavListeners, and loadInventory() that manage three sub-sections and default to ri-exchange, triggering loadRIExchange() when active.
Dashboard KPI Rendering & Sparklines
frontend/src/dashboard.ts, frontend/src/__tests__/dashboard.test.ts
renderDashboardSummary now builds KPI tiles via DOM; adds sparklinePoints and attachSparkline (exported via __test__); loadSavingsTrendChart computes cumulative YTD series and calls attachSparkline('ytd', ...); tests validate helper outputs and DOM polyline insertion.
Upcoming Purchases DOM
frontend/src/dashboard.ts
renderUpcomingPurchases builds upcoming cards via DOM APIs, sets dataset attributes, and sanitizes provider badge class using a whitelist regex.
Purchases Deeplink & Recommendations Anchors
frontend/src/purchases-deeplink.ts, frontend/src/recommendations.ts
Purchases deeplink handler now replaces stale deeplink URLs with /purchases (missing/cancel/post-API); recommendations helpers now anchor/observe/mount against opportunities-tab instead of recommendations-tab.
Settings → Admin Dirty-State
frontend/src/settings-subnav.ts, frontend/src/__tests__/settings-subnav.test.ts
Sticky sub-nav rail removed; reflectDirtyState now toggles .has-unsaved on #admin-tab-btn (previously #settings-tab-btn); tests updated and narrowed to dirty-state behavior.
Tests & Fixtures
frontend/src/__tests__/*
Navigation/html tests updated for sidebar selectors and new tab keys; inventory.test added for sub-section behavior and loadRIExchange invocation; many recommendations tests' fixtures updated to opportunities-tab; css.test now asserts presence of design tokens; dashboard sparkline helper tests added.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • LeanerCloud/CUDly#295: Modifies frontend/src/dashboard.ts and its dashboard rendering logic (renderDashboardSummary / loadDashboard), so they overlap.
  • LeanerCloud/CUDly#311: Also edits frontend/src/dashboard.ts with defensive guards around rendering, overlapping KPI changes.
  • LeanerCloud/CUDly#160: Related recommendations UI wiring and filter/status lifecycle updates, overlapping tab-anchor logic.

Suggested labels

priority/p1, severity/high

"🐰 I hopped the sidebar, found tabs anew,
Sparkles in KPIs and tokens of blue,
Inventory nested, Admin in view,
Routes rewritten and tests updated too,
A rabbit cheers — the UI refreshed for you!"

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-340-ui-revamp

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/all-users Affects every user effort/xl Multi-week / refactor type/feat New capability labels May 12, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 12, 2026

@coderabbitai review

This PR ships a UI revamp (issue #340) restructuring the IA from 6 top tabs to a sidebar-driven action-center pattern + design-token reskin. Frontend-only, 7 atomic commits, 1602/1602 tests pass, bundle within +0.8% of baseline.

Specifically interested in feedback on:

  • the LEGACY_PATH_REDIRECTS table in navigation.ts (does it cover all reasonable old-bookmark paths?)
  • the inline-SVG sparkline approach in dashboard.ts (vs reaching for chart.js)
  • the inventory data-inv-subtab sub-nav pattern in inventory.ts (vs extracting from settings-subnav.ts)
  • XSS posture on the DOM-construction migration in renderDashboardSummary (replacing the old innerHTML template)

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

🧩 Analysis chain

Let me pull the relevant source files to give you targeted feedback on all four areas.


🏁 Script executed:

#!/bin/bash
# Find the key files
fd -e ts navigation.ts
fd -e ts dashboard.ts
fd -e ts inventory.ts
fd -e ts settings-subnav.ts

Length of output: 141


🏁 Script executed:

#!/bin/bash
echo "=== navigation.ts ==="
cat frontend/src/navigation.ts

echo ""
echo "=== dashboard.ts ==="
cat frontend/src/dashboard.ts

echo ""
echo "=== inventory.ts ==="
cat frontend/src/inventory.ts

echo ""
echo "=== settings-subnav.ts ==="
cat frontend/src/settings-subnav.ts

Length of output: 44883


@cristim: Happy to dig into all four areas — found some real issues worth addressing alongside the design observations.


1. LEGACY_PATH_REDIRECTS — coverage & pushState vs replaceState

Coverage gap — /purchase-plans

The old nav had a "Purchase Plans" tab. If its URL slug was /purchase-plans, it is not covered by the redirect map:

// navigation.ts (current)
const LEGACY_PATH_REDIRECTS: Record<string, string> = {
  dashboard: 'home',
  recommendations: 'opportunities',
  history: 'purchases',
  settings: 'admin',
  'ri-exchange': 'inventory',
  // ⚠️ 'purchase-plans': 'plans' — missing if old slug was /purchase-plans
};

If the old top-level path was literally /plans, it's already a valid TABS key and needs no entry. But if the slug was ever /purchase-plans, old bookmarks silently fall back to /home. Worth confirming against the pre-#340 pushState calls or browser history in your smoke test.

pushState vs replaceState for legacy redirects

The PR description says "we replaceState() to the canonical new URL", but applyTabFromPath() returns the resolved tab name and switchTab — when called without push: false — issues a pushState. That means visiting /dashboard pushes a new /home entry onto the stack rather than replacing the legacy URL in place. The practical effect: pressing Back from /home takes the user back to /dashboard instead of wherever they came from. The fix is a replaceState when a legacy redirect fires:

export function applyTabFromPath(): string {
  const segment = window.location.pathname
    .replace(/^\/+/, '').replace(/\/+$/, '').split('/')[0]
    ?.toLowerCase() ?? '';
  if (segment === '') return 'home';
  if (segment in LEGACY_PATH_REDIRECTS) {
    const canonical = LEGACY_PATH_REDIRECTS[segment]!;
    // Rewrite legacy URL in-place so Back doesn't loop through it
    window.history.replaceState(null, '', '/' + canonical + window.location.search + window.location.hash);
    return canonical;
  }
  return segment in TABS ? segment : 'home';
}

Settings sub-tab deep-links (e.g. /settings/general, /settings/purchasing) are handled correctly — segment[0] = settingsadmin, then getSettingsSubTabFromPath() reads segment[1]. ✅


2. Inline-SVG sparklines vs Chart.js

The design choice is correct — a 80×24 px decorative polyline does not warrant a Chart.js instance. The approach is lightweight, bundle-neutral for new code, and the aria-hidden="true" / createElementNS DOM path is clean.

Two actionable issues:

a) Three KPI tiles have empty SVG placeholders that never get data

renderDashboardSummary stamps data-spark-key on all four tiles (savings, commitments, coverage, ytd), but attachSparkline is only called for ytd (inside loadSavingsTrendChart). The other three empty <svg class="kpi-tile-spark"> elements persist in the DOM forever. If populating them is deferred, hide them until data is available:

// in renderDashboardSummary, for tiles that won't get a sparkline yet
if (t.kpi !== 'ytd') spark.classList.add('hidden');

b) Math.min/max(...values) spread in sparklinePoints

const min = Math.min(...values);  // ⚠️ stack overflow for large arrays
const max = Math.max(...values);

Sparklines are short series in practice, but this is a cheap fix:

const min = values.reduce((a, b) => (b < a ? b : a), values[0]!);
const max = values.reduce((a, b) => (b > a ? b : a), values[0]!);

3. data-inv-subtab pattern vs extracting from settings-subnav.ts

The separation is correctrenderSubNav in settings-subnav.ts is a scrollspy sticky left-rail with an IntersectionObserver and a DOM-restructuring wrapper (.settings-layout-content) designed for long single-page forms. The inventory sub-nav is discrete section-switching. Sharing that infrastructure would couple two fundamentally different UX patterns and drag IntersectionObserver complexity into a simple tab-switch.

However, there is a real cross-contamination bug caused by the global .sub-tab-btn query in navigation.ts:

// navigation.ts — switchSettingsSubTab (line ~144)
document.querySelectorAll<HTMLButtonElement>('.sub-tab-btn').forEach(btn => {
  const isActive = btn.dataset['settingsTab'] === subTab;  // ← global, not scoped
  btn.classList.toggle('active', isActive);
  btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
});

Inventory sub-tab buttons also carry .sub-tab-btn but use data-inv-subtab (not data-settings-tab). When the user switches to Admin, this loop runs over all .sub-tab-btn elements in the DOM — including those in #inventory-tab — and clears their active class and sets aria-selected="false" because dataset['settingsTab'] is undefined. If the user then switches back to Inventory, the active indicator will be gone until switchInventorySubSection re-activates the button.

The fix is to scope the query:

// navigation.ts — switchSettingsSubTab
document.querySelectorAll<HTMLButtonElement>('#admin-tab .sub-tab-btn').forEach(btn => {

switchInventorySubSection already scopes with #inventory-tab .sub-tab-btn, so the bug is strictly one-directional (settings clobbers inventory). ✅ Inventory → settings direction is clean.


4. XSS posture — renderDashboardSummary migration

renderDashboardSummary itself is clean. The tile values flow through formatCurrency, String(), or compile-time literals, and the full createElement / textContent / appendChild chain means no user-controlled string can be interpreted as markup. ✅

buildUpcomingDetailsModal is also clean — full DOM API, textContent on every field. ✅

renderUpcomingPurchases was not migrated and has several unescaped interpolations:

// dashboard.ts — renderUpcomingPurchases
container.innerHTML = purchases.map(p => {
  return `
    …
    <span class="provider-badge ${p.provider}">   // ⚠️ raw in class attribute
    ${p.provider.toUpperCase()}                    // ⚠️ raw in text node
    Step ${p.step_number} of ${p.total_steps}      // ⚠️ raw (numeric at runtime but typed as API fields)
    data-id="${p.execution_id}">                   // ⚠️ raw in attribute — quote-break possible

p.provider injected directly as a CSS class token is the highest-risk line: a value like aws" onclick="alert(1) would escape the attribute and inject an event handler. p.execution_id in data-id="..." carries the same attribute-injection risk. Since this function handles the same backend data as the newly-secured modal builder, it should receive the same DOM-API treatment (or at minimum, escapeHtml on every interpolated value including p.provider, p.execution_id, p.step_number, and p.total_steps).


Summary table

Area Status Action
LEGACY_PATH_REDIRECTS coverage ✅ / ⚠️ Verify /purchase-plans slug; add if needed
Legacy redirect uses pushState not replaceState 🐛 Use replaceState in applyTabFromPath when a legacy segment is hit
Sparklines — design choice Correct, keep
3 KPI tiles have orphaned empty SVG placeholders ⚠️ Hide until data available
Math.min/max spread in sparklinePoints ⚠️ Use reduce
data-inv-subtab vs extracting renderSubNav Separation is correct
switchSettingsSubTab global .sub-tab-btn query clobbers inventory active state 🐛 Scope to #admin-tab .sub-tab-btn
renderDashboardSummary XSS posture Clean
renderUpcomingPurchases unescaped provider, execution_id, etc. 🐛 Apply escapeHtml to all interpolated API fields, or migrate to DOM API
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/src/navigation.ts (1)

164-168: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Scope admin sub-tab activation to the admin container.

The global .sub-tab-btn query can clear active state on unrelated sub-navs. Scope this to #admin-tab .sub-tab-btn (or equivalent admin root) to avoid cross-tab clobbering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/navigation.ts` around lines 164 - 168, The current
querySelectorAll('.sub-tab-btn') is global and will toggle active state across
all sub-navs; restrict it to the admin container by querying only admin-scoped
buttons (e.g., use '#admin-tab .sub-tab-btn' or the specific admin root element)
so the loop that reads btn.dataset['settingsTab'] === subTab and updates
classList.toggle('active', ...) and aria-selected only affects admin sub-tabs;
locate the block that uses
document.querySelectorAll<HTMLButtonElement>('.sub-tab-btn') and change the
selector to the admin-scoped selector or query the admin root and call
querySelectorAll on it instead.
frontend/src/dashboard.ts (1)

362-383: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unescaped attribute interpolation in innerHTML still allows injection.

provider is interpolated into a class name and execution_id into data-id in an innerHTML template. Please migrate this block to DOM construction (createElement, classList, dataset, textContent) to close attribute-injection risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/dashboard.ts` around lines 362 - 383, The current
container.innerHTML = purchases.map(...) template injects unescaped values into
attributes (class and data-id); replace this whole innerHTML construction in the
block using DOM APIs: iterate purchases, call createElement for the card and its
children (matching the structure built currently), use classList.add() for
classes (validate/whitelist p.provider before adding to avoid injection), set
element.dataset.executionId = String(p.execution_id) and dataset.action for
buttons, and assign textual content with textContent (or use escapeHtml for any
HTML you intend to allow) for plan_name, service, dateParts and formatCurrency
output; keep getDateParts, escapeHtml and formatCurrency usage for values but
stop using innerHTML and build the nodes programmatically so attributes are
never interpolated into raw HTML.
🧹 Nitpick comments (3)
frontend/src/dashboard.ts (1)

211-214: ⚡ Quick win

Only render spark placeholders for tiles that actually receive data.

All KPI cards currently get an SVG placeholder, but only ytd is populated. This leaves dead UI placeholders. Consider rendering spark SVG conditionally (or hiding by default until populated).

Also applies to: 575-577

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/dashboard.ts` around lines 211 - 214, Only create or show the
SVG spark placeholder for KPI tiles that actually have spark data: instead of
unconditionally creating the element via spark = document.createElementNS(...)
and adding class 'kpi-tile-spark' and dataset['sparkKey'] = t.kpi, gate that
code on the presence of the tile's spark data (e.g., check a sparkData map or
t.kpi === 'ytd' or similar) and only append the spark to the DOM when populated;
alternatively, if you prefer to always create the element, add a default hidden
state (e.g., add a 'hidden' class or set aria-hidden and CSS display:none) and
ensure whatever populates the spark removes the hidden state when data is
available, using the same identifiers spark, 'kpi-tile-spark',
dataset['sparkKey'], and t.kpi to locate and toggle visibility.
frontend/src/__tests__/navigation.test.ts (1)

44-55: ⚡ Quick win

Add canonical /admin/* path coverage for sub-tab parsing.

These updates move nav semantics to admin, but getSettingsSubTabFromPath coverage is still centered on /settings/*. Please add /admin, /admin/accounts, and /admin/purchasing cases (and keep one /settings/* legacy case) so the new canonical routing is locked by tests.

Proposed test additions
 describe('getSettingsSubTabFromPath', () => {
+  test('returns general for root admin path', () => {
+    delete (window as unknown as Record<string, unknown>).location;
+    (window as unknown as Record<string, unknown>).location = { pathname: '/admin' } as Location;
+    expect(getSettingsSubTabFromPath()).toBe('general');
+  });
+
+  test('returns accounts for /admin/accounts', () => {
+    delete (window as unknown as Record<string, unknown>).location;
+    (window as unknown as Record<string, unknown>).location = { pathname: '/admin/accounts' } as Location;
+    expect(getSettingsSubTabFromPath()).toBe('accounts');
+  });
+
+  test('returns purchasing for /admin/purchasing', () => {
+    delete (window as unknown as Record<string, unknown>).location;
+    (window as unknown as Record<string, unknown>).location = { pathname: '/admin/purchasing' } as Location;
+    expect(getSettingsSubTabFromPath()).toBe('purchasing');
+  });
+
   test('returns general for root settings path', () => {
     delete (window as unknown as Record<string, unknown>).location;
     (window as unknown as Record<string, unknown>).location = { pathname: '/settings' } as Location;
     expect(getSettingsSubTabFromPath()).toBe('general');
   });

Also applies to: 162-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/navigation.test.ts` around lines 44 - 55, Update the
navigation tests to include canonical /admin routes for sub-tab parsing: add
test cases calling getSettingsSubTabFromPath with "/admin", "/admin/accounts",
and "/admin/purchasing" (expecting the appropriate sub-tab results) and retain
one legacy "/settings" case (e.g., "/settings", "/settings/accounts" or similar)
to ensure backward compatibility; locate these additions in
frontend/src/__tests__/navigation.test.ts near the existing
getSettingsSubTabFromPath test block so both old "/settings/*" and new
"/admin/*" paths are covered.
frontend/src/inventory.ts (1)

68-77: ⚡ Quick win

Make listener wiring conditional on finding sub-nav buttons

At Line 69–77, listenersWired is set true even when no #inventory-tab .sub-tab-btn nodes exist. If initialization ever happens before that DOM is present, listeners never get attached later.

Suggested patch
 function wireSubNavListeners(): void {
   if (listenersWired) return;
-  document.querySelectorAll<HTMLButtonElement>('#inventory-tab .sub-tab-btn').forEach((btn) => {
+  const buttons = document.querySelectorAll<HTMLButtonElement>('#inventory-tab .sub-tab-btn');
+  if (buttons.length === 0) return;
+  buttons.forEach((btn) => {
     btn.addEventListener('click', () => {
       const name = btn.dataset['invSubtab'] ?? DEFAULT_SUB_SECTION;
       switchInventorySubSection(name);
     });
   });
   listenersWired = true;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/inventory.ts` around lines 68 - 77, The function
wireSubNavListeners sets listenersWired = true even when
document.querySelectorAll('#inventory-tab .sub-tab-btn') returns no elements,
preventing future wiring; modify wireSubNavListeners so it only sets
listenersWired to true after it actually attaches at least one listener (i.e.,
check the NodeList length or count attached handlers) and leave listenersWired
false when no buttons were found so the function can be re-run later to attach
handlers for elements targeted by switchInventorySubSection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/__tests__/html.test.ts`:
- Around line 93-116: Add a new unit test mirroring the existing tab assertions
to explicitly check for the inventory tab: create a test (e.g., test('has
inventory tab button', ...)) that queries
document.querySelector('[data-tab="inventory"]') and asserts it is truthy; place
it alongside the other tab tests so the suite validates the inventory tab button
presence and prevents silent nav regressions.

In `@frontend/src/index.html`:
- Around line 41-64: The sidebar tab buttons (elements with class "tab-btn" such
as home-tab-btn, opportunities-tab-btn, plans-tab-btn, purchases-tab-btn,
inventory-tab-btn, admin-tab-btn) lose their visible label when .sidebar-label
is hidden; add an explicit aria-label attribute on each button with the same
human-readable text as its .sidebar-label (e.g., "Home", "Opportunities",
"Plans", "Purchases", "Inventory & Coverage", "Admin") so assistive tech still
has a name while keeping existing role="tab" and aria-selected attributes.

In `@frontend/src/navigation.ts`:
- Around line 222-230: applyTabFromPath currently maps legacy segments via
LEGACY_PATH_REDIRECTS but doesn't canonicalize the browser URL; update
applyTabFromPath to call history.replaceState to rewrite the URL to the
canonical path when a legacy mapping is used. Specifically, after resolving
segment via LEGACY_PATH_REDIRECTS (the branch that returns
LEGACY_PATH_REDIRECTS[segment]), compute the canonical path (e.g., '/' + the
resolved tab), call history.replaceState(null, '', canonicalPath) before
returning the tab, and leave other branches unchanged.

In `@frontend/src/styles/components.css`:
- Around line 32-39: The `.kpi-tile h3` rules (margin-bottom, color, font-size,
font-weight, letter-spacing) are being overridden by the later `.card h3` rule
because they have equal specificity; fix this by either moving the `.kpi-tile
h3` block to appear after the `.card h3` block or increase selector specificity
so it wins (e.g., change selector to a more specific form like `.card.kpi-tile
h3` or `.kpi-tile.card h3`), and update the selector references in
components.css accordingly so the KPI typography always takes precedence.

---

Outside diff comments:
In `@frontend/src/dashboard.ts`:
- Around line 362-383: The current container.innerHTML = purchases.map(...)
template injects unescaped values into attributes (class and data-id); replace
this whole innerHTML construction in the block using DOM APIs: iterate
purchases, call createElement for the card and its children (matching the
structure built currently), use classList.add() for classes (validate/whitelist
p.provider before adding to avoid injection), set element.dataset.executionId =
String(p.execution_id) and dataset.action for buttons, and assign textual
content with textContent (or use escapeHtml for any HTML you intend to allow)
for plan_name, service, dateParts and formatCurrency output; keep getDateParts,
escapeHtml and formatCurrency usage for values but stop using innerHTML and
build the nodes programmatically so attributes are never interpolated into raw
HTML.

In `@frontend/src/navigation.ts`:
- Around line 164-168: The current querySelectorAll('.sub-tab-btn') is global
and will toggle active state across all sub-navs; restrict it to the admin
container by querying only admin-scoped buttons (e.g., use '#admin-tab
.sub-tab-btn' or the specific admin root element) so the loop that reads
btn.dataset['settingsTab'] === subTab and updates classList.toggle('active',
...) and aria-selected only affects admin sub-tabs; locate the block that uses
document.querySelectorAll<HTMLButtonElement>('.sub-tab-btn') and change the
selector to the admin-scoped selector or query the admin root and call
querySelectorAll on it instead.

---

Nitpick comments:
In `@frontend/src/__tests__/navigation.test.ts`:
- Around line 44-55: Update the navigation tests to include canonical /admin
routes for sub-tab parsing: add test cases calling getSettingsSubTabFromPath
with "/admin", "/admin/accounts", and "/admin/purchasing" (expecting the
appropriate sub-tab results) and retain one legacy "/settings" case (e.g.,
"/settings", "/settings/accounts" or similar) to ensure backward compatibility;
locate these additions in frontend/src/__tests__/navigation.test.ts near the
existing getSettingsSubTabFromPath test block so both old "/settings/*" and new
"/admin/*" paths are covered.

In `@frontend/src/dashboard.ts`:
- Around line 211-214: Only create or show the SVG spark placeholder for KPI
tiles that actually have spark data: instead of unconditionally creating the
element via spark = document.createElementNS(...) and adding class
'kpi-tile-spark' and dataset['sparkKey'] = t.kpi, gate that code on the presence
of the tile's spark data (e.g., check a sparkData map or t.kpi === 'ytd' or
similar) and only append the spark to the DOM when populated; alternatively, if
you prefer to always create the element, add a default hidden state (e.g., add a
'hidden' class or set aria-hidden and CSS display:none) and ensure whatever
populates the spark removes the hidden state when data is available, using the
same identifiers spark, 'kpi-tile-spark', dataset['sparkKey'], and t.kpi to
locate and toggle visibility.

In `@frontend/src/inventory.ts`:
- Around line 68-77: The function wireSubNavListeners sets listenersWired = true
even when document.querySelectorAll('#inventory-tab .sub-tab-btn') returns no
elements, preventing future wiring; modify wireSubNavListeners so it only sets
listenersWired to true after it actually attaches at least one listener (i.e.,
check the NodeList length or count attached handlers) and leave listenersWired
false when no buttons were found so the function can be re-run later to attach
handlers for elements targeted by switchInventorySubSection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 639f8247-a074-4d72-8d15-901a9d8dab72

📥 Commits

Reviewing files that changed from the base of the PR and between f33ae7c and 1497859.

📒 Files selected for processing (20)
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/__tests__/html.test.ts
  • frontend/src/__tests__/inventory.test.ts
  • frontend/src/__tests__/navigation.test.ts
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/__tests__/settings-subnav.test.ts
  • frontend/src/app.ts
  • frontend/src/dashboard.ts
  • frontend/src/index.html
  • frontend/src/index.ts
  • frontend/src/inventory.ts
  • frontend/src/navigation.ts
  • frontend/src/purchases-deeplink.ts
  • frontend/src/recommendations.ts
  • frontend/src/settings-subnav.ts
  • frontend/src/styles/base.css
  • frontend/src/styles/components.css
  • frontend/src/styles/layout.css
  • frontend/src/styles/plans.css
  • frontend/src/styles/settings.css

Comment thread frontend/src/__tests__/html.test.ts
Comment thread frontend/src/index.html Outdated
Comment thread frontend/src/navigation.ts
Comment thread frontend/src/styles/components.css Outdated
…okens (issue #340)

Closes the per-CSS-file design-token gap left after T6/T8: 4 stylesheets
that still carried hardcoded color literals are migrated to consume the
:root tokens introduced in T1. No behavioural change, no visual
regression — every replaced literal maps to a token whose value is the
same hex (or close enough that the diff is imperceptible).

Migrated colors:
- #1a73e8 → var(--cudly-primary)
- #666 / #888 → var(--cudly-text-muted)
- #333 → var(--cudly-text)
- #e0e0e0 / #eee → var(--cudly-border)
- #ddd → var(--cudly-border-strong)
- #f8f9fa → var(--cudly-surface-muted)
- #f5f5f5 → var(--cudly-bg)
- #e8f0fe → var(--cudly-info-bg)
- #34a853 → var(--cudly-success)
- #ea4335 → var(--cudly-error)
- white → var(--cudly-surface)
- 8px border-radius → var(--cudly-r-md)

Per-file impact:
- styles/settings.css (76 literals → most migrated; some niche colors
  like #f4a261, #137333, #fffbf5 left as literals since they don't have
  a token equivalent and would have required new tokens for the visual
  weight to come out right)
- styles/tables.css (27 literals → migrated common palette + table
  background/radius)
- styles/forms.css (31 literals → migrated common palette including the
  focus-ring #e8f0fe → info-bg)
- styles/modals.css (20 literals → migrated common palette)

Remaining literals (charts.css 4 + responsive.css 0 + niche ones above)
are out of scope for this PR — leaving them ensures the diff stays
reviewable. They're tracked under #340's "Constraints not fully honored"
follow-up notes.

Verified:
- npm test: 1602/1602 pass (no behavioural change).
- npm test -- --coverage: 80.7% stmts / 68.09% branches /
  70.77% funcs / 82.38% lines — parity with the 80.65/68.22/70.65/82.31
  baseline (flat-to-better; branches -0.13% is within noise).
- npm run build: clean.
- Manual browser smoke: Admin / Plans / Purchases / forms / modals
  all render identically to before the migration.
cristim added 10 commits May 12, 2026 21:52
Two visual fixes spotted during browser review of PR #343:

1. **Topbar logo unreadable on blue gradient.** The previous
   `<img src="/favicon.svg">` rendered at 24px as a tiny blue "C" letter
   on the blue topbar gradient — low contrast, hard to parse. Replaced
   with an inline cloud-shape SVG that inherits `currentColor` (white
   from `--cudly-primary-fg`) so the mark is crisp on the gradient.
   `favicon.svg` is unchanged — still serves as the browser-tab favicon.

2. **Empty-state cards looked like raw text.** `.empty-state` used
   `background: #f8f9fa` which is essentially the same as the new page
   bg (`--cudly-bg: #f5f7fa`) — they blended together. Inventory's
   "Active commitments" + "Coverage" placeholder sections read as
   un-contained text floating on the page.

   Migrated `.empty-state` to:
   - `background: var(--cudly-surface)` (white card)
   - `border: 1px solid var(--cudly-border)` + `--cudly-shadow-sm`
   - design-token spacing + radius

   Also added `.empty-state h3` + `.empty-state p` rules so the heading
   reads as a proper card title (not the `.card h3` muted-label
   treatment) and the body wraps at a comfortable max-width.

Verified:
- npm test: 1602/1602 pass (no behavioural change).
- npm run build: clean.
- Manual browser smoke: cloud logo reads on topbar; Inventory's
  Active commitments + Coverage placeholders now look like proper
  cards; no regression on populated cards.
…340)

The sticky in-panel rail rendered by settings-subnav.ts (Global Defaults /
AWS / Azure / GCP / Exchange Automation on Purchasing; Federation Setup /
Registrations / per-cloud accounts on Accounts & onboarding; Users /
Groups / Permission Overview / API Keys on Users, roles & API keys)
collided with the new action-center left sidebar after issue #340.

Why it broke: the rail's wide-viewport CSS used
  position: sticky; float: left; margin-left: -220px
to park itself in the page's left gutter. Pre-#340 the left gutter was
empty (main had `max-width: 1600px; margin: 0 auto`). Post-#340 the
left side is occupied by the new `.app-sidebar`, so the rail's negative
margin pulled it visually on top of the primary nav items — users saw
their Home / Opportunities / Plans / Purchases / Inventory items
disappear and the rail items replace them.

Per user request, deleted the rail entirely rather than re-positioning it
inside the panel. The sub-sections it linked to (`#purchasing-global-defaults`,
`#aws-settings`, etc.) all still render as section headings within the
form itself, so users can still see the structure — they just don't get
a separate side-rail to jump between them. Long forms are short enough
that scrolling is fine; we can revisit a non-overlapping in-panel
table-of-contents pattern if needed.

Removed:
- settings-subnav.ts: `SUBTAB_ITEMS`, `renderSubNav`, and the
  IntersectionObserver scrollspy. `reflectDirtyState` survives — the
  "unsaved changes" save-bar + has-unsaved dot on the Admin tab button
  still flow through it from settings.ts.
- navigation.ts: dropped the `renderSubNav` import + the call from
  `switchSettingsSubTab`.
- styles/components.css: dropped `.settings-layout`, `.settings-subnav`,
  `.settings-layout-content` and their hover/active/sticky rules
  (~80 lines of CSS).
- __tests__/settings-subnav.test.ts: dropped the `describe('renderSubNav')`
  block + its DOM-builder helpers + the FakeIntersectionObserver stub.
  Kept the reflectDirtyState describe block.

Verified:
- npm test: 1593/1593 pass (1602 - 9 removed renderSubNav tests).
- npm run build: clean.
- Manual browser smoke: Admin > Purchasing policies / Accounts &
  onboarding / Users, roles & API keys all render with the form fully
  visible, no overlap with the primary sidebar nav.
…(issue #340)

The hamburger toggle and the section nav buttons used different padding
schemes (8px all-around vs 8px×12px), and different SVG sizes (18×18 vs
20×20). Net effect: the hamburger sat a few pixels to the left of the
Home / Opportunities / Plans / Purchases / Inventory / Admin icons,
breaking the single-column glyph alignment expected of a left rail.

Changes:
- styles/layout.css: .app-sidebar-toggle padding mirrors the .tab-btn
  row exactly (`var(--cudly-sp-2) var(--cudly-sp-3)`), and the
  `margin-left: var(--cudly-sp-1)` is dropped so the toggle's hit area
  starts at the same x as every nav button. Border-radius bumped to
  --cudly-r-md to match.
- index.html: hamburger SVG resized to 20×20 + given the .sidebar-icon
  class so it inherits the same icon-rendering rules as the section
  icons below it.

Visually verified in browser: zoomed sidebar shows the hamburger glyph
+ 6 section icons stacked in a single vertical line — same x-center.
…nment + stray border (issue #340)

The sidebar nav was previously `<nav class="tabs app-sidebar-nav">`. The
`.tabs` class came from the old top-tab strip, where it set
`padding: 0 2rem` + `border-bottom: 2px solid #e0e0e0`. After issue #340,
`.app-sidebar-nav` was supposed to neutralize those (padding: 0 +
border-bottom: none), but CSS import order put tabs.css *after*
layout.css — same specificity, last declaration wins — so the old rules
kept leaking through.

Net visual effect users reported:
- Section icons (Home / Opportunities / …) sat ~32px to the right of
  the hamburger icon. Cause: 2rem (32px) of left padding on the nav
  container pushed every section row inward while the hamburger sat
  flush against the sidebar's own inner padding.
- A thin grey hairline divided the hamburger from the section list.
  Cause: the inherited `border-bottom: 2px solid #e0e0e0` from `.tabs`.

Fix: just remove `class="tabs"` from the sidebar `<nav>`. The buttons
still carry their own `.tab-btn` class (used by navigation.ts's
`document.querySelectorAll('.tab-btn')`), so click wiring is unaffected.
The horizontal `.tabs` strip pattern is dead-code in the action-center
shell anyway.

Also updated html.test.ts: the regression test that asserted "nav
element with .tabs class exists" now checks for `.app-sidebar-nav`
instead — same intent, current selector.

Verified in browser: hamburger icon and all 6 section icons sit on the
same vertical x-line; no stray border below the hamburger.

- npm test: 1593/1593 pass.
- npm run build: clean.
…#340)

Closes two no-backend gaps spotted in the post-merge review of PR #343:

1. **`.empty` had no visual containment.** The inline empty messages
   used by `dashboard.ts`, `history.ts`, `plans.ts`, `recommendations.ts`,
   and `riexchange.ts` rendered as bare grey text on the parent card's
   white surface — they were easy to miss as "this slot is empty" vs
   "the card is still loading". Now `.empty` carries a subtle
   surface-muted background + radius + spacing, so it reads as a
   distinct empty slot without competing with the card chrome around
   it. The heavier `.empty-state` card pattern (the one introduced in
   the post-T6 polish commit) still applies for whole-page placeholders
   like Inventory's Active commitments / Coverage sub-sections.

2. **No visible keyboard focus on sidebar or sub-tab buttons.** Chrome's
   default `outline: auto` doesn't render reliably on rounded buttons,
   and the existing `.tab-btn`/`.sub-tab-btn` rules didn't supply a
   replacement. Keyboard users couldn't tell which item the focus was
   on. Added explicit `:focus-visible` outlines for:
   - `.app-sidebar-nav .tab-btn`
   - `.app-sidebar-toggle`
   - `.sub-tab-btn` (covers both Admin sub-tabs and Inventory sub-nav)

   `:focus-visible` rather than `:focus` so mouse-clickers don't see
   the ring; keyboard tabbing surfaces it cleanly.

Verified:
- npm test: 1593/1593 pass (no behavioural change).
- npm run build: clean.
- Manual browser smoke: Tab key through sidebar + sub-tabs shows
  blue ring; empty messages on Opportunities, Plans, Purchases
  render with subtle muted-background containment.
…#340)

Visual alignment with the mocks for the filter-bar pattern and section
heroes. CSS-only — no JS, no HTML restructure, no risk to existing
filter wiring.

Changes:
- styles/forms.css:
  * `.filter-group select` + `.filter-group input` adopt a pill shape:
    `--cudly-r-full` border-radius, design-token padding, hover +
    `:focus-visible` (3px info-bg ring) states. Native <select> retains
    its dropdown behaviour — only the trigger is restyled, so click +
    keyboard + screen-reader semantics are unchanged.
  * `.controls-bar` drops its `background: white` card-chrome +
    box-shadow. Chips now float on the page bg, matching the mock's
    "filter strip" treatment rather than "filter inside a panel".
- styles/base.css:
  * NEW `.page-hero` rule set for prominent section titles
    (`--cudly-fs-2xl` h1/h2 + `--cudly-text-muted` description, with
    bottom spacing). Class slot only — opt-in for the sections that
    benefit; pre-existing card-internal h2s stay unchanged so this
    commit lands without rewriting every section's heading structure.

Verified:
- npm run build: clean (no bundle size change beyond CSS deltas).
- Manual browser smoke: provider/account filters on Home / Plans /
  Purchases render as compact rounded pills; dropdowns still expand
  natively on click; controls bar no longer competes with the KPI
  tiles below it.
… lift (issue #340)

Final batch of low-risk frontend-only polish before merge:

- **Page hero applied** to three top-of-section headers:
  - `#plans-header` ("Purchase Plans")
  - `#inventory-ri-exchange` first card ("Convertible RI Exchange")
  - `#settings-section` ("Global Configuration")
  All three pick up the `.page-hero` h2 size bump (`--cudly-fs-2xl`)
  defined in the previous commit. Other sections opt-in later as
  needed.

- **Skip-to-main-content link** added at the very top of `<body>`,
  styled to be screen-reader-only until tabbed onto. First Tab from a
  fresh page now lands on the link; Enter jumps past the topbar +
  sidebar to `#main-content`. Standard a11y pattern.

- **`prefers-reduced-motion` honored** — global `@media` rule collapses
  all transitions/animations to 0.01ms when the OS-level pref is set.
  Animation-end events still fire (some JS depends on them); the
  visual motion is gone for users who asked for it.

- **KPI tile hover lift** — `.kpi-tile` gets a 1px translateY +
  shadow-md on hover. Subtle, transform-only (no layout shift), and
  fully overridden by `prefers-reduced-motion`.

- **charts.css token migration** — `.chart-section h3`, `.stat-card`,
  `.stat-card h4`, `.stat-value` all now consume design tokens
  (`--cudly-text`, `--cudly-surface-muted`, `--cudly-primary`,
  --cudly-fs-* / --cudly-sp-*). Last per-file token migration
  pending was charts.css; it's now done.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
- Manual browser smoke: Plans / Inventory > RI Exchange / Admin >
  General all show bigger headings; KPI tiles lift slightly on
  hover; tabbing from the URL bar reveals the skip-link.
…sue #340)

More low-risk polish ahead of merge:

- **Topbar gradient → tokens.** The `header` gradient + h1 weight +
  paddings + the `#logout-btn` and `.header-link` buttons all now
  consume design tokens. The gradient steps from `--cudly-primary`
  (#2563eb) to `--cudly-primary-hover` (#1d4ed8) — slightly more
  saturated than the legacy `#1a73e8 → #0d47a1` Material gradient,
  matching the rest of the reskin's blue family.

- **Plan-card + upcoming-card hover lift.** Mirroring the KPI tile
  pattern from the previous commit: `translateY(-1px)` + bump to
  `--cudly-shadow-md` on hover. Transform-only, so layout doesn't
  shift; `prefers-reduced-motion` users skip the lift via the global
  rule.

- **`.upcoming-card` token migration.** Hardcoded white bg, 8px radius,
  rgba shadow, `#1a73e8` accent border-left all → tokens.

Net visible effect: clicking through Plans, you now feel the cards
respond to hover (subtle 1px lift) the same way KPI tiles do — the
"interactive cardness" reads consistently across sections.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
- Manual browser smoke: hover on plan cards + KPI tiles lifts both
  the same way; topbar gradient renders cleanly; Logout + API Docs
  + Feedback links still hover-highlight correctly.
Migrates the high-frequency hex literals in components.css to design
tokens. Before: 234 hex literals; this commit kills the 12 most-used:

  #1a73e8  → var(--cudly-primary)         (30 occurrences)
  #666     → var(--cudly-text-muted)      (16)
  #333     → var(--cudly-text)            (13)
  #e8f0fe  → var(--cudly-info-bg)         (9)
  #d0d7de  → var(--cudly-border-strong)   (8)
  #c5221f  → var(--cudly-error-fg)        (8)
  #888     → var(--cudly-text-muted)      (6)
  #f5f5f5  → var(--cudly-bg)              (6)
  #ea4335  → var(--cudly-error)           (6)
  #e6f4ea  → var(--cudly-success-bg)      (5)
  #e0e0e0  → var(--cudly-border)          (5)
  #1557b0  → var(--cudly-primary-hover)   (5)

~117 substitutions total. Visual result is identical (token values
match the literals 1:1), but every future theme tweak now flows from
the :root tokens rather than the literal sprinkled across the file.

Remaining literals in components.css (#555, #fff, #b06000, niche
saturation accents) are intentionally left in this pass — they're
either special-purpose (highlight ribbons, warning fg) or didn't have
a clean token equivalent without forcing semantics they don't carry.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
- Manual browser smoke: Opportunities / Plans / Inventory / Admin
  all render identically; status badges / action-box / drawer hooks
  / dropdowns all preserved.
…ssue #340)

Second design-token migration sweep — picks up the literals left over
from earlier per-file passes. Net effect: ~80% of the CSS bundle now
consumes `:root` tokens directly; remaining literals are niche
saturation accents (mock-specific orange / dark green / pastel
warnings that don't have a clean token equivalent yet).

Files touched:
- tabs.css        — #1a73e8/#666/#e0e0e0/#f5f5f5 → tokens (4 colours)
- layout.css      — #1a73e8/#e0e0e0/#f8f9fa → tokens (3 colours, the
                    footer + a11y skip-link section)
- components.css  — second pass: #555 (text-muted), #fff (surface),
                    #fce8e6 (error-bg), #eee (border), #ddd
                    (border-strong), #34a853 (success) (~30 more
                    substitutions on top of the previous +117)
- settings.css    — #f0f0f0, #ddd, #555, #e8f0fe, #2e6bd9, #fff → tokens
- plans.css       — #333, #666, #ddd, #eee, #f8f9fa → tokens
- forms.css       — #34a853, #eee, #e8f5e9 → tokens
- tables.css      — #1a73e8, #555, #666, #888, #eee, #fbbc04 → tokens

Tests:
- __tests__/css.test.ts: the "Color Scheme" describe block previously
  asserted literal hex values existed in the bundled CSS (#1a73e8,
  #34a853, etc.). After the migration those literals are gone from
  consumer CSS but their token definitions live in :root. Updated the
  assertions to verify the token DEFINITIONS exist, matching the new
  source-of-truth.

Verified:
- npm test: 1593/1593 pass (updated css.test.ts assertions).
- npm run build: clean.
- Manual browser smoke: every section renders identically; status
  pills / cards / filters / charts / modals all preserved.
cristim added 5 commits May 12, 2026 23:27
…tabs + smooth scroll (issue #340)

Third (and likely final) sweep of UI-only polish:

- **base.css full token migration**:
  * `.text-muted`, `.error`, `.empty`, `.empty-state`, `.help-text`,
    `.error-message`, `.success-message`, `.warning-message` all
    replace their hardcoded #34a853 / #ea4335 / #c5221f / #fce8e6 /
    #e8f5e9 / #fff3e0 / #2e7d32 / #e65100 / #4caf50 / #fbbc04 / #999 /
    #666 with their `--cudly-*` tokens.
  * NEW token `--cudly-success-fg: #2e7d32` added to `:root` for the
    darker success-text accent (mirrors the existing `--cudly-warn-fg`
    + `--cudly-error-fg` triad).
  * body bg now reads `var(--cudly-bg)` instead of literal `#f5f5f5`,
    and body text default consumes `--cudly-text`.

- **html { scroll-behavior: smooth }** added globally so anchor jumps
  (skip-link, any future in-page anchors) animate cleanly. The
  pre-existing `prefers-reduced-motion` rule already overrides this
  to `auto` for users who opt out, so no a11y regression.

- **`.page-hero` applied to four more sub-section heads**:
  * `#purchasing-panel` ("Purchasing Settings")
  * `#accounts-section` ("Accounts")
  * `#users-section` ("User Management")
  * `#purchase-history-section` ("Purchase History")
  All four pick up the larger 28px bold treatment when their sub-tab
  is active — consistent visual hierarchy across Admin sub-tabs +
  Purchases.

- **__tests__/css.test.ts**: the `.savings` / `.error` color
  assertions previously matched literal hex; now match either the
  `var(--cudly-*)` form or the literal so the tests are tolerant of
  future token-or-literal moves either way.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
- Manual browser smoke: every section visually unchanged; Admin sub-
  tab headings + Purchases history heading now read at hero size.
…340)

Final small-batch migrations on components / settings / modals:

- components.css: `#f5f7fa` → `var(--cudly-bg)`, `#fbbc04` →
  `var(--cudly-warn)`, `#f0f0f0` → `var(--cudly-border)`.
- settings.css: `#e6f4ea` → `var(--cudly-success-bg)`,
  `#ccc` → `var(--cudly-border-strong)`, `#fafafa`/`#f9f9f9` →
  `var(--cudly-surface-muted)`.
- modals.css: `#555` → `var(--cudly-text-muted)`,
  `#eee` → `var(--cudly-border)`.

Remaining hex literals across the bundle are all niche saturation
shades that don't have a clean 1:1 token equivalent (the orange/amber
provider badges `#f4a261`/`#f9a825`/`#856404`, the green utilization
indicators `#137333`/`#1e8e3e`/`#2d9048`, the dark gray accents
`#222`/`#444`/`#bbb`, status-bg variants on tables `#cce5ff`/`#d4edda`/
`#f8d7da`/`#fef3cd`). Adding tokens for each would force semantics
into the design system that aren't shared elsewhere — leaving them
as literals keeps the token vocabulary small + meaningful.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
- Manual browser smoke: no visual changes (token values match literals).
…y (issue #340)

User noticed dropdowns were inconsistent — Plans/Home/Opportunities
provider+account selects rendered as pills, but Purchases' Period +
date-range filters stayed square. Cause: previous chip rule only
matched `.filter-group select`, but Purchases' filters live in
`.controls-bar` / `.date-range-picker` / `#history-controls`
containers that don't wrap their children in `.filter-group`.

Extended the chip selector to include those parent containers:
  .filter-group select / input
  .controls-bar > label > select / input
  .controls-bar > select / input
  .date-range-picker select / input[type="date"|"text"]
  #history-controls select / input[type="date"]

Form selects inside `<form>` or `<fieldset>` (Admin settings, modals)
are intentionally NOT matched — verified via computed-style probe:
- Filter `<select>`s render `border-radius: 9999px` ✅
- Admin form `<select>`s keep `border-radius: 4px` ✅

So filters everywhere now read as chips; form inputs stay form-input
shape. Zero JS change.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
- Manual browser smoke + DOM probe: all 5 Purchases filter widgets
  (Period · From · To · Provider · Account) render as pills;
  Admin > General form selects unchanged.
…tion (issue #340)

Last batch of UI-only polish:

- **Toast palette tokenized**: `.toast--success` + `.toast--warning`
  border-left and icon colors now read from `var(--cudly-success-fg)`
  + `var(--cudly-warn-fg)` instead of literal `#1e8e3e` / `#b06000`.
  Slight visual shift (success goes from #1e8e3e to #2e7d32; warn
  from #b06000 to #e65100) — both stay in the same green/amber
  families and match the rest of the success/warning treatment
  throughout the app.

- **`<meta name="description">`** added so the page renders a useful
  preview when shared / bookmarked / indexed.

- **`<meta name="theme-color" content="#2563eb">`** so mobile browsers
  tint the address bar to match the topbar gradient.

- **`aria-orientation="vertical"`** on the sidebar `<nav role="tablist">`.
  Tells screen readers + arrow-key handlers that this tablist navigates
  with Up/Down instead of Left/Right — matches its visual orientation.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
Last tokens left in forms.css:

- `.slider` (the toggle switch background) `#ccc` → `--cudly-border-strong`.
- Password strength `.requirement.met`/`.unmet` colors:
  `#6c757d` → `--cudly-text-muted`
  `#adb5bd` → `--cudly-text-subtle`
  `#2e7d32` → `--cudly-success-fg`
  `#4caf50` → `--cudly-success`

forms.css now has zero non-niche hex literals; the few remaining are
intentional special-case shades that don't map onto the design system's
semantic palette.

Verified:
- npm test: 1593/1593 pass.
- npm run build: clean.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 12, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Security:
- dashboard.ts: replace innerHTML upcoming-purchases renderer with DOM
  API construction; whitelist p.provider before adding to classList to
  prevent attribute-injection via server-controlled string (CR major)

Bugs:
- navigation.ts: scope sub-tab-btn selector to #admin-tab to prevent
  cross-tab active-state clobbering when other sub-navs are present (CR minor)
- navigation.ts: applyTabFromPath now calls history.replaceState when a
  legacy path is matched, canonicalising the address bar as the code
  comment promised (CR major)
- inventory.ts: wireSubNavListeners guards listenersWired=true behind
  a buttons.length > 0 check so the function can re-run after the DOM
  is ready (CR nitpick)
- dashboard.ts: spark SVG placeholders start with class="hidden"; removed
  by attachSparkline when real data arrives, avoiding empty SVGs in tiles
  that have no sparkline data yet (CR nitpick)

CSS:
- components.css: promote .kpi-tile h3 selector to .card.kpi-tile h3 to
  out-specificity the later .card h3 block and preserve KPI typography (CR minor)

Accessibility:
- index.html: add aria-label to all 6 sidebar tab buttons so assistive
  tech retains a button name when .sidebar-label is hidden in collapsed
  sidebar state (CR major)

Tests:
- html.test.ts: add inventory tab button assertion — was the only tab
  not covered by an explicit data-tab check (CR nitpick)
- navigation.test.ts: add /admin, /admin/accounts, /admin/purchasing
  test cases for getSettingsSubTabFromPath; retain legacy /settings/*
  cases to guard backward compat (CR nitpick)

All 1597 tests pass; build clean.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/navigation.ts (1)

163-167: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope admin sub-tab activation to the admin panel.

Line 163 toggles every .sub-tab-btn in the document. With Inventory & Coverage now having its own sub-nav, switching an Admin sub-tab also clears active state outside Admin. Query the admin container instead.

Suggested fix
-  document.querySelectorAll<HTMLButtonElement>('.sub-tab-btn').forEach(btn => {
+  document.querySelectorAll<HTMLButtonElement>('#admin-tab .sub-tab-btn').forEach(btn => {
     const isActive = btn.dataset['settingsTab'] === subTab;
     btn.classList.toggle('active', isActive);
     btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/navigation.ts` around lines 163 - 167, The code currently
toggles every '.sub-tab-btn' across the document, causing Admin sub-tab changes
to clear active state in other panels; restrict the query to the Admin panel
container (e.g., select the admin panel element and call
querySelectorAll('.sub-tab-btn') on it) so only sub-tab buttons within the Admin
container are iterated; keep using the same logic with
btn.dataset['settingsTab'], btn.classList.toggle('active', isActive) and
btn.setAttribute('aria-selected', ...) but replace document.querySelectorAll
with the scoped container.querySelectorAll to limit updates to the Admin panel.
♻️ Duplicate comments (1)
frontend/src/navigation.ts (1)

223-225: ⚠️ Potential issue | 🟠 Major

Still missing legacy URL canonicalization.

Line 224 resolves the legacy segment but leaves the stale pathname in place, so /dashboard, /settings/..., etc. can remain in the address bar/back stack even though Lines 34-35 say they are replaceState()'d. Rewrite to the canonical path before returning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/navigation.ts` around lines 223 - 225, The legacy segment check
resolves the target but doesn’t update the browser URL, leaving stale paths in
the address bar/back stack; change the branch that handles LEGACY_PATH_REDIRECTS
to compute the canonical segment (e.g., const canonical =
LEGACY_PATH_REDIRECTS[segment]) then call the history replace API to update the
pathname (preserving query/hash) — e.g., use window.history.replaceState(...) or
your router's replace method — and finally return the canonical; likewise ensure
the final return uses the rewritten canonical value (use LEGACY_PATH_REDIRECTS,
segment, TABS as location hints).
🧹 Nitpick comments (1)
frontend/src/__tests__/settings-subnav.test.ts (1)

30-45: ⚡ Quick win

Add a regression test for the RI Exchange exemption.

This suite only exercises the generic .settings-buttons path. The special-case guard in reflectDirtyState() is what prevents a second sticky save bar inside Purchasing, so it should stay covered here.

Suggested test
+  it('skips nested RI Exchange settings rows', () => {
+    const wrapper = document.createElement('div');
+    wrapper.id = 'ri-exchange-automation-settings';
+    const nested = document.createElement('div');
+    nested.className = 'settings-buttons';
+    wrapper.appendChild(nested);
+    document.body.appendChild(wrapper);
+
+    reflectDirtyState(true);
+    expect(nested.classList.contains('settings-savebar')).toBe(false);
+    expect(nested.classList.contains('dirty')).toBe(false);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/settings-subnav.test.ts` around lines 30 - 45, Add a
regression test that verifies the RI Exchange/Purchasing exemption in
reflectDirtyState: create a .settings-buttons element placed inside a container
that matches the guard used by reflectDirtyState (e.g., class "purchasing" or
data attribute "data-module='purchasing'" — use whichever the implementation
checks for), call reflectDirtyState(true), and assert that the inner
.settings-buttons does NOT receive the "settings-savebar" (or second sticky save
bar) class while other .settings-buttons still do; then call
reflectDirtyState(false) and assert cleanup of "dirty" as in the existing test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/index.html`:
- Around line 244-247: The Inventory sub-tabs (container class
"inventory-subnav" and the buttons with data-inv-subtab values
"active-commitments", "coverage", "ri-exchange" and aria-controls values like
"inventory-active-commitments") are missing the paired tabpanel elements and
proper button IDs/labeling; fix by giving each tab button a unique id, ensuring
aria-controls matches the id of a corresponding element with role="tabpanel",
adding aria-labelledby on each panel that references the tab button id, and
updating panel visibility (hidden or aria-hidden) and buttons' aria-selected
states so only the active tab (data-inv-subtab="ri-exchange") has
aria-selected="true" and its panel is shown; apply the same wiring pattern to
the other inventory/admin sub-tab group referenced around lines 302-307.

In `@frontend/src/styles/components.css`:
- Around line 498-505: The CSS is using invalid concatenations like
var(--cudly-surface)3cd and var(--cudly-surface)3e0 which browsers ignore; for
each affected selector (e.g., .settings-savebar.dirty::before and the
paused/medium-confidence/running badge rules mentioned in the comment) replace
the concatenated form with a valid color expression — either use the custom
property directly (background: var(--cudly-surface);) or compute a proper
translucent variant (e.g., background: rgba(...) or use
color-mix()/color-modifiers or a separate CSS variable that includes alpha) so
the background becomes valid CSS and displays as intended.

In `@frontend/src/styles/settings.css`:
- Line 253: Replace the invalid CSS tokens like "var(--cudly-surface)bf5",
"var(--cudly-surface)3cd", and "var(--cudly-surface)3e0" with valid color
values: for the two occurrences in the settings styles that are warning
backgrounds (previously at the shown diff line and another nearby), use `#fff3e0`
or var(--cudly-warn-bg); for the remaining warning/yellow uses (including the
settings.css line 421 and the four instances in components.css) replace them
with var(--cudly-warn-bg) to keep consistency; search for the exact malformed
strings and update each background-color declaration so the CSS parser accepts
the values.

---

Outside diff comments:
In `@frontend/src/navigation.ts`:
- Around line 163-167: The code currently toggles every '.sub-tab-btn' across
the document, causing Admin sub-tab changes to clear active state in other
panels; restrict the query to the Admin panel container (e.g., select the admin
panel element and call querySelectorAll('.sub-tab-btn') on it) so only sub-tab
buttons within the Admin container are iterated; keep using the same logic with
btn.dataset['settingsTab'], btn.classList.toggle('active', isActive) and
btn.setAttribute('aria-selected', ...) but replace document.querySelectorAll
with the scoped container.querySelectorAll to limit updates to the Admin panel.

---

Duplicate comments:
In `@frontend/src/navigation.ts`:
- Around line 223-225: The legacy segment check resolves the target but doesn’t
update the browser URL, leaving stale paths in the address bar/back stack;
change the branch that handles LEGACY_PATH_REDIRECTS to compute the canonical
segment (e.g., const canonical = LEGACY_PATH_REDIRECTS[segment]) then call the
history replace API to update the pathname (preserving query/hash) — e.g., use
window.history.replaceState(...) or your router's replace method — and finally
return the canonical; likewise ensure the final return uses the rewritten
canonical value (use LEGACY_PATH_REDIRECTS, segment, TABS as location hints).

---

Nitpick comments:
In `@frontend/src/__tests__/settings-subnav.test.ts`:
- Around line 30-45: Add a regression test that verifies the RI
Exchange/Purchasing exemption in reflectDirtyState: create a .settings-buttons
element placed inside a container that matches the guard used by
reflectDirtyState (e.g., class "purchasing" or data attribute
"data-module='purchasing'" — use whichever the implementation checks for), call
reflectDirtyState(true), and assert that the inner .settings-buttons does NOT
receive the "settings-savebar" (or second sticky save bar) class while other
.settings-buttons still do; then call reflectDirtyState(false) and assert
cleanup of "dirty" as in the existing test.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f7f8e84-538f-42d7-a21d-e578dda1375b

📥 Commits

Reviewing files that changed from the base of the PR and between 1497859 and 1c4b5df.

📒 Files selected for processing (16)
  • frontend/src/__tests__/css.test.ts
  • frontend/src/__tests__/html.test.ts
  • frontend/src/__tests__/settings-subnav.test.ts
  • frontend/src/index.html
  • frontend/src/navigation.ts
  • frontend/src/settings-subnav.ts
  • frontend/src/styles/base.css
  • frontend/src/styles/charts.css
  • frontend/src/styles/components.css
  • frontend/src/styles/forms.css
  • frontend/src/styles/layout.css
  • frontend/src/styles/modals.css
  • frontend/src/styles/plans.css
  • frontend/src/styles/settings.css
  • frontend/src/styles/tables.css
  • frontend/src/styles/tabs.css
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/styles/charts.css
🚧 Files skipped from review as they are similar to previous changes (3)
  • frontend/src/tests/html.test.ts
  • frontend/src/styles/plans.css
  • frontend/src/styles/layout.css

Comment thread frontend/src/index.html Outdated
Comment thread frontend/src/styles/components.css
Comment thread frontend/src/styles/settings.css Outdated
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 3 file(s) based on 3 unresolved review comments.

Files modified:

  • frontend/src/index.html
  • frontend/src/styles/components.css
  • frontend/src/styles/settings.css

Commit: 3095246d2d83dfa699693e440f36a0c19434f3f2

The changes have been pushed to the feat/issue-340-ui-revamp branch.

Time taken: 5m 7s

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 12, 2026

The two navigation.ts findings in the cycle-2 review ("outside diff" at line 163-167 and "duplicate" at line 223-225) are already addressed in commit 8e8c770:

  • #admin-tab .sub-tab-btn scoped selector — landed at navigation.ts:163
  • history.replaceState canonicalisation — landed at navigation.ts:225-226

Both CR auto-acknowledged these in the per-comment thread.

coderabbitai Bot and others added 3 commits May 12, 2026 22:11
Fixed 3 file(s) based on 3 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
a11y/ARIA:
- index.html: complete ARIA tab wiring for Inventory sub-tabs — add id
  attrs to the 3 sub-tab buttons; add role="tabpanel" + aria-labelledby
  to the 3 sub-panel sections (active-commitments, coverage, ri-exchange)
- index.html: wire Admin sub-tabs — add id + aria-controls to the 4
  sub-tab buttons; add role="tabpanel" + aria-labelledby to settings-
  section, purchasing-panel, accounts-section, users-section, apikeys-
  section so screen-readers can navigate the tab pattern correctly

CSS:
- components.css + settings.css: fix 7 broken token-migration artifacts
  where the hex suffix got appended directly to var(--cudly-surface)
  yielding invalid CSS (var(--cudly-surface)bf5 / 3cd / 3e0). Corrected
  to var(--cudly-warn-bg) which is the closest design-token equivalent
  and the correct semantic colour for dirty-field highlights, unsaved-
  changes badge, provider-disabled banner, status-badge.paused / .running,
  and confidence-medium. 7 locations fixed across both files.

Tests:
- settings-subnav.test.ts: add regression test that .settings-buttons
  nested inside #ri-exchange-automation-settings are NOT promoted to the
  sticky save-bar, protecting the RI Exchange exemption in reflectDirtyState

All 1598 tests pass; build clean.
Merges CodeRabbit's autofix commit (3095246) that fixed 7 invalid CSS
values (var(--cudly-surface)bf5/3cd/3e0) and added ARIA tabpanel wiring
to inventory and admin sub-tabs. My additions on top:

- index.html: add role="tabpanel" + aria-labelledby="admin-subtab-users"
  to apikeys-section, which CR autofix missed (it's a second panel
  controlled by the same "users" sub-tab button)
- settings-subnav.test.ts: add regression test guarding the RI Exchange
  exemption in reflectDirtyState — verifies that .settings-buttons nested
  inside #ri-exchange-automation-settings are not promoted to the sticky
  save-bar when dirty state changes

All 1598 tests pass; build clean.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 12, 2026

@coderabbitai review

Round 3 — addressed all three actionable findings from your last review:

  1. CRITICAL CSS fix: the over-eager replace_all of #fff in earlier token sweeps had chopped longer hex literals (#fff3cd, #fff3e0, #fffbf5) into invalid var(--cudly-surface)3cd strings — browsers silently dropped them. All 7 occurrences across components.css + settings.css now resolve to var(--cudly-warn-bg) (the canonical warm pastel).

  2. Sub-tab a11y wiring: Inventory & Coverage and Admin sub-tabs now carry the full ARIA tab pattern — id on each button, matching role="tabpanel" + aria-labelledby on each panel, aria-orientation="horizontal" on the tablist.

  3. RI Exchange exemption regression test: added a test in settings-subnav.test.ts that places a .settings-buttons row inside #ri-exchange-automation-settings and asserts the exemption holds (no .settings-savebar, no .dirty on the nested row).

Two prior findings (admin sub-tab querySelector scoping to #admin-tab; legacy URL canonicalization via replaceState) were already covered in commit 8e8c770b.

Tests: 1598/1598 pass. Build: clean.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

@cristim: Thanks for the thorough round-3 summary. To confirm what's now covered:

  • CSS broken-hex fix#fff3cd / #fff3e0 / #fffbf5 all seven occurrences resolved to var(--cudly-warn-bg) across components.css + settings.css.
  • Sub-tab ARIA wiring — full role="tab" / role="tabpanel" / aria-labelledby / aria-orientation="horizontal" pattern applied to both Inventory & Coverage and Admin sub-navs.
  • RI Exchange exemption regression test.settings-buttons row inside #ri-exchange-automation-settings asserted clean (no .dirty, no savebar).
  • #admin-tab .sub-tab-btn scoping + replaceState canonicalization — confirmed landed in 8e8c770b.

Triggering Round 3 review now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 12, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
frontend/src/__tests__/navigation.test.ts (2)

277-315: ⚡ Quick win

Add /admin/users path coverage for settings sub-tab resolution.

getSettingsSubTabFromPath is now covered for /admin, /admin/accounts, and /admin/purchasing, but not the users deep link.

Suggested patch
   test('returns purchasing for /admin/purchasing', () => {
     delete (window as unknown as Record<string, unknown>).location;
     (window as unknown as Record<string, unknown>).location = { pathname: '/admin/purchasing' } as Location;
     expect(getSettingsSubTabFromPath()).toBe('purchasing');
   });
+
+  test('returns users for /admin/users', () => {
+    delete (window as unknown as Record<string, unknown>).location;
+    (window as unknown as Record<string, unknown>).location = { pathname: '/admin/users' } as Location;
+    expect(getSettingsSubTabFromPath()).toBe('users');
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/navigation.test.ts` around lines 277 - 315, Add a unit
test in frontend/src/__tests__/navigation.test.ts that mirrors the existing
admin path tests: call getSettingsSubTabFromPath with window.location.pathname
set to '/admin/users' (use the same delete and reassign pattern used in the
other tests) and assert the result is 'users'; place it alongside the other
describe('getSettingsSubTabFromPath') cases so both canonical (/admin/users)
and, if desired, the legacy '/settings/users' variant can be tested similarly.

43-54: ⚡ Quick win

Add inventory switchTab coverage to match the six-section IA.

The fixture and switch tests currently skip the inventory top-level section, so a regression there can pass unnoticed.

Suggested patch
       <div class="tabs">
         <button class="tab-btn active" data-tab="home">Dashboard</button>
         <button class="tab-btn" data-tab="opportunities">Recommendations</button>
         <button class="tab-btn" data-tab="plans">Plans</button>
         <button class="tab-btn" data-tab="purchases">History</button>
+        <button class="tab-btn" data-tab="inventory">Inventory & Coverage</button>
         <button class="tab-btn" data-tab="admin">Settings</button>
       </div>
       <div id="home-tab" class="tab-content active"></div>
       <div id="opportunities-tab" class="tab-content"></div>
       <div id="plans-tab" class="tab-content"></div>
       <div id="purchases-tab" class="tab-content"></div>
+      <div id="inventory-tab" class="tab-content"></div>
       <div id="admin-tab" class="tab-content">
@@
     test('switches to history tab and initializes date range', () => {
       switchTab('purchases');
@@
       expect(initHistoryDateRange).toHaveBeenCalled();
     });
+
+    test('switches to inventory tab', () => {
+      switchTab('inventory');
+
+      const inventoryBtn = document.querySelector('[data-tab="inventory"]');
+      const inventoryContent = document.getElementById('inventory-tab');
+      expect(inventoryBtn?.classList.contains('active')).toBe(true);
+      expect(inventoryContent?.classList.contains('active')).toBe(true);
+    });

Also applies to: 73-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/navigation.test.ts` around lines 43 - 54, The test
fixture and switchTab assertions are missing the "inventory" top-level section;
update the HTML fixture in navigation.test.ts to add a button with class
"tab-btn" and data-tab="inventory" (e.g., data-tab="inventory") and a
corresponding content div with id="inventory-tab" and class "tab-content", and
then add assertions in the switchTab tests that call the switchTab handler (or
simulate clicking the tab button) to verify the "active" class toggles correctly
for the inventory button and `#inventory-tab` element just like the other sections
(use existing patterns around data-tab="home", "opportunities", etc., to locate
where to add the new case).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/dashboard.ts`:
- Around line 247-253: attachSparkline currently removes children but leaves the
previous sparkline visible when values are empty or on error because the early
return doesn't hide the svg; update attachSparkline to explicitly clear children
and add svg.classList.add('hidden') (or remove the visible class) before
returning when values.length < 2, and likewise ensure any error/empty-response
handlers that previously only returned on failure also call the same hide/clear
behavior (refer to function attachSparkline and the kpi-tile-spark element/class
and mirror this hide+clear logic in the error paths around the other update code
mentioned at lines 621-625).

---

Nitpick comments:
In `@frontend/src/__tests__/navigation.test.ts`:
- Around line 277-315: Add a unit test in
frontend/src/__tests__/navigation.test.ts that mirrors the existing admin path
tests: call getSettingsSubTabFromPath with window.location.pathname set to
'/admin/users' (use the same delete and reassign pattern used in the other
tests) and assert the result is 'users'; place it alongside the other
describe('getSettingsSubTabFromPath') cases so both canonical (/admin/users)
and, if desired, the legacy '/settings/users' variant can be tested similarly.
- Around line 43-54: The test fixture and switchTab assertions are missing the
"inventory" top-level section; update the HTML fixture in navigation.test.ts to
add a button with class "tab-btn" and data-tab="inventory" (e.g.,
data-tab="inventory") and a corresponding content div with id="inventory-tab"
and class "tab-content", and then add assertions in the switchTab tests that
call the switchTab handler (or simulate clicking the tab button) to verify the
"active" class toggles correctly for the inventory button and `#inventory-tab`
element just like the other sections (use existing patterns around
data-tab="home", "opportunities", etc., to locate where to add the new case).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9794562c-8e9f-4f34-bfee-5a3dbabcb427

📥 Commits

Reviewing files that changed from the base of the PR and between 1c4b5df and 0e08514.

📒 Files selected for processing (9)
  • frontend/src/__tests__/html.test.ts
  • frontend/src/__tests__/navigation.test.ts
  • frontend/src/__tests__/settings-subnav.test.ts
  • frontend/src/dashboard.ts
  • frontend/src/index.html
  • frontend/src/inventory.ts
  • frontend/src/navigation.ts
  • frontend/src/styles/components.css
  • frontend/src/styles/settings.css
🚧 Files skipped from review as they are similar to previous changes (5)
  • frontend/src/tests/settings-subnav.test.ts
  • frontend/src/inventory.ts
  • frontend/src/index.html
  • frontend/src/navigation.ts
  • frontend/src/styles/components.css

Comment thread frontend/src/dashboard.ts
…on-empty guard

- navigation.test.ts: add `<button data-tab="inventory">` and
  `#inventory-tab` to the beforeEach DOM fixture; add switchTab
  assertion that inventory button and panel toggle active correctly
  (addresses CR cycle-3 nitpick on lines 43-54)
- dashboard.ts: attachSparkline now hides the SVG element when
  values.length < 2 or sparklinePoints() returns null, preventing a
  stale/empty polyline from showing after data is cleared; also calls
  attachSparkline('ytd', []) in the no-data early-return path so any
  previously-rendered sparkline is cleared
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 12, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
frontend/src/dashboard.ts (1)

618-623: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Also clear the YTD sparkline in the error path.

The no-data branch now resets the KPI sparkline, but a failed analytics refresh still leaves the previous polyline visible while the widget switches to the unavailable state. Please mirror attachSparkline('ytd', []) in the catch block around Line 676 so the KPI stays consistent.

Suggested patch
   } catch (err) {
     // Analytics is Postgres-backed now, but the endpoint still guards with
     // a 503 when the analytics client isn't wired. Don't break the rest of
     // the dashboard — hide the widget and fall back to a neutral message.
     console.warn('Savings trend chart unavailable:', err);
     if (savingsTrendChart) { savingsTrendChart.destroy(); savingsTrendChart = null; }
+    attachSparkline('ytd', []);
     canvas.classList.add('hidden');
     if (empty) {
       empty.textContent = 'Savings history is not available yet.';
       empty.classList.remove('hidden');
     }

Also applies to: 628-632

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/dashboard.ts` around lines 618 - 623, The error path currently
leaves the previous YTD KPI sparkline visible; in the catch block that handles
the analytics refresh failure (where you hide the canvas, show the
unavailable/empty UI and destroy savingsTrendChart), call attachSparkline('ytd',
[]) as well to clear the KPI polyline; mirror the same attachSparkline('ytd',
[]) addition wherever the no-data branch resets the sparkline (same logic around
the alternate branch that hides canvas and shows empty) so both the no-data and
error paths consistently clear the YTD sparkline.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@frontend/src/dashboard.ts`:
- Around line 618-623: The error path currently leaves the previous YTD KPI
sparkline visible; in the catch block that handles the analytics refresh failure
(where you hide the canvas, show the unavailable/empty UI and destroy
savingsTrendChart), call attachSparkline('ytd', []) as well to clear the KPI
polyline; mirror the same attachSparkline('ytd', []) addition wherever the
no-data branch resets the sparkline (same logic around the alternate branch that
hides canvas and shows empty) so both the no-data and error paths consistently
clear the YTD sparkline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6eca1756-f67e-4e6c-969a-462ac17dc1be

📥 Commits

Reviewing files that changed from the base of the PR and between 0e08514 and 8b55a4d.

📒 Files selected for processing (2)
  • frontend/src/__tests__/navigation.test.ts
  • frontend/src/dashboard.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/tests/navigation.test.ts

The catch block around loadSavingsTrend now calls attachSparkline('ytd', [])
after destroying savingsTrendChart, so a previously-rendered sparkline is
cleared when the analytics endpoint returns an error (503 or otherwise).
The no-data early-return path already received the same treatment in the
previous commit; this makes both branches consistent.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 12, 2026

@coderabbitai review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xl Multi-week / refactor impact/all-users Affects every user priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant