// VELOUR — signed-in dashboard ($7 tier). Coherent, data-driven across all four pillars.
const { Card, ProgressBar } = window.VelourDesignSystem_380ed4;
const FRESH = !!window.VELOUR_FRESH;

const FRESH_PILLARS = [
  { name: "Rituals", icon: "sun", color: "var(--sage)", fill: "var(--sage-fill)", text: "var(--sage-text)", metric: "Start here", sub: "Build the rituals that are yours", dir: "steady", trend: "New" },
  { name: "Wellness", icon: "heart", color: "var(--teal)", fill: "var(--teal-fill)", text: "var(--teal-text)", metric: "Connect", sub: "Sync sleep, movement & daylight", dir: "steady", trend: "New" },
  { name: "Wardrobe", icon: "coat-hanger", color: "var(--amber)", fill: "var(--amber-fill)", text: "var(--amber-text)", metric: "Empty", sub: "Photograph your closet to begin", dir: "steady", trend: "New" },
  { name: "Efficiency", icon: "currency-circle-dollar", color: "var(--terracotta)", fill: "var(--terracotta-fill)", text: "var(--terracotta-text)", metric: "Forming", sub: "Builds as your pillars do", dir: "steady", trend: "New" },
];

// =============================================================================
// LIVE DATA LAYER (fresh / new-user account only)
// Reads the same localStorage keys the four pillars write to, so the dashboard
// reflects what you've actually logged and stays in sync as you move between
// pillars. The demo (Jennifer/non-fresh) dashboard below is untouched.
// =============================================================================
const FRESH_KEYS = {
  ritualsSeq: "velour_fresh_rituals_sequences",
  ritualsLog: "velour_fresh_rituals_log",
  wellnessLog: "velour_fresh_wellness_log",
  closet: "velour_fresh_closet_v1",
  outfits: "velour_fresh_outfits_v1",
  budget: "velour_fresh_budget_v1",
};

function _load(key, fallback) {
  try { const raw = localStorage.getItem(key); if (raw) return JSON.parse(raw); } catch (e) {}
  return fallback;
}
function _toISO(d) { return window.velourDateISO(d); }
function _now() {
  const d = new Date();
  const off = Number(window.VELOUR_DAY_OFFSET) || 0;
  if (off) d.setDate(d.getDate() + off);
  return d;
}
function _todayISO() { return _toISO(_now()); }
function _addDays(iso, n) { const d = new Date(iso + "T00:00:00"); d.setDate(d.getDate() + n); return _toISO(d); }
// One formatter for the whole product — see the note on effMoney in
// DashEfficiency. This rounded to whole dollars across nine call sites, so the
// dashboard tile and the Efficiency pillar would print the same amount two
// different ways the moment a member had cents.
function _money(n) { return window.velourMoney(n); }

// ---- Daily note ("Option A" agent) -----------------------------------------
// A calm, on-brand welcome-back line assembled from the user's real numbers.
// No LLM: zero cost, works offline, every phrase is brand-controlled. The
// inspiration rotates by day (same all day) so it feels fresh without any API.
const DAILY_INSPIRATIONS = [
  "Small things, repeated, become who you are.",
  "Steadiness over intensity — that's the whole idea.",
  "What you tend to, grows.",
  "The quiet days count too.",
  "No rush — just the next intentional choice.",
  "Soft systems, sharp life.",
  "Begin again, gently.",
];
function velourDayIndex() {
  const d = _now();
  const start = Date.UTC(d.getUTCFullYear(), 0, 0);
  return Math.floor((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) - start) / 86400000);
}
// Pick one phrasing from a set, rotating by day. `salt` offsets each stat so
// variants cycle independently (the wording shifts even for the same number).
function pickVariant(arr, salt) { return arr[(velourDayIndex() + salt) % arr.length]; }

function buildDailyNote(f) {
  const idx = velourDayIndex();
  const insp = DAILY_INSPIRATIONS[idx % DAILY_INSPIRATIONS.length];
  if (f.isFreshEmpty) return "A fresh start — pick one ritual today and let it be easy. " + insp;

  // Milestones take priority when they actually land.
  if (f.durationDays === 7) return "One week with VELOUR. The quiet system is taking hold. " + insp;
  if (f.durationDays === 30) return "A month in — look how steady that already feels. " + insp;
  if (f.durationDays === 100) return "One hundred days with VELOUR. Quietly remarkable. " + insp;

  const ps = f.pieces === 1 ? "" : "s";
  const is = f.idle === 1 ? "" : "s";
  const money = f.keptMoney >= 1 ? _money(f.keptMoney) : "";

  // Each eligible stat contributes one phrasing (rotated independently by salt);
  // then the overall lead rotates across pillars by the day index.
  const C = [];
  // Rituals
  if (f.longestDays >= 2) C.push(pickVariant([
    `Your longest hold stands at ${f.longestDays} days.`,
    `${f.longestDays} days held, and counting — your steadiest yet.`,
    `That ${f.longestDays}-day hold is quietly becoming who you are.`,
  ], 1));
  if (f.totalToday > 0 && f.doneToday === f.totalToday) C.push(pickVariant([
    "Every ritual held today — a complete, quiet day.",
    "Today's rituals, all held. Beautifully unremarkable.",
    "A full set today. Nothing left undone.",
  ], 2));
  else if (f.doneToday > 0 && f.totalToday > 0) C.push(pickVariant([
    `${f.doneToday} of ${f.totalToday} rituals done so far today.`,
    `${f.doneToday} of ${f.totalToday} held today — the day's still yours.`,
  ], 3));
  if (f.daysActive >= 2) C.push(pickVariant([
    `You've shown up ${f.daysActive} of the last 7 days.`,
    `${f.daysActive} of the last 7 days, you showed up. That's the work.`,
  ], 4));
  // Wellness
  if (f.wellnessDays >= 2) C.push(pickVariant([
    `Wellness logged ${f.wellnessDays} days this week — a steady rhythm.`,
    `${f.wellnessDays} days of wellness this week. Your body's keeping notes.`,
  ], 5));
  else if (f.wellnessDays === 1) C.push(pickVariant([
    "You logged your wellness this week. A start worth keeping.",
    "One wellness check-in this week — a quiet beginning.",
  ], 6));
  if (f.score >= 80 && f.wellnessDays >= 1) C.push(pickVariant([
    "Your wellness is sitting in good balance lately.",
    "Lately your wellness reads calm and in balance.",
  ], 7));
  // Wardrobe
  if (f.pieces >= 1) C.push(pickVariant([
    `${f.pieces} piece${ps} in your capsule, earning their keep.`,
    `${f.pieces} piece${ps} catalogued — your capsule, working for you.`,
  ], 8));
  if (f.totalWears >= 1) C.push(pickVariant([
    `Your wardrobe has logged ${f.totalWears} wears — cost-per-wear, quietly falling.`,
    `${f.totalWears} wears tracked. Every outfit is paying you back.`,
  ], 9));
  if (f.idle >= 1) C.push(pickVariant([
    `${f.idle} piece${is} resting unworn — a gentle chance to restyle.`,
    `${f.idle} piece${is} sitting idle, waiting for a second life.`,
  ], 10));
  // Efficiency
  if (f.keptMoney >= 1) C.push(pickVariant([
    `${money} still yours this month — kept, not spent.`,
    `${money} kept this month. Quiet money, well held.`,
  ], 11));
  // Membership (non-milestone days)
  if (f.durationDays >= 2) C.push(pickVariant([
    `Day ${f.durationDays} with VELOUR — quietly building.`,
    `Day ${f.durationDays}. The system's doing the work now, not willpower.`,
  ], 12));

  if (!C.length) return "Good to see you back. " + insp;
  return `${C[idx % C.length]} ${insp}`;
}
// Curated facts for Jennifer's demo, consistent with her shown stats (41-day hold, 8-piece closet, $470 kept).
const DEMO_DAILY_FACTS = { longestDays: 41, daysActive: 6, doneToday: 4, totalToday: 6, wellnessDays: 6, score: 78, pieces: 12, totalWears: 426, idle: 3, keptMoney: 470, durationDays: 128, isFreshEmpty: false };

function DailyNote({ text }) {
  return (
    <div style={{ display: "flex", alignItems: "flex-start", gap: 9, marginTop: 14, maxWidth: 600 }}>
      <i className="ph ph-sparkle" style={{ fontSize: 16, color: "var(--sand)", marginTop: 3, flexShrink: 0 }}></i>
      <span style={{ fontFamily: "var(--font-serif)", fontSize: 16.5, lineHeight: 1.5, color: "rgba(247,244,239,0.82)", letterSpacing: "-0.005em" }}>{text}</span>
    </div>
  );
}

// wardrobe helpers (mirror DashWardrobe.jsx so numbers match the pillar)
function _cpw(it) { return it.wears > 0 ? it.price / it.wears : null; }
function _wDaysIdle(it) {
  if (it.lastWornDate) return Math.max(0, Math.round((new Date(_todayISO() + "T00:00:00") - new Date(it.lastWornDate + "T00:00:00")) / 86400000));
  if (it.wears === 0) return 999;
  return it.lastWorn != null ? it.lastWorn : 999;
}
function _isIdle(it) { return it.wears === 0 || _wDaysIdle(it) >= 90; }
function _roi(items) {
  if (!items.length) return 0;
  const active = items.filter((it) => it.wears > 0 && _wDaysIdle(it) < 30 && (_cpw(it) ?? 99) < 12).length;
  return Math.round((active / items.length) * 100);
}

// Read every pillar's storage and compute a harmonized snapshot for the dashboard.
// ---- the cross-pillar pattern ----------------------------------------------
// Until now the home insight put two pillars' COUNTS side by side and then
// asserted a general relationship between them — "steady mornings and steady
// nights tend to pull each other up". True of people in general, unmeasured for
// the member reading it. This computes the relationship from their own log
// instead, so the sentence is either about them or it does not appear.
//
// It is arithmetic, not a model: split the logged days by one yes/no test and
// compare the average sleep on each side.
const PATTERN_MIN_BUCKET = 5;  // days needed on BOTH sides — one day against one day is a coincidence
const PATTERN_MIN_DELTA = 15;  // minutes; under this there is no sentence worth writing

function _sleepMins(e) {
  if (!e) return null;
  const m = (e.sleepH || 0) * 60 + (e.sleepM || 0);
  return m > 0 ? m : null;
}

function _sleepSplit(wLog, isYes) {
  const yes = [], no = [];
  for (const iso of Object.keys(wLog)) {
    const mins = _sleepMins(wLog[iso]);
    if (mins == null) continue;
    (isYes(iso, wLog[iso]) ? yes : no).push(mins);
  }
  if (yes.length < PATTERN_MIN_BUCKET || no.length < PATTERN_MIN_BUCKET) return null;
  const avg = (a) => a.reduce((s, x) => s + x, 0) / a.length;
  const delta = Math.round(avg(yes) - avg(no));
  if (Math.abs(delta) < PATTERN_MIN_DELTA) return null;
  return { days: yes.length, delta };
}

// FIXED ORDER, and that is the load-bearing part. Evaluating both comparisons
// and showing whichever came out larger would be choosing the conclusion after
// seeing the answer — an honest average turned into a cherry-pick. Rituals is
// asked first every time, and the daylight split only runs if it declines.
//
// "A MORNING HELD IN FULL" — the same definition RitualsInsight already uses, and
// it takes the MORNING sequence rather than every habit the member keeps. The
// first version asked for every habit across every sequence, which sounds
// stricter and is really just unreachable: checked against two real phones, a
// member with 22 habits had never once held all of them on a day she also logged
// sleep, so the comparison could never speak to the people keeping the most
// rituals. A definition nothing else in the product uses was also its own
// warning sign.
function _findPattern(wLog, rLog, morningHabits) {
  if (morningHabits.length) {
    const r = _sleepSplit(wLog, (iso) => morningHabits.every((h) => (rLog[iso] || {})[h.id]));
    if (r) return { kind: "rituals", ...r };
  }
  const d = _sleepSplit(wLog, (iso, e) => (e.daylight || 0) >= 30);
  if (d) return { kind: "daylight", ...d };
  return null;
}

