Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-02 15:07:42 -08:00
commit 41244f943b
2 changed files with 189 additions and 157 deletions

View File

@ -1,21 +1 @@
/**
* rCal demo tab switching and zoom controls (local state only, no WebSocket).
*
* Highlights the active tab when clicked. All tabs show the same
* calendar grid for now; this is purely visual feedback.
*/
const tabs = document.querySelectorAll<HTMLElement>("[data-cal-tab]");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
tabs.forEach((t) => {
t.style.background = "transparent";
t.style.color = "#94a3b8";
});
tab.style.background = "rgba(99,102,241,0.15)";
tab.style.color = "#818cf8";
});
});
export {};

View File

@ -2,7 +2,8 @@
* <folk-calendar-view> spatio-temporal coordination calendar.
*
* Five views: Day timeline, Week timeline, Month grid, Season (3 mini-months), Year (12 mini-months).
* Three tabs: Temporal (calendar views), Spatial (split calendar + Leaflet map), Lunar (phase display).
* Unified layout: calendar always primary, map auto-shows as a floating/minimizable panel,
* lunar phases overlay directly onto the calendar, scroll/trackpad zoom controls granularity.
* Features: temporal-spatial zoom coupling, event markers with transit polylines,
* lunar phase overlay, source filtering, and day-detail panels.
*/
@ -117,7 +118,11 @@ class FolkCalendarView extends HTMLElement {
private temporalGranularity = 4; // MONTH
private spatialGranularity: number | null = null; // null = auto-coupled
private zoomCoupled = true;
private activeTab: "temporal" | "spatial" | "lunar" = "temporal";
// Map panel state (replaces old tab system)
private mapPanelState: "minimized" | "floating" | "docked" = "floating";
private lunarOverlayExpanded = false;
private _wheelTimer: ReturnType<typeof setTimeout> | null = null;
// Leaflet map (preserved across re-renders)
private leafletMap: any = null;
@ -144,6 +149,7 @@ class FolkCalendarView extends HTMLElement {
document.removeEventListener("keydown", this.boundKeyHandler);
this.boundKeyHandler = null;
}
if (this._wheelTimer) { clearTimeout(this._wheelTimer); this._wheelTimer = null; }
if (this.leafletMap) {
this.leafletMap.remove();
this.leafletMap = null;
@ -171,11 +177,10 @@ class FolkCalendarView extends HTMLElement {
case "+": case "=": e.preventDefault(); this.zoomIn(); break;
case "-": case "_": e.preventDefault(); this.zoomOut(); break;
case "m": case "M":
if (this.activeTab === "spatial") {
// Already on spatial tab — no toggle needed
} else {
this.activeTab = "spatial";
}
// Cycle: floating → docked → minimized → floating
if (this.mapPanelState === "floating") this.mapPanelState = "docked";
else if (this.mapPanelState === "docked") this.mapPanelState = "minimized";
else this.mapPanelState = "floating";
this.render();
break;
case "c": case "C":
@ -432,6 +437,8 @@ class FolkCalendarView extends HTMLElement {
this.mapContainer.remove();
}
const isDocked = this.mapPanelState === "docked";
this.shadow.innerHTML = `
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<style>${this.getStyles()}</style>
@ -446,13 +453,18 @@ class FolkCalendarView extends HTMLElement {
<button class="nav-btn" id="next">\u2192</button>
</div>
${this.renderTabs()}
${this.renderLunarOverlay()}
${this.activeTab !== "lunar" ? this.renderZoomController() : ""}
${this.renderZoomController()}
${this.activeTab !== "lunar" ? this.renderSources() : ""}
${this.renderSources()}
${this.renderTabContent()}
<div class="main-layout ${isDocked ? "main-layout--docked" : ""}">
<div class="calendar-pane" id="calendar-pane">
${this.renderCalendarContent()}
</div>
${this.renderMapPanel()}
</div>
<div class="kbd-hint">
<kbd>+</kbd>/<kbd>-</kbd> zoom &bull;
@ -461,7 +473,8 @@ class FolkCalendarView extends HTMLElement {
<kbd>1-5</kbd> view &bull;
<kbd>m</kbd> map &bull;
<kbd>c</kbd> coupling &bull;
<kbd>l</kbd> lunar
<kbd>l</kbd> lunar &bull;
<kbd>scroll</kbd> zoom
</div>
${this.selectedEvent ? this.renderEventModal() : ""}
@ -469,8 +482,8 @@ class FolkCalendarView extends HTMLElement {
this.attachListeners();
// Reattach or initialize map
if (this.activeTab === "spatial") {
// Initialize or update map when not minimized
if (this.mapPanelState !== "minimized") {
this.initOrUpdateMap();
}
}
@ -498,13 +511,61 @@ class FolkCalendarView extends HTMLElement {
}
}
// ── Tabs ──
// ── Lunar Overlay (replaces old Lunar tab) ──
private renderTabs(): string {
return `<div class="tabs">
<button class="tab-btn ${this.activeTab === "temporal" ? "active" : ""}" data-tab="temporal">Temporal</button>
<button class="tab-btn ${this.activeTab === "spatial" ? "active" : ""}" data-tab="spatial">Spatial</button>
<button class="tab-btn ${this.activeTab === "lunar" ? "active" : ""}" data-tab="lunar">Lunar</button>
private renderLunarOverlay(): string {
if (!this.showLunar) return "";
const phase = lunarPhaseForDate(this.currentDate);
const dayNum = Math.floor(phase.age) + 1;
const illumPct = Math.round(phase.illumination * 100);
const phaseName = phase.phase.replace(/_/g, " ");
const chevron = this.lunarOverlayExpanded ? "\u25B2" : "\u25BC";
let expandedHtml = "";
if (this.lunarOverlayExpanded) {
const synodic = getSynodicMonth(this.currentDate);
const elapsed = this.currentDate.getTime() - synodic.startDate.getTime();
const total = synodic.endDate.getTime() - synodic.startDate.getTime();
const progress = Math.max(0, Math.min(1, elapsed / total));
expandedHtml = `
<div class="lunar-expanded">
<div class="synodic-section">
<div class="synodic-labels">
<span>\u{1F311} ${synodic.startDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
<span>\u{1F311} ${synodic.endDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
</div>
<div class="synodic-bar">
<div class="synodic-fill" style="width:${progress * 100}%"></div>
${synodic.phases.map(p => {
const pProg = (p.date.getTime() - synodic.startDate.getTime()) / total;
return `<span class="synodic-marker" style="left:${pProg * 100}%"
title="${p.phase.replace(/_/g, " ")} \u2014 ${p.date.toLocaleDateString("default", { month: "short", day: "numeric" })}">${p.emoji}</span>`;
}).join("")}
</div>
</div>
<div class="phase-chips">
${synodic.phases.map(p => {
const isCurrent = p.phase === phase.phase;
const isPast = p.date < this.currentDate && !isCurrent;
return `<span class="phase-chip ${isCurrent ? "current" : ""} ${isPast ? "past" : ""}"
title="${p.date.toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}">
${p.emoji} <span class="phase-chip-label">${p.phase.replace(/_/g, " ")}</span>
</span>`;
}).join("")}
</div>
</div>`;
}
return `<div class="lunar-overlay">
<div class="lunar-summary" id="lunar-summary">
<span class="lunar-summary-phase">${phase.emoji} ${phaseName}</span>
<span class="lunar-summary-stats">${illumPct}% illuminated</span>
<span class="lunar-summary-stats">Day ${dayNum}/29</span>
<span class="lunar-summary-chevron">${chevron}</span>
</div>
${expandedHtml}
</div>`;
}
@ -546,17 +607,34 @@ class FolkCalendarView extends HTMLElement {
</div>`;
}
// ── Tab Content Router ──
// ── Map Panel (floating / docked / minimized) ──
private renderTabContent(): string {
switch (this.activeTab) {
case "temporal": return this.renderCalendarContent();
case "spatial": return this.renderSpatialTab();
case "lunar": return this.renderLunarTab();
default: return this.renderCalendarContent();
private renderMapPanel(): string {
if (this.mapPanelState === "minimized") {
return `<button class="map-fab" id="map-fab" title="Show map (m)">\u{1F5FA}</button>`;
}
const mode = this.mapPanelState;
const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
return `<div class="map-panel map-panel--${mode}" id="map-panel">
<div class="map-panel-header">
<span class="map-panel-title">\u{1F5FA} ${spatialLabel}</span>
<div class="map-panel-controls">
${mode === "floating"
? `<button class="map-ctrl-btn" id="map-dock" title="Dock side-by-side">\u{2B1C}</button>`
: `<button class="map-ctrl-btn" id="map-float" title="Float">\u{1F5D7}</button>`}
<button class="map-ctrl-btn" id="map-minimize" title="Minimize">\u2212</button>
</div>
</div>
<div class="map-body" id="map-host">
<div class="map-overlay-label" id="map-spatial-label">${spatialLabel}</div>
</div>
</div>`;
}
// ── Calendar Content Router ──
private renderCalendarContent(): string {
switch (this.viewMode) {
case "day": return this.renderDay();
@ -568,71 +646,6 @@ class FolkCalendarView extends HTMLElement {
}
}
// ── Spatial Tab (Split Layout) ──
private renderSpatialTab(): string {
return `<div class="spatial-split">
<div class="spatial-calendar">${this.renderCalendarContent()}</div>
<div class="spatial-map" id="map-host">
<div class="map-overlay-label" id="map-spatial-label">${SPATIAL_LABELS[this.getEffectiveSpatialIndex()]}</div>
</div>
</div>`;
}
// ── Lunar Tab ──
private renderLunarTab(): string {
const phase = lunarPhaseForDate(this.currentDate);
const synodic = getSynodicMonth(this.currentDate);
const elapsed = this.currentDate.getTime() - synodic.startDate.getTime();
const total = synodic.endDate.getTime() - synodic.startDate.getTime();
const progress = Math.max(0, Math.min(1, elapsed / total));
return `<div class="lunar-tab">
<div class="lunar-hero">
<div class="lunar-emoji">${phase.emoji}</div>
<div class="lunar-phase-name">${phase.phase.replace(/_/g, " ")}</div>
<div class="lunar-stats">
<span>Illumination: ${Math.round(phase.illumination * 100)}%</span>
<span>Age: ${phase.age.toFixed(1)} days</span>
<span>Cycle: ${synodic.durationDays.toFixed(1)} days</span>
</div>
</div>
<div class="synodic-section">
<div class="synodic-labels">
<span>\u{1F311} ${synodic.startDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
<span>\u{1F311} ${synodic.endDate.toLocaleDateString("default", { month: "short", day: "numeric" })}</span>
</div>
<div class="synodic-bar">
<div class="synodic-fill" style="width:${progress * 100}%"></div>
${synodic.phases.map(p => {
const pProg = (p.date.getTime() - synodic.startDate.getTime()) / total;
return `<span class="synodic-marker" style="left:${pProg * 100}%"
title="${p.phase.replace(/_/g, " ")} \u2014 ${p.date.toLocaleDateString("default", { month: "short", day: "numeric" })}">${p.emoji}</span>`;
}).join("")}
</div>
</div>
<div class="phase-section">
<div class="phase-section-title">Phases this cycle</div>
<div class="phase-timeline">
${synodic.phases.map(p => {
const isPast = p.date < this.currentDate;
const isCurrent = p.phase === phase.phase;
return `<div class="phase-row ${isCurrent ? "current" : ""} ${isPast && !isCurrent ? "past" : ""}">
<span class="phase-row-emoji">${p.emoji}</span>
<div class="phase-row-info">
<div class="phase-row-name">${p.phase.replace(/_/g, " ")}</div>
<div class="phase-row-date">${p.date.toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}</div>
</div>
</div>`;
}).join("")}
</div>
</div>
</div>`;
}
// ── Month View ──
private renderMonth(): string {
@ -969,12 +982,22 @@ class FolkCalendarView extends HTMLElement {
});
$("toggle-lunar")?.addEventListener("click", () => { this.showLunar = !this.showLunar; this.render(); });
// Tabs
$$("[data-tab]").forEach(el => {
el.addEventListener("click", () => {
this.activeTab = (el as HTMLElement).dataset.tab as any;
this.render();
});
// Lunar overlay expand/collapse
$("lunar-summary")?.addEventListener("click", () => {
this.lunarOverlayExpanded = !this.lunarOverlayExpanded;
this.render();
});
// Map panel controls
$("map-fab")?.addEventListener("click", () => { this.mapPanelState = "floating"; this.render(); });
$("map-minimize")?.addEventListener("click", () => { this.mapPanelState = "minimized"; this.render(); });
$("map-dock")?.addEventListener("click", () => {
this.mapPanelState = "docked";
this.render();
});
$("map-float")?.addEventListener("click", () => {
this.mapPanelState = "floating";
this.render();
});
// Zoom controller
@ -1061,6 +1084,29 @@ class FolkCalendarView extends HTMLElement {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
});
$("modal-close")?.addEventListener("click", () => { this.selectedEvent = null; this.render(); });
// Scroll/trackpad zoom on calendar pane
const calPane = $("calendar-pane");
if (calPane) {
calPane.addEventListener("wheel", (e: WheelEvent) => {
// Skip if target is inside the map panel (Leaflet handles its own zoom)
const mapPanel = this.shadow.getElementById("map-panel");
if (mapPanel && mapPanel.contains(e.target as Node)) return;
e.preventDefault();
// Debounce: 120ms
if (this._wheelTimer) clearTimeout(this._wheelTimer);
this._wheelTimer = setTimeout(() => {
if (e.deltaY > 0) {
this.zoomOut(); // scroll down → zoom out
} else if (e.deltaY < 0) {
this.zoomIn(); // scroll up → zoom in
}
this._wheelTimer = null;
}, 120);
}, { passive: false });
}
}
// ── Leaflet Map ──
@ -1077,7 +1123,7 @@ class FolkCalendarView extends HTMLElement {
this.mapContainer = document.createElement("div");
this.mapContainer.style.width = "100%";
this.mapContainer.style.height = "100%";
this.mapContainer.style.minHeight = "400px";
this.mapContainer.style.minHeight = "250px";
this.mapContainer.style.borderRadius = "8px";
}
@ -1184,11 +1230,19 @@ class FolkCalendarView extends HTMLElement {
.nav-title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
.nav-primary { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
/* ── Tabs ── */
.tabs { display: flex; gap: 2px; margin-bottom: 12px; background: #16161e; border-radius: 8px; padding: 3px; border: 1px solid #222; }
.tab-btn { flex: 1; padding: 6px 12px; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; }
.tab-btn:hover { color: #94a3b8; }
.tab-btn.active { background: #4f46e5; color: #fff; }
/* ── Lunar Overlay ── */
.lunar-overlay { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 0; margin-bottom: 12px; overflow: hidden; }
.lunar-summary { display: flex; align-items: center; gap: 12px; padding: 8px 12px; cursor: pointer; user-select: none; transition: background 0.15s; }
.lunar-summary:hover { background: rgba(255,255,255,0.04); }
.lunar-summary-phase { font-size: 13px; font-weight: 600; color: #e2e8f0; text-transform: capitalize; white-space: nowrap; }
.lunar-summary-stats { font-size: 11px; color: #94a3b8; white-space: nowrap; }
.lunar-summary-chevron { margin-left: auto; font-size: 10px; color: #64748b; }
.lunar-expanded { border-top: 1px solid #222; padding: 12px; }
.phase-chips { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 4px; }
.phase-chip { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 16px; border: 1px solid #333; font-size: 11px; color: #94a3b8; white-space: nowrap; flex-shrink: 0; transition: all 0.15s; }
.phase-chip.current { border-color: #6366f1; color: #818cf8; background: rgba(99,102,241,0.08); }
.phase-chip.past { opacity: 0.4; }
.phase-chip-label { text-transform: capitalize; }
/* ── Zoom Controller ── */
.zoom-ctrl { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding: 8px 10px; background: #16161e; border: 1px solid #222; border-radius: 8px; flex-wrap: wrap; }
@ -1212,6 +1266,32 @@ class FolkCalendarView extends HTMLElement {
.src-badge:hover { filter: brightness(1.2); }
.src-badge.filtered { opacity: 0.3; text-decoration: line-through; }
/* ── Main Layout ── */
.main-layout { position: relative; min-height: 400px; }
.main-layout--docked { display: grid; grid-template-columns: 1fr 400px; gap: 8px; min-height: 500px; }
.calendar-pane { overflow: auto; min-width: 0; }
/* ── Map Panel ── */
.map-panel { background: #0d1117; border: 1px solid #333; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
.map-panel--floating { position: absolute; bottom: 8px; right: 8px; width: 380px; height: 320px; resize: both; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
.map-panel--docked { min-height: 400px; }
.map-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: #16161e; border-bottom: 1px solid #222; cursor: default; flex-shrink: 0; }
.map-panel-title { font-size: 12px; font-weight: 500; color: #94a3b8; }
.map-panel-controls { display: flex; gap: 4px; }
.map-ctrl-btn { width: 24px; height: 24px; border-radius: 4px; border: 1px solid #333; background: transparent; color: #94a3b8; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; }
.map-ctrl-btn:hover { border-color: #6366f1; color: #e2e8f0; }
.map-body { flex: 1; position: relative; min-height: 200px; }
.map-overlay-label { position: absolute; top: 8px; right: 8px; z-index: 500; background: rgba(22,22,30,0.9); border: 1px solid #333; border-radius: 6px; padding: 4px 10px; font-size: 11px; color: #94a3b8; pointer-events: none; }
.map-fab { position: absolute; bottom: 16px; right: 16px; width: 44px; height: 44px; border-radius: 50%; border: 1px solid #333; background: #16161e; color: #e2e8f0; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; z-index: 100; box-shadow: 0 4px 16px rgba(0,0,0,0.4); transition: all 0.15s; }
.map-fab:hover { border-color: #6366f1; background: #1e1e2e; transform: scale(1.1); }
/* ── Synodic (reused in overlay) ── */
.synodic-section { margin: 0 0 8px; }
.synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: #64748b; margin-bottom: 6px; }
.synodic-bar { height: 14px; background: #222; border-radius: 7px; overflow: visible; position: relative; }
.synodic-fill { height: 100%; background: linear-gradient(to right, #1a1a2e, #e2e8f0, #1a1a2e); border-radius: 7px; transition: width 0.3s; }
.synodic-marker { position: absolute; top: -2px; font-size: 12px; transform: translateX(-50%); pointer-events: none; }
/* ── Month Grid ── */
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
.wd { text-align: center; font-size: 11px; color: #64748b; padding: 4px; font-weight: 600; }
@ -1282,12 +1362,6 @@ class FolkCalendarView extends HTMLElement {
.week-event:hover { opacity: 0.85; }
.week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Spatial Split Layout ── */
.spatial-split { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; min-height: 500px; }
.spatial-calendar { overflow: auto; }
.spatial-map { position: relative; border-radius: 8px; border: 1px solid #222; overflow: hidden; min-height: 400px; background: #0d1117; }
.map-overlay-label { position: absolute; top: 8px; right: 8px; z-index: 500; background: rgba(22,22,30,0.9); border: 1px solid #333; border-radius: 6px; padding: 4px 10px; font-size: 11px; color: #94a3b8; pointer-events: none; }
/* ── Season View ── */
.season-header { text-align: center; margin-bottom: 12px; font-size: 16px; font-weight: 600; color: #e2e8f0; }
.season-q { font-size: 12px; color: #64748b; font-weight: 400; margin-left: 4px; }
@ -1311,29 +1385,6 @@ class FolkCalendarView extends HTMLElement {
.mini-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; }
.mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; }
/* ── Lunar Tab ── */
.lunar-tab { max-width: 600px; margin: 0 auto; padding: 8px 0; }
.lunar-hero { text-align: center; padding: 20px 0; }
.lunar-emoji { font-size: 72px; line-height: 1; }
.lunar-phase-name { font-size: 20px; font-weight: 700; color: #e2e8f0; margin-top: 8px; text-transform: capitalize; }
.lunar-stats { display: flex; justify-content: center; gap: 16px; margin-top: 10px; font-size: 13px; color: #94a3b8; flex-wrap: wrap; }
.synodic-section { margin: 16px 0; }
.synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: #64748b; margin-bottom: 6px; }
.synodic-bar { height: 14px; background: #222; border-radius: 7px; overflow: visible; position: relative; }
.synodic-fill { height: 100%; background: linear-gradient(to right, #1a1a2e, #e2e8f0, #1a1a2e); border-radius: 7px; transition: width 0.3s; }
.synodic-marker { position: absolute; top: -2px; font-size: 12px; transform: translateX(-50%); pointer-events: none; }
.phase-section { margin-top: 20px; }
.phase-section-title { font-size: 14px; font-weight: 600; color: #e2e8f0; margin-bottom: 10px; }
.phase-timeline { display: flex; flex-direction: column; gap: 6px; }
.phase-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1px solid #222; transition: all 0.15s; }
.phase-row:hover { border-color: #333; }
.phase-row.current { border-color: #6366f1; background: rgba(99,102,241,0.08); }
.phase-row.past { opacity: 0.5; }
.phase-row-emoji { font-size: 24px; flex-shrink: 0; }
.phase-row-info { flex: 1; }
.phase-row-name { font-weight: 500; color: #e2e8f0; text-transform: capitalize; font-size: 13px; }
.phase-row-date { font-size: 12px; color: #94a3b8; margin-top: 2px; }
/* ── Keyboard Hint ── */
.kbd-hint { text-align: center; font-size: 10px; color: #333; margin-top: 12px; padding-top: 8px; border-top: 1px solid #1a1a2e; }
.kbd-hint kbd { padding: 1px 4px; background: #16161e; border: 1px solid #222; border-radius: 3px; font-family: inherit; font-size: 9px; }
@ -1341,8 +1392,8 @@ class FolkCalendarView extends HTMLElement {
/* ── Mobile ── */
@media (max-width: 768px) {
:host { padding: 0.25rem; }
.spatial-split { grid-template-columns: 1fr; }
.spatial-map { min-height: 300px; }
.main-layout--docked { grid-template-columns: 1fr; }
.map-panel--floating { width: 100%; left: 0; right: 0; bottom: 0; border-radius: 12px 12px 0 0; }
.year-grid { grid-template-columns: repeat(3, 1fr); }
.season-grid { grid-template-columns: 1fr; }
.day { min-height: 52px; padding: 4px; }
@ -1359,8 +1410,9 @@ class FolkCalendarView extends HTMLElement {
.zoom-track { min-width: 150px; }
.zoom-ctrl { gap: 4px; padding: 6px 8px; }
.coupling-btn { font-size: 10px; padding: 3px 8px; }
.lunar-emoji { font-size: 56px; }
.lunar-stats { gap: 10px; font-size: 12px; }
.lunar-summary { gap: 8px; flex-wrap: wrap; }
.phase-chips { gap: 4px; }
.phase-chip { padding: 3px 8px; font-size: 10px; }
}
@media (max-width: 480px) {
.day { min-height: 44px; padding: 3px; }