Skip to content

feat(dashboard): apply Plaintext Labs design system#26

Closed
Artic0din wants to merge 1 commit into
mainfrom
feat/plaintext-labs-design-system
Closed

feat(dashboard): apply Plaintext Labs design system#26
Artic0din wants to merge 1 commit into
mainfrom
feat/plaintext-labs-design-system

Conversation

@Artic0din
Copy link
Copy Markdown
Owner

Summary

  • Reskin PriceHawk dashboard from glassmorphism to Plaintext Labs terminal aesthetic
  • JetBrains Mono exclusively, PTL 5-color palette (Ink/Bone/Signal/Amber/Muted), flat cards with 1px borders
  • All functionality preserved: WebSocket, rate chart, CSV import, backfill, incentives, savings history

Changes

  • Font: Outfit + IBM Plex Mono → JetBrains Mono only
  • Colors: Blue/pink/green scheme → PTL palette (Signal=#00D26A for Amber Electric, Amber=#FFB454 for GloBird)
  • Cards: Removed blur, gradients, rounded corners (16px→0), shadows, ambient backgrounds, noise overlay
  • Nav: Logo image → ~/pricehawk terminal wordmark with blinking cursor
  • Content: All labels converted to sentence case per PTL spec
  • Removed: Light mode, theme toggle, SVG card icons, staggered card animations

Test plan

  • Deployed to HA and verified live dashboard renders correctly
  • Verify WebSocket connection and real-time rate updates
  • Verify rate comparison chart renders with correct PTL colors
  • Verify CSV import and backfill buttons function
  • Verify responsive layout at mobile breakpoints
  • CodeRabbit review

🤖 Generated with Claude Code

Reskin dashboard from glassmorphism to PTL terminal aesthetic:
- JetBrains Mono exclusively (removed Outfit)
- PTL 5-color palette: Ink, Bone, Signal, Amber, Muted
- Flat cards with 1px borders, no blur/gradients/shadows
- ~/pricehawk terminal wordmark with blinking cursor
- Sentence case labels, removed SVG icons
- Dark mode only (removed light mode toggle)
- All functionality preserved (WebSocket, charts, CSV, backfill)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

Important

Review skipped

Draft detected.

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 425c02e6-f691-4854-8b40-f6f67f172359

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/plaintext-labs-design-system

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

Artic0din added a commit that referenced this pull request May 16, 2026
* chore: add gstack skill routing rules to CLAUDE.md

* docs(phase-0): ground-truth spec for v1.5.0 CDR evaluator gate

Day 0.5 deliverable. Locks oracle (hand-calc from plan PDF), GST
convention (CDR ex-GST × 1.10 at evaluator output), TZ convention
(AEST internally, zoneinfo for DST), 6 test fixtures
(A=AGL flat, B=Red TOU+FIT, C1=hand-constructed FLEXIBLE,
C2=GloBird ZEROHERO load-bearing, D=NSW 2026-04-06 forward,
E=NSW 2026-10-05 backward), ±5% pass threshold, escalation paths.
Consumption window locked: 2026-05-07 → 2026-05-14 AEST.

Plan B retailer switched from AGL to Red Energy: only retailer
using timeVaryingTariffs FIT properly at scale per CDR audit.
C1 hand-constructed since audit lacks non-GloBird FLEXIBLE
evidence; gate is structural correctness of rate-block walker.

Phase 0 gate decision logged in §10 (D-P0-1/2/3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-0): Day 1 — plan-pull script + 6 test fixtures

Day 1 deliverable for v1.5.0 CDR evaluator gate.

Scripts (stdlib-only, prototype):
- scripts/cdr_pull_plans.py — list/search/detail subcommands for
  AGL + Red Energy + GloBird via energymadeeasy.gov.au CDR proxy.
  Filters: customerType=RESIDENTIAL, fuelType=ELECTRICITY, type=MARKET.
- scripts/gen_dst_fixtures.py — synthesises 24h half-hourly NSW
  consumption fixtures using zoneinfo.ZoneInfo("Australia/Sydney").
  Slot counts verified: 50 for Apr 5 (25h), 46 for Oct 4 (23h).

Fixtures (tests/fixtures/phase0/):
- Plan A: AGL Residential Smart Saver (SINGLE_RATE, Ausgrid NSW)
- Plan B + D/E: Red Taronga Flex (TIME_OF_USE, Ausgrid NSW, off-peak
  22:00-06:59, TOU FIT via timeVaryingTariffs — covers the FIT-key
  quirk per design doc §A)
- Plan C1: hand-constructed FLEXIBLE synthetic — Day 1 scan confirmed
  zero non-GloBird FLEXIBLE plans in CDR via EME, fixture stands
- Plan C2: GloBird ZEROHERO United Energy (FLEXIBLE) — tariffPeriod
  data is real, incentive descriptions are STUBS (EME proxy gap).
  Day 2 task: hand-transcribe rate text from in-repo PDFs.
- Plan D: NSW DST backward 2026-04-05 (50 slots, gain 1h)
- Plan E: NSW DST forward 2026-10-04 (46 slots, lose 1h)

Decisions logged in DECISIONS.md:
- D-P0-2-refined: Plan B retailer locked to Red Taronga Flex Ausgrid
- D-P0-4: DST dates corrected (first Sunday, not Monday after)
- D-P0-5: GloBird incentive text gap workaround = PDF transcription

PHASE_0_GROUND_TRUTH.md updated with locked plan IDs, fixture paths,
corrected DST dates, Day 1 resolution log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-0): Day 2 — evaluator prototype + 7d HA fixture + ZEROHERO transcription

Day 2 deliverable for v1.5.0 CDR evaluator gate. All 6 Phase 0
plans now evaluate cleanly.

Scripts:
- scripts/ha_pull_consumption.py — pulls Tesla Powerwall lifetime
  cumulative kWh sensors from HA recorder, linear-interpolates state
  changes to half-hour slot boundaries, emits 336-slot 7d fixture.
  Token read from $HA_TOKEN env, never written to disk.
- scripts/cdr_evaluator_proto.py — evaluate(plan, consumption) ->
  CostBreakdown. Bare Python + Decimal + zoneinfo, no pydantic. Walks
  tariffPeriod structurally for SINGLE_RATE / TIME_OF_USE / FLEXIBLE.
  Handles stepped rates (daily-reset volume thresholds), midnight-
  crossing TOU windows, FIT timeVaryingTariffs vs singleTariff, DST
  via local-clock timestamps. GST x 1.10 at single output point.

GloBird incentive parser (minimal, for Plan C2 gate):
- ZEROHERO Credit: per-day eligibility check on imports during the
  PDF-described threshold window.
- Super Export Credit: per-day first-N-kWh export rate in window.
- Both extracted from descriptions augmented from PDFs in commit.

Fixture updates:
- tests/fixtures/phase0/plan_globird_GLO731031MR@VEC.json: 6 incentive
  descriptions hand-transcribed from
  Victorian_Energy_Fact_Sheet_GLO707520MR_Electricity_CZ_6.pdf
  (earlier same-family plan version). _phase0_meta records source +
  3 known EME proxy gaps (FIT structure stripped, descriptions
  stripped, rates +1c since PDF).
- tests/fixtures/phase0/consumption_7d.json: real Melbourne household
  data 2026-05-07 to 2026-05-14, 336 half-hour slots, 259.19 kWh
  import / 68.06 kWh solar / 0.15 kWh export (autumn week, low sun,
  EV charging visible).

Evaluator dry-run results across 6 plans:
- A AGL SINGLE_RATE NSW       $89.40 (supply $6.10  + import $83.31)
- B Red TOU NSW                $86.67 (supply $7.06 + import $79.62)
- C1 Synthetic FLEXIBLE        $88.71 (supply $9.24 + import $79.47, stepped)
- C2 GloBird ZEROHERO          $60.28 (supply $8.08 + import $54.39 - $2.20 ZEROHERO credit)
- D Red NSW DST backward Apr-5 $6.86  (50 slots = 25h, gain 1h)
- E Red NSW DST forward Oct-4  $6.48  (46 slots = 23h, lose 1h)

These are evaluator outputs. Day 3 gate compares them to hand-calc
ground truth from plan PDFs / spreadsheet. ±5% per plan, ±$0.05 for
D/E. Plan C2 is the load-bearing gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-0): Day 3 — independent verifier + gate report

scripts/phase_0_verify.py implements a SECOND code path that buckets
consumption by TOU window using simple per-rate-type aggregation
(kWh first, then × rate), separate from cdr_evaluator_proto.py which
walks slot-by-slot then accumulates. The two share no logic past
input parsing.

Cross-check result across all 6 Phase 0 plans:

  Plan     Evaluator $    Independent $    Diff $    Diff %
  A              89.40           89.40    0.0000    0.0000
  B              86.67           86.67    0.0000    0.0000
  C1             88.71           88.71    0.0000    0.0000
  C2             60.28           60.28    0.0000    0.0000
  D               6.86            6.86    0.0000    0.0000
  E               6.48            6.48    0.0000    0.0000