function computeFreshData() {
  const today = _todayISO();
  const last7 = Array.from({ length: 7 }, (_, i) => _addDays(today, -i));

  // --- Rituals ---
  const seqs = _load(FRESH_KEYS.ritualsSeq, []);
  const rLog = _load(FRESH_KEYS.ritualsLog, {});
  const allHabits = seqs.flatMap((s) => s.habits || []);
  const totalHabits = allHabits.length;
  const doneToday = allHabits.filter((h) => (rLog[today] || {})[h.id]).length;
  const daysActive = last7.filter((d) => rLog[d] && Object.values(rLog[d]).some(Boolean)).length;
  const heldDays = last7.filter((d) => totalHabits > 0 && allHabits.every((h) => (rLog[d] || {})[h.id])).length;
  const longest = allHabits.reduce((best, h) => {
    let s = 0, cur = today;
    while ((rLog[cur] || {})[h.id]) { s++; cur = _addDays(cur, -1); }
    return Math.max(best, s);
  }, 0);
  // Cumulative since the member started: every ritual ever held, across all days.
  const totalHeld = Object.values(rLog).reduce((sum, day) => sum + Object.values(day || {}).filter(Boolean).length, 0);
  const rituals = { hasHabits: totalHabits > 0, totalHabits, doneToday, daysActive, heldDays, longest, totalHeld, seqs, log: rLog, today };

  // --- Wellness ---
  const wLog = _load(FRESH_KEYS.wellnessLog, {});
  const wDates = Object.keys(wLog).sort();
  const latestKey = wDates.length ? wDates[wDates.length - 1] : null;
  const latestW = latestKey ? wLog[latestKey] : null;
  let wScore = 0, sleepLabel = "—", hydration = 0, sleepPct = 0, hydraPct = 0, steps = 0, stepsPct = 0, daylight = 0;
  if (latestW) {
    const sleepMins = (latestW.sleepH || 0) * 60 + (latestW.sleepM || 0);
    sleepPct = Math.min(100, Math.round((sleepMins / 450) * 100));
    hydraPct = Math.min(100, Math.round(((latestW.hydration || 0) / 2.0) * 100));
    steps = latestW.steps || 0;
    stepsPct = Math.min(100, Math.round((steps / 8000) * 100));
    daylight = latestW.daylight || 0;
    wScore = Math.round((sleepPct + hydraPct + stepsPct) / 3);
    sleepLabel = `${latestW.sleepH || 0}h ${String(latestW.sleepM || 0).padStart(2, "0")}m`;
    hydration = latestW.hydration || 0;
  }
  const wDaysWeek = last7.filter((d) => wLog[d]).length;
  const wellness = {
    hasData: wDates.length > 0, score: wScore, sleepLabel, hydration, sleepPct, hydraPct, steps, stepsPct, daylight,
    daysLogged: wDaysWeek, totalDays: wDates.length, loggedToday: !!wLog[today], latestKey,
  };

  // --- Wardrobe ---
  const closet = _load(FRESH_KEYS.closet, []);
  const totalWears = closet.reduce((a, it) => a + (it.wears || 0), 0);
  const wardrobe = { hasItems: closet.length > 0, count: closet.length, roi: _roi(closet), idle: closet.filter(_isIdle).length, totalWears };

  // --- Efficiency (from budget) ---
  const budget = _load(FRESH_KEYS.budget, { total: 0, planned: [] });
  const spent = (budget.planned || []).reduce((s, p) => s + (p.price || 0), 0);
  const total = budget.total || 0;
  const efficiency = { hasBudget: total > 0, total, spent, remaining: Math.max(0, total - spent) };

  // --- Membership duration: derive "since you started" from earliest logged day ---
  const allDates = [...Object.keys(rLog), ...Object.keys(wLog)].filter(Boolean).sort();
  const memberSince = allDates.length ? allDates[0] : today;
  const durationDays = Math.max(1, Math.round((new Date(today + "T00:00:00") - new Date(memberSince + "T00:00:00")) / 86400000) + 1);

  const anyData = rituals.hasHabits || wellness.hasData || wardrobe.hasItems || efficiency.hasBudget;
  const morningHabits = (seqs.find((s) => s.id === "morning") || {}).habits || [];
  const pattern = _findPattern(wLog, rLog, morningHabits);

  // --- The long view ---
  // Free keeps the rolling window; Premium reaches back to the first logged day,
  // rolled up by month once a mark per day stops being legible. Built by the
  // shared module in _ds_bundle.js so mobile's DashboardScreen draws different
  // marks from identical numbers. `null` means nothing logged — never 0, which
  // is a different fact and would drag every sparse month's average down.
  const firstDay = window.velourHistory.earliest([rLog, wLog]);
  const ritualView = window.velourHistory.view({
    kind: "rituals", today, earliest: firstDay,
    valueFor: (d) => (totalHabits ? allHabits.filter((h) => (rLog[d] || {})[h.id]).length / totalHabits : null),
  });
  const sleepView = window.velourHistory.view({
    kind: "sleep", today, earliest: firstDay,
    valueFor: (d) => { const e = wLog[d]; return e ? (e.sleepH || 0) + (e.sleepM || 0) / 60 : null; },
  });

  return { anyData, rituals, wellness, wardrobe, efficiency, memberSince, durationDays, pattern, ritualView, sleepView };
}

// Build the four live pillar-card objects (falls back to the onboarding copy per pillar).
function freshPillarCards(d) {
  const base = {};
  FRESH_PILLARS.forEach((p) => { base[p.name] = p; });
  const cards = [];

  // Rituals
  cards.push(d.rituals.hasHabits
    ? { ...base.Rituals, metric: `${d.rituals.doneToday} of ${d.rituals.totalHabits}`, sub: d.rituals.longest > 0 ? `Longest hold ${d.rituals.longest}d · ${d.rituals.daysActive}/7 days active` : `${d.rituals.daysActive}/7 days active this week`, dir: d.rituals.doneToday === d.rituals.totalHabits ? "good" : "steady", trend: d.rituals.doneToday === d.rituals.totalHabits ? "All done today" : "Today" }
    : base.Rituals);

  // Wellness
  cards.push(d.wellness.hasData
    ? { ...base.Wellness, metric: `${d.wellness.score}%`, sub: `${d.wellness.sleepLabel} sleep · ${window.velourLitres(d.wellness.hydration)} L water`, dir: d.wellness.score >= 80 ? "up" : "steady", trend: `${d.wellness.daysLogged}/7 logged` }
    : base.Wellness);

  // Wardrobe
  cards.push(d.wardrobe.hasItems
    ? { ...base.Wardrobe, metric: `${d.wardrobe.count} piece${d.wardrobe.count === 1 ? "" : "s"}`, sub: d.wardrobe.idle > 0 ? `${d.wardrobe.idle} idle, worth re-homing` : "All earning their keep", dir: "steady", trend: `ROI ${d.wardrobe.roi}` }
    : base.Wardrobe);

  // Efficiency
  cards.push(d.efficiency.hasBudget
    ? { ...base.Efficiency, metric: `${_money(d.efficiency.remaining)} left`, sub: `${_money(d.efficiency.spent)} of ${_money(d.efficiency.total)} planned`, dir: "good", trend: "On budget" }
    : base.Efficiency);

  return cards;
}

// Hero stats = lifetime totals "since you started" (deliberately NOT the per-day
// numbers, which already live in the pillar cards below).
function freshLifetimeStats(d) {
  return [
    { label: "Rituals held", section: "rituals", value: d.rituals.totalHeld > 0 ? String(d.rituals.totalHeld) : "—", caption: d.rituals.totalHeld > 0 ? "since you started" : "none yet", color: "var(--sage)" },
    { label: "Wellness logged", section: "wellness", value: d.wellness.totalDays > 0 ? `${d.wellness.totalDays} day${d.wellness.totalDays === 1 ? "" : "s"}` : "—", caption: d.wellness.totalDays > 0 ? "days on record" : "none yet", color: "var(--teal)" },
    { label: "Wardrobe", section: "wardrobe", value: d.wardrobe.hasItems ? `${d.wardrobe.count} piece${d.wardrobe.count === 1 ? "" : "s"}` : "—", caption: d.wardrobe.hasItems ? `${d.wardrobe.totalWears} wears tracked` : "none yet", color: "var(--amber)" },
    { label: "Kept under budget", section: "efficiency", value: d.efficiency.hasBudget ? _money(d.efficiency.remaining) : "—", caption: d.efficiency.hasBudget ? `of ${_money(d.efficiency.total)} set aside` : "none yet", color: "var(--terracotta)" },
  ];
}

// ---- Jennifer's data ----
const HERO_STATS = [
  { label: "Rituals", value: "4 / 6", pct: 67, color: "var(--sage)" },
  { label: "Wellness", value: "78%", pct: 78, color: "var(--teal)" },
  { label: "Wardrobe", value: "On plan", pct: 100, color: "var(--amber)" },
  { label: "Efficiency", value: "$470 left", pct: 80, color: "var(--terracotta)" },
];

const PILLAR_CARDS = [
  { name: "Rituals", icon: "sun", color: "var(--sage)", fill: "var(--sage-fill)", text: "var(--sage-text)", metric: "41 days held", sub: "Longest you've ever held", dir: "good", trend: "On a roll" },
  { name: "Wellness", icon: "heart", color: "var(--teal)", fill: "var(--teal-fill)", text: "var(--teal-text)", metric: "78% this week", sub: "Sleep up 40 min on walk days", dir: "up", trend: "+12%" },
  { name: "Wardrobe", icon: "coat-hanger", color: "var(--amber)", fill: "var(--amber-fill)", text: "var(--amber-text)", metric: "On plan", sub: "3 pieces worth re-homing", dir: "steady", trend: "Steady" },
  { name: "Efficiency", icon: "currency-circle-dollar", color: "var(--terracotta)", fill: "var(--terracotta-fill)", text: "var(--terracotta-text)", metric: "$1,930 spent", sub: "Dining out down 18%", dir: "good", trend: "Under budget" },
];

const RITUALS = [
  { t: "Morning journal", meta: "14 days held", done: true },
  { t: "Cold water + vitamins", meta: "7 days held", done: true },
  { t: "Morning walk", meta: "Done · 7:14am", done: true },
  { t: "Cook dinner at home", meta: "Tonight", done: false },
  { t: "Evening wind-down", meta: "9:30pm", done: false },
];

function HeroStat({ s }) {
  return (
    <div style={{ background: "rgba(247,244,239,0.06)", border: "1px solid rgba(247,244,239,0.10)", borderRadius: 14, padding: "18px 20px" }}>
      <div style={{ fontSize: 13, color: "var(--sand)", marginBottom: 8 }}>{s.label}</div>
      <div style={{ fontFamily: "var(--font-sans)", fontWeight: 500, fontSize: 24, color: "var(--text-primary)", marginBottom: 14, letterSpacing: "-0.01em", whiteSpace: "nowrap" }}>{s.value}</div>
      <window.DarkBar pct={s.pct} color={s.color} />
    </div>
  );
}

