secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// Brain command palette runtime (Lane C, Cmd/Ctrl-P).
|
|
2
|
+
//
|
|
3
|
+
// This file is a TEMPLATE. It is installed at
|
|
4
|
+
// `<vault>/.quartz/quartz/components/scripts/commandPalette.inline.ts`
|
|
5
|
+
// by `brain vault render --overlay`. It does NOT compile or run from
|
|
6
|
+
// the brain repo itself — esbuild-loader inside the cloned Quartz
|
|
7
|
+
// workspace bundles it into a string that `CommandPalette.tsx`
|
|
8
|
+
// exports as `afterDOMLoaded`. The string is then injected into
|
|
9
|
+
// every page at `</body>` time by Quartz's `renderPage.tsx`.
|
|
10
|
+
//
|
|
11
|
+
// What this script does, and why: nothing in stock Quartz exposes a
|
|
12
|
+
// quick-open palette. We render the palette markup once globally
|
|
13
|
+
// (registered in `quartz.layout.ts` `afterBody` slot via the
|
|
14
|
+
// `CommandPalette` component), then this script owns the open/close
|
|
15
|
+
// lifecycle, source chips, fuzzy search against `contentIndex.json`,
|
|
16
|
+
// keyboard navigation, and SPA-nav cleanup.
|
|
17
|
+
//
|
|
18
|
+
// brain (Lane C audit fix 2026-05-02): refactored to native
|
|
19
|
+
// `<dialog>` element. We call `dialog.showModal()` to open and
|
|
20
|
+
// `dialog.close()` to dismiss. Native dialog gives us:
|
|
21
|
+
// * Focus trap inside the modal — Tab/Shift-Tab cycle within
|
|
22
|
+
// focusable descendants, no manual ring management needed.
|
|
23
|
+
// * Native `Escape` close behavior — fires a `cancel` event we
|
|
24
|
+
// intercept with `preventDefault()` so we can run the close
|
|
25
|
+
// animation before the dialog actually closes.
|
|
26
|
+
// * Automatic focus restoration to the previously-focused element
|
|
27
|
+
// on `dialog.close()` (browser-managed, no manual stash).
|
|
28
|
+
// * `::backdrop` pseudo-element for the scrim (styled in
|
|
29
|
+
// `_command_palette.scss`) — replaces the manual `<div class="brain-cmdk-backdrop">`.
|
|
30
|
+
//
|
|
31
|
+
// brain (Lane C audit fix 2026-05-02): SPA-mode aware navigation.
|
|
32
|
+
// `window.location.assign` defeats Quartz's micromorph SPA — every
|
|
33
|
+
// quick-open result selection blew the page cache and incurred a full
|
|
34
|
+
// paint. `spa.inline.ts` exposes `window.spaNavigate(url, isBack?)`
|
|
35
|
+
// at line 146; we route through it when present and only fall back
|
|
36
|
+
// to `window.location.assign` when SPA mode is disabled.
|
|
37
|
+
//
|
|
38
|
+
// brain (Lane C audit fix 2026-05-02): combobox + activedescendant
|
|
39
|
+
// a11y. The input carries `role="combobox"` (set in
|
|
40
|
+
// `CommandPalette.tsx`); we toggle `aria-expanded` on open/close,
|
|
41
|
+
// and stamp `aria-activedescendant` on the input pointing at the
|
|
42
|
+
// currently-selected option's id (`brain-cmdk-result-${idx}`) so
|
|
43
|
+
// screen readers announce each arrow-key result without DOM focus
|
|
44
|
+
// leaving the input.
|
|
45
|
+
//
|
|
46
|
+
// Contracts the script depends on:
|
|
47
|
+
//
|
|
48
|
+
// * `fetchData` — a `Promise<Record<FullSlug, ContentDetails>>`
|
|
49
|
+
// defined inline in `renderPage.tsx`'s `<head>` block. Stock
|
|
50
|
+
// Quartz exposes it for the search component; we piggyback so we
|
|
51
|
+
// don't fetch the index twice.
|
|
52
|
+
// * `window.spaNavigate(url, isBack?)` — set by `spa.inline.ts`
|
|
53
|
+
// when `enableSPA: true` in `quartz.config.ts`. Falls back to a
|
|
54
|
+
// full-page navigation when absent.
|
|
55
|
+
// * `<dialog id="brain-cmdk-root">` — the dialog element rendered
|
|
56
|
+
// by `CommandPalette.tsx`. Closed by default.
|
|
57
|
+
// * `<input class="brain-cmdk-input" role="combobox">` — the
|
|
58
|
+
// search input.
|
|
59
|
+
// * `<ul class="brain-cmdk-results" role="listbox">` — populated
|
|
60
|
+
// with `<li class="brain-cmdk-result" role="option">` rows.
|
|
61
|
+
// * `<div class="brain-cmdk-empty">` — shown when the result set
|
|
62
|
+
// is empty for a non-empty query.
|
|
63
|
+
//
|
|
64
|
+
// Keyboard contract:
|
|
65
|
+
// * `Cmd+P` (macOS) / `Ctrl+P` (Win/Linux) — open the palette
|
|
66
|
+
// (always; works from any page).
|
|
67
|
+
// * `Esc` — close the palette (handled natively via the `cancel`
|
|
68
|
+
// event so the close animation still runs).
|
|
69
|
+
// * `↑` / `↓` — move selection up/down through the result list.
|
|
70
|
+
// * `Enter` — navigate to the selected result via `spaNavigate`
|
|
71
|
+
// when available, else full reload.
|
|
72
|
+
// * Click on a result — same navigation.
|
|
73
|
+
// * Click on the dialog element itself (not its modal child) —
|
|
74
|
+
// close. Native `<dialog>` reports backdrop clicks as
|
|
75
|
+
// `event.target === dialog`.
|
|
76
|
+
//
|
|
77
|
+
// Browser support: `<dialog>` element has been baseline since 2022
|
|
78
|
+
// (Chrome 37, Firefox 98, Safari 15.4). All other features used
|
|
79
|
+
// here (`navigator.clipboard`-style globals, optional chaining, the
|
|
80
|
+
// `nav` event from spa.inline.ts) match stock Quartz's minimum.
|
|
81
|
+
|
|
82
|
+
// brain: tell TypeScript about the global Quartz wires
|
|
83
|
+
// `renderPage.tsx` and `spa.inline.ts` install. We don't import them
|
|
84
|
+
// because that creates a real ES module dependency; the inline
|
|
85
|
+
// script runs as `<script type="module">` and these are runtime
|
|
86
|
+
// globals, not module exports.
|
|
87
|
+
import { SOURCE_CHIP_ORDER, inferSource, sourceIconFor } from "../../util/sourceIcons"
|
|
88
|
+
declare global {
|
|
89
|
+
// brain: defined inline by Quartz's renderPage.tsx as
|
|
90
|
+
// `const fetchData = fetch("./static/contentIndex.json").then(d => d.json())`.
|
|
91
|
+
// The exact shape (object keyed by slug) matches stock + brain
|
|
92
|
+
// contentIndex emitter output. We treat it as a plain `Record`
|
|
93
|
+
// because we only read `title` / `tags`.
|
|
94
|
+
// eslint-disable-next-line no-var
|
|
95
|
+
var fetchData: Promise<Record<string, ContentEntry>> | undefined
|
|
96
|
+
|
|
97
|
+
interface Window {
|
|
98
|
+
addCleanup?: (fn: () => void) => void
|
|
99
|
+
// brain (Lane C audit fix): set by `spa.inline.ts` line 146 when
|
|
100
|
+
// `enableSPA: true`. Signature mirrors the upstream definition
|
|
101
|
+
// (see `spa.inline.ts` `async function navigate(url, isBack)`).
|
|
102
|
+
spaNavigate?: (url: URL, isBack?: boolean) => Promise<void>
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// brain: minimal subset of `ContentDetails` we read. Keeping the type
|
|
107
|
+
// narrow makes the script independent of upstream content-index
|
|
108
|
+
// changes — if a future Quartz adds new fields, we don't care.
|
|
109
|
+
interface ContentEntry {
|
|
110
|
+
title?: string
|
|
111
|
+
tags?: string[]
|
|
112
|
+
content?: string
|
|
113
|
+
// brain: the brain content-index emitter adds these via
|
|
114
|
+
// `BrainContentDetails` (in `quartz/plugins/emitters/contentIndex.ts`).
|
|
115
|
+
// Used to render the kind icon next to each result.
|
|
116
|
+
tier?: string
|
|
117
|
+
source?: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface Result {
|
|
121
|
+
slug: string
|
|
122
|
+
title: string
|
|
123
|
+
tags: string[]
|
|
124
|
+
source: string
|
|
125
|
+
score: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ROOT_SELECTOR = "#brain-cmdk-root"
|
|
129
|
+
const INPUT_SELECTOR = ".brain-cmdk-input"
|
|
130
|
+
const RESULTS_SELECTOR = ".brain-cmdk-results"
|
|
131
|
+
const EMPTY_SELECTOR = ".brain-cmdk-empty"
|
|
132
|
+
const CHIPS_SELECTOR = ".brain-cmdk-chips"
|
|
133
|
+
|
|
134
|
+
// brain: prefix for each result `<li>`'s id. The `aria-activedescendant`
|
|
135
|
+
// on the input points at one of these per-keystroke. Single source
|
|
136
|
+
// of truth so renderResults + setSelected can't drift apart.
|
|
137
|
+
const RESULT_ID_PREFIX = "brain-cmdk-result-"
|
|
138
|
+
|
|
139
|
+
const MAX_RESULTS = 12
|
|
140
|
+
const ACTIVE_SOURCES_KEY = "brain.commandPalette.activeSources"
|
|
141
|
+
const DEFAULT_ACTIVE_SOURCES = new Set<string>(SOURCE_CHIP_ORDER)
|
|
142
|
+
let activeSources: Set<string> = loadActiveSources()
|
|
143
|
+
|
|
144
|
+
// brain: matches `--motion-mid` in `_tokens.scss`. Used to delay the
|
|
145
|
+
// actual `dialog.close()` after we strip `.is-open`, so the exit
|
|
146
|
+
// animation can run before the browser tears down the modal. If
|
|
147
|
+
// `--motion-mid` ever changes in `_tokens.scss`, update this in lock
|
|
148
|
+
// step (the value is documented next to the call site below).
|
|
149
|
+
const CLOSE_ANIMATION_MS = 200
|
|
150
|
+
|
|
151
|
+
// brain: cleanup registry — same convention as Quartz's other inline
|
|
152
|
+
// scripts. `window.addCleanup` is wired by `spa.inline.ts` and
|
|
153
|
+
// drains every cleanup before each SPA navigation, preventing
|
|
154
|
+
// listener leaks on long sessions.
|
|
155
|
+
function registerCleanup(fn: () => void): void {
|
|
156
|
+
if (typeof window.addCleanup === "function") {
|
|
157
|
+
window.addCleanup(fn)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function loadActiveSources(): Set<string> {
|
|
162
|
+
if (typeof localStorage === "undefined") return new Set(DEFAULT_ACTIVE_SOURCES)
|
|
163
|
+
try {
|
|
164
|
+
const raw = localStorage.getItem(ACTIVE_SOURCES_KEY)
|
|
165
|
+
if (raw === null) return new Set(DEFAULT_ACTIVE_SOURCES)
|
|
166
|
+
const parsed = JSON.parse(raw)
|
|
167
|
+
if (!Array.isArray(parsed)) return new Set(DEFAULT_ACTIVE_SOURCES)
|
|
168
|
+
const filtered = parsed.filter((value): value is string => typeof value === "string")
|
|
169
|
+
return new Set<string>(filtered)
|
|
170
|
+
} catch {
|
|
171
|
+
return new Set(DEFAULT_ACTIVE_SOURCES)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function persistActiveSources(): void {
|
|
176
|
+
if (typeof localStorage === "undefined") return
|
|
177
|
+
try {
|
|
178
|
+
localStorage.setItem(ACTIVE_SOURCES_KEY, JSON.stringify([...activeSources]))
|
|
179
|
+
} catch {
|
|
180
|
+
// localStorage can throw in private browsing; filtering should still work in memory.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function passesSourceFilter(source: string): boolean {
|
|
185
|
+
return activeSources.size === 0 || activeSources.has(source)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Fuzzy match — small, deterministic, no dependency.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
//
|
|
192
|
+
// We match the query against the title (heaviest weight), then tags
|
|
193
|
+
// (medium weight), then a slug fallback (lightest weight). The score
|
|
194
|
+
// is a simple substring + position heuristic — fast enough for the
|
|
195
|
+
// ~1k-doc personal corpus and easy to reason about.
|
|
196
|
+
//
|
|
197
|
+
// brain: NOT a full Levenshtein/BM25 — for personal-corpus scale the
|
|
198
|
+
// substring approach is plenty and avoids pulling in flexsearch (which
|
|
199
|
+
// stock Quartz already loads for the search component, but this
|
|
200
|
+
// script runs in `afterBody` and importing it cleanly here would
|
|
201
|
+
// double-load the bundle).
|
|
202
|
+
|
|
203
|
+
function scoreMatch(query: string, text: string, weight: number): number {
|
|
204
|
+
if (text.length === 0) return 0
|
|
205
|
+
const idx = text.toLowerCase().indexOf(query)
|
|
206
|
+
if (idx === -1) return 0
|
|
207
|
+
// brain: position bonus — earlier matches score higher (matching
|
|
208
|
+
// the start of the title beats matching mid-string).
|
|
209
|
+
const positionBonus = Math.max(0, 10 - idx) * 0.1
|
|
210
|
+
// brain: length bonus — closer match-length to text-length means
|
|
211
|
+
// the match consumed more of the string (1.0 for exact match).
|
|
212
|
+
const lengthBonus = query.length / text.length
|
|
213
|
+
return weight + positionBonus + lengthBonus
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function fuzzy(
|
|
217
|
+
query: string,
|
|
218
|
+
data: Record<string, ContentEntry>,
|
|
219
|
+
): Result[] {
|
|
220
|
+
const q = query.trim().toLowerCase()
|
|
221
|
+
if (q.length === 0) return []
|
|
222
|
+
|
|
223
|
+
const out: Result[] = []
|
|
224
|
+
for (const slug in data) {
|
|
225
|
+
const entry = data[slug]
|
|
226
|
+
const title = entry.title ?? slug
|
|
227
|
+
const tags = entry.tags ?? []
|
|
228
|
+
const source = inferSource(slug, entry.source)
|
|
229
|
+
if (!passesSourceFilter(source)) continue
|
|
230
|
+
|
|
231
|
+
let score = 0
|
|
232
|
+
score += scoreMatch(q, title, 3.0)
|
|
233
|
+
for (const tag of tags) {
|
|
234
|
+
score += scoreMatch(q, tag, 1.5) * 0.5 // dilute multi-tag matches
|
|
235
|
+
}
|
|
236
|
+
score += scoreMatch(q, slug, 0.8)
|
|
237
|
+
|
|
238
|
+
if (score > 0) {
|
|
239
|
+
out.push({ slug, title, tags, source, score })
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Highest score first; tie-break on title for deterministic order.
|
|
244
|
+
out.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
|
|
245
|
+
return out.slice(0, MAX_RESULTS)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Render results into the listbox
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function renderResults(
|
|
253
|
+
list: HTMLUListElement,
|
|
254
|
+
empty: HTMLElement,
|
|
255
|
+
input: HTMLInputElement,
|
|
256
|
+
results: Result[],
|
|
257
|
+
): void {
|
|
258
|
+
// brain: clear by replacing innerHTML — micromorph isn't relevant
|
|
259
|
+
// here because the list owns its own subtree exclusively.
|
|
260
|
+
list.innerHTML = ""
|
|
261
|
+
|
|
262
|
+
// brain: any prior `aria-activedescendant` is stale — clear it
|
|
263
|
+
// before the new render. setSelected() below will re-stamp on
|
|
264
|
+
// the first item if there are results.
|
|
265
|
+
input.removeAttribute("aria-activedescendant")
|
|
266
|
+
|
|
267
|
+
if (results.length === 0) {
|
|
268
|
+
empty.removeAttribute("hidden")
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
empty.setAttribute("hidden", "")
|
|
272
|
+
|
|
273
|
+
results.forEach((result, idx) => {
|
|
274
|
+
const li = document.createElement("li")
|
|
275
|
+
li.className = "brain-cmdk-result"
|
|
276
|
+
li.setAttribute("role", "option")
|
|
277
|
+
li.setAttribute("data-slug", result.slug)
|
|
278
|
+
li.setAttribute("data-index", String(idx))
|
|
279
|
+
// brain (Lane C audit fix): stable id for `aria-activedescendant`.
|
|
280
|
+
// The id MUST be unique on the page; the `RESULT_ID_PREFIX` plus
|
|
281
|
+
// the index gives us that without random-id generation.
|
|
282
|
+
li.id = RESULT_ID_PREFIX + idx
|
|
283
|
+
li.setAttribute("aria-selected", idx === 0 ? "true" : "false")
|
|
284
|
+
|
|
285
|
+
const icon = document.createElement("span")
|
|
286
|
+
icon.className = "brain-cmdk-icon"
|
|
287
|
+
icon.textContent = sourceIconFor(result.source)
|
|
288
|
+
li.appendChild(icon)
|
|
289
|
+
|
|
290
|
+
const body = document.createElement("div")
|
|
291
|
+
body.className = "brain-cmdk-body"
|
|
292
|
+
|
|
293
|
+
const title = document.createElement("div")
|
|
294
|
+
title.className = "brain-cmdk-title"
|
|
295
|
+
title.textContent = result.title
|
|
296
|
+
body.appendChild(title)
|
|
297
|
+
|
|
298
|
+
const slug = document.createElement("div")
|
|
299
|
+
slug.className = "brain-cmdk-slug"
|
|
300
|
+
slug.textContent = `${result.source} / ${result.slug}`
|
|
301
|
+
body.appendChild(slug)
|
|
302
|
+
|
|
303
|
+
li.appendChild(body)
|
|
304
|
+
list.appendChild(li)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// brain (Lane C audit fix): point the input's
|
|
308
|
+
// `aria-activedescendant` at the freshly-rendered first result.
|
|
309
|
+
// Without this, screen readers wouldn't announce the default
|
|
310
|
+
// selection (the visible `aria-selected` indicator alone isn't
|
|
311
|
+
// enough — combobox semantics require the link from the input).
|
|
312
|
+
input.setAttribute("aria-activedescendant", RESULT_ID_PREFIX + "0")
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Selection state
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
function currentSelectedIndex(list: HTMLUListElement): number {
|
|
320
|
+
const items = list.querySelectorAll<HTMLLIElement>(".brain-cmdk-result")
|
|
321
|
+
for (let i = 0; i < items.length; i++) {
|
|
322
|
+
if (items[i].getAttribute("aria-selected") === "true") return i
|
|
323
|
+
}
|
|
324
|
+
return -1
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function setSelected(
|
|
328
|
+
list: HTMLUListElement,
|
|
329
|
+
input: HTMLInputElement,
|
|
330
|
+
target: number,
|
|
331
|
+
): void {
|
|
332
|
+
const items = list.querySelectorAll<HTMLLIElement>(".brain-cmdk-result")
|
|
333
|
+
if (items.length === 0) {
|
|
334
|
+
input.removeAttribute("aria-activedescendant")
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
// brain: clamp to range and wrap — wrapping makes ↑ from the top
|
|
338
|
+
// jump to bottom and vice versa, which matches quick-open conventions
|
|
339
|
+
// (Reflect, Linear, Spotlight all wrap).
|
|
340
|
+
const clamped = ((target % items.length) + items.length) % items.length
|
|
341
|
+
items.forEach((item, i) => {
|
|
342
|
+
item.setAttribute("aria-selected", i === clamped ? "true" : "false")
|
|
343
|
+
if (i === clamped) {
|
|
344
|
+
item.scrollIntoView({ block: "nearest" })
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
// brain (Lane C audit fix): re-point the input's
|
|
348
|
+
// `aria-activedescendant` at the newly-selected option's id so
|
|
349
|
+
// AT announces it on each arrow press.
|
|
350
|
+
input.setAttribute("aria-activedescendant", RESULT_ID_PREFIX + clamped)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Open / close (native dialog)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
function openPalette(dialog: HTMLDialogElement, input: HTMLInputElement): void {
|
|
358
|
+
// brain (Lane D audit fix 2026-05-02): the previous order added
|
|
359
|
+
// `.is-open` BEFORE `showModal()`, intending the class to drive
|
|
360
|
+
// the entrance animation. The Lane C reviewer flagged — and
|
|
361
|
+
// browser testing confirmed — that this order BREAKS the
|
|
362
|
+
// entrance:
|
|
363
|
+
//
|
|
364
|
+
// * Before `showModal()`, the dialog is `display: none` (native
|
|
365
|
+
// `<dialog>` default without the `[open]` attribute). Setting
|
|
366
|
+
// `.is-open` on a `display:none` element doesn't trigger any
|
|
367
|
+
// transition because the element isn't rendered.
|
|
368
|
+
// * `showModal()` flips the dialog to its open state in a
|
|
369
|
+
// single synchronous paint. Because `.is-open` is already
|
|
370
|
+
// set, the modal child's computed style at first paint is
|
|
371
|
+
// `transform: scale(1); opacity: 1` — the FINAL state. There
|
|
372
|
+
// is no `from` keyframe state to interpolate from, so the
|
|
373
|
+
// transition has no animation to run.
|
|
374
|
+
// * Result: the modal pops in instantly with no scale-up.
|
|
375
|
+
//
|
|
376
|
+
// Correct order:
|
|
377
|
+
// 1. `showModal()` first — dialog flips to `display: flex` with
|
|
378
|
+
// `.is-open` ABSENT, so the modal child paints at its
|
|
379
|
+
// resting state (`transform: scale(0.96); opacity: 0` from
|
|
380
|
+
// `_command_palette.scss` line 152-154). This is the from-state of the
|
|
381
|
+
// entrance.
|
|
382
|
+
// 2. `requestAnimationFrame` — wait one frame so the resting-
|
|
383
|
+
// state paint commits to the screen before the next mutation.
|
|
384
|
+
// Without the rAF, modern browsers would batch both DOM
|
|
385
|
+
// changes into a single paint and the transition would still
|
|
386
|
+
// have nothing to interpolate.
|
|
387
|
+
// 3. Add `.is-open` on the next frame — the class flip retargets
|
|
388
|
+
// the modal child's transform/opacity to (scale(1)/opacity(1));
|
|
389
|
+
// the browser sees the property change and runs the
|
|
390
|
+
// transition (250ms scale + opacity per `--motion-mid`).
|
|
391
|
+
// 4. Sync `aria-expanded="true"` + clear input + focus it.
|
|
392
|
+
// ARIA + focus changes don't need to wait for the animation;
|
|
393
|
+
// AT consumers and keyboard users want immediate feedback.
|
|
394
|
+
//
|
|
395
|
+
// The `_command_palette.scss` rules are unchanged — the resting state lives
|
|
396
|
+
// on `.brain-cmdk-modal` directly, the open state lives on
|
|
397
|
+
// `#brain-cmdk-root.is-open .brain-cmdk-modal`. The fix is purely
|
|
398
|
+
// in the script-side mutation order.
|
|
399
|
+
//
|
|
400
|
+
// The dialog's native `cancel` event still fires on Esc and runs
|
|
401
|
+
// the close path (`closePalette`) unchanged — listener wired at
|
|
402
|
+
// attach time in `setupPalette`, independent of this entrance
|
|
403
|
+
// order rework. Close-path mutation order is in `closePalette`
|
|
404
|
+
// below.
|
|
405
|
+
if (typeof dialog.showModal === "function") {
|
|
406
|
+
dialog.showModal()
|
|
407
|
+
} else {
|
|
408
|
+
// brain: defensive fallback for the (vanishingly rare) browser
|
|
409
|
+
// that lacks `<dialog>` support — show the element directly so
|
|
410
|
+
// the script doesn't crash. Focus trap + backdrop won't work,
|
|
411
|
+
// but the palette itself remains usable.
|
|
412
|
+
dialog.setAttribute("open", "")
|
|
413
|
+
}
|
|
414
|
+
// brain (Lane D fix): rAF before adding `.is-open` so the resting-
|
|
415
|
+
// state paint commits before the class flip retargets the
|
|
416
|
+
// animation. See block comment above.
|
|
417
|
+
requestAnimationFrame(() => {
|
|
418
|
+
dialog.classList.add("is-open")
|
|
419
|
+
})
|
|
420
|
+
input.setAttribute("aria-expanded", "true")
|
|
421
|
+
input.value = ""
|
|
422
|
+
input.focus()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function closePalette(dialog: HTMLDialogElement, input: HTMLInputElement): void {
|
|
426
|
+
// brain (Lane C audit fix): mirror sequence to openPalette.
|
|
427
|
+
// 1. Strip `.is-open` to start the exit animation.
|
|
428
|
+
// 2. Wait one rAF + the close-animation duration (200ms; matches
|
|
429
|
+
// `--motion-mid` minus rAF latency) before calling `close()`.
|
|
430
|
+
// Without the delay the dialog vanishes mid-animation.
|
|
431
|
+
// 3. `dialog.close()` natively restores focus to the
|
|
432
|
+
// previously-focused element (browser-managed) — no manual
|
|
433
|
+
// stash/restore needed.
|
|
434
|
+
// 4. Sync `aria-expanded="false"` immediately so AT announces
|
|
435
|
+
// the collapse before the visible animation finishes.
|
|
436
|
+
if (!dialog.classList.contains("is-open")) return
|
|
437
|
+
dialog.classList.remove("is-open")
|
|
438
|
+
input.setAttribute("aria-expanded", "false")
|
|
439
|
+
requestAnimationFrame(() => {
|
|
440
|
+
window.setTimeout(() => {
|
|
441
|
+
if (typeof dialog.close === "function") {
|
|
442
|
+
dialog.close()
|
|
443
|
+
} else {
|
|
444
|
+
dialog.removeAttribute("open")
|
|
445
|
+
}
|
|
446
|
+
}, CLOSE_ANIMATION_MS)
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function navigateToSlug(slug: string): void {
|
|
451
|
+
// brain (Lane C audit fix 2026-05-02): SPA-mode aware navigation.
|
|
452
|
+
// `window.location.assign` triggers a full page reload — defeats
|
|
453
|
+
// Quartz's enableSPA + micromorph and causes a flash on every
|
|
454
|
+
// selection. `spa.inline.ts:146` exposes `window.spaNavigate`
|
|
455
|
+
// when SPA mode is enabled; route through it when present.
|
|
456
|
+
// Falls back to `location.assign` so the palette still works on
|
|
457
|
+
// a hypothetical SPA-disabled build.
|
|
458
|
+
//
|
|
459
|
+
// brain: build a proper `URL` object from the slug + the page
|
|
460
|
+
// origin. spaNavigate expects a real URL (not a string); using
|
|
461
|
+
// the constructor handles slug normalization for free (it strips
|
|
462
|
+
// accidental double-slashes, encodes path segments, etc.).
|
|
463
|
+
const url = new URL("/" + slug, window.location.origin)
|
|
464
|
+
if (typeof window.spaNavigate === "function") {
|
|
465
|
+
void window.spaNavigate(url)
|
|
466
|
+
} else {
|
|
467
|
+
window.location.assign(url)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// Wire-up
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
async function setupPalette(): Promise<void> {
|
|
476
|
+
const dialog = document.querySelector<HTMLDialogElement>(ROOT_SELECTOR)
|
|
477
|
+
if (dialog === null) return // no palette markup on this page (e.g. 404)
|
|
478
|
+
|
|
479
|
+
const input = dialog.querySelector<HTMLInputElement>(INPUT_SELECTOR)
|
|
480
|
+
const list = dialog.querySelector<HTMLUListElement>(RESULTS_SELECTOR)
|
|
481
|
+
const empty = dialog.querySelector<HTMLElement>(EMPTY_SELECTOR)
|
|
482
|
+
const chipsRail = dialog.querySelector<HTMLElement>(CHIPS_SELECTOR)
|
|
483
|
+
if (input === null || list === null || empty === null) return
|
|
484
|
+
|
|
485
|
+
// brain: wait for the global content index. `fetchData` is
|
|
486
|
+
// installed by `renderPage.tsx`'s prescript; if absent for any
|
|
487
|
+
// reason (e.g. an experimental layout disables it), bail
|
|
488
|
+
// gracefully without breaking the page.
|
|
489
|
+
if (typeof fetchData === "undefined") return
|
|
490
|
+
const data = await fetchData
|
|
491
|
+
|
|
492
|
+
function rerenderCurrentQuery(): void {
|
|
493
|
+
const results = fuzzy(input.value, data)
|
|
494
|
+
renderResults(list, empty, input, results)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function refreshChipState(): void {
|
|
498
|
+
if (chipsRail === null) return
|
|
499
|
+
const chips = chipsRail.querySelectorAll<HTMLButtonElement>(".brain-cmdk-chip")
|
|
500
|
+
const values = Array.from(chips)
|
|
501
|
+
.map((chip) => chip.dataset["brainSource"])
|
|
502
|
+
.filter((value): value is string => typeof value === "string" && value !== "__all__")
|
|
503
|
+
const allActive = values.length > 0 && values.every((value) => activeSources.has(value))
|
|
504
|
+
chips.forEach((chip) => {
|
|
505
|
+
const value = chip.dataset["brainSource"]
|
|
506
|
+
if (value === "__all__") {
|
|
507
|
+
chip.dataset["active"] = allActive ? "true" : "false"
|
|
508
|
+
chip.setAttribute("aria-pressed", allActive ? "true" : "false")
|
|
509
|
+
} else if (typeof value === "string") {
|
|
510
|
+
const isActive = activeSources.has(value)
|
|
511
|
+
chip.dataset["active"] = isActive ? "true" : "false"
|
|
512
|
+
chip.setAttribute("aria-pressed", isActive ? "true" : "false")
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function bindChipHandlers(): void {
|
|
518
|
+
if (chipsRail === null) return
|
|
519
|
+
const chips = chipsRail.querySelectorAll<HTMLButtonElement>(".brain-cmdk-chip")
|
|
520
|
+
const allValues = Array.from(chips)
|
|
521
|
+
.map((chip) => chip.dataset["brainSource"])
|
|
522
|
+
.filter((value): value is string => typeof value === "string" && value !== "__all__")
|
|
523
|
+
chips.forEach((chip) => {
|
|
524
|
+
const value = chip.dataset["brainSource"]
|
|
525
|
+
const handler = (): void => {
|
|
526
|
+
if (value === "__all__") {
|
|
527
|
+
activeSources = new Set<string>(allValues)
|
|
528
|
+
} else if (typeof value === "string") {
|
|
529
|
+
if (activeSources.has(value)) {
|
|
530
|
+
activeSources.delete(value)
|
|
531
|
+
} else {
|
|
532
|
+
activeSources.add(value)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
persistActiveSources()
|
|
536
|
+
refreshChipState()
|
|
537
|
+
rerenderCurrentQuery()
|
|
538
|
+
}
|
|
539
|
+
chip.addEventListener("click", handler)
|
|
540
|
+
registerCleanup(() => chip.removeEventListener("click", handler))
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
refreshChipState()
|
|
545
|
+
bindChipHandlers()
|
|
546
|
+
|
|
547
|
+
// ---- Cmd-P toggle (page-level keyboard listener) ------------------
|
|
548
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
549
|
+
const isOpen = dialog.open
|
|
550
|
+
const isCmdP =
|
|
551
|
+
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "p"
|
|
552
|
+
|
|
553
|
+
if (isCmdP) {
|
|
554
|
+
event.preventDefault()
|
|
555
|
+
if (isOpen) {
|
|
556
|
+
closePalette(dialog, input)
|
|
557
|
+
} else {
|
|
558
|
+
openPalette(dialog, input)
|
|
559
|
+
}
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!isOpen) return
|
|
564
|
+
|
|
565
|
+
if (event.key === "Escape") {
|
|
566
|
+
event.preventDefault()
|
|
567
|
+
closePalette(dialog, input)
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (event.key === "ArrowDown") {
|
|
572
|
+
event.preventDefault()
|
|
573
|
+
setSelected(list, input, currentSelectedIndex(list) + 1)
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
if (event.key === "ArrowUp") {
|
|
577
|
+
event.preventDefault()
|
|
578
|
+
setSelected(list, input, currentSelectedIndex(list) - 1)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
if (event.key === "Enter") {
|
|
582
|
+
event.preventDefault()
|
|
583
|
+
const items = list.querySelectorAll<HTMLLIElement>(".brain-cmdk-result")
|
|
584
|
+
const idx = currentSelectedIndex(list)
|
|
585
|
+
const item = idx >= 0 ? items[idx] : null
|
|
586
|
+
if (item !== null && item !== undefined) {
|
|
587
|
+
const slug = item.getAttribute("data-slug")
|
|
588
|
+
if (slug !== null) {
|
|
589
|
+
closePalette(dialog, input)
|
|
590
|
+
navigateToSlug(slug)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ---- Native `cancel` event (Esc + browser-driven close) ----------
|
|
598
|
+
// brain (Lane C audit fix): native dialog fires `cancel` on Esc.
|
|
599
|
+
// We `preventDefault()` so the browser doesn't immediately close
|
|
600
|
+
// the dialog (which would skip our exit animation), then run the
|
|
601
|
+
// brain close path which animates and then calls `close()` itself.
|
|
602
|
+
const onCancel = (event: Event): void => {
|
|
603
|
+
event.preventDefault()
|
|
604
|
+
closePalette(dialog, input)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---- Input → search ---------------------------------------------
|
|
608
|
+
const onInput = (): void => {
|
|
609
|
+
rerenderCurrentQuery()
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---- Click on a result ------------------------------------------
|
|
613
|
+
const onListClick = (event: MouseEvent): void => {
|
|
614
|
+
const target = event.target as HTMLElement | null
|
|
615
|
+
if (target === null) return
|
|
616
|
+
const item = target.closest<HTMLLIElement>(".brain-cmdk-result")
|
|
617
|
+
if (item === null) return
|
|
618
|
+
const slug = item.getAttribute("data-slug")
|
|
619
|
+
if (slug === null) return
|
|
620
|
+
closePalette(dialog, input)
|
|
621
|
+
navigateToSlug(slug)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---- Backdrop dismiss -------------------------------------------
|
|
625
|
+
// brain (Lane C audit fix): native dialogs report backdrop clicks
|
|
626
|
+
// as `event.target === dialog` (clicks on dialog descendants
|
|
627
|
+
// bubble with `target` set to the actual descendant). This is the
|
|
628
|
+
// canonical way to dismiss-on-backdrop with `<dialog>`.
|
|
629
|
+
const onDialogClick = (event: MouseEvent): void => {
|
|
630
|
+
if (event.target === dialog) {
|
|
631
|
+
closePalette(dialog, input)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
document.addEventListener("keydown", onKeyDown)
|
|
636
|
+
dialog.addEventListener("cancel", onCancel)
|
|
637
|
+
input.addEventListener("input", onInput)
|
|
638
|
+
list.addEventListener("click", onListClick)
|
|
639
|
+
dialog.addEventListener("click", onDialogClick)
|
|
640
|
+
|
|
641
|
+
registerCleanup(() => {
|
|
642
|
+
document.removeEventListener("keydown", onKeyDown)
|
|
643
|
+
dialog.removeEventListener("cancel", onCancel)
|
|
644
|
+
input.removeEventListener("input", onInput)
|
|
645
|
+
list.removeEventListener("click", onListClick)
|
|
646
|
+
dialog.removeEventListener("click", onDialogClick)
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// brain: same registration pattern as `linkSourceTag.js` — run on
|
|
651
|
+
// first load + on every SPA `nav` event so the listeners get
|
|
652
|
+
// re-attached after micromorph swaps the DOM. Without the nav
|
|
653
|
+
// re-registration the palette's input would stop responding after
|
|
654
|
+
// the first internal-link click.
|
|
655
|
+
if (document.readyState === "loading") {
|
|
656
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
657
|
+
void setupPalette()
|
|
658
|
+
})
|
|
659
|
+
} else {
|
|
660
|
+
void setupPalette()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
document.addEventListener("nav", () => {
|
|
664
|
+
void setupPalette()
|
|
665
|
+
})
|