diff --git a/src/main/frontend/components/icon.cljs b/src/main/frontend/components/icon.cljs index 2f07ff14fc..45f902d895 100644 --- a/src/main/frontend/components/icon.cljs +++ b/src/main/frontend/components/icon.cljs @@ -333,13 +333,19 @@ explicit-bg (get avatar-data :backgroundColor) explicit-color (get avatar-data :color) shape (or (get avatar-data :shape) :circle) + fb-type (or (get avatar-data :fallback-type) :letters) + fb-icon (get avatar-data :fallback-icon) display-text (subs avatar-value 0 (min 3 (count avatar-value))) ;; Scale font-size with avatar size font-size (cond (<= size 16) "8px" (<= size 24) "10px" (<= size 32) "12px" - :else "14px")] + :else "14px") + icon-size (max 10 (int (* size 0.55))) + fallback-style (avatar-fallback-style {:font-size font-size + :bg explicit-bg + :color explicit-color})] (shui/avatar {:style {:width size :height size} :data-shape (name shape)} @@ -348,13 +354,16 @@ (shui/avatar-image {:src url :style {:object-fit "cover"} :data-shape (name shape)})) - ;; Fallback (shows while loading or on error) + ;; Fallback (shows while loading, on error, OR when there's no image + ;; but the avatar still wants to render — Letters or Icon. (shui/avatar-fallback - {:style (avatar-fallback-style {:font-size font-size - :bg explicit-bg - :color explicit-color}) + {:style fallback-style :data-shape (name shape)} - display-text)))) + (if (and (= fb-type :icon) (not (string/blank? fb-icon))) + (shui/tabler-icon fb-icon + {:size icon-size + :style {:color (:color fallback-style)}}) + display-text))))) (defn measure-text-width "Measure pixel width of text at given font-size using offscreen canvas." @@ -524,28 +533,49 @@ ;; until the image lands; if the asset is truly gone the ;; initials persist as the natural error state. (avatar-image-cp asset-uuid asset-type avatar-data opts) - ;; Text-only avatar + ;; Text-only avatar (no image set). Renders either initials + ;; or a tabler icon depending on :fallback-type. (let [size (or (:size opts) 20) avatar-value (get avatar-data :value) explicit-bg (get avatar-data :backgroundColor) explicit-color (get avatar-data :color) shape (or (get avatar-data :shape) :circle) + fb-type (or (get avatar-data :fallback-type) :letters) + fb-icon (get avatar-data :fallback-icon) display-text (subs avatar-value 0 (min 3 (count avatar-value))) - ;; Scale font-size with avatar size + ;; Scale font-size with avatar size. The earlier + ;; tier capped at 14px past 32px, which left the + ;; 38px page-icon and 56px customize-band preview + ;; reading as too-small text on too-big tiles. + ;; Past 32px we step proportionally (~38–40% of + ;; size) so the text fills the chip the way + ;; Notion / Linear / Slack avatars do. font-size (cond (<= size 16) "8px" (<= size 24) "10px" (<= size 32) "12px" - :else "14px")] + (<= size 40) "16px" + (<= size 56) "22px" + :else (str (int (* size 0.4)) "px")) + ;; Icon glyph scales to ~55% of the avatar's box. + icon-size (max 10 (int (* size 0.55))) + fallback-style (avatar-fallback-style {:font-size font-size + :bg explicit-bg + :color explicit-color})] (shui/avatar {:style {:width size :height size} :data-shape (name shape)} (shui/avatar-fallback - {:style (avatar-fallback-style {:font-size font-size - :bg explicit-bg - :color explicit-color}) + {:style fallback-style :data-shape (name shape)} - display-text))))) + (if (and (= fb-type :icon) (not (string/blank? fb-icon))) + ;; Icon fallback inherits the foreground color from + ;; avatar-fallback-style (which has already been + ;; contrast-adjusted against the muted background). + (shui/tabler-icon fb-icon + {:size icon-size + :style {:color (:color fallback-style)}}) + display-text)))))) ;; Image with asset — let image-icon-cp resolve via the filesystem ;; loader. Don't gate on a renderer-side `db/entity` check: @@ -612,9 +642,12 @@ default-icon (case (:type default-icon) :avatar (when (:block/title node-entity) - ;; Inherit color + shape from the class default. New :shape - ;; field defaults via normalize-icon when missing. - (let [inherited (select-keys (:data default-icon) [:backgroundColor :color :shape])] + ;; Inherit color + shape + fallback from the class default. + ;; normalize-icon downstream applies defaults for any field + ;; the class doesn't override. + (let [inherited (select-keys (:data default-icon) + [:backgroundColor :color + :shape :fallback-type :fallback-icon])] (cond-> {:type :avatar :data (merge inherited {:value (derive-avatar-initials (:block/title node-entity))})} @@ -752,15 +785,30 @@ [v] (cond ;; Already unified shape? (has :data key) - ;; Avatars get a small post-pass to ensure new fields (:shape) have - ;; defaults applied — legacy data stored before those fields existed - ;; would otherwise bypass the normalization branch below entirely. + ;; Avatars get a small post-pass to ensure new fields (:shape, + ;; :fallback-type, :fallback-icon) have defaults applied — legacy data + ;; stored before those fields existed would otherwise bypass the + ;; normalization branch below entirely. (and (map? v) (keyword? (:type v)) (contains? v :data)) (if (= :avatar (:type v)) - (let [explicit-shape (or (get-in v [:data :shape]) (:shape v))] + (let [explicit-shape (or (get-in v [:data :shape]) (:shape v)) + fb-type (or (get-in v [:data :fallback-type]) (:fallback-type v)) + fb-icon (or (get-in v [:data :fallback-icon]) (:fallback-icon v)) + ;; A nil fb-type defaults to :letters. A :icon fb-type with no + ;; fb-icon set degrades back to :letters so the renderer always + ;; has a usable invariant: :fallback-type :icon implies a + ;; non-blank :fallback-icon present. + effective-fb-type (cond + (nil? fb-type) :letters + (and (= fb-type :icon) (string/blank? fb-icon)) :letters + :else fb-type)] (cond-> v (nil? explicit-shape) (assoc-in [:data :shape] :circle) - (some? explicit-shape) (assoc-in [:data :shape] explicit-shape))) + (some? explicit-shape) (assoc-in [:data :shape] explicit-shape) + true (assoc-in [:data :fallback-type] effective-fb-type) + (and (= effective-fb-type :icon) + (not (string/blank? fb-icon))) + (assoc-in [:data :fallback-icon] fb-icon))) v) ;; Legacy map with :type @@ -806,16 +854,32 @@ asset-type (or (get-in v [:data :asset-type]) (:asset-type v)) ;; Shape: defaults to :circle for backward compat with avatars ;; saved before the shape field existed. - shape (or (get-in v [:data :shape]) (:shape v) :circle)] + shape (or (get-in v [:data :shape]) (:shape v) :circle) + ;; Fallback layer: rendered when no image is set. + ;; :letters → render the auto-derived initials. + ;; :icon → render :fallback-icon (a tabler icon name). + ;; Default :letters; an :icon type with no icon set + ;; degrades to :letters so the renderer never has to + ;; render an empty avatar. + fb-type (or (get-in v [:data :fallback-type]) (:fallback-type v)) + fb-icon (or (get-in v [:data :fallback-icon]) (:fallback-icon v)) + effective-fb-type (cond + (nil? fb-type) :letters + (and (= fb-type :icon) (string/blank? fb-icon)) :letters + :else fb-type)] {:type :avatar :id (or id (str "avatar-" value)) :label (or label value) :data (cond-> {:value value :backgroundColor backgroundColor :color color - :shape shape} + :shape shape + :fallback-type effective-fb-type} asset-uuid (assoc :asset-uuid asset-uuid) - asset-type (assoc :asset-type asset-type))}) + asset-type (assoc :asset-type asset-type) + (and (= effective-fb-type :icon) + (not (string/blank? fb-icon))) + (assoc :fallback-icon fb-icon))}) :image (let [;; Extract asset-uuid, stripping "image-" prefix if present (from :id fallback) raw-uuid (or (get-in v [:data :asset-uuid]) (:asset-uuid v) value) asset-uuid (if (and (string? raw-uuid) (string/starts-with? raw-uuid "image-")) @@ -3480,16 +3544,64 @@ preview-icon (or (when (= :avatar (:type current-icon)) current-icon) synthesized-avatar-context) current-shape (or (get-in preview-icon [:data :shape]) :circle) + current-fb-type (or (get-in preview-icon [:data :fallback-type]) :letters) + current-fb-icon (get-in preview-icon [:data :fallback-icon]) set-shape! (fn [new-shape] (on-chosen nil (assoc-in preview-icon [:data :shape] new-shape) true)) - reset-shape! (fn [] - ;; Phase 1: Reset only resets shape (the only - ;; customizable field today). Future phases will - ;; reset Fallback fields too. - (when (not= current-shape :circle) - (set-shape! :circle)))] + set-fallback-letters! (fn [] + (on-chosen nil + (-> preview-icon + (assoc-in [:data :fallback-type] :letters) + (update :data dissoc :fallback-icon)) + true)) + set-fallback-icon! (fn [icon-name] + (on-chosen nil + (-> preview-icon + (assoc-in [:data :fallback-type] :icon) + (assoc-in [:data :fallback-icon] icon-name)) + true)) + reset-style! (fn [] + ;; Phase 1+2: Reset clears any divergence from + ;; the system defaults — shape back to :circle + ;; and fallback back to :letters (no icon). + (when (or (not= current-shape :circle) + (not= current-fb-type :letters)) + (on-chosen nil + (-> preview-icon + (assoc-in [:data :shape] :circle) + (assoc-in [:data :fallback-type] :letters) + (update :data dissoc :fallback-icon)) + true))) + style-dirty? (or (not= current-shape :circle) + (not= current-fb-type :letters)) + ;; Open the constrained icon sub-picker. Anchored on the + ;; click event so it appears near the dropdown menu item. + ;; `:allowed-tabs [:icon]` strips the tab strip down to + ;; just Icons — emojis and the Custom tab don't make sense + ;; as fallback content for an avatar. + open-icon-sub-picker! + (fn [^js e] + (shui/popup-show! + e + (fn [{:keys [id]}] + (icon-search + {:on-chosen (fn [_e icon & _rest] + (let [icon-name (or (get-in icon [:data :value]) + (:id icon))] + (when icon-name (set-fallback-icon! icon-name)) + (shui/popup-hide! id))) + :allowed-tabs [:icon] + :icon-value (when (and (= current-fb-type :icon) current-fb-icon) + {:type :icon :data {:value current-fb-icon}}) + :page-title page-title + :preview-target-db-id preview-target-db-id + :del-btn? false})) + {:id :fallback-icon-picker + :align :end + :content-props {:class "ls-icon-picker" + :onEscapeKeyDown #(.preventDefault %)}}))] ;; All inner blocks always render so CSS transitions can run on ;; visibility/height changes — the band's gradient, the rail's ;; height, and the meta-vs-rows swap all interpolate cleanly. A @@ -3541,14 +3653,50 @@ "Circle") (shui/dropdown-menu-item {:on-click #(set-shape! :rounded-rect)} - "Rectangle")))]]]] + "Rectangle")))] + [:div.cb-row + [:span.cb-label "Fallback"] + (shui/dropdown-menu + (shui/dropdown-menu-trigger + {:as-child true} + [:button.cb-chip + {:type "button" + :data-topbar-stop "fallback" + :aria-label "Avatar fallback"} + ;; Glyph reflects current fallback. Letters → "Aa"; + ;; Icon → the actual chosen tabler icon at the chip's + ;; small size for an at-a-glance match against the + ;; rendered avatar. + [:span.cb-chip-glyph + (if (= current-fb-type :icon) + (when current-fb-icon + (shui/tabler-icon current-fb-icon {:size 11})) + [:span.glyph-letters "Aa"])] + [:span.cb-chip-label + (cond + (and (= current-fb-type :icon) current-fb-icon) + (or (some-> current-fb-icon + (string/replace #"-" " ") + string/capitalize) + "Icon") + :else + "Letters")] + (shui/tabler-icon "chevron-down" {:size 11 :class "cb-chip-chevron"})]) + (shui/dropdown-menu-content + {:align "end"} + (shui/dropdown-menu-item + {:on-click set-fallback-letters!} + "Letters") + (shui/dropdown-menu-item + {:on-click open-icon-sub-picker!} + "Icon…")))]]]] [:div.cb-rail-wrap [:div.cb-rail [:button.lx-toolbar-action.lx-toolbar-reset-link {:type "button" - :on-click reset-shape! + :on-click reset-style! :data-topbar-stop "reset" - :disabled (= current-shape :circle) + :disabled (not style-dirty?) :aria-label "Reset to default" :tab-index (if expanded? 0 -1)} (shui/tabler-icon "rotate" {:size 12}) @@ -5365,19 +5513,29 @@ icon-value (:icon-value opts) normalized (normalize-icon icon-value) *view (::view s) + *tab (::tab s) ;; Prefer current icon's color; fall back to last-used preset. ;; "inherit" is a CSS-layer sentinel (--ls-color-icon-preset), ;; not a real color — drop it before it reaches React state. denull #(when (and % (not= % "inherit")) %) icon-color (denull (get-in normalized [:data :color])) - stored (denull (storage/get :ls-icon-color-preset))] + stored (denull (storage/get :ls-icon-color-preset)) + ;; If the caller restricts the available tabs (e.g. + ;; the avatar customize band's icon-fallback sub-picker + ;; passes `:allowed-tabs [:all :emoji :icon]` to drop + ;; the Custom tab), seed `*tab` to the first allowed + ;; entry so we don't land on a hidden tab. + allowed (some-> (:allowed-tabs opts) set)] ;; Avatar/image icons open asset picker, text icons open text-picker (when (contains? #{:avatar :image :text} (:type normalized)) (reset! *view (if (= :text (:type normalized)) :text-picker :asset-picker))) + (when (and allowed (not (allowed @*tab))) + (reset! *tab (first allowed))) (assoc s ::color (atom (or icon-color stored)) ::input-ref (rum/create-ref) ::result-ref (rum/create-ref))))} - [state {:keys [on-chosen del-btn? icon-value page-title preview-target-db-id preview-target-db-ids] :as opts}] + [state {:keys [on-chosen del-btn? icon-value page-title preview-target-db-id preview-target-db-ids + allowed-tabs] :as opts}] (let [*q (::q state) *result (::result state) *tab (::tab state) @@ -5699,7 +5857,11 @@ :*virtuoso-ref *virtuoso-ref :topbar-selector ".cp__emoji-icon-picker .tabs-section [data-topbar-stop]"}) (ui/tab-items - {:tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:custom "Custom"]] + {:tabs (let [all-tabs [[:all "All"] [:emoji "Emojis"] + [:icon "Icons"] [:custom "Custom"]]] + (if-let [allowed (some-> allowed-tabs set)] + (filterv (fn [[id _]] (allowed id)) all-tabs) + all-tabs)) :active @*tab :on-change (fn [id ^js e] (reset! *tab id) diff --git a/src/main/frontend/components/icon.css b/src/main/frontend/components/icon.css index 80c8b930a7..d575ca5d82 100644 --- a/src/main/frontend/components/icon.css +++ b/src/main/frontend/components/icon.css @@ -2041,11 +2041,18 @@ the visible "growth" of the band. */ } -/* Resting meta — title + subtitle to the right of the avatar. */ +/* Resting meta — title + subtitle to the right of the avatar. min-height + matches the avatar's 56px tile so the text centers vertically against + the avatar via `justify-content: center` — without this the meta sat + at the top of the row, visually misaligned with the avatar's + midpoint. The expanded state (cb-rows) is naturally taller than the + avatar, so it doesn't need the same anchor. */ .avatar-customize-zone .preview-meta { display: flex; flex-direction: column; + justify-content: center; gap: 2px; + min-height: 56px; opacity: 1; transition: opacity 120ms ease-out; } @@ -2078,7 +2085,9 @@ .avatar-customize-zone .cb-rows { display: none; flex-direction: column; - gap: 10px; + /* 4px gap × 2× 26px row = 56px total — pixel-perfect match against the + 56px avatar tile to its left, no overflow tail under the avatar. */ + gap: 4px; min-width: 0; } @@ -2091,35 +2100,58 @@ align-items: center; justify-content: space-between; gap: 12px; - height: 28px; + height: 26px; } .avatar-customize-zone .cb-label { + /* Mirrors Settings' label style (`block text-sm font-medium leading-5 + opacity-70`) so the customize band reads as a settings panel rather + than a tooltip caption. */ + display: block; font-family: Inter, system-ui, sans-serif; - font-size: 12px; - font-weight: 700; - line-height: 16px; - color: var(--lx-gray-11, var(--rx-gray-11)); + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: var(--lx-gray-12, var(--rx-gray-12)); + opacity: 0.7; } -/* Dropdown chip — matches design: small button with leading mini-glyph, - label, trailing chevron. */ +/* Dropdown chip — Linear/Notion ghost-chip aesthetic. The chip looks like + plain text + chevron at rest, then reveals a subtle background and + border on hover (or when its dropdown is open via Radix's + `data-state="open"`). 1px transparent border at rest keeps the chip's + geometry stable across states — without it the chip would shift 2px + when the border appears on hover. */ .avatar-customize-zone .cb-chip { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; - background: var(--lx-gray-02, var(--rx-gray-02)); - border: 1px solid var(--lx-gray-05, var(--rx-gray-05)); + padding: 3px 8px; + background: transparent; + border: 1px solid transparent; border-radius: 6px; - font-size: 13px; + /* Match the cb-label's text-sm so label + value read as one continuous + "Shape: Circle" line. Tighter 18px line-height keeps the chip at + 26px (3 + 18 + 3 + 2 border = 26px) so the two-row stack still + pixel-aligns to the 56px avatar. */ + font-size: 14px; color: var(--lx-gray-12, var(--rx-gray-12)); line-height: 18px; + transition: background-color 120ms ease-out, border-color 120ms ease-out; &:hover { - border-color: var(--lx-gray-07, var(--rx-gray-07)); + background: var(--lx-gray-02, var(--rx-gray-02)); + border-color: var(--lx-gray-05, var(--rx-gray-05)); + } + + /* Radix sets data-state="open" on the trigger while the dropdown menu + is visible; pin the chip to its hover-style fill so the user has a + clear "this is the open one" anchor while picking. */ + &[data-state="open"] { + background: var(--lx-gray-02, var(--rx-gray-02)); + border-color: var(--lx-gray-05, var(--rx-gray-05)); } &:focus-visible { @@ -2150,8 +2182,42 @@ border-radius: 3px; } + /* "Aa" sigil for the Letters fallback chip — render a compact mono + hint at chip size so it reads as text-not-shape, distinct from the + circle/rect glyphs used by the Shape chip. The chip-glyph parent is + constrained to 11×11; widen here to fit "Aa" on one line. */ + .cb-chip-glyph .glyph-letters { + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + min-width: 14px; + height: 11px; + font-size: 9px; + font-weight: 700; + color: var(--lx-gray-12, var(--rx-gray-12)); + letter-spacing: -0.04em; + line-height: 11px; + white-space: nowrap; + border: none; + } + + /* Override the .cb-chip-glyph fixed 11×11 box width when the active + glyph is the "Aa" sigil — `:has` is supported in all modern browsers + Logseq targets. */ + .cb-chip-glyph:has(.glyph-letters) { + width: auto; + min-width: 14px; + } + .cb-chip-label { font-weight: 500; + /* Cap label width so a long icon name (e.g. "alert-circle-filled") + doesn't push the chevron off-edge. */ + max-width: 96px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .cb-chip-chevron { diff --git a/src/test/frontend/components/icon_test.cljs b/src/test/frontend/components/icon_test.cljs index db2e580a35..6d01196883 100644 --- a/src/test/frontend/components/icon_test.cljs +++ b/src/test/frontend/components/icon_test.cljs @@ -45,3 +45,73 @@ (is (= "#FF802B" (get-in normalized [:data :backgroundColor]))) (is (= "abc-123" (get-in normalized [:data :asset-uuid]))) (is (= "png" (get-in normalized [:data :asset-type])))))) + +(deftest normalize-icon-avatar-fallback + (testing "legacy avatars without :fallback-type default to :letters" + (let [normalized (icon/normalize-icon {:type :avatar :data {:value "JK"}})] + (is (= :letters (get-in normalized [:data :fallback-type]))) + (is (nil? (get-in normalized [:data :fallback-icon]))))) + + (testing "explicit :fallback-type :icon with valid :fallback-icon round-trips" + (let [normalized (icon/normalize-icon + {:type :avatar + :data {:value "AC" + :fallback-type :icon + :fallback-icon "briefcase"}})] + (is (= :icon (get-in normalized [:data :fallback-type]))) + (is (= "briefcase" (get-in normalized [:data :fallback-icon]))))) + + (testing ":fallback-type :icon with no :fallback-icon degrades to :letters" + ;; Defensive: an :icon type without a name is unrenderable, so we want + ;; the renderer's invariant (`:icon implies non-blank :fallback-icon`) + ;; to be enforced at normalization time rather than every read site. + (let [normalized (icon/normalize-icon + {:type :avatar + :data {:value "X" :fallback-type :icon}})] + (is (= :letters (get-in normalized [:data :fallback-type]))) + (is (nil? (get-in normalized [:data :fallback-icon]))))) + + (testing ":fallback-type :icon with blank string :fallback-icon also degrades" + (let [normalized (icon/normalize-icon + {:type :avatar + :data {:value "X" + :fallback-type :icon + :fallback-icon ""}})] + (is (= :letters (get-in normalized [:data :fallback-type]))))) + + (testing "switching back to :letters drops :fallback-icon" + ;; Simulates the user picking Letters in the Fallback dropdown after + ;; previously having picked an icon. The picker writes :fallback-type + ;; :letters; we shouldn't carry the dormant :fallback-icon along + ;; unless someone explicitly retains it. + (let [normalized (icon/normalize-icon + {:type :avatar + :data {:value "X" :fallback-type :letters}})] + (is (= :letters (get-in normalized [:data :fallback-type]))) + (is (nil? (get-in normalized [:data :fallback-icon]))))) + + (testing "fallback fields read from top-level keys (legacy serializations)" + (let [normalized (icon/normalize-icon + {:type :avatar + :fallback-type :icon + :fallback-icon "star" + :data {:value "X"}})] + (is (= :icon (get-in normalized [:data :fallback-type]))) + (is (= "star" (get-in normalized [:data :fallback-icon]))))) + + (testing "shape, fallback, color, and image all coexist in one avatar" + (let [normalized (icon/normalize-icon + {:type :avatar + :data {:value "AC" + :shape :rounded-rect + :fallback-type :icon + :fallback-icon "briefcase" + :color "#5B6CFF" + :backgroundColor "#5B6CFF" + :asset-uuid "uuid-1" + :asset-type "jpg"}})] + (is (= :rounded-rect (get-in normalized [:data :shape]))) + (is (= :icon (get-in normalized [:data :fallback-type]))) + (is (= "briefcase" (get-in normalized [:data :fallback-icon]))) + (is (= "#5B6CFF" (get-in normalized [:data :color]))) + (is (= "uuid-1" (get-in normalized [:data :asset-uuid]))))))