+
onQueryChange("")}
- className="text-base-fg/45 transition-colors hover:text-base-fg"
+ className="rounded-full text-base-fg/45 transition-colors hover:text-base-fg focus:outline-none focus-visible:text-base-fg focus-visible:ring-2 focus-visible:ring-primary"
>
@@ -57,6 +57,7 @@ export const SmartSearchBar = ({
onClick={() => onToggleTag(tag)}
className={[
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors duration-150",
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-primary text-white"
: "bg-base-fg/8 text-base-fg/70 hover:bg-base-fg/15",
diff --git a/frontend/libs/components/moodboard/src/lib/grid/gridLayout.spec.ts b/frontend/libs/components/moodboard/src/lib/grid/gridLayout.spec.ts
new file mode 100644
index 0000000000..b03837a993
--- /dev/null
+++ b/frontend/libs/components/moodboard/src/lib/grid/gridLayout.spec.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import {
+ PositionedItem,
+ computeMasonryLayout,
+ nearestInDirection,
+} from "./gridLayout";
+
+// A 3x3 grid of 100x100 tiles on a 20px pitch — centers at 50/170/290.
+const GRID: Record = {};
+["a", "b", "c", "d", "e", "f", "g", "h", "i"].forEach((id, idx) => {
+ const col = idx % 3;
+ const row = Math.floor(idx / 3);
+ GRID[id] = { id, x: col * 120, y: row * 120, width: 100, height: 100 };
+});
+// Layout: a b c
+// d e f
+// g h i
+
+describe("nearestInDirection", () => {
+ it("moves to the adjacent tile on each axis from the center", () => {
+ expect(nearestInDirection(GRID, "e", "left")).toBe("d");
+ expect(nearestInDirection(GRID, "e", "right")).toBe("f");
+ expect(nearestInDirection(GRID, "e", "up")).toBe("b");
+ expect(nearestInDirection(GRID, "e", "down")).toBe("h");
+ });
+
+ it("returns null when there is nothing in that direction", () => {
+ expect(nearestInDirection(GRID, "a", "left")).toBeNull();
+ expect(nearestInDirection(GRID, "a", "up")).toBeNull();
+ expect(nearestInDirection(GRID, "i", "right")).toBeNull();
+ expect(nearestInDirection(GRID, "i", "down")).toBeNull();
+ });
+
+ it("stays in the same row/column rather than jumping diagonally", () => {
+ // From the top-left, moving down should land on the tile directly below
+ // (same column), never the closer-by-raw-distance diagonal neighbor.
+ expect(nearestInDirection(GRID, "a", "down")).toBe("d");
+ expect(nearestInDirection(GRID, "a", "right")).toBe("b");
+ });
+
+ it("returns null for an unknown origin id", () => {
+ expect(nearestInDirection(GRID, "zzz", "down")).toBeNull();
+ });
+});
+
+describe("computeMasonryLayout", () => {
+ it("indexes every item in byId and keeps positions in sync", () => {
+ const layout = computeMasonryLayout(
+ [
+ { id: "x", aspect: 1 },
+ { id: "y", aspect: 1.5 },
+ ],
+ 600,
+ { targetColumnWidth: 280, gap: 20, minColumns: 1, maxColumns: 8 },
+ );
+ expect(Object.keys(layout.byId)).toHaveLength(2);
+ expect(layout.byId.x).toBe(layout.positions[0]);
+ });
+});
diff --git a/frontend/libs/components/moodboard/src/lib/grid/gridLayout.ts b/frontend/libs/components/moodboard/src/lib/grid/gridLayout.ts
index e89a23279e..d785b02739 100644
--- a/frontend/libs/components/moodboard/src/lib/grid/gridLayout.ts
+++ b/frontend/libs/components/moodboard/src/lib/grid/gridLayout.ts
@@ -88,6 +88,56 @@ export const computeMasonryLayout = (
};
};
+export type NavDirection = "up" | "down" | "left" | "right";
+
+// Spatial navigation: the nearest card to `fromId` in a direction, biased toward
+// staying in the same row/column (cross-axis distance weighted more). Used for
+// arrow-key focus movement across the masonry.
+export const nearestInDirection = (
+ byId: Record,
+ fromId: string,
+ dir: NavDirection,
+): string | null => {
+ const from = byId[fromId];
+ if (!from) return null;
+ const fcx = from.x + from.width / 2;
+ const fcy = from.y + from.height / 2;
+ let best: string | null = null;
+ let bestScore = Infinity;
+ for (const p of Object.values(byId)) {
+ if (p.id === fromId) continue;
+ const cx = p.x + p.width / 2;
+ const cy = p.y + p.height / 2;
+ const dx = cx - fcx;
+ const dy = cy - fcy;
+ let primary: number;
+ let cross: number;
+ if (dir === "right") {
+ if (dx <= 1) continue;
+ primary = dx;
+ cross = Math.abs(dy);
+ } else if (dir === "left") {
+ if (dx >= -1) continue;
+ primary = -dx;
+ cross = Math.abs(dy);
+ } else if (dir === "down") {
+ if (dy <= 1) continue;
+ primary = dy;
+ cross = Math.abs(dx);
+ } else {
+ if (dy >= -1) continue;
+ primary = -dy;
+ cross = Math.abs(dx);
+ }
+ const score = primary + cross * 2;
+ if (score < bestScore) {
+ bestScore = score;
+ best = p.id;
+ }
+ }
+ return best;
+};
+
// Returns only positions intersecting the scroll viewport (± buffer). Linear
// scan is fine to ~thousands of items; positions are already y-ordered enough
// for the predicate to be cheap.