function Greeting() {
  return (
    <div className="velour-dark" style={{ background: "var(--ink)", borderRadius: 22, padding: "clamp(22px, 4vw, 34px) clamp(20px, 4vw, 36px)", boxShadow: "var(--shadow-lg)" }}>
      {/* The demo greeting needed the same narrow-width treatment as FreshGreeting:
          the date block was pinned beside the heading, squeezing it to one word per
          line, and the four tiles were a fixed 4-column grid that ran off the edge. */}
      <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20, marginBottom: 28, flexWrap: "wrap" }}>
        <div style={{ minWidth: "min(100%, 260px)", flex: "1 1 260px" }}>
          <div style={{ fontSize: 13, letterSpacing: "0.04em", color: "var(--sand)", marginBottom: 8 }}>Good morning, Jennifer</div>
          <h1 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: "clamp(26px, 5.2vw, 38px)", lineHeight: 1.08, letterSpacing: "-0.02em", color: "var(--text-primary)", margin: 0, textWrap: "balance" }}>Here's your day, in order.</h1>
          <DailyNote text={buildDailyNote(DEMO_DAILY_FACTS)} />
        </div>
        <div style={{ textAlign: "right", flexShrink: 0 }}>
          <div style={{ fontSize: 14, color: "var(--sand)", whiteSpace: "nowrap" }}>{_now().toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}</div>
          <div style={{ fontSize: 13, color: "rgba(247,244,239,0.5)", marginTop: 4, whiteSpace: "nowrap" }}>7:14am · 18°C, clear</div>
        </div>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 14 }}>
        {HERO_STATS.map((s) => <HeroStat key={s.label} s={s} />)}
      </div>
    </div>
  );
}

// ---- Held together — the monthly shared ritual. Opt-in, ambient, never ranked. ----
// Admin-authored (AdminDashboard.jsx's "Held together" tab); duplicated here per
// the current no-build architecture (same pattern as the blog letters below —
// Phase 0 unifies both via one API). Joining IS adding the ritual to your own
// sequence via the same seeding mechanic as onboarding — no separate progress
// bar, badge, countdown, or leaderboard. The count is an illustrative, ambient
// "you are not alone in this," never a ranking.
// NO FALLBACK CAMPAIGN. There was one — a hard-coded August 2026 record kept as
// what a member sees while /api/campaigns is in flight or has nothing published.
// It was deleted on 2026-09-01, the same call the owner made for the letters, and
// for the same reason made concrete: once the four seeded campaigns were removed
// from the database, the fallback was the ONLY thing left showing them, so every
// dashboard went on advertising a walk-with-no-headphones month that no longer
// existed anywhere. A card with no campaign now renders nothing at all.
const HT_PILLAR_META = { rituals: { color: "var(--sage)" }, wellness: { color: "var(--teal)" }, wardrobe: { color: "var(--amber)" }, efficiency: { color: "var(--terracotta)" } };

// THE RITUAL IS NOW AN ARGUMENT, not a module constant. These two read and write
// a member's own sequence by matching on the ritual's label, and the label used to
// be fixed at page load. Once the campaign comes from the server that is a bug
// waiting: the card would offer August's ritual while these checked for July's, so
// a member who had already joined would be offered it again and could add it
// twice. Whatever the card is displaying is what gets checked and written.
function htSeqKey() { return FRESH ? "velour_fresh_rituals_sequences" : "velour_rituals_sequences"; }
function htHasJoined(ritual) {
  const seqs = _load(htSeqKey(), null);
  if (!seqs || !ritual) return false;
  return seqs.some((s) => (s.habits || []).some((h) => h.label === ritual));
}
function htJoin(ritual) {
  if (!ritual) return;
  let seqs = _load(htSeqKey(), null);
  if (!seqs || !seqs.length) {
    seqs = [
      { id: "morning", name: "Morning ritual", time: "7:00 AM", icon: "sun", habits: [] },
      { id: "evening", name: "Evening wind-down", time: "9:30 PM", icon: "moon-stars", habits: [] },
    ];
  }
  if (seqs.some((s) => (s.habits || []).some((h) => h.label === ritual))) return;
  const target = seqs.find((s) => s.id === "morning") || seqs[0];
  target.habits = [...(target.habits || []), { id: "h-ht-" + Date.now().toString(36), libId: null, label: ritual, icon: "footprints", streak: 0 }];
  try { localStorage.setItem(htSeqKey(), JSON.stringify(seqs)); } catch (e) {}
}

// The month members are holding together, from /api/campaigns — the only source.
//
// This is what the old constant's comment was waiting for. It used to say the
// month label "needs moving by hand each month until the admin campaigns API
// serves it"; that API serves it, the constant is gone, and nothing here needs
// editing when the month turns over.
function useHeldTogether() {
  // undefined = not answered yet, null = answered and there is none. The card
  // renders nothing for either, so the distinction is only about not flashing.
  const [live, setLive] = React.useState(undefined);
  React.useEffect(() => {
    let cancelled = false;
    fetch("/api/campaigns")
      .then((r) => (r.ok ? r.json() : null))
      .then((body) => {
        if (cancelled) return;
        setLive((body && body.campaign) || null);
      })
      // A failed request settles as "no campaign" rather than staying in flight
      // forever. Nothing published and nothing reachable look the same to a
      // member, and both are better rendered as silence than as last month.
      .catch(() => { if (!cancelled) setLive(null); });
    return () => { cancelled = true; };
  }, []);
  return live;
}

function HeldTogetherCard({ onJoined }) {
  const campaign = useHeldTogether();
  const [joined, setJoined] = React.useState(false);
  const [count, setCount] = React.useState(null);
  const [limited, setLimited] = React.useState(false);
  // A pillar the palette does not know must not crash the first card on the
  // dashboard. The check constraint in migration 001 allows only these four, but
  // this reads a server response, and a card that throws takes the page with it.
  const color = ((campaign && HT_PILLAR_META[campaign.pillar]) || HT_PILLAR_META.rituals).color;
  const ritual = campaign ? campaign.ritual : null;
  // Re-answered whenever the campaign changes, because both answers are ABOUT the
  // campaign: whether this account already holds THIS month's ritual, and how many
  // people the server says are holding it.
  React.useEffect(() => {
    setJoined(htHasJoined(ritual));
    setCount(campaign && campaign.joinCount != null ? campaign.joinCount : null);
  }, [ritual, campaign && campaign.joinCount]);
  // If the account already holds this month's ritual, the card must say so once
  // the pull lands — otherwise a second device offers to join something the
  // member joined on their first one.
  //
  // EVERY HOOK ABOVE THIS LINE, including this one, runs on every render. The
  // early return for "no campaign" has to come after them or the hook order
  // changes the moment a campaign is published.
  if (window.useVelourSynced) window.useVelourSynced("rituals", () => setJoined(htHasJoined(ritual)));

  // Nothing published, or the request has not answered. The card is an invitation
  // to join a specific thing; with no thing there is nothing to invite anyone to,
  // and an empty card would be worse than none. The dashboard is a flex column
  // with a gap, so returning null closes up cleanly.
  if (!campaign) return null;

  const join = () => {
    if (joined) return;
    // The plan cap is checked here, at the write, not only on the Rituals page.
    if (window.velourRitualsAtLimit && window.velourRitualsAtLimit()) { setLimited(true); return; }
    htJoin(ritual);
    setJoined(true);
    setCount((c) => (c == null ? null : c + 1));
    // Tell the dashboard, which read every pillar's storage into state and cannot
    // otherwise know this wrote to one. Its other refresh triggers are `focus` and
    // a SERVER-applied sync — neither fires for a local write on the page you are
    // already looking at, so the pillar counts would keep showing the old totals
    // under a button that says it worked. Mobile carries the same fix; there it is
    // worse, because the stale copy is also what the next write is built from.
    if (onJoined) onJoined();
  };
  return (
    <Card padding={24} style={{ borderColor: `color-mix(in srgb, ${color} 35%, var(--border))` }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
        <span aria-hidden="true" style={{ width: 8, height: 8, borderRadius: "50%", background: color }}></span>
        <span style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", fontWeight: 500 }}>Held together · {campaign.month}</span>
      </div>
      <div style={{ fontFamily: "var(--font-serif)", fontSize: 21, lineHeight: 1.35, color: "var(--text-primary)", letterSpacing: "-0.01em", marginBottom: 10 }}>{campaign.ritual}</div>
      <p style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 18px", maxWidth: 480 }}>{campaign.frameLine}</p>
      {/* Without a count this row holds only the button, and space-between would
          slide it to the left edge — so the justification follows the content. */}
      <div style={{ display: "flex", alignItems: "center", justifyContent: count != null ? "space-between" : "flex-end", flexWrap: "wrap", gap: 12 }}>
        {count != null && (
          <span style={{ fontSize: 13, color: "var(--text-tertiary)" }}>{count.toLocaleString()} members are holding this with you</span>
        )}
        <button onClick={join} disabled={joined} style={{
          padding: "10px 20px", borderRadius: 999, border: "none", cursor: joined ? "default" : "pointer",
          background: joined ? "var(--sage-fill)" : "var(--ink)", color: joined ? "var(--sage-text)" : "var(--parchment)",
          fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 7, transition: "background .3s, color .3s",
        }}>
          {joined ? (<React.Fragment><i className="ph ph-check" style={{ fontSize: 14 }}></i>Added to your rituals</React.Fragment>) : "Add to my rituals"}
        </button>
      </div>
      {limited && (
        <div style={{ marginTop: 14, paddingTop: 14, borderTop: "var(--border-width) solid var(--border)" }}>
          <div style={{ fontSize: 14.5, color: "var(--text-primary)", fontWeight: 500 }}>Your free plan holds {(window.velourPlan ? window.velourPlan.get() : {}).maxRituals} rituals.</div>
          <button onClick={() => window.velourOpenPricing()} style={{ marginTop: 8, background: "none", border: "none", padding: 0, cursor: "pointer", color: "var(--sage-text)", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 6 }}>
            See what Premium adds<i className="ph ph-arrow-right" style={{ fontSize: 13 }}></i>
          </button>
        </div>
      )}
    </Card>
  );
}

// The signature Velour Insight treatment, shared by the home overview and all
// four pillar views (each renders it first, right under the pillar header).
// It is the most-noticed card on every view, so it gets the reflective
// register: pillar-tinted fill, solid sparkle tile, italic serif headline,
// and a quiet rise on entry — reduced-motion guarded, nothing bounces.
const INSIGHT_TONES = {
  rituals: { color: "var(--sage)", fill: "var(--sage-fill)", text: "var(--sage-text)" },
  wellness: { color: "var(--teal)", fill: "var(--teal-fill)", text: "var(--teal-text)" },
  wardrobe: { color: "var(--amber)", fill: "var(--amber-fill)", text: "var(--amber-text)" },
  efficiency: { color: "var(--terracotta)", fill: "var(--terracotta-fill)", text: "var(--terracotta-text)" },
  home: { color: "var(--ink)", fill: "var(--surface-card)", text: "var(--text-primary)" },
};

// WHY IT MATTERS, 13 Sep 2026. Every pillar's insight carries a purpose line and
// the study behind it, by the owner's call: the member's own number says what is
// true, the reading says why it is worth keeping at. `cite` is an id into
// VELOUR_EDUCATION (Education.jsx), whose `why` is the sentence shown here — the
// mobile twin in PhoneFrame.jsx reads the same id from EDU_INSIGHTS, so a new
// entry goes into BOTH tables, identical, in the same sitting. A missing id or an
// entry with no `why` renders nothing: an uncited observation beats a borrowed
// one, and a broken reference must never show as an empty link. Set under its
// own rule for the reason the dashboard's reading line is — the number above is
// yours, the study is about other people.
function VelourInsight({ pillar = "home", headline, support, action, onAction, children, style, cite }) {
  const tone = INSIGHT_TONES[pillar] || INSIGHT_TONES.home;
  const reading = cite ? ((window.VELOUR_EDUCATION || []).find((e) => e.id === cite && e.why) || null) : null;
  const reduced = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
  const [inV, setInV] = React.useState(reduced);
  React.useEffect(() => {
    if (reduced) return;
    const raf = requestAnimationFrame(() => setInV(true));
    return () => cancelAnimationFrame(raf);
  }, []);
  return (
    <section aria-label="Velour insight" style={{
      // A stronger tint than the shared -fill tokens: sage and teal fills are
      // near-identical at token strength (the palette's known confusable pair),
      // and this card must read unmistakably as its pillar at a glance.
      background: pillar === "home" ? tone.fill : `color-mix(in srgb, ${tone.color} 16%, #FFFFFF)`,
      border: `2px solid ${tone.color}`, borderRadius: 20,
      padding: "26px 28px", boxShadow: "var(--shadow-sm)",
      opacity: inV ? 1 : 0, transform: inV ? "none" : "translateY(14px)",
      transition: reduced ? "none" : "opacity .7s var(--ease-out), transform .7s var(--ease-out)",
      ...style,
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }}>
        <span style={{ width: 38, height: 38, borderRadius: 11, background: tone.color, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
          <i className="ph ph-sparkle" style={{ fontSize: 19, color: "#fff" }}></i>
        </span>
        <span style={{ fontSize: 11.5, letterSpacing: "0.18em", textTransform: "uppercase", color: tone.text, fontWeight: 500 }}>Velour Insight</span>
      </div>
      <div style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", fontWeight: 500, fontSize: 26, lineHeight: 1.3, letterSpacing: "-0.01em", color: "var(--text-primary)", textWrap: "balance" }}>{headline}</div>
      {support && <p style={{ fontSize: 14.5, color: "var(--text-secondary)", lineHeight: 1.6, margin: "12px 0 0", maxWidth: "68ch" }}>{support}</p>}
      {children}
      {reading && (
        <div style={{ marginTop: 18, paddingTop: 14, borderTop: `1px solid color-mix(in srgb, ${tone.color} 35%, transparent)`, maxWidth: "68ch" }}>
          <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: tone.text, fontWeight: 500, marginBottom: 6 }}>Why it matters</div>
          <p style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.6, margin: 0 }}>{reading.why}</p>
          <a href={reading.url} target="_blank" rel="noopener noreferrer" style={{ display: "inline-flex", alignItems: "baseline", gap: 6, marginTop: 8, fontSize: 12.5, lineHeight: 1.5, color: "var(--text-tertiary)", textDecoration: "none" }}>
            <span>Related reading</span><span style={{ color: "var(--text-secondary)", textDecoration: "underline", textUnderlineOffset: 2 }}>{reading.source}</span>
          </a>
        </div>
      )}
      {action && (
        <button onClick={onAction} style={{ display: "inline-flex", alignItems: "center", gap: 7, background: "none", border: "none", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 14, fontWeight: 500, color: tone.text, padding: 0, marginTop: 18 }}>
          {action} <i className="ph ph-arrow-right" style={{ fontSize: 15 }}></i>
        </button>
      )}
    </section>
  );
}
window.VelourInsight = VelourInsight;

