diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx
new file mode 100644
index 00000000..19d4c3d0
--- /dev/null
+++ b/frontend/src/components/ChannelTags.tsx
@@ -0,0 +1,260 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { Check, Plus } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { type Tag } from "../lib/api";
+import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
+import { useScrollFade } from "../lib/useScrollFade";
+
+// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
+// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
+// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags
+// (which grows the chip row) never moves it, and the editor popover doesn't drift.
+// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
+// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
+// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
+// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
+// no longer gated on the top-of-page "Manage tags" dialog.
+
+interface TagChipProps {
+ tag: Tag;
+ onClick?: () => void;
+ title?: string;
+}
+
+function TagChip({ tag, onClick, title }: TagChipProps) {
+ const cls =
+ "shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate";
+ return onClick ? (
+
+ ) : (
+ {tag.name}
+ );
+}
+
+/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
+ * "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
+ * detail page in Part 2; internal for now.) */
+function TagEditorMenu({
+ userTags,
+ attachedIds,
+ onToggle,
+ onCreate,
+}: {
+ userTags: Tag[];
+ attachedIds: number[];
+ onToggle: (tagId: number) => void;
+ onCreate: (name: string) => void;
+}) {
+ const { t } = useTranslation();
+ const [draft, setDraft] = useState("");
+ const attached = new Set(attachedIds);
+ const submit = () => {
+ const name = draft.trim();
+ if (!name) return;
+ onCreate(name);
+ setDraft("");
+ };
+ return (
+
+ );
+}
+
+/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals
+ * all), followed by a stable "+" trigger that opens the shared editor. Fills its container width,
+ * so a table cell bounds it via the column width and a card bounds it via the card. */
+export function ChannelTagsControl({
+ userTags,
+ attachedIds,
+ onToggle,
+ onCreate,
+ onChipClick,
+}: {
+ userTags: Tag[];
+ attachedIds: number[];
+ onToggle: (tagId: number) => void;
+ onCreate: (name: string) => void;
+ /** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
+ onChipClick?: (tagId: number, name: string) => void;
+}) {
+ const { t } = useTranslation();
+ const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
+
+ // Greedy single-line fit: start showing all, shrink one chip at a time until the row (including
+ // the "+N" pill it then needs) fits its bounded width. Re-expands and re-fits on width/scale
+ // change via ResizeObserver — measurement is off the real rem-rendered chips, not a px guess.
+ const rowRef = useRef(null);
+ const [maxWidth, setMaxWidth] = useState(0);
+ const [visible, setVisible] = useState(attached.length);
+
+ // Track the bounded width so a font-scale/viewport change re-fits — measured off the real box,
+ // never a px guess (widths here are rem and grow with the text-size slider).
+ useLayoutEffect(() => {
+ const el = rowRef.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver((entries) => {
+ const w = entries[0]?.contentRect.width;
+ if (w != null) setMaxWidth(w);
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ // Reset to "show all" whenever the tag set or the available width changes…
+ useLayoutEffect(() => {
+ setVisible(attached.length);
+ }, [attached.length, maxWidth]);
+
+ // …then drop one chip at a time until the row (with the "+N" pill it then needs) fits its width.
+ useLayoutEffect(() => {
+ const el = rowRef.current;
+ if (el && el.scrollWidth > el.clientWidth + 1 && visible > 0) setVisible((v) => v - 1);
+ }, [visible, maxWidth, attached.length]);
+
+ const hidden = attached.length - visible;
+
+ const [editorOpen, setEditorOpen] = useState(false);
+ const editPop = useAnchoredPopover({
+ open: editorOpen,
+ onOpenChange: setEditorOpen,
+ mode: "menu",
+ placement: "bottom-start",
+ });
+
+ const [overflowOpen, setOverflowOpen] = useState(false);
+ const overflowPop = useAnchoredPopover({
+ open: overflowOpen,
+ onOpenChange: setOverflowOpen,
+ mode: "hover",
+ placement: "bottom",
+ });
+ const fade = useScrollFade();
+
+ return (
+ // max-w caps the cell's MAX-CONTENT so `table-layout:auto` can't ignore the column width and let
+ // an over-tagged row stretch the whole table (the documented channel/About-column trap). The chip
+ // row then greedily fits within it and collapses the rest into "+N".
+