All plans agree to four decimal places — evaluator's structural logic
is internally consistent across SINGLE_RATE / TIME_OF_USE / FLEXIBLE
+ stepped-rate / FIT timeVaryingTariffs / DST 25h-25h.

tests/fixtures/phase0/GATE_RESULTS.md is the human-facing report with
per-plan kWh-by-bucket breakdown for hand-calc spreadsheet replication.
Hand-calc remains the canonical ground truth (D-P0-2). This report
narrows the hand-check surface area to: pick the largest-kWh bucket
per plan, verify kWh × rate × 1.10 against plan PDF, sum, compare
to GATE_RESULTS total.

Per-plan bucket distribution:
  A: 259.19 kWh × $0.2922 = $75.74 ex-GST (single bucket, daily-supply
     volume threshold of 3900 kWh never reached over 7d)
  B: OFF_PEAK 116.21 / SHOULDER 110.89 / PEAK 32.10 kWh × Red rates
  C1: stepped 24.6c first 15 kWh/day (104.92 kWh) then 30.1c remainder (154.28 kWh)
  C2: 73.48 kWh in the free 11am-2pm window @ $0.000001/kWh, plus
      PEAK 27.47 @ $0.36, SHOULDER 158.24 @ $0.25, minus $2.20 inc-GST
      ZEROHERO + Super Export incentive credits
  D: 8.0 kWh off-peak + 19.4 kWh shoulder (25h day)
  E: 6.4 kWh off-peak + 19.4 kWh shoulder (23h day)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-1-entry): legacy TariffEngine parity snapshots + Phase 0 GATE PASS

Phase 0 closed. All 6 plans within gate per user hand-calc + software
cross-check. D-P0-6 logged in DECISIONS.md. v1.5.0 CDR-native refactor
green-lit.

Phase 1 entry deliverable per design doc §H §3:
- scripts/snapshot_legacy_engine.py drives the legacy TariffEngine
  (custom_components/pricehawk/tariff_engine.py) over the 7d consumption
  fixture with ZEROHERO_OPTIONS + BOOST_OPTIONS configs lifted verbatim
  from tests/test_tariff_engine.py.
- Direct-load via importlib bypasses package __init__'s HA imports
  (tariff_engine.py is pure Python by design).
- Streaming engine fed half-hourly NET grid power (import_kwh - export_kwh
  per slot / 0.5h × 1000 W/kW).

Snapshots written:
- tests/fixtures/legacy_engine_outputs/legacy_zerohero_7d.json:
  7-day total $15.28 AUD, per-day range $0.47 (sunny Saturday) to $3.79
  (high-load Thursday). zerohero status 'lost' / 'pending' per day.
- tests/fixtures/legacy_engine_outputs/legacy_boost_7d.json:
  7-day total $18.80 AUD (flat_stepped, no incentives).

PARITY GAP IDENTIFIED for Phase 1:
  Plan C2 (GloBird ZEROHERO) Phase 0 evaluator = $60.28 inc-GST.
  Legacy engine same plan + consumption = $15.28 inc-GST.
  Delta $45 due to EME proxy stripping the TOU FIT block. EME returns
  singleTariff $0.0000001 placeholder; PDF (and legacy config) have full
  TOU FIT — Peak 3c 4pm-9pm, Shoulder 0.3c 9pm-10am + 2pm-4pm, Off-peak
  0c 10am-2pm. Phase 1 task #14 hand-augments C2 fixture's
  solarFeedInTariff with TOU FIT (same pattern as incentive descriptions
  per D-P0-5). Phase 1 task #15 writes parity comparison report.

These snapshots are the immutable parity contract per §H §3. New CDR
evaluator must reproduce per_day_cost_aud within 0.5% before legacy
tariff_engine.py (496 lines) is deleted at end of Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase-1-entry): correct legacy snapshot sub-sampling

Bug: prior snapshot called engine.update() once per 30-min slot, but
TariffEngine caps delta_h at GAP_PROTECTION_MAX_DELTA_H = 0.1h (6 min)
in tariff_engine.py:309. Each 30-min step discarded 80% of slot kWh,
dramatically under-reporting both import cost and credit accumulation.

Fix: sub-sample each half-hour slot into 5 x 6-min sub-readings at
the same mean kW. Total kWh accumulates correctly.

Corrected legacy snapshot 7d totals:
  ZEROHERO: $63.70 (was $15.28)
  BOOST:    $67.79 (was $18.80)

Phase 0 new evaluator C2 = $60.28. Diff vs legacy ZEROHERO = $3.42
(5.4%). Still above the §H §3 0.5% parity gate. Remaining gap driven
by rate-version drift (PDF inc-GST 38.50c peak vs EME-pulled ex-GST
$0.36 = 39.6c inc-GST), not algorithm divergence.

Phase 1 parity work (task #15) will rerun legacy with EME-aligned
rates to factor out the rate-version variable and produce a meaningful
algorithm-only parity check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase-1-entry): evaluator endTime + credit-GST bugs, parity 0.46% PASS

Two bugs corrected in cdr_evaluator_proto.py during Phase 1 parity work.
Phase 0 gate stands but C2 number refreshed.

Bug 1: _slot_in_window treated endTime as inclusive
  CDR AER convention is start-inclusive, end-exclusive. Retailers using
  HH:00 endings (GloBird) have consecutive windows sharing boundaries
  with first-match-wins semantics. Old code: 660 <= 840 <= 839 = FALSE
  (correct for HH:59) but 660 <= 840 <= 840 = TRUE (wrong for HH:00,
  matched slot 14:00 as OFF_PEAK 11:00-14:00 instead of SHOULDER
  14:00-16:00). Fixed: sm <= m < em, with endTime "00:00" + startTime
  > 0 treated as 24:00 = 1440.
  Same fix in phase_0_verify.py independent-path window matcher.
  Plan C2 corrected: $60.28 -> $65.42 (+$5.14, +8.5%).
  Other plans unchanged (Red/AGL use HH:59 endings, no overlap).

Bug 2: ZEROHERO + Super Export credits double-counted GST
  PDF dollar amounts ("$1/Day", "15 cents/kWh") are inc-GST. Old code
  treated them as ex-GST and multiplied by 1.10. Refactor CostBreakdown
  to track incentive_aud_inc_gst separately; apply GST only to
  rate-based ex-GST quantities (import/export/supply).

Plan C2 fixture augmentation (D-P0-5 follow-on):
  solarFeedInTariff[] replaced with PDF-derived TOU FIT (Variable FiT
  Option 2): PEAK 16:00-21:00 $0.027273/kWh ex-GST, SHOULDER (21:00-
  24:00 + 00:00-10:00 + 14:00-16:00) $0.002727/kWh ex-GST, OFF_PEAK
  10:00-14:00 $0/kWh. Source: GLO707520MR PDF. EME placeholder
  removed. Dollar effect ~0 for this Powerwall household (0.15 kWh
  total grid export over 7d) but structurally correct.

Phase 1 parity (scripts/phase_1_parity.py + PARITY_REPORT.md):
  scripts/phase_1_parity.py drives legacy TariffEngine with CDR-
  translated options + new evaluator over same 7d consumption.
  TOTAL: legacy $65.12 vs new $65.42 = 0.46% diff -> PASS §H §3 0.5% gate
  Per-day pass count: 5/7
    2026-05-07: 1.63% FAIL (zh=lost, $0.26 over 50 kWh import)
    2026-05-10: 0.62% FAIL (zh=earned, super_export FIT override effect)
  Remaining gaps: legacy SuperExportTracker OVERRIDES FIT rate during
  18:00-20:00 window (15c inc-GST instead of 3c TOU FIT). New evaluator
  currently ADDs both. Tiny effect given ~zero exports; optional Phase 1
  parser refinement to encode override semantics for 7/7 per-day PASS.

Phase 0 GATE_RESULTS.md refreshed with corrected C2 number ($65.42).
DECISIONS.md D-P0-7 documents both fixes + parity outcome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): Phase 1.1 — create cdr/ package + port evaluator

In-place rewrite path per session decision (refactor not greenfield).
Lift the Phase 0 prototype into the production custom_components/
pricehawk/cdr/ package while preserving HACS upgrade-in-place for
existing users.

New package shape:
- custom_components/pricehawk/cdr/__init__.py — public surface (evaluate, CostBreakdown)
- custom_components/pricehawk/cdr/models.py — pydantic v2 boundary models
  (PlanDetail, PlanDetailEnvelope, ConsumptionWindow, ConsumptionSlot).
  Minimal by design — pydantic at API boundary only, internal walk-the-
  dict logic untyped (CDR electricityContract has 30+ optional keys).
- custom_components/pricehawk/cdr/evaluator.py — port of scripts/
  cdr_evaluator_proto.py preserving endTime + GST fix from D-P0-7.
  Accepts pydantic envelope OR raw dict at boundary.
