feat: legible avatar fallbacks + class default-icon row

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 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-05-07 16:43:10 +02:00
parent b2bf734976
commit d19310bd64
4 changed files with 270 additions and 63 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -57,30 +57,55 @@
(declare normalize-icon derive-initials derive-avatar-initials
<search-wikipedia-image <save-url-asset! open-image-asset-picker!)
(defn- convert-bg-color-to-rgba
"Convert background color to rgba format with opacity ~0.314.
Handles hex colors, CSS variables, and rgba colors."
[backgroundColor]
(cond
;; Hex color - convert to rgba with opacity
(and (string? backgroundColor)
(string/starts-with? backgroundColor "#")
(= (count (string/replace backgroundColor #"^#" "")) 6))
(let [hex (string/replace backgroundColor #"^#" "")
r (js/parseInt (subs hex 0 2) 16)
g (js/parseInt (subs hex 2 4) 16)
b (js/parseInt (subs hex 4 6) 16)]
(str "rgba(" r "," g "," b ",0.314)"))
;; Already rgba - update opacity to 0.314
(and (string? backgroundColor)
(string/includes? backgroundColor "rgba"))
(string/replace backgroundColor #",\s*[\d.]+\)$" ",0.314)")
;; CSS variable - use color-mix to apply opacity
(and (string? backgroundColor)
(string/starts-with? backgroundColor "var("))
(str "color-mix(in srgb, " backgroundColor " 31.4%, transparent)")
;; Default: use as-is (might be a color name or other format)
:else backgroundColor))
(defn- avatar-fallback-style
"Build the inline :style map for an avatar fallback chip (the colored
circle holding initials).
Design intent: a muted, hue-preserving tint behind crisp picked-color
text — so the picked hue shows through on the initials, with the bg
acting as atmospheric framing rather than competing for attention.
- bg gets `muted-tint`: same hue as picked, low chroma, L bisected to
~1.5:1 contrast vs the page surface. Always visibly distinct from
the surface, never as saturated as the picked color itself.
- text uses the picked color directly when it reads against the muted
bg; falls back to `adjust-for-contrast … 3.0` to lift L only when
needed (e.g. dark picks on a dark surface where the picked color
would still be invisible against its own muted tint).
The 3.0 target — rather than 4.5:1 body-text — treats avatar initials
as decorative identifiers (Slack/Linear/GitHub all do similar). With
4.5 the lift triggered for hues like tomato/red whose picked color
sits at ~3.5:1 against their own muted bg, and OKLCh L bisection
toward white desaturates as it climbs — turning vivid red into dusty
pink. 3.0 lets those hues pass through as-is while still safely
lifting truly-dark picks (#1a3d60 etc.) into legibility.
`bg` and `color` may be hex literals OR theme tokens (`var(--rx-...)`),
since the picker offers both a custom hex picker and a Radix-token
palette. Both are resolved to current-theme hex via `colors/->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?

View File

@@ -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))