datalog-theme 0.6.1 → 0.7.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +3 -2
  5. data/_data/js_meta.json +29 -29
  6. data/_includes/components/author-bio.html +2 -2
  7. data/_includes/components/hero.html +19 -1
  8. data/_includes/head.html +5 -10
  9. data/_includes/header.html +1 -1
  10. data/_includes/meta/math-config.html +23 -1
  11. data/_includes/meta/scripts-loader.html +2 -1
  12. data/_includes/meta/syntax-config.html +19 -0
  13. data/_includes/scripts.html +1 -9
  14. data/_includes/search/index-data.json +156 -0
  15. data/_includes/search/page.html +136 -0
  16. data/_layouts/home.html +6 -0
  17. data/_plugins/math_preprocessor.rb +3 -0
  18. data/_plugins/notebook_converter.rb +0 -39
  19. data/_plugins/search_pages.rb +73 -0
  20. data/_sass/_base.scss +7 -1
  21. data/_sass/_components.scss +25 -3
  22. data/_sass/_font-fallbacks.scss +43 -0
  23. data/_sass/_header.scss +10 -0
  24. data/_sass/_layout.scss +17 -11
  25. data/_sass/_mathematical.scss +1 -1
  26. data/_sass/_phase1-enhancements.scss +45 -0
  27. data/_sass/_search.scss +1 -1
  28. data/_sass/_syntax-highlighting.scss +2 -2
  29. data/_sass/_theme.scss +14 -0
  30. data/_sass/_variables.scss +7 -4
  31. data/assets/img/hero-detail-640.webp +0 -0
  32. data/assets/img/hero-detail.webp +0 -0
  33. data/assets/img/social-card.png +0 -0
  34. data/assets/js/dist/academic.js +1 -0
  35. data/assets/js/dist/analytics-dashboard.js +1 -0
  36. data/assets/js/dist/core.js +1 -0
  37. data/assets/js/dist/loader.js +1 -0
  38. data/assets/js/dist/manifest.json +22 -0
  39. data/assets/js/dist/math.js +1 -0
  40. data/assets/js/dist/meta.json +371 -0
  41. data/assets/js/dist/notebook.js +1 -0
  42. data/assets/js/dist/search.js +1 -0
  43. data/assets/js/dist/visualizations.js +2 -0
  44. data/assets/js/search/filters.js +9 -2
  45. data/datalog-theme.gemspec +10 -2
  46. data/lib/datalog/cli.rb +19 -1
  47. data/lib/datalog/theme/version.rb +1 -1
  48. data/lib/datalog-theme.rb +16 -0
  49. metadata +34 -20
  50. data/_data/config/features.yml +0 -262
  51. data/_data/config/site.yml +0 -42
  52. data/_data/config/theme.yml +0 -181
  53. data/assets/img/20220607123041_detail.001.png +0 -0