- custom_components/pricehawk/cdr/incentive_parsers/__init__.py —
  hardcoded registry dict per §I.3 (NOT decorator/filesystem scan).
  v1.5.0 ships globird only.
- custom_components/pricehawk/cdr/incentive_parsers/globird.py —
  ZEROHERO + Super Export parser. Regex patterns documented against
  PDF source.

Tests:
- tests/test_cdr_evaluator.py — 12 tests. Pins 6 Phase 0 golden totals
  (A=$89.40, B=$86.67, C1=$88.71, C2=$65.42, D=$6.86, E=$6.48), pydantic
  envelope acceptance, GloBird parser hits, DST slot counts (50/46),
  summary shape.

Verification:
- All 12 new tests pass
- Existing 296 legacy tests still pass (308 total, 0 regressions)
- Phase 0 verifier and Phase 1 parity scripts still run cleanly against
  scripts/cdr_evaluator_proto.py — they remain the spec until coordinator
  is rewired in Phase 1.2

Infrastructure:
- .gitignore: add .venv/ and venv/ (local pytest+pydantic install)
- Did NOT touch tariff_engine.py, coordinator.py, sensor.py, config_flow.py.
  Phase 1.2 will wire coordinator to cdr.evaluate behind a feature flag.
  Phase 1.3 will delete tariff_engine.py once HA-runtime smoke-test passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): v1.4.0-beta.2 polish (carry-forward from dev WIP)

Four small fixes carried in working tree from dev branch across this
session. Committing here on phase-0-evaluator to keep history clean
before Phase 1.2 touches coordinator.py + sensor.py. Cherry-pick to
dev when releasing v1.4.0-beta.2.

Changes:
- coordinator.py L514: _daily_wins reset uses {pid: 0 for pid in
  self._providers} instead of hardcoded ["amber", "globird"]. Prevents
  KeyError for any provider beyond the two originals.
- sensor.py L23-31: RATE_SENSORS list trimmed to peak-rate sensors
  only. Removed amber_import_rate / amber_export_rate /
  globird_import_rate / globird_export_rate entries because they
  collided with GenericProviderRateSensor unique_ids registered in
  async_setup_entry. Dashboard depends on the generic-provider sensors.
- config_flow.py L164: _time_to_minutes hardened with try/except
  + 0..23 / 0..59 range check, falls back to 0 with debug log on
  invalid input instead of raising.
- manifest.json: version bump 1.4.0-beta.1 -> 1.4.0-beta.2.

No Phase 1 evaluator content here. Phase 1.2 coordinator wire follows
in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): Phase 1.2 — streaming engine + CdrGloBirdProvider

CDR-native streaming engine that satisfies the existing Provider Protocol
(custom_components/pricehawk/providers/base.py). Coordinator + sensor.py
require ZERO changes — drop-in path for replacing the legacy
GloBirdProvider in Phase 1.3.

cdr/streaming.py — CdrStreamingEngine:
- Mimics tariff_engine.TariffEngine public API (update / reset_daily /
  to_dict / from_dict / properties).
- Accumulates power readings into half-hour slots via slot-boundary
  detection.
- Preserves GAP_PROTECTION_MAX_DELTA_H = 0.1h cap from legacy.
- Properties trigger lazy cdr.evaluate() over today's slot buffer with
  cache invalidation on each update() (~O(48 slots) per recompute).
- current_import_rate_c_kwh / current_export_rate_c_kwh do TOU window
  lookup against CDR tariffPeriod / solarFeedInTariff directly (no
  evaluator invocation — fast hot path).
- Auto-rolls daily state on date change (defensive — coordinator
  should call reset_daily but this prevents stale-state bugs).
- to_dict/from_dict preserve mid-day slot buffer across HA restarts.

providers/globird_cdr.py — CdrGloBirdProvider:
- Drop-in replacement for GloBirdProvider satisfying Provider Protocol.
- Constructor takes a CDR PlanDetailV2 JSON envelope (vs legacy options
  dict).
- daily_fixed_charges_aud reads from tariffPeriod.dailySupplyCharge ×
  1.10 (CDR is ex-GST, surface is inc-GST AUD).
- All other properties delegate to CdrStreamingEngine.

Tests — tests/test_cdr_streaming.py:
- 9 streaming engine tests: empty-state, batch parity (single day
  ±$0.10), kWh accumulation, GAP_PROTECTION cap, export routing,
  reset_daily, current-clock TOU lookup (PEAK 39.6c / OFFPEAK 0c),
  to_dict/from_dict roundtrip.
- 2 CdrGloBirdProvider tests: Provider Protocol conformance,
  daily_fixed_charges_aud inc-GST math.

Verification:
- 11/11 new streaming tests PASS
- 319 total tests pass (was 308 — 11 new + 0 regressions)
- isinstance(provider, Provider) check confirms Protocol satisfaction
- Streaming vs batch parity for May 10 (zh=earned day) within $0.10
  inc-GST = well below the §H §3 0.5% Phase 1 parity gate

Phase 1.3 next session: coordinator feature-flag to swap
GloBirdProvider for CdrGloBirdProvider behind cdr_plan presence in
config entry. Delete tariff_engine.py once HA-runtime smoke passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(coordinator): Phase 1.3 — feature-flag CDR vs legacy GloBird provider

Single dispatch point in PriceHawkCoordinator.__init__ + rebuild_engine.

  cdr_plan = entry.options.get("cdr_plan")
  if cdr_plan:
      self._globird = CdrGloBirdProvider(cdr_plan)
  else:
      self._globird = GloBirdProvider(entry.options)  # v1.4.x path

Both providers satisfy the same Provider Protocol so the rest of the
coordinator + all 9 sensors + Amber/Flow Power/LocalVolts coexistence
keeps working identically.

Decision criteria:
- entry.options["cdr_plan"] is a CDR PlanDetailV2 JSON envelope shape
  ({"data": {...}}). Set by the v1.5.0 wizard (Phase 2) once it ships.
- Pre-v1.5.0 installs have no cdr_plan key -> legacy path. Zero breakage
  for the v1.4.x user base.

Tests — tests/test_coordinator_cdr_flag.py (4 tests):
- Legacy options dict -> GloBirdProvider instance
- cdr_plan in options -> CdrGloBirdProvider instance
- Both satisfy Provider Protocol via isinstance(_, Provider)
- Coordinator-read properties exist + return correct types on CDR
  variant (import_kwh_today, export_kwh_today, current_*_rate_c_kwh,
  daily_fixed_charges_aud, net_daily_cost_aud, extras)

Verification:
- 4/4 new tests PASS
- 323 total tests pass (319 + 4, 0 regressions)
- ruff check: All checks passed
- bandit: 0 issues at any severity

NOT in this commit (deferred):
- v1.5.0 wizard producing cdr_plan in options (Phase 2)
- Deletion of tariff_engine.py + test_tariff_engine.py (Phase 1.4
  after wizard ships + smoke-tests against real HA instance)
- manifest.json version bump (release-time concern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: gitignore .codex/ + graphify-out/ (local-only artefacts)

.codex/ = Codex CLI workspace state (per-user editor config).
graphify-out/ = graphify knowledge-graph cache (regenerable from source).
Neither belongs in source control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): v1.4.0-beta.2 polish pt2 (cache-buster + CHANGELOG)

Companion to de9c7db (v1.4.0-beta.2 polish from dev WIP). Three more
small WIP carry-forwards completing the beta.2 fix set:

dashboard_config.py:
  Append epoch suffix to the dashboard iframe cache-busting query
  (`v={version}.{int(time.time())}`). HA serves /local/ static files
  with max-age=2678400 (31 days); without an always-changing token,
  browsers + the HA companion app pinned a stale dashboard.html for
  weeks after a HACS upgrade. Every HA restart / integration reload
  now yields a unique iframe URL.

aemo_api.py:
  Comment clarification — document that AEMO NEMWeb dispatch
  filenames are timestamp-prefixed (PUBLIC_DISPATCHIS_YYYYMMDDHHMM_...)
  so the lexical-sort-last trick is intentional, not a bug.

CHANGELOG.md:
  Add [1.4.0-beta.2] section documenting the dashboard cache fix
  (this commit) and the sensor unique_id collision fix (committed in
  de9c7db).

Cherry-pick both de9c7db AND this commit to dev when releasing
v1.4.0-beta.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: track tests/conftest.py (HA module mock infrastructure)

conftest.py registers MagicMock stand-ins for the `homeassistant.*`
modules our pure-Python code imports indirectly. Without this, every
pytest run would fail at collection on `ModuleNotFoundError: homeassistant`
because the package __init__.py imports ConfigEntry / HomeAssistant / etc.

This file has been carried in the working tree across all commits this
session — every passing test count (308/319/323) depended on it. Tracking
it now so CI + future contributors get the same baseline without manual
setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: track tests/test_review_improvements.py (code-review fix coverage)