// The home insight is an overview — one observation per pillar, each a doorway
// into that pillar's view (where its own Velour Insight card goes deeper).
const HOME_INSIGHTS = [
  { key: "rituals", color: "var(--sage)", line: "14 days. That's a ritual forming." },
  { key: "wellness", color: "var(--teal)", line: "You sleep 40 minutes longer on the days you walk." },
  { key: "wardrobe", color: "var(--amber)", line: "Your beige coat hasn't been worn in 94 days." },
  { key: "efficiency", color: "var(--terracotta)", line: "Home coffee saved $35 this week." },
];

function InsightTip({ onOpen }) {
  return (
    <VelourInsight pillar="home" headline="This week, VELOUR noticed.">
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 260px), 1fr))", gap: "4px 28px", marginTop: 16 }}>
        {HOME_INSIGHTS.map((n) => (
          <button key={n.key} onClick={() => onOpen && onOpen(n.key)} style={{ display: "flex", alignItems: "center", gap: 12, textAlign: "left", background: "none", border: "none", borderTop: "var(--border-width) solid var(--divider)", padding: "13px 2px", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
            <span aria-hidden="true" style={{ width: 8, height: 8, borderRadius: "50%", background: n.color, flexShrink: 0 }}></span>
            <span style={{ flex: 1, fontSize: 14.5, lineHeight: 1.45, color: "var(--text-primary)" }}>{n.line}</span>
            <i className="ph ph-arrow-right" style={{ fontSize: 15, color: n.color, flexShrink: 0 }}></i>
          </button>
        ))}
      </div>
    </VelourInsight>
  );
}

function PillarCard({ p, onOpen }) {
  return (
    <button onClick={() => onOpen && onOpen(p.name.toLowerCase())} onMouseEnter={(e) => { e.currentTarget.style.transform = "translateY(-3px)"; e.currentTarget.style.boxShadow = "var(--shadow-md)"; e.currentTarget.style.borderColor = "var(--border-strong)"; }} onMouseLeave={(e) => { e.currentTarget.style.transform = "none"; e.currentTarget.style.boxShadow = "var(--shadow-sm)"; e.currentTarget.style.borderColor = "var(--border)"; }} style={{ textAlign: "left", cursor: "pointer", background: "var(--surface-card)", border: "var(--border-width) solid var(--border)", borderRadius: 18, padding: "24px 24px 22px", boxShadow: "var(--shadow-sm)", transition: "transform var(--dur-base) var(--ease-out), box-shadow var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out)", display: "flex", flexDirection: "column", fontFamily: "var(--font-sans)" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
        <div style={{ width: 46, height: 46, borderRadius: 12, background: p.color, display: "flex", alignItems: "center", justifyContent: "center" }}>
          <i className={`ph ph-${p.icon}`} style={{ fontSize: 22, color: "#fff" }}></i>
        </div>
        <window.Trend dir={p.dir} label={p.trend} />
      </div>
      <div style={{ fontSize: 13, color: "var(--text-tertiary)", marginBottom: 4 }}>{p.name}</div>
      <div style={{ fontFamily: "var(--font-serif)", fontSize: 24, color: "var(--text-primary)", letterSpacing: "-0.01em", marginBottom: 8 }}>{p.metric}</div>
      <div style={{ fontSize: 13.5, color: "var(--text-secondary)", lineHeight: 1.5, flex: 1 }}>{p.sub}</div>
      <div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 18, fontSize: 13.5, fontWeight: 500, color: p.text }}>
        Open {p.name} <i className="ph ph-arrow-right" style={{ fontSize: 15 }}></i>
      </div>
    </button>
  );
}

function RitualChecklist() {
  const [items, setItems] = React.useState(RITUALS);
  const toggle = (i) => setItems((xs) => xs.map((x, idx) => (idx === i ? { ...x, done: !x.done } : x)));
  const done = items.filter((x) => x.done).length;
  return (
    <Card padding={0} style={{ overflow: "hidden" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "20px 24px", borderBottom: "var(--border-width) solid var(--divider)" }}>
        <span style={{ fontFamily: "var(--font-serif)", fontSize: 19, color: "var(--text-primary)" }}>Today's rituals</span>
        <span style={{ fontSize: 13.5, color: "var(--text-tertiary)", whiteSpace: "nowrap" }}>{done} of {items.length} done</span>
      </div>
      <div>
        {items.map((it, i) => (
          <button key={it.t} onClick={() => toggle(i)} style={{ width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: 14, padding: "15px 24px", borderTop: i === 0 ? "none" : "var(--border-width) solid var(--divider)", fontFamily: "var(--font-sans)" }}>
            <span style={{ width: 26, height: 26, borderRadius: "50%", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", background: it.done ? "var(--sage)" : "transparent", border: it.done ? "none" : "1.5px solid var(--border-strong)" }}>
              {it.done && <i className="ph ph-check" style={{ fontSize: 14, color: "#fff" }}></i>}
            </span>
            <span style={{ flex: 1, fontSize: 15, color: it.done ? "var(--text-tertiary)" : "var(--text-primary)", textDecoration: it.done ? "line-through" : "none" }}>{it.t}</span>
            <span style={{ fontSize: 12.5, color: "var(--text-tertiary)", whiteSpace: "nowrap" }}>{it.meta}</span>
          </button>
        ))}
      </div>
    </Card>
  );
}

function PillarPlaceholder({ section, onBack }) {
  const meta = {
    wardrobe: { name: "Wardrobe", icon: "coat-hanger", color: "var(--amber)", fill: "var(--amber-fill)", text: "var(--amber-text)", line: "Cost-per-wear, outfit planning, and the pieces quietly going unworn." },
    efficiency: { name: "Efficiency", icon: "currency-circle-dollar", color: "var(--terracotta)", fill: "var(--terracotta-fill)", text: "var(--terracotta-text)", line: "Spending clarity, time ROI, and a calmer relationship with money." },
  }[section];
  return (
    <main style={{ maxWidth: 1240, margin: "0 auto", padding: "36px 32px 64px", fontFamily: "var(--font-sans)" }}>
      <span style={{ display: "inline-flex", alignItems: "center", gap: 9, fontSize: 11.5, letterSpacing: "0.16em", textTransform: "uppercase", fontWeight: 500, color: meta.text, background: meta.fill, borderRadius: 999, padding: "6px 14px" }}><i className={`ph ph-${meta.icon}`} style={{ fontSize: 14 }}></i>{meta.name}</span>
      <div style={{ marginTop: 28, background: meta.fill, borderRadius: 20, padding: "64px 48px", textAlign: "center" }}>
        <div style={{ width: 60, height: 60, borderRadius: 16, background: meta.color, display: "inline-flex", alignItems: "center", justifyContent: "center", marginBottom: 24 }}><i className={`ph ph-${meta.icon}`} style={{ fontSize: 28, color: "#fff" }}></i></div>
        <h1 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 34, lineHeight: 1.1, letterSpacing: "-0.02em", color: meta.text, margin: 0, textWrap: "balance" }}>{meta.name} is coming into focus.</h1>
        <p style={{ fontSize: 16, lineHeight: 1.65, color: meta.text, opacity: 0.82, maxWidth: 440, margin: "16px auto 0" }}>{meta.line} We're designing this pillar next — it'll feel like a natural part of the same calm system.</p>
        <button onClick={onBack} style={{ marginTop: 28, display: "inline-flex", alignItems: "center", gap: 8, background: "var(--ink)", color: "var(--parchment)", border: "none", borderRadius: 999, padding: "12px 22px", fontFamily: "var(--font-sans)", fontSize: 14.5, fontWeight: 500, cursor: "pointer" }}><i className="ph ph-arrow-left" style={{ fontSize: 15 }}></i>Back to dashboard</button>
      </div>
    </main>
  );
}

// A lifetime stat tile — value + caption, no per-day progress bar.
// Clicking it deep-links into that pillar (same as the pillar cards).
function FreshLifetimeStat({ s, onOpen }) {
  const empty = s.value === "—";
  return (
    <button
      onClick={() => onOpen && onOpen(s.section)}
      onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(247,244,239,0.12)"; e.currentTarget.style.borderColor = "rgba(247,244,239,0.22)"; }}
      onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(247,244,239,0.06)"; e.currentTarget.style.borderColor = "rgba(247,244,239,0.10)"; }}
      style={{ textAlign: "left", width: "100%", cursor: "pointer", fontFamily: "var(--font-sans)", background: "rgba(247,244,239,0.06)", border: "1px solid rgba(247,244,239,0.10)", borderRadius: 14, padding: "18px 20px", transition: "background var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out)" }}
    >
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: 12 }}>
        <span style={{ fontSize: 13, color: "var(--sand)" }}>{s.label}</span>
        <i className="ph ph-arrow-right" style={{ fontSize: 13, color: "rgba(247,244,239,0.4)" }}></i>
      </div>
      <div style={{ fontFamily: "var(--font-serif)", fontSize: 28, color: empty ? "rgba(247,244,239,0.4)" : "var(--text-primary)", letterSpacing: "-0.01em", whiteSpace: "nowrap" }}>{s.value}</div>
      <div style={{ display: "flex", alignItems: "center", gap: 7, marginTop: 8 }}>
        <span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color, flexShrink: 0, opacity: empty ? 0.35 : 1 }}></span>
        <span style={{ fontSize: 12, color: "rgba(247,244,239,0.55)" }}>{s.caption}</span>
      </div>
    </button>
  );
}