@@ -0,0 +1,156 @@
1
+ {
2
+ "generated_at": "{{ 'now' | date_to_xmlschema }}",
3
+ "documents": [
4
+ {% assign known_langs = 'python,r,sql,julia,javascript,scala,go,java,typescript,html,css' | split: ',' %}
5
+ {% assign searchable = '' | split: '' %}
6
+ {% for post in site.posts %}
7
+ {% unless post.exclude_from_search %}
8
+ {% assign searchable = searchable | push: post %}
9
+ {% endunless %}
10
+ {% endfor %}
11
+ {% for page in site.pages %}
12
+ {% unless page.exclude_from_search or page.layout == nil or page.url contains 'search.json' %}
13
+ {% assign searchable = searchable | push: page %}
14
+ {% endunless %}
15
+ {% endfor %}
16
+ {% if site.portfolio %}
17
+ {% for project in site.portfolio %}
18
+ {% unless project.exclude_from_search %}
19
+ {% assign searchable = searchable | push: project %}
20
+ {% endunless %}
21
+ {% endfor %}
22
+ {% endif %}
23
+ {% if site.datasets %}
24
+ {% for dataset in site.datasets %}
25
+ {% unless dataset.exclude_from_search %}
26
+ {% assign searchable = searchable | push: dataset %}
27
+ {% endunless %}
28
+ {% endfor %}
29
+ {% endif %}
30
+ {% for doc in searchable %}
31
+ {% assign raw_tags = doc.tags | default: doc.topics %}
32
+ {% if raw_tags %}
33
+ {% assign tags = raw_tags %}
34
+ {% else %}
35
+ {% assign tags = '' | split: '' %}
36
+ {% endif %}
37
+ {% assign languages = '' | split: '' %}
38
+ {% if doc.languages %}
39
+ {% for lang in doc.languages %}
40
+ {% assign lang_normalized = lang | downcase %}
41
+ {% if known_langs contains lang_normalized %}
42
+ {% unless languages contains lang_normalized %}
43
+ {% assign languages = languages | push: lang_normalized %}
44
+ {% endunless %}
45
+ {% endif %}
46
+ {% endfor %}
47
+ {% elsif doc.language %}
48
+ {% assign lang_normalized = doc.language | downcase %}
49
+ {% if known_langs contains lang_normalized %}
50
+ {% assign languages = languages | push: lang_normalized %}
51
+ {% endif %}
52
+ {% elsif doc.tools %}
53
+ {% for tool in doc.tools %}
54
+ {% assign lang_normalized = tool | downcase %}
55
+ {% if known_langs contains lang_normalized %}
56
+ {% unless languages contains lang_normalized %}
57
+ {% assign languages = languages | push: lang_normalized %}
58
+ {% endunless %}
59
+ {% endif %}
60
+ {% endfor %}
61
+ {% elsif tags %}
62
+ {% for tag in tags %}
63
+ {% assign lang_normalized = tag | downcase %}
64
+ {% if known_langs contains lang_normalized %}
65
+ {% unless languages contains lang_normalized %}
66
+ {% assign languages = languages | push: lang_normalized %}
67
+ {% endunless %}
68
+ {% endif %}
69
+ {% endfor %}
70
+ {% endif %}
71
+ {% assign raw_content = doc.content | default: '' %}
72
+ {% assign content_text = raw_content | markdownify | strip_html | normalize_whitespace %}
73
+ {% assign summary = doc.summary | default: doc.excerpt | default: content_text | truncate: 280, '' %}
74
+ {% assign inline_math_segments = raw_content | split: '$' %}
75
+ {% assign display_math_segments = raw_content | split: '$$' %}
76
+ {% capture doc_type %}
77
+ {% if doc.collection == 'posts' %}post{% elsif doc.collection == 'portfolio' %}project{% elsif doc.collection == 'datasets' %}dataset{% elsif doc.layout == 'research' %}research{% elsif doc.layout == 'project' %}project{% elsif doc.layout == 'post' %}post{% else %}{{ doc.collection | default: doc.layout | default: 'page' }}{% endif %}
78
+ {% endcapture %}
79
+ {% assign normalized_title = doc.title | default: doc.name | normalize_search %}
80
+ {% assign normalized_summary = summary | normalize_search %}
81
+ {% assign normalized_content = content_text | normalize_search %}
82
+ {% assign normalized_tags = tags | normalize_search_array %}
83
+ {% assign normalized_languages = languages | normalize_search_array %}
84
+ {% assign normalized_difficulty = doc.difficulty | default: doc.level | normalize_search | default: '' %}
85
+ {
86
+ "title": {{ doc.title | default: doc.name | jsonify }},
87
+ "url": {{ doc.url | relative_url | jsonify }},
88
+ "type": {{ doc_type | strip | jsonify }},
89
+ "summary": {{ summary | strip | jsonify }},
90
+ "content": {{ content_text | jsonify }},
91
+ "code": [
92
+ {% assign raw_blocks = raw_content | split: '```' %}
93
+ {% assign code_counter = 0 %}
94
+ {% for block in raw_blocks %}
95
+ {% assign block_index = forloop.index0 | modulo: 2 %}
96
+ {% if block_index == 1 %}
97
+ {% assign lines = block | split: '\n' %}
98
+ {% assign first_line = lines[0] | default: '' %}
99
+ {% assign language_hint = first_line | strip | downcase | default: 'text' %}
100
+ {% assign code_body = block | remove_first: first_line | strip %}
101
+ {% if code_counter > 0 %},{% endif %}
102
+ {
103
+ "language": {{ language_hint | jsonify }},
104
+ "code": {{ code_body | jsonify }}
105
+ }
106
+ {% assign code_counter = code_counter | plus: 1 %}
107
+ {% endif %}
108
+ {% endfor %}
109
+ ],
110
+ "math": [
111
+ {% assign math_counter = 0 %}
112
+ {% for piece in inline_math_segments %}
113
+ {% assign piece_index = forloop.index0 | modulo: 2 %}
114
+ {% if piece_index == 1 %}
115
+ {% assign cleaned = piece | strip %}
116
+ {% if cleaned != '' %}
117
+ {% if math_counter > 0 %},{% endif %}
118
+ {{ cleaned | jsonify }}
119
+ {% assign math_counter = math_counter | plus: 1 %}
120
+ {% endif %}
121
+ {% endif %}
122
+ {% endfor %}
123
+ {% for piece in display_math_segments %}
124
+ {% assign piece_index = forloop.index0 | modulo: 2 %}
125
+ {% if piece_index == 1 %}
126
+ {% assign cleaned = piece | strip %}
127
+ {% if cleaned != '' %}
128
+ {% if math_counter > 0 %},{% endif %}
129
+ {{ cleaned | jsonify }}
130
+ {% assign math_counter = math_counter | plus: 1 %}
131
+ {% endif %}
132
+ {% endif %}
133
+ {% endfor %}
134
+ ],
135
+ "tags": {{ tags | uniq | jsonify }},
136
+ "languages": {{ languages | uniq | jsonify }},
137
+ "difficulty": {{ doc.difficulty | default: doc.level | default: '' | jsonify }},
138
+ "topics": {{ doc.topics | default: tags | uniq | jsonify }},
139
+ "date": {% if doc.date %}{{ doc.date | date_to_xmlschema | jsonify }}{% else %}null{% endif %},
140
+ "reading_time": {{ doc.reading_time | default: doc.time | default: '' | jsonify }},
141
+ "normalized": {
142
+ "title": {{ normalized_title | jsonify }},
143
+ "summary": {{ normalized_summary | jsonify }},
144
+ "content": {{ normalized_content | jsonify }},
145
+ "tags": {{ normalized_tags | uniq | jsonify }},
146
+ "languages": {{ normalized_languages | uniq | jsonify }},
147
+ "difficulty": {{ normalized_difficulty | jsonify }}
148
+ }
149
+ {% assign search_extensions = doc.datalog_search_extensions %}
150
+ {% if search_extensions and search_extensions != empty %}
151
+ ,"extensions": {{ search_extensions | jsonify }}
152
+ {% endif %}
153
+ }{% unless forloop.last %},{% endunless %}
154
+ {% endfor %}
155
+ ]
156
+ }
@@ -0,0 +1,136 @@
1
+ <div class="search-app" data-search-app data-search-index="{{ '/search.json' | relative_url }}">
2
+ <div class="search-app__panel">
3
+ <form class="search-app__form" data-search-form novalidate role="search" aria-label="Technical content search">
4
+ <div class="search-app__query">
5
+ <label class="search-app__label" for="advanced-search-input">Query</label>
6
+ <div class="search-app__input-group">
7
+ <input
8
+ id="advanced-search-input"
9
+ class="search-app__input"
10
+ type="search"
11
+ name="q"
12
+ placeholder="Search code, LaTeX, datasets, and more"
13
+ autocomplete="off"
14
+ spellcheck="false"
15
+ data-search-input
16
+ aria-describedby="search-shortcuts"
17
+ aria-activedescendant=""
18
+ />
19
+ <button type="submit" class="search-app__submit">Search</button>
20
+ </div>
21
+ <p id="search-shortcuts" class="search-app__shortcuts" data-search-shortcuts>
22
+ Keyboard shortcuts: <kbd>/</kbd> or <kbd>Ctrl</kbd> + <kbd>K</kbd> to focus, <kbd>Enter</kbd> to run, <kbd>Esc</kbd> to clear.
23
+ </p>
24
+ <div class="search-app__autocomplete" data-search-autocomplete role="listbox" aria-label="Search suggestions"></div>
25
+ </div>
26
+ <fieldset class="search-app__filters" data-search-filters>
27
+ <legend>Advanced filters</legend>
28
+ <div class="search-app__filters-grid">
29
+ <label class="search-filter">
30
+ <span class="search-filter__label">Content type</span>
31
+ <select data-filter-type>
32
+ <option value="">All content</option>
33
+ <option value="post">Tutorials &amp; blog posts</option>
34
+ <option value="research">Research outputs</option>
35
+ <option value="project">Projects &amp; portfolio</option>
36
+ <option value="dataset">Datasets</option>
37
+ <option value="page">Pages</option>
38
+ </select>
39
+ </label>
40
+ <label class="search-filter">
41
+ <span class="search-filter__label">Programming language</span>
42
+ <select data-filter-language>
43
+ <option value="">Any language</option>
44
+ <option value="python">Python</option>
45
+ <option value="r">R</option>
46
+ <option value="sql">SQL</option>
47
+ <option value="julia">Julia</option>
48
+ <option value="javascript">JavaScript</option>
49
+ </select>
50
+ </label>
51
+ <label class="search-filter">
52
+ <span class="search-filter__label">Difficulty</span>
53
+ <select data-filter-difficulty>
54
+ <option value="">All levels</option>
55
+ <option value="beginner">Beginner</option>
56
+ <option value="intermediate">Intermediate</option>
57
+ <option value="advanced">Advanced</option>
58
+ </select>
59
+ </label>
60
+ <div class="search-filter">
61
+ <span class="search-filter__label">Topics</span>
62
+ <div class="search-filter__tags" data-filter-tags role="group" aria-label="Filter by topic tags"></div>
63
+ </div>
64
+ </div>
65
+ </fieldset>
66
+ </form>
67
+ <aside class="search-app__analytics" data-search-analytics aria-live="polite">
68
+ <h2 class="search-app__analytics-title">Popular technical queries</h2>
69
+ <p class="search-app__analytics-empty">Search activity will appear here once queries are made.</p>
70
+ <ol class="search-app__analytics-list"></ol>
71
+ </aside>
72
+ </div>
73
+ <section class="search-app__results" aria-live="polite" data-search-region>
74
+ <header class="search-app__results-header">
75
+ <h2>Results</h2>
76
+ <p class="search-app__results-meta" data-search-meta>Enter a query to begin.</p>
77
+ <div class="search-app__loading" data-search-loading hidden>
78
+ <span class="search-app__spinner" aria-hidden="true"></span>
79
+ <span class="search-app__loading-text">Searching…</span>
80
+ </div>
81
+ <div class="visually-hidden" data-search-announcements>
82
+ <p aria-live="polite" data-search-live="count" hidden></p>
83
+ <p aria-live="polite" data-search-live="selection" hidden></p>
84
+ <p aria-live="polite" data-search-live="status" hidden></p>
85
+ </div>
86
+ </header>
87
+ <ol class="search-app__results-list" data-search-results role="listbox" aria-label="Search results"></ol>
88
+ <template id="search-result-template">
89
+ <li class="search-result" role="option" data-result-item>
90
+ <article class="search-result__card">
91
+ <header class="search-result__header">
92
+ <p class="search-result__meta">
93
+ <span class="search-result__type" data-result-type></span>
94
+ <span class="search-result__difficulty" data-result-difficulty></span>
95
+ <span class="search-result__language" data-result-languages></span>
96
+ </p>
97
+ <h3 class="search-result__title"><a data-result-link></a></h3>
98
+ <p class="search-result__summary" data-result-summary></p>
99
+ </header>
100
+ <div class="search-result__body">
101
+ <pre class="search-result__code language-none line-numbers" data-result-code><code></code></pre>
102
+ <div class="search-result__math" data-result-math hidden>
103
+ <div class="search-result__math-preview" data-result-math-preview aria-hidden="true"></div>
104
+ <pre class="search-result__math-source"><code data-result-math-code></code></pre>
105
+ <button type="button" class="search-result__math-copy" data-result-math-copy>
106
+ <span aria-hidden="true">⧉</span>
107
+ <span class="visually-hidden">Copy LaTeX</span>
108
+ </button>
109
+ </div>
110
+ <p class="search-result__excerpt" data-result-excerpt></p>
111
+ </div>
112
+ <footer class="search-result__footer">
113
+ <ul class="search-result__tags" data-result-tags></ul>
114
+ <time class="search-result__date" data-result-date></time>
115
+ </footer>
116
+ </article>
117
+ </li>
118
+ </template>
119
+ <div class="search-app__no-results" data-search-empty hidden>
120
+ <div class="search-app__no-results-illustration" aria-hidden="true">
121
+ <svg viewBox="0 0 120 80" role="presentation" focusable="false">
122
+ <g fill="none" stroke="currentColor" stroke-width="2">
123
+ <circle cx="40" cy="34" r="18" opacity="0.35" />
124
+ <circle cx="72" cy="46" r="14" opacity="0.2" />
125
+ <path d="M28 58h64" stroke-linecap="round" opacity="0.45" />
126
+ <path d="M48 33l8 9 16-18" stroke-linecap="round" stroke-linejoin="round" />
127
+ </g>
128
+ </svg>
129
+ </div>
130
+ <p>No matching entries were found. Try refining your filters or adjusting the query.</p>
131
+ </div>
132
+ </section>
133
+ </div>
134
+ <noscript>
135
+ <p class="search-app__noscript">JavaScript is required for the interactive search experience. Please enable JavaScript to explore technical content.</p>
136
+ </noscript>
data/_layouts/home.html CHANGED
@@ -31,6 +31,11 @@ layout: default
31
31
  </div>
