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
2 changes: 1 addition & 1 deletion docs/design/dd-spotlight-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ In the browse lens:

- Each entity renders as a compact card (name, classification badge, group accent, PK, column count) on a responsive CSS grid, grouped under the existing group headers.
- Hovering a card spotlights it: all unconnected cards dim; SVG leader lines are drawn from the card to every *connected, on-screen* card, labeled with the relationship predicate and cardinality, with direction encoded by arrowhead and color.
- Connections to *off-screen* cards render as chips on the spotlighted card ("↓ Invoice · bills") that scroll to and flash the target.
- Connections to *off-screen* cards render as chips on the spotlighted card ("↓ Invoice · bills") that smoothly scroll to the target, then flash it only after the scroll settles so the attention cue is visible.
- Clicking a card pins the spotlight (survives mouse-out); Esc or clicking empty grid releases it.

The predicates are the payoff: a spotlit `Party` literally reads as business sentences radiating outward — `makes →` to `Payment`, `← is owned by` from `Account`. This is the project's predicate philosophy made visible (predicates encode business language, not ORM verbs).
Expand Down
6 changes: 3 additions & 3 deletions docs/spec/dd-spotlight-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The grid is ordered by group then alphabetically, not by connectivity, so a node

### Off-screen chips

- Rendered on the active card: one chip per off-screen connection, `<arrow> <Name> · <label>` where the arrow glyph is ↑/↓ by target position. FK connections use the predicate (`fwd`/`rev`, `fwd ⇄ rev` for a both-bundle); flow connections use the data payload. Click scrolls the target card into view (`scrollIntoView` smooth, block center) and flashes it (temporary CSS class, animation ~1.2s, class removed on animationend).
- Rendered on the active card: one chip per off-screen connection, `<arrow> <Name> · <label>` where the arrow glyph is ↑/↓ by target position. FK connections use the predicate (`fwd`/`rev`, `fwd ⇄ rev` for a both-bundle); flow connections use the data payload. Click smoothly scrolls the target card into view (`scrollIntoView`, block center), using instant scrolling when the user prefers reduced motion. The temporary flash class is added only after the dictionary scroll position has settled and the target intersects the scrollport. It pulses three times over two seconds, then is removed on `animationend`. A newer chip click cancels any pending or active flash from the previous navigation.
- Chips do not change card layout of *other* cards (no grid reflow on hover); they overlay or extend only the active card.

