Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 86 additions & 36 deletions src/components/FrameworkSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,56 +18,105 @@ export class FrameworkSelector {
readonly el: HTMLElement;
private select: HTMLSelectElement | null = null;
private panelId: AnalysisPanelId;
private popup: HTMLElement | null = null;
private btn: HTMLButtonElement;
private outsideClickHandler: ((e: MouseEvent) => void) | null = null;
private note: string | undefined;

constructor(opts: FrameworkSelectorOptions) {
this.panelId = opts.panelId;
this.note = opts.note;

const btn = document.createElement('button');
btn.className = 'icon-btn framework-settings-btn';
btn.innerHTML = '⚙';
this.btn = btn;
Comment on lines +30 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 aria-expanded not initialized

The button's aria-expanded attribute is never set in the constructor. Before the popup is opened for the first time, the button carries no aria-expanded attribute at all. Screen readers may not announce the collapsed state correctly, and any CSS that targets [aria-expanded="false"] will silently not match.

Suggested change
const btn = document.createElement('button');
btn.className = 'icon-btn framework-settings-btn';
btn.innerHTML = '⚙';
this.btn = btn;
btn.className = 'icon-btn framework-settings-btn';
btn.innerHTML = '';
btn.setAttribute('aria-expanded', 'false');
this.btn = btn;


if (opts.isPremium) {
const select = document.createElement('select');
select.className = 'framework-selector';
select.className = 'framework-popup-select';
this.select = select;

this.populateOptions(select);
select.value = getActiveFrameworkForPanel(opts.panelId)?.id ?? '';

select.addEventListener('change', () => {
setActiveFrameworkForPanel(opts.panelId, select.value || null);
this.updateBtnTitle();
this.closePopup();
});

if (opts.note) {
const wrap = document.createElement('span');
wrap.className = 'framework-selector-wrap';
const noteEl = document.createElement('span');
noteEl.className = 'framework-selector-note';
noteEl.title = opts.note;
noteEl.textContent = '*';
wrap.append(select, noteEl);
this.el = wrap;
} else {
this.el = select;
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (this.popup) {
this.closePopup();
} else {
this.openPopup();
}
});
} else {
const select = document.createElement('select');
select.className = 'framework-selector framework-selector--locked';
select.disabled = true;
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'Default (Neutral)';
select.appendChild(defaultOpt);

const badge = document.createElement('span');
badge.className = 'framework-selector-pro-badge';
badge.textContent = 'PRO';

const wrap = document.createElement('span');
wrap.className = 'framework-selector-wrap';
wrap.append(select, badge);
if (opts.panel) {
wrap.addEventListener('click', () => {
opts.panel!.showGatedCta(PanelGateReason.FREE_TIER, () => {});
});
btn.classList.add('framework-settings-btn--locked');
btn.addEventListener('click', (e) => {
e.stopPropagation();
opts.panel?.showGatedCta(PanelGateReason.FREE_TIER, () => {});
});
}

this.updateBtnTitle();
this.el = btn;
}

private updateBtnTitle(): void {
const fw = this.select ? getActiveFrameworkForPanel(this.panelId) : null;
this.btn.title = fw ? `Framework: ${fw.name}` : 'Analysis framework';
}

private openPopup(): void {
const btnRect = this.btn.getBoundingClientRect();

const popup = document.createElement('div');
popup.className = 'framework-settings-popup';
popup.style.top = `${btnRect.bottom + 4}px`;
popup.style.right = `${document.documentElement.clientWidth - btnRect.right}px`;
Comment on lines +72 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Popup position drifts on scroll / resize

top and right are computed once from getBoundingClientRect() at the moment the popup opens, then written as static position: fixed values. If the user scrolls the page (or resizes the window) while the popup is open, the gear button moves but the popup stays at the original coordinates, creating a visual disconnect.

Consider adding scroll and resize listeners (passive, on window) that call closePopup(), keeping behaviour consistent with other lightweight dropdowns in the app:

const closeOnScroll = () => this.closePopup();
window.addEventListener('scroll', closeOnScroll, { passive: true, capture: true });
window.addEventListener('resize', closeOnScroll, { passive: true });
// store refs and remove in closePopup()


const label = document.createElement('div');
label.className = 'framework-settings-label';
label.textContent = 'Analysis Framework';
popup.appendChild(label);

if (this.select) {
popup.appendChild(this.select);
}

if (this.note) {
const noteEl = document.createElement('div');
noteEl.className = 'framework-settings-note';
noteEl.textContent = this.note;
popup.appendChild(noteEl);
}

document.body.appendChild(popup);
this.popup = popup;
this.btn.setAttribute('aria-expanded', 'true');

const handler = (e: MouseEvent) => {
if (!popup.contains(e.target as Node) && e.target !== this.btn) {
this.closePopup();
}
Comment on lines +100 to 103
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 e.target !== this.btn check is dead code

Both button click listeners (premium and free-tier branches) call e.stopPropagation(), so a click on this.btn is never propagated to the document-level handler. The condition e.target !== this.btn can therefore never be false, making it unreachable dead code.

The guard is harmless today but could mislead a future developer into thinking button-clicks reach this handler. It can be simplified to just:

Suggested change
const handler = (e: MouseEvent) => {
if (!popup.contains(e.target as Node) && e.target !== this.btn) {
this.closePopup();
}
const handler = (e: MouseEvent) => {
if (!popup.contains(e.target as Node)) {
this.closePopup();
}
};

this.el = wrap;
};
this.outsideClickHandler = handler;
setTimeout(() => document.addEventListener('click', handler), 0);
}

private closePopup(): void {
if (!this.popup) return;
if (this.select && this.popup.contains(this.select)) {
this.popup.removeChild(this.select);
}
this.popup.remove();
this.popup = null;
this.btn.setAttribute('aria-expanded', 'false');
if (this.outsideClickHandler) {
document.removeEventListener('click', this.outsideClickHandler);
this.outsideClickHandler = null;
}
}

Expand All @@ -91,9 +140,10 @@ export class FrameworkSelector {
const current = this.select.value;
this.populateOptions(this.select);
this.select.value = getActiveFrameworkForPanel(this.panelId)?.id ?? current;
this.updateBtnTitle();
}

destroy(): void {
// No async listeners to clean up; GC handles the rest
this.closePopup();
}
}
70 changes: 44 additions & 26 deletions src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -22135,38 +22135,56 @@ body.map-width-resizing {
margin-right: 4px;
}

.framework-selector {
font-size: 11px;
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 3px;
color: inherit;
padding: 1px 4px;
cursor: pointer;
max-width: 150px;
.framework-settings-btn {
font-size: 12px;
opacity: 0.6;
transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.framework-selector--locked {
opacity: 0.5;
cursor: not-allowed;
.framework-settings-btn:hover,
.framework-settings-btn[aria-expanded="true"] {
opacity: 1;
}
.framework-selector-wrap {
display: inline-flex;
align-items: center;
gap: 4px;
.framework-settings-btn--locked {
opacity: 0.35;
}
.framework-selector-pro-badge {
font-size: 9px;
font-weight: 700;
background: var(--color-accent, #7c3aed);
color: #fff;

.framework-settings-popup {
position: fixed;
z-index: 9999;
background: var(--bg-panel, #0f1117);
border: 1px solid var(--border);
border-radius: 5px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 190px;
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
}
.framework-settings-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.framework-popup-select {
font-size: 12px;
background: var(--overlay-subtle, rgba(255,255,255,0.05));
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 4px;
letter-spacing: 0.05em;
color: var(--text);
padding: 4px 6px;
cursor: pointer;
width: 100%;
}
.framework-selector-note {
.framework-popup-select:focus {
outline: 1px solid var(--accent);
}
.framework-settings-note {
font-size: 10px;
opacity: 0.5;
cursor: help;
color: var(--text-dim);
line-height: 1.4;
opacity: 0.7;
}

/* ── WM Analyst Chat Panel ──────────────────────────────────────── */
Expand Down
Loading