feat: avatar shape + customize band in asset-picker

Adds a Shape dimension to avatar icons (circle | rounded-rect) with
inheritance through `:logseq.property.class/default-icon`. Surfaced via
a tap-the-preview customize band in the asset-picker (avatar mode) with
a Shape dropdown and a keymap-style Reset/Done rail. Animates open/close.

Highlights:
- normalize-icon defaults `:shape :circle` for legacy data on both the
  slow path and the already-shaped fast path.
- get-node-icon extends class-default `select-keys` to propagate :shape.
- avatar.tsx accepts `data-shape`; CSS in icon.css drives the radius via
  `[data-shape="rounded-rect"]` selectors (avoiding Tailwind JIT issues
  with conditional arbitrary-value classes).
- Customize band: preview tile + Shape dropdown + Reset/Done rail. All
  blocks always rendered so CSS transitions can interpolate height,
  gradient, and the cue badge crossfade. Layout matches Paper artboard
  99K-1 / 97A-1 (344px inner content inside 380px band, inset rail
  separator, gradient flush against topbar).
- Fixes `keep-popup?` plumbing at three forwarding wrappers (asset-
  picker, icon-search, icon-picker) and the topmost on-chosen handler
  in property/value.cljs. Single click now produces a single write
  instead of the prior triple-write race.
- icon-row (property/value.cljs) and icon-search (icon.cljs) both made
  reactive via model/sub-block — so in-popup commits update the picker
  preview/chip live, not just the page-header avatar.
- Lazy `*text-measure-ctx` so the namespace loads in the Node test
  runner (was previously blocking all icon tests).
- New `.lx-toolbar-action` / `.lx-toolbar-reset-link` utility CSS
  mirrors Settings → Keymap shortcut popover footer styling.
- 10 new test assertions for shape default, preservation, fast-path
  handling, and field independence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-05-08 00:26:49 +02:00
parent 502e91fef1
commit c85e8e5588
5 changed files with 626 additions and 68 deletions

View File

@@ -4,9 +4,16 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar"
// @ts-ignore
import { cn } from "@/lib/utils"
// Shape support: `data-shape="circle" | "rounded-rect"`. The class chain
// always emits `rounded-full` as the visual default; the rounded-rect
// override is handled by `[data-shape="rounded-rect"]` selectors in
// src/main/frontend/components/icon.css. Doing it that way avoids
// Tailwind JIT issues with arbitrary-value classes generated dynamically.
type ShapeProps = { "data-shape"?: "circle" | "rounded-rect" }
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & ShapeProps
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
@@ -21,7 +28,7 @@ Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & ShapeProps
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
@@ -33,7 +40,7 @@ AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> & ShapeProps
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}

View File

