diff --git a/src/components.tsx b/src/components.tsx
index 6f9840e..8b41c07 100644
--- a/src/components.tsx
+++ b/src/components.tsx
@@ -1709,6 +1709,106 @@ export function ViewToggle({
);
}
+// --- Tiles per row --------------------------------------------------------
+
+// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in
+// styles.css — used to work out how many columns that default fills.
+const TILE_MIN_WIDTH = 190;
+const TILE_GAP = 16;
+const TILE_MAX_COLUMNS = 12;
+
+export interface TileColumns {
+ /** Columns currently shown (the width-based default plus the adjustment). */
+ columns: number;
+ /** True while the user has moved away from the width-based default. */
+ adjusted: boolean;
+ adjust: (delta: number) => void;
+ reset: () => void;
+ /** Spread onto each `.tile-grid` (`
`). */
+ gridProps: { ref: (el: HTMLDivElement | null) => void; className: string; style?: React.CSSProperties };
+}
+
+/** Per-row tile count for the Tiles view, adjustable with `TileColumnsControl`.
+ *
+ * The default stays the CSS auto-fill (as many ~190px tiles as fit), so the
+ * persisted setting is an *offset* from that rather than a fixed count: +1
+ * means "one more than would fit by default" on a phone and a desktop alike,
+ * and it keeps tracking the width across resizes and rotation. One offset is
+ * shared by every tiles view. */
+export function useTileColumns(): TileColumns {
+ const [offset, setOffset] = usePersistedState("tile-columns-offset", 0);
+ const [auto, setAuto] = useState(0);
+ const observer = useRef(null);
+
+ // Every grid in a view (one per group) shares the same width, so whichever
+ // mounted last is the one measured.
+ const ref = useCallback((el: HTMLDivElement | null) => {
+ if (!el) return;
+ observer.current?.disconnect();
+ // A grid that's been unmounted (e.g. switching to List) reports width 0;
+ // keep the last real measurement instead.
+ const measure = (w: number) => {
+ if (w > 0) setAuto(Math.max(1, Math.floor((w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP))));
+ };
+ measure(el.clientWidth);
+ observer.current = new ResizeObserver((entries) => measure(entries[0].contentRect.width));
+ observer.current.observe(el);
+ }, []);
+ useEffect(() => () => observer.current?.disconnect(), []);
+
+ const base = auto || 1;
+ const columns = Math.min(TILE_MAX_COLUMNS, Math.max(1, base + offset));
+ const adjusted = offset !== 0 && auto > 0;
+ return {
+ columns,
+ adjusted,
+ // Clamp against the visible count, so pressing − at one column doesn't
+ // bank an offset that has to be undone before + does anything.
+ adjust: (delta) => {
+ const next = Math.min(TILE_MAX_COLUMNS, Math.max(1, columns + delta));
+ setOffset(next - base);
+ },
+ reset: () => setOffset(0),
+ gridProps: adjusted
+ ? {
+ ref,
+ className: columns === 1 ? "tile-grid tile-grid-single" : "tile-grid",
+ style: { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` },
+ }
+ : { ref, className: "tile-grid" },
+ };
+}
+
+/** − N + stepper for the tiles-per-row count; clicking N resets to the default. */
+export function TileColumnsControl({ tiles, disabled }: { tiles: TileColumns; disabled?: boolean }) {
+ return (
+
+
+
+
+
+ );
+}
+
/**
* A single card rendered as a full image tile, with optional corner + footer.
* Clicking the image opens a larger click-to-preview modal.
diff --git a/src/styles.css b/src/styles.css
index bb2a6a0..3f83762 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2304,6 +2304,11 @@ tbody tr:hover {
.tile-rotated {
grid-column: span 2;
}
+/* …except when the per-row control is down to one column, where a span of 2
+ would add an implicit second column and overflow. */
+.tile-grid-single .tile-rotated {
+ grid-column: auto;
+}
.tile-img-wrap {
position: relative;
}
@@ -2447,6 +2452,11 @@ tbody tr:hover {
gap: 4px;
align-items: center;
}
+/* Inputs keep an intrinsic ~160px width that would overflow tiles narrowed
+ by the per-row control; let them shrink with the tile instead. */
+.tile-footer input {
+ min-width: 0;
+}
/* ============================================================
Toolbar controls: group-by + view toggle
@@ -2491,6 +2501,25 @@ tbody tr:hover {
background: var(--accent);
color: #fff;
}
+.view-toggle button:disabled {
+ background: transparent;
+ opacity: 0.4;
+ cursor: default;
+}
+/* Tiles-per-row stepper (see `TileColumnsControl`): same segmented look as
+ the List/Tiles toggle, with a fixed-width count between − and +. */
+.tile-columns button {
+ padding: 5px 9px;
+ font-variant-numeric: tabular-nums;
+}
+.tile-columns .tile-columns-count {
+ min-width: 2.2em;
+ padding-left: 4px;
+ padding-right: 4px;
+}
+.tile-columns .tile-columns-count:disabled {
+ opacity: 1;
+}
/* ============================================================
Deck metadata (format + commander)
diff --git a/src/views/CardSearch.tsx b/src/views/CardSearch.tsx
index 2bde51c..91a2c9b 100644
--- a/src/views/CardSearch.tsx
+++ b/src/views/CardSearch.tsx
@@ -14,6 +14,7 @@ import {
SortDir,
SortMode,
TagInput,
+ TileColumnsControl,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
@@ -22,6 +23,7 @@ import {
sortCards,
useCardCriteria,
usePersistedState,
+ useTileColumns,
useViewMode,
} from "../components";
@@ -76,6 +78,7 @@ export function CardSearch({
const selectAllRef = useRef(null);
const [view, setView] = useViewMode("cardsearch-view");
+ const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState("cardsearch-group", "none");
const [sortBy, setSortBy] = usePersistedState("cardsearch-sort", "name");
const [sortDir, setSortDir] = usePersistedState("cardsearch-sort-dir", "asc");
@@ -335,7 +338,7 @@ export function CardSearch({
function refTiles(cards: LibraryCard[]) {
return (
-