@dmthepm/commune 0.1.1 → 0.3.0

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.
package/lib/lib/graph.js CHANGED
@@ -8,9 +8,9 @@
8
8
  * rules, and three subtly different opinions about trailing slashes.
9
9
  *
10
10
  * The rules this module owns:
11
- * - which collections participate (notes, research, pages)
12
- * - visibility (notes opt in with `visibility: public`; research and pages
13
- * are always public)
11
+ * - which collections participate (notes, research, pages, updates)
12
+ * - visibility (notes opt in with `visibility: public`; research, pages and
13
+ * updates are always public)
14
14
  * - canonical URLs, always with a trailing slash, matching Astro's
15
15
  * directory build format
16
16
  * - the title/alias lookup used to resolve `[[WikiLinks]]`
@@ -21,14 +21,23 @@ import { slug as githubSlug } from 'github-slugger';
21
21
  import matter from 'gray-matter';
22
22
  import { readFile } from 'node:fs/promises';
23
23
  import path from 'node:path';
24
+ import { readContentHistory, readMtimeDate, } from "./dates.js";
25
+ export { SHALLOW_WARNING, toIsoDay } from "./dates.js";
24
26
  /** Where each collection's markdown lives, relative to the project root. */
25
27
  export const CONTENT_DIRS = {
26
28
  notes: 'src/content/notes',
27
29
  research: 'src/content/research',
28
30
  pages: 'src/content/pages',
31
+ updates: 'src/content/updates',
29
32
  };
