/*
# Calendar

Four views — day, week, month, and year — are one surface seen through a moving,
zooming **focus rectangle** over a month's day-grid. The whole grid is the month;
a spring shrinks the rectangle onto one row for the week, one cell for the day.
Each cell is just its grid slot mapped through that rectangle, so changing view is
one continuous zoom. As a cell grows its events reshape from stacked chips into
placed time-blocks; drag one to reschedule. Year is the same surface a step
further out, each month folding into a mini.
*/


// date arithmetic, names and colour blending: functions of their arguments

script {
    const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
    const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]

    function lerp(a: number, b: number, t: number): number { return a + (b - a) * t }
    function clamp(v: number, a: number, b: number): number { return Math.max(a, Math.min(b, v)) }

    // blend two 0xRRGGBB colors channel-wise
    function mix(a: number, b: number, t: number): number {
        const c = (x: number, y: number) => Math.round(x + (y - x) * t)
        return (c((a >> 16) & 255, (b >> 16) & 255) << 16) | (c((a >> 8) & 255, (b >> 8) & 255) << 8) | c(a & 255, b & 255)
    }

    function monthName(m: number): string { return MONTHS[m - 1] }
    function dowName(i: number): string { return DAYS[i].slice(0, 3) }

    // a day's key, "2024-1-19": what cells and events are matched by
    function dayKey(y: number, m: number, d: number): string { return y + "-" + m + "-" + d }
    function keyOf(dt: Date): string { return dayKey(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()) }
    function parseKey(k: string): Date { const p = k.split("-").map(Number); return new Date(p[0], p[1] - 1, p[2]) }

    function shortTime(min: number): string {
        const h = Math.floor(min / 60), mm = min % 60, ap = h < 12 ? "a" : "p"
        const hh = h % 12 == 0 ? 12 : h % 12
        return mm == 0 ? hh + ap : hh + ":" + (mm < 10 ? "0" + mm : "" + mm) + ap
    }
    function hourLabel(h: number): string { return (h % 12 == 0 ? 12 : h % 12) + (h < 12 ? " AM" : " PM") }
    function weekLabel(k: string): string {
        const d = parseKey(k)
        const s = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay())
        const e = new Date(s.getFullYear(), s.getMonth(), s.getDate() + 6)
        return monthName(s.getMonth() + 1) + " " + s.getDate() + " – "
             + (s.getMonth() == e.getMonth() ? "" : monthName(e.getMonth() + 1) + " ") + e.getDate()
    }
    function dayLabel(k: string): string { const d = parseKey(k); return DAYS[d.getDay()] + ", " + monthName(d.getMonth() + 1) + " " + d.getDate() }
    }


schema Event [ id: number, title: string, y: number, m: number, d: number, start: number, end: number,
    category: string, notes?: string, location?: string, attendees?: string, comment?: string ]

// the model's shapes: a day of the month grid, and the year's twelve minis

schema Day [ key: string, y: number, m: number, d: number, dow: string, col: number, row: number,
    inMonth: boolean, weekend: boolean, today: boolean, current: boolean, events[]: Event ]
schema MiniDayInfo [ num: number, col: number, row: number, inMonth: boolean, today: boolean, has: boolean ]
schema Month [ m: number, name: string, mcol: number, mrow: number, cells[]: MiniDayInfo ]


/*
## The events

The records, fetched once, and what is true of them: which fall on a day, and
how one moves. Edits write straight into them: nothing fetches the source
again, so no refresh can overwrite an edit.
*/

class Events extends DataSource [ schema = [ events[]: Event ],
    all() -> Event[] { return this.read(["events"]) ?? [] },
    byId(id: number) -> Event? { return this.all().find((e) => e.id == id) ?? null },

    // a day's events, earliest first

    on(y: number, m: number, d: number) -> Event[] {
        return this.all().filter((e) => e.y == y && e.m == m && e.d == d).sort((a, b) => a.start - b.start || a.id - b.id)
        },
    any(y: number, m: number, d: number) -> boolean { return this.all().some((e) => e.y == y && e.m == m && e.d == d) },

    // a move takes the event to another day, and to a new start when one is
    // given (a drop in a time view), keeping its length either way

    move(id: number, y: number, m: number, d: number, start: number?) {
        const i = this.all().findIndex((e) => e.id == id)
        if (i < 0) return
        const e = this.all()[i]
        this.set(["events", i, "y"], y)
        this.set(["events", i, "m"], m)
        this.set(["events", i, "d"], d)
        if (start == null) return
        const len = e.end - e.start
        const s = clamp(start, 0, 1440 - len)
        this.set(["events", i, "start"], s)
        this.set(["events", i, "end"], s + len)
        }
    ]


/*
## Dragging

A drag in progress: which event, whether the pointer has moved far enough to
make it a drag, the day under the pointer, and where on the chip it was taken.
The chip, the cells and the ghost all read it; a drop is one move of the event.
*/

class Drag extends Node [
    id: number = -1,
    moving: boolean = false,
    target: string = "",        // the key of the day under the pointer
    grabX: number = 0,
    grabY: number = 0,
    chipW: number = 0,
    chipH: number = 0,
    downX: number = 0,
    downY: number = 0,

    start(id: number, w: number, h: number, gx: number, gy: number) {
        this.id = id; this.chipW = w; this.chipH = h; this.grabX = gx; this.grabY = gy
        this.moving = false; this.target = ""; this.downX = app.pointerX; this.downY = app.pointerY
        },
    follow() {
        if (id < 0) return
        if (!moving) {
            const dx = app.pointerX - downX, dy = app.pointerY - downY
            if (dx * dx + dy * dy > 16) moving = true
            }
        if (moving) target = app.dayAt(app.pointerX, app.pointerY)?.key ?? ""
        },

    // The chip's hold fired: lift the ghost at once, before any movement. On
    // touch this is the moment the claim engages, and the pickup should be
    // visible while the finger is still parked.

    lift() {
        if (id < 0) return
        moving = true
        target = app.dayAt(app.pointerX, app.pointerY)?.key ?? ""
        },

    // A drop moves the event, but never a canceled one: on touch the browser
    // may take the gesture for a scroll, and that release must not move the
    // event to wherever the finger happened to be.

    drop(px: number, py: number, canceled: boolean) {
        if (id < 0) return
        const day = app.dayAt(px, py)
        if (moving && !canceled && day != null)
            app.events.move(id, day.y, day.m, day.d, app.blockness > 0.5 ? app.minuteAt(py - grabY) : null)
        id = -1; moving = false; target = ""
        }
    ]


