@bysivin/jumpbar 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sivin Varghese
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,242 @@
1
- # Temporary Holding Version
1
+ # jumpbar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A lightweight command palette for Vue 3. The ranking core is plain JavaScript
4
+ with no dependencies (about 1 KB gzipped); the shell is a native `<dialog>`
5
+ rendered with Tailwind utilities (about 5 KB gzipped with the core).
6
+
7
+ - Fuzzy matching with accent folding, sections, nested pages and prefixes
8
+ - Frecency so the things you use rise to the top
9
+ - Sync `commands` sources ranked on every keystroke, async `search` sources
10
+ with per-source debounce, minimum query length and `AbortSignal`
11
+ - Keyboard and pointer handling, open/close motion, skeleton rows while
12
+ remote results load, RTL and IME aware
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pnpm add @bysivin/jumpbar
18
+ ```
19
+
20
+ The package ships compiled ES modules with TypeScript declarations; Vue
21
+ stays external. `vue` 3.4 or newer is needed for the shell.
22
+
23
+ ### Any framework
24
+
25
+ The ranking core has no framework dependency. React, Svelte, Solid or plain
26
+ JavaScript projects import it from `@bysivin/jumpbar/core` and render their
27
+ own list:
28
+
29
+ ```js
30
+ import { rank, groupBySection, createFrecency } from '@bysivin/jumpbar/core';
31
+
32
+ const ranked = rank(items, { text: query, scopes: ['conversation'] });
33
+ const sections = groupBySection(ranked);
34
+ ```
35
+
36
+ The `CommandBar` shell and `useCommandBar` state are Vue 3.
37
+
38
+ ## Styling
39
+
40
+ The shell is built from Tailwind utilities and reads every colour from a
41
+ small set of semantic tokens, so it adapts to any design system.
42
+
43
+ 1. Add the preset and scan the package so its classes are generated:
44
+
45
+ ```js
46
+ // tailwind.config.js
47
+ module.exports = {
48
+ presets: [require('@bysivin/jumpbar/tailwind')],
49
+ content: ['./node_modules/@bysivin/jumpbar/dist/*.js'],
50
+ };
51
+ ```
52
+
53
+ The preset registers the `jumpbar-*` colours as CSS variables and the
54
+ open and close keyframes.
55
+
56
+ 2. Theme it. Either import the neutral defaults and override what you like
57
+ (dark values apply under a `dark` class on the root element):
58
+
59
+ ```js
60
+ import '@bysivin/jumpbar/theme.css';
61
+ ```
62
+
63
+ ```css
64
+ :root {
65
+ --jumpbar-primary: #2781f6;
66
+ }
67
+ ```
68
+
69
+ or skip the import and map the tokens onto your existing Tailwind colours,
70
+ which also carries your dark mode along:
71
+
72
+ ```js
73
+ theme: {
74
+ extend: {
75
+ colors: {
76
+ jumpbar: {
77
+ background: colors.surface,
78
+ foreground: colors.text,
79
+ primary: colors.brand,
80
+ },
81
+ },
82
+ },
83
+ },
84
+ ```
85
+
86
+ | Token | Used for |
87
+ | ------------------- | ------------------------------------------------- |
88
+ | `background` | Panel |
89
+ | `foreground` | Titles and input text |
90
+ | `muted-foreground` | Subtitles, section labels, breadcrumbs, badges |
91
+ | `subtle-foreground` | Placeholder, icons, hints, chevrons |
92
+ | `muted` | Active row, chips, avatar fallback, skeleton rows |
93
+ | `subtle` | Key caps, lighter skeleton bar |
94
+ | `border` | Dividers and key cap outline |
95
+ | `ring` | Panel outline |
96
+ | `primary` | Active row marker, page chip text |
97
+ | `primary-soft` | Page chip background |
98
+ | `overlay` | Backdrop |
99
+ | `online` `busy` `offline` | Presence dot |
100
+
101
+ 3. Provide the icon classes `i-lucide-search`, `i-lucide-loader-circle` and
102
+ `i-lucide-chevron-right` (for example through `@egoist/tailwindcss-icons`
103
+ with the Lucide set). Items bring their own icon class.
104
+
105
+ ## Usage
106
+
107
+ ```vue
108
+ <script setup>
109
+ import { CommandBar, createFrecency, useCommandBar } from '@bysivin/jumpbar';
110
+
111
+ const bar = useCommandBar();
112
+
113
+ bar.configure({
114
+ prefixes: ['@', '#'],
115
+ context: () => ({ scopes: ['conversation'] }),
116
+ frecency: createFrecency({ storage: localStorage, key: 'palette-usage' }),
117
+ onSelect: item => track(item),
118
+ onClose: () => {},
119
+ onError: (error, source) => report(error),
120
+ limit: 100,
121
+ });
122
+
123
+ bar.register({
124
+ id: 'navigation',
125
+ commands: () => [
126
+ {
127
+ id: 'inbox',
128
+ title: 'Go to inbox',
129
+ section: 'Navigation',
130
+ icon: 'i-lucide-inbox',
131
+ shortcut: ['⌥', 'I'],
132
+ run: () => router.push('/inbox'),
133
+ },
134
+ ],
135
+ });
136
+
137
+ bar.register({
138
+ id: 'contacts',
139
+ minQuery: 3,
140
+ debounce: 300,
141
+ when: ({ page }) => !page,
142
+ search: async ({ text, signal }) => {
143
+ const contacts = await api.search(text, { signal });
144
+ return contacts.map(contact => ({
145
+ id: `contact-${contact.id}`,
146
+ title: contact.name,
147
+ subtitle: contact.email,
148
+ section: 'Contacts',
149
+ avatar: { name: contact.name, src: contact.thumbnail },
150
+ run: () => router.push(`/contacts/${contact.id}`),
151
+ }));
152
+ },
153
+ });
154
+ </script>
155
+
156
+ <template>
157
+ <CommandBar
158
+ placeholder="Search or jump to"
159
+ empty-text="No matches"
160
+ :hints="[
161
+ { keys: ['↑', '↓'], label: 'navigate' },
162
+ { keys: ['↵'], label: 'select' },
163
+ { keys: ['esc'], label: 'close' },
164
+ ]"
165
+ />
166
+ </template>
167
+ ```
168
+
169
+ Bind the global shortcut yourself and call `bar.toggle()`. `bar.open({ page,
170
+ query })` opens straight into a page, for example a snooze picker.
171
+
172
+ `CommandBar` props: `placeholder`, `emptyText`, `scopeLabel` (a chip before the
173
+ input, for example the selected record), `hints` and `dir` (`ltr` or `rtl`).
174
+
175
+ ## Items
176
+
177
+ Sources return plain objects:
178
+
179
+ | Field | Meaning |
180
+ | ---------------- | ------------------------------------------------------------------------ |
181
+ | `id` | Unique across all sources. Frecency and dedupe key on it. |
182
+ | `title` | Row label. Children are matched through `parentTitle title` too. |
183
+ | `subtitle` | Muted text after the title. Truncates before the title does. |
184
+ | `section` | Group header. Groups keep the order of their first item. |
185
+ | `icon` | CSS class rendered as the leading glyph. |
186
+ | `avatar` | `{ name, src, status }` instead of the icon, initials as fallback; `status` is `online`, `busy` or `offline`. |
187
+ | `badge` | Short trailing text. |
188
+ | `shortcut` | Trailing key labels, for example `['⌥', 'E']`. |
189
+ | `keywords` | Extra words the matcher sees (word-start matches only). |
190
+ | `scopes` | Ranks first when one matches `context.scopes`. |
191
+ | `prefix` | Typing this character first lists only items carrying it. |
192
+ | `weight` | Static boost between 0 and 1. |
193
+ | `place` | `first` or `last`: sorts around every other match, in declared order. |
194
+ | `searchFromRoot` | `false` keeps a child out of root searches (prefix and page only). |
195
+ | `page` | Selecting opens a page listing the items whose `parent` is its id. |
196
+ | `placeholder` | Input placeholder while that page is open. |
197
+ | `parent` | Id of the page this item lives under. |
198
+ | `run` | Called with the context as the bar starts closing; the dialog leaves the top layer about 150 ms later. Rows without it are inert. |
199
+
200
+ ## Sources
201
+
202
+ ```js
203
+ bar.register({
204
+ id: 'conversations',
205
+ minQuery: 2,
206
+ debounce: 300,
207
+ when: ({ page }) => !page,
208
+ commands: request => [],
209
+ search: async request => [],
210
+ });
211
+ ```
212
+
213
+ `commands` runs on every keystroke and is ranked. `search` runs after the
214
+ debounce with an `AbortSignal`; its results keep their own order, append after
215
+ the ranked commands at the root, and replace the static children on a page.
216
+ The request carries `query`, `text`, `prefix`, `page`, `context` and, for
217
+ `search`, `matches` (how many commands matched locally) and `signal`.
218
+ `register` returns a function that removes the source again. A searcher that
219
+ throws has its rows dropped and `onError(error, source)` called; the default
220
+ rethrows. Text queries show at most `limit` ranked commands (default 100).
221
+
222
+ ## Ranking
223
+
224
+ Titles match by exact, prefix, word start, every-word-starts-a-word, then
225
+ scattered characters. Subtitles and keywords only match on word starts, so a
226
+ long description never floods the list. Items in `context.scopes` get a boost,
227
+ recently used items another, `weight` a small static one. `place: 'first'`
228
+ and `place: 'last'` sit outside the scoring entirely.
229
+
230
+ ## Keyboard
231
+
232
+ `↑` `↓` move, `Enter` selects, `Esc` closes, `Backspace` on an empty query goes
233
+ back one page. Hover only changes the selection when the pointer really moves,
234
+ so arrow keys keep working under a resting cursor.
235
+
236
+ ## Development
237
+
238
+ ```sh
239
+ pnpm install
240
+ pnpm test
241
+ pnpm build
242
+ ```
package/dist/core.js ADDED
@@ -0,0 +1,148 @@
1
+ const COMBINING_MARKS = /[\u0300-\u036f]/g;
2
+ const EXACT = 1;
3
+ const PREFIX = 0.9;
4
+ const WORD_START = 0.8;
5
+ const ALL_WORDS = 0.7;
6
+ const SUBSEQUENCE_MAX = 0.6;
7
+ const SUBSEQUENCE_MIN = 0.2;
8
+ const NORMALIZED_CACHE_LIMIT = 5e3;
9
+ const normalized = /* @__PURE__ */ new Map();
10
+ const normalize = (value) => {
11
+ const raw = String(value ?? "");
12
+ const cached = normalized.get(raw);
13
+ if (cached !== void 0) return cached;
14
+ if (normalized.size >= NORMALIZED_CACHE_LIMIT) normalized.clear();
15
+ const result = raw.normalize("NFD").replace(COMBINING_MARKS, "").toLowerCase().trim();
16
+ normalized.set(raw, result);
17
+ return result;
18
+ };
19
+ const tightestSpan = (query, text) => {
20
+ let best = Infinity;
21
+ for (let start = text.indexOf(query[0]); start !== -1; start = text.indexOf(query[0], start + 1)) {
22
+ let cursor = start;
23
+ let matched = true;
24
+ for (let i = 1; i < query.length; i += 1) {
25
+ cursor = text.indexOf(query[i], cursor + 1);
26
+ if (cursor === -1) {
27
+ matched = false;
28
+ break;
29
+ }
30
+ }
31
+ if (matched) best = Math.min(best, cursor - start + 1);
32
+ }
33
+ return best;
34
+ };
35
+ const fuzzyScore = (query, text, { subsequence = true } = {}) => {
36
+ const needle = normalize(query);
37
+ if (!needle) return EXACT;
38
+ const haystack = normalize(text);
39
+ if (haystack === needle) return EXACT;
40
+ if (haystack.startsWith(needle)) return PREFIX;
41
+ const words = haystack.split(/\s+/);
42
+ if (words.some((word) => word.startsWith(needle))) return WORD_START;
43
+ const needleWords = needle.split(/\s+/);
44
+ if (needleWords.every((part) => words.some((word) => word.startsWith(part)))) {
45
+ return ALL_WORDS;
46
+ }
47
+ if (!subsequence) return 0;
48
+ const compact = needle.replace(/\s+/g, "");
49
+ const span = tightestSpan(compact, haystack);
50
+ if (span === Infinity) return 0;
51
+ return SUBSEQUENCE_MIN + (SUBSEQUENCE_MAX - SUBSEQUENCE_MIN) * (compact.length / span);
52
+ };
53
+ const parseQuery = (input, prefixes = []) => {
54
+ const trimmed = String(input ?? "").trim();
55
+ const prefix = prefixes.includes(trimmed[0]) ? trimmed[0] : null;
56
+ const text = prefix ? trimmed.slice(1).trim() : trimmed;
57
+ return { prefix, text };
58
+ };
59
+ const DAY_MS = 24 * 60 * 60 * 1e3;
60
+ const HALF_LIFE_DAYS = 7;
61
+ const USES_FOR_FULL_SCORE = 3;
62
+ const MAX_IDLE_DAYS = 30;
63
+ const load = (storage, key) => {
64
+ try {
65
+ return JSON.parse(storage.getItem(key)) ?? {};
66
+ } catch {
67
+ return {};
68
+ }
69
+ };
70
+ const createFrecency = ({
71
+ storage,
72
+ key,
73
+ now = Date.now,
74
+ limit = 10
75
+ }) => {
76
+ const uses = load(storage, key);
77
+ const record = (id) => {
78
+ const at = now();
79
+ uses[id] = [...uses[id] ?? [], at].slice(-limit);
80
+ Object.keys(uses).forEach((other) => {
81
+ if (at - uses[other].at(-1) > MAX_IDLE_DAYS * DAY_MS) delete uses[other];
82
+ });
83
+ storage.setItem(key, JSON.stringify(uses));
84
+ };
85
+ const score = (id) => {
86
+ const total = (uses[id] ?? []).reduce(
87
+ (sum, usedAt) => sum + 0.5 ** ((now() - usedAt) / DAY_MS / HALF_LIFE_DAYS),
88
+ 0
89
+ );
90
+ return Math.min(1, total / USES_FOR_FULL_SCORE);
91
+ };
92
+ return { record, score };
93
+ };
94
+ const SCOPE_BOOST = 0.3;
95
+ const FRECENCY_BOOST = 0.2;
96
+ const WEIGHT_BOOST = 0.1;
97
+ const NO_FRECENCY = { score: () => 0 };
98
+ const TIERS = { first: 0, last: 2 };
99
+ const tierOf = (item) => TIERS[item.place] ?? 1;
100
+ const visibleItems = (items, { text, prefix, page }) => {
101
+ if (page) return items.filter((item) => item.parent === page);
102
+ if (prefix) return items.filter((item) => item.prefix === prefix);
103
+ if (!text) return items.filter((item) => !item.parent);
104
+ return items.filter((item) => item.searchFromRoot !== false);
105
+ };
106
+ const detailText = (item) => [item.subtitle, ...item.keywords ?? []].filter(Boolean).join(" ");
107
+ const matchScore = (text, item, parentTitle) => Math.max(
108
+ fuzzyScore(text, item.title),
109
+ parentTitle ? fuzzyScore(text, `${parentTitle} ${item.title}`) : 0,
110
+ fuzzyScore(text, detailText(item), { subsequence: false })
111
+ );
112
+ const rank = (items, { text = "", prefix = null, page = null, scopes = [], frecency = NO_FRECENCY }) => {
113
+ const titles = new Map(items.map((item) => [item.id, item.title]));
114
+ return visibleItems(items, { text, prefix, page }).map((item) => {
115
+ var _a;
116
+ const parentTitle = item.parent && !page ? titles.get(item.parent) : void 0;
117
+ const match = text ? matchScore(text, item, parentTitle) : 1;
118
+ const inScope = ((_a = item.scopes) == null ? void 0 : _a.some((scope) => scopes.includes(scope))) ? 1 : 0;
119
+ const score = match + SCOPE_BOOST * inScope + FRECENCY_BOOST * frecency.score(item.id) + WEIGHT_BOOST * (item.weight ?? 0);
120
+ return {
121
+ item: parentTitle ? { ...item, parentTitle } : item,
122
+ match,
123
+ score
124
+ };
125
+ }).filter((entry) => entry.match > 0).sort((a, b) => {
126
+ const tier = tierOf(a.item) - tierOf(b.item);
127
+ if (tier) return tier;
128
+ return a.item.place ? 0 : b.score - a.score;
129
+ }).map((entry) => entry.item);
130
+ };
131
+ const groupBySection = (items) => {
132
+ const groups = /* @__PURE__ */ new Map();
133
+ items.forEach((item) => {
134
+ const section = item.section ?? null;
135
+ if (!groups.has(section)) groups.set(section, []);
136
+ groups.get(section).push(item);
137
+ });
138
+ return [...groups].map(([section, members]) => ({ section, items: members }));
139
+ };
140
+ export {
141
+ createFrecency,
142
+ fuzzyScore,
143
+ groupBySection,
144
+ normalize,
145
+ parseQuery,
146
+ rank
147
+ };
148
+ //# sourceMappingURL=core.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.js","sources":["../src/core/match.js","../src/core/parse.js","../src/core/frecency.js","../src/core/rank.js"],"sourcesContent":["const COMBINING_MARKS = /[\\u0300-\\u036f]/g;\n\nconst EXACT = 1;\nconst PREFIX = 0.9;\nconst WORD_START = 0.8;\nconst ALL_WORDS = 0.7;\nconst SUBSEQUENCE_MAX = 0.6;\nconst SUBSEQUENCE_MIN = 0.2;\n\nconst NORMALIZED_CACHE_LIMIT = 5000;\nconst normalized = new Map();\n\nexport const normalize = value => {\n const raw = String(value ?? '');\n const cached = normalized.get(raw);\n if (cached !== undefined) return cached;\n if (normalized.size >= NORMALIZED_CACHE_LIMIT) normalized.clear();\n const result = raw\n .normalize('NFD')\n .replace(COMBINING_MARKS, '')\n .toLowerCase()\n .trim();\n normalized.set(raw, result);\n return result;\n};\n\nconst tightestSpan = (query, text) => {\n let best = Infinity;\n for (\n let start = text.indexOf(query[0]);\n start !== -1;\n start = text.indexOf(query[0], start + 1)\n ) {\n let cursor = start;\n let matched = true;\n for (let i = 1; i < query.length; i += 1) {\n cursor = text.indexOf(query[i], cursor + 1);\n if (cursor === -1) {\n matched = false;\n break;\n }\n }\n if (matched) best = Math.min(best, cursor - start + 1);\n }\n return best;\n};\n\nexport const fuzzyScore = (query, text, { subsequence = true } = {}) => {\n const needle = normalize(query);\n if (!needle) return EXACT;\n const haystack = normalize(text);\n if (haystack === needle) return EXACT;\n if (haystack.startsWith(needle)) return PREFIX;\n\n const words = haystack.split(/\\s+/);\n if (words.some(word => word.startsWith(needle))) return WORD_START;\n\n const needleWords = needle.split(/\\s+/);\n if (needleWords.every(part => words.some(word => word.startsWith(part)))) {\n return ALL_WORDS;\n }\n\n if (!subsequence) return 0;\n const compact = needle.replace(/\\s+/g, '');\n const span = tightestSpan(compact, haystack);\n if (span === Infinity) return 0;\n return (\n SUBSEQUENCE_MIN +\n (SUBSEQUENCE_MAX - SUBSEQUENCE_MIN) * (compact.length / span)\n );\n};\n","export const parseQuery = (input, prefixes = []) => {\n const trimmed = String(input ?? '').trim();\n const prefix = prefixes.includes(trimmed[0]) ? trimmed[0] : null;\n const text = prefix ? trimmed.slice(1).trim() : trimmed;\n return { prefix, text };\n};\n","const DAY_MS = 24 * 60 * 60 * 1000;\nconst HALF_LIFE_DAYS = 7;\nconst USES_FOR_FULL_SCORE = 3;\nconst MAX_IDLE_DAYS = 30;\n\nconst load = (storage, key) => {\n try {\n return JSON.parse(storage.getItem(key)) ?? {};\n } catch {\n return {};\n }\n};\n\nexport const createFrecency = ({\n storage,\n key,\n now = Date.now,\n limit = 10,\n}) => {\n const uses = load(storage, key);\n\n const record = id => {\n const at = now();\n uses[id] = [...(uses[id] ?? []), at].slice(-limit);\n Object.keys(uses).forEach(other => {\n if (at - uses[other].at(-1) > MAX_IDLE_DAYS * DAY_MS) delete uses[other];\n });\n storage.setItem(key, JSON.stringify(uses));\n };\n\n const score = id => {\n const total = (uses[id] ?? []).reduce(\n (sum, usedAt) =>\n sum + 0.5 ** ((now() - usedAt) / DAY_MS / HALF_LIFE_DAYS),\n 0\n );\n return Math.min(1, total / USES_FOR_FULL_SCORE);\n };\n\n return { record, score };\n};\n","import { fuzzyScore } from './match';\n\nconst SCOPE_BOOST = 0.3;\nconst FRECENCY_BOOST = 0.2;\nconst WEIGHT_BOOST = 0.1;\n\nconst NO_FRECENCY = { score: () => 0 };\nconst TIERS = { first: 0, last: 2 };\nconst tierOf = item => TIERS[item.place] ?? 1;\n\nconst visibleItems = (items, { text, prefix, page }) => {\n if (page) return items.filter(item => item.parent === page);\n if (prefix) return items.filter(item => item.prefix === prefix);\n if (!text) return items.filter(item => !item.parent);\n return items.filter(item => item.searchFromRoot !== false);\n};\n\nconst detailText = item =>\n [item.subtitle, ...(item.keywords ?? [])].filter(Boolean).join(' ');\n\nconst matchScore = (text, item, parentTitle) =>\n Math.max(\n fuzzyScore(text, item.title),\n parentTitle ? fuzzyScore(text, `${parentTitle} ${item.title}`) : 0,\n fuzzyScore(text, detailText(item), { subsequence: false })\n );\n\nexport const rank = (\n items,\n { text = '', prefix = null, page = null, scopes = [], frecency = NO_FRECENCY }\n) => {\n const titles = new Map(items.map(item => [item.id, item.title]));\n\n return visibleItems(items, { text, prefix, page })\n .map(item => {\n const parentTitle =\n item.parent && !page ? titles.get(item.parent) : undefined;\n const match = text ? matchScore(text, item, parentTitle) : 1;\n const inScope = item.scopes?.some(scope => scopes.includes(scope))\n ? 1\n : 0;\n const score =\n match +\n SCOPE_BOOST * inScope +\n FRECENCY_BOOST * frecency.score(item.id) +\n WEIGHT_BOOST * (item.weight ?? 0);\n return {\n item: parentTitle ? { ...item, parentTitle } : item,\n match,\n score,\n };\n })\n .filter(entry => entry.match > 0)\n .sort((a, b) => {\n const tier = tierOf(a.item) - tierOf(b.item);\n if (tier) return tier;\n return a.item.place ? 0 : b.score - a.score;\n })\n .map(entry => entry.item);\n};\n\nexport const groupBySection = items => {\n const groups = new Map();\n items.forEach(item => {\n const section = item.section ?? null;\n if (!groups.has(section)) groups.set(section, []);\n groups.get(section).push(item);\n });\n return [...groups].map(([section, members]) => ({ section, items: members }));\n};\n"],"names":[],"mappings":"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"}