jekyll-theme-zer0 1.21.0 → 1.22.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.
@@ -160,16 +160,33 @@ layout: default
160
160
  </div>
161
161
 
162
162
  <!-- Category Link -->
163
- {% comment %} Category base is configurable (`category_base`, default `/news`)
164
- so remote-theme consumers whose category index lives elsewhere
165
- (e.g. `/categories`) don't get a hardcoded 404. See issue #204. {% endcomment %}
163
+ {% comment %} Category base is configurable (`category_base`, default `/news`).
164
+ Only link the badge when that category index page actually exists in the
165
+ build; on a remote-theme Pages consumer the category section is
166
+ plugin/page-generated and absent, so we render a plain badge instead of a
167
+ link that 404s (mirrors the tag-badge and footer Quick-Links guard).
168
+ See issue #204. {% endcomment %}
166
169
  {% if page.categories.size > 0 %}
167
170
  {% assign _category_base = site.category_base | default: '/news' %}
171
+ {% assign _primary_category = page.categories | first %}
172
+ {% capture _category_url %}{{ _category_base }}/{{ _primary_category | slugify }}/{% endcapture %}
173
+ {% comment %} Category index pages can live in site.pages (a regular page) or
174
+ site.posts (a dated section index), so check both. {% endcomment %}
175
+ {% assign _category_page = site.html_pages | where: "url", _category_url | first %}
176
+ {% unless _category_page %}
177
+ {% assign _category_page = site.posts | where: "url", _category_url | first %}
178
+ {% endunless %}
168
179
  <div class="mb-3">
169
- <a href="{{ site.baseurl }}{{ _category_base }}/{{ page.categories | first | slugify }}/"
180
+ {% if _category_page %}
181
+ <a href="{{ site.baseurl }}{{ _category_url }}"
170
182
  class="badge bg-primary text-decoration-none fs-6">
171
- <i class="bi bi-folder me-1"></i>{{ page.categories | first }}
183
+ <i class="bi bi-folder me-1"></i>{{ _primary_category }}
172
184
  </a>
185
+ {% else %}
186
+ <span class="badge bg-primary fs-6">
187
+ <i class="bi bi-folder me-1"></i>{{ _primary_category }}
188
+ </span>
189
+ {% endif %}
173
190
  </div>
174
191
  {% endif %}
175
192
 
@@ -512,7 +529,7 @@ layout: default
512
529
  <!-- ================================ -->
513
530
  <!-- COMMENT SYSTEM -->
514
531
  <!-- ================================ -->
515
- {% if page.comments != false and site.giscus %}
532
+ {% if page.comments != false and site.giscus.enabled %}
516
533
  <section class="post-comments mt-5 pt-4 border-top" id="comments">
517
534
  <h2 class="h4 mb-4">
518
535
  <i class="bi bi-chat-dots me-2"></i>Comments
@@ -185,10 +185,20 @@ module Jekyll
185
185
  # Converter — pure-string transformations on the raw markdown body.
186
186
  # ---------------------------------------------------------------------
187
187
  class Converter
