perplexed

UX pass: modal redesign + wide-modal CSS unlock

The three 'Ask <provider>' modals — Ask Perplexity, Ask Claude, and Ask Perplexica — all have beautiful, wide, and consistent Modal UIs now.

Why Care?

Before this pass, the three Ask <provider> modals were three different shapes. Ask Perplexity was a plain stack of .setting-item divs with hand-rolled labels; Ask Perplexica was a stub form with one CSS rule (.text-input { width: 100%; padding: 12px; }); Ask Claude did not exist yet — Claude integration was being designed in this session and needed a UI to expose its model / effort / web-search / thinking knobs.

Three problems compounded:

  1. No shared visual language. Each modal had its own ad-hoc structure. A user toggling between providers got a different feel each time, with controls in different positions.

  2. Modals stayed narrow regardless of CSS. Every plugin in the tree had inherited the same convention from the Obsidian sample plugin — contentEl.addClass('my-modal') — and every plugin's width: 90vw; max-width: 800px; rule on that class was a no-op. The outer .modal element kept Obsidian's default constraints and the inner .modal-content shrank to fit. So even the modals that tried to widen looked indistinguishable from the bare default.

  3. Options without explanations. Dropdowns listed values (webSearch, academicSearch, wolframAlpha, sonar-deep-research) with no live description of what each option actually does. A user had to consult external docs — or just guess.

The maintenance pass on 2026-05-02_01.md (deps refresh + streaming citations bug) cleared the toolchain. This pass is the user-facing companion: with the toolchain green, redesign the three primary entry points so the plugin reads as one coherent product rather than three generations of UI archaeology.

The strategic point: the redesign is not "make it pretty." It's "make the cost of choosing the right options visible." Each modal now surfaces, beside every dropdown, a tagline that updates live as the user toggles. The user no longer needs to know what redditSearch focus mode does — the tagline tells them. The CSS unlock is what made the room for those taglines viable; without the wider modal the description column gets crushed.

What Was Built

The three 'Ask ' modals — Ask Perplexity, Ask Claude, and Ask Perplexica — were brought to a shared sectioned layout: header / question / search / returns / behavior / footer, BEM-scoped class names, native Obsidian Setting components for dropdowns and toggles, live taglines under each dropdown that explain what each option actually does, and Cmd/Ctrl+Enter submit.

The unlocking discovery was structural rather than visual: Obsidian Modal width is set on the OUTER .modal element (this.modalEl), not the INNER .modal-content (this.contentEl), so for years the convention contentEl.addClass(...) had been silently neutering every plugin's width: 90vw / max-width rules.

Fixing that single line let all three modals widen to a readable 640px form, which in turn made the sectioned layout viable. The unlock is documented in detail in context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md."

The wide-modal CSS unlock

Discovered while building Ask Claude — the modal kept rendering at the Obsidian-default narrow width even with the canonical CSS:

CSS
.claude-modal {
  width: 90vw;
  max-width: 640px;
}

The rule parsed, the rule applied, the modal stayed narrow. Diagnosis: the long-standing plugin convention of attaching the class to contentEl only sizes the inner .modal-content element, not the outer .modal container that Obsidian's stock CSS actually constrains. The fix is a single line:

TS
// Inside Modal.onOpen()
modalEl.addClass('claude-modal');     // ← outer element
// not:
// contentEl.addClass('claude-modal'); // ← inner content area only

Once the class is on modalEl, the same width rule actually widens the popup. The complementary CSS that makes the rest of the layout viable:

CSS
.claude-modal {
  width: 90vw;
  max-width: 640px;
}

/* Zero Obsidian's default ~20px padding so our own sections can
   own their padding without fighting the parent. */
.claude-modal .modal-content {
  padding: 0;
}

This pattern — modalEl.addClass(...) + width on outer + zeroed inner padding — is now the baseline for every modal in this plugin and is documented in full at context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md (with the counter-examples in this same repo that demonstrated the failure mode: text-enhancement-modal.css sets max-width: 800px correctly but renders narrow because its .ts file uses contentEl.addClass).