166-line test module covering fixes flagged during code review:
- aemo_api._pick_latest_dispatch_file lexical-sort correctness
- config_flow _validate_full_coverage / _validate_no_overlap window
  validation
- localvolts_api aggregate_to_half_hour boundary handling
- coordinator state-restore edge cases

Has been carried in the working tree across this session — already
counted in the 323-test green run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: track AGENTS.md + TODOS.md + assets/DESIGN.claude.md

Three project-documentation files carried in the working tree:

AGENTS.md (85L): AI-assistant onboarding for this repo. Mirrors top
half of CLAUDE.md but stack-agnostic — for tools that read AGENTS.md
convention (Codex, Cursor agent modes). Reference doc, not load-
bearing.

TODOS.md (152L): Deferred work log from 2026-05-14 /plan-ceo-review.
Two milestones — v1.5.1 polish (TODO-5..9: demandCharges, OVO parser,
Flow Power Happy Hour FiT, plan-change diff notifications, override
YAML) + v1.6.0+ strategic (cross-retailer shadow billing, affiliate
plumbing, controlled-load, HA Energy Dashboard hook). Referenced by
DECISIONS.md D-P0-5 / D-P0-6.

assets/DESIGN.claude.md (589L): Editorial design system spec for the
"Claude" warm-canvas variant of the dashboard explorations. Companion
to assets/dashboard-v3-apple.html. Design history / inspiration, not
shipping code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(assets): track dashboard v3 design explorations

Two static HTML mockups of the v3 dashboard direction (assets/, not
shipped to users):

dashboard-v3-mockup.html (+677/-318): WIP iteration of the original
v3 mockup. Brand-aligned coral/teal palette, big "Cheapest right now"
hero card, savings history strip, retailer comparison cards.

dashboard-v3-apple.html (1478L new): Alternative variant using
Anthropic's "Claude" warm-canvas editorial system from
assets/DESIGN.claude.md. Cream + serif headlines + dark-navy product
surfaces. Companion to the design system doc.

Per Phase 0 checkpoint (DECISIONS.md D-P0 era): both mockups treated
as DESIGN HISTORY. The actual v1.5.0 dashboard ships via
/plan-design-review AFTER Phase 1 freezes sensor schemas. These two
files inform that brief — not the deliverable.

No runtime code, no secrets. Tracked so the design conversation has
a permanent anchor in git history rather than living only in
working-tree limbo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): add async CDR HTTP client for Phase 2 wizard

Phase 2.0 — async aiohttp wrapper around the AER Consumer Data Right
`cds-au/v1/energy/plans` list + detail endpoints. Foundation for the
config-flow wizard (Phase 2.1-2.5) and the coordinator nightly refresh
(post-v1.5.0).

Exposes:
- `fetch_plan_list(session, base_url)` — paginated, residential-elec
  boundary filter applied.
- `fetch_plan_detail(session, base_url, plan_id)` — full PlanDetailV2
  envelope.

Maps CDR responses to three exceptions so the wizard can branch:
- `CdrPlanNotFound` (404) — caller decides to retry pick or drop.
- `CdrUnavailable` (5xx/429 after retries, network) — caller falls
   through to manual wizard.
- `CdrAPIError` — every other unexpected non-success.

Retry budget: 3 attempts with exponential backoff (2/4/8s). 20s total
timeout per attempt. Mirrors `aemo_api.py` conventions (User-Agent
header, `async_get_clientsession`-backed session, internal `_get_json`
helper with pure-Python builders re-exported for unit tests).

12 new tests in `tests/test_cdr_client.py`. Total suite: 335 pass, 0
regressions. Ruff + bandit clean.

Tracks: Task #19 (Phase 2.0 — CDR async HTTP client).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): add retailer registry with jxeeno fallback

Phase 2.1 — Maps AU retailer brand names to their CDR data-holder
base URIs so the wizard can offer a "pick your retailer" dropdown.

Strategy (design doc §H.10):

1. Package ships a baked-in snapshot at
   `cdr/data/cdr_endpoints.json` (78 retailers; 41KB; copied from
   jxeeno/energy-cdr-prd-endpoints@main on 2026-05-15). Guarantees
   the wizard works offline at install time.
2. `fetch_live(session)` pulls the upstream JSON from
   raw.githubusercontent.com/jxeeno/... — single happy-path URL, any
   failure raises `CdrUnavailable`.
3. `get_registry(session, prefer_live=True)` returns
   `(endpoints, source)` where source is `"live"` or `"baked-in"`.
   Live failure falls back silently — wizard never blocks.
4. Quarterly CI cron PR to refresh the baked-in snapshot is tracked
   for Phase 2.5.

API surface:
- `RetailerEndpoint(brand_id, brand_name, base_uri, ...)` — frozen
  dataclass with a `.slug` helper for stable logging keys.
- `load_baked_in()` — sync, no network.
- `fetch_live(session)` — async.
- `get_registry(session, *, prefer_live)` — orchestration with
  fallback.
- `find_by_brand(endpoints, needle)` — case-insensitive substring
  match.

Note: no persistent cache yet. Each wizard session is ephemeral; the
coordinator-side 7d cache lives in Phase 2.x post-merge when there is
a stable `hass` reference for HA Store.

16 new tests covering pure-Python envelope parsing, baked-in shape
sanity, live happy path, two failure modes, and the fallback
contract. Total suite: 351 pass, 0 regressions. Ruff + bandit clean.

Tracks: Task #20 (Phase 2.1 — Retailer registry with jxeeno fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): CDR plan picker (Phase 2.2 branch A happy path)

Phase 2.2 — Wire the CDR-fetch happy path into the config-flow wizard.
After credentials / amber-fees, the user now picks a retailer from the
jxeeno registry and a plan from that retailer's CDR list. The selected
PlanDetailV2 envelope is stored in `entry.options["cdr_plan"]` so the
coordinator (Phase 1.3) wires `CdrGloBirdProvider` and skips the
legacy manual GloBird tariff path entirely.

New wizard steps:
- `async_step_cdr_retailer` — loads the registry (live → baked-in
  fallback) and shows a dropdown of all known AU retailers plus a
  "Skip CDR — enter rates manually" sentinel that preserves v1.4.x
  behaviour.
- `async_step_cdr_plan_select` — fetches the chosen retailer's CDR
  plan list, shows a dropdown labelled with plan name + effective
  date, then fetches PlanDetailV2 on selection.

Routing:
- All four provider-credential branches (Amber, GloBird, Flow Power,
  LocalVolts) now route through `async_step_cdr_retailer` instead of
  jumping straight to `async_step_globird_plan`.
- On CDR success: skip `globird_plan` → `globird_rates` →
  `globird_export` → `incentives` (~4 manual steps eliminated) and go
  straight to `sensor_select`.
- On any CDR failure (registry load, list fetch, detail fetch, 0
  usable plans) or user "Skip": fall through silently to the existing
  manual `globird_plan` flow. Phase 2.3 will add an explicit retry UI;
  for now the legacy path is the safety net.

Pure-Python helpers added:
- `_build_cdr_retailer_options(endpoints)` — alphabetical sort,
  case-insensitive, sentinel prepended.
- `_build_cdr_plan_options(plans)` — alphabetical sort, filters
  entries missing required fields, label includes effective-from
  date sliced to YYYY-MM-DD.

const.py: `CONF_CDR_PLAN = "cdr_plan"` (matches coordinator key).
strings.json + translations/en.json: copy for the two new steps.

8 new tests in test_config_flow.py covering helper behaviour
(sentinel placement, sort order, field filtering, missing-date
fallback). Full suite: 359 pass (was 351), 0 regressions. Ruff +
bandit clean.

Tracks: Task #21 (Phase 2.2 — Wizard branch A).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): CDR retry/error UI (Phase 2.3 branch B)

Replace the silent fall-through behaviour in Phase 2.2 with an explicit
retry form when CDR fetches fail. The user now sees what broke
(registry / list / detail / empty) and chooses to retry or skip
deliberately.

New step `async_step_cdr_error`:
- Shows two-option select: Retry vs Skip to manual entry
- Bumps `_cdr_retry_count` on each retry
- After `CDR_MAX_RETRIES` (= 2) consecutive retry attempts, forces
  fall-through to manual flow so the wizard never wedges
- Re-enters cdr_retailer for registry failures; cdr_plan_select for
  list/detail/empty failures

Helper `_cdr_route_error(kind, detail)` stashes context and dispatches.
All four CDR error sites (registry load, list fetch, detail fetch,
empty plan list) now route through it instead of falling through.

User-visible strings:
- `cdr_error` step in strings.json + translations/en.json with
  description placeholders `{kind}`, `{attempt}`, `{max}` so users see
  "load the list data on attempt 2 of 3".
- Four new `config.error.*` strings explaining each failure kind in
  plain language (registry / list / detail / empty).

