diff --git a/AGENTS.md b/AGENTS.md index 6053f3d..df964ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,7 +192,18 @@ bb build-theme # Regenerate CSS with new component styles bb test # All tests pass ``` -### 6. Check running dev servers before committing — CRITICAL +### 6. Never start dev servers from the agent — CRITICAL + +**Do not run `bb dev`, `bb dev-hiccup`, `bb dev-replicant`, `bb dev-squint`, or any long-running server process from the agent.** The user manages dev servers in a separate tmux session (`ui-dev`). Starting servers from the agent blocks the session, spawns orphan processes, and can break existing tmux panes. + +The agent may only: +- Run short commands: `bb test`, `bb build-theme`, `curl`, `wc -l`, `grep` +- Inspect tmux panes via `tmux capture-pane` +- Touch files to trigger recompilation: `touch src/ui/.cljc` + +If a dev server needs restarting, **tell the user** — don't do it yourself. + +### 7. Check running dev servers before committing — CRITICAL A tmux session `ui-dev` runs all three dev servers (`bb dev-all`). **Always check every pane for compile errors before committing:** @@ -212,6 +223,30 @@ Look for: Do **not** commit if any pane shows errors. Fix them first. +### 8. Verify pages load in browser before committing — CRITICAL + +Terminal compile checks alone are **not enough**. Squint can produce empty `.mjs` files silently (no errors in the terminal). Use the `fetch` tool to verify each dev server actually serves a working page: + +```sh +# Check hiccup (server-rendered — look for actual HTML content) +curl -s http://localhost:4003 | grep -c 'sidebar-layout' + +# Check squint compiled output is not empty (must be >1 line) +wc -l dev/squint/.compiled/dev/squint.mjs +wc -l dev/squint/.compiled/ui/*.mjs | sort -n | head -5 +# Any file with only 1 line is broken — recompile it: +# touch src/ui/.cljc + +# Check replicant compiled JS exists +ls -la dev/replicant/public/js/cljs-runtime/ui.sidebar.js +``` + +If any compiled file is suspiciously small (1 line = just the import), touch the source file to trigger rewatch, or manually compile: +```sh +cd dev/squint && npx squint compile ../../src/ui/.cljc +cd dev/squint && npx squint compile src/dev/squint.cljs +``` + ## Theme System ### Token naming diff --git a/dev/hiccup/src/dev/hiccup.clj b/dev/hiccup/src/dev/hiccup.clj index 528ab8f..6e9a661 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -356,9 +356,12 @@ (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;"} - [:h3 {:style "margin: 0 0 1rem; color: var(--fg-0);"} "Dashboard"] + [: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);"}] @@ -435,8 +438,11 @@ [:body (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) :icons (icons-page) diff --git a/dev/replicant/src/dev/replicant.cljs b/dev/replicant/src/dev/replicant.cljs index 1ad506f..c3af2e2 100644 --- a/dev/replicant/src/dev/replicant.cljs +++ b/dev/replicant/src/dev/replicant.cljs @@ -344,6 +344,14 @@ (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 [] @@ -391,8 +399,11 @@ (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) :icons (icons-page) diff --git a/dev/squint/src/dev/squint.cljs b/dev/squint/src/dev/squint.cljs index 93f59e3..605444d 100644 --- a/dev/squint/src/dev/squint.cljs +++ b/dev/squint/src/dev/squint.cljs @@ -33,6 +33,14 @@ (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] [:section {:style {"margin-bottom" "2.5rem"}} [:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem" @@ -404,8 +412,11 @@ (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) "icons" (icons-page) diff --git a/src/ui/sidebar.cljc b/src/ui/sidebar.cljc index 30a2ac2..f73b89e 100644 --- a/src/ui/sidebar.cljc +++ b/src/ui/sidebar.cljc @@ -369,6 +369,89 @@ [:span {:class "sidebar-collapsible-chevron" :aria-hidden "true"}]] (into [:div {:class "sidebar-collapsible-content"}] children)]))) +;; ── Sidebar Mobile Toggle ──────────────────────────────────────────── + +(defn sidebar-mobile-toggle-class-list [_opts] ["sidebar-mobile-toggle"]) +(defn sidebar-mobile-toggle-classes [opts] (str/join " " (sidebar-mobile-toggle-class-list opts))) + +(defn sidebar-mobile-toggle + "Render a hamburger/close toggle button for mobile sidebar. + Hidden on desktop via CSS. On click, toggles `data-sidebar-open` + on the nearest `.sidebar-layout` ancestor. + + Props: + :on-click - click handler (cljs/squint only) + :class - additional CSS classes + :attrs - additional HTML attributes" + [{:keys [on-click class attrs] :as _props}] + #?(:squint + (let [classes (cond-> (sidebar-mobile-toggle-classes {}) class (str " " class)) + base-attrs (cond-> (merge {:class classes + :type "button" + :aria-label "Toggle sidebar"} attrs) + on-click (assoc :on-click on-click))] + [:button base-attrs + [:span {:class "sidebar-toggle-icon-open" :aria-hidden "true"} + (icon/icon {:icon-name :menu :size :sm})] + [:span {:class "sidebar-toggle-icon-close" :aria-hidden "true"} + (icon/icon {:icon-name :x :size :sm})]]) + + :cljs + (let [cls (sidebar-mobile-toggle-class-list {}) + classes (cond-> cls class (conj class)) + base-attrs (cond-> (merge {:class classes + :type "button" + :aria-label "Toggle sidebar"} attrs) + on-click (assoc-in [:on :click] on-click))] + [:button base-attrs + [:span {:class ["sidebar-toggle-icon-open"] :aria-hidden "true"} + (icon/icon {:icon-name :menu :size :sm})] + [:span {:class ["sidebar-toggle-icon-close"] :aria-hidden "true"} + (icon/icon {:icon-name :x :size :sm})]]) + + :clj + (let [classes (cond-> (sidebar-mobile-toggle-classes {}) class (str " " class)) + base-attrs (merge {:class classes + :type "button" + :aria-label "Toggle sidebar" + :onclick "this.closest('.sidebar-layout').toggleAttribute('data-sidebar-open')"} attrs)] + [:button base-attrs + [:span {:class "sidebar-toggle-icon-open" :aria-hidden "true"} + (icon/icon {:icon-name :menu :size :sm})] + [:span {:class "sidebar-toggle-icon-close" :aria-hidden "true"} + (icon/icon {:icon-name :x :size :sm})]]))) + +;; ── Sidebar Overlay ───────────────────────────────────────────────── + +(defn sidebar-overlay + "Render the backdrop overlay for mobile sidebar. + Clicking it closes the sidebar. Place inside sidebar-layout, + as a sibling of the sidebar. + + Props: + :on-click - click handler (cljs/squint only) + :class - additional CSS classes + :attrs - additional HTML attributes" + [{:keys [on-click class attrs] :as _props}] + #?(:squint + (let [classes (cond-> "sidebar-overlay" class (str " " class)) + base-attrs (cond-> (merge {:class classes :aria-hidden "true"} attrs) + on-click (assoc :on-click on-click))] + [:div base-attrs]) + + :cljs + (let [classes (cond-> ["sidebar-overlay"] class (conj class)) + base-attrs (cond-> (merge {:class classes :aria-hidden "true"} attrs) + on-click (assoc-in [:on :click] on-click))] + [:div base-attrs]) + + :clj + (let [classes (cond-> "sidebar-overlay" class (str " " class)) + base-attrs (merge {:class classes + :aria-hidden "true" + :onclick "this.closest('.sidebar-layout').removeAttribute('data-sidebar-open')"} attrs)] + [:div base-attrs]))) + ;; ── Sidebar Separator ─────────────────────────────────────────────── (defn sidebar-separator diff --git a/src/ui/sidebar.css b/src/ui/sidebar.css index c20fdf6..24473c2 100644 --- a/src/ui/sidebar.css +++ b/src/ui/sidebar.css @@ -4,6 +4,7 @@ display: flex; min-height: 100vh; min-height: 100dvh; + position: relative; } .sidebar-layout-main { @@ -144,7 +145,9 @@ .sidebar-content { flex: 1; overflow-y: auto; + overflow-x: hidden; padding: var(--size-2) var(--size-3); + min-width: 0; } /* Scrollbar styling */ @@ -196,6 +199,11 @@ /* ── Sidebar Menu Item ─────────────────────────────────────────── */ +.sidebar-menu > li { + display: flex; + min-width: 0; +} + .sidebar-menu-item { display: flex; align-items: center; @@ -211,8 +219,12 @@ background: transparent; font-family: inherit; width: 100%; + min-width: 0; text-align: left; line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } a.sidebar-menu-item { @@ -387,3 +399,97 @@ a.sidebar-menu-item { border-left: var(--border-0); padding-left: var(--size-2); } + +/* ── Sidebar Mobile Toggle ─────────────────────────────────────── */ + +.sidebar-mobile-toggle { + display: none; + align-items: center; + justify-content: center; + width: var(--size-10); + height: var(--size-10); + padding: 0; + border: var(--border-0); + border-radius: var(--radius-md); + background: var(--bg-0); + color: var(--fg-1); + cursor: pointer; + flex-shrink: 0; + transition: background-color 150ms ease, color 150ms ease; +} + +.sidebar-mobile-toggle:hover { + background: var(--bg-1); + color: var(--fg-0); +} + +.sidebar-mobile-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Show open icon by default, hide close icon */ +.sidebar-mobile-toggle .sidebar-toggle-icon-open { + display: flex; +} + +.sidebar-mobile-toggle .sidebar-toggle-icon-close { + display: none; +} + +/* ── Sidebar Overlay ───────────────────────────────────────────── */ + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 40; + opacity: 0; + transition: opacity 200ms ease; +} + +/* ── Mobile Responsive ─────────────────────────────────────────── */ + +@media (max-width: 768px) { + .sidebar-mobile-toggle { + display: flex; + } + + .sidebar-layout > .sidebar { + position: fixed; + top: 0; + left: 0; + z-index: 50; + height: 100vh; + height: 100dvh; + transform: translateX(-100%); + transition: transform 250ms ease; + box-shadow: none; + } + + .sidebar-layout > .sidebar-overlay { + display: block; + pointer-events: none; + } + + /* Open state — toggled via data-sidebar-open on .sidebar-layout */ + .sidebar-layout[data-sidebar-open] > .sidebar { + transform: translateX(0); + box-shadow: var(--shadow-3); + } + + .sidebar-layout[data-sidebar-open] > .sidebar-overlay { + opacity: 1; + pointer-events: auto; + } + + /* Swap icons when open */ + .sidebar-layout[data-sidebar-open] .sidebar-mobile-toggle .sidebar-toggle-icon-open { + display: none; + } + + .sidebar-layout[data-sidebar-open] .sidebar-mobile-toggle .sidebar-toggle-icon-close { + display: flex; + } +} diff --git a/test/ui/sidebar_test.clj b/test/ui/sidebar_test.clj index 01bc7d5..d33bd84 100644 --- a/test/ui/sidebar_test.clj +++ b/test/ui/sidebar_test.clj @@ -146,6 +146,37 @@ (is (= :summary (first summary))) (is (= "Section" (nth (nth summary 1) 1))))))) +(deftest sidebar-mobile-toggle-class-list-test + (testing "returns sidebar-mobile-toggle class" + (is (= ["sidebar-mobile-toggle"] (sidebar/sidebar-mobile-toggle-class-list {}))))) + +(deftest sidebar-mobile-toggle-test + (testing "renders a button with toggle icons" + (let [result (sidebar/sidebar-mobile-toggle {})] + (is (= :button (first result))) + (is (= "sidebar-mobile-toggle" (get-in result [1 :class]))) + (is (= "button" (get-in result [1 :type]))) + (is (= "Toggle sidebar" (get-in result [1 :aria-label]))))) + (testing "has onclick for toggling data-sidebar-open" + (let [result (sidebar/sidebar-mobile-toggle {})] + (is (string? (get-in result [1 :onclick]))))) + (testing "extra class appended" + (let [result (sidebar/sidebar-mobile-toggle {:class "custom"})] + (is (= "sidebar-mobile-toggle custom" (get-in result [1 :class])))))) + +(deftest sidebar-overlay-test + (testing "renders a div with overlay class" + (let [result (sidebar/sidebar-overlay {})] + (is (= :div (first result))) + (is (= "sidebar-overlay" (get-in result [1 :class]))) + (is (= "true" (get-in result [1 :aria-hidden]))))) + (testing "has onclick for closing sidebar" + (let [result (sidebar/sidebar-overlay {})] + (is (string? (get-in result [1 :onclick]))))) + (testing "extra class appended" + (let [result (sidebar/sidebar-overlay {:class "custom"})] + (is (= "sidebar-overlay custom" (get-in result [1 :class])))))) + (deftest sidebar-separator-test (testing "renders
" (let [result (sidebar/sidebar-separator)]