fix(email): short-circuit to no-op sender when EMAIL_ENABLED=false (closes #332)#333
Conversation
…loses #332) `internal/email/factory.go::NewSenderFromEnvironment` now returns a no-op `SenderInterface` at the top of the function when `EMAIL_ENABLED=false`, before dispatching by `SECRET_PROVIDER`. Background: the factory and the secret resolver share `SECRET_PROVIDER` for two different purposes (cloud email backend selection vs. secret-store backend selection). The local-dev `docker-compose.yml` already declares `EMAIL_ENABLED: "false"` and explains "Email disabled for local development (no SNS topic)", but the factory never reads the flag — it just dispatches on `SECRET_PROVIDER`. So: - `SECRET_PROVIDER=aws` + no AWS creds boots an SES sender that nothing exercises (passes by luck, not design). - `SECRET_PROVIDER=env` (the documented local-dev resolver per `internal/secrets/resolver.go:50`) + `EMAIL_ENABLED=false` hard-fails on startup: `failed to initialize email sender: unsupported email provider: env`. The factory now respects the documented `EMAIL_ENABLED` contract. The no-op sender implements all 15 `SenderInterface` methods, logs each invocation at debug level so local-dev traces still show where an email would have gone, and is guarded by a compile-time `var _ SenderInterface = (*NopSender)(nil)` assertion. Verification: - `go build ./...` clean - `go test ./...` clean (0 failures)
…diagnosis `sendPurchaseApprovalRequestVia` in `internal/email/templates.go` silently swallowed `RenderPurchaseApprovalRequestEmailHTML` errors when degrading to text-only delivery. The graceful fallback is correct (text-only is the safer alternative to dropping the approval email entirely), but the silent swallow hides template-syntax bugs from production diagnostics. This commit imports the project's standard `pkg/logging` package and emits a `Warnf` at the fallback site so an HTML render regression surfaces in logs without breaking email delivery. The fallback itself is unchanged — `htmlBody = ""` still routes through the multipart sender's text-only path. A comment notes the deliberate non-return decision so a future reader doesn't escalate the warning into an early `return err`. Originally a CodeRabbit nitpick on PR #298 that raced with the merge (commit eb09eab on the closed feat/issue-287 branch). Folded into this PR per follow-up tracking — both touch internal/email/ and the change is six lines. Verification: - `go build ./...` clean - `go test ./internal/email/...` clean
|
@coderabbitai review |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR adds a no-op email sender and integrates an EMAIL_ENABLED early-exit in the factory to return it when disabled; it also logs HTML template render failures for purchase-approval emails while keeping text-only fallback. ChangesEmail Disabling with No-op Sender
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@internal/email/factory.go`:
- Around line 47-50: Replace the literal string check of EMAIL_ENABLED with
boolean parsing: read os.Getenv("EMAIL_ENABLED"), call strconv.ParseBool on it
(import strconv), and if the parsed value is false return NewNopSender() and log
the same message; handle parse errors by either treating them as enabled (and
optionally logging a warning) or defaulting to false per your policy. Ensure you
reference the same env var name "EMAIL_ENABLED" and keep NewNopSender() as the
no-op return path.
In `@internal/email/nop_sender.go`:
- Around line 26-33: NopSender currently logs raw recipient and CC email
addresses in SendToEmail and SendToEmailWithCCMultipart (and other no-op sender
methods), which leaks PII; update those logging calls to avoid printing raw
emails by either removing the address fields or replacing them with non-PII info
(e.g., masked addresses, recipient count, or domains only); implement a small
helper (e.g., maskEmail or summarizeRecipients) and use it in
NopSender.SendToEmail and NopSender.SendToEmailWithCCMultipart (and the other
no-op methods flagged) so logs retain useful context without exposing full email
addresses.
🪄 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: 2d427b08-aaa4-448c-9218-950a35d64802
📒 Files selected for processing (3)
internal/email/factory.gointernal/email/nop_sender.gointernal/email/templates.go
… no-op logs (CR pass on PR #333) Addresses both actionable items from CodeRabbit's review on PR #333: 1. **EMAIL_ENABLED parsing** (`internal/email/factory.go`): the literal `== "false"` check only matched the lower-case spelling. `"False"`, `"FALSE"`, `"0"`, etc. would all be treated as enabled, even though the user clearly intended the no-op path. Replaced with `strconv.ParseBool` so the full set of false-y spellings is honoured (false/False/FALSE/0/f/F per stdlib semantics). Unset / empty values keep the default (enabled) so existing deployments that don't set the var are unaffected. Unparseable values emit a warning and fall through to the enabled path so the misconfiguration is visible in logs without bringing the app down. 2. **PII redaction** (`internal/email/nop_sender.go`): the previous logs included raw recipient email addresses, subjects, and (in one place) admin email addresses. Even in dev, log files leak into shared environments (terminal scrollback, screen-shares, support tickets), and email addresses are sufficient identifying information to require treating them as PII. Replaced all recipient/subject/email-data formatting with just method-name + recipient counts (e.g. `to=1 cc=3`) — enough trace context to know which email path was invoked without leaking who it would have gone to. Documented the policy in the type-level doc comment so a future maintainer doesn't add the addresses back. Verification: - `go build ./...` clean - `go test ./...` clean (0 failures) - `go test ./internal/email/...` clean
|
@coderabbitai — addressed both actionable items from review
Verification: @coderabbitai review |
|
Triggering a fresh review of the updated commit now. ✅ Actions performedReview triggered.
|
Tests added alongside the runtime changes from this PR.
`internal/email/factory_test.go` gains
`TestNewSenderFromEnvironment_EmailEnabled` — a table-driven test with
fifteen sub-cases covering every branch of the new dispatch:
- Six false-y EMAIL_ENABLED spellings (false, False, FALSE, 0, f, F)
all short-circuit to NopSender, with SECRET_PROVIDER set to a
deliberately-unsupported "env" backend so the assertion proves
the short-circuit fires BEFORE provider dispatch.
- Six true-y spellings (true, True, TRUE, 1, t, T) all fall through
to the AWS sender via SECRET_PROVIDER=aws.
- Unset / empty values fall through to the default-enabled path.
- An unparseable value ("maybe") emits a warning (visible in test
output) and falls through to enabled — protecting an accidental
misconfiguration from taking the app down.
`internal/email/nop_sender_test.go` (new file) covers the NopSender
contract:
- TestNopSender_AllMethodsReturnNil — exercises every one of the 15
SenderInterface methods and asserts they all return nil. A future
bug that returns an error would propagate into the calling
application path, breaking the EMAIL_ENABLED=false promise that
no work happens.
- TestNopSender_NilSafe — empty / nil inputs (empty CC list, empty
bodies) must not panic.
- TestNopSender_NilContext — a nil context.Context must not panic
either; the no-op shouldn't be the surface that leaks a panic.
- TestNopSender_SatisfiesInterface — runtime echo of the
compile-time `var _ SenderInterface = (*NopSender)(nil)` guard,
so a refactor that silently removes the guard is still caught.
Verification:
- `go test ./internal/email/...` clean (19 new sub-cases)
- `go test ./...` clean (0 failures)
|
Added unit tests in commit b04594e:
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
Triggering a fresh review of the updated commit now. ✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@internal/email/factory_test.go`:
- Around line 258-260: In the "unset_falls_through" subtest, capture the
original EMAIL_ENABLED value before calling os.Unsetenv and register a t.Cleanup
that restores the environment after the test; i.e., inside the
t.Run("unset_falls_through", ...) before os.Unsetenv("EMAIL_ENABLED") save orig
:= os.LookupEnv("EMAIL_ENABLED") and call t.Cleanup(func(){ if present restore
with os.Setenv else call os.Unsetenv }) so the test (and helper aws(t)) does not
leak mutated env state to later tests.
🪄 Autofix (Beta)
❌ Autofix failed (check again to retry)
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: 416d50af-d1a3-49e1-8793-42bc7d814ab1
📒 Files selected for processing (2)
internal/email/factory_test.gointernal/email/nop_sender_test.go
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. The branch was updated while autofix was in progress. Please try again. |
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 1 file(s) based on 1 unresolved review comment. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
PR #333 (closes #332) landed a botched merge of `unset_falls_through` in TestNewSenderFromEnvironment_EmailEnabled — two overlapping copies of the same sub-test ended up concatenated (one missing its closing braces, one preceded by stray fragments), plus duplicate `ctx :=` / `sender, err :=` lines. The result didn't parse: gofmt -l internal/email/factory_test.go internal/email/factory_test.go:309:2: missing ',' before newline ... internal/email/factory_test.go:312:6: expected '(', found TestNewSenderWithConfig_AWS This trips `gofmt` and `go vet` in the pre-commit workflow on every open PR against `feat/multicloud-web-frontend` (e.g. #326, #335, #336). Keep the `prev/hadPrev` version of the sub-test (the one that actually does `os.Unsetenv` first, which is the case the test name describes), drop the orphaned `orig/hadOrig` fragment, and remove the duplicate ctx/sender declarations. Verified locally: gofmt clean, `go vet ./internal/email/...` clean, `go test ./internal/email/...` 306 tests pass.
…env vars (closes #334) (#335) * fix(local-dev): docker-compose + .env.example cover the new required env vars (closes #334) A fresh `docker-compose up -d` on this branch fails on startup because the compose file is missing several env vars the app now requires. This commit adds them with local-dev defaults and documents the same contract in `.env.example` so anyone running outside docker-compose (e.g. directly via Air or `go run ./cmd/server`) has a one-stop reference. Failure chain that this fixes (each gated the next): 1. scheduled-task auth init: SCHEDULED_TASK_AUTH_MODE unset 2. admin password resolution: ADMIN_PASSWORD_SECRET required 3. (after #333 lands) SECRET_PROVIDER=aws + empty AWS creds was working by luck; switching to `env` is now the correct local-dev resolver path 4. frontend admin-setup modal asks for an API key (sourced from API_KEY_SECRET_ARN → fails when the var is empty) `docker-compose.yml` (`app` service environment block): - SECRET_PROVIDER: aws → env (internal/secrets.EnvResolver, per internal/secrets/resolver.go:50 — pairs with EMAIL_ENABLED=false so the no-op email sender from #333 kicks in). - SCHEDULED_TASK_AUTH_MODE: disabled (internal/server/scheduledauth has no default and refuses to start when unset). - ADMIN_PASSWORD_SECRET / API_KEY_SECRET_ARN as VAR-NAME indirections pointing at ADMIN_PASSWORD_DEV / ADMIN_API_KEY_DEV (the EnvResolver pattern). Concrete dev values for both, plus ADMIN_EMAIL. - CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY=1 (gate the all-zero dev key per credentials.LoadKey — refuses to start without it). `.env.example` documents: - the new SECRET_PROVIDER=env contract (replaces the now-stale "env will fail" warning that pre-dated #333), - SCHEDULED_TASK_AUTH_MODE and EMAIL_ENABLED with one-line rationale, - the VAR-NAME-indirection pattern for *_SECRET / *_SECRET_ARN with the concrete dev values co-located so future readers can trace the chain in one file. Depends on PR #333 (no-op email sender) — without that, the email factory crashes on SECRET_PROVIDER=env. Sequencing intentional. Verification: - docker-compose up -d brings postgres + app + frontend to healthy - curl http://localhost:8080/api/health → HTTP 200 - admin-setup modal accepts the documented dev defaults * fix(local-dev): align .env.example ADMIN_EMAIL with docker-compose default (CR pass on PR #335) CodeRabbit nitpick on PR #335: `.env.example` still listed `ADMIN_EMAIL=admin@example.com` while `docker-compose.yml` defaults to `admin@cudly.local`. The drift made the two reference points disagree about which placeholder a fresh checkout should use. Aligning on `admin@cudly.local` keeps both files telling the same story.
…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.
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.
…oses #340) (#343) * style(frontend): introduce design-token CSS variables for UI revamp (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. * feat(frontend/ui): action-center shell — top bar + left sidebar nav (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. * refactor(frontend): rename tab IDs + labels + add URL redirect for new 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. * feat(frontend): fold RI Exchange into Inventory & Coverage umbrella section (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. * feat(frontend/admin): relabel Admin sub-tabs per new IA (issue #340) 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. * feat(frontend/home): KPI sparklines + tightened card grid (issue #340) 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. * style(frontend): visual polish across Opportunities + Plans + action-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. * style(frontend): migrate settings/tables/forms/modals CSS to design tokens (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. * style(frontend): polish topbar logo + empty-state cards (issue #340) 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. * refactor(frontend/admin): remove in-panel sticky-rail sub-nav (issue #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. * style(frontend): align sidebar hamburger icon with the section icons (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. * fix(frontend/ui): drop .tabs class from sidebar nav — fixes icon alignment + 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. * style(frontend): empty-state visual hint + keyboard focus rings (issue #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. * style(frontend): chip-style filters + page-hero CSS scaffolding (issue #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. * style(frontend): page-hero typography + a11y + reduced-motion + hover 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. * style(frontend): tokenize topbar + plan/upcoming card hover lifts (issue #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. * style(frontend): components.css major token sweep (issue #340) 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. * style(frontend): tokenize remaining hex literals across CSS bundle (issue #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. * style(frontend): base.css final token sweep + page-hero on Admin sub-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. * style(frontend): fourth token sweep — niche bg/border literals (issue #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). * fix(frontend): apply chip styling to ALL filter dropdowns consistently (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. * style(frontend): toast color tokens + meta description + aria-orientation (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. * style(frontend): forms.css final token migrations (issue #340) 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. * fix(frontend): address CodeRabbit findings from PR #343 review 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. * fix: apply CodeRabbit auto-fixes Fixed 3 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai> * fix(frontend): address CodeRabbit cycle-2 findings for PR #343 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. * test(frontend/ui-revamp): add inventory tab fixture + sparkline hide-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 * fix(frontend/dashboard): clear YTD sparkline in analytics error path 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. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Summary
Two related fixes to
internal/email/so the package matches itsdocumented contract and produces useful diagnostics when degraded.
Changes
1. Factory short-circuits to no-op sender on
EMAIL_ENABLED=false(closes #332)internal/email/factory.go::NewSenderFromEnvironmentnow returns ano-op
SenderInterfaceat the top of the function whenEMAIL_ENABLED=false, before dispatching bySECRET_PROVIDER.The factory and the secret resolver share
SECRET_PROVIDERfor twodifferent purposes (cloud email backend vs. secret-store backend).
The local-dev
docker-compose.ymlalready declaresEMAIL_ENABLED: "false"and explains "Email disabled for localdevelopment (no SNS topic)", but the factory never reads the flag.
So
SECRET_PROVIDER=env(the documented local-dev resolver perinternal/secrets/resolver.go:50) +EMAIL_ENABLED=falsehard-failson startup with
failed to initialize email sender: unsupported email provider: env, even though no email will ever be sent.The new
internal/email/nop_sender.goimplements all 15SenderInterfacemethods, logs each invocation at debug level solocal-dev traces still show where an email would have gone, and is
guarded by a compile-time
var _ SenderInterface = (*NopSender)(nil)assertion.2. Log HTML approval-request render fallback
sendPurchaseApprovalRequestViaininternal/email/templates.gosilently swallowed
RenderPurchaseApprovalRequestEmailHTMLerrorswhen degrading to text-only delivery. Now emits
logging.Warnf("email: HTML approval-request render failed, falling back to text-only: %v", htmlErr)at the fallback site sotemplate regressions surface in logs without breaking email delivery.
The graceful fallback itself is unchanged —
htmlBody = ""stillroutes through the multipart sender's text-only path.
Originally a CodeRabbit nitpick on PR #298 that raced with the merge
(commit
eb09eabe4on the closedfeat/issue-287branch). Foldedinto this PR since both changes touch
internal/email/and thechange is six lines.
Closes #332.
Test plan
go build ./...cleango test ./...clean (0 failures)go test ./internal/email/...cleanSECRET_PROVIDER=envandEMAIL_ENABLED=false; backend reaches/api/healthHTTP 200; logs show"Email sending is disabled (EMAIL_ENABLED=false); using no-op sender".Summary by CodeRabbit
New Features
Improvements
Tests