feat: add calendar docs with inline markdown rendering

Add src/ui/calendar.md with full documentation for both calendar
namespaces (picker props, event grid, ticker, agenda, event data
format, date utilities, CSS classes).

Add a minimal markdown-to-hiccup renderer (ui.markdown) that handles
headings, fenced code blocks, tables, lists, inline code, and bold.
Styled with ui/markdown.css using theme tokens.

Each dev target renders the docs inline on the Calendar page:
- Hiccup: slurps the .md file at render time
- Replicant: embeds via compile-time macro (ui.macros/inline-file)
- Squint: fetches from /calendar.md served by Vite

Also fixes calendar event grid day cells to be square (aspect-ratio: 1
with overflow: hidden instead of min-height).
This commit is contained in:
Florian Schroedl
2026-03-29 09:59:31 +02:00
parent 25f868fb69
commit d6d205cb3b
11 changed files with 421 additions and 6 deletions

7
bb.edn
View File

@@ -20,7 +20,12 @@
(let [lr (slurp "dev/css-live-reload.js")]
(spit "dev/replicant/public/css-live-reload.js" lr)
(spit "dev/squint/public/css-live-reload.js" lr)
(println "Copied css-live-reload.js to dev targets")))}
(println "Copied css-live-reload.js to dev targets"))
(doseq [md (.listFiles (io/file "src/ui")
(reify java.io.FileFilter
(accept [_ f] (.endsWith (.getName f) ".md"))))]
(spit (str "dev/squint/public/" (.getName md)) (slurp md)))
(println "Copied component docs to dev/squint"))}
watch-theme
{:doc "Watch src/ui/*.css and tokens.edn, rebuild theme on changes"

View File

@@ -23,7 +23,8 @@
[ui.icon :as icon]
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]))
[ui.calendar-events :as cal-events]
[ui.markdown :as markdown]))
;; ── Query Params ────────────────────────────────────────────────────
@@ -412,6 +413,8 @@
(defn calendar-page []
[:div
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
(into [:div {:class "md-docs"}]
(markdown/markdown->hiccup (slurp "src/ui/calendar.md")))
(calendar-demo)])
(defn icons-page []

View File

@@ -20,7 +20,9 @@
[ui.icon :as icon]
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]))
[ui.calendar-events :as cal-events]
[ui.markdown :as markdown])
(:require-macros [ui.macros :refer [inline-file]]))
;; ── State ───────────────────────────────────────────────────────────
@@ -390,9 +392,13 @@
["Dev & Technical"
[:code :terminal :database :globe :shield :zap :book-open :map-pin]]])
(def calendar-docs-md (inline-file "../../src/ui/calendar.md"))
(defn calendar-page []
[:div
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
(into [:div {:class ["md-docs"]}]
(markdown/markdown->hiccup calendar-docs-md))
(calendar-demo)])
(defn icons-page []

View File

@@ -19,7 +19,8 @@
[ui.icon :as icon]
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]))
[ui.calendar-events :as cal-events]
[ui.markdown :as markdown]))
;; ── State ───────────────────────────────────────────────────────────
@@ -433,9 +434,23 @@
(into [:div {:style {"display" "grid" "grid-template-columns" "repeat(auto-fill, minmax(5rem, 1fr))" "gap" "var(--size-4)"}}]
(map icon-card icons)))))
(def !calendar-docs (atom nil))
(defn load-calendar-docs! []
(when-not @!calendar-docs
(-> (js/fetch "/calendar.md")
(.then (fn [r] (.text r)))
(.then (fn [text]
(reset! !calendar-docs text)
(render!))))))
(defn calendar-page []
(load-calendar-docs!)
[:div
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
(when-let [md @!calendar-docs]
(into [:div {:class "md-docs"}]
(markdown/markdown->hiccup md)))
(calendar-demo)])
(defn icons-page []

View File

@@ -1,4 +1,5 @@
(ns ui.calendar
"Month-grid date picker. See src/ui/calendar.md for full documentation."
(:require [clojure.string :as str]
[ui.button :as button]
[ui.icon :as icon]))

115
src/ui/calendar.md Normal file
View File

@@ -0,0 +1,115 @@
# Calendar
A month-grid date picker and event calendar inspired by [shadcn/radix Calendar](https://ui.shadcn.com/docs/components/radix/calendar) and the org-mode-agenda-cli.
Two namespaces: `ui.calendar` for the core date picker, `ui.calendar-events` for event grid, ticker strip, and agenda list.
## Date Picker
A compact month grid with navigation, today highlighting, and date selection.
```clojure
;; Minimal static calendar
(calendar/calendar {:year 2026 :month 3 :today-str "2026-03-29"})
;; Interactive with selection
(calendar/calendar {:year 2026 :month 3
:today-str "2026-03-29"
:selected-date "2026-03-15"
:on-select (fn [date-str] (println date-str))
:on-prev-month (fn [_] ...)
:on-next-month (fn [_] ...)})
```
### Props
| Prop | Type | Description |
|------------------|--------|-------------------------------------------|
| `:year` | int | Displayed year (e.g. 2026) |
| `:month` | int | Displayed month (112) |
| `:today-str` | string | Today's date as `"YYYY-MM-DD"` |
| `:selected-date` | string | Selected date as `"YYYY-MM-DD"` or nil |
| `:on-select` | fn | `(fn [date-str] ...)` — on day click |
| `:on-prev-month` | fn | Called on prev-month button click |
| `:on-next-month` | fn | Called on next-month button click |
| `:class` | string | Additional CSS classes |
| `:attrs` | map | Additional HTML attributes |
## Event Grid
A full month grid with colored event pills inside day cells.
```clojure
(cal-events/calendar-event-grid
{:year 2026 :month 3
:today-str "2026-03-29"
:events [{:title "Meeting" :date "2026-03-29" :time-start "10:00" :color :accent}]
:on-select (fn [date-str] ...)
:on-prev-month (fn [_] ...)
:on-next-month (fn [_] ...)
:on-event-click (fn [event-map] ...)
:max-visible 3})
```
## Day Ticker
Horizontal scrollable strip showing days with event dot indicators.
```clojure
(cal-events/ticker-strip
{:days [{:date "2026-03-29" :day-num 29 :day-label "Su"} ...]
:today-str "2026-03-29"
:selected "2026-03-29"
:events events
:on-select (fn [date-str] ...)})
```
## Agenda List
Vertical list of events grouped by day.
```clojure
(cal-events/agenda-list
{:days [{:date "2026-03-29" :label "Today"} ...]
:events events
:on-event-click (fn [event-map] ...)})
```
## Event Data Format
Events are plain maps:
```clojure
{:title "Team standup"
:date "2026-03-29" ;; YYYY-MM-DD
:time-start "09:00" ;; HH:MM or nil
:time-end "09:30" ;; HH:MM or nil
:color :accent ;; :accent :danger :success :warning or nil
:done? false}
```
## Event Colors
Colors map to the theme's semantic tokens and support dark mode automatically: `:accent`, `:danger`, `:success`, `:warning`. Pass `nil` for the default gray.
## Date Utilities
All date math is pure (no JS Date dependency) and works on all targets:
- `(days-in-month year month)` — days in a month (handles leap years)
- `(day-of-week year month day)` — 0=Mon..6=Sun
- `(first-day-of-week year month)` — weekday of the 1st
- `(calendar-days year month)` — full grid including prev/next month padding
- `(prev-month year month)` / `(next-month year month)` — returns `[year month]`
- `(date-str year month day)` — formats as `"YYYY-MM-DD"`
## CSS Classes
The calendar uses `cal-` prefixed classes. Key states on day cells:
- `.cal-day-today` — today's date
- `.cal-day-selected` — currently selected date
- `.cal-day-outside` — days from adjacent months
- `.cal-day-disabled` — unselectable days
Event pills use color classes: `.cal-event-accent`, `.cal-event-danger`, `.cal-event-success`, `.cal-event-warning`, `.cal-event-default`.

View File

@@ -1,4 +1,5 @@
(ns ui.calendar-events
"Event-aware calendar components. See src/ui/calendar.md for full documentation."
(:require [clojure.string :as str]
[ui.calendar :as cal]))

View File

@@ -15,7 +15,7 @@
.cal-event-day {
width: auto;
height: auto;
min-height: var(--size-16);
aspect-ratio: 1;
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
@@ -24,6 +24,7 @@
border-right: var(--border-0);
border-bottom: var(--border-0);
cursor: pointer;
overflow: hidden;
}
.cal-grid-events .cal-day:nth-child(7n) {
@@ -442,7 +443,6 @@
@media (max-width: 768px) {
.cal-event-day {
min-height: var(--size-12);
padding: var(--size-1);
}

7
src/ui/macros.clj Normal file
View File

@@ -0,0 +1,7 @@
(ns ui.macros)
(defmacro inline-file
"Read a file at compile time and return its contents as a string.
For use in ClojureScript (shadow-cljs) to embed file content."
[path]
(slurp path))

183
src/ui/markdown.cljc Normal file
View File

@@ -0,0 +1,183 @@
(ns ui.markdown
"Minimal markdown-to-hiccup renderer for dev documentation pages.
Supports: headings, fenced code blocks, tables, unordered lists,
inline code, bold, paragraphs."
(:require [clojure.string :as str]))
(defn- parse-inline
"Replace inline markdown (backticks, bold) with hiccup fragments.
Returns a vector of strings and hiccup nodes."
[text]
(if (str/blank? text)
[text]
(loop [remaining text
result []]
(if (str/blank? remaining)
result
(let [;; Find the next special marker
code-idx (str/index-of remaining "`")
bold-idx (str/index-of remaining "**")]
(cond
;; Inline code is nearest
(and code-idx (or (nil? bold-idx) (<= code-idx bold-idx)))
(let [before (subs remaining 0 code-idx)
after (subs remaining (inc code-idx))
end-idx (str/index-of after "`")]
(if end-idx
(let [code (subs after 0 end-idx)
rest (subs after (inc end-idx))]
(recur rest (cond-> result
(seq before) (conj before)
true (conj [:code {:class "md-inline-code"} code]))))
;; No closing backtick — treat as literal
(conj result remaining)))
;; Bold is nearest
(and bold-idx (or (nil? code-idx) (< bold-idx code-idx)))
(let [before (subs remaining 0 bold-idx)
after (subs remaining (+ bold-idx 2))
end-idx (str/index-of after "**")]
(if end-idx
(let [bold (subs after 0 end-idx)
rest (subs after (+ end-idx 2))]
(recur rest (cond-> result
(seq before) (conj before)
true (conj [:strong bold]))))
(conj result remaining)))
:else
(conj result remaining)))))))
(defn- heading-level [line]
(let [trimmed (str/triml line)]
(when (str/starts-with? trimmed "#")
(let [hashes (re-find #"^#{1,6}" trimmed)]
(when hashes
(let [n (count hashes)
text (str/trim (subs trimmed n))]
[n text]))))))
(defn- table-row? [line]
(and (str/starts-with? (str/trim line) "|")
(str/ends-with? (str/trim line) "|")))
(defn- separator-row? [line]
(and (table-row? line)
(every? #(re-matches #"\s*:?-+:?\s*" %)
(-> (str/trim line)
(subs 1)
(str/replace #"\|$" "")
(str/split #"\|")))))
(defn- parse-table-cells [line]
(->> (-> (str/trim line)
(subs 1)
(str/replace #"\|$" "")
(str/split #"\|"))
(mapv str/trim)))
(defn- list-item? [line]
(re-matches #"^\s*[-*]\s+.+" line))
(defn- list-item-text [line]
(str/replace line #"^\s*[-*]\s+" ""))
(defn markdown->hiccup
"Convert a markdown string to a hiccup data structure.
Returns a vector of hiccup elements."
[md-str]
(let [lines (str/split-lines md-str)]
(loop [i 0
result []]
(if (>= i (count lines))
result
(let [line (nth lines i)]
(cond
;; Blank line — skip
(str/blank? line)
(recur (inc i) result)
;; Fenced code block
(str/starts-with? (str/trim line) "```")
(let [lang (str/replace (str/trim (subs (str/trim line) 3)) #"\"" "")
;; Collect lines until closing ```
code-lines (loop [j (inc i)
acc []]
(if (>= j (count lines))
[acc j]
(let [l (nth lines j)]
(if (str/starts-with? (str/trim l) "```")
[acc (inc j)]
(recur (inc j) (conj acc l))))))]
(recur (second code-lines)
(conj result
[:pre {:class "md-code-block"}
[:code {:class (when (seq lang) (str "language-" lang))}
(str/join "\n" (first code-lines))]])))
;; Heading
(heading-level line)
(let [[n text] (heading-level line)
tag (keyword (str "h" n))]
(recur (inc i)
(conj result (into [tag {:class "md-heading"}] (parse-inline text)))))
;; Table
(table-row? line)
(let [header-cells (parse-table-cells line)
;; Skip separator row, collect data rows
body-start (if (and (< (inc i) (count lines))
(separator-row? (nth lines (inc i))))
(+ i 2)
(inc i))
body-rows (loop [j body-start
acc []]
(if (and (< j (count lines))
(table-row? (nth lines j)))
(recur (inc j) (conj acc (parse-table-cells (nth lines j))))
[acc j]))]
(recur (second body-rows)
(conj result
[:table {:class "md-table"}
[:thead
(into [:tr]
(map (fn [c] (into [:th] (parse-inline c)))
header-cells))]
(into [:tbody]
(map (fn [row]
(into [:tr]
(map (fn [c] (into [:td] (parse-inline c)))
row)))
(first body-rows)))])))
;; Unordered list — collect consecutive items
(list-item? line)
(let [items (loop [j i
acc []]
(if (and (< j (count lines))
(list-item? (nth lines j)))
(recur (inc j) (conj acc (list-item-text (nth lines j))))
[acc j]))]
(recur (second items)
(conj result
(into [:ul {:class "md-list"}]
(map (fn [txt] (into [:li] (parse-inline txt)))
(first items))))))
;; Paragraph (default)
:else
(let [;; Collect consecutive non-blank, non-special lines
para (loop [j i
acc []]
(if (and (< j (count lines))
(not (str/blank? (nth lines j)))
(not (str/starts-with? (str/trim (nth lines j)) "```"))
(not (str/starts-with? (str/trim (nth lines j)) "#"))
(not (table-row? (nth lines j)))
(not (list-item? (nth lines j))))
(recur (inc j) (conj acc (nth lines j)))
[acc j]))]
(recur (second para)
(conj result
(into [:p {:class "md-paragraph"}]
(parse-inline (str/join " " (first para)))))))))))))

79
src/ui/markdown.css Normal file
View File

@@ -0,0 +1,79 @@
/* ── Markdown Docs ────────────────────────────────────────────────── */
.md-docs {
display: flex;
flex-direction: column;
gap: var(--size-3);
font-size: var(--font-sm);
line-height: 1.6;
color: var(--fg-0);
}
.md-heading {
margin: 0;
color: var(--fg-0);
line-height: 1.3;
}
.md-docs h1.md-heading { font-size: var(--font-xl); margin-top: var(--size-2); }
.md-docs h2.md-heading { font-size: var(--font-lg); margin-top: var(--size-4); padding-bottom: var(--size-1); border-bottom: var(--border-0); }
.md-docs h3.md-heading { font-size: var(--font-md); margin-top: var(--size-2); }
.md-paragraph {
margin: 0;
}
.md-inline-code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.85em;
padding: var(--size-1);
background: var(--bg-2);
border-radius: var(--radius-sm);
}
.md-code-block {
margin: 0;
padding: var(--size-3);
background: var(--bg-2);
border-radius: var(--radius-md);
overflow-x: auto;
}
.md-code-block code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: var(--font-xs);
line-height: 1.5;
white-space: pre;
}
.md-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
}
.md-table th,
.md-table td {
padding: var(--size-2) var(--size-3);
text-align: left;
border-bottom: var(--border-0);
}
.md-table th {
font-weight: 600;
color: var(--fg-1);
background: var(--bg-2);
}
.md-table td {
color: var(--fg-0);
}
.md-list {
margin: 0;
padding-left: var(--size-5);
}
.md-list li {
margin-bottom: var(--size-1);
}