The shared sectioned layout

With width unlocked, all three modals adopted the same structural skeleton:

┌───────────────────────────────────────────┐
│  Header — title + subtitle                │
├───────────────────────────────────────────┤
│  Question — full-width textarea           │
├───────────────────────────────────────────┤
│  Search / Model — dropdowns w/ taglines   │
├───────────────────────────────────────────┤
│  Returns — toggles for what to include    │
├───────────────────────────────────────────┤
│  Behavior — Stream toggle, etc.           │
├───────────────────────────────────────────┤
│  Footer — Cancel + primary CTA (right)    │
└───────────────────────────────────────────┘

BEM-scoped class names (.<provider>-modal__header, __section, __section-title, __label, __textarea, __footer, __button) keep selector specificity flat and unambiguous so a future child element with class="header" from some third-party widget cannot accidentally pick up modal styles.

The header/section/footer chrome:

CSS
.claude-modal__header {
  padding: 24px 28px 16px;
  border-bottom: 1px solid var(--background-modifier-border);
}

.claude-modal__section {
  padding: 18px 28px 4px;
}

.claude-modal__section + .claude-modal__section {
  border-top: 1px solid var(--background-modifier-border-hover);
  margin-top: 4px;
}

.claude-modal__section-title {
  margin: 0 0 12px;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted);
}

.claude-modal__footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 16px 28px 24px;
  margin-top: 12px;
  border-top: 1px solid var(--background-modifier-border);
  background-color: var(--background-secondary);
  border-bottom-left-radius: var(--modal-radius, 8px);
  border-bottom-right-radius: var(--modal-radius, 8px);
}

Two design choices worth flagging:

  • Hairline borders, not boxes. --background-modifier-border / --background-modifier-border-hover give visual hierarchy without the heaviness of card-style boxes, and the tokens mean every Obsidian theme — light, dark, community — gets correct contrast for free.

  • Footer with --background-secondary background. The subtle tint on the action tray reads as "this is where you commit," which matches every native macOS / Windows convention for primary actions and is what the user's hand expects.

The textarea pattern (used identically in all three modals):

CSS
.claude-modal__textarea {
  width: 100%;
  min-height: 120px;
  padding: 12px 14px;
  font-family: var(--font-text);
  font-size: 14px;
  line-height: 1.5;
  color: var(--text-normal);
  background-color: var(--background-primary);
  border: 1px solid var(--background-modifier-border);
  border-radius: 8px;
  resize: vertical;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
  box-sizing: border-box;
}

.claude-modal__textarea:focus {
  outline: none;
  border-color: var(--interactive-accent);
  box-shadow: 0 0 0 3px var(--interactive-accent-hover);
}

The 3px box-shadow halo using the hover shade of the accent (not the saturated accent itself) is the trick that makes focus state read as modern — softer than a hard accent ring, more legible than no indicator at all.

The button pattern, opting into Obsidian's .mod-cta convention so the primary action responds to theme tweaks like a native button:

CSS
.claude-modal__button {
  padding: 8px 18px;
  font-size: 14px;
  font-weight: 500;
  border: 1px solid var(--background-modifier-border);
  border-radius: 6px;
  background-color: var(--background-primary);
  color: var(--text-normal);
  cursor: pointer;
  transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}

.claude-modal__button:hover    { background-color: var(--background-modifier-hover); }
.claude-modal__button:active   { transform: translateY(1px); }
.claude-modal__button:disabled { opacity: 0.5; cursor: not-allowed; }

.claude-modal__button.mod-cta {
  background-color: var(--interactive-accent);
  color: var(--text-on-accent);
  border-color: var(--interactive-accent);
}

.claude-modal__button.mod-cta:hover {
  background-color: var(--interactive-accent-hover);
  border-color: var(--interactive-accent-hover);
}

The transform: translateY(1px) on :active is one line; it makes buttons feel physically clickable; the cost is zero.