// Onboarding record written by the marketing site's Begin flow (fresh only) —
// gives the fresh dashboard the member's actual name.
function onboardingName() {
  // A real session's name is authoritative. The local onboarding record is
  // per-BROWSER, not per-account: it is whatever the last "Begin" on this
  // device wrote, and does not update when a different member signs in on
  // the same device afterward. Verified live 2026-08-14 — velourtest4 signed
  // in on a browser that had previously run Begin as velourtest3, and this
  // greeting kept saying "Miguel Demo 3" while DashTopBar's header correctly
  // read "Miguel" from the session, because DashTopBar already checked the
  // session first and this function didn't.
  const m = window.velourPlan && typeof window.velourPlan.member === "function" ? window.velourPlan.member() : null;
  if (m) {
    // Same derivation as DashTopBar.jsx's dashIdentity/nameFromEmail — reused
    // here rather than copied, since that file loads just before this one and
    // these .jsx files are classic scripts sharing one global scope.
    const named = (m.name || "").trim();
    if (named) return named.split(/\s+/)[0];
    if (typeof nameFromEmail === "function") { const d = nameFromEmail(m.email); if (d) return d.split(" ")[0]; }
  }
  try {
    const o = JSON.parse(localStorage.getItem("velour_fresh_onboarding_v1") || "null");
    return o && o.name ? o.name : null;
  } catch (e) { return null; }
}
function freshGreetingLabel() {
  const h = new Date().getHours();
  const part = h < 12 ? "morning" : h < 18 ? "afternoon" : "evening";
  const n = onboardingName();
  return n ? `Good ${part}, ${n}` : `Good ${part}`;
}

// Greeting block — now a "since you started" lifetime summary, not per-day numbers.
function FreshGreeting({ data, onOpen }) {
  // Re-render when /api/me answers. onboardingName() prefers the session's name,
  // but that arrives asynchronously and this block paints before it does — so
  // without subscribing, the first paint's fallback is the one that sticks for
  // the whole page life. DashTopBar already does this; same idiom on purpose.
  if (window.useVelourPlan) window.useVelourPlan();
  // Formatted in local time to match _todayISO (which is the local calendar date) —
  // keeps the greeting date in step with the date the rest of the app calls "today".
  const dateLabel = _now().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
  const sinceLabel = new Date(data.memberSince + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
  const stats = freshLifetimeStats(data);
  return (
    <div className="velour-dark" style={{ background: "var(--ink)", borderRadius: 22, padding: "clamp(22px, 4vw, 34px) clamp(20px, 4vw, 36px)", boxShadow: "var(--shadow-lg)" }}>
      {/* flexWrap so the date block drops below the heading instead of squeezing it
          to one word per line, and a fluid heading size so it stays readable when
          it does. Both were plainly broken at phone width. */}
      <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20, marginBottom: 26, flexWrap: "wrap" }}>
        <div style={{ minWidth: "min(100%, 260px)", flex: "1 1 260px" }}>
          <div style={{ fontSize: 13, letterSpacing: "0.04em", color: "var(--sand)", marginBottom: 8 }}>{freshGreetingLabel()}</div>
          <h1 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: "clamp(26px, 5.2vw, 38px)", lineHeight: 1.08, letterSpacing: "-0.02em", color: "var(--text-primary)", margin: 0, textWrap: "balance" }}>Here's your day, in order.</h1>
          <DailyNote text={buildDailyNote({ longestDays: data.rituals.longest, daysActive: data.rituals.daysActive, doneToday: data.rituals.doneToday, totalToday: data.rituals.totalHabits, wellnessDays: data.wellness.daysLogged, score: data.wellness.score, pieces: data.wardrobe.count, totalWears: data.wardrobe.totalWears, idle: data.wardrobe.idle, keptMoney: data.efficiency.hasBudget ? data.efficiency.remaining : 0, durationDays: data.durationDays, isFreshEmpty: false })} />
        </div>
        <div style={{ textAlign: "right", flexShrink: 0 }}>
          <div style={{ fontSize: 14, color: "var(--sand)", whiteSpace: "nowrap" }}>{dateLabel}</div>
          <div style={{ fontSize: 13, color: "rgba(247,244,239,0.5)", marginTop: 4, whiteSpace: "nowrap" }}>Day {data.durationDays} with VELOUR · since {sinceLabel}</div>
        </div>
      </div>
      <div style={{ fontSize: 11.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "rgba(247,244,239,0.45)", fontWeight: 500, marginBottom: 14 }}>Since you started</div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 14 }}>
        {stats.map((s) => <FreshLifetimeStat key={s.label} s={s} onOpen={onOpen} />)}
      </div>
    </div>
  );
}

// A cross-pillar line that reacts to what's actually logged — the "read off each other" feel.
// Citations come from VELOUR_EDUCATION, which is already a hand-checked list with
// real sources — never generated. A made-up DOI is the one failure mode this
// product cannot absorb, and a curated table cannot invent one. Missing id just
// means no reading line; it never blocks the observation.
function _citation(id) {
  const all = window.VELOUR_EDUCATION || [];
  return all.find((e) => e.id === id) || null;
}

function freshInsightText(d) {
  // The member's own numbers first, when there are enough of them.
  if (d.pattern) {
    const p = d.pattern;
    const mins = Math.abs(p.delta);
    const dir = p.delta > 0 ? "longer" : "less";
    if (p.kind === "rituals") return {
      tone: "rituals", color: "var(--sage)", label: "Rituals × Wellness",
      body: `On the ${p.days} days you held your morning ritual in full, you slept ${mins} minutes ${dir} than on the days you didn't.`,
      // Added 12 Sep 2026. This ran with no citation on purpose — nothing in
      // the table backed it, and an uncited observation beats a borrowed one.
      // First backed by sleep-routine-satisfaction (Prokeš), which studied sleep
      // schedules and life satisfaction — adjacent, not a match. Swapped 13 Sep
      // for routine-regularity-sleep (Monk 2003): how regular the day is,
      // against sleep, which is the comparison this card actually makes.
      cite: _citation("routine-regularity-sleep"),
    };
    return {
      tone: "wellness", color: "var(--teal)", label: "Daylight × Sleep",
      body: `On the ${p.days} days you spent 30 minutes or more outdoors, you slept ${mins} minutes ${dir} than on the days you didn't.`,
      cite: _citation("activity-sleep"),
    };
  }
  if (d.rituals.heldDays > 0 && d.wellness.hasData)
    return { tone: "rituals", color: "var(--sage)", label: "Rituals × Wellness", body: `You've held your full rituals ${d.rituals.heldDays} day${d.rituals.heldDays === 1 ? "" : "s"} this week and logged wellness ${d.wellness.daysLogged} day${d.wellness.daysLogged === 1 ? "" : "s"}. Steady mornings and steady nights tend to pull each other up.` };
  if (d.rituals.daysActive > 0 && d.wellness.hasData)
    return { tone: "wellness", color: "var(--teal)", label: "Two pillars, talking", body: `Rituals logged ${d.rituals.daysActive}/7 days, wellness ${d.wellness.daysLogged}/7. Keep both going and VELOUR will start surfacing how one moves the other.` };
  if (d.wardrobe.hasItems && d.efficiency.hasBudget)
    return { tone: "wardrobe", color: "var(--amber)", label: "Wardrobe × Efficiency", body: `${d.wardrobe.count} piece${d.wardrobe.count === 1 ? "" : "s"} tracked against a ${_money(d.efficiency.total)} budget — ${_money(d.efficiency.remaining)} still unspent. Shopping your capsule keeps that number healthy.` };
  return { tone: "rituals", color: "var(--sage)", label: "Your system is forming", body: "Each pillar you log feeds the others. Keep adding days and the cross-pillar patterns surface here." };
}

function FreshInsight({ data }) {
  const ins = freshInsightText(data);
  return (
    <Card variant="featured" pillar={ins.tone} padding={24}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
        <i className="ph ph-link" style={{ fontSize: 18, color: ins.color }}></i>
        <span style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", fontWeight: 500 }}>{ins.label}</span>
      </div>
      <div style={{ fontFamily: "var(--font-serif)", fontSize: 21, lineHeight: 1.34, color: "var(--text-primary)", letterSpacing: "-0.01em", maxWidth: 760 }}>{ins.body}</div>
      {/* Kept visibly apart from the sentence above: that number is the member's
          own, this is somebody else's research on the same subject. Running them
          together would read as though the study were about them. */}
      {ins.cite && (
        <div style={{ display: "flex", alignItems: "baseline", gap: 8, marginTop: 14, paddingTop: 12, borderTop: "var(--border-width) solid var(--divider)", fontSize: 12.5, lineHeight: 1.5, color: "var(--text-tertiary)", maxWidth: 760 }}>
          <span style={{ whiteSpace: "nowrap" }}>Related reading</span>
          <a href={ins.cite.url} target="_blank" rel="noopener noreferrer" style={{ color: "var(--text-secondary)" }}>{ins.cite.source}</a>
        </div>
      )}
    </Card>
  );
}

// Today's rituals — a high-level OVERVIEW, deliberately unlike the Rituals pillar.
// The pillar is where you check habits off one by one; here we read the log and
// reflect where the day stands at a glance (per sequence, no checkboxes), then
// hand off to the pillar to actually do them.
function FreshRitualsOverview({ rituals, onOpen }) {
  const dayMap = rituals.log[rituals.today] || {};
  const seqs = rituals.seqs
    .filter((s) => (s.habits || []).length)
    .map((s) => {
      const total = s.habits.length;
      const held = s.habits.filter((h) => dayMap[h.id]).length;
      return { id: s.id, name: s.name, time: s.time, icon: s.icon || "sun", total, held };
    });
  if (!seqs.length) return null;

  const totalHeld = seqs.reduce((a, s) => a + s.held, 0);
  const totalAll = seqs.reduce((a, s) => a + s.total, 0);

  // One calm, specific line — sentence case, no hype.
  let line;
  if (totalHeld === 0) line = "A fresh day. Your rituals are ready when you are.";
  else if (totalHeld === totalAll) line = "Every ritual held today — a quiet, complete day.";
  else {
    const active = seqs.find((s) => s.held > 0 && s.held < s.total) || seqs.find((s) => s.held > 0);
    line = `Your ${active.name.toLowerCase()} is underway — ${active.held} of ${active.total} held.`;
  }

  const statusFor = (s) => (s.held === 0 ? "Not yet begun" : s.held === s.total ? "All held" : `${s.held} of ${s.total} held`);

  return (
    <Card padding={0} style={{ overflow: "hidden" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "22px 26px 18px" }}>
        <div>
          <div style={{ fontSize: 11.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--sage-text)", fontWeight: 500, marginBottom: 8 }}>Today's rituals</div>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 21, lineHeight: 1.32, color: "var(--text-primary)", letterSpacing: "-0.01em", maxWidth: 560 }}>{line}</div>
        </div>
        <div style={{ textAlign: "right", flexShrink: 0, paddingLeft: 16 }}>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 28, color: "var(--sage-text)", letterSpacing: "-0.01em", whiteSpace: "nowrap" }}>{totalHeld}<span style={{ color: "var(--text-tertiary)", fontSize: 20 }}> / {totalAll}</span></div>
          <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 2 }}>held today</div>
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: seqs.length > 1 ? "1fr 1fr" : "1fr", gap: 1, background: "var(--divider)", borderTop: "var(--border-width) solid var(--divider)" }}>
        {seqs.map((s) => (
          <div key={s.id} style={{ background: "var(--surface-card)", padding: "18px 26px 20px" }}>
            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
              <span style={{ display: "inline-flex", alignItems: "center", gap: 9, fontSize: 14.5, color: "var(--text-primary)" }}>
                <i className={`ph ph-${s.icon}`} style={{ fontSize: 16, color: "var(--sage)" }}></i>{s.name}
              </span>
              <span style={{ fontSize: 12, color: "var(--text-tertiary)", whiteSpace: "nowrap" }}>{s.time}</span>
            </div>
            <ProgressBar value={s.held} max={s.total} pillar="rituals" size="sm" />
            <div style={{ fontSize: 12.5, color: s.held === s.total ? "var(--sage-text)" : "var(--text-tertiary)", marginTop: 9 }}>{statusFor(s)}</div>
          </div>
        ))}
      </div>

      <button onClick={() => onOpen && onOpen("rituals")} style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "14px 24px", background: "none", border: "none", borderTop: "var(--border-width) solid var(--divider)", cursor: "pointer", color: "var(--sage-text)", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500 }}>
        Open rituals to check them off <i className="ph ph-arrow-right" style={{ fontSize: 14 }}></i>
      </button>
    </Card>
  );
}

