@grimoire-rs/indexer 0.5.1 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -21,6 +21,41 @@ props `{ pkg }` → `{ pkg, compact }`.
21
21
 
22
22
  ## [Unreleased]
23
23
 
24
+ ## [0.5.2] - 2026-08-31
25
+
26
+ ### Fixed
27
+
28
+ - **The sort control drew two borders when clicked.** Hover tints a control's
29
+ border to the accent and focus drew a 2px accent outline standing 1px clear
30
+ of it, so anyone who reached the control with a mouse had both at once — one
31
+ state reading as two lines with a gap between them. The ring merges onto the
32
+ border now, the way the card's focus state already did, and the offset comes
33
+ off every bordered control in the toolbar together rather than off the one
34
+ that showed it: the search field, the keyword overflow menu's search, both
35
+ sort halves and both view picks. Keyword chips gain the same ring, having
36
+ taken the browser's default until now.
37
+ - **A long address no longer wraps the card's head onto a second line.** A
38
+ namespace breaks at its own slashes and dots, so a wide one took two rows and
39
+ left the card taller than its neighbours with the tail of the address against
40
+ the padding edge. It stays on one line and the overflow is trimmed from the
41
+ *front* — the registry host is on every address in an index and the
42
+ repository at the tail is what tells two apart — with the whole of it in the
43
+ element's `title`.
44
+ - **Keyword chips could stutter, or stop part-way through the rail's slide.**
45
+ The rail's FLIP pass inverted each chip with an inline `transition: none` and
46
+ a `translate`, then dropped both on the next animation frame; any commit
47
+ landing inside that window left the chip carrying the offset with its
48
+ transition disabled, and nothing else took those off. It also measured seats
49
+ with `getBoundingClientRect`, which reports where a chip is *drawn*, so a
50
+ chip caught mid-slide recorded its animated box and the next inversion
51
+ compounded the error instead of correcting it. Neither window was rare: the
52
+ rail's own fit measurement re-commits whenever a rescore changes how many
53
+ chips fit. Seats now come from `offsetLeft`/`offsetTop`, which ignore
54
+ transforms and scroll, and the slide is a Web Animation — it starts without a
55
+ frame, writes nothing to `style`, and clears itself when it finishes or is
56
+ cancelled. `prefers-reduced-motion` is honoured by the effect rather than by
57
+ the stylesheet, and `--grim-duration-slow` is still its length.
58
+
24
59
  ## [0.5.1] - 2026-08-31
25
60
 
26
61
  ### Added
@@ -265,6 +300,7 @@ props `{ pkg }` → `{ pkg, compact }`.
265
300
  boot can leave a `.index-*` holding Vite's `deps_temp_<hash>`, which the
266
301
  dependency optimizer recreates after the removal has already returned.
267
302
 
268
- [Unreleased]: https://github.com/grimoire-rs/indexer/compare/v0.5.1...HEAD
303
+ [Unreleased]: https://github.com/grimoire-rs/indexer/compare/v0.5.2...HEAD
304
+ [0.5.2]: https://github.com/grimoire-rs/indexer/compare/v0.5.1...v0.5.2
269
305
  [0.5.1]: https://github.com/grimoire-rs/indexer/compare/v0.5.0...v0.5.1
270
306
  [0.5.0]: https://github.com/grimoire-rs/indexer/compare/v0.4.4...v0.5.0
@@ -258,7 +258,18 @@ export default function Catalog({ packages, vscodeExtension, }) {
258
258
  ...(controlsRef.current?.querySelectorAll('button.chip:not([aria-hidden="true"])') ?? []),
259
259
  ];
260
260
  const railRefs = useRef(new Map());
261
- const railRects = useRef(new Map());
261
+ /**
262
+ * Where each chip sat at the last commit, as `offsetLeft`/`offsetTop`.
263
+ *
264
+ * Layout positions, deliberately, not `getBoundingClientRect`: the offsets
265
+ * ignore transforms and page scroll, so a chip measured mid-slide reports
266
+ * the seat it is animating towards rather than the box it is drawn in this
267
+ * frame. See the FLIP effect below for why that distinction is the whole
268
+ * fix.
269
+ */
270
+ const railSeats = useRef(new Map());
271
+ /** The slide each chip is currently running, so a new one can replace it. */
272
+ const railSlides = useRef(new WeakMap());
262
273
  const railRef = useRef(null);