Native Obsidian Setting rows for dropdowns and toggles are tightened to fit the custom layout instead of fighting it:

CSS
.claude-modal__section .setting-item {
  padding: 10px 0;
  border-top: none;
}

.claude-modal__section .setting-item + .setting-item {
  border-top: 1px solid var(--background-modifier-border-hover);
}

Mobile breakpoint at 600px (single value, applied identically across all three modals):

CSS
@media (max-width: 600px) {
  .claude-modal {
    width: 95vw;
    max-width: none;        /* explicitly clear the cap */
  }

  .claude-modal__header,
  .claude-modal__section,
  .claude-modal__footer {
    padding-left: 18px;
    padding-right: 18px;
  }

  .claude-modal__footer {
    flex-direction: column-reverse;   /* CTA on top, thumb-reach */
  }

  .claude-modal__button {
    width: 100%;
  }
}

max-width: none (rather than max-width: 95vw) is deliberate: without it, the inherited 640px cap silently wins over the new width: 95vw at 700px viewports and you get an awkward gap.

Ask Perplexity — full redesign

The previous Perplexity modal was 157 lines of inline-styled form construction with hand-rolled labels and no native Setting integration. The new modal is restructured into the sectioned skeleton above with:

  • Header: "Ask Perplexity" + subtitle "Web-grounded research with native citations. Streams into the active note at the cursor."

  • Question section: full-width textarea with placeholder from promptsService.getPerplexityQueryPlaceholder(), Cmd/Ctrl+Enter submits.

  • Model section: model dropdown (sonar-pro, sonar-small, sonar-deep-research, llama variants) with live tagline under the control. Selecting sonar-deep-research swaps in the long-form description from promptsService.getDeepResearchDescription() so the user sees the "30-60 second wait, exhaustive multi-source" caveat at decision time, not after submit. A separate "Recency Filter" dropdown lists day / week / month / year and three multi-year options that fall back to "year" (the API ceiling — the fallback is documented in the description).

  • Include in response section: three toggles with descriptions — Citations ("Append a Citations section…"), Images, Related Questions ("Surface follow-up questions Perplexity suggests…").

  • Behavior section: Stream Response toggle with the deep-research-aware description ("Deep Research model can take 30-60s — streaming makes progress visible").

  • Footer: Cancel + Ask Perplexity CTA, right-aligned.

modalEl.addClass('perplexity-modal') — the wide-modal unlock applied; the file carries an inline comment explaining why contentEl.addClass(...) would not have worked. The .css file grew from ~26 lines to 184 lines covering the full BEM stylesheet.

Ask Claude — newly built

The Claude integration needed a UI; this is what it got. Same sectioned skeleton, different control set:

  • Header: "Ask Claude" + subtitle "Server-side web search with native citations. Streams into the active note at the cursor."

  • Question section: full-width textarea, Cmd/Ctrl+Enter submits.

  • Model section: model dropdown listing the four current Claude models with live taglines —

    • claude-opus-4-7 → "Most capable — research / agentic / vision"

    • claude-opus-4-6 → "Previous-generation Opus"

    • claude-sonnet-4-6 → "Best speed / intelligence balance"

    • claude-haiku-4-5 → "Fastest, lowest cost" Effort dropdown (low / medium / high / xhigh / max) with a description that calls out which efforts are model-bound (xhigh = "deep agentic (Opus 4.7)", max = "Opus only, highest cost").

  • Behavior section: three toggles —

    • Enable Web Search — surfaces the web_search_20250305 server-side tool, with a description that mentions per-claim citations on text blocks (so the user knows why their answer will arrive with structured citation data, not inline links).

    • Adaptive Thinking — extended reasoning toggle with the latency / token tradeoff in the description.

    • Stream Response — recommended for long answers; the description explains why (avoids HTTP timeouts, writes incrementally to the note).

  • Footer: Cancel + Ask Claude CTA, right-aligned.