/* ── the calendar's colors, over San Francisco ──

   The App's theme is the San Francisco preset with these records laid over it
   (see `theme` on the App), so each one holds only what the calendar adds or
   changes: its own names (weekendBg, holidayText, miniCurrentBg …) in both
   modes, and in dark a few of the preset's own colors.

   Dark changes them because of the grid. San Francisco's dark surfaces step
   visibly apart, which suits a list; across a month of 42 equal cells the
   steps read as stripes. So the calendar's dark background, surfaces and lines
   sit closer together, the muted text is a little darker to stay below the
   main text, and the selected color is a little brighter so a selection still
   stands out. */

theme CalendarLight [
    cellBg = #FFFFFF, outOfMonthBg = #EDF0F5,
    weekendBg = #F1F4F8, todayBg = #DCE9FF, dropTargetBg = #C7DDFF, dropTargetRing = #2E6FE0,
    gridLine = #E4E9EF, dayNumber = #2A3542, bodyText = #3E4C59, hourLabel = #99A5B0,
    accentSecondary = #12A594, currentDayText = #2E6FE0, todayText = #2E6FE0,
    fieldBg = #FFFFFF, fieldEdge = #DBE1E9,
    sectionBg = #F1F5F9, sectionOpenBg = #E6EEF8, miniBg = #FFFFFF, miniCurrentBg = #E7F0FF,
    miniDayText = #3E4C59, miniDayFaint = #BFC8D2, holidayBg = #DCF3E4, holidayText = #1E7A44,
    astroBg = #EBE6FB, astroText = #5B3FB8, milestoneBg = #FBEFD6, milestoneText = #9A6A11,
    defaultBg = #DCE8FE, defaultText = #2456B0
    ]


theme CalendarDark [
    background = #0D151E, surface = #101A24, cellBg = #18242F, outOfMonthBg = #101922,
    weekendBg = #141F2A, todayBg = #14304F, dropTargetBg = #1E3C5E, dropTargetRing = #6BA4F0,
    line = #22323E, gridLine = #1B2934, textMuted = #8A9BA6,
    textFaint = #54626E, dayNumber = #C7D3DC, bodyText = #B4C2CC, hourLabel = #53636F,
    accentSecondary = #37E0C8, currentDayText = #9CC4FF, todayText = #7FB0FF,
    control = #131F29, controlSelected = #244463, fieldBg = #0F1A25, fieldEdge = #2A3B49,
    sectionBg = #16222E, sectionOpenBg = #1B2B3B, miniBg = #121C26, miniCurrentBg = #14283F,
    miniDayText = #9FB0BB, miniDayFaint = #33414C, holidayBg = #14321F, holidayText = #74D89B,
    astroBg = #241F3D, astroText = #B6A6F0, milestoneBg = #33280F, milestoneText = #F0C36B,
    defaultBg = #123050, defaultText = #8FB6F0
    ]


/*
## Event

Morphs between a stacked month-chip and a placed time-block as its day zooms
(`rowBlock`), and shrinks to a single dot in the year view (`app.yearZoom`).
*/

class Ev extends Control [ clip = true,

    rowBlock: number = 0,       // how far its day has become a time view: the cell that places it says
    inView: boolean = false,    // its day is in the view the focus rectangle is heading for: the cell says

    // a keyboard stop only where the person can see it: its day in view and
    // the chip inside the day, so the ring never travels off the surface

    focusable = { inView && this.y + this.height <= parent.height },

    cornerRadius = { lerp(5, 1.5, app.yearZoom) },
    fill = { mix(app.catFill(:category), provided("theme").accent, app.yearZoom) },
    opacity = { app.drag.id == :id && app.drag.moving ? 0.2 : 1 },
    x = { lerp(4, (parent.width - 3) / 2, app.yearZoom) },
    width = { lerp(parent.width - 8, 3, app.yearZoom) },
    y      = { lerp(lerp(26 + rowIndex * 20, :start / 1440 * parent.height, classroot.rowBlock),
                    parent.height - 6, app.yearZoom) },
    height = { lerp(lerp(17, Math.max(18, (:end - :start) / 1440 * parent.height), classroot.rowBlock),
                    3, app.yearZoom) },

    rail: View [ width = 3, height = 100%, fill = { app.catText(:category) }, opacity = { classroot.rowBlock } ],
    text: View [ x = 8, y = 2, opacity = { 1 - app.yearZoom },
        layout: SimpleLayout [ axis = x, spacing = 6 ],
        tm: Text [ fontSize = 10, fontWeight = semibold, wrap = false, textColor = { app.catText(:category) }, text = { shortTime(:start) } ],
        ti: Text [ fontSize = 10, fontWeight = regular, wrap = false, textColor = { app.catText(:category) }, text = { :title } ]
        ],

    onPointerDown(e: PointerEvent) { app.drag.start(:id, this.width, this.height, e.x, e.y) },
    onPointerMove()  { app.drag.follow() },
    onPointerUp(e: PointerUpEvent)   { app.drag.drop(e.rootX, e.rootY, e.canceled) },

    // The hold moves this chip's touch claim to the press-and-hold (a drag view
    // that also holds claims nothing at touchdown) — a quick swipe over a chip
    // pans the page; press-and-hold picks the event up, and the hold itself
    // lifts the ghost so the pickup is visible before the finger moves.

    onHold()       { app.drag.lift() },
    press()        { app.selectEvent(:id, dayKey(:y, :m, :d)) },

    // Return or Space opens it from the keyboard, and the title takes focus;
    // closing the panel gives focus back here (closeDetail)

    onKeyDown(e: KeyEvent) {
        if (e.repeat || (e.key != "Enter" && e.key != " ")) return
        app.openedFrom = this
        app.selectEvent(:id, dayKey(:y, :m, :d))
        afterSettle(() => Focus.focus(app.panel.ev.title))
        }
    ]


