From d19310bd64de3074198f0086612ad49ff16fc5cd Mon Sep 17 00:00:00 2001 From: scheinriese Date: Thu, 7 May 2026 16:43:10 +0200 Subject: [PATCH] feat: legible avatar fallbacks + class default-icon row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatar fallback rendering: replace the prior 31.4%-alpha background treatment, which silently rendered dark picks (#1a3d60 etc.) at ~1.1:1 contrast on dark surfaces. The new pipeline computes a hue-preserving muted tint for the bg via OKLCh L bisection (~1.5:1 vs page surface) and runs the picked color through adjust-for-contrast (3.0 target) for the initials. Picked color passes through as text whenever it reads; only genuinely-illegible picks get lifted, and the lift stays close to the picked hue. Works for both custom hex picks and Radix theme tokens (var(--rx-...)) via a new colors/->hex CSS-color resolver. The 3.0 target — instead of WCAG 4.5 body-text — treats avatar initials as decorative identifiers (matches Slack/Linear/GitHub practice) and lets vivid hues like tomato and red pass through without desaturating. Class default-icon row: new property row that uses the unified icon picker in default-icon mode, where Text/Image tiles commit immediately rather than drilling into sub-pickers (per-instance inheritance derives the actual face from each instance's title). Setting a class's page icon now auto-syncs into default-icon when default-icon is empty so instances inherit it; clearing the page icon only clears default-icon if the two were synced. Co-Authored-By: Claude Opus 4.7 --- src/main/frontend/colors.cljs | 89 +++++++++++ src/main/frontend/components/block.cljs | 33 +++- src/main/frontend/components/icon.cljs | 146 +++++++++++------- .../frontend/components/property/value.cljs | 65 ++++++++ 4 files changed, 270 insertions(+), 63 deletions(-) diff --git a/src/main/frontend/colors.cljs b/src/main/frontend/colors.cljs index f83d667fb6..470d2d5041 100644 --- a/src/main/frontend/colors.cljs +++ b/src/main/frontend/colors.cljs @@ -56,6 +56,24 @@ (pick js/document.documentElement)) parse-css-color->hex))) +(defn ->hex + "Resolve any inline CSS color string to its current-theme hex value: + - hex strings pass through unchanged + - `var(--name)` references are looked up via the cascade on body + (so theme tokens like Radix `--rx-orange-10` resolve to whichever + light/dark hex is active right now) + - rgb(...)/hsl(...) literals are parsed + Returns nil for blank/unresolvable input." + [css-color] + (when (string? css-color) + (let [v (string/trim css-color)] + (cond + (string/blank? v) nil + (string/starts-with? v "#") v + (string/starts-with? v "var(") + (some-> (re-find #"var\(\s*(--[\w-]+)" v) second read-bg-var) + :else (parse-css-color->hex v))))) + (defn get-accent-color [] (when-let [color (some-> js/document.documentElement @@ -301,6 +319,77 @@ adjust-for-contrast (bounded-memoize adjust-for-contrast* 256)) +;; Chroma envelope for muted tints. Picked hue is preserved; chroma is +;; clamped low so the tint reads as "atmospheric hint of hue" rather than +;; a saturated chip. Floor keeps near-grayscale picks from collapsing to +;; pure gray (so a black/white pick still tints the badge differently +;; than an unset one). +(def ^:private muted-tint-max-chroma 0.05) +(def ^:private muted-tint-min-chroma 0.02) + +(defn- muted-tint* + [picked-hex surface-hex target-vs-surface] + (let [picked-rgb (hex->rgb picked-hex) + surface-rgb (hex->rgb surface-hex)] + (if (or (nil? picked-rgb) (nil? surface-rgb)) + picked-hex + (let [[_ pC pH] (oklab->oklch (rgb->oklab picked-rgb)) + ;; Near-grayscale picks (white, black, neutrals) carry no + ;; meaningful hue — atan2(0,0) returns 0 (red-ish), which + ;; would otherwise paint every gray pick the same warm tint. + ;; Skip the chroma floor in that case so the bg stays + ;; neutral. Threshold is generous; anything visibly chromatic + ;; sits well above 0.01. + achromatic? (< pC 0.01) + chroma (if achromatic? + 0.0 + (-> pC + (min muted-tint-max-chroma) + (max muted-tint-min-chroma))) + surface-dark? (< (relative-luminance surface-rgb) 0.5) + target-L (if surface-dark? 1.0 0.0) + [surface-L _ _] (oklab->oklch (rgb->oklab surface-rgb))] + (loop [lo (min surface-L target-L) + hi (max surface-L target-L) + best-hex picked-hex + i 0] + (if (>= i 12) + best-hex + (let [mid (/ (+ lo hi) 2) + candidate-hex (oklch->hex [mid chroma pH]) + candidate-rgb (hex->rgb candidate-hex) + ratio (if candidate-rgb + (contrast-ratio candidate-rgb surface-rgb) + 1.0)] + (cond + (>= ratio target-vs-surface) + ;; meets target — pull L back toward surface so the tint + ;; stays as subtle as it can while still being visible + (let [[new-lo new-hi] + (if surface-dark? + [lo mid] + [mid hi])] + (recur new-lo new-hi candidate-hex (inc i))) + + surface-dark? + (recur mid hi best-hex (inc i)) + :else + (recur lo mid best-hex (inc i)))))))))) + +(def ^{:doc "Mute `picked-hex` into a subtle, hue-preserving tint that sits + just above `surface-hex` in contrast. Hue is taken from picked, chroma + is clamped low (atmospheric, not saturated), and L is bisected toward + the surface to land at exactly `target-vs-surface` (default ~1.5:1). + + Use for the bg of a colored badge whose foreground will display the + picked color itself — the muted bg gives the badge presence without + competing with the foreground's hue. For text on top, run the picked + color through `adjust-for-contrast` against the muted bg. + + Memoized via a 256-entry LRU cache keyed on [picked surface target]."} + muted-tint + (bounded-memoize muted-tint* 256)) + ;; Canonical light/dark page surfaces. Used by `adjust-for-both-themes` ;; to derive both rendering hexes regardless of the active theme — so ;; the picker can show users what their pick will look like in BOTH diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index 16605d4011..29ca5d2a0f 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -3212,12 +3212,35 @@ (db-property-handler/set-block-property! (:db/id block) :logseq.property/icon - icon-data)) + icon-data) + ;; For classes, auto-sync the page-icon into + ;; default-icon when default-icon is empty so + ;; instances inherit it. If default-icon is already + ;; set we leave it alone to avoid clobbering a + ;; carefully configured tag default. + (when (and (ldb/class? block) + (nil? (:logseq.property.class/default-icon block))) + (db-property-handler/set-block-property! + (:db/id block) + :logseq.property.class/default-icon + icon-data))) ;; del — set :none to override inheritance (prevents auto-fetch re-trigger) - (db-property-handler/set-block-property! - (:db/id block) - :logseq.property/icon - {:type :none}))) + (do + (db-property-handler/set-block-property! + (:db/id block) + :logseq.property/icon + {:type :none}) + ;; For classes, only clear default-icon if it + ;; matches the page-icon being removed — i.e. the + ;; two were synced. If default-icon was set + ;; independently, preserve it. + (let [default-icon (:logseq.property.class/default-icon block)] + (when (and (ldb/class? block) + default-icon + (= default-icon icon')) + (db-property-handler/remove-block-property! + (:db/id block) + :logseq.property.class/default-icon)))))) :del-btn? (boolean (and icon' (not= (:type icon') :none))) :page-title (:block/title block) :preview-target-db-id (:db/id block) diff --git a/src/main/frontend/components/icon.cljs b/src/main/frontend/components/icon.cljs index 75e101edf1..25c55acf45 100644 --- a/src/main/frontend/components/icon.cljs +++ b/src/main/frontend/components/icon.cljs @@ -57,30 +57,55 @@ (declare normalize-icon derive-initials derive-avatar-initials hex` so + they hit the same OKLCh pipeline; otherwise preset picks would skip + muting and render as flat saturated discs (bg = text = same token). + + Earlier iteration used a 31.4% alpha treatment, which silently rendered + dark picks at ~1.1:1 vs surface — invisible. The next iteration went + solid bg + auto-lifted text, which inverted the hierarchy and made + vivid picks read as a single solid disc. This version restores the + original intent with deterministic OKLCh math." + [{:keys [font-size bg color]}] + (let [bg-hex (colors/->hex bg) + color-hex (colors/->hex (or color bg)) + page-bg (when bg-hex + (colors/read-bg-var "--ls-primary-background-color")) + bg' (if (and bg-hex page-bg) + (colors/muted-tint bg-hex page-bg 1.5) + bg) + color' (if (and color-hex bg-hex page-bg) + (colors/adjust-for-contrast color-hex bg' 3.0) + (or color bg))] + (cond-> {:font-size font-size :font-weight "500"} + bg' (assoc :background-color bg') + color' (assoc :color color')))) (defn- get-asset-type-from-db "Get asset type from DB using a direct Datalog query. @@ -319,12 +344,9 @@ :style {:object-fit "cover"}})) ;; Fallback (shows while loading or on error) (shui/avatar-fallback - {:style (cond-> {:font-size font-size - :font-weight "500"} - explicit-bg - (assoc :background-color (convert-bg-color-to-rgba explicit-bg)) - explicit-color - (assoc :color explicit-color))} + {:style (avatar-fallback-style {:font-size font-size + :bg explicit-bg + :color explicit-color})} display-text)))) (defn measure-text-width @@ -509,12 +531,9 @@ (shui/avatar {:style {:width size :height size}} (shui/avatar-fallback - {:style (cond-> {:font-size font-size - :font-weight "500"} - explicit-bg - (assoc :background-color (convert-bg-color-to-rgba explicit-bg)) - explicit-color - (assoc :color explicit-color))} + {:style (avatar-fallback-style {:font-size font-size + :bg explicit-bg + :color explicit-color})} display-text))))) ;; Image with asset — let image-icon-cp resolve via the filesystem @@ -1663,7 +1682,6 @@ (colors/variable :gray :09)) my-id (:id icon-item) display-text (subs avatar-value 0 (min 3 (count avatar-value))) - bg-color-rgba (convert-bg-color-to-rgba backgroundColor) item-shape {:type :avatar :data {:value avatar-value :backgroundColor backgroundColor @@ -1685,10 +1703,9 @@ (shui/avatar {:class "w-7 h-7"} (shui/avatar-fallback - {:style {:background-color bg-color-rgba - :font-size "12px" - :font-weight "500" - :color color}} + {:style (avatar-fallback-style {:font-size "12px" + :bg backgroundColor + :color color})} display-text))])) (defn render-item @@ -2067,6 +2084,10 @@ on-chosen (:on-chosen opts) highlighted-id (:highlighted-id opts) on-tile-hover! (:on-tile-hover! opts) + ;; In default-icon mode (used by tag class default-icon row), Text and + ;; Image tiles commit immediately rather than drilling into sub-pickers. + ;; Avatar still opens the asset-picker since avatars need an image. + default-icon? (:default-icon? opts) ;; Mouse-hover preview broadcast: pass the synthesized preview item ;; the page-icon should render for each button. Keyboard hover ;; broadcasts `:custom-*` markers and relies on icon-search's @@ -2079,27 +2100,31 @@ {:data-item-id "custom-text" :tabIndex "-1" :class (when (= "custom-text" highlighted-id) "is-highlighted") - :on-click #(reset! *view :text-picker) + :on-click (if default-icon? + #(when on-chosen (on-chosen % text-item)) + #(reset! *view :text-picker)) :on-mouse-over (fn [] (some-> on-tile-hover! (apply [text-item])))} [:div.custom-tab-item-preview {:aria-hidden "true"} (icon text-item {:size 32})] [:span.custom-tab-item-label "Text"]]) - ;; Avatar option — commits the synthesized initials avatar immediately - ;; (`keep-popup? true` keeps the icon-picker mounted) and lands on the - ;; asset-picker's Avatar tab. The user can then pick / upload an image - ;; to use as the avatar background, or back out and the initials avatar - ;; stays as the committed icon. Mirrors the visual continuity the user - ;; gets from the hover preview. + ;; Avatar option. In page-icon context: commits the synthesized initials + ;; avatar immediately and lands on the asset-picker's Avatar tab so the + ;; user can pick a face image. In default-icon (class) context: commits + ;; only the type-without-image; each instance auto-derives its own + ;; initials from its own title via get-node-icon, so binding a specific + ;; face would be the wrong shape for the class default. (when avatar-item [:button.custom-tab-item {:data-item-id "custom-avatar" :tabIndex "-1" :class (when (= "custom-avatar" highlighted-id) "is-highlighted") - :on-click (fn [e] - (when on-chosen (on-chosen e avatar-item true)) - (reset! *asset-picker-initial-mode :avatar) - (reset! *view :asset-picker)) + :on-click (if default-icon? + #(when on-chosen (on-chosen % avatar-item)) + (fn [e] + (when on-chosen (on-chosen e avatar-item true)) + (reset! *asset-picker-initial-mode :avatar) + (reset! *view :asset-picker))) :on-mouse-over (fn [] (some-> on-tile-hover! (apply [avatar-item])))} [:div.custom-tab-item-preview {:aria-hidden "true"} (icon avatar-item {:size 32})] @@ -2113,10 +2138,14 @@ {:data-item-id "custom-image" :tabIndex "-1" :class (when (= "custom-image" highlighted-id) "is-highlighted") - :on-click (fn [e] - (when on-chosen (on-chosen e image-placeholder-item true)) - (reset! *asset-picker-initial-mode :image) - (reset! *view :asset-picker)) + :on-click (if default-icon? + ;; Default-icon context: commit placeholder and close. + ;; Per-instance images are auto-derived elsewhere. + (fn [e] (when on-chosen (on-chosen e image-placeholder-item))) + (fn [e] + (when on-chosen (on-chosen e image-placeholder-item true)) + (reset! *asset-picker-initial-mode :image) + (reset! *view :asset-picker))) :on-mouse-over (fn [] (some-> on-tile-hover! (apply [image-placeholder-item])))} [:div.custom-tab-item-preview {:aria-hidden "true"} [:span.image-tile-placeholder @@ -5750,7 +5779,7 @@ (icon effective-icon-value (merge {:color? true} icon-props)))) (rum/defc icon-picker - [icon-value {:keys [empty-label disabled? initial-open? del-btn? on-chosen icon-props popup-opts button-opts page-title preview-target-db-id]}] + [icon-value {:keys [empty-label disabled? initial-open? del-btn? on-chosen icon-props popup-opts button-opts page-title preview-target-db-id default-icon?]}] (let [*trigger-ref (rum/use-ref nil) ;; Optimistic post-commit override. Holds the just-committed ;; icon-value during the ~15ms SharedWorker round-trip between @@ -5789,7 +5818,8 @@ :icon-value normalized-icon-value :page-title page-title :del-btn? del-btn? - :preview-target-db-id preview-target-db-id})))] + :preview-target-db-id preview-target-db-id + :default-icon? default-icon?})))] (hooks/use-effect! (fn [] (when initial-open? diff --git a/src/main/frontend/components/property/value.cljs b/src/main/frontend/components/property/value.cljs index 448b8bf3fd..084cbacd22 100644 --- a/src/main/frontend/components/property/value.cljs +++ b/src/main/frontend/components/property/value.cljs @@ -141,6 +141,68 @@ :del-btn? (some? icon-value) :on-chosen on-chosen!})]))) +(rum/defc default-icon-row < rum/reactive + "Renders the Default Icon property for classes. + Uses a single icon-picker button that opens the universal icon search popup + supporting all icon types (icon, emoji, avatar, text, image)." + [block _editing?] + (let [block (or (model/sub-block (:db/id block)) block) + own-value (:logseq.property.class/default-icon block) + inherited-value (when-not own-value + (some :logseq.property.class/default-icon + (ldb/get-class-extends block))) + ;; Subscribe to parent entities for reactivity when inheriting + _ (when-not own-value + (doseq [parent (ldb/get-class-extends block)] + (model/sub-block (:db/id parent)))) + current-value (or own-value inherited-value) + page-title (:block/title block) + ;; Enrich type-only values for display preview. + ;; Without this, normalize-icon produces {:data {:value nil}} for + ;; type-only maps like {:type :avatar}, causing invisible buttons. + display-value (when current-value + (case (:type current-value) + :avatar (if (get-in current-value [:data :value]) + current-value + (assoc current-value :data + {:value (icon-component/derive-avatar-initials (or page-title ""))})) + :text (if (get-in current-value [:data :value]) + current-value + (assoc current-value :data + {:value (icon-component/derive-initials (or page-title ""))})) + :image (if (get-in current-value [:data :asset-uuid]) + current-value + (assoc current-value :data {:empty? true})) + current-value)) + on-chosen (fn [_e icon] + (if icon + (let [icon-data (cond + (= :text (:type icon)) {:type :text :data (:data icon)} + (= :avatar (:type icon)) {:type :avatar :data (:data icon)} + (= :image (:type icon)) {:type :image :data (:data icon)} + :else (select-keys icon [:type :id :color]))] + (property-handler/set-block-property! + (:db/id block) + :logseq.property.class/default-icon + icon-data)) + (property-handler/remove-block-property! + (:db/id block) + :logseq.property.class/default-icon)))] + [:div.flex.flex-row.items-center.gap-2.w-full.cursor-pointer + {:on-click (fn [^js e] + ;; Delegate clicks on the surrounding row to the icon-picker + ;; button, but skip if the button itself was clicked + (when-not (some-> (.-target e) (.closest "button")) + (when-let [btn (some-> (.-currentTarget e) (.querySelector "button"))] + (.click btn))))} + (icon-component/icon-picker display-value + {:disabled? config/publishing? + :del-btn? (some? current-value) + :on-chosen on-chosen + :page-title page-title + :default-icon? true + :icon-props {:size 20}})])) + (defn select-type? [block property] (let [type (:logseq.property/type property)] @@ -1442,6 +1504,9 @@ (= :logseq.property/icon (:db/ident property)) (icon-row block editing?) + (= :logseq.property.class/default-icon (:db/ident property)) + (default-icon-row block editing?) + (and (= type :number) (not editing?) (not closed-values?)) (single-number-input block property value (:table-view? opts))