The Claude modal was the first modal to use modalEl.addClass in this plugin — it's where the wide-modal unlock was discovered. Everything else inherits from this one.

Ask Perplexica — upgraded from stub to peer

The previous Perplexica modal was minimal: hand-rolled dropdowns without native Setting integration, a one-line CSS file, and dropdown options listed as raw values (webSearch, academicSearch, wolframAlpha) with no descriptions of what they do. The redesign brings it into the same shape as Perplexity and Claude:

  • Header: "Ask Perplexica" + subtitle "Self-hosted, source-grounded answers via your local Perplexica instance. Streams into the active note at the cursor."

  • Question section: full-width textarea with placeholder from promptsService.getPerplexicaQueryPlaceholder(), Cmd/Ctrl+Enter submits.

  • Search section — the Perplexica-specific knobs that previously had no explanation now each carry a live tagline:

    • Focus Mode dropdown — six modes with taglines that explain what each one actually does:

      • Web Search → "General web search via SearXNG — broad coverage, default choice."

      • Academic Search → "Scholarly papers from arXiv, Google Scholar, PubMed-style sources."

      • Writing Assistant → "No web search — pure writing help (drafting, rewriting, summarizing)."

      • Wolfram Alpha → "Computational / factual queries — math, units, structured data."

      • YouTube Search → "Video results with transcripts when available."

      • Reddit Search → "Discussion threads — opinions, anecdotes, community knowledge."

    • Optimization dropdown — three modes with the speed / depth tradeoff explicit:

      • Speed → "Fewest sources, quickest answer. Good for simple questions."

      • Balanced → "Default — reasonable depth without long waits."

      • Quality → "More sources and deeper synthesis. Slower but more thorough."

  • Include in response section: Images toggle.

  • Behavior section: Stream Response toggle with description oriented to the local-model use case ("useful for long answers and slow local models").

  • Footer: Cancel + Ask Perplexica CTA, right-aligned.

Live taglines hold the public reference to each Setting's description element via Setting.descEl rather than fragile CSS selectors:

TS
const focusSetting = new Setting(searchSection)
    .setName('Focus Mode')
    .setDesc(this.focusTagline(this.focusMode))
    .addDropdown(dd => {
        FOCUS_MODES.forEach(({ value, label }) => dd.addOption(value, label));
        dd.setValue(this.focusMode);
        dd.onChange((value) => {
            this.focusMode = value;
            if (this.focusDescEl) this.focusDescEl.textContent = this.focusTagline(value);
        });
    });
this.focusDescEl = focusSetting.descEl;

Setting.descEl is a public property on the Obsidian Setting class. Using it directly is more robust than section.querySelector('.setting-item:last-of-type .setting-item-description') — the selector approach breaks if section structure changes (e.g., another <h3> is added between Settings).

The Perplexica .css file grew from 6 lines to 161 lines, mirroring the claude-modal.css shape so the three modals are visually consistent.

Verification

  • pnpm run build (production: tsc -noEmit -skipLibCheck && node esbuild.config.mjs production) green after each modal redesign and after the final Perplexica pass. Zero ESLint errors, zero TypeScript errors on the touched files.

  • All three modals visually verified in Obsidian's renderer at the intended 640px max width.

  • Cmd/Ctrl+Enter submit verified on each modal's textarea.

  • Theme parity verified by toggling between Obsidian's light and dark themes — every color resolves through CSS custom properties (var(--text-normal), var(--background-primary), var(--interactive-accent), etc.) so no hardcoded #fff / #000 exists anywhere in the new stylesheets.

What Changed in Approach (the meta-lesson)