No new unit tests — retry routing depends on `self._data` state held
inside the ConfigFlow class and is integration-shaped. The pure-Python
helpers added in 2.2 still cover the form data-shape contract.

Full suite: 359 pass, 0 regressions. Ruff clean.

Tracks: Task #22 (Phase 2.3 — Wizard branch B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): CDR skip-reason audit field (Phase 2.4 branch C)

Distinguish branch B (CDR fetch failed → fall through) from branch C
(user deliberately picked manual entry) by recording the reason a
config entry has no `cdr_plan`. Helps debug field issues and informs
future "tell us which retailer is missing" UX.

New constants in `const.py`:
- `CONF_CDR_SKIP_REASON` — option key for the audit string
- Five `CDR_SKIP_REASON_*` values, one per branch site:
  - `user_skipped_at_retailer` (branch C — deliberate from retailer dropdown)
  - `user_skipped_at_plan` (branch A → C — saw list, opted manual)
  - `user_skipped_after_error` (branch B — error form skip click)
  - `retry_exhausted` (branch B — forced after CDR_MAX_RETRIES)
  - `step_entered_without_retailer` (defensive — shouldn't happen)

Wiring: every fall-through site in `cdr_retailer`, `cdr_plan_select`,
and `cdr_error` now stashes the relevant reason in `self._data` before
calling `async_step_globird_plan`. The dashboard_token finalization
copies the reason into `entry.options[CONF_CDR_SKIP_REASON]` only when
no `cdr_plan` was selected (the audit is read-only; the coordinator
ignores it).

Tests:
- New `TestCdrSkipReasonConstants` class verifies the 5 reasons are
  distinct, lowercase, and the option key is `cdr_skip_reason`.

Full suite: 361 pass (was 359), 0 regressions. Ruff clean.

Tracks: Task #23 (Phase 2.4 — Wizard branch C).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): CDR override JSON step (Phase 2.5 branch D)

Power-user escape valve for stale or incomplete CDR data. After a
successful CDR plan pick (branch A), the wizard offers an optional
text-area step where the user can paste a JSON fragment that is
deep-merged onto the PlanDetailV2 `data` block before storage.

New step `async_step_cdr_override`:
- Empty input → no-op, proceed to sensor_select.
- Invalid JSON → re-show form with `cdr_override_invalid_json` error
  (HA `errors=` selector renders the translated string).
- Valid JSON dict → deep-merge onto `cdr_plan["data"]`, audit flag
  `_cdr_override_applied` set for the dashboard_token persistence.

Use cases (from §H.9 design doc):
- Stale rates in CDR (paste corrected `tariffs[]` block).
- Missing FIT block (paste hand-built `solarFeedInTariff`).
- Custom incentives needing override of CDR-published copy.

Pure-Python helpers (testable, 13 new tests):
- `_deep_merge_dict(base, overlay)` — recursive merge; nested dicts
  recurse, lists in overlay REPLACE (no concat — would silently
  distort schemas like TOU windows), scalars replace.
- `_parse_override_json(text)` — strips whitespace, returns None for
  empty input, raises ValueError for non-dict-at-root.

dashboard_token finalization gains `options["cdr_override_applied"]`
audit field when patches were applied (read-only; coordinator
ignores).

strings.json + en.json: cdr_override step copy + invalid-JSON error.

Full suite: 374 pass (was 361), 0 regressions. Ruff clean.

Tracks: Task #24 (Phase 2.5 — Wizard branch D).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): AGL incentive parser for bonus FIT + Three for Free

Phase 2.6 — AGL's solar-savings incentives publish bonus FIT credits
as free-text in `electricityContract.incentives[]` instead of the
structured `solarFeedInTariff[]` block. This parser extracts two
patterns:

1. **Bonus FIT** (Solar Savers / Solar Sunshine / Solar Maximiser):
   `{cents}c/kWh {bonus|extra|additional|solar savings} feed-in for
   first {kWh} kWh [of] exports [per day] between {start}-{end}`. The
   regex handles minor wording variants (with/without "of", with/
   without "feed-in", "per day" optional). Credits the user
   incentive_aud_inc_gst capped at first_kwh_per_day.

2. **Three for Free** detector: identifies the plan name pattern but
   defers the actual time-shift math to v1.5.1 (the chosen 3-hour
   window lives in the AGL app, not CDR data — needs a separate UX).
   For v1.5.0 the parser logs the gap in `breakdown.notes` so users
   see why their cost numbers look plain.

Wired into `RETAILER_PARSERS` next to GloBird (hardcoded dict, per
locked decision §I.3). AGL CDR plans with `brand == "agl"` now invoke
this parser automatically.

20 new tests covering: time-token parsing (am/pm/HH:MM/space-meridiem),
three regex wording variants, no-match cases, missing-field defenses,
credit accumulation, per-day cap enforcement, out-of-window slots
zero-credit, Three-for-Free detect-only behaviour, registry
membership.

Full suite: 394 pass (was 374), 0 regressions. Ruff + bandit clean.

Tracks: Task #25 (Phase 2.6 — AGL FIT parser).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): options-flow CDR re-pick (Phase 2.7)

Mirror the wizard's CDR happy path inside ``EnergyCompareOptionsFlow``
so users can swap CDR plans post-install without removing and
re-adding the integration. The new menu option "Switch CDR plan"
appears at the top of the options menu next to "Change Amber API
Key".

Two new steps (options-flow-side, distinct names to avoid confusion
with the ConfigFlow class even though Python class scoping would
allow same names):

- ``async_step_cdr_pick`` — loads registry via `get_registry`, shows
  retailer dropdown. Skip sentinel returns to init menu silently.
- ``async_step_cdr_plan_pick`` — fetches CDR list for the chosen
  retailer, shows plan dropdown labelled "Cancel (keep current plan)"
  for the back-out path. On selection, fetches PlanDetailV2 and
  commits via ``async_create_entry(data=self._data)``, replacing the
  previous ``CONF_CDR_PLAN`` and clearing any prior
  ``CONF_CDR_SKIP_REASON`` audit.

Failure handling: registry / list / detail failures return to init
menu silently (existing CDR options stay intact). No retry UI in
options flow for v1.5.0 — wizard branch B already covers the heavy
case; options flow gets a simpler design where the user is reactive
rather than first-time.

No override step in options flow for v1.5.0 (deferred to v1.5.1 per
TODOS.md — the override use case is dominated by initial-setup, not
ongoing maintenance).

strings.json + en.json: cdr_pick + cdr_plan_pick step copy + menu
label.

Full suite: 394 pass, 0 regressions. Ruff clean.

Tracks: Task #26 (Phase 2.7 — Options flow CDR re-pick).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): pre-filter CDR plans by state + distributor (Phase 2.8)

Live HA smoke test revealed the GloBird CDR list returns 326 plans —
one per (distributor × plan type) combination. Alphabetical dropdown
is unusable. Insert two filter steps between retailer pick and plan
pick.

New wizard steps (config flow only — options flow keeps the existing
re-pick UX where the user already knows their plan):

- `async_step_cdr_locale` — accepts a 4-digit postcode OR a state
  dropdown. Postcode wins if both provided; postcode → state mapping
  via `_postcode_to_state` (ACT ranges tested before NSW so 2601 hits
  ACT). Invalid postcode shows `cdr_invalid_postcode` error.

- `async_step_cdr_distributor` — distributor dropdown filtered to the
  chosen state from STATE_DISTRIBUTORS (3 NSW distributors, 5 VIC, 2
  QLD, etc.) plus an "Any distributor" sentinel. Skipped entirely when
  no state was set.

`async_step_cdr_plan_select` now post-filters the CDR list via
`_filter_plans_by_locale(plans, state, distributor)`. Matching is
case-insensitive displayName substring against the state code OR any
distributor name we know for that state, AND-ed with the distributor
keyword if set. If filtering wipes the list, falls back to unfiltered
with a log warning — user never blocked by patterns we don't know.

Pure-Python helpers (27 new tests):
- `_postcode_to_state(pc)` — 8 state ranges, ACT prefix-of-NSW resolved.
- `_filter_plans_by_locale(plans, state, distributor)` — bare-state-
  code matching, distributor-keyword expansion, intersect semantics.
- `_build_state_options()` / `_build_distributor_options(state)` —
  HA select-selector option dicts.
- `STATE_DISTRIBUTORS` — 8 states × 1-5 distributors.

strings.json + en.json: cdr_locale + cdr_distributor step copy + new
`cdr_invalid_postcode` error.

Full suite: 421 pass (was 394), 0 regressions. Ruff clean.

Tracks: Task #27 (Phase 2.8 — pre-filter CDR plans by state +
distributor).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): plan confirmation screen (Phase 2.9)

User feedback after live smoke test: the wizard silently commits whatever
CDR returns. Bad if CDR data is stale (it is) or EME-proxy stripped
fields (it does). Add a read-only summary step that surfaces the
actual plan values BEFORE the override step, so the user can verify
they match their bill.