32
32
  </div>
33
33
  </section>
34
+ {% comment %}
35
+ The portfolio collection is optional: a site that has not declared it gets
36
+ nil here, and sorting nil raises rather than rendering an empty section.
37
+ {% endcomment %}
38
+ {% if site.portfolio and site.portfolio.size > 0 %}
34
39
  <section class="section section-portfolio">
35
40
  <div class="container">
36
41
  <h2 class="section-title">{% t 'home.featured_projects' %}</h2>
@@ -46,3 +51,4 @@ layout: default
46
51
  </div>
47
52
  </div>
48
53
  </section>
54
+ {% endif %}
@@ -165,6 +165,9 @@ module MathPreprocessor
165
165
  def apply(document)
166
166
  return unless document.respond_to?(:content)
167
167
  return unless document.respond_to?(:output_ext) && document.output_ext == ".html"
168
+ # A page that opts out of math rendering (`math: false` or `mathjax: false`)
169
+ # keeps its dollar signs and TeX-looking text verbatim.
170
+ return if document.respond_to?(:data) && (document.data["math"] == false || document.data["mathjax"] == false)
168
171
 
169
172
  content = document.content
170
173
  return unless content&.match?(/\$|\\\(|\\\[|\\begin\{/)
@@ -629,31 +629,9 @@ module Datalog
629
629
  nil
630
630
  end
631
631
 
632
- def patch_jupyter_converter!
633
- return unless defined?(JekyllJupyterNotebook::Converter)
634
- return if @converter_fallback_applied
635
-
636
- fallback = Module.new do
637
- def convert(content)
638
- super
639
- rescue Errno::ENOENT, StandardError => e
640
- Jekyll.logger.warn("notebook converter", "primary conversion failed: #{e.message}; using fallback renderer")
641
- Datalog::NotebookRenderer.render_from_raw(content) || ""
642
- end
643
- end
644
-
645
- JekyllJupyterNotebook::Converter.prepend(fallback)
646
- @converter_fallback_applied = true
647
- rescue StandardError => e
648
- Jekyll.logger.warn("notebook converter", "failed to apply converter fallback: #{e.message}")
649
- end
650
632
  end
651
633
  end
652
634
 
653
- Jekyll::Hooks.register :site, :after_init do |_site|
654
- Datalog::NotebookRenderer.patch_jupyter_converter!
655
- end
656
-
657
635
  module Jekyll
658
636
  # Converts Jupyter notebooks into HTML pages and downloadable assets.
659
637
  class NotebookConverter < Generator
@@ -671,8 +649,6 @@ module Jekyll
671
649
  return
672
650
  end
673
651
 
674
- ensure_dependency
675
-
676
652
  files = notebook_files
677
653
  logger.debug("notebook converter", "located #{files.size} notebooks")
678
654
  return if files.empty?
@@ -722,21 +698,6 @@ module Jekyll
722
698
  value.sub(%r{/+$}, "")
723
699
  end
724
700
 
725
- # Notebook pages do not need the gem (see #convert_notebook). When it is
726
- # present, its converter is given the same renderer as a fallback so the
727
- # gem's own `.ipynb` handling and `{% jupyter_notebook %}` tag keep working
728
- # on machines without a `jupyter` executable.
729
- def ensure_dependency
730
- return true if defined?(JekyllJupyterNotebook::Converter)
731
-
732
- require "jekyll-jupyter-notebook"
733
- Datalog::NotebookRenderer.patch_jupyter_converter!
734
- true
735
- rescue LoadError => e
736
- logger.debug("notebook converter", "jekyll-jupyter-notebook not loaded: #{e.message}")
737
- false
738
- end
739
-
740
701
  def notebook_files
741
702
  glob = File.join(site.source, config["source"], "**", "*.ipynb")
742
703
  Dir.glob(glob)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ module Datalog
6
+ # Gives sites that install the theme the two pages search needs.
7
+ #
8
+ # A theme gem can ship layouts, includes, sass and assets, but not pages: a
9
+ # site's pages come from its own source directory. The theme therefore
10
+ # shipped the search interface and its JavaScript while the page that renders
11
+ # it and the page that builds its index stayed behind in this repository, so
12
+ # search was missing entirely from every site using the gem.
13
+ #
14
+ # Both are generated here from the shipped includes, for any site with
15
+ # `features.search` enabled that has not already defined them itself.
16
+ class SearchPages < Jekyll::Generator
17
+ safe true
18
+ priority :low
19
+
20
+ INDEX_URL = "/search.json"
21
+ PAGE_URL = "/search/"
22
+
23
+ def generate(site)
24
+ return unless search_enabled?(site)
25
+
26
+ add_index(site) unless defines?(site, INDEX_URL)
27
+ add_page(site) unless defines?(site, PAGE_URL)
28
+ end
29
+
30
+ private
31
+
32
+ def search_enabled?(site)
33
+ features = site.config["features"]
34
+ features.is_a?(Hash) && features["search"]
35
+ end
36
+
37
+ # A site that ships its own search page or index keeps it untouched.
38
+ def defines?(site, url)
39
+ site.pages.any? { |page| page.url == url }
40
+ end
41
+
42
+ def add_index(site)
43
+ page = Jekyll::PageWithoutAFile.new(site, site.source, "", "search.json")
44
+ page.content = "{% include search/index-data.json %}"
45
+ page.data.merge!(
46
+ "layout" => nil,
47
+ "permalink" => INDEX_URL,
48
+ "sitemap" => false
49
+ )
50
+ site.pages << page
51
+ end
52
+
53
+ def add_page(site)
54
+ search_config = site.config["search"]
55
+ search_config = {} unless search_config.is_a?(Hash)
56
+
57
+ page = Jekyll::PageWithoutAFile.new(site, site.source, "search", "index.html")
58
+ page.content = "{% include search/page.html %}"
59
+ page.data.merge!(
60
+ "layout" => search_config["layout"] || "page",
61
+ "title" => search_config["title"] || "Search",
62
+ "permalink" => PAGE_URL,
63
+ "page_classes" => "search-page",
64
+ # Results can contain LaTeX and code, so both engines are wanted here
65
+ # even when the rest of the site loads them only where they appear.
66
+ "math" => true,
67
+ "syntax_highlighting" => true
68
+ )
69
+ page.data["subtitle"] = search_config["subtitle"] if search_config["subtitle"]
70
+ site.pages << page
71
+ end
72
+ end
73
+ end
data/_sass/_base.scss CHANGED
@@ -48,7 +48,7 @@ a {
48
48
 
49
49
  &:hover,
50
50
  &:focus {
51
- color: $color-accent-secondary;
51
+ color: $color-accent-secondary-text;
52
52
  }
53
53
  }
54
54
 
@@ -100,6 +100,12 @@ pre {
100
100
  z-index: $z-skiplink;
101
101
  transition: transform $transition-base;
102
102
 
103
+ // body.dark-mode a outranks .skip-link and would paint the text light blue
104
+ // on the blue pill (2:1); keep it white in both modes (5.2:1).
105
+ body.dark-mode & {
106
+ color: white;
107
+ }
108
+
103
109
  &:focus {
104
110
  transform: translate(-50%, 0);
105
111
  @include focus-ring(white);
@@ -109,6 +109,28 @@ body.dark-mode .btn-secondary {
109
109
  border-color: $dark-accent-primary;
110
110
  }
111
111
 
112
+ // `btn--ghost` is used in the markup but was never given a style, so those
113
+ // controls fell back to the browser's own button chrome: a light grey face
114
+ // that dark mode could not reach, leaving light text on it at 1.3:1. The
115
+ // theme tokens below follow whichever mode is active.
116
+ .btn--ghost {
117
+ background: transparent;
118
+ color: var(--color-text-primary);
119
+ border-color: var(--color-border);
120
+
121
+ &:hover {
122
+ background: rgba($color-accent-primary, 0.08);
123
+ border-color: $color-accent-primary;
124
+ color: $color-accent-primary;
125
+ }
126
+ }
127
+
128
+ body.dark-mode .btn--ghost:hover {
129
+ background: rgba($dark-accent-primary, 0.16);
130
+ border-color: $dark-accent-primary;
131
+ color: $dark-accent-primary;
132
+ }
133
+
112
134
  .stat {
113
135
  display: grid;
114
136
  gap: map.get($spacing, 1);
@@ -126,7 +148,7 @@ body.dark-mode .btn-secondary {
126
148
 
127
149
  .stat__value {
128
150
  font-size: 1.75rem;
129
- color: $color-accent-secondary;
151
+ color: $color-accent-secondary-text;
130
152
  }
131
153
 
132
154
  body.dark-mode .stat {
@@ -1148,7 +1170,7 @@ body.dark-mode {
1148
1170
 
1149
1171
  .notebook-output--widget {
1150
1172
  font-style: italic;
1151
- color: $color-accent-secondary;
1173
+ color: $color-accent-secondary-text;
1152
1174
  }
1153
1175
 
1154
1176
  .notebook-segment {
@@ -1569,7 +1591,7 @@ body.dark-mode {
1569
1591
  &:hover,
1570
1592
  &:focus-visible {
1571
1593
  background: rgba($color-accent-secondary, 0.16);
1572
- color: $color-accent-secondary;
1594
+ color: $color-accent-secondary-text;
1573
1595
  }
1574
1596
  }
1575
1597
 
@@ -0,0 +1,43 @@
1
+ // Metric-compatible local fallbacks for the IBM Plex web fonts.
2
+ //
3
+ // The Google Fonts stylesheet loads with `display=swap`, so text first paints
4
+ // in a local font and swaps once Plex arrives. These faces scale the local
5
+ // fonts to Plex's metrics (size-adjust from the average glyph width, the
6
+ // overrides from ascent, descent and line gap), so the swap barely moves the
7
+ // layout. Values derive from the fonts' metric tables (@capsizecss/metrics).
8
+
9
+ @font-face {
10
+ font-family: 'IBM Plex Sans Fallback';
11
+ src: local('Arial'), local('Liberation Sans'), local('Helvetica Neue'), local('Helvetica');
12
+ size-adjust: 101.17%;
13
+ ascent-override: 101.32%;
14
+ descent-override: 27.18%;
15
+ line-gap-override: 0%;
16
+ }
17
+
18
+ @font-face {
19
+ font-family: 'IBM Plex Serif Fallback';
20
+ src: local('Georgia');
21
+ size-adjust: 106.1%;
22
+ ascent-override: 96.61%;
23
+ descent-override: 25.92%;
24
+ line-gap-override: 0%;
25
+ }
26
+
27
+ @font-face {
28
+ font-family: 'IBM Plex Serif Fallback Times';
29
+ src: local('Times New Roman'), local('Liberation Serif'), local('Times');
30
+ size-adjust: 116.43%;
31
+ ascent-override: 88.04%;
32
+ descent-override: 23.62%;
33
+ line-gap-override: 0%;
34
+ }
35
+
36
+ @font-face {
37
+ font-family: 'IBM Plex Mono Fallback';
38
+ src: local('Courier New'), local('Liberation Mono'), local('Courier');
39
+ size-adjust: 99.98%;
40
+ ascent-override: 102.52%;
41
+ descent-override: 27.5%;
42
+ line-gap-override: 0%;
43
+ }
data/_sass/_header.scss CHANGED
@@ -226,6 +226,16 @@ body.dark-mode .header-control {
226
226
  }
227
227
  }
228
228
 
229
+ // The menu is collapsed in the markup so the page does not reflow (and animate
230
+ // the reflow) when the navigation script initialises. Without JavaScript
231
+ // nothing could open it, so keep it visible in that case; head.html adds the
232
+ // `js` class before the body renders.
233
+ html:not(.js) .site-nav {
234
+ max-height: none;
235
+ opacity: 1;
236
+ pointer-events: auto;
237
+ }
238
+
229
239
  .nav-list {
230
240
  display: flex;
231
241
  flex-direction: column;