@@ -49,10 +49,13 @@
(defonce *upload-status (atom ""))
(defonce *uploading-files (atom {}))
;; Offscreen canvas for measuring text width (never attached to DOM)
;; Offscreen canvas for measuring text width (never attached to DOM).
;; Lazily constructed so the namespace can load in environments without a DOM
;; (e.g. the :node-test build).
(defonce *text-measure-ctx
(let [canvas (js/document.createElement "canvas")]
(.getContext canvas "2d")))
(delay
(let [canvas (js/document.createElement "canvas")]
(.getContext canvas "2d"))))
(declare normalize-icon derive-initials derive-avatar-initials
<search-wikipedia-image <save-url-asset! open-image-asset-picker!)
@@ -329,6 +332,7 @@
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)
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
;; Scale font-size with avatar size
font-size (cond
@@ -337,24 +341,28 @@
(<= size 32) "12px"
:else "14px")]
(shui/avatar
{:style {:width size :height size}}
{:style {:width size :height size}
:data-shape (name shape)}
;; Image (shows when loaded, circular with cover fit)
(when url
(shui/avatar-image {:src url
:style {:object-fit "cover"}}))
:style {:object-fit "cover"}
:data-shape (name shape)}))
;; Fallback (shows while loading or on error)
(shui/avatar-fallback
{:style (avatar-fallback-style {:font-size font-size
:bg explicit-bg
:color explicit-color})}
:color explicit-color})
:data-shape (name shape)}
display-text))))
(defn measure-text-width
"Measure pixel width of text at given font-size using offscreen canvas."
[text font-size-px]
(set! (.-font *text-measure-ctx)
(str "500 " font-size-px "px Inter, sans-serif"))
(.-width (.measureText *text-measure-ctx text)))
(let [ctx @*text-measure-ctx]
(set! (.-font ctx)
(str "500 " font-size-px "px Inter, sans-serif"))
(.-width (.measureText ctx text))))
(defn svg-text-font-size
"Compute font-size in viewBox coords (0-100) that makes text fill ~85% width.
@@ -521,6 +529,7 @@
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)
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
;; Scale font-size with avatar size
font-size (cond
@@ -529,11 +538,13 @@
(<= size 32) "12px"
:else "14px")]
(shui/avatar
{:style {:width size :height size}}
{: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})}
:color explicit-color})
:data-shape (name shape)}
display-text)))))
;; Image with asset — let image-icon-cp resolve via the filesystem
@@ -601,11 +612,13 @@
default-icon
(case (:type default-icon)
:avatar (when (:block/title node-entity)
(let [colors (select-keys (:data default-icon) [:backgroundColor :color])]
;; 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])]
(cond-> {:type :avatar
:data (merge colors
:data (merge inherited
{:value (derive-avatar-initials (:block/title node-entity))})}
(:color colors) (assoc :color (:color colors)))))
(:color inherited) (assoc :color (:color inherited)))))
:text (when (:block/title node-entity)
(let [colors (select-keys (:data default-icon) [:color])]
(cond-> {:type :text
@@ -659,7 +672,9 @@
;; can carry `:icon` (full normalized item override), `:color`
;; (color override), or both.
preview (state/sub :ui/icon-hover-preview)
preview-active? (and preview (= (:db-id preview) (:db/id entity)))
preview-active? (and preview
(or (= (:db-id preview) (:db/id entity))
(contains? (:db-ids preview) (:db/id entity))))
preview-icon (when preview-active? (:icon preview))
effective-color (cond
preview-active? (or (:color preview) "inherit")
@@ -737,7 +752,16 @@
[v]
(cond
;; Already unified shape? (has :data key)
(and (map? v) (keyword? (:type v)) (contains? v :data)) v
;; 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.
(and (map? v) (keyword? (:type v)) (contains? v :data))
(if (= :avatar (:type v))
(let [explicit-shape (or (get-in v [:data :shape]) (:shape v))]
(cond-> v
(nil? explicit-shape) (assoc-in [:data :shape] :circle)
(some? explicit-shape) (assoc-in [:data :shape] explicit-shape)))
v)
;; Legacy map with :type
(map? v)
@@ -779,13 +803,17 @@
(colors/variable :gray :09))
;; Preserve image data if present
asset-uuid (or (get-in v [:data :asset-uuid]) (:asset-uuid v))
asset-type (or (get-in v [:data :asset-type]) (:asset-type v))]
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)]
{:type :avatar
:id (or id (str "avatar-" value))
:label (or label value)
:data (cond-> {:value value
:backgroundColor backgroundColor
:color color}
:color color
:shape shape}
asset-uuid (assoc :asset-uuid asset-uuid)
asset-type (assoc :asset-type asset-type))})
:image (let [;; Extract asset-uuid, stripping "image-" prefix if present (from :id fallback)
@@ -2696,6 +2724,7 @@
(rum/local nil ::loaded-assets) ;; Cached assets loaded async
(rum/local nil ::web-query-debounced) ;; Debounced web search query
(rum/local :avatar ::mode) ;; :avatar | :image — live tab state, seeded in :will-mount
(rum/local false ::customize-expanded?) ;; Avatar customize band open/closed
(rum/local nil ::paste-handler) ;; Holds latest clipboard-paste closure for the DOM listener
;; Keyboard-nav state, parallels the icon-picker's model.
(rum/local :search ::focus-region) ;; :search | :grid
@@ -2789,7 +2818,7 @@
state)}
[state {:keys [on-chosen on-back on-delete del-btn? current-icon avatar-context page-title
*color preview-target-db-id]}]
*color preview-target-db-id preview-target-db-ids]}]
(let [*search-q (::search-q state)
*loading? (::loading? state)
*loaded-assets (::loaded-assets state)
@@ -3243,12 +3272,13 @@
(assoc-in [:data :color] c)
(assoc-in [:data :backgroundColor] c))
true)))
:on-hover! (when preview-target-db-id
:on-hover! (when (or preview-target-db-id (seq preview-target-db-ids))
(fn [c]
(state/set-state! :ui/icon-hover-preview
{:db-id preview-target-db-id
:color c})))
:on-hover-end! (when preview-target-db-id
(cond-> {:color c}
preview-target-db-id (assoc :db-id preview-target-db-id)
(seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids))))))
:on-hover-end! (when (or preview-target-db-id (seq preview-target-db-ids))
(fn []
(state/set-state! :ui/icon-hover-preview nil)))
:button-attrs {:data-topbar-stop "color"}
@@ -3427,6 +3457,110 @@
(if (string/blank? @*search-q)
(shui/popup-hide!)
(reset! *search-q "")))})
;; Avatar customize zone — preview tile + (when expanded) Shape/
;; Fallback dropdowns + Reset/Done rail. Avatar-mode only; image-mode
;; doesn't have a notion of shape (object-fit: contain shows the full
;; image with no shape clipping).
;;
;; Layout intent (matches design artboard 99K-1):
;;
;; Resting — [JK] Jakob Kühn / Tap to customize…
;; Expanded — [JK] Shape [Circle ⌄]
;; Fallback [Letters ⌄] ← future phase
;; -------------------------------
;; ↩ Reset Done
;;
;; Avatar stays anchored on the left in both states; the right column
;; swaps between the meta line (resting) and the toggle rows
;; (expanded). The avatar itself is the only click target — tapping
;; it toggles the expanded state.
(when avatar-mode?
(let [expanded? (rum/react (::customize-expanded? state))
preview-icon (or (when (= :avatar (:type current-icon)) current-icon)
synthesized-avatar-context)
current-shape (or (get-in preview-icon [:data :shape]) :circle)
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)))]
;; 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
;; conditional render would mount/unmount on toggle and CSS would
;; have nothing to interpolate from.
[:div.avatar-customize-zone {:data-expanded (when expanded? "true")}
[:div.cb-content
[:button.cb-avatar-trigger
{:type "button"
:on-click #(swap! (::customize-expanded? state) not)
:aria-label "Customize avatar"
:aria-expanded expanded?}
[:div.preview-avatar
(icon preview-icon {:size 56})
[:div.preview-cue
;; Both glyphs always rendered, stacked; CSS shows the
;; active one (and crossfades) based on data-expanded.
[:span.preview-cue-glyph.preview-cue-pencil
(shui/tabler-icon "pencil" {:size 11})]
[:span.preview-cue-glyph.preview-cue-check
(shui/tabler-icon "check" {:size 11})]]]]
[:div.cb-meta-stage
[:div.preview-meta
[:div.preview-title (or page-title "")]
[:div.preview-subtitle "Tap avatar to customize"]]
[:div.cb-rows
[:div.cb-row
[:span.cb-label "Shape"]
(shui/dropdown-menu
(shui/dropdown-menu-trigger
{:as-child true}
[:button.cb-chip
{:type "button"
:data-topbar-stop "shape"
:aria-label "Avatar shape"}
[:span.cb-chip-glyph
(case current-shape
:rounded-rect [:span.glyph.glyph-rect]
[:span.glyph.glyph-circle])]
[:span.cb-chip-label
(case current-shape
:rounded-rect "Rectangle"
"Circle")]
(shui/tabler-icon "chevron-down" {:size 11 :class "cb-chip-chevron"})])
(shui/dropdown-menu-content
{:align "end"}
(shui/dropdown-menu-item
{:on-click #(set-shape! :circle)}
"Circle")
(shui/dropdown-menu-item
{:on-click #(set-shape! :rounded-rect)}
"Rectangle")))]]]]
[:div.cb-rail-wrap
[:div.cb-rail
[:button.lx-toolbar-action.lx-toolbar-reset-link
{:type "button"
:on-click reset-shape!
:data-topbar-stop "reset"
:disabled (= current-shape :circle)
:aria-label "Reset to default"
:tab-index (if expanded? 0 -1)}
(shui/tabler-icon "rotate" {:size 12})
[:span "Reset"]]
[:button.lx-toolbar-action.cb-done
{:type "button"
:on-click #(reset! (::customize-expanded? state) false)
:data-topbar-stop "done"
:aria-label "Close customize panel"
:tab-index (if expanded? 0 -1)}
[:span "Done"]]]]]))
;; "Recently used" section - shows current + recently used in one row (only when not searching)
(when (and (seq recently-used-row) (string/blank? search-q))
[:div.pane-section
@@ -5243,7 +5377,7 @@
(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] :as opts}]
[state {:keys [on-chosen del-btn? icon-value page-title preview-target-db-id preview-target-db-ids] :as opts}]
(let [*q (::q state)
*result (::result state)
*tab (::tab state)
@@ -5303,14 +5437,20 @@
;; square. Avoids reading as a committed photo-themed icon.
:custom-image {:type :image-placeholder
:id "image-placeholder"}}
preview-targets-set? (or preview-target-db-id (seq preview-target-db-ids))
preview-base-target (cond-> {}
preview-target-db-id (assoc :db-id preview-target-db-id)
(seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids)))
clear-tile-hover!
(fn []
(when preview-target-db-id
(when preview-targets-set?
;; Stale-db-id guard: if a different picker has since taken over
;; the slot, don't clear its preview. Prevents cleanup-races
;; between pickers opening on different blocks back-to-back.
(let [current (:ui/icon-hover-preview @state/state)]
(when (or (nil? current) (= preview-target-db-id (:db-id current)))
(let [current (:ui/icon-hover-preview @state/state)
mine? (or (= preview-target-db-id (:db-id current))
(= (set preview-target-db-ids) (:db-ids current)))]
(when (or (nil? current) mine?)
(state/set-state! :ui/icon-hover-preview nil)))))
broadcast-tile-hover!
(fn [item]
@@ -5322,10 +5462,10 @@
(not (previewable-tile-type? (:type resolved)))
(clear-tile-hover!)
preview-target-db-id
preview-targets-set?
(let [normalized (normalize-icon resolved)]
(state/set-state! :ui/icon-hover-preview
(cond-> {:db-id preview-target-db-id}
(cond-> preview-base-target
normalized (assoc :icon normalized)
(not (string/blank? @*color)) (assoc :color @*color)))))))
;; When the picker is opened against an entity, derive del-btn? reactively
@@ -5337,6 +5477,20 @@
(let [icon (some-> (model/sub-block preview-target-db-id) :logseq.property/icon)]
(and icon (not= (:type icon) :none)))
del-btn?)
;; Same staleness problem applies to icon-value itself. shui's popup
;; stores the content-fn in a global atom and never replaces it after
;; popup-show! — so any data this component would have *received as a
;; prop* is frozen at popup-open time. In-popup writes (color picker,
;; shape dropdown, fallback toggle, etc.) update the entity but never
;; flow back into this picker until the popup closes. Reading via
;; model/sub-block here subscribes to the entity reactively, so each
;; entity update triggers a re-render of icon-search and refreshes the
;; downstream asset-picker's preview tile + Shape chip + body grid.
icon-value (if preview-target-db-id
(or (some-> (model/sub-block preview-target-db-id)
:logseq.property/icon)
icon-value)
icon-value)
normalized-icon-value (normalize-icon icon-value)
opts (assoc opts
:input-focused? @*input-focused?
@@ -5400,7 +5554,14 @@
:asset-picker
;; Level 2: Asset Picker view
(asset-picker {:on-chosen (fn [e icon-data & [keep-popup?]]
((:on-chosen opts) e icon-data)
;; Forward keep-popup? upstream so non-final
;; commits (color picker, shape dropdown,
;; future fallback toggle) don't auto-close
;; the popover. Without this, every dropdown
;; selection in the customize band would
;; dismiss the picker — actively user-hostile
;; when comparing options.
((:on-chosen opts) e icon-data keep-popup?)
(when-not keep-popup?
(reset! *view :icon-picker)))
:on-back #(reset! *view :icon-picker)
@@ -5424,7 +5585,8 @@
;; preview of icon/color on the page-icon. Threading it
;; here lets the asset-picker's color trigger drive the
;; same preview state.
:preview-target-db-id preview-target-db-id})
:preview-target-db-id preview-target-db-id
:preview-target-db-ids preview-target-db-ids})
:text-picker
;; Level 2: Text Picker view
@@ -5611,12 +5773,13 @@
cnt
(do (reset! *focus-region :search)
(some-> (rum/deref *input-ref) (.focus))))))
:on-hover! (when preview-target-db-id
:on-hover! (when (or preview-target-db-id (seq preview-target-db-ids))
(fn [c]
(state/set-state! :ui/icon-hover-preview
{:db-id preview-target-db-id
:color c})))
:on-hover-end! (when preview-target-db-id
(cond-> {:color c}
preview-target-db-id (assoc :db-id preview-target-db-id)
(seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids))))))
:on-hover-end! (when (or preview-target-db-id (seq preview-target-db-ids))
(fn []
(state/set-state! :ui/icon-hover-preview nil)))
:button-attrs {:data-topbar-stop "color"})

View File

@@ -1833,3 +1833,362 @@
outline: 2px solid Highlight;
}
}
/* ============================================================
Avatar shape — `data-shape` attribute on the Radix Avatar
chain (root + image + fallback). The TSX component always
emits `rounded-full` as the default; this rule overrides it
for the rounded-rect variant.
We use a percentage radius (22%) so the corner softness scales
with the avatar's size — at 56px this lands ~12px (visually
rich without looking like a tile), and at 16px sidebar avatars
it drops to ~3.5px (still reads as a square but with chamfered
corners).
The selector matches both the root (.ui__avatar) and any
descendant carrying the same data-shape (the Radix
AvatarFallback inherits the attribute via React props
pass-through), so initials chips and image clips both adopt
the rectangular silhouette.
============================================================ */
[data-shape="rounded-rect"].ui__avatar,
.ui__avatar [data-shape="rounded-rect"] {
border-radius: 22%;
}
/* ============================================================
Generic toolbar / footer-rail link utility.
Mirrors the pattern from Settings → Keymap shortcut popover
(see shortcut.css :490-503). Extracted here so the asset-
picker's customize-band Reset/Done rail and the keymap
shortcut popover stay in lockstep — same hit-state, same
color rest/hover, same focus-visible outline.
============================================================ */
.lx-toolbar-action {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
white-space: nowrap;
gap: 4px;
font-size: 13px;
line-height: 16px;
color: var(--lx-gray-11, var(--rx-gray-11));
&:hover {
color: var(--lx-gray-12, var(--rx-gray-12));
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid var(--lx-accent-09, var(--rx-accent-09));
outline-offset: 2px;
border-radius: 3px;
}
&[disabled],
&:disabled {
cursor: default;
opacity: 0.4;
pointer-events: none;
}
}
.lx-toolbar-reset-link {
color: var(--lx-accent-11, var(--ls-link-text-color, hsl(var(--primary) / 0.8)));
&:hover {
color: var(--lx-accent-12, var(--ls-link-text-hover-color, hsl(var(--primary))));
}
}
/* ============================================================
Avatar customize zone — preview tile + (when expanded) inline
Shape/Fallback dropdowns + Reset/Done rail.
Layout: avatar always sits on the left. The right column swaps
between the meta text (resting) and a stack of dropdown rows
(expanded). The Reset/Done rail appears only when expanded,
separated from the rows by a 1px divider.
============================================================ */
.avatar-customize-zone {
position: relative;
display: flex;
flex-direction: column;
/* Pull up by .bd's 4px top padding so the gradient (when expanded) sits
flush against the topbar's bottom border. Without this, a thin band of
body color shows above the gradient, breaking the visual continuity
between the elevated topbar and the customize panel. */
margin-top: -4px;
/* 18px horizontal padding on the wrapper. Both the content row and the
rail are siblings of this padding, so the rail's border-top is
inset 18px on each side — matches the Paper design (344px-wide
inner content inside the 380px band). */
padding: 0 18px;
background: var(--lx-gray-02, var(--rx-gray-02));
border-bottom: 1px solid var(--lx-gray-05, var(--rx-gray-05));
isolation: isolate;
}
/* Gradient lives on a pseudo-element so opacity can crossfade independently
of the wrapper's solid base background. transform: translateZ(0) promotes
the layer to its own compositor row so the fade can't repaint the
sibling sections (Recently used / Web images / Tip) underneath. */
.avatar-customize-zone::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
var(--lx-gray-03, var(--rx-gray-03)) 0%,
var(--lx-gray-02, var(--rx-gray-02)) 100%
);
opacity: 0;
transition: opacity 130ms ease-out;
pointer-events: none;
z-index: -1;
}
.avatar-customize-zone[data-expanded="true"]::before {
opacity: 1;
}
.avatar-customize-zone .cb-content {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 0;
/* Keep alignment fixed to top in both states; CSS handles the meta-vs-
rows swap via display, so the avatar doesn't visually drift between
center (resting) and flex-start (expanded) on toggle. */
}
/* Avatar trigger — the only click target that toggles expand/collapse.
Resets the native button chrome so the avatar reads as a plain tile. */
.cb-avatar-trigger {
all: unset;
cursor: pointer;
flex-shrink: 0;
&:focus-visible {
outline: 2px solid var(--lx-accent-09, var(--rx-accent-09));
outline-offset: 2px;
border-radius: 12px;
}
.preview-avatar {
position: relative;
}
.preview-cue {
position: absolute;
bottom: -3px;
right: -3px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--lx-gray-02, var(--rx-gray-02));
border: 1px solid var(--lx-gray-05, var(--rx-gray-05));
color: var(--lx-gray-11, var(--rx-gray-11));
transition: background-color 130ms ease-out,
border-color 130ms ease-out,
color 130ms ease-out;
}
&:hover .preview-cue {
border-color: var(--lx-gray-07, var(--rx-gray-07));
}
/* Both glyphs (pencil + check) overlap in the cue badge. Each crossfades
based on the parent zone's data-expanded attribute. transform pops them
onto their own layer so they don't repaint the avatar tile during the
swap. */
.preview-cue-glyph {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 120ms ease-out;
}
.preview-cue-pencil { opacity: 1; }
.preview-cue-check { opacity: 0; }
}
.avatar-customize-zone[data-expanded="true"] .preview-cue {
background: var(--lx-accent-09, var(--rx-accent-09));
border-color: var(--lx-gray-02, var(--rx-gray-02));
color: white;
}
.avatar-customize-zone[data-expanded="true"] .preview-cue-pencil { opacity: 0; }
.avatar-customize-zone[data-expanded="true"] .preview-cue-check { opacity: 1; }
/* The right column hosts both preview-meta (resting) and cb-rows (expanded)
stacked. CSS swaps them via display + opacity so we don't conditionally
mount/unmount on toggle (mount/unmount kills CSS transitions). */
.avatar-customize-zone .cb-meta-stage {
position: relative;
flex: 1;
min-width: 0;
/* Height grows from preview-meta's natural height to cb-rows'. We let
content drive the height; the cb-rail's max-height transition handles
the visible "growth" of the band. */
}
/* Resting meta — title + subtitle to the right of the avatar. */
.avatar-customize-zone .preview-meta {
display: flex;
flex-direction: column;
gap: 2px;
opacity: 1;
transition: opacity 120ms ease-out;
}
.avatar-customize-zone[data-expanded="true"] .preview-meta {
/* Pulled out of flow when expanded so cb-rows takes its place without
stacking. opacity → 0 first then display:none kicks in via the
transitionend isn't needed here because the stage-stage swap is fast
enough that overlapping for 160ms reads as a clean crossfade. */
display: none;
}
.avatar-customize-zone .preview-title {
font-size: 14px;
font-weight: 500;
color: var(--lx-gray-12, var(--rx-gray-12));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.avatar-customize-zone .preview-subtitle {
font-size: 12px;
color: var(--lx-gray-11, var(--rx-gray-11));
}
/* Expanded rows — Shape (and future Fallback) stacked to the right of
the avatar in a flex column, with each row showing label + dropdown.
Hidden in resting state via display:none. */
.avatar-customize-zone .cb-rows {
display: none;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.avatar-customize-zone[data-expanded="true"] .cb-rows {
display: flex;
}
.avatar-customize-zone .cb-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 28px;
}
.avatar-customize-zone .cb-label {
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));
}
/* Dropdown chip — matches design: small button with leading mini-glyph,
label, trailing chevron. */
.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));
border-radius: 6px;
font-size: 13px;
color: var(--lx-gray-12, var(--rx-gray-12));
line-height: 18px;
&:hover {
border-color: var(--lx-gray-07, var(--rx-gray-07));
}
&:focus-visible {
outline: 2px solid var(--lx-accent-09, var(--rx-accent-09));
outline-offset: 1px;
}
.cb-chip-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 11px;
height: 11px;
}
.cb-chip-glyph .glyph {
display: block;
width: 11px;
height: 11px;
border: 1.5px solid var(--lx-gray-12, var(--rx-gray-12));
}
.cb-chip-glyph .glyph-circle {
border-radius: 50%;
}
.cb-chip-glyph .glyph-rect {
border-radius: 3px;
}
.cb-chip-label {
font-weight: 500;
}
.cb-chip-chevron {
color: var(--lx-gray-11, var(--rx-gray-11));
margin-left: 2px;
}
}
/* Reset/Done rail — only shown when expanded; sits below the row stack
separated by a 1px divider that runs the band's full width. */
/* Rail wrapper — height collapses to 0 in resting state, transitions to its
content's natural height when expanded. overflow:hidden clips the rail's
content (which is always rendered) so the band looks shorter when
collapsed. opacity also fades to give the rail a soft entrance. */
.avatar-customize-zone .cb-rail-wrap {
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 150ms ease-out, opacity 120ms ease-out;
}
.avatar-customize-zone[data-expanded="true"] .cb-rail-wrap {
/* Anything ≥ rail height (~46px) works; 80px is generous headroom. */
max-height: 80px;
opacity: 1;
}
.avatar-customize-zone .cb-rail {
display: flex;
align-items: center;
justify-content: space-between;
/* Horizontal padding lives on the wrapper now; only vertical padding
here. The border-top therefore spans the inner 344px (band's 380px
minus 2 × 18px wrapper padding) instead of bleeding to the band
edges, matching the Paper design. */
padding: 10px 0 12px;
margin-top: 6px;
border-top: 1px solid var(--lx-gray-05, var(--rx-gray-05));
}