// Today's wellness — a high-level OVERVIEW, distinct from the Wellness pillar's
// detailed editors. Reads the latest logged day and shows the broad strokes:
// a balance score, a few measures against their goals, and the weekly cadence.
function FreshWellnessOverview({ wellness, onOpen }) {
  if (!wellness.hasData) return null;
  const measures = [
    { icon: "moon", label: "Sleep", value: wellness.sleepLabel, pct: wellness.sleepPct },
    { icon: "drop", label: "Hydration", value: `${window.velourLitres(wellness.hydration)} L`, pct: wellness.hydraPct },
    // "Steps", not "Movement": movement is minutes of effort now, and this is a
    // step count. Renamed alongside DashWellness on 5 Sep 2026 — two different
    // numbers under one label is the ambiguity that cost a testing session when
    // the closet count described the closet and the grid described the filter.
    { icon: "footprints", label: "Steps", value: `${wellness.steps.toLocaleString()} steps`, pct: wellness.stepsPct },
  ];
  const latestLbl = wellness.latestKey
    ? new Date(wellness.latestKey + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
    : "";
  const line = wellness.loggedToday
    ? `Logged today — ${wellness.sleepLabel} of sleep and ${window.velourLitres(wellness.hydration)} L of water.`
    : `Not logged today yet. Your last entry was ${latestLbl}.`;

  return (
    <Card padding={0} style={{ overflow: "hidden" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "22px 26px 18px" }}>
        <div>
          <div style={{ fontSize: 11.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--teal-text)", fontWeight: 500, marginBottom: 8 }}>Today's wellness</div>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 21, lineHeight: 1.32, color: "var(--text-primary)", letterSpacing: "-0.01em", maxWidth: 560 }}>{line}</div>
        </div>
        <div style={{ textAlign: "right", flexShrink: 0, paddingLeft: 16 }}>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 28, color: "var(--teal-text)", letterSpacing: "-0.01em", whiteSpace: "nowrap" }}>{wellness.score}<span style={{ color: "var(--text-tertiary)", fontSize: 18 }}>%</span></div>
          <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 2 }}>in balance</div>
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1, background: "var(--divider)", borderTop: "var(--border-width) solid var(--divider)" }}>
        {measures.map((m) => (
          <div key={m.label} style={{ background: "var(--surface-card)", padding: "18px 22px 20px" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
              <i className={`ph ph-${m.icon}`} style={{ fontSize: 15, color: "var(--teal)" }}></i>
              <span style={{ fontSize: 13, color: "var(--text-secondary)", flex: 1 }}>{m.label}</span>
              <span style={{ fontSize: 13, color: "var(--text-primary)", whiteSpace: "nowrap" }}>{m.value}</span>
            </div>
            <ProgressBar value={m.pct} max={100} pillar="wellness" size="sm" />
          </div>
        ))}
      </div>

      <button onClick={() => onOpen && onOpen("wellness")} style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "14px 24px", background: "none", border: "none", borderTop: "var(--border-width) solid var(--divider)", cursor: "pointer", color: "var(--teal-text)", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500 }}>
        {wellness.loggedToday ? "Open wellness for the full picture" : "Open wellness to log today"} <i className="ph ph-arrow-right" style={{ fontSize: 14 }}></i>
      </button>
    </Card>
  );
}

// ---- The long view ----------------------------------------------------------
// Web had NO history charts at all before this — the dashboard was stat tiles and
// pillar cards, and the only charts in the whole web kit live inside the four
// pillar pages. So this is a net-new build rather than a widened constant, which
// is the half of this feature that actually cost something.
//
// Both marks follow the house chart spec: no library, inline divs, the domain
// pillar's hue, sand tracks, serif values, uppercase stone labels, a title= on
// every mark, and an entrance that collapses to the final state under
// prefers-reduced-motion. Numbers come from velourHistory so the phone and the
// laptop cannot answer the same question differently.