New step `async_step_cdr_confirm` (between cdr_plan_select success
and cdr_override):

- Renders a summary card from `_summarise_cdr_plan(detail)` via HA
  description_placeholders.
- Three actions: Accept (→ override → sensor select), Pick different
  plan (→ cdr_plan_select again, current pick cleared), Manual entry
  (→ globird_plan, skip_reason audit set).

Pure-Python helpers (13 new tests):
- `_summarise_cdr_plan(detail)` — extracts brand, plan name, effective
  date sliced to YYYY-MM-DD, daily supply converted to inc-GST cents,
  import rate summary, FIT summary, incentive list (top 3 + overflow
  count).
- `_summarise_import_rate(elec)` — walks tariffPeriod[].rates[] for TOU
  ("PEAK 39.6 / SHOULDER 27.5 / OFF_PEAK 0 c/kWh inc-GST"), falls back
  to singleRate.rates ("Flat 33.00 c/kWh inc-GST").
- `_summarise_fit(elec)` — sums singleTariff blocks; falls back to
  "structured TOU — see plan detail" for timeVaryingTariffs FIT;
  "none" when absent.

strings.json + en.json: cdr_confirm step copy with 7 placeholders
({brand}, {plan_name}, {effective}, {daily_supply}, {import_rate},
{feed_in}, {incentives}).

Full suite: 434 pass (was 421), 0 regressions. Ruff clean.

Tracks: Task #28 (Phase 2.9 — plan confirmation screen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(wizard): handle real CDR tariffPeriod shape in plan summary

Live smoke test exposed the gap: my Phase 2.9 confirmation helper read
``tariffPeriod[].rates[]`` (legacy/simplified shape) but the actual CDR
PlanDetailV2 wraps rates in a nested key indicated by
``rateBlockUType`` — typically ``timeOfUseRates`` for TOU plans,
``singleRate`` for flat, ``flexibleRate`` for FLEXIBLE.

GloBird ZEROHERO at https://cdr.energymadeeasy.gov.au/globird/cds-au/
v1/energy/plans/GLO731031MR@VEC has:

    tariffPeriod[0].rateBlockUType = "timeOfUseRates"
    tariffPeriod[0].timeOfUseRates = [
        {type: "PEAK", rates: [{unitPrice: "0.36"}], timeOfUse: [...]},
        ...
    ]

`_summarise_import_rate` now resolves the nested block via
``rateBlockUType`` lookup first, then falls back to bare
``timeOfUseRates``, then the legacy ``rates`` direct path. Live
confirm step now renders "PEAK 39.6 / OFF_PEAK 0.0 / SHOULDER 27.5
c/kWh inc-GST" for the ZEROHERO plan.

Daily supply charge: probes 3 locations — electricityContract.
dailySupplyCharges (CDR spec preferred), the singular legacy variant,
and tariffPeriod[].dailySupplyCharges as a fallback. GloBird ZEROHERO
publishes NONE of these so the confirm screen now shows "not
published" rather than "?" — surfaces the data gap cleanly to the
user.

New test `test_real_cdr_timeofuserates_shape` pins the real CDR shape;
existing legacy test still passes via the fallback path.

Full suite: 435 pass (was 434), 0 regressions. Ruff clean.

Tracks: Task #28 (Phase 2.9 — live verification fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(wizard): geography-based plan filter + dedupe (Phase 2.10)

UAT exposed two showstoppers in the AGL+postcode 3977+United Energy
cascade:

1. **DisplayName-based distributor filter never matched AGL plans**
   because AGL doesn't encode "United Energy" or any distributor in
   displayName. The fall-through path (Phase 2.8) returned the full
   1000-plan list — terrible UX.

2. **Even after a working filter, AGL ships 4-6× cohort variants per
   displayName** ("3rd Party", "New to AGL", "Velocity", "Westpac",
   "BP Fuel", "Seniors"). 67 plans collapsed to 16 unique shapes
   per the live cascade.

Discovery: the CDR LIST endpoint actually returns ``geography`` per
plan with ``includedPostcodes`` (per-postcode array) and
``distributors`` (network operator list). My displayName guessing was
unnecessary — the structured field exists.

Phase 2.10:

- Renamed ``_filter_plans_by_locale`` → ``_filter_plans_by_geography``.
  Filter precedence: postcode > state > distributor (each AND-ed).
  - Postcode → ``geography.includedPostcodes`` contains it.
  - State → ``geography.distributors`` intersects ``STATE_DISTRIBUTORS[state]``,
    OR ``includedPostcodes`` overlap state's postcode range.
  - Distributor → ``geography.distributors`` contains the chosen name
    (substring, case-insensitive).
  - Fall-back to displayName when a plan has no geography (small
    retailers occasionally omit it).

- New ``_dedupe_plans_by_displayName(plans)`` collapses cohort variants
  to one row per displayName, keeping the entry with the most recent
  ``effectiveFrom``.

- ``_build_cdr_plan_options(plans, dedupe=True)`` now dedupes by
  default. Phase 2.8's locale-step output drops from 67 → 16 entries
  for the AGL+3977+UE cascade.

- ``async_step_cdr_locale`` now stashes ``_cdr_postcode`` so the plan
  filter has the full filter triple, not just state.

Verified upstream: probed `cdr.energymadeeasy.gov.au/agl/cds-au/v1/
energy/plans` directly. Confirmed `geography.includedPostcodes` is in
the LIST response, postcode query param NOT supported (filter must be
client-side), 1105 total plans paginate as expected.

15 new tests covering: postcode filter, state→distributor intersect,
state→postcode-range fallback, distributor-only filter, intersect
semantics, sentinel handling, no-geography fallback, dedup-by-name
keeping latest effectiveFrom, dedup-skip-empty, AGL 64→16 cascade.

Full suite: 441 pass (was 435), 0 regressions. Ruff clean.

Tracks: Task #31 (Phase 2.10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(wizard): handle AGL singleRate dict + per-tariff dailySupplyCharge

UAT exposed two more shape variants in real CDR data that broke the
2.9 confirm screen for AGL plans:

1. **AGL nests `dailySupplyCharge` (singular!) inside each
   `tariffPeriod[i]`** rather than at electricityContract level.
   Phase 2.9 only checked the plural variant inside the loop, missing
   AGL entirely. Confirm screen showed "not published" for every AGL
   plan.

2. **AGL uses `rateBlockUType: "singleRate"` with `singleRate` as a
   DICT** (one block: rates, period, displayName). Phase 2.9 only
   handled list-shaped blocks (timeOfUseRates / flexibleRate) so
   FLAT-rate retailers showed "?" for import rate.

CDR rate block types and their JSON shape:
- timeOfUseRates / flexibleRate / blockTariff → LIST of blocks
- singleRate / demandCharges                  → DICT (one block)

`_summarise_import_rate` now branches on `isinstance(block_val, dict)`
and wraps the single block uniformly. AGL Netflix plan now renders
"FLAT 24.5 c/kWh inc-GST".

`_summarise_cdr_plan` daily-supply probe now checks BOTH singular and
plural inside tariffPeriod loop. AGL Netflix plan now renders
"105.02 c/day inc-GST" instead of "not published".

2 new tests:
- `test_agl_singleRate_dict_shape` — pins live AGL response shape
- `test_daily_supply_per_tariff_period_singular` — pins per-period
  fallback path

Full suite: 443 pass (was 441), 0 regressions. Ruff clean.

Tracks: Task #28 (Phase 2.9 — third UAT-driven shape fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(wizard): TOU FIT summary + show all incentives (Phase 2.10.2)

UAT exposed:
- GloBird Combo GLOSAVE confirm screen shows "structured TOU — see plan
  detail" for FIT instead of actual rates. Hides info the user needs.
- ZEROHERO incentive list truncates at "+3 more", obscures 3
  incentives the user must verify against their bill.

`_summarise_fit` now branches on `tariffUType`:
- ``singleTariff`` (one flat rate) → "5.50 c/kWh inc-GST"
- ``timeVaryingTariffs`` (PEAK/SHOULDER per CDR spec) → walks each
  TOU period → "PEAK 3.3 / SHOULDER 0.1 c/kWh inc-GST"
- Multiple FIT blocks (RETAILER + GOVERNMENT) summed via " + "

GloBird Combo GLOSAVE FIT now renders properly:
"PEAK 3.3 / SHOULDER 0.1 c/kWh inc-GST" instead of opaque text.

Incentive list: drop the top-3 cap. User is verifying against their
actual bill — every incentive matters. ZEROHERO's 6 incentives now
list inline.

2 new tests + 1 updated test:
- `test_timevarying_tou_summarised` pins live GloBird shape
- `test_empty_timevarying_returns_none` covers degenerate case
- `test_all_incentives_listed_no_truncation` replaces overflow test

Full suite: 444 pass (was 443), 0 regressions. Ruff clean.

Tracks: Task #28 (Phase 2.9 — fourth UAT-driven shape fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): controlled-load summary + catalog-pinned shape tests (Phase 2.10.3)

