Skip to content

fix(post-composer): restore mention autocomplete suggestions#306

Merged
spe1020 merged 10 commits intozapcooking:mainfrom
dmnyc:fix/mention-autocomplete-post-composer
Apr 19, 2026
Merged

fix(post-composer): restore mention autocomplete suggestions#306
spe1020 merged 10 commits intozapcooking:mainfrom
dmnyc:fix/mention-autocomplete-post-composer

Conversation

@dmnyc
Copy link
Copy Markdown
Collaborator

@dmnyc dmnyc commented Apr 19, 2026

Summary

Typing @<name> in the new kind:1 post composer was not showing any mention suggestions at all — no dropdown, not even the "Searching..." state.

Root cause

PR #280 switched MentionDropdown to position: fixed to escape the composer's overflow container. Per the CSS spec, position: fixed is positioned against the viewport unless an ancestor has transform, filter, or perspective — in which case that ancestor becomes the containing block.

Modal.svelte's <dialog> uses Tailwind's -translate-x-1/2 -translate-y-1/2 (a permanent transform: translate(...)), so the dropdown's top / left values — computed from the caret's getBoundingClientRect() in viewport coordinates — were being interpreted relative to the dialog box. The dropdown was rendering with the correct state (verified via debug logs showing show=true, populated suggestions, the search timer firing), just painted far off-screen.

Fix

Portal MentionDropdown to document.body when rendered. It now lives outside the transformed dialog subtree, so position: fixed is correctly viewport-relative and the caret-based coordinates land where expected. ReplyComposer and every other call site benefit from this too.

Also drop a redundant double-assignment in PostComposer's on:input handler (also from #280) so the composer matches ReplyComposer's working pattern — the controller's onTextChange callback is already the single source of truth for composer text state.

Notes

Test plan

  • Open the New Post modal, type @s — dropdown appears anchored to caret with matching profiles.
  • Continue typing @seth — dropdown narrows to matches.
  • Arrow-down / Enter selects a suggestion, pill renders in composer.
  • Typing a space or Esc dismisses the dropdown without posting.
  • Verify no regression in ReplyComposer (inline comment replies still work).
  • Verify no regression in [nip19]/+page.svelte reply composer.

@dmnyc dmnyc force-pushed the fix/mention-autocomplete-post-composer branch from 72034b9 to 7611c1a Compare April 19, 2026 05:46
@spe1020 spe1020 requested a review from Copilot April 19, 2026 13:49
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores mention autocomplete visibility in the post composer by portaling MentionDropdown to document.body so its position: fixed caret coordinates remain viewport-relative even inside transformed modals. The diff also includes several stacked UX fixes in the recipe editor and app shell.

Changes:

  • Portal MentionDropdown to document.body and simplify PostComposer’s input handling to rely on the mention controller as the source of truth.
  • Improve recipe editor UX (auto-save drafts, ensure markdown preview links open in new tabs, add media-by-URL, fix drag-reorder drop behavior).
  • App-shell tweaks (suppress fatal UI for benign window errors, prevent dark-mode logo flash, version bump).

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/components/MentionDropdown.svelte Portals dropdown to document.body to avoid transformed-modal containing-block issues.
src/components/PostComposer.svelte Removes redundant state assignment in on:input, delegating to mention controller callbacks.
src/routes/create/+page.svelte Adds auto-save state, signature-based change detection, and silent debounced draft saving.
src/lib/parser.ts Forces target/_blank behavior to survive DOMPurify sanitization for markdown-rendered links.
src/components/MediaUploader.svelte Adds a collapsible “Add images by URL” UI with basic URL validation and dedupe.
src/components/StringComboBox.svelte Improves drag-and-drop reorder to support “drop after” (incl. last item) with visual indicators.
src/components/ErrorBoundary.svelte Changes handling of window errors to log-only and removes the previous svelte:window binding.
src/components/Header.svelte Adds logo marker classes for pre-Tailwind flash prevention.
src/components/DesktopSideNav.svelte Adds logo marker classes for pre-Tailwind flash prevention.
src/app.html Inlines early CSS to hide the incorrect theme logo before Tailwind loads.
package.json Bumps app version.
Comments suppressed due to low confidence (1)

src/routes/create/+page.svelte:78

  • onMount now continues running after goto('/login') (it isn't awaited/returned), so draft initialization + event listeners + auto-save arming can still happen briefly during redirect. Consider await goto('/login') and return (or just return after calling goto) to avoid side effects when the user isn't authenticated.
  onMount(async () => {
    if ($userPublickey == '') goto('/login');

    // Initialize draft store
    initializeDraftStore();

    // Check for draft ID in URL
    const draftId = $page.url.searchParams.get('draft');
    if (draftId) {
      loadDraftById(draftId);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 79 to 105
@@ -106,8 +105,6 @@
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleError() is no longer wired up to anything after removing <svelte:window on:error={handleError} />, so the component will never set error and will never render the fallback UI. If you still want to support the “programmatic CustomEvent path” described in the comment, add an explicit event listener that routes those CustomEvents to handleError (and keep the separate logging-only path for native ErrorEvents), or otherwise remove the dead error UI/state if the boundary is intended to be log-only now.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/create/+page.svelte Outdated
Comment on lines +403 to +406
// Serialized form of all draft fields — drives change detection for auto-save
$: draftSignature = JSON.stringify([
title,
$images,
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

draftSignature is assigned in a reactive statement but is never declared (no let draftSignature anywhere in the component). With TS strict this will fail compilation (draftSignature is not defined). Declare it in the <script> block (e.g., initialize to an empty string) before first use in onMount/runAutoSave and the reactive assignment.

Copilot uses AI. Check for mistakes.
dmnyc added 10 commits April 19, 2026 13:53
Tailwind's dark:hidden / dark:block on the logo <img> tags only take
effect once the main CSS bundle loads. In dev (and occasional edge
cases in prod) there's a brief window where both logos are visible and
the light one paints first. Add a tiny inline <style> in app.html
keyed on html.dark so the correct logo is selected before any external
CSS arrives; tag the <img> pairs with logo-light / logo-dark.
A subtle "Add images by URL" toggle under the media grid lets users
paste a direct image/video URL instead of uploading. First URL added
becomes the cover photo (satisfying the publish requirement). Works
across all three recipe editors (create, fork, gated) via the shared
MediaUploader component.
DOMPurify can strip target/rel on <a> in some configurations, which
caused markdown-preview links in the recipe editor to navigate
in-place and destroy unsaved edits. Add an afterSanitizeAttributes
hook that reapplies target=_blank + rel=noopener noreferrer on every
anchor post-sanitization.
Watch the 11 draft fields via a JSON signature; 2s after the last
edit, save to localStorage (and queue the debounced relay sync) so
users don't lose work. Skips while empty and while a manual save is
in flight; updates the URL with ?draft=<id> so reloads resume the
same draft.
Auto-save was toggling isSavingDraft and writing a transient status
message, which swapped the Save Draft button to "Saving…" and shifted
the button row every 2s while typing — the editor appeared to jump
around. The URL was also rewritten on every save.

Make auto-save fully silent: no button state toggle, no message flash,
and only write ?draft=<id> the first time a new draft gets an id.
Manual save keeps its full feedback, and the Save Draft button's cloud
icon still reflects relay sync state once the background publisher
settles.
Two issues made reordering the last items nearly impossible:

- handleDrop always inserted before the target, so dropping on the
  last item never moved the dragged item past it. Detect whether the
  cursor is in the target's top or bottom half and insert before or
  after accordingly. A top/bottom box-shadow marker shows which side
  the drop will land on (no layout shift).

- The 8px flex gap between items was dead space — the drop zone only
  covered each pill. Drop gap-2 and give each <li> py-1 padding
  (first:pt-0 last:pb-0) so the hit area extends into the gap; the
  visible pill moves to an inner <div>. Total vertical spacing
  between items is unchanged.
- parser.ts: use a dedicated DOMPurify instance for markdown so the
  afterSanitizeAttributes hook doesn't leak target=_blank behavior into
  every other sanitize() call in the app (e.g. the longform editor).
- MediaUploader.svelte: normalize the pasted URL via `parsed.toString()`
  before the dedupe check and before storing, so equivalent URLs
  (`example.com` vs `example.com/`) collapse to one entry.
- MediaUploader.svelte: add `aria-expanded` + `aria-controls` to the
  "Add images by URL" disclosure toggle for screen-reader support.
- create/+page.svelte: allow auto-save to persist when all fields are
  cleared on an existing draft (previously the delete-everything state
  was never saved, so reloading brought the old content back).
- create/+page.svelte: declare `draftSignature` explicitly and debounce
  its computation to 250ms so we don't re-JSON.stringify the entire
  draft on every keystroke.
<svelte:window on:error> was catching native ErrorEvents but treating
them as CustomEvents, so the details were lost and every spurious
window error — including the ones iOS Safari fires during share /
backgrounding / service-worker transitions — nuked the entire app
with an "Oops! Something went wrong" screen.

Remove the svelte:window binding and align the window 'error'
listener with the existing 'unhandledrejection' listener: log-only,
no fatal UI. The programmatic CustomEvent path is preserved for
child components that want to surface a fallback deliberately.
After the earlier patch removed `<svelte:window on:error={handleError} />`
to stop benign window errors from nuking the app, `handleError` had no
caller and the entire fallback UI / retry / error-state code was
effectively dead — RecipeErrorBoundary and FeedErrorBoundary would
never render their fallback.

Re-wire `handleError` to a dedicated `window` CustomEvent
(`zc:fatal-error`). Child components that hit a genuinely fatal error
can dispatch:

    window.dispatchEvent(new CustomEvent('zc:fatal-error', {
      detail: { error, errorInfo }
    }));

to surface the boundary's fallback UI. Native ErrorEvents are still
logged-only, same as before.
Typing `@<name>` in the new kind:1 post composer was not showing any
suggestions. The dropdown component WAS rendering with the right state
(`show=true`, populated `suggestions`), but it was positioned
off-screen because of a CSS containing-block issue introduced by zapcooking#280.

Root cause: zapcooking#280 switched MentionDropdown to `position: fixed` to
escape the composer's overflow container. Per the CSS spec, `fixed`
positions against the viewport unless an ancestor has `transform`,
`filter`, or `perspective` — in which case that ancestor becomes the
containing block. Modal's <dialog> uses Tailwind's
`-translate-x-1/2 -translate-y-1/2` (a permanent `transform`), so the
dropdown's `top`/`left` values — computed from the caret's
`getBoundingClientRect()` in viewport coordinates — were being
interpreted relative to the dialog box instead, pushing the dropdown
off-screen.

Fix: portal the dropdown directly to `document.body` when `show` is
true. It now lives outside the transformed dialog subtree, so
`position: fixed` is correctly viewport-relative and the caret-based
coordinates land where expected.

Also drop a redundant double-assignment in PostComposer's on:input
handler (also from zapcooking#280) so the composer matches the working
ReplyComposer pattern — the controller's `onTextChange` callback is
already the single source of truth for composer text state.
@dmnyc dmnyc force-pushed the fix/mention-autocomplete-post-composer branch from 014776d to 42fc0aa Compare April 19, 2026 17:56
@spe1020 spe1020 merged commit ca0e8ec into zapcooking:main Apr 19, 2026
1 check passed
@dmnyc dmnyc deleted the fix/mention-autocomplete-post-composer branch April 19, 2026 20:00
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.

3 participants