188
- # Match fenced code blocks (``` or ~~~), indented code lines, and inline
189
- # code spans so we can skip them when rewriting wiki-link / tag syntax.
190
- FENCED_CODE_RE = /(^[ \t]{0,3}(```|~~~)[^\n]*\n.*?^[ \t]{0,3}\2[^\n]*$)/m
191
- INLINE_CODE_RE = /(`+)[^`\n]*?\1/
188
+ # Match fenced code blocks and inline code spans so we can skip them when
189
+ # rewriting wiki-link / tag syntax.
190
+ #
191
+ # FENCED_CODE_RE has two branches (closed fence first so it is not greedily
192
+ # swallowed by the EOF branch):
193
+ # 1. A variable-length fence (3+ backticks or tildes) closed by an
194
+ # equal-length run — `\2` requires the close to match the open exactly,
195
+ # so a 4-backtick fence is not closed by an inner ``` fence.
196
+ # 2. An unclosed fence runs to end-of-input (matches Obsidian/kramdown,
197
+ # which treat a dangling fence as code through EOF).
198
+ FENCED_CODE_RE = /(^[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n.*?^[ \t]{0,3}\2[^\n]*$)|(^[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n.*\z)/m
199
+ # Inline code: the delimiter is a run of N backticks; the span may contain
200
+ # shorter backtick runs (CommonMark), but not a newline or the closing run.
201
+ INLINE_CODE_RE = /(`+)(?:[^`\n]|(?!\1)`)*?\1/
192
202
 
193
203
  # ![[target]] or ![[target|width-or-alias]]
194
204
  EMBED_RE = /!\[\[([^\]\n|]+?)(?:\|([^\]\n]+))?\]\]/
@@ -209,6 +219,7 @@ module Jekyll
209
219
  @site = site
210
220
  @index = index
211
221
  @config = config
222
+ @callout_seq = 0
212
223
  end
213
224
 
214
225
  def convert(markdown, current_url: nil)
@@ -246,26 +257,47 @@ module Jekyll
246
257
  end
247
258
 
248
259
  # > [!type] Title → Bootstrap alert. Body is dedented (leading "> ").
260
+ # Fold markers make the callout an accessible disclosure:
261
+ # `-` collapsed by default, `+` expanded; both get a <button> toggle.
262
+ # Plain callouts (no marker) stay a static heading.
249
263
  def transform_callouts(text)
250
264
  text.gsub(CALLOUT_RE) do
251
265
  m = Regexp.last_match
252
266
  type = m[:type].downcase
253
267
  spec = CALLOUT_TYPES[type] || CALLOUT_TYPES['note']
254
268
  fold = m[:fold]
269
+ foldable = fold == '+' || fold == '-'
270
+ collapsed = fold == '-'
255
271
  title = m[:title].to_s.strip
256
272
  title = type.capitalize if title.empty?
257
273
  body = m[:body].to_s.gsub(/^[ \t]{0,3}>[ \t]?/, '').rstrip
258
274
 
259
- # Allow inner Markdown by emitting a span with `markdown="1"` so
260
- # kramdown still parses the body content.
261
- collapsed_attr = fold == '-' ? ' data-collapsed="true"' : ''
262
- alert_class = "alert alert-#{spec[:alert]} #{@config['callout_class_prefix']} #{@config['callout_class_prefix']}-#{type}"
263
-
275
+ prefix = @config['callout_class_prefix']
276
+ collapsed_attr = collapsed ? ' data-collapsed="true"' : ''
277
+ alert_class = "alert alert-#{spec[:alert]} #{prefix} #{prefix}-#{type}"
278
+ body_id = "#{prefix}-body-#{next_callout_id}"
279
+ body_hidden = collapsed ? ' hidden' : ''
280
+
281
+ icon = %(<i class="bi #{spec[:icon]} me-2" aria-hidden="true"></i>)
282
+ # Voice the callout type for screen readers (the icon is decorative).
283
+ sr_type = %(<span class="visually-hidden">#{escape_html(type.capitalize)}: </span>)
284
+ title_inner = "#{icon}#{sr_type}#{escape_html(title)}"
285
+
286
+ title_el =
287
+ if foldable
288
+ chevron = %(<i class="bi bi-chevron-down #{prefix}-chevron ms-auto" aria-hidden="true"></i>)
289
+ %(<button type="button" class="#{prefix}-title #{prefix}-toggle" aria-expanded="#{collapsed ? 'false' : 'true'}" aria-controls="#{body_id}">#{title_inner}#{chevron}</button>)
290
+ else
291
+ %(<div class="#{prefix}-title" role="heading" aria-level="3">#{title_inner}</div>)
292
+ end
293
+
294
+ # Outer div is markdown="0" so kramdown leaves the chrome untouched;
295
+ # the body is markdown="1" so its content is still parsed.
264
296
  <<~HTML
265
297
 
266
298
  <div class="#{alert_class}" role="alert"#{collapsed_attr} markdown="0">
267
- <div class="#{@config['callout_class_prefix']}-title"><i class="bi #{spec[:icon]} me-2" aria-hidden="true"></i>#{escape_html(title)}</div>
268
- <div class="#{@config['callout_class_prefix']}-body" markdown="1">
299
+ #{title_el}
300
+ <div class="#{prefix}-body" id="#{body_id}"#{body_hidden} markdown="1">
269
301
 
270
302
  #{body}
271
303
 
@@ -276,6 +308,10 @@ module Jekyll
276
308
  end
277
309
  end
278
310
 
311
+ def next_callout_id
312
+ @callout_seq += 1
313
+ end
314
+
279
315
  def transform_embeds(text)
280
316
  text.gsub(EMBED_RE) do
281
317
  target = Regexp.last_match(1).to_s.strip
@@ -321,7 +357,7 @@ module Jekyll
321
357
  # Use a Liquid include so the embedded note can be rendered with the
322
358
  # transclude template (avoids re-running this converter on its body).
323
359
  url = info[:url].to_s
324
- url += "##{anchor}" unless anchor.nil? || anchor.empty?
360
+ url += "##{anchorize(anchor)}" unless anchor.nil? || anchor.empty?
325
361
  %({% include content/transclude.html target="#{escape_attr(clean_target)}" url="#{escape_attr(url)}" %})
326
362
  end
327
363
  end
@@ -335,7 +371,9 @@ module Jekyll
335
371
 
336
372
  info = @index.lookup(clean_target)
337
373
  if info.nil?
338
- %(<a href="#" class="#{@config['broken_link_class']}" data-wiki-target="#{escape_attr(clean_target)}" title="Unresolved wiki-link: #{escape_attr(clean_target)}">#{escape_html(display)}</a>)
374
+ # A broken link points nowhere — render a non-navigating <span> so a
375
+ # click can't scroll the page to the top (the old href="#" bug).
376
+ %(<span class="#{@config['broken_link_class']}" data-wiki-target="#{escape_attr(clean_target)}" title="Unresolved wiki-link: #{escape_attr(clean_target)}">#{escape_html(display)}</span>)
339
377
  else
340
378
  url = info[:url].to_s
341
379
  url += "##{anchorize(anchor)}" unless anchor.nil? || anchor.empty?
@@ -345,9 +383,25 @@ module Jekyll
345
383
  end
346
384
  end
347
385
 
386
+ # Hex-colour-shaped tokens in prose (#ffffff, #fff, #1a2b3c) are not tags.
387
+ # Suppress standard CSS hex lengths (3/6/8) and any digit-containing hex;
388
+ # length 4/5/7 all-letter tokens stay tags so iconic hex-words (#cafe,
389
+ # #dead, #beef, #face) still link. Mirrors isColorLikeTag() in
390
+ # assets/js/obsidian-wiki-links.js byte-for-byte.
391
+ def color_like_tag?(tag)
392
+ return false unless tag =~ /\A[0-9a-fA-F]+\z/
393
+
394
+ len = tag.length
395
+ return true if [3, 6, 8].include?(len)
396
+
397
+ len.between?(3, 8) && tag =~ /\d/ ? true : false
398
+ end
399
+
348
400
  def transform_inline_tags(text)
349
401
  text.gsub(INLINE_TAG_RE) do
350
402
  tag = Regexp.last_match(1)
403
+ next "##{tag}" if color_like_tag?(tag)
404
+
351
405
  base = @config['tag_base_url'].to_s
352
406
  base = "#{base}/" unless base.end_with?('/')
353
407
  %(<a href="#{base}##{slugify(tag)}" class="obsidian-tag">##{escape_html(tag)}</a>)
@@ -366,14 +420,29 @@ module Jekyll
366
420
  end
367
421
  end
368
422
 
423
+ # Replicate kramdown's basic header-id algorithm so [[Page#Heading]]
424
+ # fragments land on the heading kramdown actually generated:
425
+ # strip leading non-letters → drop chars outside [a-zA-Z0-9 -] →
426
+ # spaces to dashes (consecutive spaces kept as consecutive dashes,
427
+ # matching kramdown) → downcase. Underscores are removed, not kept.
428
+ # Must stay byte-identical to anchorize() in assets/js/obsidian-wiki-links.js.
369
429
  def anchorize(anchor)
370
430
  return '' if anchor.nil?
371
431
 
372
- anchor.downcase.strip.gsub(/[^\w\s-]/, '').gsub(/\s+/, '-')
432
+ anchor.to_s.strip
433
+ .gsub(/^[^a-zA-Z]+/, '')
434
+ .gsub(/[^a-zA-Z0-9 -]/, '')
435
+ .tr(' ', '-')
436
+ .downcase
373
437
  end
374
438
 
439
+ # Match Jekyll's default `slugify` filter (the same one pages/tags.md uses
440
+ # for its anchor ids) so inline #tags link to a real on-page anchor.
441
+ # Inline tags are ASCII-only by construction (INLINE_TAG_RE), so the
442
+ # ASCII-equivalent of Jekyll::Utils.slugify is byte-identical here and
443
+ # avoids a hard Jekyll dependency in the standalone unit tests.
375
444
  def slugify(value)
376
- value.downcase.gsub(/[^\w\/-]+/, '-').gsub(/-+/, '-').gsub(%r{/}, '-')
445
+ value.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
377
446
  end
378
447
 
379
448
  def escape_html(value)
@@ -21,6 +21,12 @@
21
21
  border-bottom-color: transparent;
22
22
  }
23
23
 
24
+ &:focus-visible {
25
+ outline: 2px solid var(--bs-primary);
26
+ outline-offset: 2px;
27
+ border-bottom-color: transparent;
28
+ }
29
+
24
30
  &[aria-current="page"] {
25
31
  background-color: rgba(var(--bs-primary-rgb), 0.08);
26
32
  padding: 0 0.15rem;
@@ -46,16 +52,22 @@
46
52
  margin: 0 0.1rem;
47
53
  font-size: 0.85em;
48
54
  font-weight: 500;
49
- color: var(--bs-secondary);
50
- background-color: rgba(var(--bs-secondary-rgb), 0.1);
55
+ // Theme-aware tokens give legible contrast in both light and dark modes.
56
+ color: var(--bs-secondary-color);
57
+ background-color: var(--bs-secondary-bg);
51
58
  border-radius: 0.5rem;
52
59
  text-decoration: none;
53
- transition: background-color 0.15s ease;
60
+ transition: background-color 0.15s ease, color 0.15s ease;
54
61
 
55
- &:hover,
56
- &:focus {
57
- background-color: rgba(var(--bs-secondary-rgb), 0.2);
58
- color: var(--bs-primary);
62
+ &:hover {
63
+ background-color: var(--bs-tertiary-bg);
64
+ color: var(--bs-emphasis-color);
65
+ text-decoration: none;
66
+ }
67
+
68
+ &:focus-visible {
69
+ outline: 2px solid var(--bs-primary);
70
+ outline-offset: 1px;
59
71
  text-decoration: none;
60
72
  }
61
73
  }
@@ -111,14 +123,47 @@
111
123
  align-items: center;
112
124
  }
113
125
 
126
+ // Foldable callouts render the title as a real <button> disclosure. Reset the
127
+ // button chrome so it reads like the static title, then add affordances.
128
+ .obsidian-callout-toggle {
129
+ width: 100%;
130
+ padding: 0;
131
+ border: 0;
132
+ background: none;
133
+ color: inherit;
134
+ font: inherit;
135
+ font-weight: 600;
136
+ text-align: left;
137
+ cursor: pointer;
138
+
139
+ &:focus-visible {
140
+ outline: 2px solid currentColor;
141
+ outline-offset: 2px;
142
+ border-radius: 0.2rem;
143
+ }
144
+ }
145
+
146
+ .obsidian-callout-chevron {
147
+ transition: transform 0.2s ease;
148
+ }
149
+
150
+ .obsidian-callout-toggle[aria-expanded="false"] .obsidian-callout-chevron {
151
+ transform: rotate(-90deg);
152
+ }
153
+
114
154
  .obsidian-callout-body {
115
155
  margin: 0;
116
156
 
117
157
  > :last-child {
118
158
  margin-bottom: 0;
119
159
  }
160
+
161
+ &[hidden] {
162
+ display: none;
163
+ }
120
164
  }
121
165
 
166
+ // Legacy / no-JS fallback: keep the CSS-only collapse working.
122
167
  &[data-collapsed="true"] .obsidian-callout-body {
123
168
  display: none;
124
169
  }
@@ -157,7 +202,13 @@
157
202
  // and _includes/navigation/local-graph-fab.html (footer) via
158
203
  // assets/js/obsidian-local-graph.js.
159
204
  // ----------------------------------------------------------------------------
160
- .obsidian-local-graph-fab {
205
+ // ID selector (not the class) is REQUIRED for the fixed position to stick:
206
+ // the FAB is a direct child of `.zer0-bg-body`, which matches the elevation
207
+ // rule `.zer0-bg-body > *:not(.fixed-top):not(.offcanvas):not(.modal)` in
208
+ // _sass/theme/_backgrounds.scss (specificity 0,4,0) and forces
209
+ // `position: relative; z-index: 1` — dropping the FAB into page flow at the
210
+ // bottom. An id (1,0,0) wins, mirroring `#aiChatToggle`. See ai-chat.html.
211
+ #obsidianLocalGraphFab.obsidian-local-graph-fab {
161
212
  position: fixed;
162
213
  left: var(--zer0-space-fab-offset, 1rem);
163
214
  right: auto;
@@ -18,7 +18,13 @@ sitemap: false
18
18
  permalink, collection, tags, and the body's outgoing wiki-link targets.
19
19
  The JS resolver normalizes lookup keys to lowercase + collapsed whitespace
20
20
  (matching _plugins/obsidian_links.rb#normalize).
21
+
22
+ Honours `obsidian: { enabled: false }` in _config.yml: emit an empty (but
23
+ valid) index so the client resolver, backlinks, and graph all no-op.
21
24
  {%- endcomment -%}
25
+ {%- if site.obsidian.enabled == false -%}
26
+ {"generated_at": {{ site.time | date_to_xmlschema | jsonify }}, "count": 0, "entries": []}
27
+ {%- else -%}
22
28
  {%- assign all_docs = "" | split: "" -%}
23
29
  {%- for collection in site.collections -%}
24
30
  {%- for doc in collection.docs -%}
@@ -37,6 +43,13 @@ sitemap: false
37
43
  {%- for doc in all_docs -%}
38
44
  {%- assign basename = doc.path | split: "/" | last | replace: ".md", "" | replace: ".markdown", "" | replace: ".html", "" -%}
39
45
  {%- assign body_source = doc.content | default: "" -%}
46
+ {%- comment -%}
47
+ Strip fenced code so Bash `[[ ... ]]` tests and literal examples don't
48
+ leak into excerpts or become graph edges. Both fence syntaxes are
49
+ handled: ``` first, then ~~~. (Structural limits: unbalanced fences,
50
+ indented code, and fence-in-fence are not perfectly handled here — the
51
+ server-side Ruby plugin's FENCED_CODE_RE is stricter.)
52
+ {%- endcomment -%}
40
53
  {%- assign body_without_fences = "" -%}
41
54
  {%- assign fence_parts = body_source | split: "```" -%}
42
55
  {%- for fence_part in fence_parts -%}
@@ -45,8 +58,16 @@ sitemap: false
45
58
  {%- assign body_without_fences = body_without_fences | append: fence_part -%}
46
59
  {%- endif -%}
47
60
  {%- endfor -%}
61
+ {%- assign body_without_tilde = "" -%}
62
+ {%- assign tilde_parts = body_without_fences | split: "~~~" -%}
63
+ {%- for tilde_part in tilde_parts -%}
64
+ {%- assign tilde_mod = forloop.index0 | modulo: 2 -%}
65
+ {%- if tilde_mod == 0 -%}
66
+ {%- assign body_without_tilde = body_without_tilde | append: tilde_part -%}
67
+ {%- endif -%}
68
+ {%- endfor -%}
48
69
  {%- assign body_without_code = "" -%}
49
- {%- assign inline_code_parts = body_without_fences | split: "`" -%}
70
+ {%- assign inline_code_parts = body_without_tilde | split: "`" -%}
50
71
  {%- for inline_code_part in inline_code_parts -%}
51
72
  {%- assign inline_code_mod = forloop.index0 | modulo: 2 -%}
52
73
  {%- if inline_code_mod == 0 -%}
@@ -77,7 +98,6 @@ sitemap: false
77
98
  {%- if target_raw and target_raw != "" -%}
78
99
  {%- assign target = target_raw | split: "|" | first | split: "#" | first | split: "^" | first | strip | downcase -%}
79
100
  {%- assign target_is_code = false -%}
80
- {%- assign first_char = target | slice: 0 -%}
81
101
  {%- if target contains "$" or target contains "==" or target contains "!=" or target contains "=~" -%}
82
102
  {%- assign target_is_code = true -%}
83
103
  {%- endif -%}
@@ -87,7 +107,9 @@ sitemap: false
87
107
  {%- if target contains " -eq " or target contains " -ne " or target contains " -lt " or target contains " -le " or target contains " -gt " or target contains " -ge " -%}
88
108
  {%- assign target_is_code = true -%}
89
109
  {%- endif -%}
90
- {%- if first_char == "-" or target contains "{" or target contains "}" or target contains "(" or target contains ")" or target contains '"' or target contains "'" -%}
110
+ {%- comment -%} Keep braces (template/code), but allow parentheses,
111
+ quotes, and leading dashes — real page titles use those. {%- endcomment -%}
112
+ {%- if target contains "{" or target contains "}" -%}
91
113
  {%- assign target_is_code = true -%}
92
114
  {%- endif -%}
93
115
  {%- if target != "" and target.size < 200 and target_is_code == false -%}
@@ -95,6 +117,30 @@ sitemap: false
95
117
  {%- endif -%}
96
118
  {%- endif -%}
97
119
  {%- endfor -%}
120
+ {%- comment -%}
121
+ Note transclusions ![[Note Title]] are outgoing edges too. Image embeds
122
+ (![[file.png]]) are not — skip known image/binary extensions via an
123
+ exact comma-delimited match so titles ending in a short token aren't
124
+ dropped.
125
+ {%- endcomment -%}
126
+ {%- assign embed_chunks = body | split: "![[" -%}
127
+ {%- for embed_chunk in embed_chunks offset: 1 -%}
128
+ {%- assign etarget_raw = embed_chunk | split: "]]" | first -%}
129
+ {%- if etarget_raw and etarget_raw != "" -%}
130
+ {%- assign etarget = etarget_raw | split: "|" | first | split: "#" | first | split: "^" | first | strip | downcase -%}
131
+ {%- assign is_image = false -%}
132
+ {%- if etarget contains "." -%}
133
+ {%- assign eext = etarget | split: "." | last -%}
134
+ {%- assign eext_token = "," | append: eext | append: "," -%}
135
+ {%- if ",png,jpg,jpeg,gif,svg,webp,avif,bmp,pdf," contains eext_token -%}
136
+ {%- assign is_image = true -%}
137
+ {%- endif -%}
138
+ {%- endif -%}
139
+ {%- if etarget != "" and etarget.size < 200 and is_image == false -%}
140
+ {%- assign outgoing = outgoing | push: etarget -%}
141
+ {%- endif -%}
142
+ {%- endif -%}
143
+ {%- endfor -%}
98
144
  {%- assign outgoing = outgoing | uniq -%}
99
145
  {
100
146
  "title": {{ doc.title | default: basename | jsonify }},
@@ -110,3 +156,4 @@ sitemap: false
110
156
  {%- endfor -%}
111
157
  ]
112
158
  }
159
+ {%- endif -%}
@@ -34,6 +34,48 @@
34
34
  return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
35
35
  }
36
36
 
37
+ function prefersReducedMotion() {
38
+ return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
39
+ }
40
+
41
+ // Cytoscape is vendored under assets/vendor/ (no runtime CDN). Path comes from
42
+ // window.OBSIDIAN_CONFIG.cytoscapeUrl (set by Liquid), with a base fallback.
43
+ function cytoscapeSrc() {
44
+ var cfg = window.OBSIDIAN_CONFIG || {};
45
+ if (cfg.cytoscapeUrl) return cfg.cytoscapeUrl;
46
+ var base = (document.querySelector('base') || {}).href || '/';
47
+ return base.replace(/\/$/, '') + '/assets/vendor/cytoscape/cytoscape.min.js';
48
+ }
49
+
50
+ // Load cytoscape, coordinating with obsidian-local-graph.js via the shared
51
+ // window.__obsidianCytoscapeLoading queue. cb(true) on success, cb(false) on
52
+ // failure so the caller can show a recovery message.
53
+ function loadCytoscape(cb) {
54
+ if (typeof window.cytoscape === 'function') return cb(true);
55
+ if (window.__obsidianCytoscapeLoading) {
56
+ window.__obsidianCytoscapeLoading.push(cb);
57
+ return;
58
+ }
59
+ var queue = window.__obsidianCytoscapeLoading = [cb];
60
+ function flush(ok) {
61
+ window.__obsidianCytoscapeLoading = null;
62
+ queue.forEach(function (fn) { try { fn(ok); } catch (e) { /* ignore */ } });
63
+ }
64
+ var existing = document.querySelector('script[src*="cytoscape"]');
65
+ if (existing) {
66
+ if (typeof window.cytoscape === 'function') return flush(true);
67
+ existing.addEventListener('load', function () { flush(true); });
68
+ existing.addEventListener('error', function () { flush(false); });
69
+ return;
70
+ }
71
+ var s = document.createElement('script');
72
+ s.src = cytoscapeSrc();
73
+ s.defer = true;
74
+ s.onload = function () { flush(true); };
75
+ s.onerror = function () { flush(false); };
76
+ document.head.appendChild(s);
77
+ }
78
+
37
79
  function buildLookup(entries) {
38
80
  var byKey = Object.create(null);
39
81
  entries.forEach(function (entry) {
@@ -211,6 +253,8 @@
211
253
 
212
254
  var theme = readTheme();
213
255
  container.style.backgroundColor = theme.canvasBg;
256
+ var nodeDur = prefersReducedMotion() ? '0ms' : '160ms';
257
+ var edgeDur = prefersReducedMotion() ? '0ms' : '150ms';
214
258
 
215
259
  var cy = window.cytoscape({
216
260
  container: container,
@@ -245,7 +289,7 @@
245
289
  'border-color': theme.nodeBorder,
246
290
  'opacity': 0.88,
247
291
  'transition-property': 'background-color, border-color, width, height, text-opacity, opacity',
248
- 'transition-duration': '160ms'
292
+ 'transition-duration': nodeDur
249
293
  }
250
294
  },
251
295
  {
@@ -279,7 +323,7 @@
279
323
  'arrow-scale': 0.75,
280
324
  'opacity': 0.55,
281
325
  'transition-property': 'line-color, width, opacity',
282
- 'transition-duration': '150ms'
326
+ 'transition-duration': edgeDur
283
327
  }
284
328
  },
285
329
  {
@@ -409,18 +453,9 @@
409
453
  function applyOrphansVisibility(cy, show) {
410
454
  if (!cy) return;
411
455
  var orphans = cy.nodes().filter(function (n) { return n.degree(false) === 0; });
412
- if (show) {
413
- orphans.style('display', 'element');
414
- } else {
415
- orphans.style('display', 'none');
416
- }
417
- // Re-run a quick layout pass on visible elements so the connected
418
- // cluster expands into the freed space.
419
- cy.layout(Object.assign({}, COSE_LAYOUT, {
420
- randomize: false,
421
- numIter: 1400,
422
- eles: cy.elements(':visible')
423
- })).run();
456
+ orphans.style('display', show ? 'element' : 'none');
457
+ // The initial COSE pass already positioned every node, so just refit the
458
+ // visible set — avoids a jarring full relayout of the stable core on toggle.
424
459
  cy.fit(cy.elements(':visible'), 80);
425
460
  }
426
461
 
@@ -467,13 +502,23 @@
467
502
  var byKey = buildLookup(entries);
468
503
  var elements = buildElements(entries, byKey);
469
504
  computeNodeDegree(elements);
470
- container.innerHTML = '';
471
- setStats(entries, elements);
472
- var cy = renderGraph(container, elements);
473
- wireSearch(cy, byKey);
474
- wireFitButton(cy);
475
- wireOrphansToggle(cy);
476
- window.ObsidianGraph = { cy: cy, byKey: byKey, entries: entries };
505
+ loadCytoscape(function (ok) {
506
+ if (!ok) {
507
+ container.innerHTML =
508
+ '<div class="alert alert-danger" role="alert">' +
509
+ 'Graph view failed to load: the <code>cytoscape</code> library could ' +
510
+ 'not be fetched. Check your network connection or content security policy.' +
511
+ '</div>';
512
+ return;
513
+ }
514
+ container.innerHTML = '';
515
+ setStats(entries, elements);
516
+ var cy = renderGraph(container, elements);
517
+ wireSearch(cy, byKey);
518
+ wireFitButton(cy);
519
+ wireOrphansToggle(cy);
520
+ window.ObsidianGraph = { cy: cy, byKey: byKey, entries: entries };
521
+ });
477
522
  })
478
523
  .catch(function (err) {
479
524
  container.innerHTML =