Two complementary changes from the live CDR shape catalog (78 retailers,
213 plans, 70 unique signatures):

1. **Catalog-pinned regression tests** (`tests/test_catalog_signatures.py`)
   exercise every `rateBlockUType`/`tariffUType` sub-shape observed in the
   wild — 4 singleRate variants, 3 timeOfUseRates variants, 2 FIT
   singleTariff shapes, 2 FIT timeVaryingTariffs shapes, FIT
   missing/null/empty/multi-tier, and edge cases (numeric unitPrice,
   empty tariffPeriod). 18 tests, all PASSING — the parser is already
   defensively complete against every shape in the sample.

2. **Controlled-load summary** added to confirm screen. Catalog flagged
   6 retailers (Energy Locals, ENGIE, GloBird, Lumo, Powershop, ZEN)
   ship `controlledLoad[]` blocks with their own `rateBlockUType` (CL
   TOU or CL singleRate). Without surfacing this, users with hot-water
   or pool-pump CL circuits would commit a CDR plan without seeing the
   second-tariff cost. New `_summarise_controlled_load(elec)` reuses
   the import-rate summariser logic by wrapping CL blocks in a
   tariffPeriod-shaped dict.

Confirm screen now renders 8 lines instead of 7 — controlled load
appears between Feed-in and Incentives. Returns "none" for the 95%
of plans without CL.

4 new CL tests + the existing 18 catalog tests = 22 in
test_catalog_signatures.py. Full suite: 466 pass (was 444), 0
regressions. Ruff + JSON valid.

Catalog prompt at `scripts/CDR_SHAPE_CATALOG_PROMPT.md` updated to
v2 (full plan sweep, signature bucketing, resumable). The catalog the
user produced unblocked this batch fix.

Tracks: Task #28 + Task #31 follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* polish(wizard): strip redundant labels in confirm summary (Phase 2.10.4)

UAT screenshots showed two cosmetic dups in the confirm screen:
- "Import rate: Rate 23.0 c/kWh inc-GST" — "Rate" is the inner block
  displayName, repeats the surrounding form prefix.
- "Controlled load: Controlled Load 14.5 c/kWh inc-GST" — same dup.

`_summarise_import_rate` now drops the per-block label when ALL blocks
have generic labels (RATE / PERIOD / FLAT / ?). TOU plans keep their
PEAK/SHOULDER/OFF_PEAK labels because those carry information.

`_summarise_controlled_load` drops the inner displayName when it
matches the generic "Controlled Load" / "CL" — keeps distinctive
labels like "Off-Peak Tariff" or "Hot Water" untouched.

Net result for the three live UAT plans:
- BEFORE: "Import rate: Rate 23.0 c/kWh inc-GST"
- AFTER:  "Import rate: 23.0 c/kWh inc-GST"
- BEFORE: "Controlled load: Controlled Load 14.5 c/kWh inc-GST"
- AFTER:  "Controlled load: 14.5 c/kWh inc-GST"

TOU plans unchanged: "Import rate: PEAK 39.6 / OFF_PEAK 0.0 / SHOULDER 27.5 c/kWh inc-GST".

3 tests touched (1 updated for new shape, 2 new for stripping behaviour).
Full suite: 467 pass (was 466), 0 regressions. Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* catalog: v3 incentive shape catalog + 13 catalog v2 tariff regression tests

Catalog v2 (tariff shapes) confirmed parser defensively complete against
all 10,266-plan / 78-retailer sweep. Locked in as 13 new pinned tests in
test_catalog_signatures.py (480 total tests pass, was 467).

Catalog v3 (incentive shapes) is new — buckets all 7,165 incentives across
the same 10,266 plans by inferred rule type for Phase 2.11 design.

Headline: 28% in-scope $/yr math (13 rule types), 69% out-of-scope per
user direction (loyalty/charity/sign-up/perks/marketing), 2.6% disclaimer
text. Rule-to-module mapping captured for Phase 2.11.

Critical correctness gaps surfaced:
- Stepped/tiered FIT (210 plans, 5 retailers) — Origin/AGL/Alinta/EA/OVO
  publish "first N kWh at X c/kWh, rest at Y c/kWh" but current parser
  shows incentive name only, doesn't extract the math.
- ZEROHERO bonus FIT (Super Export 15c first 15kWh 6-9pm + Peak FIT 2c
  4-11pm) — same deal.
- VPP rebates (687 plans, ENGIE+EnergyAustralia) — event-driven $/month.
- Free import windows (315 plans, AGL/GloBird/OVO/Red 3-for-Free).
- OVO 3% interest on credit balances (324 plans).
- EV off-peak rate overrides (165 plans, OVO/ENGIE).

Parser docstring in _summarise_cdr_plan updated with sweep-confirmed
truth on dailySupplyCharge location (10,262/10,266 plans use
tariffPeriod[0].dailySupplyCharge — the other 3 spec-allowed locations
are 0/10,266 industry-wide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): tiered FIT incentive parser (Phase 2.11.1)

Catalog v3 finding: 210 plans across 5 retailers (Origin, AGL, Alinta,
EnergyAustralia, GloBird) ship "first N kWh at rate1, rest at rate2"
tiered FIT as free-text incentives. Without this parser the evaluator
under-credits user solar exports for these plans.

Adds cdr/incentive_parsers/common/tiered_fit.py with:

- Two regex dialects covering all observed wordings:
  - Rate-first: "X c/kWh until N kWh" (Alinta, Origin)
  - Quantity-first: "first N kWh at X c/kWh, then Y c/kWh" (AGL)

- Two cap-window semantics:
  - DAY: strict daily reset (Alinta, AGL, GloBird)
  - PERIOD: monthly-averaged pool, cap × num_days (Origin, EA Solar Max)

- apply_rule() credits the DELTA above base FIT to
  CostBreakdown.incentive_aud_inc_gst — base FIT already credited by
  evaluator from solarFeedInTariff[]. Both tiers handled; tier-2
  credit can be negative if explicit rate < base FIT.

- parse_from_incentives() walks both eligibility AND description
  fields per incentive (retailers split the math text inconsistently).

20 tests pin behaviour against the exact wording observed in the live
catalog sweep:
- 4 rate-first dialect tests (Alinta exact text, Origin period-averaged,
  EA Solar Max no-rate-in-elig fallback, edge cases)
- 1 quantity-first dialect test (AGL exact text including "Tarriff" typo)
- 5 day-cap math tests (below cap, above cap no-tier2, above cap with
  tier2, day reset, zero export)
- 3 period-cap math tests (within pool, exhausted early, trace records
  window type)
- 5 parse_from_incentives walking tests (eligibility, description fallback,
  first-match, no-match, empty list)

Module is NOT yet wired into RETAILER_PARSERS dispatch — retailer files
(origin.py, alinta.py, energyaustralia.py) ship as Phase 2.11.2.

Full suite: 500 pass (was 480, +20). Zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): wire tiered_fit to Origin/Alinta/EnergyAustralia (Phase 2.11.2)

Activates the Phase 2.11.1 tiered_fit math against 3 new retailers via
RETAILER_PARSERS dispatch. Per-retailer parser files are intentionally
thin (~50 LOC each) — they only handle brand-slug routing + base FIT
lookup, then delegate the math to common/tiered_fit.apply_rule.

Adds:
- common/__init__.py: base_fit_c_per_kwh_inc_gst() helper that reads
  solarFeedInTariff[].rates[0].unitPrice and converts ex-GST → inc-GST
  cents (×100 for cents, ×1.10 for GST).
- origin.py: handles "Origin offers 12c/kWh until daily export limit
  of 8 kWh, averaged across billing period" pattern (84 plans, PERIOD
  cap_window).
- alinta.py: handles "7c/kWh for first 10kW exported, then 0.04c/kWh"
  pattern (66 plans, DAY cap_window).
- energyaustralia.py: handles future-proof Solar Max with explicit
  rate-and-cap text (currently 0 plans match because EA's eligibility
  describes the averaging window but not the rate). No-op when rule
  not extractable; pinned by test_solar_max_no_rate_in_elig_no_op.

11 new tests in test_cdr_incentive_parsers_phase_2_11_2.py pin:
- All 3 retailers registered in RETAILER_PARSERS
- Unknown brands no-op cleanly
- Origin 30-day pool math (within + exhausted)
- Alinta single-day + daily-reset
- EA Solar Max graceful no-op when rate not in elig
- EA with explicit rate-and-cap text (future variant)

Net behavioural change: 210 plans across 3 retailers now correctly
credit tiered FIT delta to incentive_aud_inc_gst. Estimated user impact:
+$50-200/yr accuracy improvement for solar households on these plans.

Full suite: 511 pass (was 500, +11). Zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): bonus FIT parser + GloBird Peak FIT wiring (Phase 2.11.3)

Catalog v3 finding: 90 GloBird ZEROHERO plans publish two stacked
bonus FIT rules in incentives[]:

