Skip to content

feat(snippets): complete a snippet name as you type it - #35

Merged
mindaugaskasp merged 3 commits into
mainfrom
feat/snippet-name-autocomplete
Aug 8, 2026
Merged

feat(snippets): complete a snippet name as you type it#35
mindaugaskasp merged 3 commits into
mainfrom
feat/snippet-name-autocomplete

Conversation

@mindaugaskasp

Copy link
Copy Markdown
Owner

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 was SnippetNameHint, 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 Dep against those three gives Deploy — 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 — dev appearing 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, plus indexableNames(), 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 owns SnippetNameHint too, since both are help for naming this snippet.
  • .ghost-field in ui.css sets 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 by check-theme-depth.mjs:134. Worst of the 14 is sepia at 3.44 on the panel; --text scores 9.77–21 on the same grounds, so typed and suggested text are never confusable. Full parsed table in the spec.

A snippet-name-ghost probe is added to theme-sweep's SURFACES — 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 on main, so one new import broke it — fixed by moving SnippetNameHint into SnippetNameField, which nets the import back to zero and is the better shape anyway. useQuickLook.js went 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. No prettier-ignore anywhere.

Testing

  • npm run checkexit 0. Coverage 95.18 / 88.02 / 95.91 / 96.17, all above floors.
  • 21 new unit tests across nameComplete and useNameComplete, 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.
  • 50 e2e passed on the host across both surfaces and every snippet/launcher spec.

Spec: specs/2026-08-08-snippet-name-autocomplete/plan.md

🤖 Generated with Claude Code

mindaugaskasp and others added 3 commits August 8, 2026 12:48
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>
@mindaugaskasp
mindaugaskasp merged commit 8d7ccd5 into main Aug 8, 2026
6 checks passed
@mindaugaskasp
mindaugaskasp deleted the feat/snippet-name-autocomplete branch August 8, 2026 10:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant