feat(snippets): complete a snippet name as you type it - #35
Merged
Conversation
A library grown over months is full of near-repeats — Deploy — prod,
Deploy — staging, Deploy — dev — and the shared head was retyped every
time. Both name fields were bare inputs; the only naming help was
SnippetNameHint, which lists {{token}}s rather than completing.
Shell-completion semantics: complete to the longest common prefix of
every name that matches what has been typed, and to the whole name only
when exactly one matches. Typing "Dep" against those three gives
"Deploy — " and stops. It never invents the part that tells two
snippets apart, which is the difference between a completion and a
guess — and it makes the rule a pure function of (typed, names), so it
unit-tests without a mount.
Rendering is a two-layer field: an overlay BEHIND the input carries a
transparent copy of the typed text (to occupy its exact width) followed
by the ghost in --text-dim, with the input opaque on top. Unlike the
body highlighter, the input keeps its own text, so native selection and
caret rendering are untouched. The ghost is never part of the value, so
selection, copy and the saved name can only contain what was typed or
accepted.
- utils/nameComplete.js — the pure rule, plus indexableNames(), which
leaves secret snippets out: their guarantee is about contents, but a
secret's NAME hints at what it is.
- composables/useNameComplete.js — accept keys and the scroll mirror.
Tab accepts anywhere; → only from the caret's end, where it would do
nothing anyway. Escape is deliberately neither: it already closes the
compose panel, and one key with two meanings in one field is how the
launcher's key bugs happened.
- SnippetNameField.vue — one component for both surfaces, so the ghost
markup exists once. It owns SnippetNameHint too, since both are help
for naming this snippet.
- .ghost-field in ui.css sets every metric on ONE selector for both
layers; changing one alone detaches the ghost from the caret.
No new token: the ghost is hint ink, so it reuses --text-dim, which
check-theme-depth.mjs already floors at 3.0. Worst of the 14 is sepia
at 3.44 on the panel. A theme-sweep SURFACES probe is added for it,
which also closes a finding carried from the previous change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment sweep (three headers trimmed), the validation record, and the plan amendment for the SnippetNameField extraction that replaced the two per-surface wiring steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review and QA on 5318cce found the feature did not work on either
surface — and that every test I had written passed anyway.
The ghost was drawn behind the input, and both inputs were opaque:
- Editor: `.ghost-field > input { background: none }` and
`.dialog input { background: var(--bg) }` are both specificity 0-1-1
and the latter comes later in ui.css, so the input painted white over
the overlay.
- Launcher: input-class="ql-compose-name" handed a SCOPED class across
a component boundary. The scope attribute is only stamped on elements
a component's own template renders, so the input never matched and
fell back to a raw UA input — which also broke the hard-keyline
contract on contrast and beacon.
- Both: the shared selector set font but not padding or border, so the
ghost sat (border + padding-left) off the typed text, contradicting
the comment directly above it.
Every assertion used toHaveText on .name-ghost, which reads
textContent and passes for an element completely covered by an opaque
sibling. That is the failure javascript-testing-best-practices is cited
for in docs/standards.md.
Fixed by inverting the structure rather than winning the specificity
fight: the WRAPPER carries the visible box, the input inside is bare, so
nothing opaque can sit above the ghost. SnippetNameField owns that in
its own scoped stylesheet and the dead per-surface rules are gone. The
new assertion is geometric — transparent background, identical fontSize
and padding, identical box origin and width, and overlay.textContent
=== typed + ghost — and was proved red by restoring the opaque fill.
Seven more bugs, each with a failing test first:
- A read-only field still ACCEPTED the ghost: the template blanked the
display while `ghost` stayed live, so Tab in view mode wrote unseen
text into the value and raised the discard guard on a snippet that
was only looked at. Emptied at the source.
- Shift+Tab accepted instead of moving focus back; so did Ctrl/Alt/Meta.
- An IME composition was broken — Tab is a candidate key in CJK IMEs.
- Typing a name IN FULL still ghosted a longer one, and Tab (the only
key that leaves the field) silently renamed the snippet.
- A stored {{token}} name could be ghosted into the editor, which
expands tokens on save — storing a name never accepted.
- The completion's casing depended on store order.
- SnippetNameHint and the required hint had swapped order.
SnippetNameField no longer swallows SnippetNameHint: that was cap-driven,
not designed. The script line is bought honestly by extracting
composables/useFormatToolbar.js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A library grown over months is full of near-repeats —
Deploy — prod,Deploy — staging,Deploy — dev— and the shared head was retyped every time. Both name fields were bare<input>s; the only naming help wasSnippetNameHint, which lists{{token}}s rather than completing what you're typing. The cost is highest in the launcher, which exists to be fast.The rule
Shell-completion semantics. Complete to the longest common prefix of every name that matches what's been typed; complete to the whole name only when exactly one matches. Typing
Depagainst those three givesDeploy —and stops.That is the whole design, and it's why this is worth building rather than a "did you mean". It never invents the part that tells two snippets apart —
Deploy — devappearing when you meant staging is worse than no suggestion. It's also a pure function of (typed, names), so it unit-tests without a mount.How it renders
A two-layer field: an overlay behind the input carries a transparent copy of the typed text (to occupy its exact width) followed by the ghost in
--text-dim, with the real input opaque on top. Unlike the body highlighter added in #34, the input keeps its own text — so native selection and caret rendering are untouched.The ghost is never part of the input's value. Selection, copy and the saved name can only contain what was typed or accepted. The e2e asserts that at every step, not just at the end.
Pieces
utils/nameComplete.js— the pure rule, plusindexableNames(), which leaves secret snippets out: their guarantee is about contents, but a secret's name hints at what it is, and offering that inside an unrelated snippet leaks intent.composables/useNameComplete.js— accept keys and the scroll mirror. Tab accepts anywhere;→only from the caret's end, where it would do nothing anyway. Escape is deliberately neither — it already closes the compose panel, and one key with two meanings in one field is how feat(quick-look): capture a snippet without leaving the home row #34's keyboard bugs happened.composables/useSnippetNameComplete.js— the three-line store binding, kept apart so the above stays Pinia-free.SnippetNameField.vue— one component for both surfaces, so the ghost markup exists once. It ownsSnippetNameHinttoo, since both are help for naming this snippet..ghost-fieldinui.csssets every metric that decides where a glyph lands on one selector for both layers; changing one alone detaches the ghost from the caret.Themes
No new token. The ghost is hint ink, so it reuses
--text-dim— the token both placeholders already use, already floored at 3.0 bycheck-theme-depth.mjs:134. Worst of the 14 is sepia at 3.44 on the panel;--textscores 9.77–21 on the same grounds, so typed and suggested text are never confusable. Full parsed table in the spec.A
snippet-name-ghostprobe is added totheme-sweep'sSURFACES— the ghost's relative weight is the one thing a contrast table can't settle. That also closes a finding carried from #34.Two caps, both resolved by splitting
SnippetEditorDialog.vue's script sat at exactly 100 onmain, so one new import broke it — fixed by movingSnippetNameHintintoSnippetNameField, which nets the import back to zero and is the better shape anyway.useQuickLook.jswent over when the index was first threaded through as a prop; that plumbing came out entirely once it was clear both windows own an instance of the same store. Noprettier-ignoreanywhere.Testing
npm run check→ exit 0. Coverage 95.18 / 88.02 / 95.91 / 96.17, all above floors.nameCompleteanduseNameComplete, both written before their source and watched failing. The LCP rule additionally proved by swapping in a naive first-match — 2 tests went red, green on restore.Spec:
specs/2026-08-08-snippet-name-autocomplete/plan.md🤖 Generated with Claude Code