1. Peak solar feed-in (uncapped windowed bonus, 70 plans):
   "X cents/kWh applies to exports between Yam-Zpm (Local Time)
    everyday." Currently NOT extracted by globird.py — this commit
    adds it as a new credit line.

2. Super Export Credit (capped windowed bonus, 20 plans):
   "X cents/kWh applies to the first N kWh of exports between Yam-Zpm
    everyday, and is inclusive of any other Feed-in tariff."
   Already extracted by existing globird.py code.

Adds common/bonus_fit.py with shared regex + apply functions for both
patterns. Refactor of existing globird.py Super Export math to use
the new helper deferred to a future commit (existing math passes all
test cases for ZEROHERO's specific case, so refactor is pure churn).

Live verified against GLO731031MR@VEC (ZEROHERO Residential Flexible
Rate United Energy) — fetched today, 6 incentives present:
- Perfect if you love free stuff (Three for Free $0/kWh 11am-2pm)
- ZEROHERO Credit ($1/day if behavioral met)
- Super Export Credit (15c/kWh first 15kWh exports 6-9pm)  ← parsed
- Critical Peak-Export Credit (event-driven)
- Critical Peak-Import Credit (event-driven)
- Peak solar feed-in (2c/kWh exports 4-11pm)               ← NEW

Known gap (TODO Phase 2.11.4 polish): Super Export and Peak FIT
overlap in 6-9pm window. Both credit additively, over-counting
Peak FIT for first 15kWh of 6-9pm exports by ~$5-30/yr in real-world
usage (max theoretical $109.50/yr for 15kWh × 365 days × 2c).

17 new tests pin behaviour:
- 5 parse_uncapped_window: ZEROHERO 5c + 2c live samples, capped-text
  rejection, empty/unrelated text
- 2 parse_capped_window: ZEROHERO Super Export 15c live, uncapped-text
  rejection
- 2 apply_uncapped_window: in-window credit, zero-export no-op
- 4 apply_capped_window: above cap, below cap, daily reset, outside
  window
- 3 parse_from_incentives: full ZEROHERO block (extracts both rules),
  no-match, empty input
- 1 end-to-end via apply_retailer_incentives dispatch

Full suite: 528 pass (was 511, +17). Zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): free / discounted import window parser + 4 retailer wirings (Phase 2.11.4)

Catalog v3 finding: 214 plans across GloBird/AGL/OVO/Red zero-rate or
discount imports inside specific time windows. Five distinct wordings:
- "Free electricity between 11am and 2pm everyday" (OVO/MYOB Free 3)
- "Free electricity usage applies from 10am to 1pm every day" (AGL TFF)
- "$0.00 for consumption between 10am-2pm" (GloBird 4-hour free)
- "$0.00 for consumption between 11am-2pm" (GloBird ZEROHERO 3-for-Free)
- "$0.06/kWh incl. GST for consumption between 11am-2pm & 12am-6am"
  (GloBird Nine-hour low EV rate — TWO non-contiguous windows)

Adds:

- common/free_window.py — parse_rule + apply_rule + parse_from_incentives.
  Handles single-window AND two-window (joined by '&') variants.
  Math: in-window imports billed at free_rate; credit
  (normal_rate - free_rate) × in-window kWh.

- common/__init__.py: peak_import_rate_c_per_kwh_inc_gst() helper that
  picks max TOU rate AS LONG AS the tariff doesn't already encode a
  near-free window (min rate ≤ 1c inc-GST → returns 0 → free_window
  no-ops). This prevents double-credit on plans like GloBird ZEROHERO
  Flex where the 11am-2pm window is in tariffPeriod itself.

- ovo.py — NEW per-retailer file (brand "ovo-energy", covers MYOB co-brand)
- red.py — NEW per-retailer file (brand "red-energy", weekend-only window
  approximated as all-week in v1; Phase 2.11.5 will add day-of-week
  filtering for ~$5-15/yr accuracy improvement)
- agl.py — wires free_window for "Three for Free Usage" eligibility text
  (supersedes Phase 2.6 deferred stub now that we know the window)
- globird.py — wires free_window for "Perfect if you love free stuff",
  "Four-hour free usage every day", "Nine-hour low EV rate"

- __init__.py: registers ovo-energy + red-energy in RETAILER_PARSERS.

Critical fix during integration: phase 0 golden test for ZEROHERO Flex
(GLO731031MR@VEC) regressed from $65.42 to $43.73 ($21.69 over 7 days)
because free_window was crediting peak rate × in-window imports, but
the FLEXIBLE tariff already encodes 11am-2pm at ~0c off-peak. Resolved
by adding TARIFF_ENCODES_FREE_WINDOW_THRESHOLD_C_INC_GST guard in the
peak_import_rate helper. Plans with a tariff min rate ≤ 1c inc-GST get
a 0 from the helper, which makes free_window's apply_rule no-op (since
delta ≤ 0). Test passes again.

24 new tests (test_cdr_free_window.py):
- 5 catalog wording matches (incl. AGL "to" separator, OVO "and"
  separator, two-window "&" separator)
- 3 edge cases (empty, unrelated, no-window)
- 8 apply_rule math tests (in-window credit, two-window credit,
  outside-window no-op, zero-normal-rate guard, normal-below-free guard,
  zero-import no-op, trace string format)
- 4 parse_from_incentives walking tests
- 4 dispatch e2e tests (OVO, Red, AGL, GloBird Flex no-double-credit)

Full suite: 552 pass (was 528, +24). Zero regressions, including the
phase 0 golden total which now correctly stays at $65.42.

Phase 2.11 status — 5 sub-phases shipped:
✅ 2.11.1 — common/tiered_fit.py (210 plans)
✅ 2.11.2 — origin/alinta/energyaustralia wiring (210 plans live)
✅ 2.11.3 — common/bonus_fit.py + GloBird Peak FIT (90 plans)
✅ 2.11.4 — common/free_window.py + 4 retailer wirings (214 plans)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(coordinator): Amber daily replay + CDR-aware ZEROHERO + supply charge (Phase 2.11.5)

Three Phase 2.11 UAT-blocking fixes shipped together since they're all in
the same hot-path on the coordinator's update loop:

1. **Amber daily replay on restore** — when a comparator is enabled
   mid-day OR a fresh install loads with no persisted accumulator, fetch
   today's grid power history (HA recorder) + Amber prices (Amber API)
   and seed the AmberCalculator with today's true totals so the
   dashboard reflects real spend immediately instead of starting from $0
   and slowly catching up. Replay is idempotent: gated on
   amber_was_restored from the persist read, so a clean restart that
   restored from disk skips the API roundtrip. Handles the kW→W unit
   convention so the seeded values match the live coordinator's tick
   math.

2. **ZEROHERO detection from CDR plan** — coordinator was gating
   `globird_zerohero_status` on the legacy `options.incentives` dict,
   which is empty when a CDR plan supplies the incentive set. Now also
   walks `cdr_plan.data.electricityContract.incentives[]` looking for a
   displayName containing both "zerohero" and "credit", so users on a
   CDR-driven ZEROHERO Flex plan see the daily-credit status instead of
   "unknown".

3. **Daily supply from CDR plan** — `globird_daily_supply_aud` was
   reading from `options.daily_supply_charge` (the legacy manual-tariff
   key, 0.0 for CDR entries). Now reads from
   `tariffPeriod[0].dailySupplyCharge` of the CDR plan and applies the
   ×1.10 GST factor, falling back to the legacy key only when no CDR
   plan is configured.

Verified live against ZEROHERO Residential Flexible Rate
(GLO731031MR@VEC) on HA 2026.5.1:
- daily_supply_aud: 0.0 → $1.155 (= $1.05 × 1.10 GST, matches catalog)
- zerohero_status: "unknown" → "pending"
- amber_cost_today after kW-fix replay: $0.0017 → $1.72 (~18h
  accumulated, realistic for typical household)

529 non-pydantic tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(config_flow): Step-1 cleanup + comparator toggles (Phase 2.12)

Two UX fixes surfaced during Phase 2.11 UAT:

1. **Step 1 "currently with" dropdown** — only retailers with a live
   consumer API now appear. Old options: Amber/GloBird/FlowPower/
   LocalVolts. New options: Amber/FlowPower/LocalVolts/Other (no API).
   GloBird as a "currently with" option was conceptually wrong — GloBird
   has no consumer API, so it can't be a truth-data source. Existing
   entries with current_provider=globird keep working (the wizard
   routing falls through to the CDR plan picker for both
   …
@Artic0din
Copy link
Copy Markdown
Owner Author

Closing — superseded by Phase 3.5 dashboard rewrite (tracked separately). Design-system exploration here is captured in assets/DESIGN.claude.md on dev. Not needed as a separate PR.

@Artic0din Artic0din closed this May 16, 2026
@Artic0din Artic0din deleted the feat/plaintext-labs-design-system branch May 16, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant