nandi/frqpublic Fork 0
ad3a77d
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Make a reaction small, and the picker a panel rather than a page

Two things were too big. The pills under a message were caption-sized, which
made a reaction read as a second thing said rather than a footnote on what was
said; they are 14pt glyphs now, on a pill laid out from that.

And the picker took the whole window, so choosing a reaction hid the message
being reacted to — which is the one thing that has to stay in sight. It is a
panel over the compose bar now: the message list gives up exactly the room the
panel needs and keeps the rest. That room is measured rather than fixed, since
the popular row is two rows deep and a group is as deep as the grid allows,
and a fixed reserve would leave a hole above the short one.

The groups go by their first word to keep them to two rows at panel width, the
grid is nine across, and while the picker is open the jump-to-present row
stands down: the panel is holding the compose bar's place, and a gap under it
would just be a gap.

Opening another buffer closes the picker — it belongs to the message it was
opened on, and carrying it across would offer to react to something no longer
on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T12:42:37-07:00 Browse files
ad3a77d parent: 18c5ccd
modified src/frq/app.jolt +77 -35
@@ -254,6 +254,11 @@
254254 ;; The message it answers is older than this buffer goes.
255255 [:dim-label {:label "↩ replying to an earlier message"}])])
256256
257+(def ^:private pill-size
258+ "How big a reaction is under a message. Small: it is a footnote on what was
259+ said, not a second thing said."
260+ 14)
261+
257262 (defn- react-button
258263 "The way to put an emoji on a message, beside the way to answer it.
259264
@@ -275,6 +280,7 @@
275280 (for [emoji (sort (keys reactions))]
276281 [:reaction {:key emoji
277282 :emoji emoji
283+ :size pill-size
278284 :count (count (get reactions emoji))
279285 :mine (s/my-reaction? m emoji)
280286 :on-click #(s/toggle-reaction! channel m emoji)}])]))
@@ -414,51 +420,77 @@
414420 [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]]))
415421
416422 (def ^:private picker-columns
417- "Emoji to a row. Narrow enough that the grid still fits a phone-width
418- window, which is the width this app is laid out for."
419- 8)
423+ "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
424+ phone-width window, which is the width this app is laid out for."
425+ 9)
426+
427+(def ^:private picker-size 20)
428+
429+(def ^:private picker-row-height 48)
430+(def ^:private picker-grid-max 170)
420431
421-(defn emoji-picker-screen
422- "The whole set to choose from, searchable, for one message.
432+(defn- picker-reserve
433+ "How much room the picker panel wants under the message list.
423434
424- A screen rather than something floating over the conversation: the tree
425- backend paints in one layer and has no z-order to put a panel on top with —
426- the same reason the lightbox is a screen. It opens on the emoji people
427- actually react with; the groups and the search box are for the rest."
435+ Measured rather than fixed: a panel is as tall as the grid it is showing, and
436+ a reserve that assumed the tallest would leave a hole above a short one — the
437+ popular row is two rows deep, a group is as deep as the grid allows."
438+ []
439+ (let [n (min s/picker-limit (count (s/picker-emoji)))
440+ rows (quot (+ n picker-columns -1) picker-columns)
441+ searching? (seq (str/trim @s/emoji-search))]
442+ (+ 82 ; the separator and compose bar
443+ 44 ; the search box
444+ (if searching? 0 88) ; the two rows of groups
445+ (min picker-grid-max (* picker-row-height rows))
446+ ;; The spacing between the panel's own rows, which is small and adds up
447+ ;; — a reserve that came up short would put the compose bar under the
448+ ;; bottom of the window.
449+ 60)))
450+
451+(defn emoji-picker
452+ "The whole set to choose from, over the compose bar, for one message.
453+
454+ A panel under the conversation rather than a screen of its own: choosing a
455+ reaction is something done *to* a message, and a picker that hid the message
456+ would be asking about something no longer on screen. It takes the bottom of
457+ the window; the list above it gives up that much room and keeps the rest.
458+
459+ It opens on the emoji people actually react with; the groups and the search
460+ box are for the rest."
428461 []
429462 (let [shown (s/picker-emoji)
430463 over (max 0 (- (count shown) s/picker-limit))
431- shown (vec (take s/picker-limit shown))
432- rows (partition-all picker-columns shown)
464+ rows (partition-all picker-columns (take s/picker-limit shown))
433465 searching? (seq (str/trim @s/emoji-search))]
434- [:vbox {:spacing 8 :margin 12}
435- [:hbox {:spacing 8}
436- [:button {:label "← Back" :on-click s/close-picker!}]
437- [:title {:label "React"}]]
438- [:entry {:text @s/emoji-search
439- :width-request 300
440- :placeholder "Search emoji"
441- :on-change #(reset! s/emoji-search %)}]
442- ;; The groups are what the search box is not: a way in for someone who
443- ;; does not have a word for what they want. Dimmed out while a search is
444- ;; running, since what is on screen then is the search's answer.
466+ [:vbox {:key :picker :spacing 4}
467+ [:hbox {:spacing 6}
468+ [:entry {:text @s/emoji-search
469+ :width-request 240
470+ :placeholder "Search emoji"
471+ :on-change #(reset! s/emoji-search %)}]
472+ [:button {:label "✕" :on-click s/close-picker!}]]
473+ ;; The groups are what the search box is not: a way in for someone who has
474+ ;; no word for what they want. Their first word is enough to tell them
475+ ;; apart, and is what keeps them to two rows. They give way to the
476+ ;; search's own answer while something is typed.
445477 [:vbox {:key :groups :spacing 4}
446478 (when-not searching?
447- (for [[i row] (map-indexed vector (partition-all 3 (cons nil s/emoji-groups)))]
479+ (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
448480 [:hbox {:key i :spacing 4}
449481 (for [g row]
450482 [:button {:key (or g "popular")
451- :label (or g "Popular")
483+ :label (if g (first (str/split g #" ")) "Popular")
452484 :kind (if (= g @s/emoji-group) :primary :normal)
453485 :on-click #(reset! s/emoji-group g)}])]))]
454- [:separator {}]
455- [:scroll {:orientation :vertical :reserve 40}
486+ [:scroll {:orientation :vertical :max-height picker-grid-max}
456487 (if (seq rows)
457488 (for [[i row] (map-indexed vector rows)]
458- [:hbox {:key i :spacing 4}
489+ [:hbox {:key i :spacing 2}
459490 (for [[glyph] row]
460491 [:reaction {:key glyph
461492 :emoji glyph
493+ :size picker-size
462494 :count 0
463495 :on-click #(s/react-from-picker! glyph)}])])
464496 [:dim-label {:label "No emoji by that name."}])]
@@ -490,7 +522,12 @@
490522 ;; move the compose bar, and should: it appears because the
491523 ;; reader asked to answer something, unlike the jump button,
492524 ;; which appears on its own and must not shift what is under it.
493- :reserve (if @s/replying-to 150 116)
525+ ;; And the picker's, which takes the bottom of the window
526+ ;; while it is open — the list gives up that room rather than
527+ ;; being painted under it.
528+ :reserve (cond @s/reacting (picker-reserve)
529+ @s/replying-to 150
530+ :else 116)
494531 :stick-to-bottom true
495532 :scroll-to-bottom @s/jump-tick
496533 :on-change #(reset! s/at-present? (= "end" %))}
@@ -501,10 +538,17 @@
501538 ;; back to the present belongs next to the thing that puts you there.
502539 ;; The row keeps its height whether or not the button is in it, so the
503540 ;; compose bar below stays where the reader last saw it.
541+ ;; Choosing an emoji is the only thing being done while the picker is
542+ ;; open, so it takes the row the way back to the present would have had.
543+ [:vbox {:key :picker-panel}
544+ (when @s/reacting [emoji-picker])]
504545 [:vbox {:key :jump}
505- (if @s/at-present?
506- [:spacer {:size 34}]
507- [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])]
546+ (cond
547+ ;; Not even the spacer while the picker is open: the panel is what is
548+ ;; holding the compose bar's place, and a gap under it is just a gap.
549+ @s/reacting nil
550+ @s/at-present? [:spacer {:size 34}]
551+ :else [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])]
508552 [:separator {}]
509553 ;; What the draft is answering, directly above where it is being typed.
510554 [:vbox {:key :replying}
@@ -577,10 +621,8 @@
577621 ;; ---------------------------------------------------------------- shell
578622
579623 (defn app []
580- (cond
581- @s/lightbox [lightbox-screen]
582- @s/reacting [emoji-picker-screen]
583- :else
624+ (if @s/lightbox
625+ [lightbox-screen]
584626 (case @s/screen
585627 :connect [connect-screen]
586628 :chat [chat-screen]
@@ -254,6 +254,11 @@
254 ;; The message it answers is older than this buffer goes.254 ;; The message it answers is older than this buffer goes.
255 [:dim-label {:label "↩ replying to an earlier message"}])])255 [:dim-label {:label "↩ replying to an earlier message"}])])
256 256
257+(def ^:private pill-size
258+ "How big a reaction is under a message. Small: it is a footnote on what was
259+ said, not a second thing said."
260+ 14)
261+
257 (defn- react-button262 (defn- react-button
258 "The way to put an emoji on a message, beside the way to answer it.263 "The way to put an emoji on a message, beside the way to answer it.
259 264
@@ -275,6 +280,7 @@
275 (for [emoji (sort (keys reactions))]280 (for [emoji (sort (keys reactions))]
276 [:reaction {:key emoji281 [:reaction {:key emoji
277 :emoji emoji282 :emoji emoji
283+ :size pill-size
278 :count (count (get reactions emoji))284 :count (count (get reactions emoji))
279 :mine (s/my-reaction? m emoji)285 :mine (s/my-reaction? m emoji)
280 :on-click #(s/toggle-reaction! channel m emoji)}])]))286 :on-click #(s/toggle-reaction! channel m emoji)}])]))
@@ -414,51 +420,77 @@
414 [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]]))420 [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]]))
415 421
416 (def ^:private picker-columns422 (def ^:private picker-columns
417- "Emoji to a row. Narrow enough that the grid still fits a phone-width423+ "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
418- window, which is the width this app is laid out for."424+ phone-width window, which is the width this app is laid out for."
419- 8)425+ 9)
426+
427+(def ^:private picker-size 20)
428+
429+(def ^:private picker-row-height 48)
430+(def ^:private picker-grid-max 170)
420 431
421-(defn emoji-picker-screen432+(defn- picker-reserve
422- "The whole set to choose from, searchable, for one message.433+ "How much room the picker panel wants under the message list.
423 434
424- A screen rather than something floating over the conversation: the tree435+ Measured rather than fixed: a panel is as tall as the grid it is showing, and
425- backend paints in one layer and has no z-order to put a panel on top with —436+ a reserve that assumed the tallest would leave a hole above a short one — the
426- the same reason the lightbox is a screen. It opens on the emoji people437+ popular row is two rows deep, a group is as deep as the grid allows."
427- actually react with; the groups and the search box are for the rest."438+ []
439+ (let [n (min s/picker-limit (count (s/picker-emoji)))
440+ rows (quot (+ n picker-columns -1) picker-columns)
441+ searching? (seq (str/trim @s/emoji-search))]
442+ (+ 82 ; the separator and compose bar
443+ 44 ; the search box
444+ (if searching? 0 88) ; the two rows of groups
445+ (min picker-grid-max (* picker-row-height rows))
446+ ;; The spacing between the panel's own rows, which is small and adds up
447+ ;; — a reserve that came up short would put the compose bar under the
448+ ;; bottom of the window.
449+ 60)))
450+
451+(defn emoji-picker
452+ "The whole set to choose from, over the compose bar, for one message.
453+
454+ A panel under the conversation rather than a screen of its own: choosing a
455+ reaction is something done *to* a message, and a picker that hid the message
456+ would be asking about something no longer on screen. It takes the bottom of
457+ the window; the list above it gives up that much room and keeps the rest.
458+
459+ It opens on the emoji people actually react with; the groups and the search
460+ box are for the rest."
428 []461 []
429 (let [shown (s/picker-emoji)462 (let [shown (s/picker-emoji)
430 over (max 0 (- (count shown) s/picker-limit))463 over (max 0 (- (count shown) s/picker-limit))
431- shown (vec (take s/picker-limit shown))464+ rows (partition-all picker-columns (take s/picker-limit shown))
432- rows (partition-all picker-columns shown)
433 searching? (seq (str/trim @s/emoji-search))]465 searching? (seq (str/trim @s/emoji-search))]
434- [:vbox {:spacing 8 :margin 12}466+ [:vbox {:key :picker :spacing 4}
435- [:hbox {:spacing 8}467+ [:hbox {:spacing 6}
436- [:button {:label "← Back" :on-click s/close-picker!}]468+ [:entry {:text @s/emoji-search
437- [:title {:label "React"}]]469+ :width-request 240
438- [:entry {:text @s/emoji-search470+ :placeholder "Search emoji"
439- :width-request 300471+ :on-change #(reset! s/emoji-search %)}]
440- :placeholder "Search emoji"472+ [:button {:label "✕" :on-click s/close-picker!}]]
441- :on-change #(reset! s/emoji-search %)}]473+ ;; The groups are what the search box is not: a way in for someone who has
442- ;; The groups are what the search box is not: a way in for someone who474+ ;; no word for what they want. Their first word is enough to tell them
443- ;; does not have a word for what they want. Dimmed out while a search is475+ ;; apart, and is what keeps them to two rows. They give way to the
444- ;; running, since what is on screen then is the search's answer.476+ ;; search's own answer while something is typed.
445 [:vbox {:key :groups :spacing 4}477 [:vbox {:key :groups :spacing 4}
446 (when-not searching?478 (when-not searching?
447- (for [[i row] (map-indexed vector (partition-all 3 (cons nil s/emoji-groups)))]479+ (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
448 [:hbox {:key i :spacing 4}480 [:hbox {:key i :spacing 4}
449 (for [g row]481 (for [g row]
450 [:button {:key (or g "popular")482 [:button {:key (or g "popular")
451- :label (or g "Popular")483+ :label (if g (first (str/split g #" ")) "Popular")
452 :kind (if (= g @s/emoji-group) :primary :normal)484 :kind (if (= g @s/emoji-group) :primary :normal)
453 :on-click #(reset! s/emoji-group g)}])]))]485 :on-click #(reset! s/emoji-group g)}])]))]
454- [:separator {}]486+ [:scroll {:orientation :vertical :max-height picker-grid-max}
455- [:scroll {:orientation :vertical :reserve 40}
456 (if (seq rows)487 (if (seq rows)
457 (for [[i row] (map-indexed vector rows)]488 (for [[i row] (map-indexed vector rows)]
458- [:hbox {:key i :spacing 4}489+ [:hbox {:key i :spacing 2}
459 (for [[glyph] row]490 (for [[glyph] row]
460 [:reaction {:key glyph491 [:reaction {:key glyph
461 :emoji glyph492 :emoji glyph
493+ :size picker-size
462 :count 0494 :count 0
463 :on-click #(s/react-from-picker! glyph)}])])495 :on-click #(s/react-from-picker! glyph)}])])
464 [:dim-label {:label "No emoji by that name."}])]496 [:dim-label {:label "No emoji by that name."}])]
@@ -490,7 +522,12 @@
490 ;; move the compose bar, and should: it appears because the522 ;; move the compose bar, and should: it appears because the
491 ;; reader asked to answer something, unlike the jump button,523 ;; reader asked to answer something, unlike the jump button,
492 ;; which appears on its own and must not shift what is under it.524 ;; which appears on its own and must not shift what is under it.
493- :reserve (if @s/replying-to 150 116)525+ ;; And the picker's, which takes the bottom of the window
526+ ;; while it is open — the list gives up that room rather than
527+ ;; being painted under it.
528+ :reserve (cond @s/reacting (picker-reserve)
529+ @s/replying-to 150
530+ :else 116)
494 :stick-to-bottom true531 :stick-to-bottom true
495 :scroll-to-bottom @s/jump-tick532 :scroll-to-bottom @s/jump-tick
496 :on-change #(reset! s/at-present? (= "end" %))}533 :on-change #(reset! s/at-present? (= "end" %))}
@@ -501,10 +538,17 @@
501 ;; back to the present belongs next to the thing that puts you there.538 ;; back to the present belongs next to the thing that puts you there.
502 ;; The row keeps its height whether or not the button is in it, so the539 ;; The row keeps its height whether or not the button is in it, so the
503 ;; compose bar below stays where the reader last saw it.540 ;; compose bar below stays where the reader last saw it.
541+ ;; Choosing an emoji is the only thing being done while the picker is
542+ ;; open, so it takes the row the way back to the present would have had.
543+ [:vbox {:key :picker-panel}
544+ (when @s/reacting [emoji-picker])]
504 [:vbox {:key :jump}545 [:vbox {:key :jump}
505- (if @s/at-present?546+ (cond
506- [:spacer {:size 34}]547+ ;; Not even the spacer while the picker is open: the panel is what is
507- [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])]548+ ;; holding the compose bar's place, and a gap under it is just a gap.
549+ @s/reacting nil
550+ @s/at-present? [:spacer {:size 34}]
551+ :else [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])]
508 [:separator {}]552 [:separator {}]
509 ;; What the draft is answering, directly above where it is being typed.553 ;; What the draft is answering, directly above where it is being typed.
510 [:vbox {:key :replying}554 [:vbox {:key :replying}
@@ -577,10 +621,8 @@
577 ;; ---------------------------------------------------------------- shell621 ;; ---------------------------------------------------------------- shell
578 622
579 (defn app []623 (defn app []
580- (cond624+ (if @s/lightbox
581- @s/lightbox [lightbox-screen]625+ [lightbox-screen]
582- @s/reacting [emoji-picker-screen]
583- :else
584 (case @s/screen626 (case @s/screen
585 :connect [connect-screen]627 :connect [connect-screen]
586 :chat [chat-screen]628 :chat [chat-screen]
modified src/frq/state.jolt +16 -11
@@ -70,6 +70,16 @@
7070 (defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))
7171 (defn cancel-reply! [] (reset! replying-to nil))
7272
73+;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
74+;; when it is closed. The picker is a panel over the compose bar rather than a
75+;; screen: what is being reacted to has to stay in sight.
76+(defonce reacting (atom nil))
77+
78+;; What the picker is showing: the search box, and which group is on screen
79+;; when nothing has been typed. `nil` is the popular row it opens on.
80+(defonce emoji-search (atom ""))
81+(defonce emoji-group (atom nil))
82+
7383 ;; The picture being looked at full size, or nil. Vidya's tree has no overlay,
7484 ;; so this is a screen of its own rather than a layer over the chat.
7585 (defonce lightbox (atom nil)) ; {:path :url}
@@ -158,6 +168,9 @@
158168 [name]
159169 (reset! current name)
160170 (reset! screen :chat)
171+ ;; A picker belongs to the message it was opened on; carrying it into another
172+ ;; buffer would offer to react to something that is no longer on screen.
173+ (reset! reacting nil)
161174 (swap! channels #(-> (ensure-channel % name)
162175 (assoc-in [name :unread] 0)
163176 (assoc-in [name :accessed] (swap! access-tick inc))))
@@ -513,16 +526,6 @@
513526 (reset! replying-to nil)
514527 (reset! draft ""))))
515528
516-;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
517-;; when it is closed. The picker is a screen of its own — the tree backend
518-;; paints in one layer, so there is no floating it over the conversation.
519-(defonce reacting (atom nil))
520-
521-;; What the picker is showing: the search box, and which group is on screen
522-;; when nothing has been typed. `nil` is the popular row it opens on.
523-(defonce emoji-search (atom ""))
524-(defonce emoji-group (atom nil))
525-
526529 (defn open-picker!
527530 "Choose an emoji for this message. Opening it fresh — no leftover search from
528531 the last time, which would be a screen of somebody else's question."
@@ -549,7 +552,9 @@
549552 face, and by the emoji itself, so pasting one finds it."
550553 []
551554 (let [q (str/lower-case (str/trim @emoji-search))
552- group @emoji-group]
555+ ;; A blank group is no group: the popular row is what nothing selected
556+ ;; means, and an empty string would filter the catalog down to nothing.
557+ group (when (seq (or @emoji-group "")) @emoji-group)]
553558 (cond
554559 (seq q) (->> emoji/catalog
555560 (filter (fn [[glyph name _]]
@@ -70,6 +70,16 @@
70 (defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))70 (defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))
71 (defn cancel-reply! [] (reset! replying-to nil))71 (defn cancel-reply! [] (reset! replying-to nil))
72 72
73+;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
74+;; when it is closed. The picker is a panel over the compose bar rather than a
75+;; screen: what is being reacted to has to stay in sight.
76+(defonce reacting (atom nil))
77+
78+;; What the picker is showing: the search box, and which group is on screen
79+;; when nothing has been typed. `nil` is the popular row it opens on.
80+(defonce emoji-search (atom ""))
81+(defonce emoji-group (atom nil))
82+
73 ;; The picture being looked at full size, or nil. Vidya's tree has no overlay,83 ;; The picture being looked at full size, or nil. Vidya's tree has no overlay,
74 ;; so this is a screen of its own rather than a layer over the chat.84 ;; so this is a screen of its own rather than a layer over the chat.
75 (defonce lightbox (atom nil)) ; {:path :url}85 (defonce lightbox (atom nil)) ; {:path :url}
@@ -158,6 +168,9 @@
158 [name]168 [name]
159 (reset! current name)169 (reset! current name)
160 (reset! screen :chat)170 (reset! screen :chat)
171+ ;; A picker belongs to the message it was opened on; carrying it into another
172+ ;; buffer would offer to react to something that is no longer on screen.
173+ (reset! reacting nil)
161 (swap! channels #(-> (ensure-channel % name)174 (swap! channels #(-> (ensure-channel % name)
162 (assoc-in [name :unread] 0)175 (assoc-in [name :unread] 0)
163 (assoc-in [name :accessed] (swap! access-tick inc))))176 (assoc-in [name :accessed] (swap! access-tick inc))))
@@ -513,16 +526,6 @@
513 (reset! replying-to nil)526 (reset! replying-to nil)
514 (reset! draft ""))))527 (reset! draft ""))))
515 528
516-;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
517-;; when it is closed. The picker is a screen of its own — the tree backend
518-;; paints in one layer, so there is no floating it over the conversation.
519-(defonce reacting (atom nil))
520-
521-;; What the picker is showing: the search box, and which group is on screen
522-;; when nothing has been typed. `nil` is the popular row it opens on.
523-(defonce emoji-search (atom ""))
524-(defonce emoji-group (atom nil))
525-
526 (defn open-picker!529 (defn open-picker!
527 "Choose an emoji for this message. Opening it fresh — no leftover search from530 "Choose an emoji for this message. Opening it fresh — no leftover search from
528 the last time, which would be a screen of somebody else's question."531 the last time, which would be a screen of somebody else's question."
@@ -549,7 +552,9 @@
549 face, and by the emoji itself, so pasting one finds it."552 face, and by the emoji itself, so pasting one finds it."
550 []553 []
551 (let [q (str/lower-case (str/trim @emoji-search))554 (let [q (str/lower-case (str/trim @emoji-search))
552- group @emoji-group]555+ ;; A blank group is no group: the popular row is what nothing selected
556+ ;; means, and an empty string would filter the catalog down to nothing.
557+ group (when (seq (or @emoji-group "")) @emoji-group)]
553 (cond558 (cond
554 (seq q) (->> emoji/catalog559 (seq q) (->> emoji/catalog
555 (filter (fn [[glyph name _]]560 (filter (fn [[glyph name _]]