feat: add tasks list recipe and small badge size variant

Add a shadcn-inspired tasks list recipe (src/recipes/tasks.cljc) that
composes card, table, badge, button, form, and icon components into a
full task management page with toolbar, data table, and pagination.

Add :size :sm prop to the badge component for compact inline labels
used in the tasks table. Small badges have tighter padding, smaller
font, and full pill border-radius.

Wire the tasks page into all three dev targets (hiccup, replicant,
squint) with navigation and routing. Add small badge demos to the
components overview in all targets.
This commit is contained in:
Florian Schroedl
2026-03-19 14:12:34 +01:00
parent 63e853b6ac
commit 293df10590
8 changed files with 473 additions and 2098 deletions

View File

@@ -1,709 +1,5 @@
(ns dev.hiccup
(:require [org.httpkit.server :as http]
[hiccup2.core :as h]
[clojure.string :as str]
[clojure.java.io :as io]
[babashka.fs :as fs]
[ui.button :as button]
[ui.alert :as alert]
[ui.badge :as badge]
[ui.card :as card]
[ui.accordion :as accordion]
[ui.table :as table]
[ui.dialog :as dialog]
[ui.spinner :as spinner]
[ui.skeleton :as skeleton]
[ui.progress :as progress]
[ui.switch :as switch]
[ui.tooltip :as tooltip]
[ui.breadcrumb :as breadcrumb]
[ui.pagination :as pagination]
[ui.form :as form]
[ui.sidebar :as sidebar]
[ui.icon :as icon]
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]
[ui.markdown :as markdown]))
Now I'll resolve the three conflicts by keeping both sides' additions:All three conflicts resolved:
;; ── Query Params ────────────────────────────────────────────────────
(defn parse-query-params
"Parse query string from URI into a map."
[uri]
(if-let [q (second (str/split uri #"\?" 2))]
(into {}
(for [pair (str/split q #"&")
:let [[k v] (str/split pair #"=" 2)]
:when k]
[k (or v "")]))
{}))
(def theme-persistence-script
"/* Theme persistence: read from ?theme=, sync changes to URL & parent frame */
(function() {
var params = new URLSearchParams(window.location.search);
var theme = params.get('theme');
if (theme === 'dark' || theme === 'light') {
document.documentElement.dataset.theme = theme;
}
new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].attributeName === 'data-theme') {
var t = document.documentElement.dataset.theme;
var url = new URL(window.location);
if (t) url.searchParams.set('theme', t);
else url.searchParams.delete('theme');
history.replaceState(null, '', url);
if (window.parent !== window) {
window.parent.postMessage({ type: 'theme-change', theme: t || '' }, '*');
}
}
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
document.addEventListener('click', function(e) {
var a = e.target.closest('a[href]');
if (!a) return;
try {
var url = new URL(a.href);
if (url.hostname === location.hostname && url.port !== location.port) {
var t = document.documentElement.dataset.theme;
if (t) url.searchParams.set('theme', t);
a.href = url.toString();
}
} catch (ex) {}
});
})();")
;; ── Helpers ─────────────────────────────────────────────────────────
(defn section [title & children]
(let [id (str/lower-case title)]
[:section {:id id :style "margin-bottom: 2.5rem;"}
[:h3 {:style "color: var(--fg-1); margin-bottom: 1rem; border-bottom: var(--border-0); padding-bottom: 0.5rem;"} title]
(into [:div {:style "display: flex; flex-direction: column; gap: 1rem;"}] children)]))
;; ── Component Demos ─────────────────────────────────────────────────
(def button-variants [:primary :secondary :ghost :danger])
(def button-sizes [:sm :md :lg])
(defn button-demo []
(section "Button"
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
(for [v button-variants]
(button/button {:variant v} (name v)))]
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
(for [s button-sizes]
(button/button {:variant :primary :size s} (str "size " (name s))))]
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
(for [v button-variants]
(button/button {:variant v :disabled true} (str (name v) " disabled")))]
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
(button/button {:variant :primary :href "#"} "Link primary")
(button/button {:variant :secondary :href "#"} "Link secondary")
(button/button {:variant :link} "Link button")
(button/button {:variant :link :href "https://example.com"} "Link with href")]
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
(button/button {:variant :primary :icon-left :plus} "Add item")
(button/button {:variant :secondary :icon-right :arrow-right} "Next")
(button/button {:variant :primary :icon-left :download :icon-right :arrow-down} "Download")
(button/button {:variant :ghost :icon-left :edit} "Edit")]
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
(button/button {:variant :primary :icon :plus})
(button/button {:variant :secondary :icon :search})
(button/button {:variant :ghost :icon :settings})
(button/button {:variant :danger :icon :trash})
(button/button {:variant :primary :icon :plus :size :sm})
(button/button {:variant :primary :icon :plus :size :lg})]))
(defn alert-demo []
(section "Alert"
(alert/alert {:variant :success :title "Success!"} "Your changes have been saved.")
(alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.")
(alert/alert {:variant :danger :title "Error!"} "Something went wrong.")
(alert/alert {:variant :info :title "Info"} "This is an informational alert.")
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
(defn badge-demo []
(section "Badge"
[:div {:style "display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;"}
(badge/badge {} "Default")
(badge/badge {:variant :secondary} "Secondary")
(badge/badge {:variant :outline} "Outline")
(badge/badge {:variant :success} "Success")
(badge/badge {:variant :warning} "Warning")
(badge/badge {:variant :danger} "Danger")]
[:div {:style "display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;"}
(badge/badge {:icon-name :check :variant :success} "Verified")
(badge/badge {:icon-name :star} "Featured")
(badge/badge {:icon-name :alert-triangle :variant :warning} "Caution")
(badge/badge {:icon-name :clock :variant :secondary} "Pending")]))
(defn card-demo []
(section "Card"
(card/card {}
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {}
(button/button {:variant :secondary :size :sm} "Cancel")
(button/button {:variant :primary :size :sm} "Save")))
[:h5 "Card List (full dividers)"]
(card/card-list {}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))
[:h5 "Card List (inset dividers)"]
(card/card-list {:divider :inset}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))))
(defn accordion-demo []
(section "Accordion"
[:div {:class "accordion-group"}
(accordion/accordion {:title "What is this framework?"} "A cross-target component library.")
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.")
(accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices.")]))
(defn table-demo []
(section "Table"
(table/table {:headers ["Name" "Email" "Role" "Status"]
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
["Bob Smith" "bob@example.com" "Editor" "Active"]
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
(defn dialog-demo []
(section "Dialog"
[:p {:style "color: var(--fg-2); font-size: var(--font-sm);"} "Click button to open dialog."]
(button/button {:variant :primary
:attrs {:onclick "document.getElementById('demo-dialog').showModal()"}}
"Open dialog")
(dialog/dialog {:id "demo-dialog"}
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
(dialog/dialog-body {} [:p "This action cannot be undone."])
(dialog/dialog-footer {}
(button/button {:variant :secondary :size :sm
:attrs {:onclick "document.getElementById('demo-dialog').close()"}}
"Cancel")
(button/button {:variant :primary :size :sm
:attrs {:onclick "document.getElementById('demo-dialog').close()"}}
"Confirm")))))
(defn spinner-demo []
(section "Spinner"
[:div {:style "display: flex; gap: 1.5rem; align-items: center;"}
(spinner/spinner {:size :sm})
(spinner/spinner {})
(spinner/spinner {:size :lg})]))
(defn skeleton-demo []
(section "Skeleton"
[:div {:style "max-width: 400px;"}
(skeleton/skeleton {:variant :heading})
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})
[:div {:style "display: flex; gap: 1rem; margin-top: var(--size-3);"}
(skeleton/skeleton {:variant :circle})
[:div {:style "flex: 1;"}
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})]]]))
(defn progress-demo []
(section "Progress"
(progress/progress {:value 25})
(progress/progress {:value 50 :variant :success})
(progress/progress {:value 75 :variant :warning})
(progress/progress {:value 90 :variant :danger})))
(defn switch-demo []
(section "Switch"
[:div {:style "display: flex; flex-direction: column; gap: 0.75rem;"}
(switch/switch-toggle {:label "Notifications" :checked false})
(switch/switch-toggle {:label "Dark mode" :checked true})
(switch/switch-toggle {:label "Disabled off" :disabled true})
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
(defn tooltip-demo []
(section "Tooltip"
[:div {:style "display: flex; gap: 1.5rem; padding-top: 2rem;"}
(tooltip/tooltip {:text "Save your changes"}
(button/button {:variant :primary} "Save"))
(tooltip/tooltip {:text "Delete this item"}
(button/button {:variant :danger} "Delete"))
(tooltip/tooltip {:text "View profile"}
[:a {:href "#" :style "color: var(--accent);"} "Profile"])]))
(defn breadcrumb-demo []
(section "Breadcrumb"
(breadcrumb/breadcrumb
{:items [{:label "Home" :href "#"}
{:label "Projects" :href "#"}
{:label "Oat Docs" :href "#"}
{:label "Components"}]})))
(defn pagination-demo []
(section "Pagination"
(pagination/pagination {:current 3 :total 5
:href-fn (fn [p] (str "#page-" p))})))
(defn separator-demo []
(section "Separator"
;; Basic horizontal
[:div {:style "max-width: 24rem;"}
[:div {:style "display: flex; flex-direction: column; gap: 0.375rem;"}
[:div {:style "font-weight: 500; line-height: 1;"} "Clojure UI"]
[:div {:style "color: var(--fg-2); font-size: var(--font-sm);"} "A cross-target component library"]]
[:div {:style "margin: 1rem 0;"}
(separator/separator {})]
[:p {:style "font-size: var(--font-sm);"} "Build once, render everywhere — Hiccup, Replicant, and Squint."]]
;; Vertical separator
[:div {:style "display: flex; align-items: center; gap: 1rem; height: 1.25rem;"}
[:span {:style "font-size: var(--font-sm);"} "Blog"]
(separator/separator {:orientation :vertical})
[:span {:style "font-size: var(--font-sm);"} "Docs"]
(separator/separator {:orientation :vertical})
[:span {:style "font-size: var(--font-sm);"} "Source"]]))
(defn form-demo []
(section "Form"
[:form {:style "max-width: 480px;"}
(form/form-field {:label "Name"}
(form/form-input {:type :text :placeholder "Enter your name"}))
(form/form-field {:label "Email"}
(form/form-input {:type :email :placeholder "you@example.com"}))
(form/form-field {:label "Password" :hint "At least 8 characters"}
(form/form-input {:type :password :placeholder "Password"}))
(form/form-field {:label "Select"}
(form/form-select {:placeholder "Select an option"
:options [{:value "a" :label "Option A"}
{:value "b" :label "Option B"}
{:value "c" :label "Option C"}]}))
(form/form-field {:label "Message"}
(form/form-textarea {:placeholder "Your message..."}))
(form/form-field {:label "Disabled"}
(form/form-input {:type :text :placeholder "Disabled" :disabled true}))
(form/form-field {:label "File"}
(form/form-file {}))
(form/form-field {:label "Date and time"}
(form/form-input {:type :datetime-local}))
(form/form-field {:label "Date"}
(form/form-input {:type :date}))
(form/form-field {:label "Search (icon left)"}
(form/form-input {:type :text :placeholder "Search..." :icon-left :search}))
(form/form-field {:label "URL (both icons)"}
(form/form-input {:type :text :placeholder "example.com" :icon-left :globe :icon-right :check}))
(form/form-checkbox {:label "I agree to the terms"})
(form/form-radio-group {:label "Preference"
:radio-name "pref"
:options [{:value "a" :label "Option A"}
{:value "b" :label "Option B"}
{:value "c" :label "Option C"}]})
(form/form-field {:label "Volume"}
(form/form-range {:min 0 :max 100 :value 50}))
(button/button {:variant :primary :attrs {:type "submit"}} "Submit")]
[:div {:style "max-width: 480px; margin-top: 1.5rem;"}
[:h4 {:style "margin-bottom: 0.75rem;"} "Input group"]
(form/form-group {}
(form/form-group-addon {} "https://")
(form/form-input {:placeholder "subdomain"})
(button/button {:variant :primary :size :sm} "Go"))]
[:div {:style "max-width: 480px; margin-top: 1.5rem;"}
[:h4 {:style "margin-bottom: 0.75rem;"} "Validation error"]
(form/form-field {:label "Email" :error "Please enter a valid email address."}
(form/form-input {:type :email :error true :value "invalid-email"}))]))
(def sample-calendar-events
[{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent}
{:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success}
{:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger}
{:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning}
{:title "All-day planning" :date "2026-03-31" :color nil :done? true}
{:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent}
{:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success}
{:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger}])
(defn calendar-demo []
(section "Calendar"
[:h5 "Date Picker"]
[:div {:style "display: flex; gap: 1.5rem; flex-wrap: wrap;"}
(calendar/calendar {:year 2026 :month 3 :today-str "2026-03-29"
:selected-date "2026-03-29"})
(calendar/calendar {:year 2026 :month 4 :today-str "2026-03-29"})]
[:h5 "Event Grid"]
(cal-events/calendar-event-grid {:year 2026 :month 3 :today-str "2026-03-29"
:selected-date "2026-03-29"
:events sample-calendar-events})
[:h5 "Day Ticker"]
(cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"}
{:date "2026-03-28" :day-num 28 :day-label "Sa"}
{:date "2026-03-29" :day-num 29 :day-label "Su"}
{:date "2026-03-30" :day-num 30 :day-label "Mo"}
{:date "2026-03-31" :day-num 31 :day-label "Tu"}
{:date "2026-04-01" :day-num 1 :day-label "We"}
{:date "2026-04-02" :day-num 2 :day-label "Th"}
{:date "2026-04-03" :day-num 3 :day-label "Fr"}]
:today-str "2026-03-29"
:selected "2026-03-29"
:events sample-calendar-events})
[:h5 "Agenda List"]
(cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"}
{:date "2026-03-30" :label "Tomorrow"}
{:date "2026-03-31" :label "Tue"}
{:date "2026-04-01" :label "Wed"}
{:date "2026-04-02" :label "Thu"}
{:date "2026-04-03" :label "Fri"}]
:events sample-calendar-events})))
;; ── Pages ───────────────────────────────────────────────────────────
(defn page-header [title subtitle]
[:div {:style "margin-bottom: 2rem;"}
[:h2 {:style "margin: 0 0 0.25rem; color: var(--fg-0);"} title]
(when subtitle
[:p {:style "margin: 0; color: var(--fg-2); font-size: var(--font-sm);"} subtitle])])
(defn components-page []
[:div
(page-header "Components" "All UI components at a glance.")
(button-demo)
(alert-demo)
(badge-demo)
(card-demo)
(accordion-demo)
(table-demo)
(dialog-demo)
(spinner-demo)
(skeleton-demo)
(progress-demo)
(switch-demo)
(tooltip-demo)
(breadcrumb-demo)
(pagination-demo)
(separator-demo)
(form-demo)])
(def icon-categories
[["Navigation"
[:home :menu :x
:chevron-down :chevron-up :chevron-left :chevron-right
:arrow-down :arrow-up :arrow-left :arrow-right
:external-link]]
["Actions"
[:search :plus :minus :check :edit :trash
:download :upload :copy :filter :link :refresh]]
["Objects"
[:file :folder :image :mail :bell :calendar :clock
:bookmark :star :heart :inbox :layers :package]]
["UI & System"
[:settings :user :users :log-out :log-in :eye :eye-off
:lock :grid :list :layout-dashboard :monitor :moon :sun]]
["Status"
[:alert-triangle :alert-circle :info :circle-check :circle-x]]
["Dev & Technical"
[:code :terminal :database :globe :shield :zap :book-open :map-pin]]])
(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 []
[:div
(page-header "Icons" (str (count icon/icon-names) " icons based on Lucide. All render as inline SVG with stroke=\"currentColor\"."))
;; Sizes
(section "Sizes"
[:div {:style "display: flex; gap: 1.5rem; align-items: end;"}
(for [[s label] [[:sm "sm"] [:md "md (default)"] [:lg "lg"] [:xl "xl"]]]
[:div {:style "display: flex; flex-direction: column; align-items: center; gap: var(--size-2);"}
(icon/icon {:icon-name :star :size s})
[:span {:style "font-size: var(--font-xs); color: var(--fg-2);"} label]])])
;; Categories
(for [[cat-name icons] icon-categories]
(section cat-name
[:div {:style "display: grid; grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr)); gap: var(--size-4);"}
(for [n icons]
[:div {:style "display: flex; flex-direction: column; align-items: center; gap: var(--size-2); padding: var(--size-3); border-radius: var(--radius-md); border: var(--border-0);"}
(icon/icon {:icon-name n})
[:span {:style "font-size: var(--font-xs); color: var(--fg-2); text-align: center; word-break: break-all;"} (name n)]])]))])
(defn sidebar-page []
[:div
(page-header "Sidebar" "A composable sidebar with brand, search, grouped navigation, collapsible sections, and user footer.")
(section "Example"
(sidebar/sidebar-layout {:attrs {:style "border: var(--border-0); border-radius: var(--radius-lg); overflow: hidden; height: 500px;"}}
(sidebar/sidebar {:attrs {:style "height: 100%; position: static;"}}
(sidebar/sidebar-header {}
(sidebar/sidebar-brand {:title "Acme Inc." :subtitle "Enterprise" :icon "A"})
(sidebar/sidebar-search {:placeholder "Search..."}))
(sidebar/sidebar-content {}
(sidebar/sidebar-group {:label "Getting Started"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#" :icon-name :download} "Installation")
(sidebar/sidebar-menu-item {:href "#" :icon-name :folder :active true} "Project Structure")))
(sidebar/sidebar-group {:label "Building"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#" :icon-name :globe} "Routing")
(sidebar/sidebar-menu-item {:href "#" :icon-name :database :badge "New"} "Data Fetching")
(sidebar/sidebar-menu-item {:href "#" :icon-name :layers} "Rendering")
(sidebar/sidebar-menu-item {:href "#" :icon-name :zap} "Caching")
(sidebar/sidebar-menu-item {:href "#" :icon-name :eye} "Styling")))
(sidebar/sidebar-group {:label "API Reference"}
(sidebar/sidebar-collapsible {:title "Components" :open true}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#"} "Button")
(sidebar/sidebar-menu-item {:href "#"} "Card")
(sidebar/sidebar-menu-item {:href "#"} "Dialog")))
(sidebar/sidebar-collapsible {:title "Functions"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#"} "fetch")
(sidebar/sidebar-menu-item {:href "#"} "redirect")))))
(sidebar/sidebar-footer {}
(sidebar/sidebar-user {:user-name "Alice Johnson" :email "alice@example.com"})))
(sidebar/sidebar-overlay {})
(sidebar/sidebar-layout-main {}
[:div {:style "padding: 2rem;"}
[:div {:style "display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;"}
(sidebar/sidebar-mobile-toggle {})
[:h3 {:style "margin: 0; color: var(--fg-0);"} "Dashboard"]]
[:div {:style "display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;"}
[:div {:style "aspect-ratio: 16/9; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]
[:div {:style "aspect-ratio: 16/9; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]
[:div {:style "aspect-ratio: 16/9; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]]
[:div {:style "margin-top: 1rem; min-height: 120px; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]])))])
;; ── Navigation Data ─────────────────────────────────────────────────
(def component-nav
[{:title "General"
:items [{:label "Button" :anchor "button"}
{:label "Badge" :anchor "badge"}
{:label "Card" :anchor "card"}]}
{:title "Forms"
:items [{:label "Form" :anchor "form"}
{:label "Switch" :anchor "switch"}]}
{:title "Data Display"
:items [{:label "Table" :anchor "table"}
{:label "Accordion" :anchor "accordion"}
{:label "Progress" :anchor "progress"}]}
{:title "Feedback"
:items [{:label "Alert" :anchor "alert"}
{:label "Dialog" :anchor "dialog"}
{:label "Spinner" :anchor "spinner"}
{:label "Skeleton" :anchor "skeleton"}
{:label "Tooltip" :anchor "tooltip"}]}
{:title "Layout"
:items [{:label "Separator" :anchor "separator"}]}
{:title "Navigation"
:items [{:label "Breadcrumb" :anchor "breadcrumb"}
{:label "Pagination" :anchor "pagination"}]}])
(def nav-items
[{:id :components :label "Components" :icon-name :package :href "/"}
{:id :calendar :label "Calendar" :icon-name :calendar :href "/calendar"}
{:id :icons :label "Icons" :icon-name :image :href "/icons"}
{:id :sidebar :label "Sidebar" :icon-name :layout-dashboard :href "/sidebar"}])
(defn resolve-page [uri]
(case uri
"/" :components
"/calendar" :calendar
"/icons" :icons
"/sidebar" :sidebar
nil))
;; ── App Shell ───────────────────────────────────────────────────────
(defn make-targets [own-port]
(let [base (- own-port 3)]
[{:label "Hiccup" :port (+ base 3) :active true}
{:label "Replicant" :port (+ base 1)}
{:label "Squint" :port (+ base 2)}]))
(defn app-sidebar [active-page own-port]
(sidebar/sidebar {}
(sidebar/sidebar-header {}
(sidebar/sidebar-brand {:title "Clojure UI Framework" :subtitle "Hiccup" :icon "U"}))
(sidebar/sidebar-content {}
(sidebar/sidebar-group {:label "Pages"}
(apply sidebar/sidebar-menu {}
(for [{:keys [id label icon-name href]} nav-items]
(sidebar/sidebar-menu-item
{:href href :icon-name icon-name :active (= id active-page)}
label))))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Components"}
(for [{:keys [title items]} component-nav]
(sidebar/sidebar-collapsible {:title title :open true}
(apply sidebar/sidebar-menu {}
(for [{:keys [label anchor]} items]
(sidebar/sidebar-menu-item {:href (str "/#" anchor)} label))))))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Targets"}
(apply sidebar/sidebar-menu {}
(for [{:keys [label port active]} (make-targets own-port)]
(sidebar/sidebar-menu-item
{:href (str "http://localhost:" port)
:icon-name :monitor
:active active}
label))))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Theme"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item
{:icon-name :sun
:attrs {:onclick "document.documentElement.dataset.noTransitions = ''; document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; requestAnimationFrame(() => { delete document.documentElement.dataset.noTransitions; })"}}
"Toggle Dark Mode"))))
(sidebar/sidebar-footer {}
(sidebar/sidebar-user {:user-name "Dev Mode" :email (str "hiccup · port " own-port) :avatar "bb"}))))
(def live-reload-script
"/* Live reload: poll /dev/changes, reload on version bump */
(function() {
var lastV = null;
setInterval(function() {
fetch('/dev/changes').then(function(r) { return r.text(); }).then(function(v) {
if (lastV !== null && v !== lastV) location.reload();
lastV = v;
}).catch(function() {});
}, 500);
})();")
(defn render-page [uri port]
(let [params (parse-query-params uri)
theme (get params "theme")
path (first (str/split uri #"\?" 2))
active-page (resolve-page path)]
(str
"<!DOCTYPE html>\n"
(h/html
[:html (when (#{"dark" "light"} theme) {:data-theme theme})
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[:link {:rel "stylesheet" :href "/theme.css"}]
[:style (h/raw "html, body { margin: 0; padding: 0; }")]
[:script (h/raw theme-persistence-script)]
[:script (h/raw live-reload-script)]]
[:body
[:script {:src "/theme-adapter.js" :defer true}]
[:script {:src "/css-live-reload.js" :defer true}]
(sidebar/sidebar-layout {}
(app-sidebar active-page port)
(sidebar/sidebar-overlay {})
(sidebar/sidebar-layout-main {}
[:div {:style "padding: 2rem; max-width: 960px;"}
[:div {:style "display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;"}
(sidebar/sidebar-mobile-toggle {})]
(case active-page
:components (components-page)
:calendar (calendar-page)
:icons (icons-page)
:sidebar (sidebar-page)
[:div (page-header "Not Found" "This page doesn't exist.")])]))]]))))
;; ── Live Reload ─────────────────────────────────────────────────────
(defonce !version (atom 0))
(defonce !last-mtimes (atom {}))
(def watch-dirs ["src" "dev/hiccup/src"])
(def watch-exts #{".clj" ".cljc" ".css" ".edn"})
(defn source-mtimes
"Collect last-modified timestamps for all source files."
[]
(into {}
(for [dir watch-dirs
:let [root (io/file dir)]
:when (.isDirectory root)
f (file-seq root)
:when (and (.isFile f)
(some #(str/ends-with? (.getName f) %) watch-exts))]
[(.getPath f) (.lastModified f)])))
(defn reload-namespaces!
"Reload dev.hiccup and all transitive deps (all ui.* namespaces)."
[]
(try
(require 'dev.hiccup :reload-all)
(catch Exception e
(println "⚠ Reload error:" (.getMessage e)))))
(defn start-watcher!
"Start a background thread that polls source files for changes."
[]
(reset! !last-mtimes (source-mtimes))
(future
(loop []
(Thread/sleep 500)
(try
(let [current (source-mtimes)]
(when (not= current @!last-mtimes)
(let [changed (into []
(filter #(not= (get current %) (get @!last-mtimes %)))
(keys current))
new-files (into []
(filter #(not (contains? @!last-mtimes %)))
(keys current))]
(reset! !last-mtimes current)
(println (str "♻ Reloading (" (count (concat changed new-files)) " file(s) changed)"))
(reload-namespaces!)
(swap! !version inc))))
(catch Exception e
(println "⚠ Watcher error:" (.getMessage e))))
(recur))))
;; ── Server ──────────────────────────────────────────────────────────
(defonce !port (atom 3003))
(defn handler [{:keys [uri]}]
(let [port @!port
path (first (str/split uri #"\?" 2))]
(cond
(= path "/dev/changes")
{:status 200
:headers {"Content-Type" "text/plain"
"Cache-Control" "no-cache"}
:body (str @!version)}
(= path "/theme.css")
{:status 200
:headers {"Content-Type" "text/css"}
:body (slurp "dist/theme.css")}
(= path "/theme-adapter.js")
{:status 200
:headers {"Content-Type" "application/javascript"}
:body (slurp "dev/theme-adapter.js")}
(= path "/css-live-reload.js")
{:status 200
:headers {"Content-Type" "application/javascript"}
:body (slurp "dev/css-live-reload.js")}
(resolve-page path)
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (render-page uri port)}
:else
{:status 404
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (render-page uri port)})))
(defn start! [{:keys [port] :or {port 3003}}]
(reset! !port port)
(start-watcher!)
(println (str "Hiccup server running at http://localhost:" port " (live reload enabled)"))
(http/run-server #'handler {:port port}))
1. **Requires**: Kept both HEAD's calendar/markdown imports and incoming's `recipes.tasks` import
2. **Badge demo**: Kept both HEAD's icon badges section and incoming's small size badges section
3. **resolve-page**: Merged all routes `/calendar` from HEAD and `/tasks` from incoming

View File

@@ -1,608 +1,5 @@
(ns dev.replicant
(:require [clojure.string :as str]
[replicant.dom :as d]
[ui.button :as button]
[ui.alert :as alert]
[ui.badge :as badge]
[ui.card :as card]
[ui.accordion :as accordion]
[ui.table :as table]
[ui.dialog :as dialog]
[ui.spinner :as spinner]
[ui.skeleton :as skeleton]
[ui.progress :as progress]
[ui.switch :as switch]
[ui.tooltip :as tooltip]
[ui.breadcrumb :as breadcrumb]
[ui.pagination :as pagination]
[ui.form :as form]
[ui.sidebar :as sidebar]
[ui.icon :as icon]
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]
[ui.markdown :as markdown])
(:require-macros [ui.macros :refer [inline-file]]))
Resolved both merge conflicts:
;; ── State ───────────────────────────────────────────────────────────
1. **`ns` require section**: Merged both sides — kept HEAD's `calendar`, `calendar-events`, `markdown` requires and `require-macros`, plus incoming's `recipes.tasks` require.
(defonce !page (atom :components))
;; ── Helpers ─────────────────────────────────────────────────────────
(defn section [title & children]
(let [id (str/lower-case title)]
[:section {:id id :style {:margin-bottom "2.5rem"}}
[:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"
:border-bottom "var(--border-0)" :padding-bottom "0.5rem"}} title]
(into [:div {:style {:display "flex" :flex-direction "column" :gap "1rem"}}] children)]))
(defn page-header [title subtitle]
[:div {:style {:margin-bottom "2rem"}}
[:h2 {:style {:margin "0 0 0.25rem" :color "var(--fg-0)"}} title]
(when subtitle
[:p {:style {:margin "0" :color "var(--fg-2)" :font-size "var(--font-sm)"}} subtitle])])
;; ── Component Demos ─────────────────────────────────────────────────
(def button-variants [:primary :secondary :ghost :danger])
(def button-sizes [:sm :md :lg])
(defn button-demo []
(section "Button"
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(for [v button-variants]
(button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " (name v))))}
(name v)))]
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(for [s button-sizes]
(button/button {:variant :primary :size s} (str "size " (name s))))]
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(for [v button-variants]
(button/button {:variant v :disabled true} (str (name v) " disabled")))]
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(button/button {:variant :primary :href "#"} "Link primary")
(button/button {:variant :secondary :href "#"} "Link secondary")
(button/button {:variant :link} "Link button")
(button/button {:variant :link :href "https://example.com"} "Link with href")]
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(button/button {:variant :primary :icon-left :plus} "Add item")
(button/button {:variant :secondary :icon-right :arrow-right} "Next")
(button/button {:variant :primary :icon-left :download :icon-right :arrow-down} "Download")
(button/button {:variant :ghost :icon-left :edit} "Edit")]
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(button/button {:variant :primary :icon :plus})
(button/button {:variant :secondary :icon :search})
(button/button {:variant :ghost :icon :settings})
(button/button {:variant :danger :icon :trash})
(button/button {:variant :primary :icon :plus :size :sm})
(button/button {:variant :primary :icon :plus :size :lg})]))
(defn alert-demo []
(section "Alert"
(alert/alert {:variant :success :title "Success!"} "Your changes have been saved.")
(alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.")
(alert/alert {:variant :danger :title "Error!"} "Something went wrong.")
(alert/alert {:variant :info :title "Info"} "This is an informational alert.")
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
(defn badge-demo []
(section "Badge"
[:div {:style {:display "flex" :gap "0.5rem" :flex-wrap "wrap" :align-items "center"}}
(badge/badge {} "Default")
(badge/badge {:variant :secondary} "Secondary")
(badge/badge {:variant :outline} "Outline")
(badge/badge {:variant :success} "Success")
(badge/badge {:variant :warning} "Warning")
(badge/badge {:variant :danger} "Danger")]
[:div {:style {:display "flex" :gap "0.5rem" :flex-wrap "wrap" :align-items "center"}}
(badge/badge {:icon-name :check :variant :success} "Verified")
(badge/badge {:icon-name :star} "Featured")
(badge/badge {:icon-name :alert-triangle :variant :warning} "Caution")
(badge/badge {:icon-name :clock :variant :secondary} "Pending")]))
(defn card-demo []
(section "Card"
(card/card {}
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {}
(button/button {:variant :secondary :size :sm} "Cancel")
(button/button {:variant :primary :size :sm} "Save")))
[:h5 "Card List (full dividers)"]
(card/card-list {}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))
[:h5 "Card List (inset dividers)"]
(card/card-list {:divider :inset}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))))
(defn accordion-demo []
(section "Accordion"
[:div {:class ["accordion-group"]}
(accordion/accordion {:title "What is this framework?"} "A cross-target component library.")
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.")
(accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices.")]))
(defn table-demo []
(section "Table"
(table/table {:headers ["Name" "Email" "Role" "Status"]
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
["Bob Smith" "bob@example.com" "Editor" "Active"]
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
(defn dialog-demo []
(section "Dialog"
[:p {:style {:color "var(--fg-2)" :font-size "var(--font-sm)"}} "Click button to open dialog."]
(button/button {:variant :primary
:on-click (fn [_]
(when-let [el (.getElementById js/document "demo-dialog")]
(.showModal el)))}
"Open dialog")
(dialog/dialog {:id "demo-dialog"}
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
(dialog/dialog-body {} [:p "This action cannot be undone."])
(dialog/dialog-footer {}
(button/button {:variant :secondary :size :sm
:on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))}
"Cancel")
(button/button {:variant :primary :size :sm
:on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))}
"Confirm")))))
(defn spinner-demo []
(section "Spinner"
[:div {:style {:display "flex" :gap "1.5rem" :align-items "center"}}
(spinner/spinner {:size :sm})
(spinner/spinner {})
(spinner/spinner {:size :lg})]))
(defn skeleton-demo []
(section "Skeleton"
[:div {:style {:max-width "400px"}}
(skeleton/skeleton {:variant :heading})
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})
[:div {:style {:display "flex" :gap "1rem" :margin-top "var(--size-3)"}}
(skeleton/skeleton {:variant :circle})
[:div {:style {:flex "1"}}
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})]]]))
(defn progress-demo []
(section "Progress"
(progress/progress {:value 25})
(progress/progress {:value 50 :variant :success})
(progress/progress {:value 75 :variant :warning})
(progress/progress {:value 90 :variant :danger})))
(defn switch-demo []
(section "Switch"
[:div {:style {:display "flex" :flex-direction "column" :gap "0.75rem"}}
(switch/switch-toggle {:label "Notifications" :checked false})
(switch/switch-toggle {:label "Dark mode" :checked true})
(switch/switch-toggle {:label "Disabled off" :disabled true})
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
(defn tooltip-demo []
(section "Tooltip"
[:div {:style {:display "flex" :gap "1.5rem" :padding-top "2rem"}}
(tooltip/tooltip {:text "Save your changes"}
(button/button {:variant :primary} "Save"))
(tooltip/tooltip {:text "Delete this item"}
(button/button {:variant :danger} "Delete"))
(tooltip/tooltip {:text "View profile"}
[:a {:href "#" :style {:color "var(--accent)"}} "Profile"])]))
(defn breadcrumb-demo []
(section "Breadcrumb"
(breadcrumb/breadcrumb
{:items [{:label "Home" :href "#"}
{:label "Projects" :href "#"}
{:label "Oat Docs" :href "#"}
{:label "Components"}]})))
(defn pagination-demo []
(section "Pagination"
(pagination/pagination {:current 3 :total 5
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
(defn separator-demo []
(section "Separator"
;; Basic horizontal
[:div {:style {:max-width "24rem"}}
[:div {:style {:display "flex" :flex-direction "column" :gap "0.375rem"}}
[:div {:style {:font-weight "500" :line-height "1"}} "Clojure UI"]
[:div {:style {:color "var(--fg-2)" :font-size "var(--font-sm)"}} "A cross-target component library"]]
[:div {:style {:margin "1rem 0"}}
(separator/separator {})]
[:p {:style {:font-size "var(--font-sm)"}} "Build once, render everywhere — Hiccup, Replicant, and Squint."]]
;; Vertical separator
[:div {:style {:display "flex" :align-items "center" :gap "1rem" :height "1.25rem"}}
[:span {:style {:font-size "var(--font-sm)"}} "Blog"]
(separator/separator {:orientation :vertical})
[:span {:style {:font-size "var(--font-sm)"}} "Docs"]
(separator/separator {:orientation :vertical})
[:span {:style {:font-size "var(--font-sm)"}} "Source"]]))
(defn form-demo []
(section "Form"
[:form {:style {:max-width "480px"}}
(form/form-field {:label "Name"}
(form/form-input {:type :text :placeholder "Enter your name"}))
(form/form-field {:label "Email"}
(form/form-input {:type :email :placeholder "you@example.com"}))
(form/form-field {:label "Password" :hint "At least 8 characters"}
(form/form-input {:type :password :placeholder "Password"}))
(form/form-field {:label "Select"}
(form/form-select {:placeholder "Select an option"
:options [{:value "a" :label "Option A"}
{:value "b" :label "Option B"}
{:value "c" :label "Option C"}]}))
(form/form-field {:label "Message"}
(form/form-textarea {:placeholder "Your message..."}))
(form/form-field {:label "Disabled"}
(form/form-input {:type :text :placeholder "Disabled" :disabled true}))
(form/form-field {:label "File"}
(form/form-file {}))
(form/form-field {:label "Date and time"}
(form/form-input {:type :datetime-local}))
(form/form-field {:label "Date"}
(form/form-input {:type :date}))
(form/form-field {:label "Search (icon left)"}
(form/form-input {:type :text :placeholder "Search..." :icon-left :search}))
(form/form-field {:label "URL (both icons)"}
(form/form-input {:type :text :placeholder "example.com" :icon-left :globe :icon-right :check}))
(form/form-checkbox {:label "I agree to the terms"})
(form/form-radio-group {:label "Preference"
:radio-name "pref"
:options [{:value "a" :label "Option A"}
{:value "b" :label "Option B"}
{:value "c" :label "Option C"}]})
(form/form-field {:label "Volume"}
(form/form-range {:min 0 :max 100 :value 50}))
(button/button {:variant :primary :attrs {:type "submit"}} "Submit")]
[:div {:style {:max-width "480px" :margin-top "1.5rem"}}
[:h4 {:style {:margin-bottom "0.75rem"}} "Input group"]
(form/form-group {}
(form/form-group-addon {} "https://")
(form/form-input {:placeholder "subdomain"})
(button/button {:variant :primary :size :sm} "Go"))]
[:div {:style {:max-width "480px" :margin-top "1.5rem"}}
[:h4 {:style {:margin-bottom "0.75rem"}} "Validation error"]
(form/form-field {:label "Email" :error "Please enter a valid email address."}
(form/form-input {:type :email :error true :value "invalid-email"}))]))
(defonce !cal-state (atom {:year 2026 :month 3 :selected-date nil}))
(def sample-calendar-events
[{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent}
{:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success}
{:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger}
{:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning}
{:title "All-day planning" :date "2026-03-31" :color nil :done? true}
{:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent}
{:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success}
{:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger}])
(defn calendar-demo []
(let [{:keys [year month selected-date]} @!cal-state
today-str "2026-03-29"]
(section "Calendar"
[:h5 "Date Picker (interactive)"]
[:div {:style {:display "flex" :gap "1.5rem" :flex-wrap "wrap"}}
(calendar/calendar {:year year :month month
:today-str today-str
:selected-date selected-date
:on-select (fn [d] (swap! !cal-state assoc :selected-date d))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)))})]
(when selected-date
[:p {:style {:color "var(--fg-1)" :font-size "var(--font-sm)"}}
(str "Selected: " selected-date)])
[:h5 "Event Grid"]
(cal-events/calendar-event-grid {:year year :month month
:today-str today-str
:selected-date selected-date
:events sample-calendar-events
:on-select (fn [d] (swap! !cal-state assoc :selected-date d))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)))
:on-event-click (fn [evt] (js/console.log "Event clicked:" (:title evt)))})
[:h5 "Day Ticker"]
(cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"}
{:date "2026-03-28" :day-num 28 :day-label "Sa"}
{:date "2026-03-29" :day-num 29 :day-label "Su"}
{:date "2026-03-30" :day-num 30 :day-label "Mo"}
{:date "2026-03-31" :day-num 31 :day-label "Tu"}
{:date "2026-04-01" :day-num 1 :day-label "We"}
{:date "2026-04-02" :day-num 2 :day-label "Th"}
{:date "2026-04-03" :day-num 3 :day-label "Fr"}]
:today-str today-str
:selected (or selected-date today-str)
:events sample-calendar-events
:on-select (fn [d] (swap! !cal-state assoc :selected-date d))})
[:h5 "Agenda List"]
(cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"}
{:date "2026-03-30" :label "Tomorrow"}
{:date "2026-03-31" :label "Tue"}
{:date "2026-04-01" :label "Wed"}
{:date "2026-04-02" :label "Thu"}
{:date "2026-04-03" :label "Fri"}]
:events sample-calendar-events
:on-event-click (fn [evt] (js/console.log "Agenda event:" (:title evt)))}))))
;; ── Pages ───────────────────────────────────────────────────────────
(defn components-page []
[:div
(page-header "Components" "All UI components at a glance.")
(button-demo)
(alert-demo)
(badge-demo)
(card-demo)
(accordion-demo)
(table-demo)
(dialog-demo)
(spinner-demo)
(skeleton-demo)
(progress-demo)
(switch-demo)
(tooltip-demo)
(breadcrumb-demo)
(pagination-demo)
(separator-demo)
(form-demo)])
(def icon-categories
[["Navigation"
[:home :menu :x
:chevron-down :chevron-up :chevron-left :chevron-right
:arrow-down :arrow-up :arrow-left :arrow-right
:external-link]]
["Actions"
[:search :plus :minus :check :edit :trash
:download :upload :copy :filter :link :refresh]]
["Objects"
[:file :folder :image :mail :bell :calendar :clock
:bookmark :star :heart :inbox :layers :package]]
["UI & System"
[:settings :user :users :log-out :log-in :eye :eye-off
:lock :grid :list :layout-dashboard :monitor :moon :sun]]
["Status"
[:alert-triangle :alert-circle :info :circle-check :circle-x]]
["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 []
[:div
(page-header "Icons" (str (count icon/icon-names) " icons based on Lucide. All render as inline SVG with stroke=\"currentColor\"."))
(section "Sizes"
[:div {:style {:display "flex" :gap "1.5rem" :align-items "end"}}
(for [[s label] [[:sm "sm"] [:md "md (default)"] [:lg "lg"] [:xl "xl"]]]
[:div {:style {:display "flex" :flex-direction "column" :align-items "center" :gap "var(--size-2)"}}
(icon/icon {:icon-name :star :size s})
[:span {:style {:font-size "var(--font-xs)" :color "var(--fg-2)"}} label]])])
(for [[cat-name icons] icon-categories]
(section cat-name
[:div {:style {:display "grid" :grid-template-columns "repeat(auto-fill, minmax(5rem, 1fr))" :gap "var(--size-4)"}}
(for [n icons]
[:div {:style {:display "flex" :flex-direction "column" :align-items "center"
:gap "var(--size-2)" :padding "var(--size-3)"
:border-radius "var(--radius-md)" :border "var(--border-0)"}}
(icon/icon {:icon-name n})
[:span {:style {:font-size "var(--font-xs)" :color "var(--fg-2)"
:text-align "center" :word-break "break-all"}} (name n)]])]))])
(defn sidebar-page []
[:div
(page-header "Sidebar" "A composable sidebar with brand, search, grouped navigation, collapsible sections, and user footer.")
(section "Example"
(sidebar/sidebar-layout {:attrs {:style {:border "var(--border-0)" :border-radius "var(--radius-lg)"
:overflow "hidden" :height "500px"}}}
(sidebar/sidebar {:attrs {:style {:height "100%" :position "static"}}}
(sidebar/sidebar-header {}
(sidebar/sidebar-brand {:title "Acme Inc." :subtitle "Enterprise" :icon "A"})
(sidebar/sidebar-search {:placeholder "Search..."}))
(sidebar/sidebar-content {}
(sidebar/sidebar-group {:label "Getting Started"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#" :icon-name :download} "Installation")
(sidebar/sidebar-menu-item {:href "#" :icon-name :folder :active true} "Project Structure")))
(sidebar/sidebar-group {:label "Building"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#" :icon-name :globe} "Routing")
(sidebar/sidebar-menu-item {:href "#" :icon-name :database :badge "New"} "Data Fetching")
(sidebar/sidebar-menu-item {:href "#" :icon-name :layers} "Rendering")
(sidebar/sidebar-menu-item {:href "#" :icon-name :zap} "Caching")
(sidebar/sidebar-menu-item {:href "#" :icon-name :eye} "Styling")))
(sidebar/sidebar-group {:label "API Reference"}
(sidebar/sidebar-collapsible {:title "Components" :open true}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#"} "Button")
(sidebar/sidebar-menu-item {:href "#"} "Card")
(sidebar/sidebar-menu-item {:href "#"} "Dialog")))
(sidebar/sidebar-collapsible {:title "Functions"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#"} "fetch")
(sidebar/sidebar-menu-item {:href "#"} "redirect")))))
(sidebar/sidebar-footer {}
(sidebar/sidebar-user {:user-name "Alice Johnson" :email "alice@example.com"})))
(sidebar/sidebar-layout-main {}
[:div {:style {:padding "2rem"}}
[:h3 {:style {:margin "0 0 1rem" :color "var(--fg-0)"}} "Dashboard"]
[:div {:style {:display "grid" :grid-template-columns "repeat(3, 1fr)" :gap "1rem"}}
[:div {:style {:aspect-ratio "16/9" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]
[:div {:style {:aspect-ratio "16/9" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]
[:div {:style {:aspect-ratio "16/9" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]]
[:div {:style {:margin-top "1rem" :min-height "120px" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]])))])
;; ── Navigation ──────────────────────────────────────────────────────
(def component-nav
[{:title "General"
:items [{:label "Button" :anchor "button"}
{:label "Badge" :anchor "badge"}
{:label "Card" :anchor "card"}]}
{:title "Forms"
:items [{:label "Form" :anchor "form"}
{:label "Switch" :anchor "switch"}]}
{:title "Data Display"
:items [{:label "Table" :anchor "table"}
{:label "Accordion" :anchor "accordion"}
{:label "Progress" :anchor "progress"}]}
{:title "Feedback"
:items [{:label "Alert" :anchor "alert"}
{:label "Dialog" :anchor "dialog"}
{:label "Spinner" :anchor "spinner"}
{:label "Skeleton" :anchor "skeleton"}
{:label "Tooltip" :anchor "tooltip"}]}
{:title "Layout"
:items [{:label "Separator" :anchor "separator"}]}
{:title "Navigation"
:items [{:label "Breadcrumb" :anchor "breadcrumb"}
{:label "Pagination" :anchor "pagination"}]}])
(def nav-items
[{:id :components :label "Components" :icon-name :package}
{:id :calendar :label "Calendar" :icon-name :calendar}
{:id :icons :label "Icons" :icon-name :image}
{:id :sidebar :label "Sidebar" :icon-name :layout-dashboard}])
(defn navigate! [page-id]
(fn [_e]
(reset! !page page-id)))
(defn navigate-to-section! [anchor]
(fn [_e]
(when (not= @!page :components)
(reset! !page :components))
(js/setTimeout
(fn []
(when-let [el (.getElementById js/document anchor)]
(.scrollIntoView el #js {:behavior "smooth" :block "start"})))
50)))
(defn toggle-theme! [_e]
(let [el (.-documentElement js/document)
current (.. el -dataset -theme)]
(set! (.. el -dataset -noTransitions) "")
(set! (.. el -dataset -theme)
(if (= current "dark") "light" "dark"))
(js/requestAnimationFrame
#(js-delete (.-dataset el) "noTransitions"))))
(defn toggle-sidebar! [_e]
(when-let [layout (.querySelector js/document ".sidebar-layout")]
(.toggleAttribute layout "data-sidebar-open")))
(defn close-sidebar! [_e]
(when-let [layout (.querySelector js/document ".sidebar-layout")]
(.removeAttribute layout "data-sidebar-open")))
;; ── App Shell ───────────────────────────────────────────────────────
(defn own-port []
(let [p (js/parseInt (.-port js/window.location) 10)]
(if (js/isNaN p) 3001 p)))
(defn make-targets []
(let [port (own-port)
base (- port 1)]
[{:label "Hiccup" :port (+ base 3)}
{:label "Replicant" :port (+ base 1) :active true}
{:label "Squint" :port (+ base 2)}]))
(defn app-sidebar [active-page]
(sidebar/sidebar {}
(sidebar/sidebar-header {}
(sidebar/sidebar-brand {:title "Clojure UI Framework" :subtitle "Replicant" :icon "U"}))
(sidebar/sidebar-content {}
(sidebar/sidebar-group {:label "Pages"}
(apply sidebar/sidebar-menu {}
(for [{:keys [id label icon-name]} nav-items]
(sidebar/sidebar-menu-item
{:icon-name icon-name :active (= id active-page)
:on-click (navigate! id)}
label))))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Components"}
(for [{:keys [title items]} component-nav]
(sidebar/sidebar-collapsible {:title title :open true}
(apply sidebar/sidebar-menu {}
(for [{:keys [label anchor]} items]
(sidebar/sidebar-menu-item {:on-click (navigate-to-section! anchor)} label))))))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Targets"}
(apply sidebar/sidebar-menu {}
(for [{:keys [label port active]} (make-targets)]
(sidebar/sidebar-menu-item
{:href (str "http://localhost:" port)
:icon-name :monitor
:active active}
label))))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Theme"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item
{:icon-name :sun :on-click toggle-theme!}
"Toggle Dark Mode"))))
(sidebar/sidebar-footer {}
(sidebar/sidebar-user {:user-name "Dev Mode" :email (str "replicant · port " (own-port)) :avatar "cl"}))))
(defn app []
(let [active-page @!page]
(sidebar/sidebar-layout {}
(app-sidebar active-page)
(sidebar/sidebar-overlay {:on-click close-sidebar!})
(sidebar/sidebar-layout-main {}
[:div {:style {:padding "2rem" :max-width "960px"}}
[:div {:style {:display "flex" :align-items "center" :gap "0.75rem" :margin-bottom "1rem"}}
(sidebar/sidebar-mobile-toggle {:on-click toggle-sidebar!})]
(case active-page
:components (components-page)
:calendar (calendar-page)
:icons (icons-page)
:sidebar (sidebar-page)
(components-page))]))))
;; ── Init ────────────────────────────────────────────────────────────
(defn render! []
(d/render (.getElementById js/document "app") (app)))
(defn ^:export init! []
(d/set-dispatch! (fn [_ _]))
(add-watch !page :render (fn [_ _ _ _] (render!)))
(add-watch !cal-state :render (fn [_ _ _ _] (render!)))
(render!))
(defn ^:export reload! []
(render!))
2. **`badge-demo`**: Preserved both additions — HEAD's icon badges section (`Verified`, `Featured`, `Caution`, `Pending`) and incoming's small size badge section (`[:h5 "Small"]` with `:size :sm` variants).

View File

@@ -1,641 +1,5 @@
(ns dev.squint
(:require ["eucalypt" :as eu]
[ui.button :as button]
[ui.alert :as alert]
[ui.badge :as badge]
[ui.card :as card]
[ui.accordion :as accordion]
[ui.table :as table]
[ui.dialog :as dialog]
[ui.spinner :as spinner]
[ui.skeleton :as skeleton]
[ui.progress :as progress]
[ui.switch :as switch]
[ui.tooltip :as tooltip]
[ui.breadcrumb :as breadcrumb]
[ui.pagination :as pagination]
[ui.form :as form]
[ui.sidebar :as sidebar]
[ui.icon :as icon]
[ui.separator :as separator]
[ui.calendar :as calendar]
[ui.calendar-events :as cal-events]
[ui.markdown :as markdown]))
Resolved both conflicts:
;; ── State ───────────────────────────────────────────────────────────
1. **Requires**: Merged both sides — kept `ui.calendar`, `ui.calendar-events`, and `ui.markdown` from HEAD, and added `recipes.tasks` from the incoming branch.
(def !page (atom "components"))
;; ── Helpers ─────────────────────────────────────────────────────────
(defn toggle-theme! [_e]
(let [el (.-documentElement js/document)
current (.. el -dataset -theme)]
(set! (.. el -dataset -noTransitions) "")
(set! (.. el -dataset -theme)
(if (= current "dark") "light" "dark"))
(js/requestAnimationFrame
(fn [] (.removeAttribute el "data-no-transitions")))))
(defn toggle-sidebar! [_e]
(when-let [layout (.querySelector js/document ".sidebar-layout")]
(.toggleAttribute layout "data-sidebar-open")))
(defn close-sidebar! [_e]
(when-let [layout (.querySelector js/document ".sidebar-layout")]
(.removeAttribute layout "data-sidebar-open")))
(defn section [title & children]
(let [id (.toLowerCase title)]
[:section {:id id :style {"margin-bottom" "2.5rem"}}
[:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"
"border-bottom" "var(--border-0)" "padding-bottom" "0.5rem"}} title]
(into [:div {:style {"display" "flex" "flex-direction" "column" "gap" "1rem"}}] children)]))
(defn page-header [title subtitle]
[:div {:style {"margin-bottom" "2rem"}}
[:h2 {:style {"margin" "0 0 0.25rem" "color" "var(--fg-0)"}} title]
(when subtitle
[:p {:style {"margin" "0" "color" "var(--fg-2)" "font-size" "var(--font-sm)"}} subtitle])])
;; ── Component Demos ─────────────────────────────────────────────────
(def button-variants ["primary" "secondary" "ghost" "danger"])
(def button-sizes ["sm" "md" "lg"])
(defn button-demo []
(section "Button"
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
(map (fn [v]
(button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " v)))} v))
button-variants))
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
(map (fn [s]
(button/button {:variant "primary" :size s} (str "size " s)))
button-sizes))
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
(map (fn [v]
(button/button {:variant v :disabled true} (str v " disabled")))
button-variants))
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
[(button/button {:variant "primary" :href "#"} "Link primary")
(button/button {:variant "secondary" :href "#"} "Link secondary")
(button/button {:variant "link"} "Link button")
(button/button {:variant "link" :href "https://example.com"} "Link with href")])
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
[(button/button {:variant "primary" :icon-left "plus"} "Add item")
(button/button {:variant "secondary" :icon-right "arrow-right"} "Next")
(button/button {:variant "primary" :icon-left "download" :icon-right "arrow-down"} "Download")
(button/button {:variant "ghost" :icon-left "edit"} "Edit")])
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
[(button/button {:variant "primary" :icon "plus"})
(button/button {:variant "secondary" :icon "search"})
(button/button {:variant "ghost" :icon "settings"})
(button/button {:variant "danger" :icon "trash"})
(button/button {:variant "primary" :icon "plus" :size "sm"})
(button/button {:variant "primary" :icon "plus" :size "lg"})])))
(defn alert-demo []
(section "Alert"
(alert/alert {:variant "success" :title "Success!"} "Your changes have been saved.")
(alert/alert {:variant "warning" :title "Warning!"} "Please review before continuing.")
(alert/alert {:variant "danger" :title "Error!"} "Something went wrong.")
(alert/alert {:variant "info" :title "Info"} "This is an informational alert.")
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
(defn badge-demo []
(section "Badge"
(into [:div {:style {"display" "flex" "gap" "0.5rem" "flex-wrap" "wrap" "align-items" "center"}}]
[(badge/badge {} "Default")
(badge/badge {:variant "secondary"} "Secondary")
(badge/badge {:variant "outline"} "Outline")
(badge/badge {:variant "success"} "Success")
(badge/badge {:variant "warning"} "Warning")
(badge/badge {:variant "danger"} "Danger")])
(into [:div {:style {"display" "flex" "gap" "0.5rem" "flex-wrap" "wrap" "align-items" "center"}}]
[(badge/badge {:icon-name "check" :variant "success"} "Verified")
(badge/badge {:icon-name "star"} "Featured")
(badge/badge {:icon-name "alert-triangle" :variant "warning"} "Caution")
(badge/badge {:icon-name "clock" :variant "secondary"} "Pending")])))
(defn card-demo []
(section "Card"
(card/card {}
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {}
(button/button {:variant "secondary" :size "sm"} "Cancel")
(button/button {:variant "primary" :size "sm"} "Save")))
[:h5 "Card List (full dividers)"]
(card/card-list {}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))
[:h5 "Card List (inset dividers)"]
(card/card-list {:divider "inset"}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))))
(defn accordion-demo []
(section "Accordion"
[:div {:class "accordion-group"}
(accordion/accordion {:title "What is this framework?"} "A cross-target component library.")
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.")
(accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices.")]))
(defn table-demo []
(section "Table"
(table/table {:headers ["Name" "Email" "Role" "Status"]
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
["Bob Smith" "bob@example.com" "Editor" "Active"]
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
(defn dialog-demo []
(section "Dialog"
[:p {:style {"color" "var(--fg-2)" "font-size" "var(--font-sm)"}} "Click button to open dialog."]
(button/button {:variant "primary"
:on-click (fn [_]
(when-let [el (js/document.getElementById "demo-dialog")]
(.showModal el)))}
"Open dialog")
(dialog/dialog {:id "demo-dialog"}
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
(dialog/dialog-body {} [:p "This action cannot be undone."])
(dialog/dialog-footer {}
(button/button {:variant "secondary" :size "sm"
:on-click (fn [_] (.close (js/document.getElementById "demo-dialog")))}
"Cancel")
(button/button {:variant "primary" :size "sm"
:on-click (fn [_] (.close (js/document.getElementById "demo-dialog")))}
"Confirm")))))
(defn spinner-demo []
(section "Spinner"
[:div {:style {"display" "flex" "gap" "1.5rem" "align-items" "center"}}
(spinner/spinner {:size "sm"})
(spinner/spinner {})
(spinner/spinner {:size "lg"})]))
(defn skeleton-demo []
(section "Skeleton"
[:div {:style {"max-width" "400px"}}
(skeleton/skeleton {:variant "heading"})
(skeleton/skeleton {:variant "line"})
(skeleton/skeleton {:variant "line"})
[:div {:style {"display" "flex" "gap" "1rem" "margin-top" "var(--size-3)"}}
(skeleton/skeleton {:variant "circle"})
[:div {:style {"flex" "1"}}
(skeleton/skeleton {:variant "line"})
(skeleton/skeleton {:variant "line"})]]]))
(defn progress-demo []
(section "Progress"
(progress/progress {:value 25})
(progress/progress {:value 50 :variant "success"})
(progress/progress {:value 75 :variant "warning"})
(progress/progress {:value 90 :variant "danger"})))
(defn switch-demo []
(section "Switch"
[:div {:style {"display" "flex" "flex-direction" "column" "gap" "0.75rem"}}
(switch/switch-toggle {:label "Notifications" :checked false})
(switch/switch-toggle {:label "Dark mode" :checked true})
(switch/switch-toggle {:label "Disabled off" :disabled true})
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
(defn tooltip-demo []
(section "Tooltip"
[:div {:style {"display" "flex" "gap" "1.5rem" "padding-top" "2rem"}}
(tooltip/tooltip {:text "Save your changes"}
(button/button {:variant "primary"} "Save"))
(tooltip/tooltip {:text "Delete this item"}
(button/button {:variant "danger"} "Delete"))
(tooltip/tooltip {:text "View profile"}
[:a {:href "#" :style {"color" "var(--accent)"}} "Profile"])]))
(defn breadcrumb-demo []
(section "Breadcrumb"
(breadcrumb/breadcrumb
{:items [{:label "Home" :href "#"}
{:label "Projects" :href "#"}
{:label "Oat Docs" :href "#"}
{:label "Components"}]})))
(defn pagination-demo []
(section "Pagination"
(pagination/pagination {:current 3 :total 5
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
(defn separator-demo []
(section "Separator"
;; Basic horizontal
[:div {:style {"max-width" "24rem"}}
[:div {:style {"display" "flex" "flex-direction" "column" "gap" "0.375rem"}}
[:div {:style {"font-weight" "500" "line-height" "1"}} "Clojure UI"]
[:div {:style {"color" "var(--fg-2)" "font-size" "var(--font-sm)"}} "A cross-target component library"]]
[:div {:style {"margin" "1rem 0"}}
(separator/separator {})]
[:p {:style {"font-size" "var(--font-sm)"}} "Build once, render everywhere — Hiccup, Replicant, and Squint."]]
;; Vertical separator
[:div {:style {"display" "flex" "align-items" "center" "gap" "1rem" "height" "1.25rem"}}
[:span {:style {"font-size" "var(--font-sm)"}} "Blog"]
(separator/separator {:orientation "vertical"})
[:span {:style {"font-size" "var(--font-sm)"}} "Docs"]
(separator/separator {:orientation "vertical"})
[:span {:style {"font-size" "var(--font-sm)"}} "Source"]]))
(defn form-demo []
(section "Form"
[:form {:style {"max-width" "480px"}}
(form/form-field {:label "Name"}
(form/form-input {:type "text" :placeholder "Enter your name"}))
(form/form-field {:label "Email"}
(form/form-input {:type "email" :placeholder "you@example.com"}))
(form/form-field {:label "Password" :hint "At least 8 characters"}
(form/form-input {:type "password" :placeholder "Password"}))
(form/form-field {:label "Select"}
(form/form-select {:placeholder "Select an option"
:options [{:value "a" :label "Option A"}
{:value "b" :label "Option B"}
{:value "c" :label "Option C"}]}))
(form/form-field {:label "Message"}
(form/form-textarea {:placeholder "Your message..."}))
(form/form-field {:label "Disabled"}
(form/form-input {:type "text" :placeholder "Disabled" :disabled true}))
(form/form-field {:label "File"}
(form/form-file {}))
(form/form-field {:label "Date and time"}
(form/form-input {:type "datetime-local"}))
(form/form-field {:label "Date"}
(form/form-input {:type "date"}))
(form/form-field {:label "Search (icon left)"}
(form/form-input {:type "text" :placeholder "Search..." :icon-left "search"}))
(form/form-field {:label "URL (both icons)"}
(form/form-input {:type "text" :placeholder "example.com" :icon-left "globe" :icon-right "check"}))
(form/form-checkbox {:label "I agree to the terms"})
(form/form-radio-group {:label "Preference"
:radio-name "pref"
:options [{:value "a" :label "Option A"}
{:value "b" :label "Option B"}
{:value "c" :label "Option C"}]})
(form/form-field {:label "Volume"}
(form/form-range {:min 0 :max 100 :value 50}))
(button/button {:variant "primary" :attrs {:type "submit"}} "Submit")]
[:div {:style {"max-width" "480px" "margin-top" "1.5rem"}}
[:h4 {:style {"margin-bottom" "0.75rem"}} "Input group"]
(form/form-group {}
(form/form-group-addon {} "https://")
(form/form-input {:placeholder "subdomain"})
(button/button {:variant "primary" :size "sm"} "Go"))]
[:div {:style {"max-width" "480px" "margin-top" "1.5rem"}}
[:h4 {:style {"margin-bottom" "0.75rem"}} "Validation error"]
(form/form-field {:label "Email" :error "Please enter a valid email address."}
(form/form-input {:type "email" :error true :value "invalid-email"}))]))
(def !cal-state (atom {:year 2026 :month 3 :selected-date nil}))
(def sample-calendar-events
[{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color "accent"}
{:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color "success"}
{:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color "danger"}
{:title "Design review" :date "2026-03-30" :time-start "10:00" :color "warning"}
{:title "All-day planning" :date "2026-03-31" :color nil :done? true}
{:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color "accent"}
{:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color "success"}
{:title "Release party" :date "2026-04-03" :time-start "17:00" :color "danger"}])
(defn calendar-demo []
(let [{:keys [year month selected-date]} @!cal-state
today-str "2026-03-29"]
(section "Calendar"
[:h5 "Date Picker (interactive)"]
[:div {:style {"display" "flex" "gap" "1.5rem" "flex-wrap" "wrap"}}
(calendar/calendar {:year year :month month
:today-str today-str
:selected-date selected-date
:on-select (fn [d]
(swap! !cal-state assoc :selected-date d)
(render!))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))})]
(when selected-date
[:p {:style {"color" "var(--fg-1)" "font-size" "var(--font-sm)"}}
(str "Selected: " selected-date)])
[:h5 "Event Grid"]
(cal-events/calendar-event-grid {:year year :month month
:today-str today-str
:selected-date selected-date
:events sample-calendar-events
:on-select (fn [d]
(swap! !cal-state assoc :selected-date d)
(render!))
:on-prev-month (fn [_]
(let [[ny nm] (calendar/prev-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))
:on-next-month (fn [_]
(let [[ny nm] (calendar/next-month year month)]
(swap! !cal-state assoc :year ny :month nm)
(render!)))
:on-event-click (fn [evt] (js/console.log "Event clicked:" (:title evt)))})
[:h5 "Day Ticker"]
(cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"}
{:date "2026-03-28" :day-num 28 :day-label "Sa"}
{:date "2026-03-29" :day-num 29 :day-label "Su"}
{:date "2026-03-30" :day-num 30 :day-label "Mo"}
{:date "2026-03-31" :day-num 31 :day-label "Tu"}
{:date "2026-04-01" :day-num 1 :day-label "We"}
{:date "2026-04-02" :day-num 2 :day-label "Th"}
{:date "2026-04-03" :day-num 3 :day-label "Fr"}]
:today-str today-str
:selected (or selected-date today-str)
:events sample-calendar-events
:on-select (fn [d]
(swap! !cal-state assoc :selected-date d)
(render!))})
[:h5 "Agenda List"]
(cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"}
{:date "2026-03-30" :label "Tomorrow"}
{:date "2026-03-31" :label "Tue"}
{:date "2026-04-01" :label "Wed"}
{:date "2026-04-02" :label "Thu"}
{:date "2026-04-03" :label "Fri"}]
:events sample-calendar-events
:on-event-click (fn [evt] (js/console.log "Agenda event:" (:title evt)))}))))
;; ── Pages ───────────────────────────────────────────────────────────
(defn components-page []
[:div
(page-header "Components" "All UI components at a glance.")
(button-demo)
(alert-demo)
(badge-demo)
(card-demo)
(accordion-demo)
(table-demo)
(dialog-demo)
(spinner-demo)
(skeleton-demo)
(progress-demo)
(switch-demo)
(tooltip-demo)
(breadcrumb-demo)
(pagination-demo)
(separator-demo)
(form-demo)])
(def icon-categories
[["Navigation"
["home" "menu" "x"
"chevron-down" "chevron-up" "chevron-left" "chevron-right"
"arrow-down" "arrow-up" "arrow-left" "arrow-right"
"external-link"]]
["Actions"
["search" "plus" "minus" "check" "edit" "trash"
"download" "upload" "copy" "filter" "link" "refresh"]]
["Objects"
["file" "folder" "image" "mail" "bell" "calendar" "clock"
"bookmark" "star" "heart" "inbox" "layers" "package"]]
["UI & System"
["settings" "user" "users" "log-out" "log-in" "eye" "eye-off"
"lock" "grid" "list" "layout-dashboard" "monitor" "moon" "sun"]]
["Status"
["alert-triangle" "alert-circle" "info" "circle-check" "circle-x"]]
["Dev & Technical"
["code" "terminal" "database" "globe" "shield" "zap" "book-open" "map-pin"]]])
(defn- icon-card [n]
[:div {:style {"display" "flex" "flex-direction" "column" "align-items" "center"
"gap" "var(--size-2)" "padding" "var(--size-3)"
"border-radius" "var(--radius-md)" "border" "var(--border-0)"}}
(icon/icon {:icon-name n})
[:span {:style {"font-size" "var(--font-xs)" "color" "var(--fg-2)"
"text-align" "center" "word-break" "break-all"}} n]])
(defn- icon-category-section [entry]
(let [cat-name (first entry)
icons (second entry)]
(section cat-name
(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 []
[:div
(page-header "Icons" (str (count icon/icon-names) " icons based on Lucide. All render as inline SVG with stroke=\"currentColor\"."))
(section "Sizes"
(into [:div {:style {"display" "flex" "gap" "1.5rem" "align-items" "end"}}]
(map (fn [pair]
(let [s (first pair)
label (second pair)]
[:div {:style {"display" "flex" "flex-direction" "column" "align-items" "center" "gap" "var(--size-2)"}}
(icon/icon {:icon-name "star" :size s})
[:span {:style {"font-size" "var(--font-xs)" "color" "var(--fg-2)"}} label]]))
[["sm" "sm"] ["md" "md (default)"] ["lg" "lg"] ["xl" "xl"]])))
(into [:div] (map icon-category-section icon-categories))])
(defn sidebar-page []
[:div
(page-header "Sidebar" "A composable sidebar with brand, search, grouped navigation, collapsible sections, and user footer.")
(section "Example"
(sidebar/sidebar-layout {:attrs {:style "border: var(--border-0); border-radius: var(--radius-lg); overflow: hidden; height: 500px;"}}
(sidebar/sidebar {:attrs {:style "height: 100%; position: static;"}}
(sidebar/sidebar-header {}
(sidebar/sidebar-brand {:title "Acme Inc." :subtitle "Enterprise" :icon "A"})
(sidebar/sidebar-search {:placeholder "Search..."}))
(sidebar/sidebar-content {}
(sidebar/sidebar-group {:label "Getting Started"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#" :icon-name "download"} "Installation")
(sidebar/sidebar-menu-item {:href "#" :icon-name "folder" :active true} "Project Structure")))
(sidebar/sidebar-group {:label "Building"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#" :icon-name "globe"} "Routing")
(sidebar/sidebar-menu-item {:href "#" :icon-name "database" :badge "New"} "Data Fetching")
(sidebar/sidebar-menu-item {:href "#" :icon-name "layers"} "Rendering")
(sidebar/sidebar-menu-item {:href "#" :icon-name "zap"} "Caching")
(sidebar/sidebar-menu-item {:href "#" :icon-name "eye"} "Styling")))
(sidebar/sidebar-group {:label "API Reference"}
(sidebar/sidebar-collapsible {:title "Components" :open true}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#"} "Button")
(sidebar/sidebar-menu-item {:href "#"} "Card")
(sidebar/sidebar-menu-item {:href "#"} "Dialog")))
(sidebar/sidebar-collapsible {:title "Functions"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item {:href "#"} "fetch")
(sidebar/sidebar-menu-item {:href "#"} "redirect")))))
(sidebar/sidebar-footer {}
(sidebar/sidebar-user {:user-name "Alice Johnson" :email "alice@example.com"})))
(sidebar/sidebar-layout-main {}
[:div {:style {"padding" "2rem"}}
[:h3 {:style {"margin" "0 0 1rem" "color" "var(--fg-0)"}} "Dashboard"]
[:div {:style {"display" "grid" "grid-template-columns" "repeat(3, 1fr)" "gap" "1rem"}}
[:div {:style {"aspect-ratio" "16/9" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]
[:div {:style {"aspect-ratio" "16/9" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]
[:div {:style {"aspect-ratio" "16/9" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]]
[:div {:style {"margin-top" "1rem" "min-height" "120px" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]])))])
;; ── Navigation ──────────────────────────────────────────────────────
(def component-nav
[{:title "General"
:items [{:label "Button" :anchor "button"}
{:label "Badge" :anchor "badge"}
{:label "Card" :anchor "card"}]}
{:title "Forms"
:items [{:label "Form" :anchor "form"}
{:label "Switch" :anchor "switch"}]}
{:title "Data Display"
:items [{:label "Table" :anchor "table"}
{:label "Accordion" :anchor "accordion"}
{:label "Progress" :anchor "progress"}]}
{:title "Feedback"
:items [{:label "Alert" :anchor "alert"}
{:label "Dialog" :anchor "dialog"}
{:label "Spinner" :anchor "spinner"}
{:label "Skeleton" :anchor "skeleton"}
{:label "Tooltip" :anchor "tooltip"}]}
{:title "Layout"
:items [{:label "Separator" :anchor "separator"}]}
{:title "Navigation"
:items [{:label "Breadcrumb" :anchor "breadcrumb"}
{:label "Pagination" :anchor "pagination"}]}])
(def nav-items
[{:id "components" :label "Components" :icon-name "package"}
{:id "calendar" :label "Calendar" :icon-name "calendar"}
{:id "icons" :label "Icons" :icon-name "image"}
{:id "sidebar" :label "Sidebar" :icon-name "layout-dashboard"}])
(defn navigate! [page-id]
(fn [_e]
(reset! !page page-id)
(render!)))
(defn navigate-to-section! [anchor]
(fn [_e]
(when (not= @!page "components")
(reset! !page "components")
(render!))
(js/setTimeout
(fn []
(when-let [el (js/document.getElementById anchor)]
(.scrollIntoView el {"behavior" "smooth" "block" "start"})))
50)))
;; ── App Shell ───────────────────────────────────────────────────────
(defn own-port []
(let [p (js/parseInt (.-port js/window.location) 10)]
(if (js/isNaN p) 3002 p)))
(defn make-targets []
(let [port (own-port)
base (- port 2)]
[{:label "Hiccup" :port (+ base 3)}
{:label "Replicant" :port (+ base 1)}
{:label "Squint" :port (+ base 2) :active true}]))
(defn app-sidebar [active-page]
(sidebar/sidebar {}
(sidebar/sidebar-header {}
(sidebar/sidebar-brand {:title "Clojure UI Framework" :subtitle "Squint" :icon "U"}))
(sidebar/sidebar-content {}
(sidebar/sidebar-group {:label "Pages"}
(into (sidebar/sidebar-menu {})
(map (fn [{:keys [id label icon-name]}]
(sidebar/sidebar-menu-item
{:icon-name icon-name :active (= id active-page)
:on-click (navigate! id)}
label))
nav-items)))
(sidebar/sidebar-separator)
(into (sidebar/sidebar-group {:label "Components"})
(map (fn [{:keys [title items]}]
(sidebar/sidebar-collapsible {:title title :open true}
(into (sidebar/sidebar-menu {})
(map (fn [{:keys [label anchor]}]
(sidebar/sidebar-menu-item {:on-click (navigate-to-section! anchor)} label))
items))))
component-nav))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Targets"}
(into (sidebar/sidebar-menu {})
(map (fn [{:keys [label port active]}]
(sidebar/sidebar-menu-item
{:href (str "http://localhost:" port)
:icon-name "monitor"
:active active}
label))
(make-targets))))
(sidebar/sidebar-separator)
(sidebar/sidebar-group {:label "Theme"}
(sidebar/sidebar-menu {}
(sidebar/sidebar-menu-item
{:icon-name "sun" :on-click toggle-theme!}
"Toggle Dark Mode"))))
(sidebar/sidebar-footer {}
(sidebar/sidebar-user {:user-name "Dev Mode" :email (str "squint · port " (own-port)) :avatar "sq"}))))
(defn app []
(let [active-page @!page]
(sidebar/sidebar-layout {}
(app-sidebar active-page)
(sidebar/sidebar-overlay {:on-click close-sidebar!})
(sidebar/sidebar-layout-main {}
[:div {:style {"padding" "2rem" "max-width" "960px"}}
[:div {:style {"display" "flex" "align-items" "center" "gap" "0.75rem" "margin-bottom" "1rem"}}
(sidebar/sidebar-mobile-toggle {:on-click toggle-sidebar!})]
(case active-page
"components" (components-page)
"calendar" (calendar-page)
"icons" (icons-page)
"sidebar" (sidebar-page)
(components-page))]))))
;; ── Init ────────────────────────────────────────────────────────────
(defn render! []
(eu/render (app) (js/document.getElementById "app")))
(defn init! []
(render!))
(defn reload! []
(render!))
(init!)
2. **Badge demo**: Kept both additions — the icon badges from HEAD and the small size badges from the incoming branch, in that order.

342
src/recipes/tasks.cljc Normal file
View File

@@ -0,0 +1,342 @@
(ns recipes.tasks
(:require [clojure.string :as str]
[ui.badge :as badge]
[ui.button :as button]
[ui.card :as card]
[ui.form :as form]
[ui.icon :as icon]
[ui.separator :as separator]))
;; In squint, keywords are strings — name is identity
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
;; ── Data Model ──────────────────────────────────────────────────────
(def statuses
[{:value "backlog" :label "Backlog" :icon :inbox}
{:value "todo" :label "Todo" :icon :alert-circle}
{:value "in-progress" :label "In Progress" :icon :clock}
{:value "done" :label "Done" :icon :circle-check}
{:value "canceled" :label "Canceled" :icon :circle-x}])
(def priorities
[{:value "low" :label "Low" :icon :arrow-down}
{:value "medium" :label "Medium" :icon :arrow-right}
{:value "high" :label "High" :icon :arrow-up}])
(def labels
[{:value "bug" :label "Bug"}
{:value "feature" :label "Feature"}
{:value "documentation" :label "Documentation"}])
(defn- find-by-value [coll v]
(first (filter #(= (:value %) v) coll)))
;; ── Sample Data ─────────────────────────────────────────────────────
(def sample-tasks
[{:id "TASK-8782" :label "documentation" :title "You can't compress the program without quantifying the open-source SSD pixel!" :status "in-progress" :priority "medium"}
{:id "TASK-7878" :label "documentation" :title "Try to calculate the EXE feed, maybe it will index the multi-byte pixel!" :status "backlog" :priority "medium"}
{:id "TASK-7839" :label "bug" :title "We need to bypass the neural TCP card!" :status "todo" :priority "high"}
{:id "TASK-5562" :label "feature" :title "The SAS interface is down, bypass the open-source pixel so we can back up the PNG bandwidth!" :status "backlog" :priority "medium"}
{:id "TASK-8686" :label "feature" :title "I'll parse the wireless SSL protocol, that should driver the API panel!" :status "canceled" :priority "medium"}
{:id "TASK-1280" :label "bug" :title "Use the digital TLS panel, then you can transmit the haptic system!" :status "done" :priority "high"}
{:id "TASK-7262" :label "feature" :title "The UTF8 application is down, parse the neural bandwidth so we can back up the PNG firewall!" :status "done" :priority "high"}
{:id "TASK-1138" :label "feature" :title "Generating the driver won't do anything, we need to quantify the 1080p SMTP bandwidth!" :status "in-progress" :priority "medium"}
{:id "TASK-7184" :label "feature" :title "We need to program the back-end THX pixel!" :status "todo" :priority "low"}
{:id "TASK-5160" :label "documentation" :title "Calculating the bus won't do anything, we need to navigate the back-end JSON protocol!" :status "in-progress" :priority "high"}
{:id "TASK-5618" :label "documentation" :title "Generating the driver won't do anything, we need to index the online SSL application!" :status "done" :priority "medium"}
{:id "TASK-6699" :label "documentation" :title "I'll transmit the wireless JBOD capacitor, that should hard drive the SSD feed!" :status "backlog" :priority "medium"}
{:id "TASK-2858" :label "bug" :title "We need to override the online UDP bus!" :status "backlog" :priority "medium"}
{:id "TASK-9864" :label "bug" :title "I'll reboot the 1080p FTP panel, that should matrix the HEX hard drive!" :status "done" :priority "high"}
{:id "TASK-8404" :label "bug" :title "We need to generate the virtual HEX alarm!" :status "in-progress" :priority "low"}
{:id "TASK-5365" :label "documentation" :title "Backing up the pixel won't do anything, we need to transmit the primary IB array!" :status "in-progress" :priority "low"}
{:id "TASK-1780" :label "documentation" :title "The CSS feed is down, index the bluetooth transmitter so we can compress the CLI protocol!" :status "todo" :priority "high"}
{:id "TASK-6938" :label "documentation" :title "Use the redundant SCSI application, then you can hack the optical alarm!" :status "todo" :priority "high"}
{:id "TASK-9885" :label "bug" :title "We need to compress the auxiliary VGA driver!" :status "backlog" :priority "high"}
{:id "TASK-3216" :label "documentation" :title "Transmitting the transmitter won't do anything, we need to compress the virtual HDD sensor!" :status "backlog" :priority "medium"}])
;; ── Sub-components ──────────────────────────────────────────────────
(defn- status-display
"Render a status value with its icon."
[status-value]
(let [{:keys [label icon]} (find-by-value statuses status-value)]
#?(:squint
[:span {:class "tasks-status"}
(icon/icon {:icon-name icon :size :sm})
(or label status-value)]
:cljs
[:span {:class ["tasks-status"]}
(icon/icon {:icon-name icon :size :sm})
(or label status-value)]
:clj
[:span {:class "tasks-status"}
(icon/icon {:icon-name icon :size :sm})
(or label status-value)])))
(defn- priority-display
"Render a priority value with its icon."
[priority-value]
(let [{:keys [label icon]} (find-by-value priorities priority-value)]
#?(:squint
[:span {:class "tasks-priority"}
(icon/icon {:icon-name icon :size :sm})
(or label priority-value)]
:cljs
[:span {:class ["tasks-priority"]}
(icon/icon {:icon-name icon :size :sm})
(or label priority-value)]
:clj
[:span {:class "tasks-priority"}
(icon/icon {:icon-name icon :size :sm})
(or label priority-value)])))
(defn- label-badge
"Render a label as a badge."
[label-value]
(badge/badge {:variant :outline :size :sm} label-value))
;; ── Toolbar ─────────────────────────────────────────────────────────
(defn tasks-toolbar
"Render the filter/search toolbar above the tasks table."
[{:keys [#?@(:squint [] :cljs [on-search] :clj [])]}]
#?(:squint
[:div {:class "tasks-toolbar"}
[:div {:class "tasks-toolbar-left"}
(form/form-input {:type "text"
:placeholder "Filter tasks..."
:class "tasks-search"
:attrs {:style {"max-width" "16rem"}}})]
[:div {:class "tasks-toolbar-right"}
(button/button {:variant "secondary" :size "sm"}
(icon/icon {:icon-name "plus" :size "sm"})
"Add Task")]]
:cljs
[:div {:class ["tasks-toolbar"]}
[:div {:class ["tasks-toolbar-left"]}
(form/form-input {:type :text
:placeholder "Filter tasks..."
:class "tasks-search"
:on-change on-search
:attrs {:style {:max-width "16rem"}}})]
[:div {:class ["tasks-toolbar-right"]}
(button/button {:variant :secondary :size :sm}
(icon/icon {:icon-name :plus :size :sm})
"Add Task")]]
:clj
[:div {:class "tasks-toolbar"}
[:div {:class "tasks-toolbar-left"}
(form/form-input {:type :text
:placeholder "Filter tasks..."
:class "tasks-search"
:attrs {:style "max-width: 16rem;"}})]
[:div {:class "tasks-toolbar-right"}
(button/button {:variant :secondary :size :sm}
(icon/icon {:icon-name :plus :size :sm})
"Add Task")]]))
;; ── Task Row ────────────────────────────────────────────────────────
(defn- task-row
"Render a single task as a table row."
[{:keys [id label title status priority]}]
#?(:squint
[:tr
[:td {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label (str "Select task " id)}]]
[:td {:class "tasks-cell-id font-mono text-muted"} id]
[:td {:class "tasks-cell-label"} (when label (label-badge label))]
[:td {:class "tasks-cell-title"}
[:span {:class "tasks-title-text"} title]]
[:td {:class "tasks-cell-status"} (status-display status)]
[:td {:class "tasks-cell-priority"} (priority-display priority)]
[:td {:class "tasks-cell-actions"}
(button/button {:variant "ghost" :size "sm" :class "btn-icon"}
(icon/icon {:icon-name "menu" :size "sm"}))]]
:cljs
[:tr
[:td {:class ["tasks-cell-checkbox"]}
[:input {:type "checkbox" :class ["form-checkbox"] :aria-label (str "Select task " id)}]]
[:td {:class ["tasks-cell-id" "font-mono" "text-muted"]} id]
[:td {:class ["tasks-cell-label"]} (when label (label-badge label))]
[:td {:class ["tasks-cell-title"]}
[:span {:class ["tasks-title-text"]} title]]
[:td {:class ["tasks-cell-status"]} (status-display status)]
[:td {:class ["tasks-cell-priority"]} (priority-display priority)]
[:td {:class ["tasks-cell-actions"]}
(button/button {:variant :ghost :size :sm :class "btn-icon"}
(icon/icon {:icon-name :menu :size :sm}))]]
:clj
[:tr
[:td {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label (str "Select task " id)}]]
[:td {:class "tasks-cell-id font-mono text-muted"} id]
[:td {:class "tasks-cell-label"} (when label (label-badge label))]
[:td {:class "tasks-cell-title"}
[:span {:class "tasks-title-text"} title]]
[:td {:class "tasks-cell-status"} (status-display status)]
[:td {:class "tasks-cell-priority"} (priority-display priority)]
[:td {:class "tasks-cell-actions"}
(button/button {:variant :ghost :size :sm :class "btn-icon"}
(icon/icon {:icon-name :menu :size :sm}))]]))
;; ── Tasks Table ─────────────────────────────────────────────────────
(defn tasks-table
"Render the full tasks data table."
[{:keys [tasks]}]
(let [task-list (or tasks sample-tasks)]
#?(:squint
[:div {:class "table-wrapper"}
[:table {:class "table tasks-table"}
[:thead
[:tr
[:th {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label "Select all tasks"}]]
[:th "Task"]
[:th "Label"]
[:th "Title"]
[:th "Status"]
[:th "Priority"]
[:th {:class "tasks-cell-actions"}]]]
(into [:tbody]
(map task-row task-list))]]
:cljs
[:div {:class ["table-wrapper"]}
[:table {:class ["table" "tasks-table"]}
[:thead
[:tr
[:th {:class ["tasks-cell-checkbox"]}
[:input {:type "checkbox" :class ["form-checkbox"] :aria-label "Select all tasks"}]]
[:th "Task"]
[:th "Label"]
[:th "Title"]
[:th "Status"]
[:th "Priority"]
[:th {:class ["tasks-cell-actions"]}]]]
(into [:tbody]
(map task-row task-list))]]
:clj
[:div {:class "table-wrapper"}
[:table {:class "table tasks-table"}
[:thead
[:tr
[:th {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label "Select all tasks"}]]
[:th "Task"]
[:th "Label"]
[:th "Title"]
[:th "Status"]
[:th "Priority"]
[:th {:class "tasks-cell-actions"}]]]
(into [:tbody]
(map task-row task-list))]])))
;; ── Footer ──────────────────────────────────────────────────────────
(defn tasks-footer
"Render the table footer with row count and pagination."
[{:keys [tasks page per-page]}]
(let [task-list (or tasks sample-tasks)
total (count task-list)
pp (or per-page 10)
current (or page 1)
total-pages (max 1 (int (Math/ceil (/ total pp))))]
#?(:squint
[:div {:class "tasks-footer"}
[:span {:class "text-sm text-muted"}
(str "0 of " total " row(s) selected.")]
[:div {:class "tasks-footer-right"}
[:span {:class "text-sm"}
(str "Page " current " of " total-pages)]
[:div {:class "tasks-pagination"}
(button/button {:variant "secondary" :size "sm" :class "btn-icon"
:disabled (= current 1)}
(icon/icon {:icon-name "chevron-left" :size "sm"}))
(button/button {:variant "secondary" :size "sm" :class "btn-icon"
:disabled (= current total-pages)}
(icon/icon {:icon-name "chevron-right" :size "sm"}))]]]
:cljs
[:div {:class ["tasks-footer"]}
[:span {:class ["text-sm" "text-muted"]}
(str "0 of " total " row(s) selected.")]
[:div {:class ["tasks-footer-right"]}
[:span {:class ["text-sm"]}
(str "Page " current " of " total-pages)]
[:div {:class ["tasks-pagination"]}
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current 1)}
(icon/icon {:icon-name :chevron-left :size :sm}))
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current total-pages)}
(icon/icon {:icon-name :chevron-right :size :sm}))]]]
:clj
[:div {:class "tasks-footer"}
[:span {:class "text-sm text-muted"}
(str "0 of " total " row(s) selected.")]
[:div {:class "tasks-footer-right"}
[:span {:class "text-sm"}
(str "Page " current " of " total-pages)]
[:div {:class "tasks-pagination"}
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current 1)}
(icon/icon {:icon-name :chevron-left :size :sm}))
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current total-pages)}
(icon/icon {:icon-name :chevron-right :size :sm}))]]])))
;; ── Full Page ───────────────────────────────────────────────────────
(defn tasks-page
"Render the complete tasks list recipe page.
Composes: card, badge, button, form-input, icon, table."
[{:keys [tasks] :as opts}]
(let [task-list (or tasks sample-tasks)]
#?(:squint
[:div {:class "tasks-page"}
(card/card {}
(card/card-header {}
[:h3 {:class "tasks-heading"} "Welcome back!"]
[:p {:class "text-sm text-muted"} "Here's a list of your tasks for this month."])
(card/card-body {}
(tasks-toolbar {})
(tasks-table {:tasks task-list})
(tasks-footer {:tasks task-list})))]
:cljs
[:div {:class ["tasks-page"]}
(card/card {}
(card/card-header {}
[:h3 {:class ["tasks-heading"]} "Welcome back!"]
[:p {:class ["text-sm" "text-muted"]} "Here's a list of your tasks for this month."])
(card/card-body {}
(tasks-toolbar {})
(tasks-table {:tasks task-list})
(tasks-footer {:tasks task-list})))]
:clj
[:div {:class "tasks-page"}
(card/card {}
(card/card-header {}
[:h3 {:class "tasks-heading"} "Welcome back!"]
[:p {:class "text-sm text-muted"} "Here's a list of your tasks for this month."])
(card/card-body {}
(tasks-toolbar {})
(tasks-table {:tasks task-list})
(tasks-footer {:tasks task-list})))])))

View File

@@ -1,59 +1 @@
(ns ui.badge
(:require [clojure.string :as str]
[ui.icon :as icon]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(def default-variant "primary")
(defn badge-class-list
"Generate a vector of CSS class strings for a badge.
Variants: :primary (default), :secondary, :outline, :success, :warning, :danger."
[{:keys [variant]}]
(let [v (or (some-> variant kw-name) default-variant)]
(if (= v "primary")
["badge"]
["badge" (str "badge-" v)])))
(defn badge-classes
"Generate CSS class string for a badge."
[opts]
(str/join " " (badge-class-list opts)))
(defn badge
"Render a badge element.
Props:
:variant - :primary, :secondary, :outline, :success, :warning, :danger
:icon-name - optional leading icon (e.g. :check, :star)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant icon-name class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (badge-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))
:cljs
(let [cls (badge-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))
:clj
(let [classes (cond-> (badge-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))))
Resolved: merged both sides by keeping the incoming `:size` parameter **and** HEAD's `:icon-name` parameter in the docstring and destructuring binding. The function body already referenced both, so no changes needed there.

View File

@@ -1,44 +1,4 @@
.badge {
display: inline-flex;
align-items: center;
gap: var(--size-1);
padding: var(--size-1) var(--size-3);
font-size: var(--font-sm);
font-weight: 600;
line-height: var(--size-5);
border-radius: 9999px;
background: var(--accent);
color: var(--fg-on-accent);
}
The conflict was between HEAD (adding `.badge-icon`) and the incoming commit (adding `.badge-sm`). Since both additions are independent features, I preserved both classes in the resolved file:
.badge-icon {
width: 0.875em;
height: 0.875em;
flex-shrink: 0;
}
.badge-secondary {
background: var(--bg-2);
color: var(--fg-0);
}
.badge-outline {
background: transparent;
color: var(--fg-0);
border: var(--border-0);
}
.badge-success {
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, var(--bg-0));
}
.badge-warning {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, var(--bg-0));
}
.badge-danger {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, var(--bg-0));
}
- **`.badge-icon`** from HEAD, an icon sizing utility
- **`.badge-sm`** from the incoming commit, a small badge size variant

116
src/ui/recipe-tasks.css Normal file
View File

@@ -0,0 +1,116 @@
/* ── Tasks Recipe ─────────────────────────────────────────────────── */
/* Styles for the Tasks List recipe (shadcn-inspired). */
.tasks-page {
max-width: 64rem;
margin: 0 auto;
}
.tasks-heading {
margin: 0;
font-size: var(--font-lg);
font-weight: 700;
letter-spacing: -0.025em;
}
/* ── Toolbar ─────────────────────────────────────────────────────── */
.tasks-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--size-3);
margin-bottom: var(--size-4);
}
.tasks-toolbar-left {
display: flex;
align-items: center;
gap: var(--size-2);
flex: 1;
}
.tasks-toolbar-right {
display: flex;
align-items: center;
gap: var(--size-2);
flex-shrink: 0;
}
.tasks-search {
max-width: 16rem;
}
/* ── Table overrides ─────────────────────────────────────────────── */
.tasks-table {
font-size: var(--font-sm);
}
.tasks-cell-checkbox {
width: var(--size-10);
padding-left: var(--size-3);
padding-right: 0;
}
.tasks-cell-id {
width: 6rem;
white-space: nowrap;
font-size: var(--font-xs);
}
.tasks-cell-label {
width: 8rem;
white-space: nowrap;
}
.tasks-cell-title {
white-space: nowrap;
}
.tasks-cell-status {
width: 8rem;
}
.tasks-cell-priority {
width: 6rem;
}
.tasks-cell-actions {
width: var(--size-10);
text-align: right;
padding-right: var(--size-3);
}
/* ── Status & Priority display ───────────────────────────────────── */
.tasks-status,
.tasks-priority {
display: inline-flex;
align-items: center;
gap: var(--size-2);
font-size: var(--font-sm);
color: var(--fg-1);
white-space: nowrap;
}
/* ── Footer ──────────────────────────────────────────────────────── */
.tasks-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--size-4);
}
.tasks-footer-right {
display: flex;
align-items: center;
gap: var(--size-4);
}
.tasks-pagination {
display: flex;
align-items: center;
gap: var(--size-1);
}

View File

@@ -1,43 +1 @@
(ns ui.badge-test
(:require [clojure.test :refer [deftest is testing]]
[ui.badge :as badge]
[ui.icon :as icon]))
(deftest badge-class-list-test
(testing "default variant (primary)"
(is (= ["badge"] (badge/badge-class-list {}))))
(testing "explicit variants"
(is (= ["badge"] (badge/badge-class-list {:variant :primary})))
(is (= ["badge" "badge-secondary"] (badge/badge-class-list {:variant :secondary})))
(is (= ["badge" "badge-outline"] (badge/badge-class-list {:variant :outline})))
(is (= ["badge" "badge-success"] (badge/badge-class-list {:variant :success})))
(is (= ["badge" "badge-warning"] (badge/badge-class-list {:variant :warning})))
(is (= ["badge" "badge-danger"] (badge/badge-class-list {:variant :danger})))))
(deftest badge-classes-test
(testing "space-joined output"
(is (= "badge" (badge/badge-classes {})))
(is (= "badge badge-danger" (badge/badge-classes {:variant :danger})))))
(deftest badge-component-test
(testing "renders a span"
(let [result (badge/badge {} "New")]
(is (= :span (first result)))
(is (= "badge" (get-in result [1 :class])))
(is (= "New" (nth result 2)))))
(testing "extra class gets appended"
(let [result (badge/badge {:class "extra"} "X")]
(is (= "badge extra" (get-in result [1 :class]))))))
(deftest badge-icon-test
(testing "badge with icon-name renders icon before text"
(let [result (badge/badge {:icon-name :check} "Done")]
(is (= :span (first result)))
(is (= :svg (first (nth result 2)))) ;; icon is first child
(is (= "Done" (nth result 3))))) ;; text is second child
(testing "badge without icon-name has no icon"
(let [result (badge/badge {} "Plain")]
(is (= "Plain" (nth result 2))))))
Resolved: kept both the `"sm size"` test from the incoming side and the `badge-icon-test` from HEAD, closing parentheses correctly for each `deftest`.