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:
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.
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'swidth: 90vw; max-width: 800px;rule on that class was a no-op. The outer.modalelement kept Obsidian's default constraints and the inner.modal-contentshrank to fit. So even the modals that tried to widen looked indistinguishable from the bare default.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
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:
.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:
// 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:
.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:
.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-hovergive 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-secondarybackground. 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):
.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:
.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:
.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):
@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. Selectingsonar-deep-researchswaps in the long-form description frompromptsService.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_20250305server-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:
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/#000exists anywhere in the new stylesheets.
What Changed in Approach (the meta-lesson)
| Pattern this rejects | Pattern this adopts |
Attach the modal class to contentEl because that's what the Obsidian sample plugin does | Attach 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 toggles | Use 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 does | Surface 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 sizes | Reach 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 limitation | Treat 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-modalandtext-enhancement-with-images-modalare still on thecontentEl.addClasspattern. The.cssfiles setmax-width: 800pxcorrectly but the rule is silently ignored. These modals would benefit from the same conversion: change the one.tsline tomodalEl.addClass(...), zero.modal-contentpadding, 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, andlmstudio-modallikewise. 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 throughpromptsService(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 topromptsServicefor consistency with the other modals.Effort
xhighandmaxmodel-binding is a description-only hint. The Claude modal's Effort dropdown listsxhigh(Opus 4.7) andmax(Opus only) without enforcing model compatibility — a user could pair Haiku withmaxand 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 → Cancelwired 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.cssrebuilt). 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 + matchingModalskeleton, 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.cssare the reference implementation; the other two follow this shape.Obsidian Modal API:
Modal.modalElandModal.contentElare both public; this pass relies on the distinction.Setting.descElis 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 setwidth/max-widthon classes attached viacontentEl.addClass(...)and therefore render at the Obsidian default narrow width. Conversion to the new pattern is a one-line.tschange + zeroed.modal-contentpadding; 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.