Pattern this rejectsPattern this adopts
Attach the modal class to contentEl because that's what the Obsidian sample plugin doesAttach to modalEl (the outer .modal element) so width / max-width rules actually apply to the popup; zero .modal-content padding so your own sections own their spacing
Hand-roll labels + form-style divs for dropdowns and togglesUse Obsidian's native Setting components for dropdowns / toggles — automatic theme parity, native interaction patterns, accessibility for free; reach for raw HTML only where Setting is a poor fit (e.g., a 6-row textarea, where Setting's right-edge control placement is wrong)
List dropdown values without explaining what each one doesSurface a live tagline under every dropdown that updates on change — the user reads the consequence of the choice at decision time, not after submit. Hold the description element via Setting.descEl (public API) rather than CSS selectors that drift with structure
Use nested CSS selectors that drift (.my-modal .header, .my-modal .section)Use BEM-flat selectors (.my-modal__header, .my-modal__section) so a stray class="header" from a third-party widget can't accidentally inherit modal styles
Hardcode colors (#fff, #333) and font sizesReach for Obsidian's CSS custom properties (var(--text-normal), var(--font-text), var(--background-modifier-border)) so the modal is theme-portable across light, dark, and community themes for free
Three modals, three structural shapes ("each provider gets its own UI")One sectioned skeleton — header / question / search / returns / behavior / footer — applied identically to all three providers; the differences are in which controls each section contains, not in the layout itself
Treat "the modal stays narrow" as an Obsidian limitationTreat unexpected styling behavior as a clue that the CSS is targeting the wrong DOM node; verify with the inspector before concluding the framework is at fault

The generalizable point: the unlock was structural, not aesthetic. The width problem looked like a CSS taste question ("Obsidian modals just look narrow") and was actually a DOM-targeting question ("the class is on the wrong element"). Once that single line shifted from contentEl.addClass to modalEl.addClass, the room for everything else — sectioned layout, live taglines, breathing footer — appeared. The CSS files in this pass would have been written essentially the same way against either DOM target, but only one of those targets makes the result visible to the user.

Open Items

  • text-enhancement-modal and text-enhancement-with-images-modal are still on the contentEl.addClass pattern. The .css files set max-width: 800px correctly but the rule is silently ignored. These modals would benefit from the same conversion: change the one .ts line to modalEl.addClass(...), zero .modal-content padding, and they widen for free with no other changes. Held — separate intent from this pass, and the call sites are different enough (these modals are content-paste workflows, not Q&A) that a light layout audit should accompany the CSS conversion.

  • url-update-modal, article-generator-modal, and lmstudio-modal likewise. Same fix pattern; same scoping reason.

  • No automated visual-regression tests. All verification was manual via the Obsidian renderer. The plugin has no Playwright / Storybook setup; the BEM scoping makes it easy to add later (each modal class is independently snapshot-able), but adding a test runner is its own project.

  • Tagline copy is not externalized. The focus-mode and optimization taglines for Perplexica live as inline literals in PerplexicaModal.ts. Other modals route their copy through promptsService (e.g., getPerplexityQueryPlaceholder(), getDeepResearchDescription()). The Perplexica taglines are small enough that inlining is fine for now, but if the user wants to localize or A/B-test descriptions, they should be moved to promptsService for consistency with the other modals.

  • Effort xhigh and max model-binding is a description-only hint. The Claude modal's Effort dropdown lists xhigh (Opus 4.7) and max (Opus only) without enforcing model compatibility — a user could pair Haiku with max and discover the mismatch at request time. Validation could be added at submit, or the dropdown could disable model-incompatible options when the model dropdown changes; held for a UX follow-up.

  • No keyboard shortcut beyond Cmd/Ctrl+Enter. Tab order works but there's no Esc → Cancel wired explicitly (the default modal Esc behavior closes, which is fine but not documented in the modal). A small affordance like a footer hint ("⌘↵ to send · Esc to cancel") could be added — held.

  • Commit not created. The Perplexica pass is staged in working tree only (src/modals/PerplexicaModal.ts, src/styles/perplexica-modal.css, styles.css rebuilt). The Claude and Perplexity passes were committed earlier (9d5bc80 progress(model-provider), d7551d7 stuck(model), 46e4f24 align(requirements)).

Files Touched

perplexed/
├── src/
│   ├── modals/
│   │   ├── ClaudeModal.ts                    (created — 195 lines, sets the wide-modal unlock pattern; modalEl.addClass; sectioned skeleton; CLAUDE_MODELS + EFFORT_OPTIONS const tables; live model tagline; web-search / thinking / stream toggles; Cmd/Ctrl+Enter submit)
│   │   ├── PerplexityModal.ts                (rewritten — 157 → 243 lines; modalEl.addClass with inline comment explaining why; PERPLEXITY_MODELS + RECENCY_OPTIONS const tables; live model tagline that swaps to long-form description for sonar-deep-research; citations / images / related-questions toggles; Cmd/Ctrl+Enter submit)
│   │   └── PerplexicaModal.ts                (rewritten — 128 → 209 lines; modalEl.addClass; FOCUS_MODES + OPTIMIZATION_MODES const tables with taglines; live taglines via Setting.descEl; images / stream toggles; Cmd/Ctrl+Enter submit)
│   └── styles/
│       ├── claude-modal.css                  (created — 179 lines; full BEM stylesheet establishing the pattern; header / sections / footer chrome; textarea with focus halo; mod-cta button; mobile breakpoint at 600px)
│       ├── perplexity-modal.css              (rewritten — ~26 → 184 lines; mirrors claude-modal.css shape with perplexity- prefix; same width / padding / chrome / button conventions)
│       ├── perplexica-modal.css              (rewritten — 6 → 161 lines; mirrors claude-modal.css shape with perplexica- prefix; same width / padding / chrome / button conventions)
│       └── main.css                          (already imported all three modal stylesheets — no plumbing change required)
└── context-v/
    └── issues/
        └── Widen-Modals-in-Obsidian-using-CSS.md   (full write-up of the contentEl-vs-modalEl unlock, with copy-paste starter CSS, design-token table, common gotchas, and counter-examples in this same repo)

main.js and styles.css are build artifacts; rebuilt by pnpm run build and not part of the source-of-truth diff.

Reference

  • Predecessor changelog: 2026-05-02_01.md — toolchain maintenance pass (deps refresh + streaming citations bug fix). This pass picks up from that baseline; the redesigns assume the toolchain is current and the streaming pipeline returns correct citation metadata.

  • The wide-modal write-up: context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md — TL;DR, DOM-element distinction, why the convention fails, full fix with copy-paste starter CSS + matching Modal skeleton, theme token table, common gotchas, verification receipts pointing at the working / counter-example modals in this repo.

  • Pattern source modal: src/modals/ClaudeModal.ts + src/styles/claude-modal.css are the reference implementation; the other two follow this shape.

  • Obsidian Modal API: Modal.modalEl and Modal.contentEl are both public; this pass relies on the distinction. Setting.descEl is also public and is what the Perplexica modal uses to live-update taglines.

  • CSS custom properties used: --text-normal, --text-muted, --text-faint, --text-on-accent, --background-primary, --background-secondary, --background-modifier-border, --background-modifier-border-hover, --background-modifier-hover, --interactive-accent, --interactive-accent-hover, --font-text, --modal-radius. All Obsidian-native tokens; theme-portable across light / dark / community themes.

  • Counter-example modals (still on the old pattern): text-enhancement-modal.css, text-enhancement-with-images-modal.css, url-update-modal.css, article-generator-modal.css, lmstudio-modal.css — all set width / max-width on classes attached via contentEl.addClass(...) and therefore render at the Obsidian default narrow width. Conversion to the new pattern is a one-line .ts change + zeroed .modal-content padding; held for a follow-up pass.

  • Recent commits on development (this pass): 9d5bc80 progress(model-provider): attempt to add new model, claude (Claude modal landed), d7551d7 stuck(model): stuck trying to get model Claude API working (Perplexity modal redesign + first pass at the wide-modal unlock applied to both), 46e4f24 align(requirements): align Perplexed to Obsidian's plugin community standards (tidy alignment of both modals), Perplexica redesign — staged in working tree only.