// Scroll-into-view gate, same idiom as FeatureSplit's.
function useHistInView(threshold = 0.25) {
  const ref = React.useRef(null);
  const [inView, setInView] = React.useState(false);
  React.useEffect(() => {
    if (!("IntersectionObserver" in window)) { setInView(true); return; }
    const io = new IntersectionObserver((es) => {
      es.forEach((e) => { if (e.isIntersecting) { setInView(true); io.disconnect(); } });
    }, { threshold });
    if (ref.current) io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  return [ref, inView];
}
function histReduced() {
  return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}

// Rituals: heat grid, sequential alpha ramp on sage.
//
// Daily mode lays seven rows and flows DOWN each column, so a column is a run of
// seven consecutive days — the wide-card orientation, and the one that keeps the
// cells square on a 1240px page instead of stretching them into bars. It is
// deliberately not aligned to Monday: the grid is "a week's worth per column",
// not a calendar, and labelling it as a calendar would be a claim the data
// doesn't support.
// Shared focus ring for every tappable mark in the long view. One rule, used by
// both charts, so a keyboard user gets the same affordance on rituals and sleep.
const HIST_FOCUS_STYLE = <style>{`.vr-hist-mark:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 3px; }`}</style>;

function RitualHistory({ view }) {
  const [ref, inView] = useHistInView();
  const monthly = view.mode === "monthly";
  const shown = inView || histReduced();
  const H = window.velourHistory;
  // Which month is expanded to its own days, or null for the monthly overview.
  // TAPPING IS THE INTERACTION NOW, NOT HOVERING. title= never fires on a
  // touchscreen, so before this the entire long view was unreadable on a phone
  // once it rolled up to months — a member could see bar heights and nothing
  // else. Every mark below is a real button for exactly that reason.
  const [openMonth, setOpenMonth] = React.useState(null);
  // The one day a tap has selected, inside whichever grid is currently on
  // screen. Cleared on every change of grid so a stale readout can never point
  // at a day that has scrolled out of view.
  const [selectedDay, setSelectedDay] = React.useState(null);

  // Zero is the sand track, not alpha 0 — an unheld day is a fact and has to be
  // visible as one. Nothing-logged is the same track with a border.
  const fill = (v) => {
    if (v == null || v <= 0) return "rgba(217,207,192,0.45)";
    if (v >= 1) return "rgba(90,122,92,1)";
    if (v >= 0.66) return "rgba(90,122,92,0.8)";
    if (v >= 0.34) return "rgba(90,122,92,0.55)";
    return "rgba(90,122,92,0.3)";
  };
  const dayLabel = (iso) => new Date(iso + "T00:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
  // TWO SHAPES OF POINT MEET IN THIS COMPONENT, and conflating them was the
  // bug: a rolled-up bucket carries {label, n, value}, a single day carries
  // only {date, value} — including the days handed back by monthDays() for a
  // drill-down, which come from the SAME daily series regardless of what the
  // outer view's own mode is. monthTip() is for bars only; dayTip() is for
  // every individual day, top-level or drilled, and never reads view.mode.
  const monthTip = (p) => p.value == null ? `${p.label} — nothing logged` : `${p.label} — ${Math.round(p.value * 100)}% of rituals held across ${p.n} day${p.n === 1 ? "" : "s"} logged`;
  const dayTip = (p) => p.value == null ? `${p.date} — nothing logged` : `${p.date} — ${Math.round(p.value * 100)}% of rituals held`;
  // The caption counts DAYS in both modes — velourHistory.summary reaches back
  // through the rollup for that. Counting buckets instead would have reported
  // "0 months with every ritual held" for a member with a hundred perfect days,
  // because a monthly mean only reaches 1.0 if every ritual was held every single
  // day of that month.
  const sum = H.summary(view);

  const openDays = openMonth ? H.monthDays(view, openMonth) : null;
  const openPoint = openMonth ? view.points.find((p) => p.key === openMonth) : null;
  const selected = selectedDay ? (openDays || view.dailyPoints || view.points).find((p) => p.date === selectedDay) : null;

  // The day-cell heat grid — the finest-grain mark, shared between the
  // top-level daily view and a drilled-in month. Same cells, same interaction,
  // whichever array is behind them.
  const dayGrid = (points) => (
    <div style={{ display: "grid", gap: 4, justifyContent: "start", gridTemplateRows: "repeat(7, 14px)", gridAutoFlow: "column", gridAutoColumns: "14px" }}>
      {points.map((p, i) => (
        <button key={p.date || i} type="button" className="vr-hist-mark" title={dayTip(p)} aria-label={dayTip(p)} aria-pressed={selectedDay === p.date}
          onClick={() => setSelectedDay((d) => (d === p.date ? null : p.date))}
          style={{
            width: 14, height: 14, borderRadius: 4, background: fill(p.value),
            border: selectedDay === p.date ? "1.5px solid var(--sage-text)" : "none",
            padding: 0, cursor: "pointer", color: "var(--sage-text)",
            opacity: shown ? 1 : 0,
            transition: histReduced() ? "none" : `opacity 600ms cubic-bezier(.16,1,.3,1) ${Math.min(i * 8, 400)}ms`,
          }} />
      ))}
    </div>
  );

  return (
    <div ref={ref}>
      {HIST_FOCUS_STYLE}
      {monthly && !openMonth && (
        <React.Fragment>
          <div style={{ display: "flex", alignItems: "flex-end", gap: 6, height: 116 }}>
            {view.points.map((p, i) => (
              <button key={p.key || i} type="button" className="vr-hist-mark" title={monthTip(p)} aria-label={`${monthTip(p)}. Tap to see the days.`}
                onClick={() => { setOpenMonth(p.key); setSelectedDay(null); }}
                style={{ flex: 1, maxWidth: 34, height: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end", background: "none", border: "none", padding: 0, cursor: "pointer", color: "var(--sage-text)" }}>
                <div style={{
                  width: "100%",
                  height: shown ? `${p.value == null ? 0 : Math.max(6, p.value * 100)}%` : 0,
                  minHeight: p.value == null ? 6 : 0,
                  borderRadius: "6px 6px 2px 2px",
                  background: p.value == null ? "transparent" : "var(--sage)",
                  border: p.value == null ? "var(--border-width) dashed var(--border)" : "none",
                  transition: histReduced() ? "none" : `height 900ms cubic-bezier(.16,1,.3,1) ${Math.min(i * 14, 500)}ms`,
                }} />
              </button>
            ))}
          </div>
          <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
            {view.points.map((p, i) => (
              <div key={p.key || i} style={{ flex: 1, maxWidth: 34, fontSize: 9.5, letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "center", color: "var(--text-tertiary)", overflow: "hidden" }}>{p.label}</div>
            ))}
          </div>
        </React.Fragment>
      )}

      {monthly && openMonth && (
        <div>
          <button type="button" className="vr-hist-mark" onClick={() => { setOpenMonth(null); setSelectedDay(null); }}
            style={{ display: "inline-flex", alignItems: "center", gap: 6, background: "none", border: "none", padding: 0, marginBottom: 12, cursor: "pointer", color: "var(--sage-text)", fontFamily: "var(--font-sans)", fontSize: 12.5, fontWeight: 500 }}>
            <i className="ph ph-arrow-left" style={{ fontSize: 13 }}></i>All months
          </button>
          <div style={{ fontSize: 12.5, color: "var(--text-tertiary)", marginBottom: 10 }}>
            {openPoint ? `${openPoint.label} · ${openPoint.n} day${openPoint.n === 1 ? "" : "s"} logged` : openMonth}
          </div>
          {dayGrid(openDays || [])}
        </div>
      )}

      {!monthly && dayGrid(view.points)}

      {/* The tap-to-read line. This is the whole answer to "how does a touch
          user get what a mouse user gets from title=" — select a mark, read
          the same sentence the tooltip would have said. */}
      <div style={{ minHeight: 17, marginTop: 10 }}>
        {selected && (
          <span style={{ fontSize: 12.5, color: "var(--sage-text)", fontWeight: 500 }}>
            {dayLabel(selected.date)} — {selected.value == null ? "nothing logged" : `${Math.round(selected.value * 100)}% of rituals held`}
          </span>
        )}
      </div>

      {/* The series carries its own text label — sage reads gray at this chroma
          and must never be identified by colour alone. */}
      <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
        <span style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 22, color: "var(--sage-text)", letterSpacing: "-0.01em" }}>{sum.full}</span>
        <span style={{ fontSize: 12.5, color: "var(--text-tertiary)" }}>day{sum.full === 1 ? "" : "s"} with every ritual held{monthly ? `, of ${sum.logged} logged` : ""}</span>
      </div>
    </div>
  );
}

// Wellness: sleep bars on teal, one per day or per month.
function SleepHistory({ view }) {
  const [ref, inView] = useHistInView();
  const monthly = view.mode === "monthly";
  const shown = inView || histReduced();
  const H = window.velourHistory;
  const [openMonth, setOpenMonth] = React.useState(null);
  const [selectedDay, setSelectedDay] = React.useState(null);

  const vals = view.points.map((p) => p.value).filter((v) => v != null);
  const max = Math.max(9, ...vals);
  // NIGHTS, NOT BARS. In monthly mode `vals.length` is a count of months, and
  // captioning it "across 9 nights logged" was simply a false number on a
  // member's own dashboard. summary() weights by nights logged and reports the
  // same average the daily view would.
  const sum = H.summary(view);
  const avg = sum.mean;
  const dayLabel = (iso) => new Date(iso + "T00:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
  // Same split as RitualHistory, same reason: a drilled day never carries the
  // {label, n} a monthly bucket has, so the two need their own formatters
  // rather than one branching on the outer view's mode.
  const monthTip = (p) => p.value == null ? `${p.label} — no entry` : `${p.label} — ${p.value.toFixed(1)}h average of ${p.n} night${p.n === 1 ? "" : "s"}`;
  const dayTip = (p) => p.value == null ? `${p.date} — no entry` : `${p.date} — ${p.value.toFixed(1)}h`;

  const openDays = openMonth ? H.monthDays(view, openMonth) : null;
  const openPoint = openMonth ? view.points.find((p) => p.key === openMonth) : null;
  const selected = selectedDay ? (openDays || view.dailyPoints || view.points).find((p) => p.date === selectedDay) : null;
  // A drilled month's own average, computed straight from its days rather than
  // via summary() — that helper expects a whole view, and a month's slice is
  // deliberately not one.
  const openAvg = React.useMemo(() => {
    if (!openDays) return null;
    const nights = openDays.filter((p) => p.value != null);
    return nights.length ? nights.reduce((a, p) => a + p.value, 0) / nights.length : null;
  }, [openDays]);

  // One bar renderer, two meanings depending on `mode` — "months" for the
  // overview (click navigates into that month), "days" for a single day
  // anywhere else (click selects it for the readout line below). Sharing the
  // markup keeps the two visually identical; the props branch is what
  // prevents a repeat of the tip-formatter bug above from happening here too.
  const dayBars = (points, gap, mode) => (
    <div style={{ display: "flex", alignItems: "flex-end", gap, height: 116 }}>
      {points.map((p, i) => {
        const h = p.value;
        const pct = h == null ? 0 : Math.max(6, (h / max) * 100);
        const isMonth = mode === "months";
        const label = isMonth ? `${monthTip(p)}. Tap to see the days.` : dayTip(p);
        const active = !isMonth && selectedDay === p.date;
        return (
          <button key={p.key || p.date || i} type="button" className="vr-hist-mark" title={isMonth ? monthTip(p) : dayTip(p)} aria-label={label} aria-pressed={isMonth ? undefined : active}
            onClick={() => (isMonth ? (setOpenMonth(p.key), setSelectedDay(null)) : setSelectedDay((d) => (d === p.date ? null : p.date)))}
            style={{ flex: 1, maxWidth: 34, height: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end", background: "none", border: "none", padding: 0, cursor: "pointer", color: "var(--teal-text)" }}>
            <div style={{
              width: "100%",
              height: shown ? `${pct}%` : 0,
              borderRadius: "6px 6px 2px 2px",
              background: h == null ? "transparent" : h >= 7 ? "var(--teal)" : "rgba(29,158,117,0.35)",
              border: h == null ? "var(--border-width) dashed var(--border)" : (active ? "1.5px solid var(--teal-text)" : "none"),
              minHeight: h == null ? 6 : 0,
              transition: histReduced() ? "none" : `height 900ms cubic-bezier(.16,1,.3,1) ${Math.min(i * 14, 500)}ms`,
            }} />
          </button>
        );
      })}
    </div>
  );

  return (
    <div ref={ref}>
      {HIST_FOCUS_STYLE}
      {monthly && !openMonth && (
        <React.Fragment>
          {dayBars(view.points, 6, "months")}
          <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
            {view.points.map((p, i) => (
              <div key={p.key || i} style={{ flex: 1, maxWidth: 34, fontSize: 9.5, letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "center", color: "var(--text-tertiary)", overflow: "hidden" }}>{p.label}</div>
            ))}
          </div>
        </React.Fragment>
      )}

      {monthly && openMonth && (
        <div>
          <button type="button" className="vr-hist-mark" onClick={() => { setOpenMonth(null); setSelectedDay(null); }}
            style={{ display: "inline-flex", alignItems: "center", gap: 6, background: "none", border: "none", padding: 0, marginBottom: 12, cursor: "pointer", color: "var(--teal-text)", fontFamily: "var(--font-sans)", fontSize: 12.5, fontWeight: 500 }}>
            <i className="ph ph-arrow-left" style={{ fontSize: 13 }}></i>All months
          </button>
          <div style={{ fontSize: 12.5, color: "var(--text-tertiary)", marginBottom: 10 }}>
            {openPoint ? `${openPoint.label} · ${openAvg == null ? "no nights logged" : `${openAvg.toFixed(1)}h average of ${openPoint.n} night${openPoint.n === 1 ? "" : "s"}`}` : openMonth}
          </div>
          {dayBars(openDays || [], 4, "days")}
        </div>
      )}

      {!monthly && dayBars(view.points, view.points.length > 40 ? 2 : view.points.length > 20 ? 3 : 6, "days")}

      <div style={{ minHeight: 17, marginTop: 10 }}>
        {selected && (
          <span style={{ fontSize: 12.5, color: "var(--teal-text)", fontWeight: 500 }}>
            {dayLabel(selected.date)} — {selected.value == null ? "no entry" : `${selected.value.toFixed(1)}h`}
          </span>
        )}
      </div>

      <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
        <span style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 22, color: "var(--teal-text)", letterSpacing: "-0.01em" }}>{avg == null ? "—" : `${Math.floor(avg)}h ${String(Math.round((avg % 1) * 60)).padStart(2, "0")}m`}</span>
        <span style={{ fontSize: 12.5, color: "var(--text-tertiary)" }}>average sleep across {sum.logged} night{sum.logged === 1 ? "" : "s"} logged</span>
      </div>
    </div>
  );
}
// Today's wardrobe — parity fix, 12 Sep 2026. Web's home dashboard had no
// wardrobe or efficiency snapshot at all; mobile has always shown both
// (WardrobeDonut, MoneyBar) alongside its charts. A member moving between
// surfaces saw four pillars represented on the phone and two on the laptop,
// which is what got reported back as "the long view shows different things on
// each surface" — it wasn't the long view, it was this gap. Current-state
// only, same as mobile's version: not plan-gated, not historical, no new data
// (computeFreshData already returns everything used here).
function FreshWardrobeOverview({ wardrobe, onOpen }) {
  if (!wardrobe.hasItems) return null;
  const worn = wardrobe.count - wardrobe.idle;
  const wornPct = wardrobe.count > 0 ? Math.round((worn / wardrobe.count) * 100) : 0;
  const measures = [
    { icon: "coat-hanger", label: "Worn", value: `${worn} of ${wardrobe.count} pieces`, pct: wornPct },
    { icon: "sparkle", label: "ROI", value: `${wardrobe.roi}%`, pct: wardrobe.roi },
  ];
  const line = wardrobe.idle > 0
    ? `${wardrobe.count} piece${wardrobe.count === 1 ? "" : "s"} in your capsule — ${wardrobe.idle} resting right now.`
    : `${wardrobe.count} piece${wardrobe.count === 1 ? "" : "s"} in your capsule, all earning their keep.`;

  return (
    <Card padding={0} style={{ overflow: "hidden" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "22px 26px 18px" }}>
        <div>
          <div style={{ fontSize: 11.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--amber-text)", fontWeight: 500, marginBottom: 8 }}>Today's wardrobe</div>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 21, lineHeight: 1.32, color: "var(--text-primary)", letterSpacing: "-0.01em", maxWidth: 560 }}>{line}</div>
        </div>
        <div style={{ textAlign: "right", flexShrink: 0, paddingLeft: 16 }}>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 28, color: "var(--amber-text)", letterSpacing: "-0.01em", whiteSpace: "nowrap" }}>{wardrobe.roi}<span style={{ color: "var(--text-tertiary)", fontSize: 18 }}>%</span></div>
          <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 2 }}>in rotation</div>
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1, background: "var(--divider)", borderTop: "var(--border-width) solid var(--divider)" }}>
        {measures.map((m) => (
          <div key={m.label} style={{ background: "var(--surface-card)", padding: "18px 22px 20px" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
              <i className={`ph ph-${m.icon}`} style={{ fontSize: 15, color: "var(--amber)" }}></i>
              <span style={{ fontSize: 13, color: "var(--text-secondary)", flex: 1 }}>{m.label}</span>
              <span style={{ fontSize: 13, color: "var(--text-primary)", whiteSpace: "nowrap" }}>{m.value}</span>
            </div>
            <ProgressBar value={m.pct} max={100} pillar="wardrobe" size="sm" />
          </div>
        ))}
      </div>

      <button onClick={() => onOpen && onOpen("wardrobe")} style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "14px 24px", background: "none", border: "none", borderTop: "var(--border-width) solid var(--divider)", cursor: "pointer", color: "var(--amber-text)", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500 }}>
        Open wardrobe for the full picture <i className="ph ph-arrow-right" style={{ fontSize: 14 }}></i>
      </button>
    </Card>
  );
}

