/ src / ui.js
ui.js
   1  import {
   2    APP_ENTRY_KEY,
   3    AUTH_STATE_KEY,
   4    BUTTON_RESET_DELAY_MS,
   5    COLOR_PALETTE,
   6    DEV_HOSTS,
   7    DEV_POLL_MS,
   8    DEMO_MODE,
   9    DOT_NAME_MAX_LENGTH,
  10    MENU_SCRIM_HIDE_MS,
  11    MOBILE_BREAKPOINT,
  12    MOBILE_MONTH_BATCH_SIZE,
  13    MOBILE_SCROLL_NEAR_TOP,
  14    MODAL_ANIMATION_MS,
  15    ONBOARDING_KEY,
  16    PERIOD_SCROLL_THRESHOLD,
  17    POPOVER_ANIMATION_MS,
  18    SUGGESTED_DOT_TYPES,
  19    STORAGE_KEY,
  20    SUPPRESS_DAY_CLOSE_MS,
  21    SUPPRESS_DAY_OPEN_MS,
  22    VIEW_MODE_KEY,
  23    YEAR_BATCH_SIZE
  24  } from "./constants.js";
  25  import {
  26    appShell,
  27    colorModeDarkButton,
  28    colorModeLightButton,
  29    deleteModal,
  30    deleteText,
  31    dotTypeList,
  32    hideSuggestionsInput,
  33    marketingCalendar,
  34    marketingHero,
  35    marketingLogin,
  36    marketingMonth,
  37    marketingPage,
  38    marketingYear,
  39    menuScrim,
  40    mobileMenuPortal,
  41    monthGrid,
  42    onboardingDotTypeList,
  43    onboardingModal,
  44    onboardingSuggestedDotList,
  45    periodPickerLabel,
  46    periodPickerMenu,
  47    popover,
  48    popoverItemTemplate,
  49    popoverScrim,
  50    showKeyboardHintsInput,
  51    settingsModal,
  52    suggestedDotContent,
  53    suggestedDotList,
  54    todayButton,
  55    weekStartMondayInput,
  56    yearGrid
  57  } from "./dom.js";
  58  import {
  59    buildMonthCells,
  60    clamp,
  61    formatISODate,
  62    hash32,
  63    isMobileView,
  64    monthDiff,
  65    startOfMonth,
  66    weekdayShort
  67  } from "./utils.js";
  68  import {
  69    clearDotPosition,
  70    createDemoState,
  71    defaultState,
  72    getDayDotIds,
  73    getDayNote,
  74    getDemoDotPosition,
  75    getDotPosition,
  76    normalizeImportedState,
  77    normalizeDotTypeName,
  78    requestRender,
  79    saveAndRender,
  80    saveDotPosition,
  81    setDayNote,
  82    setState,
  83    state
  84  } from "./state.js";
  85  import { showToast } from "./toast.js";
  86  
  87  let activePopover = null;
  88  let activeNoteEdit = null;
  89  let activeNoteEditMonthIso = null;
  90  let pendingFocusDotId = null;
  91  let pendingDeleteDotTypeId = null;
  92  let pendingDeleteMode = "safe";
  93  let pendingDeleteDotTypeName = "";
  94  let loadedYearBatchCount = 1;
  95  let loadedMobileMonthCount = 24;
  96  let periodLoadInProgress = false;
  97  let suppressDayOpenUntil = 0;
  98  let monthScrollAttached = false;
  99  let hasInitializedMobileMonthScroll = false;
 100  let pendingMobileMonthAnchorIso = null;
 101  let lastObservedMobileMonthIso = null;
 102  let settingsModalHideTimer = null;
 103  let popoverHideTimer = null;
 104  let menuScrimHideTimer = null;
 105  let hasEnteredApp = false;
 106  let loginMode = false;
 107  let updateAuthUIFn = () => {};
 108  let lastFocusTrigger = null;
 109  
 110  // WeakMaps for private state on DOM elements (avoids underscore-prefixed properties).
 111  const portalParents = new WeakMap();
 112  const hostRows = new WeakMap();
 113  const hexInputs = new WeakMap();
 114  const currentColors = new WeakMap();
 115  
 116  // Cached lookups rebuilt each render cycle.
 117  let dotTypeMap = new Map();
 118  let dotTypeInUseSet = new Set();
 119  const DOT_SPACING_BY_MODE = {
 120    year: 9,
 121    month: 11
 122  };
 123  const DOT_BOUNDS = {
 124    minLeft: 6,
 125    maxLeft: 94,
 126    minTop: 12,
 127    maxTop: 88
 128  };
 129  const DOT_CANDIDATE_OFFSETS = [
 130    [0, 0],
 131    [1, 0],
 132    [-1, 0],
 133    [0, 1],
 134    [0, -1],
 135    [1, 1],
 136    [-1, 1],
 137    [1, -1],
 138    [-1, -1],
 139    [2, 0],
 140    [-2, 0],
 141    [0, 2],
 142    [0, -2],
 143    [2, 1],
 144    [-2, 1],
 145    [2, -1],
 146    [-2, -1],
 147    [1, 2],
 148    [-1, 2],
 149    [1, -2],
 150    [-1, -2]
 151  ];
 152  const MARKETING_DOT_COLORS = {
 153    "demo-exercise": "#FF0000",
 154    "demo-sugar": "#FF7A00",
 155    "demo-slept": "#FFC700",
 156    "demo-reading": "#15C771",
 157    "demo-cooking": "#2F8CFA",
 158    "demo-social": "#B632CC",
 159    "demo-movie": "#2F8CFA",
 160    "demo-music": "#15C771"
 161  };
 162  
 163  function getMarketingDotColor(dotType) {
 164    return MARKETING_DOT_COLORS[dotType.id] || dotType.color;
 165  }
 166  
 167  function reduceMarketingDotDensity(demoState) {
 168    const reducedDayDots = {};
 169    const keepWeekdaysByDot = {
 170      "demo-exercise": new Set([1, 3, 5]),
 171      "demo-slept": new Set([1, 2, 4, 6]),
 172      "demo-reading": new Set([1, 4]),
 173      "demo-cooking": new Set([0, 2, 6]),
 174      "demo-social": new Set([2, 4, 6]),
 175      "demo-sugar": new Set([6]),
 176      "demo-movie": new Set([5]),
 177      "demo-music": new Set([0, 6])
 178    };
 179  
 180    Object.entries(demoState.dayDots || {}).forEach(([iso, dotIds]) => {
 181      const date = new Date(`${iso}T00:00:00`);
 182      const weekday = date.getDay();
 183      const month = date.getMonth();
 184      const weekBucket = Math.floor((date.getDate() - 1) / 7);
 185  
 186      const kept = dotIds.filter((dotId) => {
 187        const weekdaySet = keepWeekdaysByDot[dotId];
 188        if (!weekdaySet || !weekdaySet.has(weekday)) return false;
 189  
 190        // Keep seasonal signals visible while still reducing total dot count.
 191        if (dotId === "demo-social") {
 192          if (month >= 2 && month <= 3) return weekday !== 6 || weekBucket % 2 === 0;
 193          if (month === 4) return false;
 194          return weekBucket % 2 === 0;
 195        }
 196        if (dotId === "demo-sugar") {
 197          if (month >= 10) return weekday === 0 || weekday === 6;
 198          return weekday === 6 && weekBucket % 2 === 0;
 199        }
 200        if (dotId === "demo-music" && month === 4) {
 201          return [1, 3, 5].includes(weekday);
 202        }
 203        if (dotId === "demo-slept" && month >= 10) {
 204          return hash32(`${iso}|${dotId}|holiday-thin`) % 3 !== 0;
 205        }
 206        return true;
 207      });
 208  
 209      if (kept.length > 0) reducedDayDots[iso] = kept;
 210    });
 211    demoState.dayDots = reducedDayDots;
 212  }
 213  
 214  
 215  export function registerAuthUpdater(fn) {
 216    updateAuthUIFn = fn;
 217  }
 218  
 219  export function getHasEnteredApp() {
 220    return hasEnteredApp;
 221  }
 222  
 223  export function setHasEnteredApp(value) {
 224    hasEnteredApp = value;
 225  }
 226  
 227  export function setLoginMode(value) {
 228    loginMode = value;
 229  }
 230  
 231  export function showLogin() {
 232    loginMode = true;
 233    marketingHero?.classList.add("hidden");
 234    marketingLogin?.classList.remove("hidden");
 235  }
 236  
 237  export function showMarketingHero() {
 238    loginMode = false;
 239    marketingLogin?.classList.add("hidden");
 240    marketingHero?.classList.remove("hidden");
 241  }
 242  
 243  export function showMarketingPage() {
 244    try {
 245      localStorage.setItem(VIEW_MODE_KEY, "marketing");
 246    } catch {
 247      // ignore
 248    }
 249    marketingPage?.classList.remove("hidden");
 250    appShell?.classList.add("hidden");
 251  }
 252  
 253  export function resetToLoggedOut() {
 254    hasEnteredApp = false;
 255    loginMode = false;
 256    try {
 257      localStorage.removeItem(APP_ENTRY_KEY);
 258    } catch {
 259      // ignore
 260    }
 261    marketingLogin?.classList.add("hidden");
 262    marketingHero?.classList.remove("hidden");
 263    showMarketingPage();
 264  }
 265  
 266  export function render() {
 267    const active = document.activeElement;
 268    const editingNote = active instanceof HTMLElement && active.classList.contains("note-editor");
 269    if (editingNote) return;
 270  
 271    // Rebuild per-render caches for O(1) lookups.
 272    dotTypeMap = new Map(state.dotTypes.map((d) => [d.id, d]));
 273    dotTypeInUseSet = new Set();
 274    for (const ids of Object.values(state.dayDots)) {
 275      for (const id of ids) dotTypeInUseSet.add(id);
 276    }
 277  
 278    applyTheme();
 279    renderPeriodPicker();
 280    renderDiaryGrid();
 281    updateTodayButtonVisibility();
 282    renderDotTypeList();
 283    if (weekStartMondayInput) weekStartMondayInput.checked = Boolean(state.weekStartsMonday);
 284    if (hideSuggestionsInput) hideSuggestionsInput.checked = !state.hideSuggestions;
 285    if (showKeyboardHintsInput) showKeyboardHintsInput.checked = Boolean(state.showKeyboardHints);
 286    document.documentElement.classList.toggle("hide-keyboard-hints", !state.showKeyboardHints);
 287    if (suggestedDotContent) {
 288      suggestedDotContent.classList.toggle("hidden", Boolean(state.hideSuggestions));
 289    }
 290    const darkModeEnabled = isDarkModeEnabled();
 291    colorModeLightButton?.classList.toggle("active", !darkModeEnabled);
 292    colorModeDarkButton?.classList.toggle("active", darkModeEnabled);
 293    renderSuggestedDotTypes();
 294    renderOnboardingLists();
 295    updateAuthUIFn();
 296  }
 297  
 298  function isViewingTodayMonth() {
 299    const selectedMonth = startOfMonth(new Date(state.monthCursor));
 300    const currentMonth = startOfMonth(new Date());
 301    return (
 302      selectedMonth.getFullYear() === currentMonth.getFullYear() &&
 303      selectedMonth.getMonth() === currentMonth.getMonth()
 304    );
 305  }
 306  
 307  function updateTodayButtonVisibility() {
 308    if (!todayButton) return;
 309    const isMobileMonthView = isMobileView() && !monthGrid.classList.contains("hidden");
 310    let shouldShow = false;
 311    if (isMobileMonthView) {
 312      const nearBottom = monthGrid.scrollTop + monthGrid.clientHeight >= monthGrid.scrollHeight - 28;
 313      shouldShow = !nearBottom && !isViewingTodayMonth();
 314    }
 315    todayButton.classList.toggle("hidden", !shouldShow);
 316  }
 317  
 318  export function renderPeriodPicker(preserveScroll = false, previousScrollTop = 0) {
 319    if (!periodPickerMenu || !periodPickerLabel) return;
 320    const currentYear = new Date().getFullYear();
 321    if (state.yearCursor > currentYear) {
 322      state.yearCursor = currentYear;
 323    }
 324    periodPickerMenu.innerHTML = "";
 325    if (isMobileView()) {
 326      const selectedMonthDate = new Date(state.monthCursor);
 327      const currentMonthDate = startOfMonth(new Date());
 328      if (selectedMonthDate > currentMonthDate) {
 329        state.monthCursor = currentMonthDate.toISOString();
 330        state.yearCursor = currentMonthDate.getFullYear();
 331      }
 332      const normalizedSelectedMonthDate = new Date(state.monthCursor);
 333      periodPickerLabel.textContent = normalizedSelectedMonthDate.toLocaleDateString(undefined, {
 334        month: "short",
 335        year: "numeric"
 336      });
 337      const selectedMonthDiff = monthDiff(currentMonthDate, normalizedSelectedMonthDate);
 338      if (selectedMonthDiff >= loadedMobileMonthCount) {
 339        loadedMobileMonthCount = selectedMonthDiff + 1;
 340      }
 341  
 342      for (let i = 0; i < loadedMobileMonthCount; i += 1) {
 343        const optionDate = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() - i, 1);
 344        const optionYear = optionDate.getFullYear();
 345        const optionMonth = optionDate.getMonth();
 346  
 347        const item = document.createElement("button");
 348        item.type = "button";
 349        item.className = "period-picker-item";
 350        if (
 351          optionYear === normalizedSelectedMonthDate.getFullYear() &&
 352          optionMonth === normalizedSelectedMonthDate.getMonth()
 353        ) {
 354          item.classList.add("active");
 355        }
 356        item.textContent = optionDate.toLocaleDateString(undefined, { month: "short", year: "numeric" });
 357        item.addEventListener("click", () => {
 358          state.monthCursor = startOfMonth(optionDate).toISOString();
 359          state.yearCursor = optionYear;
 360          pendingMobileMonthAnchorIso = state.monthCursor;
 361          closePeriodMenu();
 362          saveAndRender();
 363        });
 364        periodPickerMenu.appendChild(item);
 365      }
 366  
 367      if (preserveScroll) {
 368        periodPickerMenu.scrollTop = previousScrollTop;
 369      }
 370      return;
 371    }
 372  
 373    periodPickerLabel.textContent = String(state.yearCursor);
 374    const minLoadedYear = currentYear - loadedYearBatchCount * YEAR_BATCH_SIZE + 1;
 375    if (state.yearCursor < minLoadedYear) {
 376      loadedYearBatchCount = Math.ceil((currentYear - state.yearCursor + 1) / YEAR_BATCH_SIZE);
 377    }
 378  
 379    const oldestLoadedYear = currentYear - loadedYearBatchCount * YEAR_BATCH_SIZE + 1;
 380    for (let year = currentYear; year >= oldestLoadedYear; year -= 1) {
 381      const item = document.createElement("button");
 382      item.type = "button";
 383      item.className = "period-picker-item";
 384      if (year === state.yearCursor) item.classList.add("active");
 385      item.textContent = String(year);
 386      item.addEventListener("click", () => {
 387        state.yearCursor = year;
 388        const monthDate = new Date(state.monthCursor);
 389        monthDate.setFullYear(year);
 390        state.monthCursor = startOfMonth(monthDate).toISOString();
 391        closePeriodMenu();
 392        saveAndRender();
 393      });
 394      periodPickerMenu.appendChild(item);
 395    }
 396  
 397    if (preserveScroll) {
 398      periodPickerMenu.scrollTop = previousScrollTop;
 399    }
 400  }
 401  
 402  export function shiftYearBy(delta) {
 403    if (isMobileView()) return;
 404    const amount = Number(delta) || 0;
 405    if (!amount) return;
 406    const currentYear = new Date().getFullYear();
 407    const nextYear = Math.min(currentYear, state.yearCursor + amount);
 408    if (nextYear === state.yearCursor) return;
 409    state.yearCursor = nextYear;
 410    const monthDate = new Date(state.monthCursor);
 411    monthDate.setFullYear(nextYear);
 412    state.monthCursor = startOfMonth(monthDate).toISOString();
 413    closePeriodMenu();
 414    saveAndRender();
 415  }
 416  
 417  export function renderDiaryGrid() {
 418    if (isMobileView()) {
 419      const wasHidden = monthGrid.classList.contains("hidden");
 420      yearGrid.classList.add("hidden");
 421      monthGrid.classList.remove("hidden");
 422      if (wasHidden) {
 423        pendingMobileMonthAnchorIso = startOfMonth(new Date(state.monthCursor)).toISOString();
 424      }
 425      renderMonthGrid();
 426    } else {
 427      monthGrid.classList.add("hidden");
 428      yearGrid.classList.remove("hidden");
 429      renderYearGrid();
 430    }
 431  }
 432  
 433  export function renderYearGrid() {
 434    const year = state.yearCursor;
 435    const todayIso = formatISODate(new Date());
 436    yearGrid.innerHTML = "";
 437  
 438    for (let monthIndex = 0; monthIndex < 12; monthIndex += 1) {
 439      const column = document.createElement("section");
 440      column.className = "month-column";
 441      if (new Date(year, monthIndex + 1, 0).getDate() === 31) {
 442        column.classList.add("month-31");
 443      }
 444  
 445      const monthTitle = document.createElement("h3");
 446      monthTitle.className = "month-title";
 447      monthTitle.textContent = new Date(year, monthIndex, 1).toLocaleDateString(undefined, { month: "long" });
 448      column.appendChild(monthTitle);
 449  
 450      const daysInMonth = new Date(year, monthIndex + 1, 0).getDate();
 451      for (let dayNum = 1; dayNum <= 31; dayNum += 1) {
 452        if (dayNum > daysInMonth) {
 453          const filler = document.createElement("div");
 454          filler.className = "year-day filler";
 455          column.appendChild(filler);
 456          continue;
 457        }
 458  
 459        const date = new Date(year, monthIndex, dayNum);
 460        const iso = formatISODate(date);
 461        const isEditingThisDay = activeNoteEdit === iso;
 462        const row = document.createElement(isEditingThisDay ? "div" : "button");
 463        if (!isEditingThisDay) row.type = "button";
 464        row.className = "year-day";
 465        if (iso === todayIso) row.classList.add("current-day");
 466        row.dataset.date = iso;
 467  
 468        const label = document.createElement("span");
 469        label.className = "day-label";
 470        label.textContent = `${String(dayNum).padStart(2, "0")} ${weekdayShort(date)}`;
 471        row.appendChild(label);
 472  
 473        const dotLayer = document.createElement("div");
 474        dotLayer.className = "dot-layer";
 475        const dayDotIds = getDayDotIds(iso);
 476        const resolvedPositions = resolveDotPositionsForDay({
 477          isoDate: iso,
 478          dotIds: dayDotIds,
 479          mode: "year",
 480          getBasePosition: (dotId) => getDotPosition(iso, dotId, "year"),
 481          isLocked: (dotId) => Boolean(state.dotPositions?.[iso]?.[dotId])
 482        });
 483        dayDotIds.forEach((dotId) => {
 484          const dotType = dotTypeMap.get(dotId);
 485          if (!dotType) return;
 486          const sticker = document.createElement("span");
 487          sticker.className = "dot-sticker";
 488          sticker.style.background = dotType.color;
 489          const pos = resolvedPositions.get(dotId) || getDotPosition(iso, dotId, "year");
 490          sticker.style.left = `${pos.left}%`;
 491          sticker.style.top = `${pos.top}%`;
 492          sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`;
 493          sticker.title = `${dotType.name} (drag to move)`;
 494          sticker.addEventListener("pointerdown", (event) => {
 495            startDotDrag(event, { isoDate: iso, dotId, sticker, mode: "year" });
 496          });
 497          dotLayer.appendChild(sticker);
 498        });
 499        row.appendChild(dotLayer);
 500  
 501        const note = getDayNote(iso);
 502        if (activeNoteEdit === iso) {
 503          row.appendChild(buildNoteEditor(iso, "day-note", null));
 504        } else if (note) {
 505          const noteNode = document.createElement("span");
 506          noteNode.className = "day-note";
 507          noteNode.textContent = note;
 508          row.appendChild(noteNode);
 509        }
 510  
 511        if (!isEditingThisDay) {
 512          row.addEventListener("click", (event) => {
 513            if (Date.now() < suppressDayOpenUntil) return;
 514            if (activePopover && activePopover.isoDate !== iso) {
 515              closePopover();
 516              return;
 517            }
 518            openPopover(iso, event.clientX, event.clientY, null);
 519          });
 520        }
 521        column.appendChild(row);
 522      }
 523  
 524      yearGrid.appendChild(column);
 525    }
 526  }
 527  
 528  export function renderMonthGrid() {
 529    const selectedMonthDate = startOfMonth(new Date(state.monthCursor));
 530    const currentMonthDate = startOfMonth(new Date());
 531    const todayIso = formatISODate(new Date());
 532    const previousScrollTop = monthGrid.scrollTop;
 533    monthGrid.innerHTML = "";
 534    monthGrid.classList.add("month-scroll-list");
 535  
 536    const buildMonthCell = (day, monthIso) => {
 537      const isEditingThisDay =
 538        activeNoteEdit === day.iso &&
 539        (!activeNoteEditMonthIso || activeNoteEditMonthIso === monthIso);
 540      const cell = document.createElement(isEditingThisDay ? "div" : "button");
 541      if (!isEditingThisDay) cell.type = "button";
 542      cell.className = "month-day";
 543      if (day.iso === todayIso) cell.classList.add("current-day");
 544      if (!day.inCurrentMonth) cell.classList.add("muted-day");
 545      cell.dataset.date = day.iso;
 546  
 547      const dayLabel = document.createElement("div");
 548      dayLabel.className = "month-day-label";
 549      dayLabel.textContent = `${String(day.date.getDate()).padStart(2, "0")} ${weekdayShort(day.date)}`;
 550      cell.appendChild(dayLabel);
 551  
 552      const dotLayer = document.createElement("div");
 553      dotLayer.className = "dot-layer";
 554      const dayDotIds = getDayDotIds(day.iso);
 555      const resolvedPositions = resolveDotPositionsForDay({
 556        isoDate: day.iso,
 557        dotIds: dayDotIds,
 558        mode: "month",
 559        getBasePosition: (dotId) => getDotPosition(day.iso, dotId, "month"),
 560        isLocked: (dotId) => Boolean(state.dotPositions?.[day.iso]?.[dotId])
 561      });
 562      dayDotIds.forEach((dotId) => {
 563        const dotType = dotTypeMap.get(dotId);
 564        if (!dotType) return;
 565        const sticker = document.createElement("span");
 566        sticker.className = "dot-sticker";
 567        sticker.style.background = dotType.color;
 568        const pos = resolvedPositions.get(dotId) || getDotPosition(day.iso, dotId, "month");
 569        sticker.style.left = `${pos.left}%`;
 570        sticker.style.top = `${pos.top}%`;
 571        sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`;
 572        sticker.title = `${dotType.name} (drag to move)`;
 573        sticker.addEventListener("pointerdown", (event) => {
 574          startDotDrag(event, { isoDate: day.iso, dotId, sticker, mode: "month" });
 575        });
 576        dotLayer.appendChild(sticker);
 577      });
 578      cell.appendChild(dotLayer);
 579  
 580      const note = getDayNote(day.iso);
 581      if (isEditingThisDay) {
 582        cell.appendChild(buildNoteEditor(day.iso, "month-note", monthIso));
 583      } else if (note) {
 584        const noteNode = document.createElement("span");
 585        noteNode.className = "month-note";
 586        noteNode.textContent = note;
 587        cell.appendChild(noteNode);
 588      }
 589  
 590      if (!isEditingThisDay) {
 591        cell.addEventListener("click", (event) => {
 592          if (Date.now() < suppressDayOpenUntil) return;
 593          if (activePopover && activePopover.isoDate !== day.iso) {
 594            closePopover();
 595            return;
 596          }
 597          openPopover(day.iso, event.clientX, event.clientY, monthIso);
 598        });
 599      }
 600      return cell;
 601    };
 602  
 603    const monthsToRender = Math.max(loadedMobileMonthCount, monthDiff(currentMonthDate, selectedMonthDate) + 1);
 604  
 605    const monthSections = [];
 606    for (let i = 0; i < monthsToRender; i += 1) {
 607      const monthDate = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth() - i, 1);
 608      const monthIso = startOfMonth(monthDate).toISOString();
 609  
 610      const section = document.createElement("section");
 611      section.className = "month-scroll-section";
 612      section.dataset.monthIso = monthIso;
 613  
 614      const title = document.createElement("h3");
 615      title.className = "month-scroll-title";
 616      title.textContent = monthDate.toLocaleDateString(undefined, { month: "long", year: "numeric" });
 617      section.appendChild(title);
 618  
 619      const daysWrap = document.createElement("div");
 620      daysWrap.className = "month-scroll-days";
 621      const days = buildMonthCells(monthDate, state.weekStartsMonday);
 622      days.forEach((day) => {
 623        daysWrap.appendChild(buildMonthCell(day, monthIso));
 624      });
 625      section.appendChild(daysWrap);
 626      monthSections.push(section);
 627    }
 628  
 629    for (let i = monthSections.length - 1; i >= 0; i -= 1) {
 630      monthGrid.appendChild(monthSections[i]);
 631    }
 632  
 633    if (!monthGrid.dataset.scrollListenerAttached) {
 634      monthGrid.dataset.scrollListenerAttached = "1";
 635      monthGrid.addEventListener(
 636        "scroll",
 637        () => {
 638          if (!isMobileView()) return;
 639          const sections = monthGrid.querySelectorAll(".month-scroll-section");
 640          const containerRect = monthGrid.getBoundingClientRect();
 641          const containerCenter = containerRect.top + containerRect.height / 2;
 642          let nearest = null;
 643          let nearestDistance = Number.POSITIVE_INFINITY;
 644          sections.forEach((section) => {
 645            const rect = section.getBoundingClientRect();
 646            const sectionCenter = rect.top + rect.height / 2;
 647            const distance = Math.abs(sectionCenter - containerCenter);
 648            if (distance < nearestDistance) {
 649              nearestDistance = distance;
 650              nearest = section;
 651            }
 652          });
 653          if (!nearest?.dataset.monthIso) return;
 654          const nearestMonthIso = nearest.dataset.monthIso;
 655          if (nearestMonthIso !== lastObservedMobileMonthIso) {
 656            lastObservedMobileMonthIso = nearestMonthIso;
 657            const monthDate = new Date(nearestMonthIso);
 658            state.monthCursor = startOfMonth(monthDate).toISOString();
 659            state.yearCursor = monthDate.getFullYear();
 660            periodPickerLabel.textContent = monthDate.toLocaleDateString(undefined, {
 661              month: "short",
 662              year: "numeric"
 663            });
 664          }
 665          updateTodayButtonVisibility();
 666  
 667          const nearTop = monthGrid.scrollTop <= MOBILE_SCROLL_NEAR_TOP;
 668          if (nearTop && !periodLoadInProgress) {
 669            periodLoadInProgress = true;
 670            const previousHeight = monthGrid.scrollHeight;
 671            const previousTop = monthGrid.scrollTop;
 672            loadedMobileMonthCount += MOBILE_MONTH_BATCH_SIZE;
 673            requestRender(() => {
 674              const addedHeight = monthGrid.scrollHeight - previousHeight;
 675              monthGrid.scrollTop = previousTop + Math.max(0, addedHeight);
 676              periodLoadInProgress = false;
 677            });
 678          }
 679        },
 680        { passive: true }
 681      );
 682    }
 683  
 684    const selectedMonthIso = selectedMonthDate.toISOString();
 685    const initialAnchorIso = !hasInitializedMobileMonthScroll ? selectedMonthIso : null;
 686    const stateChangeAnchorIso =
 687      hasInitializedMobileMonthScroll && selectedMonthIso !== lastObservedMobileMonthIso ? selectedMonthIso : null;
 688    const targetAnchorIso = pendingMobileMonthAnchorIso || stateChangeAnchorIso || initialAnchorIso;
 689    lastObservedMobileMonthIso = targetAnchorIso || selectedMonthIso;
 690    if (targetAnchorIso) {
 691      requestAnimationFrame(() => {
 692        const target = monthGrid.querySelector(`[data-month-iso="${targetAnchorIso}"]`);
 693        if (target) {
 694          target.scrollIntoView({ block: "start" });
 695        } else {
 696          monthGrid.scrollTop = previousScrollTop;
 697        }
 698        hasInitializedMobileMonthScroll = true;
 699        pendingMobileMonthAnchorIso = null;
 700        updateTodayButtonVisibility();
 701      });
 702    } else {
 703      monthGrid.scrollTop = previousScrollTop;
 704      updateTodayButtonVisibility();
 705    }
 706  }
 707  
 708  export function scrollToToday() {
 709    const today = new Date();
 710    const todayMonth = startOfMonth(today);
 711  
 712    state.monthCursor = todayMonth.toISOString();
 713    state.yearCursor = todayMonth.getFullYear();
 714    closePeriodMenu();
 715    periodPickerLabel.textContent = todayMonth.toLocaleDateString(undefined, {
 716      month: "short",
 717      year: "numeric"
 718    });
 719  
 720    if (isMobileView() && monthGrid?.classList.contains("month-scroll-list")) {
 721      pendingMobileMonthAnchorIso = null;
 722      monthGrid.scrollTo({
 723        top: Math.max(0, monthGrid.scrollHeight - monthGrid.clientHeight),
 724        behavior: "smooth"
 725      });
 726      requestAnimationFrame(() => updateTodayButtonVisibility());
 727      return;
 728    }
 729  
 730    saveAndRender();
 731  }
 732  
 733  function resolveDotPositionsForDay({ isoDate, dotIds, mode, getBasePosition, isLocked }) {
 734    const resolved = new Map();
 735    if (!Array.isArray(dotIds) || dotIds.length <= 1) return resolved;
 736  
 737    const spacing = DOT_SPACING_BY_MODE[mode] || DOT_SPACING_BY_MODE.year;
 738    const clamped = (left, top) => ({
 739      left: clamp(left, DOT_BOUNDS.minLeft, DOT_BOUNDS.maxLeft),
 740      top: clamp(top, DOT_BOUNDS.minTop, DOT_BOUNDS.maxTop)
 741    });
 742    const distanceToClosest = (candidate) => {
 743      const points = [...resolved.values()];
 744      if (points.length === 0) return Number.POSITIVE_INFINITY;
 745      return points.reduce((min, placed) => {
 746        const dx = placed.left - candidate.left;
 747        const dy = placed.top - candidate.top;
 748        return Math.min(min, Math.hypot(dx, dy));
 749      }, Number.POSITIVE_INFINITY);
 750    };
 751  
 752    const makeCandidates = (base) =>
 753      DOT_CANDIDATE_OFFSETS.map(([ox, oy]) => {
 754        const point = clamped(base.left + ox * spacing, base.top + oy * spacing);
 755        return { ...base, left: point.left, top: point.top };
 756      });
 757  
 758    dotIds.forEach((dotId) => {
 759      if (!isLocked(dotId)) return;
 760      resolved.set(dotId, getBasePosition(dotId));
 761    });
 762  
 763    dotIds.forEach((dotId) => {
 764      if (resolved.has(dotId)) return;
 765      const base = getBasePosition(dotId);
 766      const candidates = makeCandidates(base);
 767      let best = candidates[0];
 768      let bestClearance = distanceToClosest(best);
 769  
 770      candidates.forEach((candidate) => {
 771        const clearance = distanceToClosest(candidate);
 772        if (clearance >= spacing && bestClearance < spacing) {
 773          best = candidate;
 774          bestClearance = clearance;
 775          return;
 776        }
 777        if ((clearance >= spacing && bestClearance >= spacing) || (clearance < spacing && bestClearance < spacing)) {
 778          const bestDist = Math.hypot(best.left - base.left, best.top - base.top);
 779          const candidateDist = Math.hypot(candidate.left - base.left, candidate.top - base.top);
 780          if (
 781            clearance > bestClearance + 0.01 ||
 782            (Math.abs(clearance - bestClearance) < 0.01 && candidateDist < bestDist)
 783          ) {
 784            best = candidate;
 785            bestClearance = clearance;
 786          }
 787        }
 788      });
 789  
 790      resolved.set(dotId, best);
 791    });
 792  
 793    return resolved;
 794  }
 795  
 796  export function renderMarketingCalendar() {
 797    if (!marketingCalendar || !marketingYear || !marketingMonth) return;
 798    // Skip heavy render if the user is already signed in and won't see the marketing page.
 799    if (hasEnteredApp && marketingPage?.classList.contains("hidden")) return;
 800    const demoState = createDemoState();
 801    reduceMarketingDotDensity(demoState);
 802    const year = demoState.yearCursor;
 803    const todayIso = formatISODate(new Date());
 804    marketingYear.innerHTML = "";
 805    marketingMonth.innerHTML = "";
 806  
 807    for (let monthIndex = 0; monthIndex < 12; monthIndex += 1) {
 808      const column = document.createElement("section");
 809      column.className = "month-column";
 810      if (new Date(year, monthIndex + 1, 0).getDate() === 31) {
 811        column.classList.add("month-31");
 812      }
 813  
 814      const monthTitle = document.createElement("h3");
 815      monthTitle.className = "month-title";
 816      monthTitle.textContent = new Date(year, monthIndex, 1).toLocaleDateString(undefined, { month: "long" });
 817      column.appendChild(monthTitle);
 818  
 819      const daysInMonth = new Date(year, monthIndex + 1, 0).getDate();
 820      for (let dayNum = 1; dayNum <= 31; dayNum += 1) {
 821        if (dayNum > daysInMonth) {
 822          const filler = document.createElement("div");
 823          filler.className = "year-day filler";
 824          column.appendChild(filler);
 825          continue;
 826        }
 827  
 828        const date = new Date(year, monthIndex, dayNum);
 829        const iso = formatISODate(date);
 830        const row = document.createElement("div");
 831        row.className = "year-day";
 832        if (iso === todayIso) row.classList.add("current-day");
 833  
 834        const label = document.createElement("span");
 835        label.className = "day-label";
 836        label.textContent = `${String(dayNum).padStart(2, "0")} ${weekdayShort(date)}`;
 837        row.appendChild(label);
 838  
 839        const dotLayer = document.createElement("div");
 840        dotLayer.className = "dot-layer";
 841        (demoState.dayDots[iso] || []).forEach((dotId) => {
 842          const dotType = demoState.dotTypes.find((t) => t.id === dotId);
 843          if (!dotType) return;
 844          const sticker = document.createElement("span");
 845          sticker.className = "dot-sticker";
 846          sticker.style.background = getMarketingDotColor(dotType);
 847          const pos = getDemoDotPosition(demoState, iso, dotId);
 848          sticker.style.left = `${pos.left}%`;
 849          sticker.style.top = `${pos.top}%`;
 850          sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`;
 851          dotLayer.appendChild(sticker);
 852        });
 853        row.appendChild(dotLayer);
 854  
 855        const note = demoState.dayNotes[iso];
 856        if (note) {
 857          const noteNode = document.createElement("span");
 858          noteNode.className = "day-note";
 859          noteNode.textContent = note;
 860          row.appendChild(noteNode);
 861        }
 862  
 863        column.appendChild(row);
 864      }
 865  
 866      marketingYear.appendChild(column);
 867    }
 868  
 869    renderMarketingMonth(demoState);
 870  }
 871  
 872  export function renderMarketingMonth(demoState) {
 873    const monthDate = startOfMonth(new Date());
 874    const days = buildMonthCells(monthDate, demoState.weekStartsMonday);
 875    const todayIso = formatISODate(new Date());
 876    marketingMonth.innerHTML = "";
 877  
 878    for (const day of days) {
 879      const cell = document.createElement("div");
 880      cell.className = "month-day";
 881      if (day.iso === todayIso) cell.classList.add("current-day");
 882      if (!day.inCurrentMonth) cell.classList.add("muted-day");
 883  
 884      const dayLabel = document.createElement("div");
 885      dayLabel.className = "month-day-label";
 886      dayLabel.textContent = `${String(day.date.getDate()).padStart(2, "0")} ${weekdayShort(day.date)}`;
 887      cell.appendChild(dayLabel);
 888  
 889      const dotLayer = document.createElement("div");
 890      dotLayer.className = "dot-layer";
 891      (demoState.dayDots[day.iso] || []).forEach((dotId) => {
 892        const dotType = demoState.dotTypes.find((t) => t.id === dotId);
 893        if (!dotType) return;
 894        const sticker = document.createElement("span");
 895        sticker.className = "dot-sticker";
 896        sticker.style.background = getMarketingDotColor(dotType);
 897        const pos = getDemoDotPosition(demoState, day.iso, dotId);
 898        sticker.style.left = `${pos.left}%`;
 899        sticker.style.top = `${pos.top}%`;
 900        sticker.style.transform = `translate(-50%, -50%) rotate(${pos.rotate}deg)`;
 901        dotLayer.appendChild(sticker);
 902      });
 903      cell.appendChild(dotLayer);
 904  
 905      const note = demoState.dayNotes[day.iso];
 906      if (note) {
 907        const noteNode = document.createElement("span");
 908        noteNode.className = "month-note";
 909        noteNode.textContent = note;
 910        cell.appendChild(noteNode);
 911      }
 912  
 913      marketingMonth.appendChild(cell);
 914    }
 915  }
 916  
 917  export function renderDotTypeList(targetList = dotTypeList) {
 918    if (!targetList) return;
 919    targetList.innerHTML = "";
 920  
 921    if (state.dotTypes.length === 0) {
 922      const empty = document.createElement("li");
 923      empty.className = "empty-state";
 924      empty.innerHTML = "You don’t have any dot types yet";
 925      targetList.appendChild(empty);
 926    }
 927  
 928    state.dotTypes.forEach((dotType) => {
 929      const item = document.createElement("li");
 930      item.className = "dot-type-row";
 931  
 932      const swatch = document.createElement("span");
 933      swatch.className = "swatch";
 934      swatch.style.background = dotType.color;
 935  
 936      const inputWrap = document.createElement("div");
 937      inputWrap.className = "dot-input-wrap";
 938  
 939      const nameInput = document.createElement("input");
 940      nameInput.type = "text";
 941      nameInput.value = dotType.name;
 942      nameInput.maxLength = DOT_NAME_MAX_LENGTH;
 943      nameInput.setAttribute("aria-label", "Dot meaning");
 944      nameInput.className = "dot-name-input";
 945      syncDotTypeInputSize(nameInput);
 946      nameInput.addEventListener("input", () => {
 947        syncDotTypeInputSize(nameInput);
 948      });
 949      nameInput.addEventListener("focus", () => {
 950        nameInput.select();
 951      });
 952      nameInput.addEventListener("click", () => {
 953        nameInput.select();
 954      });
 955      nameInput.addEventListener("change", () => {
 956        const nextName = normalizeDotTypeName(nameInput.value) || dotType.name;
 957        const changed = nextName !== dotType.name;
 958        dotType.name = nextName;
 959        nameInput.value = nextName;
 960        syncDotTypeInputSize(nameInput);
 961        saveAndRender();
 962        if (changed) {
 963          showToast(`Renamed dot to "${nextName}".`);
 964        }
 965      });
 966      if (pendingFocusDotId === dotType.id) {
 967        requestAnimationFrame(() => {
 968          nameInput.focus();
 969          nameInput.select();
 970        });
 971        pendingFocusDotId = null;
 972      }
 973  
 974      const colorPicker = buildColorPicker(dotType, swatch);
 975      swatch.addEventListener("click", (event) => {
 976        event.stopPropagation();
 977        openColorPicker(colorPicker);
 978      });
 979  
 980      const inUse = isDotTypeInUse(dotType.id);
 981  
 982      const actions = document.createElement("div");
 983      actions.className = "dot-actions";
 984  
 985      const toggle = document.createElement("button");
 986      toggle.type = "button";
 987      toggle.className = "dot-actions-toggle";
 988      toggle.innerHTML = `
 989        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
 990          <path fill-rule="evenodd" clip-rule="evenodd" d="M5 8.5C5.82843 8.5 6.5 9.17157 6.5 10C6.5 10.8284 5.82843 11.5 5 11.5C4.17157 11.5 3.5 10.8284 3.5 10C3.5 9.17157 4.17157 8.5 5 8.5Z" fill="currentColor"/>
 991          <path fill-rule="evenodd" clip-rule="evenodd" d="M10 8.5C10.8284 8.5 11.5 9.17157 11.5 10C11.5 10.8284 10.8284 11.5 10 11.5C9.17157 11.5 8.5 10.8284 8.5 10C8.5 9.17157 9.17157 8.5 10 8.5Z" fill="currentColor"/>
 992          <path fill-rule="evenodd" clip-rule="evenodd" d="M15 8.5C15.8284 8.5 16.5 9.17157 16.5 10C16.5 10.8284 15.8284 11.5 15 11.5C14.1716 11.5 13.5 10.8284 13.5 10C13.5 9.17157 14.1716 8.5 15 8.5Z" fill="currentColor"/>
 993        </svg>
 994      `;
 995      toggle.setAttribute("aria-label", "More actions");
 996  
 997      const menu = document.createElement("div");
 998      menu.className = "dot-actions-menu hidden";
 999      menu.dataset.portal = "dot-actions";
1000  
1001      const renameItem = document.createElement("button");
1002      renameItem.type = "button";
1003      renameItem.className = "dot-actions-item";
1004      renameItem.textContent = "Rename";
1005      renameItem.addEventListener("click", () => {
1006        closeDotMenus();
1007        nameInput.focus();
1008        nameInput.select();
1009      });
1010  
1011      const deleteItem = document.createElement("button");
1012      deleteItem.type = "button";
1013      deleteItem.className = "dot-actions-item";
1014      deleteItem.textContent = "Delete";
1015      deleteItem.addEventListener("click", () => {
1016        promptDeleteDotType(dotType.id, dotType.name);
1017        closeDotMenus();
1018      });
1019  
1020      const colorItem = document.createElement("button");
1021      colorItem.type = "button";
1022      colorItem.className = "dot-actions-item";
1023      colorItem.textContent = "Change color";
1024      colorItem.addEventListener("click", () => {
1025        closeDotMenus();
1026        openColorPicker(colorPicker);
1027      });
1028  
1029      const permanentDeleteItem = document.createElement("button");
1030      permanentDeleteItem.type = "button";
1031      permanentDeleteItem.className = "dot-actions-item danger-solid";
1032      permanentDeleteItem.textContent = "Permanently Delete";
1033      permanentDeleteItem.addEventListener("click", () => {
1034        promptPermanentDeleteDotType(dotType.id, dotType.name);
1035        closeDotMenus();
1036      });
1037  
1038      toggle.addEventListener("click", (event) => {
1039        event.stopPropagation();
1040        const opening = menu.classList.contains("hidden");
1041        closeDotMenus();
1042        item.classList.toggle("menu-open", opening);
1043        if (opening) {
1044          showAnimated(menu);
1045          requestAnimationFrame(() => {
1046            if (!window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches) {
1047              positionDotActionsMenu(menu);
1048            } else {
1049              menu.style.removeProperty("--menu-offset-x");
1050              menu.style.removeProperty("--menu-offset-y");
1051              if (mobileMenuPortal && !menu.dataset.portalActive) {
1052                menu.dataset.portalActive = "true";
1053                portalParents.set(menu, actions);
1054                mobileMenuPortal.appendChild(menu);
1055              }
1056            }
1057            updateMenuScrim();
1058          });
1059        } else {
1060          menu.classList.add("hidden");
1061          updateMenuScrim();
1062        }
1063      });
1064  
1065      if (inUse) {
1066        menu.append(renameItem, colorItem, permanentDeleteItem);
1067      } else {
1068        menu.append(renameItem, colorItem, deleteItem);
1069      }
1070      actions.append(toggle, menu);
1071      inputWrap.append(nameInput, actions);
1072      item.append(swatch, inputWrap, colorPicker);
1073      targetList.appendChild(item);
1074    });
1075  
1076    if (targetList === dotTypeList && state.hideSuggestions) {
1077      const addItem = document.createElement("li");
1078      addItem.className = "dot-type-add-item";
1079      const addButton = document.createElement("button");
1080      addButton.type = "button";
1081      addButton.className = "suggestion-chip add-new";
1082      addButton.textContent = "Add New";
1083      addButton.addEventListener("click", addNewDotType);
1084      addItem.appendChild(addButton);
1085      targetList.appendChild(addItem);
1086    }
1087  }
1088  
1089  export function renderSuggestedDotTypes(targetList = suggestedDotList) {
1090    if (!targetList) return;
1091    targetList.innerHTML = "";
1092  
1093    SUGGESTED_DOT_TYPES.forEach((suggestion) => {
1094      if (hasDotTypeName(suggestion.name)) return;
1095  
1096      const chip = document.createElement("button");
1097      chip.type = "button";
1098      chip.className = "suggestion-chip";
1099      const chipSwatch = document.createElement("span");
1100      chipSwatch.className = "swatch";
1101      chipSwatch.style.background = suggestion.color;
1102      const chipLabel = document.createElement("span");
1103      chipLabel.textContent = suggestion.name;
1104      chip.append(chipSwatch, chipLabel);
1105      chip.addEventListener("click", () => addSuggestedDotType(suggestion));
1106      targetList.appendChild(chip);
1107    });
1108  
1109    const addNewChip = document.createElement("button");
1110    addNewChip.type = "button";
1111    addNewChip.className = "suggestion-chip add-new";
1112    addNewChip.textContent = "Add New";
1113    addNewChip.addEventListener("click", addNewDotType);
1114    targetList.appendChild(addNewChip);
1115  }
1116  
1117  function restoreFocus() {
1118    if (lastFocusTrigger && typeof lastFocusTrigger.focus === "function") {
1119      lastFocusTrigger.focus({ preventScroll: true });
1120    }
1121    lastFocusTrigger = null;
1122  }
1123  
1124  export function openPopover(isoDate, x, y, contextMonthIso = null) {
1125    if (popoverHideTimer) {
1126      clearTimeout(popoverHideTimer);
1127      popoverHideTimer = null;
1128    }
1129    const shouldAnimateIn = popover.classList.contains("hidden");
1130    lastFocusTrigger = document.activeElement;
1131    activePopover = { isoDate, contextMonthIso };
1132    activeNoteEdit = null;
1133    document.body.classList.add("popover-open");
1134    popover.innerHTML = "";
1135  
1136    const selectedIds = new Set(getDayDotIds(isoDate));
1137  
1138    const header = document.createElement("h1");
1139    header.className = "popover-date";
1140    const date = new Date(isoDate);
1141    const parts = new Intl.DateTimeFormat(undefined, {
1142      weekday: "short",
1143      day: "2-digit",
1144      month: "short"
1145    }).formatToParts(date);
1146    const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
1147    header.textContent = `${byType.weekday} ${byType.day} ${byType.month}`;
1148    popover.appendChild(header);
1149  
1150    if (state.dotTypes.length === 0) {
1151      const empty = document.createElement("div");
1152      empty.textContent = "Add a dot type first.";
1153      empty.className = "muted";
1154      empty.style.padding = "8px";
1155      popover.appendChild(empty);
1156    }
1157  
1158    state.dotTypes.forEach((dotType) => {
1159      const node = popoverItemTemplate.content.firstElementChild.cloneNode(true);
1160      node.querySelector(".swatch").style.background = dotType.color;
1161      node.querySelector(".label").textContent = dotType.name;
1162      if (selectedIds.has(dotType.id)) {
1163        node.classList.add("selected");
1164      }
1165  
1166      node.addEventListener("click", () => {
1167        const wasSelected = selectedIds.has(dotType.id);
1168        toggleDot(isoDate, dotType.id);
1169        if (wasSelected) {
1170          openPopover(isoDate, x, y, contextMonthIso);
1171        } else {
1172          closePopover();
1173        }
1174      });
1175  
1176      popover.appendChild(node);
1177    });
1178  
1179    const noteWrap = document.createElement("div");
1180    noteWrap.className = "popover-note";
1181    const addDotTypeButton = document.createElement("button");
1182    addDotTypeButton.type = "button";
1183    addDotTypeButton.className = "note-edit-button";
1184    setButtonLabelWithShortcut(addDotTypeButton, "Add dot type", "D");
1185    addDotTypeButton.addEventListener("click", () => {
1186      closePopover();
1187      addNewDotType();
1188      openSettingsModal();
1189    });
1190    const noteButton = document.createElement("button");
1191    noteButton.type = "button";
1192    noteButton.className = "note-edit-button";
1193    setButtonLabelWithShortcut(noteButton, getDayNote(isoDate) ? "Edit note" : "Add note", "N");
1194    noteButton.addEventListener("click", () => {
1195      closePopover();
1196      startNoteEdit(isoDate, contextMonthIso, "", true);
1197    });
1198    noteWrap.append(addDotTypeButton, noteButton);
1199    popover.appendChild(noteWrap);
1200  
1201    popover.classList.remove("hidden");
1202    const isSmallScreen = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
1203    if (isSmallScreen) {
1204      popover.style.left = "";
1205      popover.style.top = "";
1206    } else {
1207      const maxX = window.innerWidth - popover.offsetWidth - 8;
1208      const maxY = window.innerHeight - popover.offsetHeight - 8;
1209      popover.style.left = `${clamp(x, 8, maxX)}px`;
1210      popover.style.top = `${clamp(y, 8, maxY)}px`;
1211    }
1212    if (shouldAnimateIn) {
1213      showAnimated(popover);
1214    } else {
1215      popover.classList.add("visible");
1216    }
1217    showPopoverScrim();
1218  }
1219  
1220  export function closePopover() {
1221    activePopover = null;
1222    document.body.classList.remove("popover-open");
1223    if (popoverHideTimer) {
1224      clearTimeout(popoverHideTimer);
1225      popoverHideTimer = null;
1226    }
1227    popover.classList.remove("visible");
1228    popoverHideTimer = window.setTimeout(() => {
1229      popover.classList.add("hidden");
1230      popoverHideTimer = null;
1231    }, POPOVER_ANIMATION_MS);
1232    hidePopoverScrim();
1233    restoreFocus();
1234  }
1235  
1236  export function showPopoverScrim() {
1237    if (!popoverScrim) return;
1238    popoverScrim.classList.remove("hidden");
1239    requestAnimationFrame(() => {
1240      popoverScrim.classList.add("visible");
1241    });
1242  }
1243  
1244  export function hidePopoverScrim() {
1245    if (!popoverScrim) return;
1246    popoverScrim.classList.remove("visible");
1247    window.setTimeout(() => {
1248      popoverScrim.classList.add("hidden");
1249    }, POPOVER_ANIMATION_MS);
1250  }
1251  
1252  export function startNoteEdit(isoDate, contextMonthIso = null, initialText = "", immediateFocus = false) {
1253    activeNoteEdit = isoDate;
1254    activeNoteEditMonthIso = contextMonthIso;
1255    const applyInitialText = (editor) => {
1256      if (!initialText) return;
1257      editor.textContent = `${editor.textContent || ""}${initialText}`;
1258    };
1259    const focusEditor = (editor) => {
1260      if (!editor) return false;
1261      applyInitialText(editor);
1262      editor.focus();
1263      const selection = window.getSelection();
1264      if (selection) {
1265        selection.removeAllRanges();
1266        const range = document.createRange();
1267        range.selectNodeContents(editor);
1268        range.collapse(false);
1269        selection.addRange(range);
1270      }
1271      return true;
1272    };
1273    const findEditor = () => {
1274      const scopedSelector = contextMonthIso
1275        ? `[data-note-editor="${isoDate}"][data-note-month="${contextMonthIso}"]`
1276        : null;
1277      const visibleContainer = isMobileView() ? monthGrid : yearGrid;
1278      const containerSelector = `[data-note-editor="${isoDate}"]`;
1279      const fallbackSelector = `[data-note-editor="${isoDate}"]`;
1280      return (
1281        (scopedSelector && document.querySelector(scopedSelector)) ||
1282        visibleContainer?.querySelector(containerSelector) ||
1283        document.querySelector(fallbackSelector)
1284      );
1285    };
1286  
1287    if (immediateFocus) {
1288      render();
1289      if (focusEditor(findEditor())) return;
1290    }
1291  
1292    requestRender(() => {
1293      requestAnimationFrame(() => {
1294        focusEditor(findEditor());
1295      });
1296    });
1297  }
1298  
1299  export function finishNoteEdit(isoDate, editor) {
1300    activeNoteEdit = null;
1301    activeNoteEditMonthIso = null;
1302    setDayNote(isoDate, editor.textContent || "");
1303  }
1304  
1305  export function buildNoteEditor(isoDate, baseClass, monthIso = null) {
1306    const editor = document.createElement("div");
1307    editor.className = `${baseClass} note-editor`;
1308    editor.contentEditable = "true";
1309    editor.spellcheck = true;
1310    editor.dataset.noteEditor = isoDate;
1311    if (monthIso) editor.dataset.noteMonth = monthIso;
1312    editor.textContent = getDayNote(isoDate);
1313    editor.addEventListener("input", (event) => {
1314      event.stopPropagation();
1315    });
1316    editor.addEventListener("keydown", (event) => {
1317      event.stopPropagation();
1318      if (event.key === "Enter") {
1319        event.preventDefault();
1320        finishNoteEdit(isoDate, editor);
1321        editor.blur();
1322      }
1323    });
1324    editor.addEventListener("blur", () => {
1325      finishNoteEdit(isoDate, editor);
1326    });
1327    editor.addEventListener("pointerdown", (event) => {
1328      event.stopPropagation();
1329    });
1330    editor.addEventListener("click", (event) => {
1331      event.stopPropagation();
1332    });
1333    return editor;
1334  }
1335  
1336  export function toggleDot(isoDate, dotId) {
1337    const ids = new Set(getDayDotIds(isoDate));
1338    if (ids.has(dotId)) {
1339      ids.delete(dotId);
1340      clearDotPosition(isoDate, dotId);
1341    } else ids.add(dotId);
1342  
1343    if (ids.size === 0) {
1344      delete state.dayDots[isoDate];
1345    } else {
1346      state.dayDots[isoDate] = [...ids];
1347    }
1348  
1349    saveAndRender();
1350  }
1351  
1352  export function deleteDotType(dotId) {
1353    if (isDotTypeInUse(dotId)) return;
1354  
1355    state.dotTypes = state.dotTypes.filter((d) => d.id !== dotId);
1356  
1357    for (const [iso, ids] of Object.entries(state.dayDots)) {
1358      const next = ids.filter((id) => id !== dotId);
1359      clearDotPosition(iso, dotId);
1360      if (next.length === 0) delete state.dayDots[iso];
1361      else state.dayDots[iso] = next;
1362    }
1363  
1364    saveAndRender();
1365  }
1366  
1367  export function promptDeleteDotType(dotId, dotName) {
1368    if (isDotTypeInUse(dotId)) return;
1369    pendingDeleteDotTypeId = dotId;
1370    pendingDeleteDotTypeName = dotName;
1371    pendingDeleteMode = "safe";
1372    deleteText.textContent = "You haven’t used this dot yet.";
1373    deleteModal.classList.remove("hidden");
1374  }
1375  
1376  export function promptPermanentDeleteDotType(dotId, dotName) {
1377    pendingDeleteDotTypeId = dotId;
1378    pendingDeleteDotTypeName = dotName;
1379    pendingDeleteMode = "force";
1380    deleteText.textContent = `This will remove “${dotName}” from all days it is already applied to and delete it from your dot types.`;
1381    deleteModal.classList.remove("hidden");
1382  }
1383  
1384  export function closeDeleteModal() {
1385    pendingDeleteDotTypeId = null;
1386    pendingDeleteDotTypeName = "";
1387    pendingDeleteMode = "safe";
1388    deleteModal.classList.add("hidden");
1389  }
1390  
1391  export function confirmDeleteDotType() {
1392    if (!pendingDeleteDotTypeId) return;
1393    if (pendingDeleteMode === "force") {
1394      forceDeleteDotType(pendingDeleteDotTypeId);
1395      showToast(`Permanently deleted "${pendingDeleteDotTypeName}".`);
1396    } else {
1397      deleteDotType(pendingDeleteDotTypeId);
1398      showToast(`Deleted "${pendingDeleteDotTypeName}".`);
1399    }
1400    closeDeleteModal();
1401  }
1402  
1403  export function forceDeleteDotType(dotId) {
1404    state.dotTypes = state.dotTypes.filter((d) => d.id !== dotId);
1405  
1406    for (const [iso, ids] of Object.entries(state.dayDots)) {
1407      const next = ids.filter((id) => id !== dotId);
1408      clearDotPosition(iso, dotId);
1409      if (next.length === 0) delete state.dayDots[iso];
1410      else state.dayDots[iso] = next;
1411    }
1412  
1413    saveAndRender();
1414  }
1415  
1416  export function closeSettingsModal() {
1417    if (settingsModalHideTimer) {
1418      clearTimeout(settingsModalHideTimer);
1419      settingsModalHideTimer = null;
1420    }
1421    settingsModal.classList.remove("visible");
1422    settingsModalHideTimer = window.setTimeout(() => {
1423      settingsModal.classList.add("hidden");
1424      settingsModalHideTimer = null;
1425    }, MODAL_ANIMATION_MS);
1426    closeDotMenus();
1427    restoreFocus();
1428  }
1429  
1430  export function openSettingsModal() {
1431    if (settingsModalHideTimer) {
1432      clearTimeout(settingsModalHideTimer);
1433      settingsModalHideTimer = null;
1434    }
1435    lastFocusTrigger = document.activeElement;
1436    showAnimated(settingsModal);
1437  }
1438  
1439  export function closePeriodMenu() {
1440    if (!periodPickerMenu) return;
1441    periodPickerMenu.classList.remove("visible");
1442    periodPickerMenu.classList.add("hidden");
1443    updateMenuScrim();
1444  }
1445  
1446  function getPeriodPickerItems() {
1447    return Array.from(periodPickerMenu.querySelectorAll(".period-picker-item"));
1448  }
1449  
1450  function getFocusedPeriodPickerIndex(items) {
1451    const activeElement = document.activeElement;
1452    if (activeElement instanceof HTMLElement) {
1453      const focusedIndex = items.indexOf(activeElement);
1454      if (focusedIndex >= 0) return focusedIndex;
1455    }
1456    return items.findIndex((item) => item.classList.contains("active"));
1457  }
1458  
1459  function focusPeriodPickerItem(items, index) {
1460    if (!items.length) return;
1461    const clampedIndex = clamp(index, 0, items.length - 1);
1462    const item = items[clampedIndex];
1463    item.focus({ preventScroll: true });
1464    item.scrollIntoView({ block: "nearest" });
1465  }
1466  
1467  function getFocusableElements(container) {
1468    if (!container) return [];
1469    const selector = [
1470      "a[href]",
1471      "button:not([disabled])",
1472      "input:not([disabled])",
1473      "select:not([disabled])",
1474      "textarea:not([disabled])",
1475      "[tabindex]:not([tabindex='-1'])"
1476    ].join(", ");
1477    return Array.from(container.querySelectorAll(selector)).filter((element) => {
1478      if (!(element instanceof HTMLElement)) return false;
1479      if (element.getAttribute("aria-hidden") === "true") return false;
1480      if (element.closest(".hidden")) return false;
1481      return element.offsetParent !== null;
1482    });
1483  }
1484  
1485  function trapFocus(container, event) {
1486    const focusable = getFocusableElements(container);
1487    if (focusable.length === 0) {
1488      event.preventDefault();
1489      return;
1490    }
1491    const activeElement = document.activeElement;
1492    const activeIndex = focusable.indexOf(activeElement);
1493    if (activeIndex === -1) {
1494      event.preventDefault();
1495      (event.shiftKey ? focusable[focusable.length - 1] : focusable[0]).focus();
1496      return;
1497    }
1498    if (!event.shiftKey && activeIndex === focusable.length - 1) {
1499      event.preventDefault();
1500      focusable[0].focus();
1501      return;
1502    }
1503    if (event.shiftKey && activeIndex === 0) {
1504      event.preventDefault();
1505      focusable[focusable.length - 1].focus();
1506      return;
1507    }
1508  }
1509  
1510  function getPopoverDotItems() {
1511    return Array.from(popover.querySelectorAll(".popover-item"));
1512  }
1513  
1514  function getFocusedPopoverDotIndex(items) {
1515    const activeElement = document.activeElement;
1516    if (activeElement instanceof HTMLElement) {
1517      const focusedIndex = items.indexOf(activeElement);
1518      if (focusedIndex >= 0) return focusedIndex;
1519    }
1520    const selectedIndex = items.findIndex((item) => item.classList.contains("selected"));
1521    return selectedIndex >= 0 ? selectedIndex : 0;
1522  }
1523  
1524  function focusPopoverDotItem(items, index) {
1525    if (!items.length) return;
1526    const clampedIndex = clamp(index, 0, items.length - 1);
1527    const item = items[clampedIndex];
1528    item.focus({ preventScroll: true });
1529    item.scrollIntoView({ block: "nearest" });
1530  }
1531  
1532  export function openPeriodMenu() {
1533    showAnimated(periodPickerMenu);
1534    updateMenuScrim();
1535    requestAnimationFrame(() => {
1536      const items = getPeriodPickerItems();
1537      if (!items.length) return;
1538      const currentIndex = getFocusedPeriodPickerIndex(items);
1539      focusPeriodPickerItem(items, currentIndex >= 0 ? currentIndex : 0);
1540    });
1541  }
1542  
1543  export function addSuggestedDotType(suggestion) {
1544    if (hasDotTypeName(suggestion.name)) return;
1545    state.dotTypes.push({
1546      id: crypto.randomUUID(),
1547      name: suggestion.name,
1548      color: suggestion.color
1549    });
1550    saveAndRender();
1551    showToast(`Added "${suggestion.name}".`);
1552  }
1553  
1554  export function addNewDotType() {
1555    const dotId = crypto.randomUUID();
1556    const dotName = "New Dot";
1557    state.dotTypes.push({
1558      id: dotId,
1559      name: dotName,
1560      color: getNextSuggestedColor()
1561    });
1562    pendingFocusDotId = dotId;
1563    saveAndRender();
1564    showToast(`Added "${dotName}".`);
1565  }
1566  
1567  export function hasDotTypeName(name) {
1568    const target = normalizeDotTypeName(name).toLowerCase();
1569    return state.dotTypes.some((dot) => normalizeDotTypeName(dot.name).toLowerCase() === target);
1570  }
1571  
1572  function setButtonLabelWithShortcut(button, label, shortcut) {
1573    button.textContent = "";
1574    const text = document.createElement("span");
1575    text.className = "button-label";
1576    text.textContent = label;
1577    const hint = document.createElement("span");
1578    hint.className = "key-hint";
1579    hint.setAttribute("aria-hidden", "true");
1580    hint.textContent = shortcut;
1581    button.append(text, hint);
1582    button.setAttribute("aria-label", `${label} (${shortcut})`);
1583  }
1584  
1585  function normalizeDotTypeColorInput(value) {
1586    const raw = String(value || "").trim();
1587    if (!raw) return null;
1588  
1589    const hexBody = raw.startsWith("#") ? raw.slice(1) : raw;
1590    if (/^[0-9a-fA-F]{3}$/.test(hexBody)) {
1591      const expanded = hexBody
1592        .split("")
1593        .map((char) => char + char)
1594        .join("")
1595        .toUpperCase();
1596      return `#${expanded}`;
1597    }
1598  
1599    if (/^[0-9a-fA-F]{6}$/.test(hexBody)) {
1600      return `#${hexBody.toUpperCase()}`;
1601    }
1602  
1603    const namedColor = raw.toLowerCase();
1604    if (!/^[a-z]+$/.test(namedColor)) return null;
1605  
1606    if (typeof CSS !== "undefined" && typeof CSS.supports === "function") {
1607      if (CSS.supports("color", namedColor)) return namedColor;
1608      return null;
1609    }
1610  
1611    const probe = document.createElement("span");
1612    probe.style.color = "";
1613    probe.style.color = namedColor;
1614    return probe.style.color ? namedColor : null;
1615  }
1616  
1617  export function getNextSuggestedColor() {
1618    for (const suggestion of SUGGESTED_DOT_TYPES) {
1619      if (!state.dotTypes.some((dot) => dot.color.toLowerCase() === suggestion.color.toLowerCase())) {
1620        return suggestion.color;
1621      }
1622    }
1623    return "#000000";
1624  }
1625  
1626  export function isDotTypeInUse(dotId) {
1627    return dotTypeInUseSet.has(dotId);
1628  }
1629  
1630  export function handlePeriodPickerScroll() {
1631    if (periodPickerMenu.classList.contains("hidden")) return;
1632    const nearBottom =
1633      periodPickerMenu.scrollTop + periodPickerMenu.clientHeight >= periodPickerMenu.scrollHeight - PERIOD_SCROLL_THRESHOLD;
1634    if (!nearBottom || periodLoadInProgress) return;
1635  
1636    periodLoadInProgress = true;
1637    const previousScrollTop = periodPickerMenu.scrollTop;
1638    if (isMobileView()) loadedMobileMonthCount += MOBILE_MONTH_BATCH_SIZE;
1639    else loadedYearBatchCount += 1;
1640    renderPeriodPicker(true, previousScrollTop);
1641    requestAnimationFrame(() => {
1642      periodLoadInProgress = false;
1643    });
1644  }
1645  
1646  export function dismissPopoverFromScrim(event) {
1647    event?.preventDefault?.();
1648    event?.stopPropagation?.();
1649    closePopover();
1650    suppressDayOpenUntil = Date.now() + SUPPRESS_DAY_OPEN_MS;
1651  }
1652  
1653  export function handleGlobalPointerDown(event) {
1654    if (!event.target.closest(".period-picker")) {
1655      closePeriodMenu();
1656    }
1657    if (!event.target.closest(".dot-actions, .dot-actions-menu")) {
1658      closeDotMenus();
1659    }
1660    if (!event.target.closest(".color-picker, .swatch")) {
1661      closeColorPickers();
1662    }
1663    if (!settingsModal.classList.contains("hidden") && event.target === settingsModal) {
1664      closeSettingsModal();
1665      return;
1666    }
1667    if (!deleteModal.classList.contains("hidden") && event.target === deleteModal) {
1668      closeDeleteModal();
1669      return;
1670    }
1671    if (!activePopover) return;
1672    const insidePopover = popover.contains(event.target);
1673    const clickedDay = event.target.closest(".year-day, .month-day");
1674    if (!insidePopover && clickedDay) {
1675      closePopover();
1676      suppressDayOpenUntil = Date.now() + SUPPRESS_DAY_CLOSE_MS;
1677      return;
1678    }
1679    if (!insidePopover && !clickedDay) {
1680      closePopover();
1681    }
1682  }
1683  
1684  export function handleGlobalKeyDown(event) {
1685    const target = event.target;
1686    const isEditableTarget =
1687      target instanceof HTMLElement &&
1688      (target.isContentEditable ||
1689        target.tagName === "INPUT" ||
1690        target.tagName === "TEXTAREA" ||
1691        target.tagName === "SELECT");
1692    const isPlainShortcutKey = !event.metaKey && !event.ctrlKey && !event.altKey;
1693    const key = String(event.key || "").toLowerCase();
1694    const isPeriodMenuOpen = !periodPickerMenu.classList.contains("hidden");
1695    const isSettingsOpen = !settingsModal.classList.contains("hidden");
1696    const isDotPopoverOpen = Boolean(activePopover) && !popover.classList.contains("hidden");
1697  
1698    if (isDotPopoverOpen && event.key === "Tab") {
1699      trapFocus(popover, event);
1700    }
1701  
1702    if (isDotPopoverOpen && !isEditableTarget) {
1703      const dotItems = getPopoverDotItems();
1704      const isArrowNavKey =
1705        event.key === "ArrowDown" ||
1706        event.key === "ArrowUp" ||
1707        event.key === "ArrowLeft" ||
1708        event.key === "ArrowRight";
1709      if (dotItems.length > 0 && isArrowNavKey) {
1710        event.preventDefault();
1711        const currentIndex = getFocusedPopoverDotIndex(dotItems);
1712        const direction = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
1713        focusPopoverDotItem(dotItems, currentIndex + direction);
1714        return;
1715      }
1716    }
1717  
1718    if (isSettingsOpen && event.key === "Tab") {
1719      trapFocus(settingsModal, event);
1720    }
1721  
1722    if (isPeriodMenuOpen && !isEditableTarget) {
1723      const items = getPeriodPickerItems();
1724      if (items.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
1725        event.preventDefault();
1726        const currentIndex = getFocusedPeriodPickerIndex(items);
1727        const startIndex = currentIndex >= 0 ? currentIndex : 0;
1728        const direction = event.key === "ArrowDown" ? 1 : -1;
1729        focusPeriodPickerItem(items, startIndex + direction);
1730        return;
1731      }
1732      if (items.length > 0 && event.key === "Home") {
1733        event.preventDefault();
1734        focusPeriodPickerItem(items, 0);
1735        return;
1736      }
1737      if (items.length > 0 && event.key === "End") {
1738        event.preventDefault();
1739        focusPeriodPickerItem(items, items.length - 1);
1740        return;
1741      }
1742      if (items.length > 0 && (event.key === "Enter" || event.key === " ")) {
1743        event.preventDefault();
1744        const currentIndex = getFocusedPeriodPickerIndex(items);
1745        const target = items[currentIndex >= 0 ? currentIndex : 0];
1746        target.click();
1747        return;
1748      }
1749    }
1750  
1751    const isQuestionSettingsShortcut = isPlainShortcutKey && !isEditableTarget && event.key === "?";
1752    if (isQuestionSettingsShortcut) {
1753      event.preventDefault();
1754      closePopover();
1755      openSettingsModal();
1756      return;
1757    }
1758  
1759    const isDesktopYearView =
1760      !isMobileView() &&
1761      !appShell.classList.contains("hidden") &&
1762      !yearGrid.classList.contains("hidden") &&
1763      monthGrid.classList.contains("hidden");
1764    const isYearArrowShortcut =
1765      isPlainShortcutKey &&
1766      !isEditableTarget &&
1767      !isPeriodMenuOpen &&
1768      !isSettingsOpen &&
1769      !isDotPopoverOpen &&
1770      (event.key === "ArrowLeft" || event.key === "ArrowRight");
1771    if (isDesktopYearView && isYearArrowShortcut) {
1772      event.preventDefault();
1773      shiftYearBy(event.key === "ArrowRight" ? 1 : -1);
1774      return;
1775    }
1776  
1777    const isYearShortcut = isPlainShortcutKey && !isEditableTarget && key === "y";
1778    if (isYearShortcut) {
1779      event.preventDefault();
1780      closePopover();
1781      if (periodPickerMenu.classList.contains("hidden")) openPeriodMenu();
1782      else closePeriodMenu();
1783      return;
1784    }
1785  
1786    const isAddDotTypeShortcut = isPlainShortcutKey && !isEditableTarget && activePopover && key === "d";
1787    if (isAddDotTypeShortcut) {
1788      event.preventDefault();
1789      closePopover();
1790      addNewDotType();
1791      openSettingsModal();
1792      return;
1793    }
1794  
1795    const isAddNoteShortcut = isPlainShortcutKey && !isEditableTarget && activePopover && key === "n";
1796    if (isAddNoteShortcut) {
1797      event.preventDefault();
1798      const { isoDate, contextMonthIso } = activePopover;
1799      closePopover();
1800      startNoteEdit(isoDate, contextMonthIso || null, "", true);
1801      return;
1802    }
1803  
1804    const isTypeToStartNote =
1805      !event.metaKey &&
1806      !event.ctrlKey &&
1807      !event.altKey &&
1808      !isEditableTarget &&
1809      activePopover &&
1810      event.key.length === 1 &&
1811      event.key !== " ";
1812    if (isTypeToStartNote) {
1813      event.preventDefault();
1814      const { isoDate, contextMonthIso } = activePopover;
1815      closePopover();
1816      startNoteEdit(isoDate, contextMonthIso || null, event.key);
1817      return;
1818    }
1819  
1820    if (event.key !== "Escape") return;
1821    closePopover();
1822    closePeriodMenu();
1823    closeSettingsModal();
1824    closeDeleteModal();
1825  }
1826  
1827  export function showOnboardingIfNeeded() {
1828    if (!hasEnteredApp) return;
1829    if (DEMO_MODE) return;
1830    try {
1831      if (localStorage.getItem(AUTH_STATE_KEY) !== "1") return;
1832    } catch {
1833      return;
1834    }
1835    try {
1836      if (localStorage.getItem(ONBOARDING_KEY) === "1") return;
1837      showOnboardingStep("intro");
1838      onboardingModal?.classList.remove("hidden");
1839    } catch {
1840      // Ignore storage access issues.
1841    }
1842  }
1843  
1844  export function showOnboardingStep(step) {
1845    onboardingModal?.querySelectorAll(".onboarding-step").forEach((panel) => {
1846      panel.classList.toggle("hidden", panel.dataset.step !== step);
1847    });
1848    onboardingModal?.querySelectorAll(".onboarding-dot").forEach((dot) => {
1849      dot.classList.toggle("active", dot.dataset.step === step);
1850    });
1851    if (step === "dots") {
1852      renderOnboardingLists();
1853    }
1854  }
1855  
1856  export function closeOnboardingModal() {
1857    onboardingModal?.classList.add("hidden");
1858  }
1859  
1860  export function completeOnboarding() {
1861    try {
1862      localStorage.setItem(ONBOARDING_KEY, "1");
1863    } catch {
1864      // ignore
1865    }
1866    closeOnboardingModal();
1867  }
1868  
1869  export function renderOnboardingLists() {
1870    renderDotTypeList(onboardingDotTypeList);
1871    renderSuggestedDotTypes(onboardingSuggestedDotList);
1872  }
1873  
1874  export function enterApp({ skipOnboarding = false } = {}) {
1875    if (!DEMO_MODE) {
1876      try {
1877        if (localStorage.getItem(AUTH_STATE_KEY) !== "1") {
1878          showMarketingPage();
1879          showLogin();
1880          showToast("Sign in to access your diary.");
1881          return false;
1882        }
1883      } catch {
1884        showMarketingPage();
1885        showLogin();
1886        showToast("Sign in to access your diary.");
1887        return false;
1888      }
1889    }
1890    hasEnteredApp = true;
1891    loginMode = false;
1892    try {
1893      localStorage.setItem(APP_ENTRY_KEY, "1");
1894      localStorage.setItem(VIEW_MODE_KEY, "app");
1895    } catch {
1896      // ignore
1897    }
1898    marketingPage?.classList.add("hidden");
1899    appShell?.classList.remove("hidden");
1900    hasInitializedMobileMonthScroll = false;
1901    requestRender();
1902    if (skipOnboarding) {
1903      try {
1904        localStorage.setItem(ONBOARDING_KEY, "1");
1905      } catch {
1906        // ignore
1907      }
1908      closeOnboardingModal();
1909    } else {
1910      showOnboardingIfNeeded();
1911    }
1912    return true;
1913  }
1914  
1915  export function startDotDrag(event, { isoDate, dotId, sticker, mode }) {
1916    event.preventDefault();
1917    event.stopPropagation();
1918  
1919    const parent = sticker.parentElement;
1920    if (!parent) return;
1921    const pointerId = event.pointerId;
1922    let moved = false;
1923  
1924    const updatePosition = (pointerEvent) => {
1925      const rect = parent.getBoundingClientRect();
1926      const nextLeft = clamp(((pointerEvent.clientX - rect.left) / rect.width) * 100, 6, 94);
1927      const nextTop = clamp(((pointerEvent.clientY - rect.top) / rect.height) * 100, 12, 88);
1928      const current = getDotPosition(isoDate, dotId, mode);
1929      sticker.style.left = `${nextLeft}%`;
1930      sticker.style.top = `${nextTop}%`;
1931      sticker.style.transform = `translate(-50%, -50%) rotate(${current.rotate}deg)`;
1932      moved = true;
1933      return { nextLeft, nextTop };
1934    };
1935  
1936    let last = null;
1937    const onMove = (moveEvent) => {
1938      if (moveEvent.pointerId !== pointerId) return;
1939      last = updatePosition(moveEvent);
1940    };
1941  
1942    const onUp = (upEvent) => {
1943      if (upEvent.pointerId !== pointerId) return;
1944      sticker.removeEventListener("pointermove", onMove);
1945      sticker.removeEventListener("pointerup", onUp);
1946      sticker.removeEventListener("pointercancel", onUp);
1947      if (moved && last) {
1948        saveDotPosition(isoDate, dotId, last.nextLeft, last.nextTop);
1949        saveAndRender();
1950        suppressDayOpenUntil = Date.now() + SUPPRESS_DAY_OPEN_MS;
1951      }
1952    };
1953  
1954    sticker.setPointerCapture(pointerId);
1955    sticker.addEventListener("pointermove", onMove);
1956    sticker.addEventListener("pointerup", onUp);
1957    sticker.addEventListener("pointercancel", onUp);
1958  }
1959  
1960  export function closeDotMenus() {
1961    document.querySelectorAll(".dot-type-row.menu-open").forEach((row) => {
1962      row.classList.remove("menu-open");
1963    });
1964    document.querySelectorAll(".dot-actions-menu").forEach((menu) => {
1965      menu.classList.remove("visible");
1966      menu.classList.add("hidden");
1967      menu.style.removeProperty("--menu-offset-x");
1968      menu.style.removeProperty("--menu-offset-y");
1969      if (menu.dataset.portalActive === "true") {
1970        const parent = portalParents.get(menu);
1971        if (parent) parent.appendChild(menu);
1972        menu.dataset.portalActive = "";
1973      }
1974    });
1975    updateMenuScrim();
1976  }
1977  
1978  export function closeColorPickers() {
1979    document.querySelectorAll(".color-picker").forEach((picker) => {
1980      picker.classList.remove("visible");
1981      picker.classList.add("hidden");
1982      const row = hostRows.get(picker);
1983      if (row) {
1984        row.classList.remove("menu-open");
1985        hostRows.delete(picker);
1986      }
1987      if (picker.dataset.portalActive === "true") {
1988        const parent = portalParents.get(picker);
1989        if (parent) parent.appendChild(picker);
1990        picker.dataset.portalActive = "";
1991      }
1992    });
1993    updateMenuScrim();
1994  }
1995  
1996  export function openColorPicker(picker) {
1997    const opening = picker.classList.contains("hidden");
1998    closeColorPickers();
1999    if (!opening) return;
2000    const hostRow = picker.closest(".dot-type-row");
2001    if (hostRow) {
2002      hostRow.classList.add("menu-open");
2003      hostRows.set(picker, hostRow);
2004    }
2005    const hexInput = hexInputs.get(picker);
2006    const colorFn = currentColors.get(picker);
2007    if (hexInput && colorFn) {
2008      hexInput.value = colorFn();
2009    }
2010    const isMobile = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
2011    if (isMobile && mobileMenuPortal && !picker.dataset.portalActive) {
2012      picker.dataset.portalActive = "true";
2013      portalParents.set(picker, picker.parentElement);
2014      mobileMenuPortal.appendChild(picker);
2015    }
2016    showAnimated(picker);
2017    updateMenuScrim();
2018  }
2019  
2020  export function buildColorPicker(dotType, swatch) {
2021    const picker = document.createElement("div");
2022    picker.className = "color-picker hidden";
2023    picker.dataset.portal = "color-picker";
2024    currentColors.set(picker, () => dotType.color);
2025  
2026    const grid = document.createElement("div");
2027    grid.className = "color-grid";
2028    const palette = COLOR_PALETTE.filter((color, index, arr) => arr.indexOf(color) === index);
2029    palette.forEach((color) => {
2030      const btn = document.createElement("button");
2031      btn.type = "button";
2032      btn.className = "color-swatch";
2033      btn.style.background = color;
2034      btn.setAttribute("aria-label", `Use ${color}`);
2035      btn.addEventListener("click", () => {
2036        const changed = dotType.color !== color;
2037        dotType.color = color;
2038        saveAndRender();
2039        if (changed) {
2040          showToast(`Changed color for "${dotType.name}".`);
2041        }
2042        closeColorPickers();
2043      });
2044      grid.appendChild(btn);
2045    });
2046  
2047    const customRow = document.createElement("div");
2048    customRow.className = "color-custom";
2049  
2050    const hexLabel = document.createElement("label");
2051    hexLabel.className = "sr-only";
2052    hexLabel.textContent = "Custom color";
2053    const hexInput = document.createElement("input");
2054    hexInput.type = "text";
2055    hexInput.value = dotType.color;
2056    hexInput.placeholder = "#RRGGBB or red";
2057    hexInput.className = "color-hex-input";
2058    hexInput.setAttribute("aria-label", "Custom color hex value");
2059    hexInputs.set(picker, hexInput);
2060  
2061    const applyButton = document.createElement("button");
2062    applyButton.type = "button";
2063    applyButton.textContent = "Apply";
2064    applyButton.className = "color-apply";
2065    applyButton.addEventListener("click", () => {
2066      const normalized = normalizeDotTypeColorInput(hexInput.value);
2067      if (!normalized) {
2068        showToast("Enter a valid color name or hex color.");
2069        return;
2070      }
2071      const changed = dotType.color.toLowerCase() !== normalized.toLowerCase();
2072      dotType.color = normalized;
2073      swatch.style.background = normalized;
2074      saveAndRender();
2075      if (changed) {
2076        showToast(`Changed color for "${dotType.name}".`);
2077      }
2078      closeColorPickers();
2079    });
2080  
2081    customRow.append(hexInput, applyButton);
2082    picker.append(grid, customRow);
2083    return picker;
2084  }
2085  
2086  export function positionDotActionsMenu(menu) {
2087    const boundaryRect = menu.closest(".settings-card, .modal-card")?.getBoundingClientRect() || {
2088      left: 8,
2089      top: 8,
2090      right: window.innerWidth - 8,
2091      bottom: window.innerHeight - 8
2092    };
2093    const padding = 8;
2094    const rect = menu.getBoundingClientRect();
2095    let offsetX = 0;
2096    let offsetY = 0;
2097  
2098    if (rect.left < boundaryRect.left + padding) {
2099      offsetX = boundaryRect.left + padding - rect.left;
2100    } else if (rect.right > boundaryRect.right - padding) {
2101      offsetX = boundaryRect.right - padding - rect.right;
2102    }
2103  
2104    if (rect.bottom > boundaryRect.bottom - padding) {
2105      offsetY = boundaryRect.bottom - padding - rect.bottom;
2106    }
2107  
2108    menu.style.setProperty("--menu-offset-x", `${offsetX}px`);
2109    menu.style.setProperty("--menu-offset-y", `${offsetY}px`);
2110  }
2111  
2112  export function updateMenuScrim() {
2113    if (!menuScrim) return;
2114    const isMobileSheet = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
2115    const hasDotMenu = Boolean(document.querySelector(".dot-actions-menu:not(.hidden)"));
2116    const hasColorPicker = Boolean(document.querySelector(".color-picker:not(.hidden)"));
2117    const hasPeriodMenu = !periodPickerMenu.classList.contains("hidden");
2118    const shouldShow = isMobileSheet && (hasDotMenu || hasPeriodMenu || hasColorPicker);
2119    if (menuScrimHideTimer) {
2120      clearTimeout(menuScrimHideTimer);
2121      menuScrimHideTimer = null;
2122    }
2123    if (shouldShow) {
2124      menuScrim.classList.remove("hidden");
2125      requestAnimationFrame(() => {
2126        menuScrim.classList.add("visible");
2127      });
2128      return;
2129    }
2130    menuScrim.classList.remove("visible");
2131    menuScrimHideTimer = window.setTimeout(() => {
2132      menuScrim.classList.add("hidden");
2133      menuScrimHideTimer = null;
2134    }, MENU_SCRIM_HIDE_MS);
2135  }
2136  
2137  export function showAnimated(element) {
2138    element.classList.remove("hidden");
2139    element.classList.remove("visible");
2140    void element.offsetWidth;
2141    requestAnimationFrame(() => {
2142      requestAnimationFrame(() => {
2143        element.classList.add("visible");
2144      });
2145    });
2146  }
2147  
2148  export function syncDotTypeInputSize(input) {
2149    const value = input.value || " ";
2150    const style = window.getComputedStyle(input);
2151    const sizer = syncDotTypeInputSize._sizer || document.createElement("span");
2152    if (!syncDotTypeInputSize._sizer) {
2153      sizer.style.position = "absolute";
2154      sizer.style.visibility = "hidden";
2155      sizer.style.whiteSpace = "pre";
2156      sizer.style.pointerEvents = "none";
2157      sizer.style.left = "-9999px";
2158      sizer.style.top = "0";
2159      document.body.appendChild(sizer);
2160      syncDotTypeInputSize._sizer = sizer;
2161    }
2162    sizer.style.font = style.font;
2163    sizer.style.letterSpacing = style.letterSpacing;
2164    sizer.textContent = value;
2165  
2166    const measured = Math.ceil(sizer.getBoundingClientRect().width) + 3;
2167    input.style.width = `${measured}px`;
2168  }
2169  
2170  export function applyTheme() {
2171    document.documentElement.dataset.theme = isDarkModeEnabled() ? "dark" : "light";
2172  }
2173  
2174  export function downloadDataExport() {
2175    const payload = {
2176      version: 1,
2177      exportedAt: new Date().toISOString(),
2178      data: state
2179    };
2180    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
2181    const url = URL.createObjectURL(blob);
2182    const anchor = document.createElement("a");
2183    anchor.href = url;
2184    anchor.download = `dot-diary-export-${new Date().toISOString().slice(0, 10)}.json`;
2185    document.body.appendChild(anchor);
2186    anchor.click();
2187    anchor.remove();
2188    URL.revokeObjectURL(url);
2189    showToast("Downloaded your data.");
2190  }
2191  
2192  export async function handleDataImport(event) {
2193    const file = event.target.files?.[0];
2194    if (!file) return;
2195    try {
2196      const raw = await file.text();
2197      const parsed = JSON.parse(raw);
2198      const next = normalizeImportedState(parsed?.data ?? parsed);
2199      setState(next);
2200      loadedYearBatchCount = 1;
2201      loadedMobileMonthCount = 24;
2202      closePeriodMenu();
2203      closeDotMenus();
2204      saveAndRender();
2205      showToast("Imported your data.");
2206    } catch {
2207      showToast("Could not import that file.");
2208    } finally {
2209      event.target.value = "";
2210    }
2211  }
2212  
2213  export function isDarkModeEnabled() {
2214    if (typeof state.darkMode === "boolean") {
2215      return state.darkMode;
2216    }
2217    return window.matchMedia("(prefers-color-scheme: dark)").matches;
2218  }
2219  
2220  export function handleResetOnboarding() {
2221    try {
2222      localStorage.removeItem(STORAGE_KEY);
2223      localStorage.removeItem(ONBOARDING_KEY);
2224      localStorage.removeItem(AUTH_STATE_KEY);
2225    } catch {
2226      // ignore
2227    }
2228    closeSettingsModal();
2229    setState(structuredClone(defaultState));
2230    saveAndRender();
2231    resetToLoggedOut();
2232    showOnboardingIfNeeded();
2233  }
2234  
2235  export function setupDevAutoReload() {
2236    if (!DEV_HOSTS.has(window.location.hostname)) return;
2237    const files = [
2238      "index.html",
2239      "styles.css",
2240      "src/app.js",
2241      "src/constants.js",
2242      "src/dom.js",
2243      "src/utils.js",
2244      "src/state.js",
2245      "src/ui.js",
2246      "src/auth.js",
2247      "src/toast.js"
2248    ];
2249    let lastHash = "";
2250    const poll = async () => {
2251      try {
2252        const contents = await Promise.all(
2253          files.map((file) =>
2254            fetch(`${file}?t=${Date.now()}`, { cache: "no-store" })
2255              .then((response) => (response.ok ? response.text() : ""))
2256              .catch(() => "")
2257          )
2258        );
2259        const combined = contents.join("||");
2260        if (!combined) return;
2261        const nextHash = String(hash32(combined));
2262        if (lastHash && nextHash !== lastHash) {
2263          window.location.reload();
2264          return;
2265        }
2266        lastHash = nextHash;
2267      } catch {
2268        // ignore polling errors
2269      }
2270    };
2271    poll();
2272    window.setInterval(poll, DEV_POLL_MS);
2273  }