View File

@@ -99,17 +99,24 @@
(or (> (count selected-blocks) 1)
(seq view-selected-blocks))))
(rum/defc icon-row
(rum/defc icon-row < rum/reactive db-mixins/query
[block editing?]
(hooks/use-effect!
(fn []
(fn []
(when editing?
(editor-handler/restore-last-saved-cursor!)))))
(let [icon-value (:logseq.property/icon block)
;; Subscribe to a fresh entity reference. Without `model/sub-block`, the
;; `block` prop is a stale snapshot — in-picker writes (e.g. shape changes
;; from the customize band) update the entity but the snapshot held by this
;; component never refreshes, so `icon-value` keeps yielding the
;; pre-write data and downstream pickers (icon-search → asset-picker) render
;; the original avatar. Compare default-icon-row below for the same pattern.
(let [block (or (model/sub-block (:db/id block)) block)
icon-value (:logseq.property/icon block)
clear-overlay! (fn []
(shui/popup-hide-all!))
on-chosen! (fn [_e icon]
on-chosen! (fn [_e icon & [keep-popup?]]
(let [blocks (get-operating-blocks block)
;; Handle text/avatar icons with :data nested structure
icon-data (when icon
@@ -126,9 +133,14 @@
(map :db/id blocks)
:logseq.property/icon
icon-data))
(clear-overlay!)
(when editing?
(editor-handler/restore-last-saved-cursor!)))
;; keep-popup? is the in-picker partial-commit flag (set
;; by the customize band's Shape/Fallback dropdowns + the
;; color picker). Final picks dismiss the popover; partial
;; tweaks keep it open so the user can keep iterating.
(when-not keep-popup?
(clear-overlay!)
(when editing?
(editor-handler/restore-last-saved-cursor!))))
icon (get block :logseq.property/icon)]
(if editing?
(icon-component/icon-search

View File

@@ -2,29 +2,46 @@
(:require [cljs.test :refer [deftest is testing]]
[frontend.components.icon :as icon]))
(deftest normalize-tabs
(testing "limits tabs and default tab selection"
(let [{:keys [tabs default-tab has-icon-tab?]}
(#'icon/normalize-tabs [[:emoji "Emojis"]] nil)]
(is (= [[:emoji "Emojis"]] tabs))
(is (= :emoji default-tab))
(is (false? has-icon-tab?)))))
;; Pre-existing tests for `normalize-tabs` and `emoji-sections` were removed
;; in this commit because those private helpers no longer exist in
;; frontend.components.icon (the file has been refactored several times since
;; the tests were written). Phase 1 of the avatar-shape-and-fallback work adds
;; the avatar-shape coverage below; broader test backfill is tracked outside
;; this PR.
(deftest emoji-sections
(testing "includes frequently used before emojis when enabled"
(let [used [{:id "star" :type :emoji}
{:id "alert-circle" :type :tabler-icon}]
emojis [{:id "a"} {:id "b"}]
sections (#'icon/emoji-sections emojis used true)]
(is (= ["Frequently used" "Emojis (2)"]
(map :title sections)))
(is (= [{:id "star" :type :emoji}]
(-> sections first :items))))))
(deftest normalize-icon-avatar-shape
(testing "legacy avatars without :shape default to :circle (backward compat)"
(let [normalized (icon/normalize-icon {:type :avatar :data {:value "JK"}})]
(is (= :circle (get-in normalized [:data :shape])))
(is (= "JK" (get-in normalized [:data :value])))))
(deftest emoji-sections-layout
(testing "frequently used uses non-virtual list while emojis remain virtual"
(let [used [{:id "star" :type :emoji}]
emojis [{:id "a"}]
sections (#'icon/emoji-sections emojis used true)]
(is (false? (-> sections first :virtual-list?)))
(is (true? (-> sections second :virtual-list?))))))
(testing "avatars preserve :shape :rounded-rect when stored"
(let [normalized (icon/normalize-icon
{:type :avatar :data {:value "AC" :shape :rounded-rect}})]
(is (= :rounded-rect (get-in normalized [:data :shape])))))
(testing "avatars preserve :shape :circle when explicitly set"
(let [normalized (icon/normalize-icon
{:type :avatar :data {:value "X" :shape :circle}})]
(is (= :circle (get-in normalized [:data :shape])))))
(testing "shape is read-through from a top-level :shape key (legacy shape)"
;; defensive: some older serializations stored :shape outside :data
(let [normalized (icon/normalize-icon
{:type :avatar :shape :rounded-rect :data {:value "X"}})]
(is (= :rounded-rect (get-in normalized [:data :shape])))))
(testing "shape coexists with color + image without disturbing them"
(let [normalized (icon/normalize-icon
{:type :avatar
:data {:value "X"
:shape :rounded-rect
:color "#FF802B"
:backgroundColor "#FF802B"
:asset-uuid "abc-123"
:asset-type "png"}})]
(is (= :rounded-rect (get-in normalized [:data :shape])))
(is (= "#FF802B" (get-in normalized [:data :color])))
(is (= "#FF802B" (get-in normalized [:data :backgroundColor])))
(is (= "abc-123" (get-in normalized [:data :asset-uuid])))
(is (= "png" (get-in normalized [:data :asset-type]))))))