Skip to content

fix(email): short-circuit to no-op sender when EMAIL_ENABLED=false (closes #332)#333

Merged
cristim merged 6 commits into
feat/multicloud-web-frontendfrom
feat/email-nop-sender
May 11, 2026
Merged

fix(email): short-circuit to no-op sender when EMAIL_ENABLED=false (closes #332)#333
cristim merged 6 commits into
feat/multicloud-web-frontendfrom
feat/email-nop-sender

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 11, 2026

Summary

Two related fixes to internal/email/ so the package matches its
documented 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::NewSenderFromEnvironment now returns a
no-op SenderInterface at the top of the function when
EMAIL_ENABLED=false, before dispatching by SECRET_PROVIDER.

The factory and the secret resolver share SECRET_PROVIDER for two
different purposes (cloud email backend vs. secret-store backend).
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.
So SECRET_PROVIDER=env (the documented local-dev resolver per
internal/secrets/resolver.go:50) + EMAIL_ENABLED=false hard-fails
on 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.go 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.

2. Log HTML approval-request render fallback

sendPurchaseApprovalRequestVia in internal/email/templates.go
silently swallowed RenderPurchaseApprovalRequestEmailHTML errors
when 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 so
template regressions surface in logs without breaking email delivery.
The graceful fallback itself is unchanged — htmlBody = "" still
routes through the multipart sender's text-only path.

Originally a CodeRabbit nitpick on PR #298 that raced with the merge
(commit eb09eabe4 on the closed feat/issue-287 branch). Folded
into this PR since both changes touch internal/email/ and the
change is six lines.

Closes #332.

Test plan

  • go build ./... clean
  • go test ./... clean (0 failures)
  • go test ./internal/email/... clean
  • Manual: start the local docker-compose stack with
    SECRET_PROVIDER=env and EMAIL_ENABLED=false; backend reaches
    /api/health HTTP 200; logs show
    "Email sending is disabled (EMAIL_ENABLED=false); using no-op sender".

Summary by CodeRabbit

  • New Features

    • Option to fully disable email delivery via an environment setting; when disabled the system suppresses outbound emails and logs the change.
  • Improvements

    • Added a no-op email sender that silently suppresses sends while logging minimal, non-PII details.
    • Approval email template render failures now log a warning and fall back to plain-text delivery.
  • Tests

    • Added tests verifying the disable flag behavior and the no-op sender's nil-safe, no-error contract.

Review Change Stack

cristim added 2 commits May 11, 2026 21:28
…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
@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/xs Trivial / one-liner type/bug Defect labels May 11, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 11, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 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: eadc1df7-e611-4a6d-852b-a23f5d0cd61b

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

The 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.

Changes

Email Disabling with No-op Sender

Layer / File(s) Summary
No-op Sender Implementation
internal/email/nop_sender.go
Defines NopSender with NewNopSender() and implements all SenderInterface methods. Each method suppresses delivery by returning nil and emits debug logs with only limited non-PII metadata. Includes compile-time interface satisfaction check.
Factory Early-Exit Logic
internal/email/factory.go
Adds strconv import and updates NewSenderFromEnvironment to read EMAIL_ENABLED; when set and parsed to false it logs and returns NewNopSender() early. Unparseable values emit a warning and continue normal provider detection.
Template Error Logging
internal/email/templates.go
Adds pkg/logging import and changes sendPurchaseApprovalRequestVia to log a logging.Warnf if HTML template rendering fails; preserves non-fatal text-only fallback (HTML body set to "").
Tests
internal/email/factory_test.go, internal/email/nop_sender_test.go
Adds tests verifying EMAIL_ENABLED gating behavior in the factory and that NewNopSender() methods all return nil and are nil-safe; also runtime interface assignment check.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • LeanerCloud/CUDly#298: Related prior changes to the email sender interface and HTML render fallback handling.

Suggested labels

severity/low, urgency/this-quarter, effort/s

Poem

🐰 A silent sender hops in place,
Debugging traces, no outbound race.
EMAIL_ENABLED whispers "no",
Factory returns the quiet show.
Templates warn when HTML fails—soft grace.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and specifically summarizes the main change: adding a short-circuit to a no-op sender when EMAIL_ENABLED=false, with reference to the closed issue.
Linked Issues check ✅ Passed All objectives from issue #332 are met: EMAIL_ENABLED=false triggers no-op sender (factory.go), all 15 SenderInterface methods implemented with compile-time assertion (nop_sender.go), debug-level logging implemented, HTML render fallback logs warning (templates.go), and tests verify all requirements.
Out of Scope Changes check ✅ Passed All changes are within scope: factory.go reads EMAIL_ENABLED and returns NopSender, nop_sender.go implements the no-op type, templates.go adds logging as a bundled follow-up per #332, and test files validate both.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/email-nop-sender

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between c95a6ac and a553b96.

📒 Files selected for processing (3)
  • internal/email/factory.go
  • internal/email/nop_sender.go
  • internal/email/templates.go

Comment thread internal/email/factory.go Outdated
Comment thread internal/email/nop_sender.go Outdated
… 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
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 11, 2026

@coderabbitai — addressed both actionable items from review 4266799238 in commit 46c0609b7:

  1. EMAIL_ENABLED parsing — switched to strconv.ParseBool so the full false-y spelling set is honoured (false/False/FALSE/0/f/F). Unset/empty keeps the default (enabled) so existing deployments are unaffected; unparseable values emit a Warnf and fall through to enabled, so a misconfiguration is visible in logs without bringing the app down.

  2. PII redaction in no-op logs — removed recipient emails, subjects, and embedded admin emails from every NopSender.* log line. Replaced with method-name + recipient counts (to=1 cc=3) — enough trace context to know which path was invoked without leaking who it would have gone to. Documented the policy in the NopSender type doc comment so a future maintainer doesn't add addresses back. Considered using the existing redactEmail helper in internal/auth/service_password.go but it's package-private and even a masked address is gratuitous for a no-op sender.

Verification: go build ./... clean, go test ./... clean (0 failures), go test ./internal/email/... clean.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

@cristim — thanks for the thorough follow-up. Both changes look well-considered:

  1. strconv.ParseBool for EMAIL_ENABLED — idiomatic Go, covers the full boolean spelling set, and the warn-then-fall-through behaviour for unparseable values is the right trade-off (visible misconfiguration without a hard crash). The "unset = enabled" default preserves backward compatibility for existing deployments.

  2. PII redaction in no-op logs — replacing email addresses and subjects with to=N cc=N counts is exactly the right level of detail for a dev/trace path: enough to confirm which code branch fired, nothing that leaks PII into log sinks. Documenting the policy in the NopSender type comment is a nice touch to guard against future regressions.

Triggering a fresh review of the updated commit 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.

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)
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 11, 2026

Added unit tests in commit b04594e:

  • TestNewSenderFromEnvironment_EmailEnabled — 15 sub-cases covering every branch (6 false-y spellings short-circuit to NopSender with SECRET_PROVIDER=env to prove the short-circuit fires before dispatch; 6 true-y spellings fall through to AWS sender; unset/empty fall through; unparseable warns + falls through).
  • TestNopSender_AllMethodsReturnNil — exercises every one of the 15 SenderInterface methods on NopSender.
  • TestNopSender_NilSafe — nil/empty inputs don't panic.
  • TestNopSender_NilContext — nil context doesn't panic.
  • TestNopSender_SatisfiesInterface — runtime echo of the compile-time guard.

go test ./... clean. @coderabbitai review

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 11, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 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.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

@cristim — the test suite looks thorough and well-structured:

  • TestNewSenderFromEnvironment_EmailEnabled with 15 sub-cases is exactly the right approach — pinning every false-y/true-y spelling plus the unset/empty/unparseable edge cases to concrete assertions.
  • TestNopSender_NilSafe and TestNopSender_NilContext are good defensive additions; nil-input panics in no-op paths are a common footgun that often gets missed.
  • TestNopSender_SatisfiesInterface as a runtime echo of the compile-time guard is a nice belt-and-suspenders touch.

Triggering a fresh review of the updated commit 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.

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

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between 46c0609 and b04594e.

📒 Files selected for processing (2)
  • internal/email/factory_test.go
  • internal/email/nop_sender_test.go

Comment thread internal/email/factory_test.go
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

Note

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

⚠️ Branch updated during autofix.

The branch was updated while autofix was in progress. Please try again.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 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 1 file(s) based on 1 unresolved review comment.

Files modified:

  • internal/email/factory_test.go

Commit: b415e7e5485be882742094bc944dbe1a0920a105

The changes have been pushed to the feat/email-nop-sender branch.

Time taken: 2m 17s

coderabbitai Bot and others added 2 commits May 11, 2026 20:04
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>
@cristim cristim merged commit 80ef684 into feat/multicloud-web-frontend May 11, 2026
4 of 5 checks passed
cristim added a commit that referenced this pull request May 11, 2026
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.
cristim added a commit that referenced this pull request May 11, 2026
…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.
cristim added a commit that referenced this pull request May 12, 2026
…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 a commit that referenced this pull request May 12, 2026
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.
cristim added a commit that referenced this pull request May 12, 2026
…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 a commit that referenced this pull request May 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xs Trivial / one-liner impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant