image-gin

Modals: Widen Magnific search to fill the viewport, beautify the result grid, fix the URL/CORS bugs that broke selection

Summary

The only modal in active use today is the Magnific image-selector — invoked via Cmd+P → Image Gin: Search Magnific Images. The starting state was bad: the modal rendered narrow with two columns of tiny 100×100 thumbnails, titles truncated at 30 characters, and clicking a result either failed silently with CORS errors or inserted a non-rendering link (![title](https://www.freepik.com/...htm)) into the user's note.

This pass widened it, made the grid look right, and fixed the two bugs that prevented selection from working.

Image Selector Modal as of v0.1.0, May 2026

1. The width unlock — modalEl vs contentEl

Reference: /Users/mpstaton/code/lossless-monorepo/perplexed/context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md documents the underlying pattern in detail. Short version:

In an Obsidian Modal subclass, two DOM elements are exposed:

  • this.modalEl — the outer popup container (Obsidian gives it .modal)

  • this.contentEl — the inner content slot (Obsidian gives it .modal-content)

The width constraint lives on the outer one. The plugin-template convention — repeated across dozens of community plugins, including this one — is contentEl.addClass('my-modal'), then in CSS .my-modal { max-width: 800px }. The CSS parses correctly and applies, but only to the inner content area. The outer modal stays at Obsidian's default narrow width and the inner content is sized to fit inside it. You get a narrow modal with a slab of inner whitespace and conclude "Obsidian doesn't let me size modals." That conclusion is wrong.

The fix is one line per modal:

TS
// in onOpen()
modalEl.addClass('magnific-modal');   // ← OUTER element gets the class
// not:
// contentEl.addClass('magnific-modal');   // ← inner only — width has no effect

The CSS width: 90vw; max-width: 1100px; on .magnific-modal then actually does what it says.

Applied this swap to all four modals in the codebase even though only Magnific is currently exercised — the other three (CurrentFileModal, ConvertLocalImagesForCurrentFile, BatchDirectoryConvertLocalToRemote) had the same contentEl.addClass(...) mistake and were silently constrained to Obsidian's default width too. Width budgets:

Modalmax-widthRationale
image-gin-modal (CurrentFileModal, ConvertLocalImagesForCurrentFile)880pxForm-style, single column
batch-directory-modal1000pxData-list view, more columns of info
magnific-modal1100pxImage grid — needs room for 4 columns

All use width: 90vw alongside the cap so they grow with viewport but don't become unreadable slabs on a 4K display.

2. Magnific-specific beautification

The screenshot before this pass showed two columns of tiny 100×100 thumbnails with truncated titles, lots of empty card space. After:

  • Grid: repeat(auto-fill, minmax(240px, 1fr)) — yields 4 columns at the 1100px modal width, gracefully drops to 3 / 2 / 1 as the modal narrows.

  • Thumbnails: width: 100%; aspect-ratio: 1 / 1; object-fit: cover — fills its cell as a square, no more 100×100 inline cap. The inline img.style.maxWidth = '100px'; img.style.maxHeight = '100px' overrides in the TS were what was pinning thumbnails small even when the CSS allowed bigger; removed them.

  • Titles: switched from white-space: nowrap; text-overflow: ellipsis (one line, ellipses at ~30 chars) to display: -webkit-box; -webkit-line-clamp: 2 (up to two lines before truncating). Also removed the JS-side title.length > 30 ? title.substring(0, 30) + '...' : title truncation in two spots — redundant with the CSS clamp and was capping titles at 30 chars regardless of available space.

  • Hover: translateY(-2px) lift, accent-colored border, subtle shadow.

3. Side-benefit: scoped down a globally-leaking .modal rule

Found a bare .modal { padding: 1.5rem; max-width: 900px; min-height: 60vh; } rule sitting at the top of current-file-modal.css. .modal is Obsidian's class on every modal in the app — command palette, quick-switcher, settings, every other plugin's modals. That rule was forcing 60% viewport height and a 900px max width on all of them. Scoped it to the three image-gin modal classes only.

This is a strict reduction in side effects on Obsidian and other plugins; nothing image-gin-specific got worse. Worth flagging in case anyone else has been using that as an inadvertent global theme — they shouldn't have been.

4. URL bug — image.url is the catalog page, not the image

The Magnific API response has two URL fields with confusingly-similar names:

JSON
{
  "url": "https://www.freepik.com/free-photo/...htm",
  "image": {
    "source": {
      "url": "https://img.freepik.com/...jpg"
    }
  }
}

image.url is the HTML catalog page on freepik.com — for humans to click through. image.image.source.url is the actual image file on the CDN.

The MagnificModal was using image.url in two places where it needed image.image.source.url:

  • Selection markdown![title](${image.url}) was inserting ![title](https://www.freepik.com/some-page.htm) into the user's note. That doesn't render as an image. Fixed to use the image source URL.

  • Cache full-size on clickcacheFullSizeImage was trying to download the catalog page as if it were a binary image. Even if it had succeeded, the result would have been a saved HTML page, not an image. Fixed to download the actual image source URL, and update image.image.source.url to the cached vault path so the markdown insertion picks up the local copy.

(Worth knowing: the rebrand from Freepik to Magnific only changed the API hostapi.magnific.com with x-magnific-api-key — not the underlying content infrastructure. Catalog pages still live on freepik.com, images still serve from img.freepik.com / img.flaticon.com. Seeing freepik.com URLs in the response is expected and not a sign of incomplete migration.)

5. CORS — imageCacheService was using fetch() instead of requestUrl

Even with the URL fix in §4, downloading from img.freepik.com via plain fetch() from the Obsidian renderer is hit-or-miss because of CORS. Renderer origin is app://obsidian.md; image hosts don't send Access-Control-Allow-Origin: app://obsidian.md because nobody else lives there.

Switched ImageCacheService.cacheImage from fetch() to Obsidian's requestUrl, matching the pattern already used in recraftImageService, imagekitService, and (since earlier this session) magnificService. requestUrl runs through Obsidian's main process and bypasses the renderer's CORS sandbox. This is what requestUrl exists for.

The thumbnail caching path (cacheImages for the grid) was already working with fetch() because thumbnail hosts happened to send permissive CORS headers — but it goes through the same cacheImage method, so it benefits from the swap too. One change covers both call sites.

Verified working at end of pass

  • pnpm build green.

  • Magnific search renders at full width, 4-column grid with square thumbnails.

  • Clicking a result inserts a working ![title](https://img.freepik.com/...jpg) markdown that actually renders.

  • No more CORS errors in the console on selection.

Not done — explicitly out of scope this pass

  • No DOM refactor into the BEM __header / __section / __footer pattern from the perplexed reference doc. The width unlock is the load-bearing change; layout polish on top is optional. Each modal kept its existing DOM structure to keep regression risk low.

  • No CSS unification across the four modals (image-gin-modal, batch-directory-modal, magnific-modal each own their own rules). Could be merged into a shared scaffold later.

  • Resolution upgradeimage.image.source.url from the search response is whatever Magnific returns, which is preview-sized rather than full-resolution. Getting full-res from Magnific likely requires a separate authenticated /download endpoint (and may consume credits per their pricing). Not investigated here. If someone wants larger inserted images, that's a feature add against Magnific's docs.