/*
## Cell

One day of the surface — its rectangle is its month-grid slot mapped through the
focus rectangle, so the same object is a month cell, a week column, or a full day.
In the year view (`app.yearZoom`) it re-lays-out into the current month's mini rather than
scaling, keeping its text crisp.
*/

class Cell [ clip = true,
    accessibilityRole = group,
    accessibilityLabel = { dayLabel(:key) + (:today ? ", today" : "") },

    rowBlock: number = { :row == app.anchorRow ? app.blockness : 0 },

    // inside the rectangle the view is heading for (its targets, not the
    // sprung values, so reachability does not flicker mid-transition)

    inRows: boolean = { :row >= app.r0To && :row < app.r0To + app.nrTo },
    inCols: boolean = { :col >= app.c0To && :col < app.c0To + app.ncTo },
    inView: boolean = { app.mode != "year" && inRows && inCols },

    x = { lerp(app.pad + app.gutter + (:col - app.c0) * app.colW + app.gap / 2, app.slotX + 10 + :col * app.miniCellW, app.yearZoom) },
    y = { lerp(app.headH + (:row - app.r0) * app.rowH + app.gap / 2, app.slotY + 34 + :row * app.miniRowH, app.yearZoom) },
    width = { lerp(app.colW - app.gap, app.miniCellW, app.yearZoom) },
    height = { lerp(app.rowH - app.gap, app.miniRowH, app.yearZoom) },

    // the fill fades as the cell becomes a column, then melts to the mini's
    // highlight in the year view; only the drop target draws a ring

    bg: View [ width = 100%, height = 100%, cornerRadius = 8,
        opacity = { 1 - 0.82 * classroot.rowBlock },
        dayBg: number = { !:inMonth ? provided("theme").outOfMonthBg
                        : :weekend  ? provided("theme").weekendBg
                        :             provided("theme").cellBg },
        fill = { mix(
              :key == app.drag.target ? provided("theme").dropTargetBg
            : :today              ? mix(provided("theme").todayBg, provided("theme").cellBg, classroot.rowBlock)
            :                       dayBg,
            provided("theme").miniCurrentBg, app.yearZoom) },
        stroke = { :key == app.drag.target ? stroke(2, mix(provided("theme").dropTargetRing, provided("theme").miniCurrentBg, app.yearZoom)) : null }
        ],

    // current-day accent bar, fading in as the cell fills the surface

    mark: View [ width = 100%, height = 3, fill = { provided("theme").accent }, visible = { :current }, opacity = { classroot.rowBlock } ],
    num: Text [ x = { lerp(11, parent.width / 2 - 5, app.yearZoom) }, y = { lerp(7, 2, app.yearZoom) },
        fontSize = { lerp(13, 9, app.yearZoom) }, wrap = false, fontWeight = semibold,
        opacity = { :inMonth ? 1 : 1 - app.yearZoom },
        textColor = { :inMonth ? provided("theme").dayNumber : provided("theme").textMuted },
        State [ applied = { :today }, textColor = { provided("theme").todayText } ],
        State [ applied = { :current }, textColor = { provided("theme").currentDayText } ],
        text = { "" + :d }
        ],
    dow: Text [ x = 34, y = 8, fontSize = 11, wrap = false, opacity = { classroot.rowBlock },
        textColor = { :current ? provided("theme").currentDayText : provided("theme").textMuted }, text = { :dow } ],
    Ev [ datapath = :events[], key = :id, rowBlock = { classroot.rowBlock }, inView = { classroot.inView } ],

    onClick() { app.pickDay(:key) }
    ]


/*
## Year minis

The year view's twelve small months. The current month is drawn by the main
`board` folding into its slot, so only the other eleven come from here.
*/

class MiniDay [ width = { app.miniCellW }, height = { app.miniRowH },
    x = { :col * app.miniCellW }, y = { :row * app.miniRowH },
    n: Text [ width = 100%, textAlign = center, y = 1, fontSize = 9, wrap = false,
        textColor = { :today ? provided("theme").accent : :inMonth ? provided("theme").miniDayText : provided("theme").miniDayFaint },
        text = { :inMonth ? "" + :num : "" } ],
    dot: View [ x = center, y = { parent.height - 4 }, width = 3, height = 3,
        cornerRadius = 2, fill = { provided("theme").accent }, visible = { :has } ]
    ]


class MiniMonth [ cornerRadius = 12, clip = true, fill = { provided("theme").miniBg },
    width = { app.miniW }, height = { app.miniH },
    x = { app.pad + :mcol * (app.miniW + app.gap) }, y = { app.pad + :mrow * (app.miniH + app.gap) },
    visible = { :m != app.month },      // the current month is the folding board, not a mini

    name: Text [ x = 14, y = 11, fontSize = 13, fontWeight = semibold,
        textColor = { :m == app.month ? provided("theme").todayText : provided("theme").dayNumber }, text = { :name } ],
    grid: View [ x = 10, y = 34, width = { parent.width - 20 }, height = { parent.height - 44 },
        MiniDay [ datapath = :cells[] ]
        ],

    onClick() { app.pickMonth(:m) }
    ]


/*
## Paging arrow

`dir` says everything: −1 turns the chevron left and pages back, +1 right and
forward — the same disclosure mark, at the same weight, as every other chevron
in the app.
*/

class NavArrow extends Control [ width = 36, height = 34, cornerRadius = 10,
    dir: number = 1,
    accessibilityLabel = { dir < 0 ? "Previous" : "Next" },
    fill = { pressed ? provided("theme").controlSelected : provided("theme").control },
    g: ChevronIcon [ iconSize = 15, x = center, y = center,
        dir = { classroot.dir < 0 ? "left" : "right" },
        ink = { classroot.pressed || classroot.hovered ? provided("theme").text : provided("theme").textMuted } ],
    press() { app.step(dir) }
    ]


/*
## Detail section

A collapsible field in the event panel — a spring-driven height, one open at a
time. Its input two-way binds to the event property named by `field`, so an edit
writes straight to the record.
*/

class DetailSection [ clip = true, cornerRadius = 10, fill = { provided("theme").sectionBg },
    idx: number = 0,
    label: string = "",
    field: string = "",
    isOpen: boolean = { app.openSection == idx },
    width = 100%, height = 46,
    Spring [ attribute = height, to = { isOpen ? 156 : 46 }, stiffness = 210, damping = 24 ],

    // collapsed, the field is clipped out of sight: Tab reaches the header only

    tabOrder() { return isOpen ? this.tabDefault() : [this.hdr] },

    hdr: Control [ width = 100%, height = 46, padding = [0, 11, 0, 15],
        accessibilityExpanded = { classroot.isOpen },
        layout: SimpleLayout [ axis = x ],      // each child centers itself, as icons do
        lbl: Text [ y = center, fontSize = 12, fontWeight = bold, wrap = false, letterSpacing = 1,
            textColor = { classroot.isOpen ? provided("theme").text : provided("theme").textMuted }, text = { classroot.label } ],
        Spacer [ ],

        MinusIcon [ y = center, iconSize = 15, visible = { classroot.isOpen }, ink = { provided("theme").textMuted } ],
        PlusIcon [ y = center, iconSize = 15, visible = { !classroot.isOpen }, ink = { provided("theme").textMuted } ],
        press() { app.openSection = classroot.idx }
        ],

    // A TextInput styles itself from the theme's `surface` and `line`; setting
    // fill and stroke here gives it the calendar's field colors, focus edge included.

    content: TextInput [ x = 13, y = 50, width = { parent.width - 26 }, height = { parent.height - 60 }, multiline = true, wrap = true,
        fontSize = 16, textColor = { provided("theme").bodyText }, placeholder = { "Add " + classroot.field },
        fill = { provided("theme").fieldBg },
        stroke = { stroke(1, this.focused ? provided("theme").accent : provided("theme").fieldEdge) },
        text <-> { classroot.field } ],
    open: State [ applied = { isOpen }, fill = { provided("theme").sectionOpenBg } ]
    ]


// This app is a fixed window: every view lives in-frame, and the browser never
// scrolls it. The App clips what lies outside it, so the detail panel, parked
// beyond the right edge until `panelInset` slides it in, paints nowhere and
// adds nothing to scroll to.

// Below the floor the calendar degrades instead of adapting (the grid needs its
// rows), so it declares one and the stage pans. The floor is phone-honest:
// with the bar tiering to two rows and the detail panel overlaying instead of
// insetting (below), nothing but the grid itself decides the minimum — seven
// legible columns, the same budget a phone-native month view spends.

