feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts

`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up
(lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own
files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide,
which features/ may read too; a page is still opened lazily by import(). Its contract test probes
every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red.

Six modules got that face; the shared channel UI (about, tags, tag manager) moved to
features/channel, the filter chips and the saved-views context to features/filters, and the prefs
store's controller type moved next to the store instead of importing it from its own page. The
header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each
naming why: they go when the header gets slots and every rail loads lazily.

`chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the
entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its
import() a silent no-op. Proved on a build with that mutant; today's build has none.
This commit is contained in:
2026-10-22 23:30:27 +02:00
parent 63a0fecc8b
commit d5867bacca
66 changed files with 453 additions and 109 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { api } from "../../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import ChannelAboutContent from "../../features/channel/ChannelAbout";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { SURFACE } from "../../components/ui/surfaces";
@@ -1,116 +0,0 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../../lib/api";
import { linkify } from "../../lib/linkify";
import { Badge } from "../../components/ui/Badge";
import { languageName, regionName, vocab } from "../../lib/vocab";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
function parseKeywords(s: string): string[] {
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
}
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
// slug is the vocabulary word: named in the reader's language, else shown with spaces.
function topicSlugs(urls: string[]): string[] {
const seen = new Set<string>();
for (const url of urls) {
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
let slug = seg;
try {
slug = decodeURIComponent(seg);
} catch {
/* keep raw */
}
slug = slug.trim();
if (slug) seen.add(slug);
}
return [...seen];
}
/**
* The channel "About" content — description, external links, and the enriched metadata
* (country, language, topics, keywords). Shared by the channel detail page's About tab and the
* Channel manager's About-column hover overlay, so both show exactly the same thing.
*/
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
const { t, i18n } = useTranslation();
const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
const hasAboutDetails =
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
return (
<>
{ch.description ? (
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{linkify(ch.description)}
</p>
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
{ch.external_links && ch.external_links.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{ch.external_links.map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
{l.title || l.url}
</a>
))}
</div>
)}
{hasAboutDetails && (
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
{ch.country && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
<span className="inline-flex items-center gap-2">
{/^[A-Za-z]{2}$/.test(ch.country) && (
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
<span
className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
aria-hidden
/>
)}
{regionName(t, ch.country, i18n.language)}
</span>
</div>
)}
{ch.default_language && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
<span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
</div>
)}
{topics.length > 0 && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
<div className="flex flex-wrap gap-1.5">
{/* Future search epic: these topic chips become clickable filters. */}
{topics.map((tp) => (
<Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
))}
</div>
</div>
)}
{keywords.length > 0 && (
<div className="flex gap-2">
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<Badge key={i}>{kw}</Badge>
))}
</div>
</div>
)}
</div>
)}
</>
);
}
@@ -1,61 +0,0 @@
// @vitest-environment jsdom
//
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import { ChannelTagsControl } from "./ChannelTags";
import { api } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the channel tag editor", () => {
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<ChannelTagsControl
userTags={[]}
attachedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
/>
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("common.loadError");
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
expect(alert.closest("[role=menu]")).toBeNull();
expect(await screen.findByText("common.retry")).toBeTruthy();
});
});
@@ -1,105 +0,0 @@
// @vitest-environment jsdom
//
// Contract of the suggested-tag ghost chips: on an UNTAGGED channel, ChannelTagsControl renders
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
i18n: { language: "en" },
}),
};
});
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import type { Tag } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
const tag = (id: number, name: string): Tag => ({
id,
name,
color: null,
category: "other",
system: false,
channel_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
const suggestions: SuggestedTag[] = [
{ tag: docu, confidence: 0.95 },
{ tag: history, confidence: 0.6 },
];
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<FloatingTree>
<ChannelTagsControl
userTags={[docu, history]}
attachedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
suggestions={suggestions}
onAcceptSuggestion={onAccept}
onDismissSuggestion={onDismiss}
{...props}
/>
</FloatingTree>,
);
return { onAccept, onDismiss };
}
describe("ChannelTagsControl suggested ghost chips", () => {
it("renders a ghost chip per suggestion and accepts on click", () => {
const { onAccept } = renderControl();
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
expect(onAccept).toHaveBeenCalledWith(docu.id);
});
it("dismisses via the × without accepting", () => {
const { onAccept, onDismiss } = renderControl();
const dismissButtons = screen.getAllByRole("button", {
name: "channels.tags.dismissSuggestion",
});
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
// suggestions array, so the second × is the "history" suggestion.
expect(dismissButtons).toHaveLength(2);
fireEvent.click(dismissButtons[1]!);
expect(onDismiss).toHaveBeenCalledWith(history.id);
expect(onAccept).not.toHaveBeenCalled();
});
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
renderControl({ attachedIds: [docu.id] });
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
expect(
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
).toHaveLength(0);
});
});
@@ -1,442 +0,0 @@
import { useRef, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../../lib/api";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { Chip, DismissibleChip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { useTags } from "../../lib/useTags";
import { SURFACE } from "../../components/ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
// (which grows the chip row to its right) 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;
}
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
function TagChip({ tag, onClick, title }: TagChipProps) {
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
return onClick ? (
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
{name}
</Chip>
) : (
<span className={chipClass("tag", "xs")}>{name}</span>
);
}
// A suggested user tag: a dashed "ghost" chip the user hasn't applied yet, learned from
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
export interface SuggestedTag {
tag: Tag;
confidence: number;
}
function SuggestedChip({
suggestion,
onAccept,
onDismiss,
}: {
suggestion: SuggestedTag;
onAccept: () => void;
onDismiss: () => void;
}) {
const { t } = useTranslation();
const pct = Math.round(suggestion.confidence * 100);
return (
<DismissibleChip
variant="suggest"
size="xs"
className="max-w-36 font-medium"
icon={<Sparkles />}
onClick={onAccept}
bodyTitle={t("channels.tags.suggestedHint", { pct })}
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
onDismiss={onDismiss}
dismissLabel={t("channels.tags.dismissSuggestion")}
>
{suggestion.tag.name}
</DismissibleChip>
);
}
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
function SuggestedChipsLane({
suggestions,
onAccept,
onDismiss,
}: {
suggestions: SuggestedTag[];
onAccept: (tagId: number) => void;
onDismiss: (tagId: number) => void;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: key,
initial: suggestions.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, suggestions.length - visible);
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
// reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
// render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
// unmounts under the cursor gets no mouseleave), so without this it springs back the next time
// the lane overflows. Convergent — the write falsifies its own condition.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
const { setReference: setOverflowAnchor } = overflowPop.refs;
return (
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{suggestions.map((s, i) => (
<span
key={s.tag.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span>
))}
<span ref={pillRef} className={chipClass("suggest", "xs")}>
+{suggestions.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{suggestions.slice(0, visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
{hidden > 0 && (
<Chip
variant="suggest"
size="xs"
ref={setOverflowAnchor}
aria-label={t("channels.tags.moreCount", { count: hidden })}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</Chip>
)}
</div>
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
<PopoverSurface
popover={overflowPop}
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{suggestions.slice(visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
</PopoverSurface>
</div>
);
}
/** 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. The rows are the caller's
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
function TagEditorMenu({
userTags,
attachedIds,
onToggle,
onCreate,
}: {
userTags: Tag[];
attachedIds: number[];
onToggle: (tagId: number) => void;
onCreate: (name: string) => void;
}) {
const { t } = useTranslation();
const tags = useTags();
const attached = new Set(attachedIds);
return (
<MembershipMenu
dense
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
onToggle={(row) => onToggle(row.id as number)}
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
onRetry={() => void tags.refetch()}
emptyText={t("channels.tags.noneYet")}
errorText={t("common.loadError")}
create={{
placeholder: t("channels.tags.createPlaceholder"),
maxLength: 64,
submitLabel: t("channels.tags.create"),
onCreate: (name) => {
onCreate(name);
return true;
},
}}
/>
);
}
/** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor),
* followed by a width-bounded chip row with "+N" overflow (hover reveals all). 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,
suggestions,
onAcceptSuggestion,
onDismissSuggestion,
bounded = true,
}: {
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;
/** Manager only: suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
* chips after the (empty) attached row. Omit on the detail page. */
suggestions?: SuggestedTag[];
onAcceptSuggestion?: (tagId: number) => void;
onDismissSuggestion?: (tagId: number) => void;
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
* table (the manager). Set false where there is free width (the channel detail header), so tags
* fit inline instead of needlessly collapsing into "+N". */
bounded?: boolean;
}) {
const { t } = useTranslation();
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
// the channel already carries (an optimistic accept adds it before the query refetches).
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
// and it re-measures on width/scale change via the hook's ResizeObserver.
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: tagsKey,
initial: attached.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, attached.length - visible);
const [editorOpen, setEditorOpen] = useState(false);
const editPop = useAnchoredPopover({
open: editorOpen,
onOpenChange: setEditorOpen,
mode: "menu",
placement: "bottom-start",
// The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
// of the roving list so neither steals the field's caret.
hostsInput: true,
});
const { setReference: setEditAnchor } = editPop.refs;
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
// and the flag reset with it, or the card returns unasked on the next overflow.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
const { setReference: setOverflowAnchor } = overflowPop.refs;
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
const pillClass = chipClass("filter", "xs");
return (
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
// content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
// table. The fixed width keeps the table stable and centered; the chip row fits what it can and
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<div
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
>
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
<Chip
variant="ghost"
size="xs"
ref={setEditAnchor}
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
icon={<Plus className="size-3" />}
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
{...editPop.getReferenceProps()}
>
{!bounded && t("channels.tags.add")}
</Chip>
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
channels only, and accepting one removes the channel from the suggestion set — so a channel
never shows both, and each lane gets the full cell width instead of clipping the other. */}
{attached.length === 0 && shownSuggestions.length > 0 ? (
<SuggestedChipsLane
suggestions={shownSuggestions}
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
/>
) : (
<>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
right edge; the pill is the sample the hook reserves room for. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<TagChip tag={tg} />
</span>
))}
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
}
/>
))}
{hidden > 0 && (
<Chip
size="xs"
ref={setOverflowAnchor}
aria-label={t("channels.tags.moreCount", { count: hidden })}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</Chip>
)}
</div>
</>
)}
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface
popover={overflowPop}
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{attached.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
</PopoverSurface>
{/* Click: the shared editor (toggle existing + create inline). */}
<PopoverSurface
popover={editPop}
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
listInside
>
<TagEditorMenu
userTags={userTags}
attachedIds={attachedIds}
onToggle={onToggle}
onCreate={onCreate}
/>
</PopoverSurface>
</div>
);
}
+4 -4
View File
@@ -33,14 +33,14 @@ import { useChannelActions } from "./useChannelActions";
import { useContextMenu } from "../../components/ui/useContextMenu";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useChannelTagActions } from "../../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useTagSuggestions } from "../../features/channel/useTagSuggestions";
import { useChannelPriority } from "../../lib/useChannelPriority";
import { placeByPriority } from "../../lib/channelOrder";
import { focusNavItem } from "../../lib/listNav";
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
import ColumnSortControl from "../../components/ui/ColumnSortControl";
import FilterChipGroup from "../../components/ui/FilterChipGroup";
import TagFilterChips from "../feed/TagFilterChips";
import TagFilterChips from "../../features/filters/TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "../../components/ui/Pager";
@@ -57,10 +57,10 @@ import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { optionsFromColumns } from "../../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import TagManager from "../../features/channel/TagManager";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
import { useFeedFilters, useFeedFiltersActions } from "../feed";
import { useWizard } from "../../features/wizard/wizardContext";
import { useMe } from "../../lib/useMe";
import { useTags } from "../../lib/useTags";
@@ -1,230 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
import { useTags } from "../../lib/useTags";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
// Channel manager). The popover rides the shared platform, so the list's own scroll can't clip it and
// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer.
function TagRow({
tag,
channels,
onRename,
onDelete,
onPickChannel,
}: {
tag: Tag;
channels: { id: string; title: string }[];
onRename: (name: string) => void;
onDelete: () => void;
onPickChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
const { ref: popFadeRef, style: popFadeStyle } = useScrollFade();
const commit = () => {
const v = name.trim();
if (v && v !== tag.name) onRename(v);
else setName(tag.name);
};
const pop = useAnchoredPopover({
open: open && channels.length > 0,
onOpenChange: setOpen,
mode: "hover",
placement: "right-start",
offset: 8,
hoverDelay: { open: 0, close: 150 },
});
const { setReference } = pop.refs;
return (
<div className="flex items-center gap-2">
<Input
className="flex-1"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
/>
<span
ref={setReference}
{...pop.getReferenceProps()}
className={`text-xs text-muted tabular-nums shrink-0 ${
channels.length
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
: ""
}`}
>
{t("tagManager.channels", { count: tag.channel_count })}
</span>
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
<Trash2 />
</IconButton>
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}>
<div className="text-2xs uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
<div
ref={popFadeRef}
style={popFadeStyle}
className="max-h-72 overflow-y-auto no-scrollbar"
>
{channels.map((ch) => (
<Pressable
key={ch.id}
layer
onClick={() => onPickChannel(ch.title)}
className="w-full text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg truncate"
>
{ch.title}
</Pressable>
))}
</div>
</PopoverSurface>
</div>
);
}
export default function TagManager({
onClose,
onFocusChannel,
}: {
onClose: () => void;
onFocusChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
const [newName, setNewName] = useState("");
const tagsQuery = useTags();
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? [])
.filter((c) => c.tag_ids.includes(tagId))
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.feed() });
};
const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
meta: { failTitle: "channels.tags.createFailed" },
onSuccess: () => {
setNewName("");
invalidate();
},
});
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
const onFailed = (e: unknown, fallback: string) => {
notifyActionError(e, fallback);
invalidate();
};
const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate,
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
});
const del = useMutation({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => {
invalidate();
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
},
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
});
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
const pickChannel = (name: string) => {
onClose();
onFocusChannel(name);
};
return (
<Modal initialFocus="close" title={t("tagManager.title")} onClose={onClose}>
<div className="flex flex-col gap-2">
{userTags.length === 0 ? (
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
) : (
// Cap at ~8 rows tall, then scroll — the list can grow long.
<div
ref={listFadeRef}
style={listFadeStyle}
className="flex flex-col gap-2 max-h-88 overflow-y-auto no-scrollbar pr-1"
>
{userTags.map((tag) => (
<TagRow
key={tag.id}
tag={tag}
channels={channelsForTag(tag.id)}
onPickChannel={pickChannel}
onRename={(name) => rename.mutate({ id: tag.id, name })}
onDelete={async () => {
// Only confirm when the tag is actually in use; an unused tag deletes outright.
if (tag.channel_count > 0) {
const ok = await confirm({
title: t("tagManager.deleteTitle"),
message: t("tagManager.confirmDelete", {
name: tag.name,
count: tag.channel_count,
}),
confirmLabel: t("tagManager.delete"),
danger: true,
});
if (!ok) return;
}
del.mutate({ id: tag.id, name: tag.name });
}}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) create.mutate(newName.trim());
}}
className="flex items-center gap-2 mt-2 pt-3 border-t border-border"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<Input
className="flex-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("tagManager.newPlaceholder")}
/>
<Button
type="submit"
variant="outline"
size="sm"
className="shrink-0"
disabled={!newName.trim()}
>
{t("tagManager.add")}
</Button>
</form>
</div>
</Modal>
);
}
@@ -7,11 +7,11 @@ import type { Column } from "../../components/ui/DataTable";
import { PriorityStepper } from "../../components/ui/PriorityStepper";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/ChannelTags";
import { StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync";
import { RelativeTime } from "../../components/ui/RelativeTime";
import type { FilterChip } from "../feed/TagFilterChips";
import type { FilterChip } from "../../features/filters/TagFilterChips";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
+3
View File
@@ -0,0 +1,3 @@
// The channel manager's public face: its store, mounted at the root and read app-wide.
export { ChannelsProvider } from "./ChannelsProvider";
export { useChannels, useChannelsActions } from "./channelsContext";
@@ -1,129 +0,0 @@
// @vitest-environment jsdom
//
// Container-logic contract of the tag-suggestion hook, the wiring the presentational
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
// optimistic cache write is the actual code path (not a re-implementation).
import { describe, it, expect, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
});
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
const confirmState = vi.hoisted(() => ({ result: true }));
vi.mock("../../components/ui/confirmContext", () => ({
useConfirm: () => () => Promise.resolve(confirmState.result),
}));
import { api, type Tag } from "../../lib/api";
import type { TagSuggestions } from "../../lib/api/tags";
import { qk } from "../../lib/queryKeys";
import { useTagSuggestions } from "./useTagSuggestions";
const tag = (id: number, name: string): Tag => ({
id,
name,
color: null,
category: "other",
system: false,
channel_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
const tags = [docu, history];
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
const seed = (): TagSuggestions => ({
UC_a: [
{ tag_id: docu.id, confidence: 0.95 },
{ tag_id: history.id, confidence: 0.6 },
],
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
});
function setup() {
const qc = new QueryClient({
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
});
qc.setQueryData(qk.tagSuggestions(), seed());
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
const toggleTag = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
return { qc, toggleTag, result };
}
describe("useTagSuggestions", () => {
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
const { result } = setup();
const forA = result.current.suggestionsFor("UC_a");
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
});
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
const { result } = setup();
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
expect(result.current.visibleSuggestions).toHaveLength(3);
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
expect(result.current.highConfidence).toEqual([
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
]);
});
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
const { qc, toggleTag, result } = setup();
act(() => result.current.acceptSuggestion("UC_a", docu.id));
// The real tag is attached (hasTag=false → attach).
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
// OTHER ghost chip (history) goes too — not just the accepted one.
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
expect(cache?.UC_a).toBeUndefined();
expect(cache?.UC_b).toBeDefined(); // other channels untouched
});
it("dismiss hides only that one (channel, tag) for the session", () => {
const { result } = setup();
act(() => result.current.dismissSuggestion("UC_a", history.id));
// UC_a loses only the history suggestion; docu stays.
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
});
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
confirmState.result = true;
const { toggleTag, result } = setup();
await act(async () => {
await result.current.acceptAllHighConfidence();
});
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
expect(toggleTag).toHaveBeenCalledTimes(1);
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
});
it("bulk-accept does nothing when the confirm is cancelled", async () => {
confirmState.result = false;
const { toggleTag, result } = setup();
await act(async () => {
await result.current.acceptAllHighConfidence();
});
expect(toggleTag).not.toHaveBeenCalled();
});
});
@@ -1,91 +0,0 @@
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Tag, type TagSuggestions } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { useConfirm } from "../../components/ui/confirmContext";
import type { SuggestedTag } from "./ChannelTags";
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
const HIGH_CONFIDENCE = 0.9;
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
/** Suggested-tag state for the Channel Manager: the /tags/suggestions query, a session-only
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
* the large Channels component. */
export function useTagSuggestions({
tags,
toggleTag,
}: {
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
tags: Tag[];
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
* failed accept restores the ghost chips this hook optimistically removed. */
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
const suggestionsFor = (cid: string): SuggestedTag[] =>
(suggestions[cid] ?? []).flatMap((s) => {
const tag = tagById.get(s.tag_id);
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
});
const dismissSuggestion = (cid: string, tagId: number) =>
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
const acceptSuggestion = (cid: string, tagId: number) => {
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
if (!old?.[cid]) return old;
const next = { ...old };
delete next[cid];
return next;
});
toggleTag(cid, tagId, false);
};
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
// the bulk action accepts.
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
);
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
const acceptAllHighConfidence = async () => {
const ok = await confirm({
title: t("channels.tags.acceptAllTitle"),
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
});
if (!ok) return;
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
};
return {
suggestionsFor,
dismissSuggestion,
acceptSuggestion,
visibleSuggestions,
highConfidence,
acceptAllHighConfidence,
};
}