mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-29 11:35:47 +00:00
Closes ADR-093 P0.10, P1.2, P1.6, P1.7, P2.1, P2.2, P2.3, P2.5.
## Iter G — modal contents (P1.6)
- nv-palette "New scene…" now opens a 5-field form (name, dipole
moment, heart→sensor distance, ferrous toggle, 60 Hz mains toggle).
On Apply: builds a real Scene JSON and pushes via client.loadScene().
- nv-palette "Export proof bundle…" now calls client.exportProofBundle()
and triggers a real blob download with a timestamp filename.
## Iter H — a11y pass (P2.1, P2.2, P2.3, P2.5)
- Skip-to-main-content link at top of nv-app (focus-visible only).
- <main id="main-content" role="main"> wraps the central area; tabindex="-1"
so the skip link can land focus there.
- nv-rail wraps its 5 view buttons in <nav role="navigation"
aria-label="Primary"> with aria-current="page" on the active button
and aria-label on every button. SVGs marked aria-hidden="true".
- nv-console body is now role="log" aria-live="polite"
aria-label="Console output".
- nv-modal auto-focuses first interactive element on open and traps
Tab cycling inside the dialog; nv-onboarding already had a dismiss
affordance covered.
## Iter I — drag persistence (P1.7) + density visual (P1.2)
- scenePositions signal in appStore + IndexedDB key 'scene-positions'.
- nv-scene restores drag positions at connect; persists on pointerup.
- Density visual (CSS body.density-{comfy,default,compact}) confirmed
active — was already wired but flagged as "doesn't change anything"
in P1.2; verified during this iter.
## P0.10 — REPL history persistence
- replHistory + pushReplHistory in appStore, persisted to IndexedDB
key 'repl-history'.
- nv-console arrow-up/down now read from the shared signal so command
history survives view switches and reloads.
Validated end-to-end with `npx agent-browser` on
https://ruvnet.github.io/RuView/nvsim/ — skip-link, main role, console
log role, nav role, aria-current="page", New Scene modal with 5 form
fields all confirmed live. Console errors: zero.
ADR-093 §2/§3/§4 updated to mark these items resolved.
Co-Authored-By: claude-flow <ruv@ruv.net>
154 lines
5.3 KiB
TypeScript
154 lines
5.3 KiB
TypeScript
/* Modal dialog — opened via window.dispatchEvent('nv-modal', { title, body, buttons }). */
|
||
import { LitElement, html, css } from 'lit';
|
||
import { customElement, state } from 'lit/decorators.js';
|
||
|
||
interface ModalButton {
|
||
label: string;
|
||
variant?: 'ghost' | 'primary' | 'danger';
|
||
onClick?: () => void;
|
||
}
|
||
interface ModalReq {
|
||
title: string;
|
||
body: string;
|
||
buttons?: ModalButton[];
|
||
}
|
||
|
||
@customElement('nv-modal')
|
||
export class NvModal extends LitElement {
|
||
@state() private open = false;
|
||
@state() private mTitle = '';
|
||
@state() private mBody = '';
|
||
@state() private buttons: ModalButton[] = [];
|
||
|
||
static styles = css`
|
||
:host {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0,0,0,0.55);
|
||
backdrop-filter: blur(4px);
|
||
z-index: 200;
|
||
display: grid; place-items: center;
|
||
opacity: 0; pointer-events: none;
|
||
transition: opacity 0.18s;
|
||
}
|
||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||
.modal {
|
||
background: var(--bg-1);
|
||
border: 1px solid var(--line-2);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||
width: min(520px, 92vw);
|
||
max-height: 86vh;
|
||
display: flex; flex-direction: column;
|
||
transform: translateY(12px) scale(0.98);
|
||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||
}
|
||
:host([open]) .modal { transform: translateY(0) scale(1); }
|
||
.h {
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.h .ttl { font-size: 14px; font-weight: 600; }
|
||
.body { padding: 16px; overflow-y: auto; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
|
||
.f {
|
||
padding: 12px 16px;
|
||
border-top: 1px solid var(--line);
|
||
display: flex; gap: 8px; justify-content: flex-end;
|
||
}
|
||
button {
|
||
padding: 6px 12px;
|
||
border-radius: 8px;
|
||
font-size: 12.5px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-2); color: var(--ink);
|
||
}
|
||
button.ghost { background: transparent; }
|
||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
|
||
button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
|
||
.close {
|
||
width: 28px; height: 28px;
|
||
background: transparent; border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
color: var(--ink-2);
|
||
}
|
||
`;
|
||
|
||
override connectedCallback(): void {
|
||
super.connectedCallback();
|
||
window.addEventListener('nv-modal', this.onModal as EventListener);
|
||
window.addEventListener('keydown', this.onKey);
|
||
}
|
||
override disconnectedCallback(): void {
|
||
super.disconnectedCallback();
|
||
window.removeEventListener('nv-modal', this.onModal as EventListener);
|
||
window.removeEventListener('keydown', this.onKey);
|
||
}
|
||
|
||
private onModal = (e: Event): void => {
|
||
const r = (e as CustomEvent).detail as ModalReq;
|
||
this.mTitle = r.title; this.mBody = r.body;
|
||
this.buttons = r.buttons ?? [{ label: 'Close', variant: 'primary' }];
|
||
this.open = true; this.setAttribute('open', '');
|
||
// a11y: focus the first interactive element inside the modal so keyboard
|
||
// users land in the dialog rather than behind it. Light focus trap via
|
||
// the keydown handler below catches Tab cycling.
|
||
requestAnimationFrame(() => {
|
||
const root = this.shadowRoot;
|
||
if (!root) return;
|
||
const first = root.querySelector<HTMLElement>('input, select, textarea, button:not(.close)');
|
||
first?.focus();
|
||
});
|
||
};
|
||
|
||
override updated(): void {
|
||
if (!this.open) return;
|
||
const root = this.shadowRoot;
|
||
if (!root) return;
|
||
// Trap Tab inside the modal while open.
|
||
const trap = (e: KeyboardEvent): void => {
|
||
if (e.key !== 'Tab') return;
|
||
const focusables = Array.from(
|
||
root.querySelectorAll<HTMLElement>('input, select, textarea, button, [href]'),
|
||
).filter((el) => !el.hasAttribute('disabled'));
|
||
if (focusables.length === 0) return;
|
||
const first = focusables[0];
|
||
const last = focusables[focusables.length - 1];
|
||
const active = (root.activeElement as HTMLElement | null) ?? null;
|
||
if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
|
||
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
|
||
};
|
||
root.removeEventListener('keydown', trap as EventListener);
|
||
root.addEventListener('keydown', trap as EventListener);
|
||
}
|
||
|
||
private onKey = (e: KeyboardEvent): void => {
|
||
if (e.key === 'Escape' && this.open) this.close();
|
||
};
|
||
|
||
private close(): void { this.open = false; this.removeAttribute('open'); }
|
||
private clickBtn(b: ModalButton): void { b.onClick?.(); this.close(); }
|
||
|
||
override render() {
|
||
return html`
|
||
<div class="modal" role="dialog" aria-modal="true">
|
||
<div class="h">
|
||
<div class="ttl">${this.mTitle}</div>
|
||
<button class="close" @click=${() => this.close()}>×</button>
|
||
</div>
|
||
<div class="body" .innerHTML=${this.mBody}></div>
|
||
<div class="f">
|
||
${this.buttons.map((b) => html`
|
||
<button class=${b.variant ?? ''} @click=${() => this.clickBtn(b)}>${b.label}</button>
|
||
`)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
export function openModal(req: ModalReq): void {
|
||
window.dispatchEvent(new CustomEvent('nv-modal', { detail: req }));
|
||
}
|