diff --git a/src/lib/components/SearchPalette.svelte b/src/lib/components/SearchPalette.svelte
new file mode 100644
index 0000000..d05793d
--- /dev/null
+++ b/src/lib/components/SearchPalette.svelte
@@ -0,0 +1,266 @@
+
+
+{#if open}
+
+
+
+
+
(activeIndex = 0)}
+ type="text"
+ class="palette-input"
+ placeholder="Search skills, projects, companies…"
+ role="combobox"
+ aria-expanded={results.length > 0}
+ aria-controls="palette-results"
+ aria-autocomplete="list"
+ aria-activedescendant={results.length ? `palette-opt-${activeIndex}` : undefined}
+ autocomplete="off"
+ spellcheck="false"
+ />
+
+ {#if query && !results.length}
+
No matches for “{query}”.
+ {/if}
+
+
+ {#each results as result, i (result.node.id)}
+
+
+ - choose(i)}
+ onmousemove={() => (activeIndex = i)}
+ >
+
+ {result.node.label}
+ {#if result.context}
+ {result.context}
+ {/if}
+ {result.node.category}
+
+ {/each}
+
+
+
+ ↑↓ to navigate · Enter to open · Esc to close
+
+
+{/if}
+
+
diff --git a/src/lib/components/SkillTree.svelte b/src/lib/components/SkillTree.svelte
index d98193f..e2c2121 100644
--- a/src/lib/components/SkillTree.svelte
+++ b/src/lib/components/SkillTree.svelte
@@ -12,6 +12,7 @@
import TreeNavigator from './TreeNavigator.svelte';
import Hud from './Hud.svelte';
import CoachHint from './CoachHint.svelte';
+ import SearchPalette from './SearchPalette.svelte';
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
import {
BRANCH_IDS,
@@ -24,7 +25,13 @@
import { createController, type SkillTreeController } from '$lib/pixi/controller';
import { createLines } from '$lib/pixi/lines';
import { createNodes } from '$lib/pixi/nodes';
- import { attachPanZoom, attachKeyboard, updateParallax, panToNode } from '$lib/pixi/interaction';
+ import {
+ attachPanZoom,
+ attachKeyboard,
+ updateParallax,
+ panToNode,
+ updateLabels
+ } from '$lib/pixi/interaction';
import { runEntranceAnimations } from '$lib/pixi/animation';
import { createLifecycle } from '$lib/pixi/lifecycle';
@@ -41,6 +48,7 @@
const VISITED_KEY = 'atlas:visited';
let introPlaying = $state(false);
let showCoachHint = $state(false);
+ let searchOpen = $state(false);
let skipEntrance: (() => void) | null = null;
function hasVisitedBefore() {
@@ -248,7 +256,9 @@
onToggleStats: toggleStatsPanel,
onCloseModal: closeModal,
isModalVisible: () => modalVisible,
- isStatsVisible: () => statsPanelVisible
+ isStatsVisible: () => statsPanelVisible,
+ onOpenSearch: () => (searchOpen = true),
+ isSearchOpen: () => searchOpen
},
life
);
@@ -306,6 +316,10 @@
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
});
+ // Labels start hidden below the zoom threshold; set their initial state
+ // so they're correct before the first zoom interaction.
+ updateLabels(controller);
+
// Sequenced entrance animation. Returning visitors skip straight to the
// settled tree; first-timers get the intro plus a Skip control.
const returning = hasVisitedBefore();
@@ -407,6 +421,15 @@
+ (searchOpen = false)}
+ onSelect={(node) => {
+ if (controllerRef) panToNode(controllerRef, node.x, node.y, life);
+ openModal(node);
+ }}
+/>
+
{
diff --git a/src/lib/config.ts b/src/lib/config.ts
index bb79799..85ce805 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -38,6 +38,16 @@ export const NODE_RADII: Record = {
};
export const MOBILE_RADIUS_FACTOR = 0.8;
+/**
+ * Zoom at which on-canvas node labels appear.
+ *
+ * Deliberately just above the settled desktop zoom (endScale = 1), so the
+ * default full-tree view stays a clean constellation — 57 constant-size labels
+ * at that zoom overlap into noise. Zooming in at all reveals them, and 'L'
+ * forces them on at any zoom.
+ */
+export const LABEL_ZOOM_THRESHOLD = 1.05;
+
/** Keyboard pan distance per arrow-key press (px). */
export const PAN_SPEED = 50;
diff --git a/src/lib/pixi/animation.ts b/src/lib/pixi/animation.ts
index a1c7506..c42ab19 100644
--- a/src/lib/pixi/animation.ts
+++ b/src/lib/pixi/animation.ts
@@ -5,7 +5,7 @@ import { Container } from 'pixi.js';
import { gsap } from 'gsap';
import { getDistanceFromOrigin } from '$lib/utils/tree';
import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config';
-import { updateParallax } from './interaction';
+import { updateParallax, updateLabels } from './interaction';
import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
@@ -36,11 +36,13 @@ export function runEntranceAnimations(
/** Put the tree straight into its settled, interactive state. */
function showFinishedTree() {
treeContainer.scale.set(endScale);
+ updateLabels(ctrl);
for (const obj of nodeObjects) {
obj.graphics.alpha = 1;
obj.graphics.scale.set(1);
obj.glowGraphics.alpha = 1;
+ if (obj.label) obj.label.alpha = 1;
if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static';
}
@@ -136,6 +138,14 @@ export function runEntranceAnimations(
delay: originDelay,
ease: 'power2.out'
});
+ if (originNode.label) {
+ gsap.to(originNode.label, {
+ alpha: 1,
+ duration: originDuration,
+ delay: originDelay,
+ ease: 'power2.out'
+ });
+ }
}
// STEP 2: Start zoom out after origin appears
@@ -147,7 +157,10 @@ export function runEntranceAnimations(
duration: zoomDuration,
delay: zoomDelay,
ease: 'power1.out',
- onUpdate: () => updateParallax(ctrl)
+ onUpdate: () => {
+ updateParallax(ctrl);
+ updateLabels(ctrl);
+ }
});
// STEP 3: Animate in the rest of the nodes (excluding origin)
@@ -182,6 +195,12 @@ export function runEntranceAnimations(
ease: 'power2.out'
});
+ // Labels arrive with their node, never before it — otherwise they show
+ // over invisible nodes during the splash.
+ if (obj.label) {
+ gsap.to(obj.label, { alpha: 1, duration: 0.5, delay, ease: 'power2.out' });
+ }
+
// Start ambient pulse after node appears
gsap.to(obj.glowGraphics.scale, {
x: 1.4,
diff --git a/src/lib/pixi/controller.ts b/src/lib/pixi/controller.ts
index fe3d611..818dfbb 100644
--- a/src/lib/pixi/controller.ts
+++ b/src/lib/pixi/controller.ts
@@ -29,6 +29,9 @@ export interface SkillTreeController {
initialTreeX: number;
initialTreeY: number;
endScale: number;
+
+ /** Labels pinned on by the user ('L'), overriding the zoom threshold. */
+ labelsForced: boolean;
}
export function createController(
@@ -53,6 +56,7 @@ export function createController(
isTouchZooming: false,
initialTreeX: treeContainer.x,
initialTreeY: treeContainer.y,
- endScale
+ endScale,
+ labelsForced: false
};
}
diff --git a/src/lib/pixi/interaction.ts b/src/lib/pixi/interaction.ts
index d10345c..29ee8f0 100644
--- a/src/lib/pixi/interaction.ts
+++ b/src/lib/pixi/interaction.ts
@@ -4,10 +4,111 @@
// handlers read/write it by reference. attach* functions return a cleanup that
// removes the window-level listeners they added.
import { gsap } from 'gsap';
-import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion } from '$lib/config';
+import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion, LABEL_ZOOM_THRESHOLD } from '$lib/config';
import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle';
+/**
+ * Fade node labels in once the tree is zoomed in far enough to have room for
+ * them, and counter-scale so text stays a constant size on screen rather than
+ * ballooning with the zoom. Called on every zoom change (not per frame).
+ */
+/** Screen-space rectangle, used for label collision tests. */
+interface Box {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+function overlaps(a: Box, b: Box): boolean {
+ return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
+}
+
+/** Bigger nodes win a contested label. */
+const SIZE_PRIORITY: Record = {
+ 'x-large': 0,
+ large: 1,
+ medium: 2,
+ small: 3
+};
+
+export function updateLabels(ctrl: SkillTreeController) {
+ const { treeContainer, nodeObjects } = ctrl;
+ const scale = treeContainer.scale.x;
+ const visible = ctrl.labelsForced || scale >= LABEL_ZOOM_THRESHOLD;
+ const inverse = 1 / scale;
+
+ if (!visible) {
+ for (const obj of nodeObjects) if (obj.label) obj.label.visible = false;
+ return;
+ }
+
+ // Every node's footprint on screen. A label may never cover a node other
+ // than its own.
+ const nodeBoxes = new Map();
+ for (const obj of nodeObjects) {
+ const g = obj.graphics;
+ const w = (g.width || 0) / 2;
+ const h = (g.height || 0) / 2;
+ const cx = treeContainer.x + obj.node.x * scale;
+ const cy = treeContainer.y + obj.node.y * scale;
+ nodeBoxes.set(obj.node.id, {
+ left: cx - w,
+ top: cy - h,
+ right: cx + w,
+ bottom: cy + h
+ });
+ }
+
+ // Draw labels biggest-node-first, and drop any that would collide with a
+ // label already placed or with someone else's node. In dense clusters this
+ // simply shows fewer labels until the user zooms further in.
+ const ordered = [...nodeObjects].sort(
+ (a, b) => (SIZE_PRIORITY[a.node.size] ?? 9) - (SIZE_PRIORITY[b.node.size] ?? 9)
+ );
+
+ const placed: Box[] = [];
+ const PAD = 2;
+
+ for (const obj of ordered) {
+ const label = obj.label;
+ if (!label) continue;
+
+ // Counter-scale so the text stays a constant size on screen instead of
+ // growing with the zoom. Alpha is deliberately NOT set here — it is owned
+ // by the entrance animation, so a label can't appear over a node that has
+ // not faded in yet.
+ label.scale.set(inverse);
+
+ const screenW = label.width * scale;
+ const screenH = label.height * scale;
+ const cx = treeContainer.x + label.x * scale;
+ const top = treeContainer.y + label.y * scale;
+
+ const box: Box = {
+ left: cx - screenW / 2 - PAD,
+ top: top - PAD,
+ right: cx + screenW / 2 + PAD,
+ bottom: top + screenH + PAD
+ };
+
+ let blocked = placed.some((other) => overlaps(box, other));
+ if (!blocked) {
+ for (const [id, nodeBox] of nodeBoxes) {
+ if (id === obj.node.id) continue;
+ if (overlaps(box, nodeBox)) {
+ blocked = true;
+ break;
+ }
+ }
+ }
+
+ label.visible = !blocked;
+ if (!blocked) placed.push(box);
+ }
+}
+
/** Offset the background layers behind the tree to create parallax depth. */
export function updateParallax(ctrl: SkillTreeController) {
const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX;
@@ -161,6 +262,7 @@ export function attachPanZoom(
// Zoom centered on origin node (same as mouse wheel zoom)
treeContainer.scale.set(newScale);
updateParallax(ctrl);
+ updateLabels(ctrl);
}
ctrl.lastTouchDistance = distance;
@@ -210,6 +312,8 @@ export function attachPanZoom(
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
treeContainer.scale.set(newScale);
updateParallax(ctrl);
+ updateLabels(ctrl);
+ updateLabels(ctrl);
},
{ passive: false }
);
@@ -234,13 +338,19 @@ export function panToNode(ctrl: SkillTreeController, x: number, y: number, life:
duration: prefersReducedMotion() ? 0 : 0.4,
ease: 'power2.out',
overwrite: 'auto',
- onUpdate: () => updateParallax(ctrl)
+ onUpdate: () => {
+ updateParallax(ctrl);
+ updateLabels(ctrl);
+ }
});
});
}
interface KeyboardOpts {
onToggleStats: () => void;
+ /** Cmd/Ctrl-K — open the search palette. */
+ onOpenSearch: () => void;
+ isSearchOpen: () => boolean;
onCloseModal: () => void;
isModalVisible: () => boolean;
isStatsVisible: () => boolean;
@@ -266,6 +376,17 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
// modal body) must scroll it, not pan the tree behind it. Previously
// every arrow press called preventDefault() unconditionally, so those
// regions could not be scrolled by keyboard at all.
+ // Cmd/Ctrl-K opens search. Checked before the modifier bail-out below,
+ // since it is itself a modifier combo.
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
+ e.preventDefault();
+ opts.onOpenSearch();
+ return;
+ }
+
+ // While the palette is open it owns the keyboard entirely.
+ if (opts.isSearchOpen()) return;
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
// `instanceof Element`, not a truthiness check: a keydown dispatched on
@@ -336,6 +457,8 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax(ctrl);
+ updateLabels(ctrl);
+ updateLabels(ctrl);
}
// - for zoom out
@@ -345,6 +468,15 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax(ctrl);
+ updateLabels(ctrl);
+ updateLabels(ctrl);
+ }
+
+ // L forces labels on/off regardless of zoom.
+ if (e.key === 'l' || e.key === 'L') {
+ e.preventDefault();
+ ctrl.labelsForced = !ctrl.labelsForced;
+ updateLabels(ctrl);
}
// R to reset view to origin
@@ -358,7 +490,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
duration: 0.5,
ease: 'power2.out',
overwrite: 'auto',
- onUpdate: () => updateParallax(ctrl),
+ onUpdate: () => {
+ updateParallax(ctrl);
+ updateLabels(ctrl);
+ },
onComplete: () => {
// Update parallax baseline after reset
ctrl.initialTreeX = treeContainer.x;
@@ -371,7 +506,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
duration: 0.5,
ease: 'power2.out',
overwrite: 'auto',
- onUpdate: () => updateParallax(ctrl)
+ onUpdate: () => {
+ updateParallax(ctrl);
+ updateLabels(ctrl);
+ }
});
});
}
diff --git a/src/lib/pixi/nodes.ts b/src/lib/pixi/nodes.ts
index 9544936..9aec53f 100644
--- a/src/lib/pixi/nodes.ts
+++ b/src/lib/pixi/nodes.ts
@@ -1,10 +1,10 @@
// Tree node rendering: the origin profile node and regular icon nodes.
// Nodes start non-interactive (eventMode 'none') until the entrance animation
// enables them; see animation.ts.
-import { Graphics, Container, Assets, Sprite, type Texture } from 'pixi.js';
+import { Graphics, Container, Assets, Sprite, Text, TextStyle, type Texture } from 'pixi.js';
import { gsap } from 'gsap';
import { treeNodes, type TreeNode } from '$lib/data';
-import { categoryColors } from '$lib/theme';
+import { categoryColors, categoryColorHex } from '$lib/theme';
import { getNodeRadius } from '$lib/utils/tree';
import type { NodeObject } from './types';
import type { SkillTreeController } from './controller';
@@ -56,6 +56,40 @@ function attachNodeInteraction(
});
}
+/**
+ * One shared TextStyle per category. Pixi rasterises Text to a texture, so
+ * reusing styles keeps that cheap; labels also start invisible and are only
+ * revealed above the zoom threshold.
+ */
+function makeLabel(node: TreeNode, radius: number, isMobile: boolean): Text {
+ const style = new TextStyle({
+ fontFamily: 'system-ui, sans-serif',
+ fontSize: isMobile ? 11 : 12,
+ fontWeight: '600',
+ fill: categoryColorHex[node.category],
+ align: 'center',
+ stroke: { color: '#05050a', width: 3, join: 'round' }
+ });
+
+ // Long labels are the main source of collisions ("Data Structures &
+ // Algorithms" is nearly 3x a node's diameter). The full text is always
+ // available in the tooltip and modal.
+ const MAX_LABEL_CHARS = 18;
+ const text =
+ node.label.length > MAX_LABEL_CHARS
+ ? `${node.label.slice(0, MAX_LABEL_CHARS - 1)}…`
+ : node.label;
+
+ const label = new Text({ text, style });
+ label.anchor.set(0.5, 0);
+ label.x = node.x;
+ label.y = node.y + radius + 6;
+ label.alpha = 0;
+ label.eventMode = 'none';
+ label.resolution = Math.min(window.devicePixelRatio || 1, 2);
+ return label;
+}
+
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise {
const { isMobile } = opts;
const nodeObjects: NodeObject[] = [];
@@ -116,7 +150,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
treeContainer.addChild(profileContainer);
- nodeObjects.push({ graphics: profileContainer, node, glowGraphics });
+ const originLabel = makeLabel(node, radius, isMobile);
+ treeContainer.addChild(originLabel);
+ nodeObjects.push({ graphics: profileContainer, node, glowGraphics, label: originLabel });
} else {
const nodeContainer = new Container();
nodeContainer.x = node.x;
@@ -153,7 +189,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
treeContainer.addChild(nodeContainer);
- nodeObjects.push({ graphics: nodeContainer, node, glowGraphics });
+ const nodeLabel = makeLabel(node, radius, isMobile);
+ treeContainer.addChild(nodeLabel);
+ nodeObjects.push({ graphics: nodeContainer, node, glowGraphics, label: nodeLabel });
}
}
diff --git a/src/lib/pixi/types.ts b/src/lib/pixi/types.ts
index e7ab746..892b9b8 100644
--- a/src/lib/pixi/types.ts
+++ b/src/lib/pixi/types.ts
@@ -1,5 +1,5 @@
// Shared structural types for the PixiJS skill-tree rendering modules.
-import type { Graphics, Container } from 'pixi.js';
+import type { Graphics, Container, Text } from 'pixi.js';
import type { TreeNode } from '$lib/data';
/** A parent→child connection: glow + core line + animated plasma beam. */
@@ -17,6 +17,8 @@ export interface NodeObject {
graphics: Graphics | Container;
node: TreeNode;
glowGraphics: Graphics;
+ /** On-canvas label, shown above the zoom threshold. */
+ label?: Text;
}
/** The parallax background layers plus the vignette overlay. */
diff --git a/src/lib/utils/search.test.ts b/src/lib/utils/search.test.ts
new file mode 100644
index 0000000..1858efb
--- /dev/null
+++ b/src/lib/utils/search.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest';
+import { searchNodes } from './search';
+
+describe('searchNodes', () => {
+ it('returns nothing for an empty query', () => {
+ expect(searchNodes('')).toEqual([]);
+ expect(searchNodes(' ')).toEqual([]);
+ });
+
+ it('ranks an exact label match first', () => {
+ expect(searchNodes('React')[0].node.label).toBe('React');
+ expect(searchNodes('Docker')[0].node.label).toBe('Docker');
+ });
+
+ it('is case-insensitive', () => {
+ expect(searchNodes('react')[0].node.id).toBe(searchNodes('REACT')[0].node.id);
+ });
+
+ it('matches a subsequence, not just a substring', () => {
+ const ids = searchNodes('wbrtc').map((r) => r.node.id);
+ expect(ids).toContain('be-webrtc');
+ });
+
+ it('prefers the shorter label when both match a prefix', () => {
+ expect(searchNodes('git')[0].node.label).toBe('Git');
+ });
+
+ it('finds projects by their tech stack', () => {
+ const results = searchNodes('Prisma');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results.every((r) => r.node.category === 'projects')).toBe(true);
+ expect(results[0].context).toMatch(/^tech: /);
+ });
+
+ it('finds experience by company name', () => {
+ const results = searchNodes('Peraton');
+ expect(results[0].node.category).toBe('experience');
+ expect(results[0].context).toBe('Peraton');
+ });
+
+ it('returns nothing for a query that matches nothing', () => {
+ expect(searchNodes('zzzzqqqxyw')).toEqual([]);
+ });
+
+ it('never returns duplicate nodes', () => {
+ const ids = searchNodes('e').map((r) => r.node.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
diff --git a/src/lib/utils/search.ts b/src/lib/utils/search.ts
new file mode 100644
index 0000000..4a17fe1
--- /dev/null
+++ b/src/lib/utils/search.ts
@@ -0,0 +1,93 @@
+// ==========================================================================
+// SEARCH — subsequence matching over the tree data. Pure, no DOM, no PixiJS.
+// ==========================================================================
+
+import { treeNodes, type TreeNode } from '$lib/data';
+
+export interface SearchResult {
+ node: TreeNode;
+ score: number;
+ /** Why it matched, for display ("React" vs "tech: React"). */
+ context?: string;
+}
+
+/**
+ * Case-insensitive subsequence match: "wbrtc" matches "WebRTC".
+ *
+ * Returns a score where lower is better, or null for no match. Consecutive
+ * runs and an early first match are rewarded, so "react" ranks the React node
+ * above a project that merely lists React in its stack.
+ */
+function subsequenceScore(haystack: string, needle: string): number | null {
+ if (!needle) return 0;
+ const h = haystack.toLowerCase();
+ const n = needle.toLowerCase();
+
+ // Exact and prefix matches short-circuit to the best scores.
+ if (h === n) return -1000;
+ if (h.startsWith(n)) return -500 + h.length;
+
+ let score = 0;
+ let hi = 0;
+ let lastHit = -1;
+ for (let ni = 0; ni < n.length; ni++) {
+ const found = h.indexOf(n[ni], hi);
+ if (found === -1) return null;
+ // Penalise gaps; reward adjacency.
+ score += found === lastHit + 1 ? 0 : found - hi + 1;
+ lastHit = found;
+ hi = found + 1;
+ }
+ // Prefer shorter haystacks — "Git" should beat "GitHub Actions" for "git".
+ return score + h.length * 0.1;
+}
+
+/** Search every node by label, then category, then description/tech stack. */
+export function searchNodes(query: string, nodes: TreeNode[] = treeNodes): SearchResult[] {
+ const q = query.trim();
+ if (!q) return [];
+
+ const results: SearchResult[] = [];
+
+ for (const node of nodes) {
+ const labelScore = subsequenceScore(node.label, q);
+ if (labelScore !== null) {
+ results.push({ node, score: labelScore });
+ continue;
+ }
+
+ // Tech stack is a strong signal — searching "prisma" should surface the
+ // projects that use it even though no node is named Prisma.
+ if (node.category === 'projects' && node.techStack) {
+ const tech = node.techStack.find((t) => subsequenceScore(t, q) !== null);
+ if (tech) {
+ results.push({ node, score: 200, context: `tech: ${tech}` });
+ continue;
+ }
+ }
+
+ if (node.category === 'experience' && node.company) {
+ const companyScore = subsequenceScore(node.company, q);
+ if (companyScore !== null) {
+ results.push({ node, score: companyScore + 100, context: node.company });
+ continue;
+ }
+ }
+
+ // Descriptions use plain substring matching, not subsequence. Over long
+ // prose a subsequence match is almost always spurious — "Prisma" found a
+ // p...r...i...s...m...a spread across an unrelated sentence.
+ const haystack = node.description.toLowerCase();
+ const needle = q.toLowerCase();
+ const at = haystack.indexOf(needle);
+ if (at !== -1) {
+ results.push({
+ node,
+ score: 400 + at,
+ context: node.description.slice(Math.max(0, at - 20), at + 45).trim()
+ });
+ }
+ }
+
+ return results.sort((a, b) => a.score - b.score);
+}