263
274
  const kwMenuRef = useRef(null);
264
275
  const kwTriggerRef = useRef(null);
@@ -373,71 +384,76 @@ export default function Catalog({ packages, vscodeExtension, }) {
373
384
  * between two frames reads as having been *replaced*, and a reader who
374
385
  * cannot see that a chip moved has no reason to believe it is the same one.
375
386
  *
376
- * First (the map of rects kept from the last commit), Last (measured now),
377
- * Invert (an inline translate back to where the chip was), Play (dropped on
378
- * the next frame, so the stylesheet's transition carries it home). Measure
379
- * every chip before transforming any: `translate` composites and does not
380
- * reflow, but reading a rect after writing a style on a sibling is the
381
- * shape that makes a layout thrash, and this runs per keystroke.
387
+ * Two choices carry the whole thing, and both are here because the shape
388
+ * they replace — an inline `translate` under the stylesheet's transition,
389
+ * dropped again on a `requestAnimationFrame` could leave a chip stopped
390
+ * off its seat with no path back:
391
+ *
392
+ * - **Seats come from `offsetLeft`/`offsetTop`, never from
393
+ * `getBoundingClientRect`.** The offsets are layout positions and ignore
394
+ * both transforms and scroll; a rect is where the chip is *drawn*, so a
395
+ * chip measured mid-slide recorded its animated box and the next
396
+ * inversion compounded that error — the stutter. Mid-slide is the common
397
+ * case, not a rare one: the fit measurement above commits a second time
398
+ * whenever a rescore changes how many chips fit, and that commit lands
399
+ * inside the previous slide. Because the offsets are transform-blind,
400
+ * that second commit now measures the same seats and starts nothing.
401
+ * - **The slide is a Web Animation, not an inline style.** It needs no
402
+ * frame to start, so no commit can land between an invert and its play
403
+ * and freeze a chip at the offset. It writes nothing to `style`, so
404
+ * there is nothing left to strip when a chip unmounts or a pass is
405
+ * superseded. And it clears itself the instant it finishes or is
406
+ * cancelled, which makes "the rail always ends up in its real state" a
407
+ * property of the mechanism rather than of a cleanup remembering to run.
382
408
  */
383
409
  useLayoutEffect(() => {
384
- // Undo whatever the last pass left on the chips before measuring
385
- // anything. Two reasons, and both were visible: `getBoundingClientRect`
386
- // reports the *translated* box, so a chip caught mid-slide would be
387
- // measured where it is drawn rather than where it belongs and the next
388
- // inversion would compound that error; and a chip whose play frame never
389
- // ran is still carrying `transition: none` with an offset, which is a chip
390
- // frozen off its seat. Clearing here is what unfreezes it.
391
- //
392
- // Mid-slide is not a rare case: the fit measurement above commits a second
393
- // time whenever the rescore changes how many chips fit, and that commit
394
- // lands between this one and its animation frame.
395
- for (const el of railRefs.current.values()) {
396
- el.style.transition = "";
397
- el.style.translate = "";
398
- }
399
- const previous = railRects.current;
410
+ const previous = railSeats.current;
400
411
  const current = new Map();
401
412
  const moved = [];
402
- // A second pass, deliberately: every write above is flushed before the
403
- // first read below, rather than interleaving them per chip.
404
413
  for (const [keyword, el] of railRefs.current) {
405
- const rect = el.getBoundingClientRect();
406
- current.set(keyword, rect);
414
+ const x = el.offsetLeft;
415
+ const y = el.offsetTop;
416
+ current.set(keyword, { x, y });
407
417
  const was = previous.get(keyword);
408
418
  if (!was)
409
419
  continue;
410
- const dx = was.left - rect.left;
411
- const dy = was.top - rect.top;
420
+ const dx = was.x - x;
421
+ const dy = was.y - y;
412
422
  if (dx !== 0 || dy !== 0)
413
423
  moved.push({ el, dx, dy });
414
424
  }
415
- railRects.current = current;
425
+ // Rebuilt from `railRefs` every pass, so a chip that left the rail leaves
426
+ // this map with it and cannot seed a slide if it comes back elsewhere.
427
+ railSeats.current = current;
416
428
  if (moved.length === 0)
417
429
  return;
430
+ const rail = railRef.current;
431
+ // Guarded rather than assumed, like the fit observer above: the test
432
+ // renderer's DOM has no Web Animations API, and a rail that does not
433
+ // slide is not worth throwing during a render over.
434
+ if (!rail || typeof rail.animate !== "function")
435
+ return;
436
+ // The motion is the ornament here — the filtering works identically
437
+ // without it — so a reader who asked for less of it gets none.
438
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
439
+ return;
440
+ // The stylesheet stays the source of truth for how long a slide runs:
441
+ // `--grim-duration-slow` is written in milliseconds and that is the unit
442
+ // the Web Animations API takes, so the token survives the move out of CSS.
443
+ // Read once, after every seat above, so the reads and the writes below
444
+ // stay in two passes rather than interleaving per chip.
445
+ const ms = Number.parseFloat(getComputedStyle(rail).getPropertyValue("--grim-duration-slow"));
418
446
  for (const { el, dx, dy } of moved) {
419
- el.style.transition = "none";
420
- el.style.translate = `${dx}px ${dy}px`;
447
+ // One slide per chip. A chip that moves again mid-slide takes the new
448
+ // delta from its layout seat, which starts the second slide a step off
449
+ // where the first had drawn it — a jump measured in the pixels one
450
+ // frame of easing covers, and it is bounded, unlike two animations
451
+ // compositing the same property against each other.
452
+ // ponytail: not blended with the in-flight offset, which would cost a
453
+ // computed-style read per chip in the middle of the write pass.
454
+ railSlides.current.get(el)?.cancel();
455
+ railSlides.current.set(el, el.animate({ translate: [`${dx}px ${dy}px`, "none"] }, { duration: Number.isFinite(ms) ? ms : 200, easing: "ease-out" }));
421
456
  }
422
- const frame = requestAnimationFrame(() => {
423
- for (const { el } of moved) {
424
- el.style.transition = "";
425
- el.style.translate = "";
426
- }
427
- });
428
- return () => {
429
- cancelAnimationFrame(frame);
430
- // Cancelling is not enough on its own. Nothing else takes these off, so
431
- // a commit landing before the frame ran would leave every moved chip
432
- // sitting at its inverted offset with transitions disabled — the rail
433
- // stopping halfway and staying there. Deselecting the last keyword is
434
- // the reliable way to see it: the rescore is at its largest, so the fit
435
- // changes and the extra commit always lands.
436
- for (const { el } of moved) {
437
- el.style.transition = "";
438
- el.style.translate = "";
439
- }
440
- };
441
457
  });
442
458
  /** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
443
459
  const focusCard = (from, delta) => {
@@ -334,7 +334,18 @@ export default function Catalog({
334
334
  ];
335
335
 
336
336
  const railRefs = useRef(new Map<string, HTMLElement>());
337
- const railRects = useRef(new Map<string, DOMRect>());
337
+ /**
338
+ * Where each chip sat at the last commit, as `offsetLeft`/`offsetTop`.
339
+ *
340
+ * Layout positions, deliberately, not `getBoundingClientRect`: the offsets
341
+ * ignore transforms and page scroll, so a chip measured mid-slide reports
342
+ * the seat it is animating towards rather than the box it is drawn in this
343
+ * frame. See the FLIP effect below for why that distinction is the whole
344
+ * fix.
345
+ */
346
+ const railSeats = useRef(new Map<string, { x: number; y: number }>());
347
+ /** The slide each chip is currently running, so a new one can replace it. */
348
+ const railSlides = useRef(new WeakMap<HTMLElement, Animation>());
338
349
  const railRef = useRef<HTMLDivElement>(null);
339
350
 
340
351
  const kwMenuRef = useRef<HTMLDivElement>(null);
@@ -450,68 +461,79 @@ export default function Catalog({
450
461
  * between two frames reads as having been *replaced*, and a reader who
451
462
  * cannot see that a chip moved has no reason to believe it is the same one.
452
463
  *
453
- * First (the map of rects kept from the last commit), Last (measured now),
454
- * Invert (an inline translate back to where the chip was), Play (dropped on
455
- * the next frame, so the stylesheet's transition carries it home). Measure
456
- * every chip before transforming any: `translate` composites and does not
457
- * reflow, but reading a rect after writing a style on a sibling is the
458
- * shape that makes a layout thrash, and this runs per keystroke.
464
+ * Two choices carry the whole thing, and both are here because the shape
465
+ * they replace — an inline `translate` under the stylesheet's transition,
466
+ * dropped again on a `requestAnimationFrame` could leave a chip stopped
467
+ * off its seat with no path back:
468
+ *
469
+ * - **Seats come from `offsetLeft`/`offsetTop`, never from
470
+ * `getBoundingClientRect`.** The offsets are layout positions and ignore
471
+ * both transforms and scroll; a rect is where the chip is *drawn*, so a
472
+ * chip measured mid-slide recorded its animated box and the next
473
+ * inversion compounded that error — the stutter. Mid-slide is the common
474
+ * case, not a rare one: the fit measurement above commits a second time
475
+ * whenever a rescore changes how many chips fit, and that commit lands
476
+ * inside the previous slide. Because the offsets are transform-blind,
477
+ * that second commit now measures the same seats and starts nothing.
478
+ * - **The slide is a Web Animation, not an inline style.** It needs no
479
+ * frame to start, so no commit can land between an invert and its play
480
+ * and freeze a chip at the offset. It writes nothing to `style`, so
481
+ * there is nothing left to strip when a chip unmounts or a pass is
482
+ * superseded. And it clears itself the instant it finishes or is
483
+ * cancelled, which makes "the rail always ends up in its real state" a
484
+ * property of the mechanism rather than of a cleanup remembering to run.
459
485
  */
460
486
  useLayoutEffect(() => {
461
- // Undo whatever the last pass left on the chips before measuring
462
- // anything. Two reasons, and both were visible: `getBoundingClientRect`
463
- // reports the *translated* box, so a chip caught mid-slide would be
464
- // measured where it is drawn rather than where it belongs and the next
465
- // inversion would compound that error; and a chip whose play frame never
466
- // ran is still carrying `transition: none` with an offset, which is a chip
467
- // frozen off its seat. Clearing here is what unfreezes it.
468
- //
469
- // Mid-slide is not a rare case: the fit measurement above commits a second
470
- // time whenever the rescore changes how many chips fit, and that commit
471
- // lands between this one and its animation frame.
472
- for (const el of railRefs.current.values()) {
473
- el.style.transition = "";
474
- el.style.translate = "";
475
- }
476
- const previous = railRects.current;
477
- const current = new Map<string, DOMRect>();
487
+ const previous = railSeats.current;
488
+ const current = new Map<string, { x: number; y: number }>();
478
489
  const moved: { el: HTMLElement; dx: number; dy: number }[] = [];
479
- // A second pass, deliberately: every write above is flushed before the
480
- // first read below, rather than interleaving them per chip.
481
490
  for (const [keyword, el] of railRefs.current) {
482
- const rect = el.getBoundingClientRect();
483
- current.set(keyword, rect);
491
+ const x = el.offsetLeft;
492
+ const y = el.offsetTop;
493
+ current.set(keyword, { x, y });
484
494
  const was = previous.get(keyword);
485
495
  if (!was) continue;
486
- const dx = was.left - rect.left;
487
- const dy = was.top - rect.top;
496
+ const dx = was.x - x;
497
+ const dy = was.y - y;
488
498
  if (dx !== 0 || dy !== 0) moved.push({ el, dx, dy });
489
499
  }
490
- railRects.current = current;
500
+ // Rebuilt from `railRefs` every pass, so a chip that left the rail leaves
501
+ // this map with it and cannot seed a slide if it comes back elsewhere.
502
+ railSeats.current = current;
491
503
  if (moved.length === 0) return;
504
+ const rail = railRef.current;
505
+ // Guarded rather than assumed, like the fit observer above: the test
506
+ // renderer's DOM has no Web Animations API, and a rail that does not
507
+ // slide is not worth throwing during a render over.
508
+ if (!rail || typeof rail.animate !== "function") return;
509
+ // The motion is the ornament here — the filtering works identically
510
+ // without it — so a reader who asked for less of it gets none.
511
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
512
+ // The stylesheet stays the source of truth for how long a slide runs:
513
+ // `--grim-duration-slow` is written in milliseconds and that is the unit
514
+ // the Web Animations API takes, so the token survives the move out of CSS.
515
+ // Read once, after every seat above, so the reads and the writes below
516
+ // stay in two passes rather than interleaving per chip.
517
+ const ms = Number.parseFloat(
518
+ getComputedStyle(rail).getPropertyValue("--grim-duration-slow"),
519
+ );
492
520
  for (const { el, dx, dy } of moved) {
493
- el.style.transition = "none";
494
- el.style.translate = `${dx}px ${dy}px`;
521
+ // One slide per chip. A chip that moves again mid-slide takes the new
522
+ // delta from its layout seat, which starts the second slide a step off
523
+ // where the first had drawn it — a jump measured in the pixels one
524
+ // frame of easing covers, and it is bounded, unlike two animations
525
+ // compositing the same property against each other.
526
+ // ponytail: not blended with the in-flight offset, which would cost a
527
+ // computed-style read per chip in the middle of the write pass.
528
+ railSlides.current.get(el)?.cancel();
529
+ railSlides.current.set(
530
+ el,
531
+ el.animate(
532
+ { translate: [`${dx}px ${dy}px`, "none"] },
533
+ { duration: Number.isFinite(ms) ? ms : 200, easing: "ease-out" },
534
+ ),
535
+ );
495
536
  }
496
- const frame = requestAnimationFrame(() => {
497
- for (const { el } of moved) {
498
- el.style.transition = "";
499
- el.style.translate = "";
500
- }
501
- });
502
- return () => {
503
- cancelAnimationFrame(frame);
504
- // Cancelling is not enough on its own. Nothing else takes these off, so
505
- // a commit landing before the frame ran would leave every moved chip
506
- // sitting at its inverted offset with transitions disabled — the rail
507
- // stopping halfway and staying there. Deselecting the last keyword is
508
- // the reliable way to see it: the rescore is at its largest, so the fit
509
- // changes and the extra commit always lands.
510
- for (const { el } of moved) {
511
- el.style.transition = "";
512
- el.style.translate = "";
513
- }
514
- };
515
537
  });
516
538
 
517
539
  /** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
@@ -38,7 +38,7 @@ export function PackageCard({ pkg: p, vscodeExtension, activeKeywords, onToggleK
38
38
  const thread = externalUrl(p.rating.url);
39
39
  const vote = vscodeVoteUrl(vscodeExtension, p.ref);
40
40
  return (_jsxs("span", { class: "rating-group", children: [thread ? (_jsxs("a", { class: "rating-count", href: thread, target: "_blank", rel: "noopener noreferrer", tabIndex: -1, title: `${votes} — open the thread to vote`, "aria-label": `${p.name}: ${votes}. Open the voting thread`, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })) : (_jsxs("span", { class: "rating-count", title: votes, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })), vote && (_jsx("a", { class: "rating-vote", href: vote, tabIndex: -1, title: "Upvote in VS Code", "aria-label": `Upvote ${p.name} in VS Code`, children: _jsx(BrandMark, { path: mdiMicrosoftVisualStudioCode, size: 12 }) }))] }));
41
- })(), _jsxs("p", { class: "namespace", children: [_jsx("span", { class: "kind", "data-slot": "package-kind", children: p.kind }), _jsx("span", { "aria-hidden": "true", children: " \u00B7 " }), p.namespace] })] }), p.keywords && p.keywords.length > 0 && (_jsx("div", { class: "keywords", "data-slot": "package-keywords", children: p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: activeKeywords.includes(kw)
41
+ })(), _jsxs("p", { class: "namespace", children: [_jsx("span", { class: "kind", "data-slot": "package-kind", children: p.kind }), _jsx("span", { "aria-hidden": "true", children: " \u00B7 " }), _jsx("span", { class: "address", title: p.namespace, children: p.namespace })] })] }), p.keywords && p.keywords.length > 0 && (_jsx("div", { class: "keywords", "data-slot": "package-keywords", children: p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: activeKeywords.includes(kw)
42
42
  ? "chip keyword active"
43
43
  : "chip keyword", "aria-pressed": activeKeywords.includes(kw), tabIndex: -1, onClick: () => onToggleKeyword(kw), children: kw }, kw))) })), p.description && _jsx("p", { class: "description", children: p.description }), _jsxs("div", { class: "card-foot", children: [_jsxs("div", { class: "copy-group", children: [_jsx(CopyButton, { command: `grim add --global ${p.ref}`, variant: "global", name: `global add for ${p.name}` }), _jsx(CopyButton, { command: `grim add ${p.ref}`, name: `project add for ${p.name}` }), vscodeUrl(vscodeExtension, p.ref) && (_jsx("a", { class: "copy vscode", href: vscodeUrl(vscodeExtension, p.ref), title: "Open in VS Code", "aria-label": `Open ${p.name} in VS Code`, tabIndex: -1, children: _jsx(BrandMark, { path: mdiMicrosoftVisualStudioCode }) }))] }), (() => {
44
44
  const at = lastUpdated(p);
@@ -175,7 +175,15 @@ export function PackageCard({
175
175
  {p.kind}
176
176
  </span>
177
177
  <span aria-hidden="true"> · </span>
178
- {p.namespace}
178
+ {/* Its own element so the row can give the address the slack and
179
+ nothing else: the kind is one short word and keeps its width,
180
+ and what does not fit is dropped off the FRONT — a registry
181
+ host is the least distinguishing part of an address and the
182
+ repository is the most. `title` keeps the whole of it
183
+ reachable, since the ellipsis hides the head. */}
184
+ <span class="address" title={p.namespace}>
185
+ {p.namespace}
186
+ </span>
179
187
  </p>
180
188
  </div>
181
189
  {/* Under the head, above the description: what the package
@@ -849,10 +849,6 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
849
849
  font-size: var(--grim-text-md);
850
850
  min-width: 0;
851
851
  }
852
- .controls input[type="search"]:focus-visible {
853
- outline: 2px solid var(--grim-color-accent);
854
- outline-offset: 1px;
855
- }
856
852
  /* The `/` shortcut, shown as the key it is. It is a hint for a field
857
853
  that is not yet in use, so it withdraws the moment the field is
858
854
  focused or holds a query — at which point it would only be
@@ -1026,11 +1022,6 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1026
1022
  .sort-field:focus-visible {
1027
1023
  z-index: 1;
1028
1024
  }
1029
- .sort-dir:focus-visible,
1030
- .sort-field:focus-visible {
1031
- outline: 2px solid var(--grim-color-accent);
1032
- outline-offset: 1px;
1033
- }
1034
1025
  /* One box for every chip in the filter row — the kinds, the keywords,
1035
1026
  the deprecated toggle and the overflow menu's trigger alike. The
1036
1027
  line-height is stated rather than inherited because a `<summary>`
@@ -1062,21 +1053,13 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1062
1053
  background: var(--grim-color-accent-soft);
1063
1054
  }
1064
1055
  /* The keyword rail reorders on every click — it is rescored against
1065
- what is on screen — and the chips slide rather than jump. The FLIP
1066
- effect in `Catalog.tsx` sets an inline `translate` back to where
1067
- each chip was and drops it on the next frame; this transition is
1068
- what plays that back. Without a declared duration the invert would
1069
- simply stick. */
1070
- .chip.kw {
1071
- transition:
1072
- border-color var(--grim-duration-base) ease,
1073
- translate var(--grim-duration-slow) ease-out;
1074
- }
1075
- @media (prefers-reduced-motion: reduce) {
1076
- .chip.kw {
1077
- transition: border-color var(--grim-duration-base) ease;
1078
- }
1079
- }
1056
+ what is on screen — and the chips slide rather than jump. There is
1057
+ deliberately no `translate` transition here: the FLIP effect in
1058
+ `Catalog.tsx` runs each slide as a Web Animation instead, reading
1059
+ `--grim-duration-slow` for its length and honouring
1060
+ `prefers-reduced-motion` itself. A transition needs an inline style
1061
+ to drive it, and an inline style is what a superseded pass used to
1062
+ leave behind on a chip stopped off its seat. */
1080
1063
  /* The overflow menu. A popover, and it has to be one: `.filter-row`
1081
1064
  above is a scroll container, and an absolutely-positioned panel
1082
1065
  inside a scroll container is cropped to the row AND stretches the
@@ -1143,10 +1126,6 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1143
1126
  font: inherit;
1144
1127
  font-size: var(--grim-text-sm);
1145
1128
  }
1146
- .kw-menu-search:focus-visible {
1147
- outline: 2px solid var(--grim-color-accent);
1148
- outline-offset: 1px;
1149
- }
1150
1129
  .kw-menu-list {
1151
1130
  display: flex;
1152
1131
  flex-direction: column;
@@ -1240,9 +1219,26 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1240
1219
  .view-pick.active {
1241
1220
  z-index: 1;
1242
1221
  }
1222
+
1223
+ /* One focus ring for every bordered control in the toolbar, and it is
1224
+ the card's: the border takes the accent and the outline sits
1225
+ directly on it, so the two merge into one thicker edge.
1226
+
1227
+ An offset ring is what put two borders on the sort combo. Hover
1228
+ already tints the border, so a ring standing 1px clear of it drew a
1229
+ second line in the same colour — one state reading as two. Every
1230
+ control here tints its border on hover or while active, so the
1231
+ offset had to go from all of them together rather than from the one
1232
+ that was reported. */
1233
+ .chip:focus-visible,
1234
+ .controls input[type="search"]:focus-visible,
1235
+ .kw-menu-search:focus-visible,
1236
+ .sort-dir:focus-visible,
1237
+ .sort-field:focus-visible,
1243
1238
  .view-pick:focus-visible {
1244
1239
  outline: 2px solid var(--grim-color-accent);
1245
- outline-offset: 1px;
1240
+ outline-offset: 0;
1241
+ border-color: var(--grim-color-accent);
1246
1242
  }
1247
1243
 
1248
1244
  .grid {
@@ -1745,6 +1741,34 @@ const { title, description, image = config.logo ?? config.favicon } = Astro.prop
1745
1741
  font-size: var(--grim-text-xs);
1746
1742
  line-height: 1.3;
1747
1743
  font-family: ui-monospace, "SFMono-Regular", monospace;
1744
+ /* One line, whatever the address is. A namespace breaks at its own
1745
+ slashes and dots, so left to itself a long one wrapped to a second
1746
+ line and made the card's head taller than its neighbours'. */
1747
+ display: flex;
1748
+ min-width: 0;
1749
+ white-space: nowrap;
1750
+ }
1751
+ /* Trimmed from the FRONT: `github.com/` is on every address in an
1752
+ index and the repository at the tail is what tells two apart, so the
1753
+ head is what to spend.
1754
+
1755
+ `direction: rtl` is the mechanism — `text-overflow` ellipsises at
1756
+ the line's inline END, which in an RTL box is its left edge, and
1757
+ `text-align: left` keeps a short address sitting under the name
1758
+ rather than flung to the right. The address itself still reads
1759
+ left-to-right: it is one run of Latin characters, so the bidi
1760
+ algorithm lays it out LTR inside the RTL box. That holds because a
1761
+ namespace neither starts nor ends with punctuation — one that did
1762
+ would have that character reordered to the far end.
1763
+
1764
+ The two-value `text-overflow: ellipsis ""` would say this directly
1765
+ and is Firefox-only. */
1766
+ .namespace .address {
1767
+ min-width: 0;
1768
+ overflow: hidden;
1769
+ text-overflow: ellipsis;
1770
+ direction: rtl;
1771
+ text-align: left;
1748
1772
  }
1749
1773
  /* The rating is one control with two halves — the count, which opens
1750
1774
  the forge thread, and the vote, which casts one — joined the way
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grimoire-rs/indexer",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "CLI + Astro integration for running your own grim package index",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",