diff --git a/CLAUDE.md b/CLAUDE.md
index 15b9100..bce62d7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -348,7 +348,19 @@ no adjustments.
The `pf_bucket` datalist on the Baseline page was already per version
(`bucketsInUse`), so the two now agree.
-**Completion is the datalist and nothing else.** The operation panel used to
+**Completion matches on words, not substrings.** `TagInput` in
+`OperationPanel.jsx` replaces the native datalist on the tag field: both sides
+are lowercased, stripped of punctuation, relieved of stopwords and lightly
+stemmed, so `Pulled Forward` finds `Pull Forward` and `moved to different sku`
+finds `moved to a different sku` — neither of which a substring match reaches,
+one inserted word being enough to hide the tag that already exists. Typing
+something that reduces to an existing tag without being it says so outright
+(*"Already used as … — use that"*), because the ledger will otherwise report the
+two as separate initiatives. Nothing is merged automatically; the suggestion is
+offered and the typing stays yours. The change log's inline tag cell still uses
+the plain datalist.
+
+**Completion is otherwise the list and nothing else.** The operation panel used to
carry six quick-pick pills under the tag field as well. They wrapped to two rows
as soon as the tags were longer than a word, and the panel docked at the bottom
has a fixed height, so they pushed the form past its own border. The datalist
diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx
index f83f07f..b66fa07 100644
--- a/ui/src/components/OperationPanel.jsx
+++ b/ui/src/components/OperationPanel.jsx
@@ -343,6 +343,101 @@ function LedgerInput({ value, active, held, onChange, onFocus, onClear, suffix }
)
}
+// Tag completion that survives how people actually type.
+//
+// The native datalist substring-matches, so "moved to different sku" never
+// offers "moved to a different sku" -- one inserted word and the existing tag
+// is invisible. That is how a version ends up with four spellings of one
+// initiative and a ledger line for each.
+//
+// Words, not substrings: both sides are lowercased, stripped of punctuation,
+// relieved of the words that carry no meaning here, and lightly stemmed, so
+// "Pulled Forward" and "Pull Forward" reduce to the same thing. Nothing is
+// merged automatically -- the match is offered and the typing is still yours.
+const TAG_STOP = new Set(['a', 'an', 'the', 'to', 'of', 'for', 'in', 'on'])
+
+function tagTokens(s) {
+ return String(s || '')
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, ' ')
+ .split(/\s+/)
+ .filter(w => w && !TAG_STOP.has(w))
+ .map(w => w.replace(/(ed|es|s)$/, ''))
+}
+
+const tagKey = (s) => tagTokens(s).sort().join(' ')
+
+function TagInput({ value, onChange, tags = [], className }) {
+ const [open, setOpen] = useState(false)
+ const boxRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) }
+ document.addEventListener('mousedown', onDown, true)
+ return () => document.removeEventListener('mousedown', onDown, true)
+ }, [open])
+
+ const typed = value.trim()
+ const tokens = tagTokens(typed)
+
+ // Ranked: every typed word present (as a whole word or the start of one)
+ // beats sharing some of them, and a shorter tag beats a longer one on a tie,
+ // being the more likely thing you meant.
+ const matches = (() => {
+ if (!tags.length) return []
+ const scored = tags.map(t => {
+ const tt = tagTokens(t.tag)
+ if (!tokens.length) return { t, score: 1 }
+ const hit = tokens.filter(w => tt.some(x => x === w || x.startsWith(w) || w.startsWith(x)))
+ if (!hit.length) return { t, score: 0 }
+ return { t, score: hit.length === tokens.length ? 2 : 1 }
+ })
+ return scored
+ .filter(x => x.score > 0)
+ .sort((a, b) => b.score - a.score || a.t.tag.length - b.t.tag.length)
+ .slice(0, 6)
+ .map(x => x.t)
+ })()
+
+ // Typed something that reduces to an existing tag without being it. Worth
+ // saying out loud, because the ledger will report the two separately.
+ const nearDupe = typed
+ && !tags.some(t => t.tag === typed)
+ && tags.find(t => tagKey(t.tag) && tagKey(t.tag) === tagKey(typed))
+
+ return (
+
+ { onChange(e.target.value); setOpen(true) }}
+ onFocus={() => setOpen(true)}
+ onKeyDown={e => { if (e.key === 'Escape') { setOpen(false); e.stopPropagation() } }}
+ placeholder="initiative, e.g. reduce_spend"
+ className={className} />
+
+ {open && (matches.length > 0 || nearDupe) && (
+