30
- /** Collection scan order. Stable so derived artifacts are deterministic. */
31
- export const COLLECTIONS = ['notes', 'research', 'pages'];
33
+ /**
34
+ * Collection scan order. Stable so derived artifacts are deterministic.
35
+ *
36
+ * `updates` is last because it was added last, and the order is the order
37
+ * `backlinks.json` is keyed in: putting it anywhere else would rewrite the
38
+ * whole committed artifact to say the same thing.
39
+ */
40
+ export const COLLECTIONS = ['notes', 'research', 'pages', 'updates'];
32
41
  /**
33
42
  * Convert a content file path to its collection-relative slug and canonical URL.
34
43
  *
@@ -117,7 +126,64 @@ export function normalizeDate(value) {
117
126
  return value.split('T')[0];
118
127
  return undefined;
119
128
  }
120
- /** Is this entry publicly visible? Notes opt in; research and pages are always public. */
129
+ /**
130
+ * The date an author wrote down, if they wrote one down.
131
+ *
132
+ * `updated` first, then `date` — the key the changelog-shaped collections use,
133
+ * where the entry *is* a dated thing rather than a page that happens to have
134
+ * been edited. Kept as one function so the graph, `--recent` and the site-wide
135
+ * last-updated value can never disagree about what an author claimed.
136
+ */
137
+ export function claimedDate(data) {
138
+ return normalizeDate(data.updated) ?? normalizeDate(data.date);
139
+ }
140
+ /**
141
+ * Decide an entry's dates, and say where the answer came from.
142
+ *
143
+ * Precedence, for both `created` and `updated`: what the author wrote, then
144
+ * what the repository records, then — only outside a repository — the file's
145
+ * mtime. The first is a claim and can be years stale; the second is a fact
146
+ * about the file and is what the ticket asked for; the third exists so a
147
+ * developer's unversioned folder produces dates instead of blanks.
148
+ *
149
+ * What is deliberately *not* here is a fourth fallback. Inside a shallow
150
+ * checkout there is no honest date to be had — every file's only commit is the
151
+ * one the CI runner fetched, and every file's mtime is the moment it was
152
+ * written to disk — so both are refused and `updatedSource` says `none`. A
153
+ * missing date a site can decline to render; a wrong one it renders
154
+ * confidently. The same goes for a file that has never been committed.
155
+ *
156
+ * `updatedSource` is reported and `createdSource` is not, deliberately: the
157
+ * date a site displays and a reader judges is `updated`, and one honest label
158
+ * beside it is worth more than two labels nobody reads. `created` follows the
159
+ * same precedence, and `modifiedInGit` is carried whenever git knew the file,
160
+ * so anything that needs to audit the pair has both halves.
161
+ */
162
+ async function resolveDates(root, file, data, history) {
163
+ const claimed = claimedDate(data);
164
+ const createdClaim = normalizeDate(data.created);
165
+ const committed = history.files.get(file);
166
+ // Read once, and only where an mtime is an answer at all: a versioned tree
167
+ // never stats a file, and a complete vault outside one never stats twice.
168
+ let mtime;
169
+ const fileMtime = async () => history.kind === 'unversioned' ? (mtime ??= await readMtimeDate(root, file)) : undefined;
170
+ const updated = claimed ?? committed?.updated ?? (await fileMtime());
171
+ const created = createdClaim ?? committed?.created ?? (await fileMtime());
172
+ const updatedSource = claimed
173
+ ? 'frontmatter'
174
+ : committed
175
+ ? 'git'
176
+ : updated
177
+ ? 'mtime'
178
+ : 'none';
179
+ return {
180
+ ...(updated ? { updated } : {}),
181
+ updatedSource,
182
+ ...(created ? { created } : {}),
183
+ ...(committed ? { modifiedInGit: committed.updated } : {}),
184
+ };
185
+ }
186
+ /** Is this entry publicly visible? Notes opt in; every other collection is public. */
121
187
  function isPublic(collection, data) {
122
188
  if (collection !== 'notes')
123
189
  return true;
@@ -136,6 +202,9 @@ function isPublic(collection, data) {
136
202
  export async function loadContentEntries(options = {}) {
137
203
  const root = options.root ?? process.cwd();
138
204
  const entries = [];
205
+ // One walk for the whole vault, before the scan rather than inside it: the
206
+ // alternative is a `git log` per file, which is a child process per note.
207
+ const history = await readContentHistory(root, Object.values(CONTENT_DIRS));
139
208
  for (const collection of COLLECTIONS) {
140
209
  // `cwd` keeps globby's results root-relative, which is exactly the
141
210
  // spelling `ContentEntry.file` promises; only the read needs the join.
@@ -148,7 +217,7 @@ export async function loadContentEntries(options = {}) {
148
217
  continue;
149
218
  const { slug, urlPath } = toUrlPath(file, collection, data);
150
219
  const summary = typeof data.summary === 'string' ? data.summary : undefined;
151
- const updated = normalizeDate(data.updated);
220
+ const dates = await resolveDates(root, file, data, history);
152
221
  entries.push({
153
222
  slug,
154
223
  urlPath,
@@ -158,7 +227,7 @@ export async function loadContentEntries(options = {}) {
158
227
  tags: data.tags || [],
159
228
  status: data.status || 'seed',
160
229
  ...(summary ? { summary } : {}),
161
- ...(updated ? { updated } : {}),
230
+ ...dates,
162
231
  body: content,
163
232
  frontmatter: data,
164
233
  file,
@@ -298,6 +367,17 @@ export function stripCode(content) {
298
367
  }
299
368
  /** Frontmatter keys whose values are vocabulary, not links. */
300
369
  const NON_LINK_KEYS = new Set(['aliases', 'tags']);
370
+ /**
371
+ * Frontmatter keys whose strings are link targets in their own right.
372
+ *
373
+ * Everywhere else in frontmatter a link has to be spelled `[[like this]]`,
374
+ * because a bare string is usually prose and guessing otherwise would turn a
375
+ * page's own `url:` into a self-edge. Under `links:` the guess is the whole
376
+ * point: an update declares the pages it rolls up, and writing them as
377
+ * `[[double brackets]]` inside a YAML list is ceremony for a field that means
378
+ * nothing else.
379
+ */
380
+ const LINK_KEYS = new Set(['links']);
301
381
  /**
302
382
  * Extract every outbound link target from a markdown body and its frontmatter.
303
383
  *
@@ -333,27 +413,34 @@ export function extractLinks(content, frontmatter = {}) {
333
413
  */
334
414
  function extractFrontmatterLinks(frontmatter) {
335
415
  const links = [];
336
- const walk = (value) => {
416
+ const walk = (value, bare) => {
337
417
  if (typeof value === 'string') {
338
418
  for (const match of value.matchAll(WIKILINK)) {
339
419
  const target = stripSubpath(match[1]);
340
420
  if (target)
341
421
  links.push({ kind: 'name', target });
342
422
  }
423
+ // A string that already spelled its link is not also a bare target.
424
+ if (bare && !value.includes('[[')) {
425
+ const link = classifyFrontmatterTarget(value);
426
+ if (link)
427
+ links.push(link);
428
+ }
343
429
  }
344
430
  else if (Array.isArray(value)) {
345
- value.forEach(walk);
431
+ for (const nested of value)
432
+ walk(nested, bare);
346
433
  }
347
434
  else if (value && typeof value === 'object') {
348
435
  for (const [key, nested] of Object.entries(value)) {
349
436
  if (!NON_LINK_KEYS.has(key))
350
- walk(nested);
437
+ walk(nested, bare || LINK_KEYS.has(key));
351
438
  }
352
439
  }
353
440
  };
354
441
  for (const [key, value] of Object.entries(frontmatter)) {
355
442
  if (!NON_LINK_KEYS.has(key))
356
- walk(value);
443
+ walk(value, LINK_KEYS.has(key));
357
444
  }
358
445
  return links;
359
446
  }
@@ -376,6 +463,26 @@ const MARKDOWN_LINK = /!?\[[^\]]*\]\(\s*(<[^>]*>|[^()\s]+)(?:\s+"[^"]*")?\s*\)/g
376
463
  * handed to the title lookup, where it only ever resolved by the coincidence of
377
464
  * a note listing its own slug as an alias.
378
465
  */
466
+ /**
467
+ * Classify one bare string from a `links:` list.
468
+ *
469
+ * A site path is a `url` link, a filename is the file's title, and anything
470
+ * else is read as a title — the same two namespaces every other edge resolves
471
+ * through, so `check` reports an unresolvable entry here exactly as it reports
472
+ * a broken `[[WikiLink]]`. External URLs are not edges, as everywhere else.
473
+ */
474
+ function classifyFrontmatterTarget(raw) {
475
+ const value = raw.trim();
476
+ if (!value)
477
+ return null;
478
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//'))
479
+ return null;
480
+ const classified = classifyMarkdownTarget(value);
481
+ if (classified)
482
+ return classified;
483
+ const title = stripSubpath(value);
484
+ return title ? { kind: 'name', target: title } : null;
485
+ }
379
486
  function classifyMarkdownTarget(raw) {
380
487
  const destination = raw.replace(/^<|>$/g, '').trim();
381
488
  // Anything with a scheme, and protocol-relative `//host`, leaves the site.
@@ -454,8 +561,10 @@ export const STAR_CONFIG = {
454
561
  */
455
562
  export function calculateStars(notes) {
456
563
  const starredSlugs = new Set();
457
- // Standalone pages belong in search and WikiLinks, not note rankings.
458
- const notesArray = Array.from(notes.values()).filter((note) => note.collection !== 'pages');
564
+ // Standalone pages and changelog updates belong in search and WikiLinks, not
565
+ // note rankings. An update links to everything it rolled up, so ranking it
566
+ // alongside notes would let the changelog crowd out the writing it describes.
567
+ const notesArray = Array.from(notes.values()).filter((note) => note.collection !== 'pages' && note.collection !== 'updates');
459
568
  // Skip if too few notes
460
569
  if (notesArray.length < STAR_CONFIG.minNotesForStars) {
461
570
  return starredSlugs;
@@ -540,6 +649,14 @@ export function buildGraph(entries) {
540
649
  for (const entry of entries) {
541
650
  extracted.set(entry.urlPath, extractLinks(entry.body, entry.frontmatter));
542
651
  files.set(entry.urlPath, entry.file);
652
+ // `updated` here is the author's claim, not the resolved date on the
653
+ // entry, and it stays that way on purpose. `backlinks.json` is committed,
654
+ // and a git-derived date cannot be committed alongside the commit that
655
+ // changes it: the file's new date does not exist until that commit does,
656
+ // so every content commit would land with the artifact already stale.
657
+ // The resolved date is on `ContentEntry`, in `graph query --json`, and in
658
+ // the build-time `site.json` — all of which are computed, not stored.
659
+ const claimed = claimedDate(entry.frontmatter);
543
660
  notes.set(entry.urlPath, {
544
661
  slug: entry.urlPath,
545
662
  title: entry.title,
@@ -550,7 +667,7 @@ export function buildGraph(entries) {
550
667
  tags: entry.tags,
551
668
  status: entry.status,
552
669
  ...(entry.summary ? { summary: entry.summary } : {}),
553
- ...(entry.updated ? { updated: entry.updated } : {}),
670
+ ...(claimed ? { updated: claimed } : {}),
554
671
  });
555
672
  }
556
673
  const diagnostics = [];
@@ -579,7 +696,14 @@ export function buildGraph(entries) {
579
696
  }
580
697
  }
581
698
  if (resolved && notes.has(resolved)) {
582
- resolvedOutbound.push(resolved);
699
+ // One edge per target, not one per spelling. `[[World]]` in the body
700
+ // and `/notes/world/` under `links:` are the same edge written two
701
+ // ways, and an update names both — the prose says it and the
702
+ // frontmatter lists it. `inbound` has always been deduplicated;
703
+ // `outbound` reaching the same page twice was the same fact
704
+ // counted twice.
705
+ if (!resolvedOutbound.includes(resolved))
706
+ resolvedOutbound.push(resolved);
583
707
  const target = notes.get(resolved);
584
708
  if (!target.inbound.includes(fromUrl)) {
585
709
  target.inbound.push(fromUrl);
@@ -635,6 +759,36 @@ export function formatDiagnostic(diagnostic) {
635
759
  }
636
760
  return `⚠️ ${diagnostic.rule} in ${diagnostic.file}: ${diagnostic.message}`;
637
761
  }
762
+ /**
763
+ * The newest change across the whole site, and which entry it was.
764
+ *
765
+ * "When did this wiki last change" is not the same question as "when did this
766
+ * page last change", and a home page that answers the second while appearing
767
+ * to answer the first is the bug this ticket opened on. Ties go to the entry
768
+ * scanned first, which is stable because the scan is.
769
+ */
770
+ export function summarizeSite(entries) {
771
+ let newest;
772
+ let newestInGit;
773
+ for (const entry of entries) {
774
+ if (entry.updated && (!newest || entry.updated > newest.updated))
775
+ newest = entry;
776
+ if (entry.modifiedInGit && (!newestInGit || entry.modifiedInGit > newestInGit)) {
777
+ newestInGit = entry.modifiedInGit;
778
+ }
779
+ }
780
+ return {
781
+ ...(newest
782
+ ? {
783
+ lastUpdated: newest.updated,
784
+ lastUpdatedPath: newest.urlPath,
785
+ lastUpdatedSource: newest.updatedSource,
786
+ }
787
+ : {}),
788
+ ...(newestInGit ? { lastModifiedInGit: newestInGit } : {}),
789
+ entries: entries.length,
790
+ };
791
+ }
638
792
  /**
639
793
  * The public artifact, exactly as `public/backlinks.json` stores it.
640
794
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dmthepm/commune",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.3.0",
5
5
  "description": "An Astro wiki engine with WikiLinks, sliding panes, backlinks and static search, plus a commune CLI that queries the content graph and checks links",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -85,15 +85,12 @@
85
85
  "unist-util-visit": "^5.1.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@astrojs/check": "^0.9.10",
89
88
  "@astrojs/markdown-remark": "^7.3.0",
90
89
  "@astrojs/sitemap": "^3.7.4",
91
- "@tailwindcss/typography": "^0.5.20",
92
90
  "@types/hast": "^3.0.5",
93
91
  "@types/mdast": "^4.0.4",
94
92
  "@types/node": "^22.20.1",
95
93
  "astro": "^7.2.10",
96
- "autoprefixer": "^10.5.4",
97
94
  "tailwindcss": "^3.4.0",
98
95
  "typescript": "^5.6.0"
99
96
  }
@@ -2,7 +2,11 @@
2
2
  export interface Props { slug: string; title?: string; hideCount?: boolean; }
3
3
  const { slug, hideCount = false } = Astro.props;
4
4
  ---
5
- <aside class="backlinks" data-backlinks-for={slug} data-hide-count={hideCount ? 'true' : undefined}>
5
+ <!-- Starts hidden: the list is filled from `/backlinks.json` at runtime, so
6
+ without JavaScript, or on a note nothing links to, the heading would
7
+ otherwise stand over an empty list. `BacklinksScript` reveals it once it
8
+ has links to show. -->
9
+ <aside class="backlinks hidden" data-backlinks-for={slug} data-hide-count={hideCount ? 'true' : undefined}>
6
10
  <h4>Links to this note</h4>
7
11
  <ul class="backlinks-list"></ul>
8
12
  </aside>
@@ -5,6 +5,16 @@
5
5
  (function() {
6
6
  let backlinksData = null;
7
7
 
8
+ // Note titles are the vault author's prose and go into markup below; a stray
9
+ // <, & or " in one should read as that character rather than break the list
10
+ // open.
11
+ const escapeHtml = (value) => String(value ?? '')
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&#39;');
17
+
8
18
  async function initializeBacklinks(scope = document) {
9
19
  try {
10
20
  // Fetch backlinks data if not already loaded
@@ -57,7 +67,7 @@
57
67
  };
58
68
 
59
69
  // Render links and then add stars as separate clickable elements
60
- list.innerHTML = items.map(s => `<li><a href="${s}">${titleFor(s)}</a></li>`).join('');
70
+ list.innerHTML = items.map(s => `<li><a href="${escapeHtml(s)}">${escapeHtml(titleFor(s))}</a></li>`).join('');
61
71
 
62
72
  // Add stars to starred notes
63
73
  list.querySelectorAll('li').forEach((li, index) => {
@@ -1,5 +1,11 @@
1
1
  ---
2
2
  ---
3
+ <!-- First thing in the tab order on every page: reading pages put the whole
4
+ note between the header and the footer, and a keyboard reader should not
5
+ have to walk the header on each one. #main is on the page's <main>, or on
6
+ the first pane's scroll container where <main> is the pane container. -->
7
+ <a class="skip-link" href="#main">Skip to content</a>
8
+
3
9
  <header class="commune-header">
4
10
  <div class="wrap">
5
11
  <a href="/" class="brand-section">
@@ -21,6 +27,30 @@
21
27
  </header>
22
28
 
23
29
  <style>
30
+ .skip-link{
31
+ /* Fixed, not absolute: the reader may be scrolled anywhere in a long note
32
+ when they reach for Tab, and the link has to be on screen to be useful. */
33
+ position:fixed;
34
+ left:0.5rem;
35
+ top:0.5rem;
36
+ z-index:calc(var(--z-modal) + 1);
37
+ padding:0.6rem 0.9rem;
38
+ border:1.5px solid var(--c-accent);
39
+ border-radius:var(--c-radius-md);
40
+ background:var(--c-bg);
41
+ color:var(--c-accent);
42
+ font-size:0.9rem;
43
+ font-weight:600;
44
+ box-shadow:var(--c-shadow-md);
45
+ transform:translateY(calc(-100% - 1rem));
46
+ transition:transform 0.15s ease;
47
+ }
48
+ .skip-link:focus{
49
+ transform:translateY(0);
50
+ }
51
+ @media print{
52
+ .skip-link{display:none}
53
+ }
24
54
  .commune-header{
25
55
  position:sticky;
26
56
  top:0;
@@ -193,6 +223,16 @@
193
223
  border-radius:var(--c-radius-sm);
194
224
  }
195
225
 
226
+ /* A hover state whose whole content is movement loses the movement. The
227
+ rule lives here rather than in `design-system.css` because Astro scopes
228
+ this block's selectors, and a global `.theme-btn:hover` would lose the
229
+ specificity contest to the scoped rule above. */
230
+ @media (prefers-reduced-motion: reduce) {
231
+ .theme-btn:hover {
232
+ transform: none;
233
+ }
234
+ }
235
+
196
236
  /* Mobile: hide Command-K text and search text, show only icon */
197
237
  @media (max-width: 768px) {
198
238
  .search-btn kbd,
@@ -239,9 +279,44 @@
239
279
  });
240
280
  })();
241
281
 
282
+ // Publish the header's real height. The pane container positions itself
283
+ // against --header-height, and the fallback in the stylesheet is a
284
+ // hand-counted number the header has outgrown — it measures 73px at the
285
+ // default font size and more once the text is zoomed. Measuring keeps the
286
+ // pane's top gap equal to its bottom gap at any zoom level or font size,
287
+ // and means no consumer has to re-count the number after restyling the
288
+ // header.
289
+ (function trackHeaderHeight(){
290
+ const header = document.querySelector('.commune-header');
291
+ if (!header) return;
292
+ const publish = () => {
293
+ const height = Math.round(header.getBoundingClientRect().height);
294
+ if (height > 0) document.documentElement.style.setProperty('--header-height', height + 'px');
295
+ };
296
+ publish();
297
+ if ('ResizeObserver' in window) new ResizeObserver(publish).observe(header);
298
+ else addEventListener('resize', publish);
299
+ })();
300
+
301
+ // One platform check, shared with the search modal's own handler, so the
302
+ // printed shortcut and the key that actually works cannot drift apart.
303
+ // navigator.platform is deprecated but still the only thing every browser
304
+ // answers; userAgentData.platform is preferred where it exists.
305
+ window.CommuneIsMac = () => /mac/i.test(
306
+ (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ''
307
+ );
308
+
309
+ // The button printed ⌘K to everyone. On Windows and Linux the shortcut is
310
+ // Ctrl+K, so the label named a key combination that did nothing.
311
+ (function labelSearchShortcut(){
312
+ if (window.CommuneIsMac()) return;
313
+ document.querySelectorAll('.search-btn kbd').forEach((el) => { el.textContent = 'Ctrl K'; });
314
+ document.querySelectorAll('.search-btn').forEach((el) => { el.setAttribute('aria-label', 'Search (Ctrl K)'); });
315
+ })();
316
+
242
317
  // cmd/ctrl-k opens modal
243
318
  addEventListener('keydown', (e) => {
244
- const mac = navigator.platform.toUpperCase().includes('MAC');
319
+ const mac = window.CommuneIsMac();
245
320
  if ((mac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 'k') {
246
321
  e.preventDefault();
247
322
  dispatchEvent(new CustomEvent('commune:openSearch'));
@@ -5,13 +5,24 @@
5
5
  (function() {
6
6
  let backlinksData = null;
7
7
 
8
+ // Focus belongs to the dialog while it is open, and goes back to the star
9
+ // that opened it on close — otherwise a keyboard reader who opens the modal
10
+ // is dropped at the top of the document with no way back to where they were.
11
+ let starModalOpener = null;
12
+
13
+ const starModalFocusables = (modal) => Array.from(
14
+ modal.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])')
15
+ ).filter((el) => !el.hasAttribute('disabled'));
16
+
8
17
  // Show the star modal
9
18
  function showStarModal() {
10
19
  const modal = document.getElementById('star-modal');
11
20
  if (modal) {
21
+ starModalOpener = document.activeElement;
12
22
  modal.style.display = 'flex';
13
23
  // Prevent body scroll when modal is open
14
24
  document.body.style.overflow = 'hidden';
25
+ starModalFocusables(modal)[0]?.focus();
15
26
  }
16
27
  }
17
28
 
@@ -21,6 +32,10 @@
21
32
  if (modal) {
22
33
  modal.style.display = 'none';
23
34
  document.body.style.overflow = '';
35
+ if (starModalOpener instanceof HTMLElement && document.contains(starModalOpener)) {
36
+ starModalOpener.focus({ preventScroll: true });
37
+ }
38
+ starModalOpener = null;
24
39
  }
25
40
  }
26
41
 
@@ -31,6 +46,9 @@
31
46
  const modal = document.createElement('div');
32
47
  modal.id = 'star-modal';
33
48
  modal.className = 'star-modal';
49
+ modal.setAttribute('role', 'dialog');
50
+ modal.setAttribute('aria-modal', 'true');
51
+ modal.setAttribute('aria-label', 'Top 5%');
34
52
  modal.innerHTML = `
35
53
  <div class="star-modal-backdrop"></div>
36
54
  <div class="star-modal-content">
@@ -60,10 +78,25 @@
60
78
  hideStarModal();
61
79
  });
62
80
 
63
- // Close on Escape key
81
+ // Close on Escape, and keep Tab inside the dialog while it is open.
64
82
  document.addEventListener('keydown', (e) => {
65
- if (e.key === 'Escape' && modal.style.display === 'flex') {
83
+ if (modal.style.display !== 'flex') return;
84
+ if (e.key === 'Escape') {
66
85
  hideStarModal();
86
+ return;
87
+ }
88
+ if (e.key !== 'Tab') return;
89
+ const items = starModalFocusables(modal);
90
+ if (!items.length) { e.preventDefault(); return; }
91
+ const first = items[0];
92
+ const last = items[items.length - 1];
93
+ const active = document.activeElement;
94
+ if (e.shiftKey && (active === first || !modal.contains(active))) {
95
+ e.preventDefault();
96
+ last.focus();
97
+ } else if (!e.shiftKey && (active === last || !modal.contains(active))) {
98
+ e.preventDefault();
99
+ first.focus();
67
100
  }
68
101
  });
69
102
  }
@@ -110,9 +143,13 @@
110
143
  star.setAttribute('role', 'button');
111
144
  star.setAttribute('tabindex', '0');
112
145
 
113
- // Click handler to open modal
146
+ // Click handler to open modal. stopPropagation matters: the pane
147
+ // container's delegated click handler treats any click that is not
148
+ // on a link or a real button as "focus this pane", and would pull
149
+ // focus straight back out of the modal we just opened.
114
150
  star.addEventListener('click', (e) => {
115
151
  e.preventDefault();
152
+ e.stopPropagation();
116
153
  showStarModal();
117
154
  });
118
155
 
@@ -120,6 +157,7 @@
120
157
  star.addEventListener('keydown', (e) => {
121
158
  if (e.key === 'Enter' || e.key === ' ') {
122
159
  e.preventDefault();
160
+ e.stopPropagation();
123
161
  showStarModal();
124
162
  }
125
163
  });