// Today's efficiency — same parity fix, same rules: current-state, no new data.
function FreshEfficiencyOverview({ efficiency, onOpen }) {
  if (!efficiency.hasBudget) return null;
  const keptPct = efficiency.total > 0 ? Math.round((efficiency.remaining / efficiency.total) * 100) : 0;
  const spentPct = efficiency.total > 0 ? Math.round((efficiency.spent / efficiency.total) * 100) : 0;
  const measures = [
    { icon: "piggy-bank", label: "Kept", value: _money(efficiency.remaining), pct: keptPct },
    { icon: "credit-card", label: "Spent", value: _money(efficiency.spent), pct: spentPct },
  ];

  return (
    <Card padding={0} style={{ overflow: "hidden" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "22px 26px 18px" }}>
        <div>
          <div style={{ fontSize: 11.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--terracotta-text)", fontWeight: 500, marginBottom: 8 }}>Today's efficiency</div>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 21, lineHeight: 1.32, color: "var(--text-primary)", letterSpacing: "-0.01em", maxWidth: 560 }}>{_money(efficiency.remaining)} kept of {_money(efficiency.total)} planned this month.</div>
        </div>
        <div style={{ textAlign: "right", flexShrink: 0, paddingLeft: 16 }}>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 28, color: "var(--terracotta-text)", letterSpacing: "-0.01em", whiteSpace: "nowrap" }}>{keptPct}<span style={{ color: "var(--text-tertiary)", fontSize: 18 }}>%</span></div>
          <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 2 }}>kept</div>
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1, background: "var(--divider)", borderTop: "var(--border-width) solid var(--divider)" }}>
        {measures.map((m) => (
          <div key={m.label} style={{ background: "var(--surface-card)", padding: "18px 22px 20px" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
              <i className={`ph ph-${m.icon}`} style={{ fontSize: 15, color: "var(--terracotta)" }}></i>
              <span style={{ fontSize: 13, color: "var(--text-secondary)", flex: 1 }}>{m.label}</span>
              <span style={{ fontSize: 13, color: "var(--text-primary)", whiteSpace: "nowrap" }}>{m.value}</span>
            </div>
            <ProgressBar value={m.pct} max={100} pillar="efficiency" size="sm" />
          </div>
        ))}
      </div>

      <button onClick={() => onOpen && onOpen("efficiency")} style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "14px 24px", background: "none", border: "none", borderTop: "var(--border-width) solid var(--divider)", cursor: "pointer", color: "var(--terracotta-text)", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500 }}>
        Open efficiency for the full picture <i className="ph ph-arrow-right" style={{ fontSize: 14 }}></i>
      </button>
    </Card>
  );
}

function HistoryPanel({ pillarLabel, color, icon, title, range, note, children }) {
  return (
    <div style={{ background: "var(--surface-card)", padding: "22px 26px 24px" }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, marginBottom: 18, flexWrap: "wrap" }}>
        <div>
          <div style={{ fontSize: 11.5, letterSpacing: "0.14em", textTransform: "uppercase", color, fontWeight: 500, marginBottom: 7 }}>{pillarLabel}</div>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 19, lineHeight: 1.3, color: "var(--text-primary)", letterSpacing: "-0.01em" }}>{title}</div>
        </div>
        <span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--text-tertiary)", whiteSpace: "nowrap" }}>
          <i className={`ph ph-${icon}`} style={{ fontSize: 14, color }}></i>{range}
        </span>
      </div>
      {children}
      {note && <p style={{ fontSize: 12.5, lineHeight: 1.55, color: "var(--text-tertiary)", margin: "16px 0 0" }}>{note}</p>}
    </div>
  );
}

// The whole section. Renders nothing at all when there is nothing to draw —
// a member with no rituals and no wellness log gets the onboarding above, not
// two empty frames.
function FreshLongView({ data }) {
  const H = window.velourHistory;
  const rv = data.ritualView, sv = data.sleepView;
  const hasRituals = data.rituals.hasHabits;
  const hasSleep = sv.points.some((p) => p.value != null);
  if (!hasRituals && !hasSleep) return null;
  const note = (v) => (v.capped ? H.cappedNote() : null);
  return (
    <div>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", margin: "8px 2px 16px", gap: 14, flexWrap: "wrap" }}>
        <h2 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 24, color: "var(--text-primary)", margin: 0, letterSpacing: "-0.01em" }}>The long view</h2>
        {/* The justification, in the member's language. An insight says what is
            true now; this says whether it is moving. */}
        <span style={{ fontSize: 13.5, color: "var(--text-tertiary)" }}>Whether it's changing, not just where it stands</span>
      </div>
      <Card padding={0} style={{ overflow: "hidden" }}>
        <div style={{ display: "grid", gridTemplateColumns: hasRituals && hasSleep ? "repeat(auto-fit, minmax(320px, 1fr))" : "1fr", gap: 1, background: "var(--divider)" }}>
          {hasRituals && (
            <HistoryPanel pillarLabel="Rituals" color="var(--sage-text)" icon="sun" title="How steadily you've held them" range={H.rangeLabel(rv)} note={note(rv)}>
              <RitualHistory view={rv} />
            </HistoryPanel>
          )}
          {hasSleep && (
            <HistoryPanel pillarLabel="Wellness" color="var(--teal-text)" icon="moon" title="How you've been sleeping" range={H.rangeLabel(sv)} note={note(sv)}>
              <SleepHistory view={sv} />
            </HistoryPanel>
          )}
        </div>
      </Card>
    </div>
  );
}

function FreshHome({ onOpen }) {
  // Same reason as FreshGreeting: this renders "Welcome, {name}" from the same
  // asynchronously-resolved member.
  const plan = window.useVelourPlan ? window.useVelourPlan() : null;
  const [data, setData] = React.useState(computeFreshData);

  // Re-read storage whenever the dashboard regains focus (e.g. coming back from a pillar).
  React.useEffect(() => {
    const refresh = () => setData(computeFreshData());
    window.addEventListener("focus", refresh);
    return () => window.removeEventListener("focus", refresh);
  }, []);
  // AND WHENEVER THE PLAN RESOLVES. The long view's window is chosen inside
  // computeFreshData, which runs once on mount — before /api/me has answered, so
  // it reads the fallback plan. Subscribing without recomputing would re-render
  // the same stale series: a paying member would sit looking at the free window
  // for the life of the page. The ritual cap had this exact shape.
  const planTier = plan && plan.tier;
  const firstPlan = React.useRef(true);
  React.useEffect(() => {
    if (firstPlan.current) { firstPlan.current = false; return; }
    setData(computeFreshData());
  }, [planTier]);
  // And whenever the account's data lands. This is the screen a magic link opens,
  // so it is the one that decides whether a member arriving on a second device
  // sees their things or an empty account. Every pillar, since it summarises all four.
  if (window.useVelourSynced) window.useVelourSynced(null, () => setData(computeFreshData()));

  // First run — nothing logged anywhere yet. Keep the calm onboarding.
  if (!data.anyData) {
    return (
      <main style={{ maxWidth: 1240, margin: "0 auto", padding: "32px 32px 64px", display: "flex", flexDirection: "column", gap: 24 }}>
        <div className="velour-dark" style={{ background: "var(--ink)", borderRadius: 22, padding: "40px 36px", boxShadow: "var(--shadow-lg)" }}>
          <div style={{ fontSize: 13, letterSpacing: "0.04em", color: "var(--sand)", marginBottom: 10 }}>{onboardingName() ? `Welcome, ${onboardingName()}` : "Welcome to VELOUR"}</div>
          <h1 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 38, lineHeight: 1.08, letterSpacing: "-0.02em", color: "var(--text-primary)", margin: 0, maxWidth: 560 }}>Let's set up your calm.</h1>
          <p style={{ fontSize: 16, lineHeight: 1.6, color: "rgba(247,244,239,0.7)", margin: "14px 0 0", maxWidth: 520 }}>Four pillars, one quiet system. Nothing's tracked yet — start with whichever one calls to you, and the rest will follow.</p>
          <DailyNote text={buildDailyNote({ isFreshEmpty: true })} />
        </div>
        <div>
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", margin: "8px 2px 16px" }}>
            <h2 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 24, color: "var(--text-primary)", margin: 0, letterSpacing: "-0.01em" }}>Your four pillars</h2>
            <span style={{ fontSize: 13.5, color: "var(--text-tertiary)" }}>Tap one to begin</span>
          </div>
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", gap: 20 }}>
            {FRESH_PILLARS.map((p) => <PillarCard key={p.name} p={p} onOpen={onOpen} />)}
          </div>
        </div>
      </main>
    );
  }

  // Live dashboard — populated from the four pillars' real data.
  const cards = freshPillarCards(data);
  return (
    <main style={{ maxWidth: 1240, margin: "0 auto", padding: "32px 32px 64px", display: "flex", flexDirection: "column", gap: 24 }}>
      <FreshGreeting data={data} onOpen={onOpen} />
      <FreshInsight data={data} />
      {/* History sits directly under the insight on purpose: the insight says what
          is true now, and this is the answer to "yes, but is it moving?" */}
      <FreshLongView data={data} />
      <HeldTogetherCard onJoined={() => setData(computeFreshData())} />
      <div>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", margin: "8px 2px 16px" }}>
          <h2 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 24, color: "var(--text-primary)", margin: 0, letterSpacing: "-0.01em" }}>Your four pillars</h2>
          <span style={{ fontSize: 13.5, color: "var(--text-tertiary)" }}>Logged data syncs here</span>
        </div>
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", gap: 20 }}>
          {cards.map((p) => <PillarCard key={p.name} p={p} onOpen={onOpen} />)}
        </div>
      </div>
      <FreshRitualsOverview rituals={data.rituals} onOpen={onOpen} />
      <FreshWellnessOverview wellness={data.wellness} onOpen={onOpen} />
      {/* Wardrobe and Efficiency, added 12 Sep 2026 to close the gap mobile
          never had — see the comment above FreshWardrobeOverview. */}
      <FreshWardrobeOverview wardrobe={data.wardrobe} onOpen={onOpen} />
      <FreshEfficiencyOverview efficiency={data.efficiency} onOpen={onOpen} />
      <window.EducationSection mode="full" onOpen={onOpen} />
    </main>
  );
}

function DashHome({ onOpen }) {
  if (FRESH) return <FreshHome onOpen={onOpen} />;
  return (
    <main style={{ maxWidth: 1240, margin: "0 auto", padding: "32px 32px 64px", display: "flex", flexDirection: "column", gap: 24 }}>
      <Greeting />
      {/* Legacy demo dashboard: it reads nothing from ritual storage, so there is
          nothing here to tell. onJoined is deliberately absent rather than wired
          to state this component does not have. */}
      <HeldTogetherCard />
      <InsightTip onOpen={onOpen} />
      <div>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", margin: "8px 2px 16px" }}>
          <h2 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 24, color: "var(--text-primary)", margin: 0, letterSpacing: "-0.01em" }}>Your four pillars</h2>
          <span style={{ fontSize: 13.5, color: "var(--text-tertiary)" }}>Tap any pillar to dive in</span>
        </div>
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", gap: 20 }}>
          {PILLAR_CARDS.map((p) => <PillarCard key={p.name} p={p} onOpen={onOpen} />)}
        </div>
      </div>
      <RitualChecklist />
      <window.EducationSection mode="full" onOpen={onOpen} />
    </main>
  );
}

function Dashboard({ onNavigate }) {
  const [section, setSection] = React.useState("dashboard");
  React.useEffect(() => { window.scrollTo(0, 0); }, [section]);
  let body;
  if (section === "rituals") body = <window.DashRituals onNavigate={onNavigate} />;
  else if (section === "wellness") body = <window.DashWellness />;
  else if (section === "wardrobe") body = <window.DashWardrobe onNavigate={onNavigate} />;
  else if (section === "efficiency") body = <window.DashEfficiency />;
  else if (section === "education") body = <window.DashEducation />;
  else if (section === "blog") body = <window.DashBlog onNavigate={onNavigate} />;
  else body = <DashHome onOpen={setSection} />;
  return (
    <div style={{ background: "var(--surface-sunken)", minHeight: "100vh", fontFamily: "var(--font-sans)" }}>
      <window.DashTopBar onNavigate={onNavigate} section={section} onSection={setSection} />
      {/* Keyed so each pillar switch replays the calm fade-up (see .vr-page-in). */}
      <div key={section} className="vr-page-in">{body}</div>
    </div>
  );
}

window.Dashboard = Dashboard;