App [ minWidth = 360, minHeight = 420, appName = "Declare Calendar",
    fill = { provided("theme").background }, fontFamily = ["system-ui", "sans-serif"],

    /* The theme is San Francisco, light or dark, with the calendar's record for
       that mode spread over it, so a library control dropped in here finds every
       token it reads. `themeMode` is Light / Dark / Auto — Auto follows the
       environment: what the host provides as `dark` when this app runs embedded
       (a desktop passing its appearance through), else the OS through `app.systemDark`. */

    theme = { { ...(app.isDark ? SanFranciscoDark : SanFrancisco), ...(app.isDark ? CalendarDark : CalendarLight) } },
    themeMode: "auto" | "light" | "dark" = "auto",

    isDark: boolean = { app.themeMode == "auto"
        ? hostProvided("dark", app.systemDark)
        : app.themeMode == "dark" },

    // layout constants

    barH: number = { app.barNarrow ? 100 : 60 },     // two rows when narrow; everything below derives
    pad: number = 20,
    gap: number = 8,

    /* Responsive chrome, all from the window width. As it narrows the
       appearance switch drops out, then the tabs shrink to single letters
       (D/W/M/Y). Thresholds are sized to the widest view so the bar never flickers
       when you switch views at a fixed width. */

    showTheme: boolean = { app.width >= 1030 },
    tabsCompact: boolean = { app.width < 910 },
    brandCompact: boolean = { app.width < 640 },     // "Declare Calendar" → "Declare" (still the way home)
    barNarrow: boolean = { app.width < 560 },        // the bar tiers to two rows: controls above, date nav below
    tabW: number = { app.tabsCompact ? 44 : 74 },
    tabsW: number = { 4 * app.tabW },

    // The date block is a fixed width per mode, from the widest label that mode
    // can ever show — measured, in the face the title actually renders in, so
    // a font or locale change moves it with no constant to retune. It must not
    // follow the current label: the arrows sit at its edges, so a width that
    // tracked "May 4" and then "September 30" would shift them as you page.

    titleFace: object = { providedTextStyle(({ fontSize: 16, fontWeight: "semibold" })) },
    widestOf(samples: string[]) -> number {
        return Math.max(0, ...samples.map((t) => measureText(t, app.titleFace).width))
        },
    dateW: number = { Math.ceil(app.widestOf(({
        week:  ["September 30 – October 31"],           // the cross-month week, longest names
        day:   ["Wednesday, September 30"],             // longest weekday + longest month
        month: ["September 2088"],
        year:  ["2088"],
        })[app.mode]) + 14) },
    panelOverlay: boolean = { app.width < 720 },     // the detail panel overlays the grid instead of insetting it
    panelW: number = { Math.min(340, app.width - 32) },

    // view + paging state

    mode: "day" | "week" | "month" | "year" = "month",

    // Where the user has paged to: the only date state, and all navigation
    // writes here. `0` / `""` mean "today".

    navYear: number = 0,
    navMonth: number = 0,      // month is 1-based
    navKey: string = "",

    // The visible date, derived: the paged-to date, or today's when unset.

    // Today is the clock's, so it turns over at midnight.

    clock: Time [ tick = day ],
    year: number = { app.navYear == 0 ? app.clock.year : app.navYear },
    month: number = { app.navMonth == 0 ? app.clock.month : app.navMonth },
    todayKey: string = { app.clock.year + "-" + app.clock.month + "-" + app.clock.day },
    anchorKey: string = { app.navKey == "" ? app.todayKey : app.navKey },
    monthRows: number = { app.computeMonthRows() },
    yearSeen: boolean = false,
    selectedId: number = -1,
    openSection: number = 0,

    /* The data file sits beside the app. A host that embeds the calendar
       provides `base`, the path to the app's home, and the same relative
       address resolves through it. `auto` fetches whenever the url changes,
       so a base that arrives late still loads the right file. */

    events: Events [ autoFetch = true, url = { hostProvided("base", "") + "data/events.json" } ],

    /* The model is derived, not built: `cal.contents` recomputes when the visible
       month or the events change, and keyed replication (`:key`, `:id`) makes each
       recompute cost only the days that changed. The year grid is its own
       derivation, deferred until the year view is first visited. The weekday
       names and hour labels never change, so they are computed once. */

    cal: Dataset [ schema = [ grid[]: Day ], contents = { app.buildModel() } ],
    yearCal: Dataset [ schema = [ months[]: Month ], contents = { app.yearSeen ? app.buildMonths() : { months: [] } } ],
    labels: Dataset [ schema = [ cols[]: [ name: string, col: number ], hours[]: [ h: number, label: string ] ],
        contents = { { cols: [0, 1, 2, 3, 4, 5, 6].map((i) => ({ name: dowName(i), col: i })),
                       hours: Array.from({ length: 24 }, (_, h) => ({ h: h, label: hourLabel(h) })) } } ],

    // the detail panel slides in on this value; the body shrinks by the same amount

    panelInset: number = 0,
    Spring [ attribute = panelInset, to = { app.selectedId >= 0 ? app.panelW : 0 }, stiffness = 240, damping = 30 ],

    // geometry

    bodyW: number = { app.width - (app.panelOverlay ? 0 : app.panelInset) },   // an overlaying panel never squeezes the grid
    bodyH: number = { app.height - app.barH },
    headH: number = 30,
    miniW: number = { (app.bodyW - 2 * app.pad - app.gap * 3) / 4 },
    miniH: number = { (app.bodyH - 2 * app.pad - app.gap * 2) / 3 },
    miniCellW: number = { (app.miniW - 20) / 7 },
    miniRowH: number = { (app.miniH - 44) / 6 },

    /* The focus rectangle: (c0,r0) is where the frame starts, (nc,nr) how many
       columns/rows it spans. Month is the whole grid; week springs onto the anchor
       row, day onto the anchor cell. The four springs carry the frame between them. */

    anchorCol: number = { app.gridIndexOf(app.anchorKey) % 7 },
    anchorRow: number = { Math.floor(app.gridIndexOf(app.anchorKey) / 7) },
    c0To: number = { app.mode == "day" ? app.anchorCol : 0 },
    r0To: number = { app.mode == "week" || app.mode == "day" ? app.anchorRow : 0 },
    ncTo: number = { app.mode == "day" ? 1 : 7 },
    nrTo: number = { app.mode == "week" || app.mode == "day" ? 1 : app.monthRows },
    c0: number = 0,
    r0: number = 0,
    nc: number = 7,
    nr: number = 6,
    Spring [ attribute = c0, to = { app.c0To }, stiffness = 150, damping = 24, mass = 0.9, epsilon = 0.002 ],
    Spring [ attribute = r0, to = { app.r0To }, stiffness = 150, damping = 24, mass = 0.9, epsilon = 0.002 ],
    Spring [ attribute = nc, to = { app.ncTo }, stiffness = 150, damping = 24, mass = 0.9, epsilon = 0.002 ],
    Spring [ attribute = nr, to = { app.nrTo }, stiffness = 150, damping = 24, mass = 0.9, epsilon = 0.002 ],
    colW: number = { (app.bodyW - 2 * app.pad - app.gutter) / app.nc },
    rowH: number = { (app.bodyH - app.headH) / app.nr },

    // 0 = month chips, 1 = time blocks. Keyed on the focus span (one focused
    // row **is** a time view), never on pixel height: a short-landscape day
    // still resolves to blocks, and a tall monitor's month can never start
    // half-morphing its chips. Riding `nr` also keeps the morph continuous:
    // chips hold shape while the focus zooms, then resolve into blocks over
    // the last row of travel.

    blockness: number = { clamp(2 - app.nr, 0, 1) },

    gutter: number = { app.blockness * 52 },                             // hour-label gutter, opens in time views

    /* Year zoom: `yearZoom` (0→1) shrinks the month surface into its slot in the 4×3 year
       grid while the other eleven months resolve around it. `yearScale` scales the
       surface and the surrounding grid in lock-step. */

    myCol: number = { (app.month - 1) % 4 },
    myRow: number = { Math.floor((app.month - 1) / 4) },
    slotX: number = { app.pad + app.myCol * (app.miniW + app.gap) },
    slotY: number = { app.pad + app.myRow * (app.miniH + app.gap) },
    yearScale: number = { lerp(app.bodyW / app.miniW, 1, app.yearZoom) },
    yearZoom: number = 0,
    Spring [ attribute = yearZoom, to = { app.mode == "year" ? 1 : 0 }, stiffness = 150, damping = 24, mass = 0.9, epsilon = 0.002 ],

    periodLabel: string = { app.periodText() },
    periodText() -> string {
        switch (this.mode) {
            case "year":  return "" + this.year
            case "month": return monthName(this.month) + " " + this.year
            case "week":  return weekLabel(this.anchorKey)
            default:      return dayLabel(this.anchorKey)
            }
        },

    // a day's 0..41 slot in the visible month's grid

    gridIndexOf(key: string) -> number {
        const first = new Date(this.year, this.month - 1, 1)
        const start = new Date(this.year, this.month - 1, 1 - first.getDay())
        const d = parseKey(key)
        return Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
                         - Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())) / 86400000)
        },

    // these return number, not Color, because mix() blends plain 0xRRGGBB
    // numbers, and theme tokens are always set

    catFill(c: string) -> number { const t = provided("theme"); return c == "holiday" ? t.holidayBg : c == "astro" ? t.astroBg : c == "milestone" ? t.milestoneBg : t.defaultBg },
    catText(c: string) -> number { const t = provided("theme"); return c == "holiday" ? t.holidayText : c == "astro" ? t.astroText : c == "milestone" ? t.milestoneText : t.defaultText },

    computeMonthRows() -> number {
        const first = new Date(this.year, this.month - 1, 1)
        return Math.ceil((first.getDay() + new Date(this.year, this.month, 0).getDate()) / 7)
        },

    /* ## The model
       `buildModel` builds the visible month's grid, each day holding its events —
       the data's own records, so an event row reads and writes its record.
       `buildMonths` builds the year's twelve minis from the year, today and the
       events; a view switch touches none of these, so it never rebuilds on one. */

    buildModel() -> object {
        const first = new Date(this.year, this.month - 1, 1)
        const start = new Date(this.year, this.month - 1, 1 - first.getDay())
        const grid = []
        for (let i = 0; i < this.computeMonthRows() * 7; i++) {
            const dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)
            const y = dt.getFullYear(), m = dt.getMonth() + 1, d = dt.getDate()
            grid.push({ key: dayKey(y, m, d), y: y, m: m, d: d, dow: dowName(dt.getDay()), col: i % 7, row: Math.floor(i / 7),
                        inMonth: m == this.month, weekend: dt.getDay() == 0 || dt.getDay() == 6,
                        today: dayKey(y, m, d) == this.todayKey, current: dayKey(y, m, d) == this.anchorKey,
                        events: this.events.on(y, m, d) })
            }
        return { grid: grid }
        },
    buildMonths() -> object {
        const months = []
        for (let m = 1; m <= 12; m++) {
            const st = new Date(this.year, m - 1, 1 - new Date(this.year, m - 1, 1).getDay())
            const cells = []
            for (let i = 0; i < 42; i++) {
                const dt = new Date(st.getFullYear(), st.getMonth(), st.getDate() + i)
                const inMonth = dt.getMonth() == m - 1
                cells.push({ num: dt.getDate(), col: i % 7, row: Math.floor(i / 7), inMonth: inMonth,
                             today: keyOf(dt) == this.todayKey,
                             has: inMonth && this.events.any(dt.getFullYear(), m, dt.getDate()) })
                }
            months.push({ m: m, name: monthName(m), mcol: (m - 1) % 4, mrow: Math.floor((m - 1) / 4), cells: cells })
            }
        return { months: months }
        },

    /* ## Navigation
       A view switch is just a new `mode` (the focus rect springs); a page turn is
       a new navYear/navMonth/navKey. The model derives from those, so navigation
       only sets state — and `today()` is a return to the defaults. */

    goMode(m: "day" | "week" | "month" | "year") { this.selectedId = -1; this.mode = m; if (m == "year") this.yearSeen = true },
    pickDay(key: string) {
        const d = parseKey(key)
        this.navKey = key; this.navYear = d.getFullYear(); this.navMonth = d.getMonth() + 1; this.selectedId = -1; this.mode = "day"
        },
    pickMonth(m: number) { this.navMonth = m; this.selectedId = -1; this.mode = "month" },
    today()       { this.navYear = 0; this.navMonth = 0; this.navKey = "" },
    step(dir: number) {
        const a = parseKey(this.anchorKey)      // paging keeps the anchor day coherent
        if (this.mode == "year") {
            const y = this.year + dir
            this.navYear = y
            this.navKey = dayKey(y, a.getMonth() + 1, Math.min(a.getDate(), new Date(y, a.getMonth() + 1, 0).getDate()))
        } else if (this.mode == "month") {
            let m = this.month + dir, y = this.year
            if (m < 1) { m = 12; y-- } else if (m > 12) { m = 1; y++ }
            this.navMonth = m; this.navYear = y
            this.navKey = dayKey(y, m, Math.min(a.getDate(), new Date(y, m, 0).getDate()))
        } else {
            const n = new Date(a.getFullYear(), a.getMonth(), a.getDate() + dir * (this.mode == "week" ? 7 : 1))
            this.navKey = keyOf(n); this.navYear = n.getFullYear(); this.navMonth = n.getMonth() + 1
            }
        },
    selectEvent(id: number, key: string?) {
        this.selectedId = id; this.openSection = 0
        if (key != null) this.navKey = key
        },
    openedFrom: View? = null,           // the event the keyboard opened the panel from

    // Tab reads the bar first, though it is declared last to paint on top

    tabOrder() { return [this.bar, ...this.tabDefault().filter((v) => v != this.bar)] },

    closeDetail() {
        const back = this.openedFrom
        const inside = Focus.holds(this.panel)
        this.selectedId = -1
        this.openedFrom = null
        if (back != null && inside) afterSettle(() => Focus.focus(back))
        },

    /* ## Drag to reschedule
       A gesture that moved is a drag and activates nothing, so the chip's
       `press` fires only on a real tap. The drag itself is `drag` (its class,
       above); the App answers the two questions it asks of the surface — which
       day is under a point, and what time a height means in a time view. A drop
       is one move of the event, so it wakes the derived model exactly like a
       title edit. */

    drag: Drag [ ],

    // invert the span mapping: a screen point to the day under it

    dayAt(px: number, py: number) -> Day? {
        const col = Math.floor((px - this.pad - this.gutter) / this.colW + this.c0)
        const row = Math.floor((py - this.barH - this.headH) / this.rowH + this.r0)
        return this.cal.value?.grid[clamp(row, 0, this.monthRows - 1) * 7 + clamp(col, 0, 6)] ?? null
        },

    // in a time view, the minute of the day a height on the surface stands for,
    // to the quarter hour

    minuteAt(py: number) -> number {
        return Math.round(((py - this.barH - this.headH) / this.rowH * 1440) / 15) * 15
        },

    /* ## Keyboard
       Arrows page the current view; Escape closes the detail panel. A Keys
       member — lifetime-managed, no listener to clean up. The arrows
       gate on the panel being closed: its text fields own the keyboard while
       an event is being edited (Keys is the raw stream). */

    keys: Keys [
        onKeyUp(e: KeyEvent) {
            if (e.key == "Escape") app.closeDetail()
            // the arrows page the calendar — not while the view tabs rove with them
            else if (app.selectedId < 0 && !Focus.holds(app.bar.top.tabs) && e.key == "ArrowLeft") app.step(-1)
            else if (app.selectedId < 0 && !Focus.holds(app.bar.top.tabs) && e.key == "ArrowRight") app.step(1)
            }
        ],

    /* ## The surface — day / week / month → year
       For year the surface does not scale into its slot; each Cell re-lays-out into
       the current month's slot (driven by `app.yearZoom`), so the month becomes its mini. The
       other eleven months are the deferred `yearCal`. */

    board: View [ x = 0, y = { app.barH }, width = { app.bodyW }, height = { app.bodyH }, clip = true,

        // the month's backdrop: authored at the destination slot and scaled up about
        // its centre, so it starts full-bleed and converges by a pure scale to the mini

        monthBg: View [ x = { app.slotX }, y = { app.slotY }, width = { app.miniW }, height = { app.miniH },
            cornerRadius = 12, fill = { mix(provided("theme").background, provided("theme").miniCurrentBg, app.yearZoom) },
            scale = { app.yearScale }, pivotX = { app.miniW / 2 }, pivotY = { app.miniH / 2 } ],
        weekdays: View [ opacity = { (1 - app.blockness) * (1 - app.yearZoom) }, datapath = { app.labels.value },
            View [ datapath = :cols[], key = :col, x = { app.pad + :col * (app.bodyW - 2 * app.pad) / 7 + 11 },
                l: Text [ y = 9, fontSize = 11, wrap = false, textColor = { provided("theme").textMuted }, text = { :name } ]
                ]
            ],
        hours: View [ opacity = { app.blockness }, datapath = { app.labels.value },
            View [ datapath = :hours[], key = :h, y = { app.headH + :h / 24 * (app.bodyH - app.headH) }, width = { app.bodyW }, height = 1, fill = { provided("theme").gridLine },
                lbl: Text [ x = 6, y = 1, fontSize = 10, wrap = false, textColor = { provided("theme").hourLabel }, text = { :label } ]
                ]
            ],
        grid: View [ datapath = { app.cal.value },
            Cell [ datapath = :grid[], key = :key ]
            ],
        yearName: Text [ x = { app.slotX + 14 }, y = { app.slotY + 11 }, fontSize = 13, fontWeight = semibold, wrap = false,
            textColor = { provided("theme").todayText }, text = { monthName(app.month) },
            opacity = { clamp((app.yearZoom - 0.5) / 0.5, 0, 1) } ]
        ],

    /* ## Year — the same surface pulled back to the full 4×3 grid, the eleven other
       months sweeping in around the shrinking board. */

    yearView: View [ y = { app.barH }, width = { app.bodyW }, height = { app.bodyH }, clip = true, visible = { app.yearZoom > 0.02 },
        opacity = { clamp((app.yearZoom - 0.5) / 0.3, 0, 1) },
        scale = { app.yearScale }, pivotX = { app.slotX + app.miniW / 2 }, pivotY = { app.slotY + app.miniH / 2 },
        grid: View [ width = 100%, height = 100%, datapath = { app.yearCal.value },
            MiniMonth [ datapath = :months[], key = :m ]
            ]
        ],

    /* ## Event detail panel
       Points its datapath at the selected event, so every field two-way binds
       straight to the record and reseeds when a different event opens. */

    panel: View [ width = { app.panelW }, height = { app.bodyH }, y = { app.barH }, x = { app.width - app.panelInset },
        // shown while it is open or sliding; hidden once closed, so nothing in
        // it is reachable — and its stops go the moment it starts to close

        visible = { app.selectedId >= 0 || app.panelInset > 0.5 },
        tabOrder() { return app.selectedId >= 0 ? this.tabDefault() : [] },
        fill = { provided("theme").surface }, stroke = { [null, null, null, stroke(1, provided("theme").line)] },
        padding = [24, 22, 24, 22],
        layout: SimpleLayout [ axis = y, spacing = 8 ],
        datapath = { app.events.byId(app.selectedId) },

        /* The event section — the title block in the sections' own grammar: the
           same card, its label row carrying the panel's close where the others
           carry their sign, the category color as the card's left edge, and the
           read-only date · time under the editable title. */

        ev: View [ width = 100%, cornerRadius = 10, clip = true, fill = { provided("theme").sectionBg },
            stroke = { [null, null, null, stroke(4, app.catText(:category))] },
            padding = [0, 13, 12, 13],
            layout: SimpleLayout [ axis = y ],
            hdr: View [ width = 100%, height = 40,
                layout: SimpleLayout [ axis = x ],
                lbl: Text [ y = center, fontSize = 12, fontWeight = bold, wrap = false, letterSpacing = 1,
                    textColor = { provided("theme").textMuted }, text = "EVENT" ],
                Spacer [ ],
                close: Control [ width = 15, height = 15, y = center, cornerRadius = 4,
                    press() { app.closeDetail() },
                    CloseIcon [ y = center, iconSize = 15, ink = { provided("theme").textMuted } ]
                    ]
                ],
            title: TextInput [ width = 100%, height = 32, fontSize = 16, fontWeight = bold,
                textColor = { provided("theme").text }, placeholder = "Untitled event",
                fill = { provided("theme").fieldBg },
                stroke = { stroke(1, this.focused ? provided("theme").accent : provided("theme").fieldEdge) },
                text <-> :title ],
            meta: Text [ padding = [12, 0, 0, 0], fontSize = 12.5, wrap = false, textColor = { provided("theme").textMuted },
                text = { dayLabel(dayKey(:y, :m, :d)) + "  ·  " + shortTime(:start) + " – " + shortTime(:end) } ]
            ],

        DetailSection [ idx = 0, label = "NOTES",     field = "notes" ],
        DetailSection [ idx = 1, label = "LOCATION",  field = "location" ],
        DetailSection [ idx = 2, label = "ATTENDEES", field = "attendees" ]
        ],

    // the grabbed event, following the pointer during a drag

    ghost: View [ visible = { app.drag.moving }, opacity = 0.9, x = { app.pointerX - app.drag.grabX }, y = { app.pointerY - app.drag.grabY },
        datapath = { app.drag.moving ? app.events.byId(app.drag.id) : null },
        width = { app.drag.chipW }, height = { Math.max(app.drag.chipH, 34) }, cornerRadius = 6, clip = true, fill = { app.catFill(:category ?? "") },
        shadow = shadow(0, 6, 16, #00000088),
        rail: View [ width = 3, height = 100%, fill = { app.catText(:category ?? "") } ],
        tm: Text [ x = 11, y = 4, fontSize = 10, fontWeight = bold, wrap = false, textColor = { app.catText(:category ?? "") }, text = { shortTime(:start ?? 0) } ],
        ti: Text [ x = 11, y = 17, fontSize = 11, wrap = false, textColor = { app.catText(:category ?? "") }, text = { :title ?? "" } ]
        ],

    /* ## Chrome
       Declared last for the top z-order. Three zones: brand + theme switch hug the
       left, Today + the view tabs hug the right, and the date nav floats near the
       centre, clamped so it never rides into either side. */

    bar: View [ width = { app.width }, height = { app.barH }, fill = { provided("theme").background },
        stroke = { [null, null, stroke(1, provided("theme").line), null] },   // the rule under the bar is its own bottom edge

        // Tab follows the bar as it reads. On one row the date nav sits between
        // the two clusters, so it is visited between them; on two rows it is
        // the second row, which tree order already gives.

        tabOrder() { return app.barNarrow ? this.tabDefault() : [top.brand, top.themeSwitch, nav, top.today, top.tabs] },

        // Row 1 is the first 60px band — fixed, not barH-derived, so the narrow
        // tier's second row doesn't drag it downward. Brand and the appearance
        // switch hug the left, Today and the view tabs the right.

        top: View [ x = 20, width = { app.width - 20 - (app.barNarrow ? 12 : 20) }, height = 60,
            layout: SimpleLayout [ axis = x, spacing = 16, align = center ],

            // where each cluster ends, in the bar's coordinates — the date nav
            // floats between them

            leftEnd: number = { this.x + (app.showTheme ? this.themeSwitch.x + this.themeSwitch.width : this.brand.x + this.brand.width) },
            rightStart: number = { this.x + this.today.x },

            brand: View [ height = 22,
                layout: SimpleLayout [ axis = x, spacing = 5 ],

                // the word "Declare" is the way home — navigate() resolves against the
                // distro root, so this works on either host at any mount point

                Control [ press() { app.navigate("apps/homepage/homepage.declare") },
                    accessibilityRole = link,
                    Text [ fontSize = 18, fontWeight = semibold, letterSpacing = { -0.02 * fontSize }, wrap = false, textFill = { gradient("90deg", provided("theme").accent, provided("theme").accentSecondary) }, text = "Declare" ]
                    ],
                Text [ fontSize = 18, fontWeight = semibold, letterSpacing = { -0.02 * fontSize }, wrap = false, textColor = { provided("theme").text }, text = "Calendar",
                    visible = { !app.brandCompact } ]
                ],

            // the library's light/dark switch: it shows the current appearance, and
            // choosing one pins themeMode, leaving Auto

            themeSwitch: AppearanceSwitch [
                visible = { app.showTheme },
                dark = { app.isDark },
                input(v: boolean) { app.themeMode = v ? "dark" : "light" }
                ],

            Spacer [ ],

            // quiet like the tabs beside it: muted until the pointer is on it

            today: Button [ height = 34, fontSize = 13, label = "Today",
                inkColor = { hovered || pressed ? provided("theme").text : provided("theme").textMuted },
                onClick() { app.today() }
                ],

            // Day / Week / Month / Year — the library's `Segmented`, which brings
            // tab stops, arrow-key roving, and the shared look.
            //
            // The labels shorten to single letters when the bar narrows, which the
            // data carries: `choices` is a constraint like any other.

            tabs: Segmented [ height = 34, width = { app.tabsW },
                value = { app.mode },
                input(v: object) { app.goMode(v as "day" | "week" | "month" | "year") },
                choices = { [ ({ id: "day",   label: app.tabsCompact ? "D" : "Day" }),
                              ({ id: "week",  label: app.tabsCompact ? "W" : "Week" }),
                              ({ id: "month", label: app.tabsCompact ? "M" : "Month" }),
                              ({ id: "year",  label: app.tabsCompact ? "Y" : "Year" }) ] }
                ]
            ],

        // The date block is a fixed width per view, so the arrows hold still while
        // paging. Narrow: the nav takes the second row, centered. Wide: it floats
        // near the center, clamped off both clusters — and the clamp can never
        // invert (the max is floored at the min, so a squeezed bar pins the nav
        // left rather than flinging it off-frame).

        nav: View [ y = { app.barNarrow ? 56 : 13 },
            layout: SimpleLayout [ axis = x, spacing = 6, align = center ],
            x = { app.barNarrow
                ? (app.width - this.width) / 2
                : clamp((app.width - this.width) / 2, parent.top.leftEnd + 24,
                            Math.max(parent.top.leftEnd + 24, parent.top.rightStart - this.width - 24)) },
            prev:  NavArrow [ dir = -1 ],
            title: Text [ width = { app.dateW }, textAlign = center, fontSize = 16, wrap = false,
                fontWeight = semibold, textColor = { provided("theme").text }, text = { app.periodLabel } ],
            next:  NavArrow [ dir = 1 ]
            ]
        ]
    ]
