diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py
index a8d04736..cb05aa8e 100644
--- a/backend/tests/test_feed_views.py
+++ b/backend/tests/test_feed_views.py
@@ -96,6 +96,7 @@ def _seed_tagged(db, make_user):
"T1": Tag(user_id=None, name="Tech", category="topic"),
"T2": Tag(user_id=None, name="Gaming", category="topic"),
"O1": Tag(user_id=None, name="Favourite", category="other"),
+ "O2": Tag(user_id=None, name="Docs", category="other"),
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
@@ -104,8 +105,15 @@ def _seed_tagged(db, make_user):
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
db.add_all(chans.values())
db.flush()
- # channel -> (system) tag keys
- links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []}
+ # channel -> (system) tag keys. chA carries two languages (L1+L2) and chC two "other" tags
+ # (O1+O2) so the per-category any/all match narrows non-trivially for language and other, not
+ # only topic (chA/chB share topic tags T1/T2).
+ links = {
+ "chA": ["L1", "L2", "T1"],
+ "chB": ["L1", "T2"],
+ "chC": ["L2", "T1", "O1", "O2"],
+ "chD": [],
+ }
for cid, tkeys in links.items():
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
for tk in tkeys:
@@ -129,6 +137,10 @@ _SUMMARY_CASES = [
"show=all&tags={L1}&tags={T1}",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=or",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
+ # Per-category any/all is NOT topic-only — language and other must drill down identically too.
+ "show=all&tags={L1}&tags={L2}&tag_mode_language=or",
+ "show=all&tags={L1}&tags={L2}&tag_mode_language=and",
+ "show=all&tags={O1}&tags={O2}&tag_mode_other=and",
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
"show=all&q=clip",
diff --git a/frontend/src/components/Combobox.dom.test.tsx b/frontend/src/components/Combobox.dom.test.tsx
index e675a58f..19401cc5 100644
--- a/frontend/src/components/Combobox.dom.test.tsx
+++ b/frontend/src/components/Combobox.dom.test.tsx
@@ -205,6 +205,31 @@ describe("Combobox", () => {
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
+ it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
+ // The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
+ // only at >= 2, and clicking 'all' reports the mode change out.
+ const onMatchModeChange = vi.fn();
+ const props = {
+ label: "Topic",
+ options: OPTIONS,
+ onChange: vi.fn(),
+ matchMode: "or" as const,
+ onMatchModeChange,
+ };
+ // One selection: no footer.
+ const { rerender } = wrap();
+ fireEvent.focus(screen.getByRole("combobox"));
+ expect(screen.queryByText("sidebar.all")).toBeNull();
+
+ // Two selections: footer appears with an any/all segment.
+ rerender({});
+ fireEvent.focus(screen.getByRole("combobox"));
+ const all = screen.getByText("sidebar.all");
+ expect(all).toBeTruthy();
+ fireEvent.click(all);
+ expect(onMatchModeChange).toHaveBeenCalledWith("and");
+ });
+
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
diff --git a/frontend/src/lib/api/tagModes.test.ts b/frontend/src/lib/api/tagModes.test.ts
new file mode 100644
index 00000000..33b5cecf
--- /dev/null
+++ b/frontend/src/lib/api/tagModes.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
+
+// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
+// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
+describe("coerceTagModes", () => {
+ it("defaults to all-'or' for empty/undefined input", () => {
+ expect(coerceTagModes(undefined)).toEqual(DEFAULT_TAG_MODES);
+ expect(coerceTagModes({})).toEqual(DEFAULT_TAG_MODES);
+ expect(coerceTagModes(null)).toEqual(DEFAULT_TAG_MODES);
+ });
+
+ it("migrates a legacy global tagMode:'and' into the topic slot only", () => {
+ expect(coerceTagModes({ tagMode: "and" })).toEqual({
+ language: "or",
+ topic: "and",
+ other: "or",
+ });
+ });
+
+ it("ignores a legacy tagMode:'or' (stays all-'or')", () => {
+ expect(coerceTagModes({ tagMode: "or" })).toEqual(DEFAULT_TAG_MODES);
+ });
+
+ it("preserves an explicit per-category tagModes object", () => {
+ expect(coerceTagModes({ tagModes: { language: "and", topic: "or", other: "and" } })).toEqual({
+ language: "and",
+ topic: "or",
+ other: "and",
+ });
+ });
+
+ it("an explicit per-category value wins over the legacy scalar", () => {
+ // A blob carrying both should honour the new per-category topic, not the legacy global.
+ expect(coerceTagModes({ tagMode: "and", tagModes: { topic: "or" } }).topic).toBe("or");
+ });
+
+ it("coerces invalid/garbage values to 'or'", () => {
+ expect(coerceTagModes({ tagModes: { topic: "x" as unknown as "or" } }).topic).toBe("or");
+ expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
+ });
+});
diff --git a/frontend/src/lib/chipOverflow.test.ts b/frontend/src/lib/chipOverflow.test.ts
new file mode 100644
index 00000000..02ae1d99
--- /dev/null
+++ b/frontend/src/lib/chipOverflow.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from "vitest";
+import { decideChipLayout, type ChipFitInput } from "./chipOverflow";
+
+// Build measurer-style right edges for `n` chips of a fixed width, starting after a label, with a gap.
+function rights(labelRight: number, widths: number[], gap: number): number[] {
+ const out: number[] = [];
+ let cursor = labelRight;
+ for (const w of widths) {
+ cursor += gap + w;
+ out.push(cursor);
+ }
+ return out;
+}
+
+const base = (over: Partial = {}): ChipFitInput => {
+ const gap = 6;
+ const labelRight = 60;
+ const widths = [50, 50, 50]; // 3 chips
+ return {
+ n: 3,
+ labelRight,
+ chipRights: rights(labelRight, widths, gap),
+ plusW: 40,
+ clearW: 6 + 60,
+ avail: 1000,
+ gap,
+ narrowPx: 420,
+ ...over,
+ };
+};
+
+describe("decideChipLayout", () => {
+ it("shows all chips when the whole set plus Clear all fits", () => {
+ expect(decideChipLayout(base({ avail: 1000 }))).toEqual({ mode: "all", visible: 3 });
+ });
+
+ it("empty chip set is a no-op 'all' with 0 visible", () => {
+ expect(decideChipLayout(base({ n: 0, chipRights: [] }))).toEqual({ mode: "all", visible: 0 });
+ });
+
+ it("collapses to a single count pill on a narrow lane when overflowing", () => {
+ // last chip right edge is ~228; avail below NARROW and below that -> count.
+ expect(decideChipLayout(base({ avail: 200, narrowPx: 420 }))).toEqual({
+ mode: "count",
+ visible: 0,
+ });
+ });
+
+ it("fits as many chips as leave room for the +N pill on a wide-but-tight lane", () => {
+ // chip right edges: 116, 172, 228; clearW 66. all-fit needs 228+66=294 <= avail; keep avail below
+ // that (so not 'all') but >= narrowPx (so not 'count'). Loop reserves gap(6)+plus(40)=46 per chip:
+ // chip2 228+46=274 > 250 -> dropped; chip1 218 <= 250 -> visible 2.
+ expect(decideChipLayout(base({ avail: 250, narrowPx: 200 }))).toEqual({
+ mode: "some",
+ visible: 2,
+ });
+ });
+
+ it("reserves the +N pill width so a later chip is dropped when it wouldn't fit beside it", () => {
+ // Two chips of width 100 after a label, gap 6, plus pill 40. Right edges: 166, 272. avail 300,
+ // narrowPx 100 (so 300 >= narrow -> 'some'): chip0 166+46=212 <= 300 ok; chip1 272+46=318 > 300
+ // -> dropped. visible 1.
+ const input = base({
+ n: 2,
+ chipRights: rights(60, [100, 100], 6),
+ plusW: 40,
+ clearW: 6 + 60,
+ avail: 300,
+ gap: 6,
+ narrowPx: 100,
+ });
+ expect(decideChipLayout(input)).toEqual({ mode: "some", visible: 1 });
+ });
+
+ it("falls back to the count pill when not even one chip fits beside the +N pill", () => {
+ // Wide-ish lane (>= narrow) but a single very wide chip: label 60, chip width 500 -> right 566.
+ // avail 430 >= narrow(420); all-fit 566+clearW > 430; loop: 566+6+40 > 430 -> k=0; labelRight
+ // 60+6+40=106 <= 430 so NOT count by that guard... use narrow-avail instead: keep avail 430 but
+ // make label+gap+plus exceed it to force count.
+ const input = base({
+ n: 1,
+ labelRight: 400,
+ chipRights: [966],
+ plusW: 40,
+ clearW: 0,
+ avail: 430,
+ gap: 6,
+ narrowPx: 420,
+ });
+ // label 400 + gap 6 + plus 40 = 446 > 430 and k===0 -> count.
+ expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 });
+ });
+});
diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts
index 237f85db..3e444bcd 100644
--- a/frontend/src/lib/urlState.dom.test.ts
+++ b/frontend/src/lib/urlState.dom.test.ts
@@ -121,4 +121,23 @@ describe("filterParamUpdates", () => {
expect(back.scope).toBe("all");
expect(back.includeShorts).toBe(true);
});
+
+ it("round-trips per-category tag modes (only 'and' is emitted)", () => {
+ const f: FeedFilters = {
+ ...DEFAULTS,
+ tags: [1, 2],
+ tagModes: { language: "and", topic: "and", other: "or" },
+ };
+ const params = filtersToParams(f);
+ expect(params.get("tagmode_language")).toBe("and");
+ expect(params.get("tagmode_topic")).toBe("and");
+ expect(params.has("tagmode_other")).toBe(false); // 'or' is the default, not emitted
+ const back = paramsToFilters(params, DEFAULTS);
+ expect(back.tagModes).toEqual({ language: "and", topic: "and", other: "or" });
+ });
+
+ it("migrates a legacy global ?tagmode=and into the topic slot only", () => {
+ const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
+ expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
+ });
});