### CSS — `src/app/styles.css`
Expand Down Expand Up @@ -149,7 +149,7 @@ The visual test `test/visual/test-dd-spotlight-grid.ts` (Playwright, `models/key
| CP2 | Lens toggle + GridCard + grid rendering + `onOpenEntity` wiring. Visual test created with assertions: toggle renders in sticky bar; browse lens shows a grid card per entity (count vs `/api/model` nodes); search filters grid cards; lens persists across reload (localStorage); print emulation (`page.emulateMedia({ media: 'print' })`) shows read-lens DOM; ⓘ opens entity modal | typecheck (src/ adds no new errors) + `bun test/visual/test-dd-spotlight-grid.ts` |
| CP3 | Spotlight state + dimming. Test extended: hover a known entity and assert the lit set is EXACTLY {active} ∪ connected ids — compute the expected set in-page from `window.__MODEL__`-derived edges (or fetch `/api/model`), not hardcoded — and all other cards carry the dim class; pin survives mouse-out; Esc releases; empty-grid click releases; lens switch + search change clear pin. This count assertion is the validation gate for the design's "Known tension" (dimming makes line crossings tolerable) | `bun test/visual/test-dd-spotlight-grid.ts` |
| CP4 | `SpotlightOverlay` leader lines. Test extended: `<path>` count equals on-screen connection count for a pinned entity; predicate label text present (e.g. a known predicate from key-inherited); distinct stroke colors for out vs in; anchors track a window resize (resize viewport, assert line endpoint within tolerance of card rect) | `bun test/visual/test-dd-spotlight-grid.ts` |
| CP5 | Off-screen chips. Test extended: pin an entity with off-screen connections; chip rendered with arrow + name + predicate; click scrolls target into scrollport and flash class appears then clears | `bun test/visual/test-dd-spotlight-grid.ts` |
| CP5 | Off-screen chips. Test extended: pin an entity with off-screen connections; chip rendered with arrow + name + predicate; click scrolls target into the scrollport; flash is absent while scrolling, appears after scroll settlement, then clears | `bun test/visual/test-dd-spotlight-grid.ts` |
| CP6 | Full visual review: run the complete script; capture + REVIEW screenshots dark + light (spotlight active, pinned, chips visible); confirm read lens byte-identical behavior (CP9 highlight + CP10 print tests still pass) | `bun test/visual/test-dd-spotlight-grid.ts` + `bun test/visual/test-cp9-dd-search-highlight.ts` + screenshots reviewed |
| CP7 | Docs: CLAUDE.md feature-map row — Feature: "DD browse lens: spotlight grid (cards, hover/pin spotlight, predicate leader lines, off-screen chips)", Design: `dd-spotlight-grid`, Spec: `dd-spotlight-grid`, Guide: `—`, Skill: `—` | row present; `bun run typecheck` (src/ adds no new errors) + full `bun run test` |

Expand Down Expand Up @@ -213,7 +213,7 @@ Shipped CP1–CP7 via the autopilot subagent loop (worktree `dd-spotlight-grid`)
- **CP2** `GridCard.tsx` + lens toggle in `DictionaryView` + `onOpenEntity` wiring in `App.tsx`. Browse/read lenses, localStorage `ignatius-dict-lens`, print forces read. Findings folded: print save/restore resets `savedLensRef` after restore; reader legend gated to read lens; ⓘ-modal test assertion tightened to `.modal-backdrop`.
- **CP3** spotlight state + dimming. Blocking finding fixed: dropped `pointer-events: none` from the dim class (dimmed cards stay interactive — hover retargets, ⓘ still opens); spotlight state moved above `switchLens`; `hoverId` cleared on lens switch.
- **CP4** `SpotlightOverlay.tsx` — leader lines, amber out / teal in (theme vars `--spotlight-line-out`/`--spotlight-line-in`, dark+light), arrowheads encode direction (`auto-start-reverse` so in-arrows point at the active card), midpoint pill stacks one predicate label per bundled edge with a parent-first cardinality chip (the always-on placement here was **superseded by CP14 hover-reveal** — pills now hidden by default, revealed on connected-card hover); rAF-throttled anchoring on scroll/resize/ResizeObserver. Blocking finding fixed: in-arrowhead orientation. Added per-path stroke-mapping + rev-text + cardinality-orientation assertions.
- **CP5** off-screen chips — connections outside the scrollport render as chips (arrow + name + predicate; `fwd ⇄ rev` for a both-bundle) instead of lines; click scrolls + flashes the target; a connection is exactly line-or-chip via a shared on-screen set.
- **CP5** off-screen chips — connections outside the scrollport render as chips (arrow + name + predicate; `fwd ⇄ rev` for a both-bundle) instead of lines; click scrolls the target, waits for the dictionary scroll position to settle, then flashes the now-visible card; a connection is exactly line-or-chip via a shared on-screen set.
- **CP6** light-mode visual coverage added to the test (theme toggled via the real `.theme-toggle`); read-lens regressions (CP9 search highlight, CP10 print, sticky-search + debounce) all still PASS.
- **CP7** CLAUDE.md feature-map row + this log.

Expand Down
101 changes: 96 additions & 5 deletions src/app/components/entity/SpotlightOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,97 @@ export function SpotlightOverlay({

useEffect(() => {
const NS = 'http://www.w3.org/2000/svg';
const SCROLL_SETTLE_MS = 100;
const SCROLL_SETTLE_TIMEOUT_MS = 5_000;
const FLASH_CLASS = 'dict-grid-card--flash';
let scrollSettleRaf: number | null = null;
let flashingCard: HTMLElement | null = null;
let flashEndHandler: ((event: AnimationEvent) => void) | null = null;

function clearActiveFlash() {
if (flashingCard !== null) {
if (flashEndHandler !== null) {
flashingCard.removeEventListener('animationend', flashEndHandler);
}
flashingCard.classList.remove(FLASH_CLASS);
}
flashingCard = null;
flashEndHandler = null;
}

function cancelPendingFlash() {
if (scrollSettleRaf !== null) {
cancelAnimationFrame(scrollSettleRaf);
scrollSettleRaf = null;
}
}

function flashCard(targetCard: HTMLElement) {
clearActiveFlash();
flashingCard = targetCard;
flashEndHandler = () => {
targetCard.classList.remove(FLASH_CLASS);
if (flashingCard === targetCard) {
flashingCard = null;
flashEndHandler = null;
}
};

targetCard.classList.add(FLASH_CLASS);
targetCard.addEventListener('animationend', flashEndHandler, { once: true });
}

function scrollToCardAndFlash(targetCard: HTMLElement, scrollport: HTMLElement) {
cancelPendingFlash();
clearActiveFlash();

const startedAt = performance.now();
let lastMovementAt = startedAt;
let previousScrollLeft = scrollport.scrollLeft;
let previousScrollTop = scrollport.scrollTop;

const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth';
targetCard.scrollIntoView({ behavior: scrollBehavior, block: 'center' });

function waitForScrollToSettle(now: number) {
const currentScrollLeft = scrollport.scrollLeft;
const currentScrollTop = scrollport.scrollTop;
if (
currentScrollLeft !== previousScrollLeft
|| currentScrollTop !== previousScrollTop
) {
previousScrollLeft = currentScrollLeft;
previousScrollTop = currentScrollTop;
lastMovementAt = now;
}

const targetRect = targetCard.getBoundingClientRect();
const scrollportRect = scrollport.getBoundingClientRect();
const targetIsVisible =
targetRect.bottom >= scrollportRect.top
&& targetRect.top <= scrollportRect.bottom
&& targetRect.right >= scrollportRect.left
&& targetRect.left <= scrollportRect.right;

if (targetIsVisible && now - lastMovementAt >= SCROLL_SETTLE_MS) {
scrollSettleRaf = null;
flashCard(targetCard);
return;
}

if (now - startedAt >= SCROLL_SETTLE_TIMEOUT_MS) {
scrollSettleRaf = null;
return;
}

scrollSettleRaf = requestAnimationFrame(waitForScrollToSettle);
}

scrollSettleRaf = requestAnimationFrame(waitForScrollToSettle);
}


function renderPredicatePill(
svg: SVGSVGElement,
Expand Down Expand Up @@ -749,11 +840,9 @@ export function SpotlightOverlay({
e.stopPropagation();
const targetCard = resolveOtherCard(targetId);
if (targetCard === null) return;
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetCard.classList.add('dict-grid-card--flash');
targetCard.addEventListener('animationend', () => {
targetCard.classList.remove('dict-grid-card--flash');
}, { once: true });
const scrollport = document.querySelector<HTMLElement>('[data-ignatius="dict-view"]');
if (scrollport === null) return;
scrollToCardAndFlash(targetCard, scrollport);
});

container.appendChild(btn);
Expand Down Expand Up @@ -1073,6 +1162,8 @@ export function SpotlightOverlay({
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
cancelPendingFlash();
clearActiveFlash();
};
}, [activeId, connections, flowConnections, inheritedConnections, labelHoverCardId, gridContainerRef]);

Expand Down
13 changes: 8 additions & 5 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2740,15 +2740,18 @@ a.dict-link-missing:hover {
text-overflow: ellipsis;
}

/* Flash animation — added by chip click, removed on animationend. */
/* Three attention pulses over two seconds; added after scrolling settles. */
@keyframes spotlight-flash {
0% { box-shadow: 0 0 0 3px var(--spotlight-line-out, #f59e0b), 0 0 12px 4px var(--spotlight-line-out, #f59e0b); }
60% { box-shadow: 0 0 0 3px var(--spotlight-line-out, #f59e0b), 0 0 12px 4px var(--spotlight-line-out, #f59e0b); }
100% { box-shadow: none; }
0%, 100% { box-shadow: none; }
35% {
box-shadow:
0 0 0 3px var(--spotlight-line-out, #f59e0b),
0 0 12px 4px var(--spotlight-line-out, #f59e0b);
}
}

.dict-grid-card--flash {
animation: spotlight-flash 1.2s ease-out forwards;
animation: spotlight-flash 666.667ms ease-in-out 3 forwards;
}

/* ── Focus / isolate mode (CP15) ─────────────────────────────────────────── */
Expand Down
Loading
Loading