From d6d205cb3bdf5fcf6efdc07d08e80ab7db8ee4f0 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Sun, 29 Mar 2026 09:59:31 +0200 Subject: [PATCH] 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). --- bb.edn | 7 +- dev/hiccup/src/dev/hiccup.clj | 5 +- dev/replicant/src/dev/replicant.cljs | 8 +- dev/squint/src/dev/squint.cljs | 17 ++- src/ui/calendar.cljc | 1 + src/ui/calendar.md | 115 +++++++++++++++++ src/ui/calendar_events.cljc | 1 + src/ui/calendar_events.css | 4 +- src/ui/macros.clj | 7 + src/ui/markdown.cljc | 183 +++++++++++++++++++++++++++ src/ui/markdown.css | 79 ++++++++++++ 11 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 src/ui/calendar.md create mode 100644 src/ui/macros.clj create mode 100644 src/ui/markdown.cljc create mode 100644 src/ui/markdown.css diff --git a/bb.edn b/bb.edn index 3136d5a..dd7f09a 100644 --- a/bb.edn +++ b/bb.edn @@ -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" diff --git a/dev/hiccup/src/dev/hiccup.clj b/dev/hiccup/src/dev/hiccup.clj index 4a485bc..0bcbf54 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -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 [] diff --git a/dev/replicant/src/dev/replicant.cljs b/dev/replicant/src/dev/replicant.cljs index 47dfee3..c5ee9b6 100644 --- a/dev/replicant/src/dev/replicant.cljs +++ b/dev/replicant/src/dev/replicant.cljs @@ -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 [] diff --git a/dev/squint/src/dev/squint.cljs b/dev/squint/src/dev/squint.cljs index b31d9f7..d762f98 100644 --- a/dev/squint/src/dev/squint.cljs +++ b/dev/squint/src/dev/squint.cljs @@ -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 [] diff --git a/src/ui/calendar.cljc b/src/ui/calendar.cljc index fb81407..ecfd717 100644 --- a/src/ui/calendar.cljc +++ b/src/ui/calendar.cljc @@ -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])) diff --git a/src/ui/calendar.md b/src/ui/calendar.md new file mode 100644 index 0000000..be12908 --- /dev/null +++ b/src/ui/calendar.md @@ -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 (1–12) | +| `: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`. diff --git a/src/ui/calendar_events.cljc b/src/ui/calendar_events.cljc index 0bf4104..0591e53 100644 --- a/src/ui/calendar_events.cljc +++ b/src/ui/calendar_events.cljc @@ -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])) diff --git a/src/ui/calendar_events.css b/src/ui/calendar_events.css index 601fe4c..6dd0509 100644 --- a/src/ui/calendar_events.css +++ b/src/ui/calendar_events.css @@ -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); } diff --git a/src/ui/macros.clj b/src/ui/macros.clj new file mode 100644 index 0000000..d079b19 --- /dev/null +++ b/src/ui/macros.clj @@ -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)) diff --git a/src/ui/markdown.cljc b/src/ui/markdown.cljc new file mode 100644 index 0000000..f393644 --- /dev/null +++ b/src/ui/markdown.cljc @@ -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))))))))))))) diff --git a/src/ui/markdown.css b/src/ui/markdown.css new file mode 100644 index 0000000..6d45adc --- /dev/null +++ b/src/ui/markdown.css @@ -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); +}