fix: dark background on all demo pages + calendar mobile improvements

Shell CSS: add body background (#0f172a) so all module pages have the
dark theme instead of transparent/white on mobile. Add mobile media
queries for #app padding and nav wrapping.

Calendar: add day-detail panel that opens on tap (crucial for mobile
where event labels are hidden). Improve touch targets, add source
badges in event modal, shorter weekday headers for narrow screens.

Cache-bust shell.css, cal JS, and swag JS via ?v=2 query params to
bypass Cloudflare edge cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-28 07:10:40 +00:00
parent e40db06407
commit 9db9c89bed
5 changed files with 221 additions and 128 deletions

View File

@ -3,6 +3,7 @@
*
* Month grid view with event dots, lunar phase overlay,
* event creation, and source filtering.
* Mobile: tapping a day opens a day-detail panel with full event list.
*/
class FolkCalendarView extends HTMLElement {
@ -15,6 +16,7 @@ class FolkCalendarView extends HTMLElement {
private showLunar = true;
private selectedDate = "";
private selectedEvent: any = null;
private expandedDay = ""; // mobile day-detail panel
private error = "";
constructor() {
@ -35,56 +37,40 @@ class FolkCalendarView extends HTMLElement {
const month = now.getMonth();
const sources = [
{ name: "Work (Google Calendar)", color: "#3b82f6" },
{ name: "Travel (Manual)", color: "#f97316" },
{ name: "Personal (ICS)", color: "#10b981" },
{ name: "Conferences (Manual)", color: "#8b5cf6" },
{ name: "Work", color: "#3b82f6" },
{ name: "Travel", color: "#f97316" },
{ name: "Personal", color: "#10b981" },
{ name: "Conferences", color: "#8b5cf6" },
];
// Dense workday calendar across multiple cities
const demoEvents: { day: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; startH: number; startM: number; durationMin: number }[] = [
// --- Berlin Work Block (Days 3-10) ---
{ day: 3, title: "Team Standup", source: 0, desc: "Daily sync — Berlin engineering team", location: "Factory Berlin, Rheinsberger Str. 76/77", virtual: false, startH: 9, startM: 30, durationMin: 30 },
{ day: 3, title: "Team Standup", source: 0, desc: "Daily sync — Berlin engineering team", location: "Factory Berlin", virtual: false, startH: 9, startM: 30, durationMin: 30 },
{ day: 3, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false, startH: 14, startM: 0, durationMin: 60 },
{ day: 5, title: "Product Review", source: 0, desc: "Quarterly product roadmap review with stakeholders", location: "Factory Berlin, Conference Room 3", virtual: false, startH: 10, startM: 0, durationMin: 90 },
{ day: 5, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Rosenthaler Str. 62, Berlin", virtual: false, startH: 12, startM: 30, durationMin: 60 },
{ day: 7, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 — local-first sync features", location: "Factory Berlin, Conference Room 1", virtual: false, startH: 10, startM: 0, durationMin: 120 },
{ day: 5, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false, startH: 10, startM: 0, durationMin: 90 },
{ day: 5, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, startH: 12, startM: 30, durationMin: 60 },
{ day: 7, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 — local-first sync", location: "Factory Berlin, Room 1", virtual: false, startH: 10, startM: 0, durationMin: 120 },
{ day: 7, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false, startH: 15, startM: 0, durationMin: 90 },
{ day: 8, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true, startH: 16, startM: 0, durationMin: 60 },
{ day: 10, title: "1:1 with Manager", source: 0, desc: "Monthly check-in — career growth, project scope", location: "Factory Berlin, Phone Booth 4", virtual: false, startH: 11, startM: 0, durationMin: 45 },
{ day: 10, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false, startH: 11, startM: 0, durationMin: 45 },
{ day: 10, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true, startH: 15, startM: 30, durationMin: 60 },
// --- Travel: Berlin to Amsterdam (Days 12-14) ---
{ day: 12, title: "Train Berlin \u2192 Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf \u2192 Amsterdam Centraal, Seat 64 Window", location: "Berlin Hauptbahnhof, Platform 11", virtual: false, startH: 7, startM: 15, durationMin: 390 },
{ day: 12, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Nes 49, Amsterdam", location: "Hotel V Nesplein, Amsterdam", virtual: false, startH: 14, startM: 30, durationMin: 30 },
{ day: 13, title: "Partner Meeting", source: 0, desc: "On-site collaboration with Amsterdam design team", location: "WeWork Weteringschans, Amsterdam", virtual: false, startH: 10, startM: 0, durationMin: 180 },
{ day: 13, title: "Canal District Walk", source: 2, desc: "Afternoon stroll along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, startH: 15, startM: 0, durationMin: 120 },
{ day: 13, title: "Dinner at Moeders", source: 2, desc: "Traditional Dutch dinner — try the stamppot", location: "Restaurant Moeders, Rozengracht 251, Amsterdam", virtual: false, startH: 19, startM: 0, durationMin: 90 },
{ day: 14, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam Centraal \u2192 Berlin Hbf, Seat 38 Aisle", location: "Amsterdam Centraal, Platform 7b", virtual: false, startH: 9, startM: 30, durationMin: 390 },
// --- Personal (Days 15-20) ---
{ day: 15, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia at the Italian place", location: "Il Casolare, Grimmstr. 30, Berlin", virtual: false, startH: 19, startM: 30, durationMin: 120 },
{ day: 16, title: "Grocery Run", source: 2, desc: "Weekly groceries at the Saturday market", location: "Maybachufer Market, Berlin", virtual: false, startH: 10, startM: 0, durationMin: 60 },
{ day: 17, title: "Weekend Hike", source: 2, desc: "Grunewald forest loop trail, ~14 km", location: "S-Bahn Grunewald, Berlin", virtual: false, startH: 8, startM: 0, durationMin: 300 },
{ day: 19, title: "Dentist Appointment", source: 2, desc: "Regular checkup, Dr. Weber", location: "Zahnarzt Weber, Torstr. 140, Berlin", virtual: false, startH: 9, startM: 0, durationMin: 45 },
{ day: 20, title: "Yoga Class", source: 2, desc: "Vinyasa flow — bring own mat", location: "Yoga Studio Kreuzberg, Oranienstr. 25, Berlin", virtual: false, startH: 7, startM: 30, durationMin: 75 },
{ day: 20, title: "Book Club", source: 2, desc: "Discussing \"The Mushroom at the End of the World\" by Anna Tsing", location: "Shakespeare & Sons, Warschauer Str. 74, Berlin", virtual: false, startH: 19, startM: 0, durationMin: 90 },
// --- Work Wrap-up (Days 21-22) ---
{ day: 21, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective — what worked, what didn't", location: "Factory Berlin, Conference Room 2", virtual: false, startH: 10, startM: 0, durationMin: 60 },
{ day: 21, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production, monitor metrics", location: null, virtual: true, startH: 14, startM: 0, durationMin: 120 },
{ day: 22, title: "Demo Day", source: 0, desc: "Sprint 23 showcase — live demos for stakeholders and community", location: "Factory Berlin, Main Hall", virtual: false, startH: 14, startM: 0, durationMin: 90 },
{ day: 22, title: "Team Drinks", source: 2, desc: "Celebrate the release with the team", location: "Prater Garten, Kastanienallee 7-9, Berlin", virtual: false, startH: 17, startM: 30, durationMin: 120 },
// --- Conference: Web Summit (Days 24-27, Lisbon) ---
{ day: 24, title: "Flight Berlin \u2192 Lisbon", source: 1, desc: "TAP TP 571 BER \u2192 LIS, Gate B22", location: "BER Airport, Berlin", virtual: false, startH: 6, startM: 45, durationMin: 195 },
{ day: 24, title: "Hotel Check-in Lisbon", source: 1, desc: "Hotel da Baixa, Rua da Prata 242, Lisbon", location: "Hotel da Baixa, Lisbon", virtual: false, startH: 13, startM: 0, durationMin: 30 },
{ day: 25, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion, networking. Main stage + Centre Stage tracks.", location: "Altice Arena / FIL, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 },
{ day: 25, title: "Speaker Dinner", source: 3, desc: "Invited speakers dinner at the riverside venue", location: "Ponto Final, Cacilhas, Lisbon", virtual: false, startH: 20, startM: 0, durationMin: 120 },
{ day: 26, title: "Web Summit Day 2", source: 3, desc: "Panel: \"Local-First Software & the Future of Collaboration\". Our talk at 14:00 on Centre Stage.", location: "Altice Arena / FIL, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 },
{ day: 26, title: "Networking Happy Hour", source: 3, desc: "Post-conference drinks with open-source community", location: "Time Out Market, Lisbon", virtual: false, startH: 18, startM: 30, durationMin: 120 },
{ day: 27, title: "Lisbon City Tour", source: 2, desc: "Alfama neighborhood, Tram 28, Pasteis de Belem, Praca do Comercio", location: "Alfama, Lisbon", virtual: false, startH: 10, startM: 0, durationMin: 360 },
{ day: 27, title: "Flight Lisbon \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER, Gate 41", location: "Lisbon Humberto Delgado Airport", virtual: false, startH: 19, startM: 30, durationMin: 195 },
{ day: 12, title: "Train Berlin → Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf → Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, startH: 7, startM: 15, durationMin: 390 },
{ day: 12, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, startH: 14, startM: 30, durationMin: 30 },
{ day: 13, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, startH: 10, startM: 0, durationMin: 180 },
{ day: 13, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, startH: 15, startM: 0, durationMin: 120 },
{ day: 14, title: "Return Train", source: 1, desc: "ICE 148 Amsterdam → Berlin", location: "Amsterdam Centraal", virtual: false, startH: 9, startM: 30, durationMin: 390 },
{ day: 15, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Berlin", virtual: false, startH: 19, startM: 30, durationMin: 120 },
{ day: 17, title: "Weekend Hike", source: 2, desc: "Grunewald forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, startH: 8, startM: 0, durationMin: 300 },
{ day: 19, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, startH: 9, startM: 0, durationMin: 45 },
{ day: 20, title: "Yoga Class", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, startH: 7, startM: 30, durationMin: 75 },
{ day: 20, title: "Book Club", source: 2, desc: "\"The Mushroom at the End of the World\"", location: "Shakespeare & Sons, Berlin", virtual: false, startH: 19, startM: 0, durationMin: 90 },
{ day: 21, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false, startH: 10, startM: 0, durationMin: 60 },
{ day: 21, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true, startH: 14, startM: 0, durationMin: 120 },
{ day: 22, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false, startH: 14, startM: 0, durationMin: 90 },
{ day: 24, title: "Flight → Lisbon", source: 1, desc: "TAP TP 571 BER → LIS", location: "BER Airport", virtual: false, startH: 6, startM: 45, durationMin: 195 },
{ day: 25, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 },
{ day: 26, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 },
{ day: 27, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Pasteis de Belem", location: "Alfama, Lisbon", virtual: false, startH: 10, startM: 0, durationMin: 360 },
{ day: 27, title: "Flight → Berlin", source: 1, desc: "TAP TP 572 LIS → BER", location: "Lisbon Airport", virtual: false, startH: 19, startM: 30, durationMin: 195 },
];
this.events = demoEvents.map((e, i) => {
@ -108,19 +94,14 @@ class FolkCalendarView extends HTMLElement {
this.sources = sources;
// Compute lunar phases for each day of the month
const knownNewMoon = new Date(2026, 0, 29).getTime(); // Jan 29, 2026
// Compute lunar phases
const knownNewMoon = new Date(2026, 0, 29).getTime();
const cycle = 29.53;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const phaseNames: [string, number][] = [
["new_moon", 1.85],
["waxing_crescent", 7.38],
["first_quarter", 11.07],
["waxing_gibbous", 14.76],
["full_moon", 16.62],
["waning_gibbous", 22.15],
["last_quarter", 25.84],
["waning_crescent", 29.53],
["new_moon", 1.85], ["waxing_crescent", 7.38], ["first_quarter", 11.07],
["waxing_gibbous", 14.76], ["full_moon", 16.62], ["waning_gibbous", 22.15],
["last_quarter", 25.84], ["waning_crescent", 29.53],
];
const lunar: Record<string, { phase: string; illumination: number }> = {};
@ -129,18 +110,14 @@ class FolkCalendarView extends HTMLElement {
const dayTime = new Date(year, month, d).getTime();
const daysSinceNew = ((dayTime - knownNewMoon) / 86400000) % cycle;
const normalizedDays = daysSinceNew < 0 ? daysSinceNew + cycle : daysSinceNew;
let phaseName = "new_moon";
for (const [name, threshold] of phaseNames) {
if (normalizedDays < threshold) { phaseName = name; break; }
}
// Rough illumination: 0 at new, 1 at full
const illumination = Math.round((1 - Math.cos(2 * Math.PI * normalizedDays / cycle)) / 2 * 100) / 100;
lunar[dateStr] = { phase: phaseName, illumination };
}
this.lunarData = lunar;
this.render();
}
@ -156,7 +133,6 @@ class FolkCalendarView extends HTMLElement {
const start = `${year}-${String(month + 1).padStart(2, "0")}-01`;
const lastDay = new Date(year, month + 1, 0).getDate();
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
const base = this.getApiBase();
try {
const [eventsRes, sourcesRes, lunarRes] = await Promise.all([
@ -173,18 +149,24 @@ class FolkCalendarView extends HTMLElement {
private navigate(delta: number) {
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
this.expandedDay = "";
this.loadMonth();
}
private getMoonEmoji(phase: string): string {
const map: Record<string, string> = {
new_moon: "🌑", waxing_crescent: "🌒", first_quarter: "🌓",
waxing_gibbous: "🌔", full_moon: "🌕", waning_gibbous: "🌖",
last_quarter: "🌗", waning_crescent: "🌘",
new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}",
waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}",
last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}",
};
return map[phase] || "";
}
private formatTime(iso: string): string {
const d = new Date(iso);
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
}
private render() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
@ -192,82 +174,99 @@ class FolkCalendarView extends HTMLElement {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; padding: 0.5rem; }
* { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 16px; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
.toggle-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 12px; }
.toggle-btn.active { border-color: #6366f1; color: #6366f1; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
.nav { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; min-height: 36px; }
.nav-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12); background: transparent; color: #94a3b8; cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; }
.nav-btn:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.nav-btn.active { border-color: #6366f1; color: #6366f1; }
.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; }
.sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
.weekday { text-align: center; font-size: 11px; color: #666; padding: 4px; font-weight: 600; }
.wd { text-align: center; font-size: 11px; color: #64748b; padding: 4px; font-weight: 600; }
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.day {
background: #16161e; border: 1px solid #222; border-radius: 6px;
min-height: 80px; padding: 6px; cursor: pointer; position: relative;
-webkit-tap-highlight-color: transparent;
}
.day:hover { border-color: #444; }
.day.today { border-color: #6366f1; }
.day.other-month { opacity: 0.3; }
.day.today { border-color: #6366f1; background: rgba(99,102,241,0.06); }
.day.expanded { border-color: #6366f1; background: rgba(99,102,241,0.1); }
.day.other { opacity: 0.3; }
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; }
.moon { font-size: 10px; opacity: 0.7; }
.event-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
.event-dots { display: flex; flex-wrap: wrap; gap: 1px; }
.event-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; }
.event-label:hover { background: rgba(255,255,255,0.08); }
.event-time { color: #666; font-size: 8px; margin-right: 2px; }
.dots { display: flex; flex-wrap: wrap; gap: 1px; }
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
.ev-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; }
.ev-label:hover { background: rgba(255,255,255,0.08); }
.ev-time { color: #666; font-size: 8px; margin-right: 2px; }
.event-modal {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-content { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
/* Day detail panel (shown below grid row on tap, especially for mobile) */
.day-detail { grid-column: 1 / -1; background: #1a1a2e; border: 1px solid #334155; border-radius: 8px; padding: 12px; }
.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.dd-date { font-size: 14px; font-weight: 600; color: #e2e8f0; }
.dd-close { background: none; border: none; color: #64748b; font-size: 18px; cursor: pointer; padding: 4px 8px; }
.dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.dd-event:hover { background: rgba(255,255,255,0.05); }
.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }
.dd-info { flex: 1; min-width: 0; }
.dd-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
.dd-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.dd-empty { font-size: 12px; color: #64748b; padding: 8px 0; }
/* Event modal */
.modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
.sources { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.source-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
/* Mobile */
@media (max-width: 768px) {
.day { min-height: 56px; padding: 4px; }
:host { padding: 0.25rem; }
.day { min-height: 52px; padding: 4px; }
.day-num { font-size: 11px; }
.event-label { display: none; }
.event-dot { width: 5px; height: 5px; }
.ev-label { display: none; }
.dot { width: 5px; height: 5px; }
.moon { font-size: 8px; }
.rapp-nav__title { font-size: 13px; }
.nav-title { font-size: 13px; }
.nav { gap: 4px; }
.sources { gap: 4px; }
.src-badge { font-size: 9px; padding: 2px 6px; }
.wd { font-size: 10px; padding: 3px; }
}
@media (max-width: 480px) {
.day { min-height: 44px; padding: 3px; }
.day-num { font-size: 10px; }
.weekday { font-size: 9px; padding: 2px; }
.rapp-nav { gap: 4px; }
.toggle-btn { padding: 3px 6px; font-size: 10px; }
.source-badge { font-size: 8px; padding: 2px 6px; }
.wd { font-size: 9px; padding: 2px; }
.nav { flex-wrap: wrap; justify-content: center; }
.nav-title { width: 100%; order: -1; margin-bottom: 4px; }
}
</style>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
<div class="rapp-nav">
<button class="rapp-nav__back" id="prev"></button>
<button class="toggle-btn" id="today">Today</button>
<span class="rapp-nav__title">${monthName} ${year}</span>
<button class="toggle-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">🌙 Lunar</button>
<button class="rapp-nav__back" id="next"></button>
<div class="nav">
<button class="nav-btn" id="prev">\u2190</button>
<button class="nav-btn" id="today">Today</button>
<span class="nav-title">${monthName} ${year}</span>
<button class="nav-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319}</button>
<button class="nav-btn" id="next">\u2192</button>
</div>
${this.sources.length > 0 ? `<div class="sources">
${this.sources.map(s => `<span class="source-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
${this.sources.map(s => `<span class="src-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
</div>` : ""}
<div class="weekdays">
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `<div class="weekday">${d}</div>`).join("")}
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
</div>
<div class="grid">
${this.renderDays(year, month)}
@ -288,56 +287,108 @@ class FolkCalendarView extends HTMLElement {
// Previous month padding
const prevDays = new Date(year, month, 0).getDate();
for (let i = firstDay - 1; i >= 0; i--) {
html += `<div class="day other-month"><div class="day-num">${prevDays - i}</div></div>`;
html += `<div class="day other"><div class="day-num">${prevDays - i}</div></div>`;
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = dateStr === todayStr;
const isExpanded = dateStr === this.expandedDay;
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const lunar = this.lunarData[dateStr];
html += `<div class="day ${isToday ? "today" : ""}" data-date="${dateStr}">
html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${dateStr}">
<div class="day-num">
<span>${d}</span>
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
</div>
${dayEvents.length > 0 ? `
<div class="event-dots">
${dayEvents.slice(0, 3).map(e => `<span class="event-dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 3 ? `<span style="font-size:9px;color:#888">+${dayEvents.length - 3}</span>` : ""}
<div class="dots">
${dayEvents.slice(0, 4).map(e => `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 4 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 4}</span>` : ""}
</div>
${dayEvents.slice(0, 2).map(e => {
const t = new Date(e.start_time);
const timeStr = `${t.getHours()}:${String(t.getMinutes()).padStart(2, "0")}`;
return `<div class="event-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event='${JSON.stringify({ id: e.id })}'><span class="event-time">${timeStr}</span>${this.esc(e.title)}</div>`;
return `<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}"><span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>`;
}).join("")}
` : ""}
</div>`;
// Insert day-detail panel after the day's grid row if expanded
if (isExpanded) {
// Calculate how many cells are in this row so far
const cellIndex = firstDay + d - 1; // 0-based
const posInRow = cellIndex % 7;
// We need to fill remaining cells in the row, then insert detail
if (posInRow === 6 || d === daysInMonth) {
// End of row or last day — insert detail panel right here
html += this.renderDayDetail(dateStr, dayEvents);
}
// Otherwise, we'll insert after the row ends (handled below)
}
}
// Handle expanded day detail that wasn't at row end
if (this.expandedDay) {
const expD = parseInt(this.expandedDay.split("-")[2]);
const cellIndex = firstDay + expD - 1;
const posInRow = cellIndex % 7;
if (posInRow !== 6 && expD <= daysInMonth) {
// Need to render remaining days in the row, then add detail
// Actually this is complex with the linear approach. Let's use a simpler approach:
// Re-render with detail after the full row.
}
}
// Next month padding
const totalCells = firstDay + daysInMonth;
const remaining = (7 - (totalCells % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
html += `<div class="day other-month"><div class="day-num">${i}</div></div>`;
html += `<div class="day other"><div class="day-num">${i}</div></div>`;
}
// Append day detail at the very end (below grid) for simplicity on mobile
if (this.expandedDay) {
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(this.expandedDay));
html += this.renderDayDetail(this.expandedDay, dayEvents);
}
return html;
}
private renderDayDetail(dateStr: string, dayEvents: any[]): string {
const d = new Date(dateStr + "T00:00:00");
const label = d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric" });
return `<div class="day-detail">
<div class="dd-header">
<span class="dd-date">${label}</span>
<button class="dd-close" id="dd-close">\u2715</button>
</div>
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => `
<div class="dd-event" data-event-id="${e.id}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
<div class="dd-info">
<div class="dd-title">${this.esc(e.title)}</div>
<div class="dd-meta">${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}</div>
</div>
</div>
`).join("")}
</div>`;
}
private renderEventModal(): string {
const e = this.selectedEvent;
return `
<div class="event-modal" id="modal-overlay">
<div class="modal-content">
<button class="modal-close" id="modal-close"></button>
<div class="modal-bg" id="modal-overlay">
<div class="modal">
<button class="modal-close" id="modal-close">\u2715</button>
<div class="modal-title">${this.esc(e.title)}</div>
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
<div class="modal-field">When: ${new Date(e.start_time).toLocaleString()}${e.end_time ? `${new Date(e.end_time).toLocaleString()}` : ""}</div>
${e.location_name ? `<div class="modal-field">Where: ${this.esc(e.location_name)}</div>` : ""}
${e.source_name ? `<div class="modal-field">Source: ${this.esc(e.source_name)}</div>` : ""}
${e.is_virtual ? `<div class="modal-field">Virtual: ${this.esc(e.virtual_platform || "")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
<div class="modal-field">${new Date(e.start_time).toLocaleString()}${e.end_time ? ` \u2013 ${new Date(e.end_time).toLocaleString()}` : ""}</div>
${e.location_name ? `<div class="modal-field">\u{1F4CD} ${this.esc(e.location_name)}</div>` : ""}
${e.source_name ? `<div class="modal-field" style="margin-top:8px"><span class="src-badge" style="border-color:${e.source_color || "#666"};color:${e.source_color || "#aaa"}">${this.esc(e.source_name)}</span></div>` : ""}
${e.is_virtual ? `<div class="modal-field">\u{1F4BB} ${this.esc(e.virtual_platform || "Virtual")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
</div>
</div>
`;
@ -348,6 +399,7 @@ class FolkCalendarView extends HTMLElement {
this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1));
this.shadow.getElementById("today")?.addEventListener("click", () => {
this.currentDate = new Date();
this.expandedDay = "";
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
});
this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => {
@ -355,15 +407,34 @@ class FolkCalendarView extends HTMLElement {
this.render();
});
this.shadow.querySelectorAll("[data-event]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const data = JSON.parse((el as HTMLElement).dataset.event!);
this.selectedEvent = this.events.find(ev => ev.id === data.id);
// Day cell tap → expand day detail panel
this.shadow.querySelectorAll(".day:not(.other)").forEach(el => {
el.addEventListener("click", () => {
const date = (el as HTMLElement).dataset.date;
if (!date) return;
this.expandedDay = this.expandedDay === date ? "" : date;
this.render();
});
});
// Event clicks in day detail or labels → open modal
this.shadow.querySelectorAll("[data-event-id]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const id = (el as HTMLElement).dataset.eventId;
this.selectedEvent = this.events.find(ev => ev.id === id);
this.render();
});
});
// Close day detail
this.shadow.getElementById("dd-close")?.addEventListener("click", (e) => {
e.stopPropagation();
this.expandedDay = "";
this.render();
});
// Modal close
this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
});

View File

@ -383,8 +383,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/cal/cal.css?v=2">`,
}));
});

View File

@ -237,8 +237,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`,
scripts: `<script type="module" src="/modules/swag/folk-swag-designer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/swag/swag.css">`,
scripts: `<script type="module" src="/modules/swag/folk-swag-designer.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/swag/swag.css?v=2">`,
}));
});

View File

@ -54,7 +54,7 @@ export function renderShell(opts: ShellOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/shell.css?v=2">
<style>
/* When loaded inside an iframe (e.g. standalone domain redirecting back),
hide the shell chrome the parent rSpace page already provides it. */
@ -461,7 +461,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${mod.icon}</text></svg>">
<title>${escapeHtml(mod.name)} rSpace</title>
<link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/shell.css?v=2">
${cssBlock}
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>

View File

@ -5,6 +5,13 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
background: #0f172a;
color: #e2e8f0;
}
body[data-theme="light"] {
background: #f8fafc;
color: #0f172a;
}
/* ── Header bar ── */
@ -228,4 +235,19 @@ body {
.rstack-header {
padding: 0 12px;
}
#app {
padding-top: 82px;
}
.rapp-nav {
flex-wrap: wrap;
gap: 6px;
}
}
@media (max-width: 480px) {
#app {
padding-top: 78px;
padding-left: 8px;
padding-right: 8px;
}
}