datalog-theme 0.10.0 → 0.11.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +55 -3
  6. data/_data/i18n/es.yml +55 -3
  7. data/_data/i18n/pt.yml +55 -3
  8. data/_includes/analytics/dashboard.html +24 -9
  9. data/_includes/components/academic-dashboard.html +168 -133
  10. data/_includes/components/author-bio.html +7 -5
  11. data/_includes/components/author-list.html +6 -5
  12. data/_includes/components/citation-tools.html +2 -1
  13. data/_includes/components/content-provenance.html +19 -1
  14. data/_includes/components/correction-report.html +15 -8
  15. data/_includes/components/hero.html +22 -15
  16. data/_includes/components/package-index.html +121 -0
  17. data/_includes/components/package-install.html +27 -50
  18. data/_includes/components/post-list.html +14 -0
  19. data/_includes/components/responsive-image.html +15 -21
  20. data/_includes/footer.html +1 -1
  21. data/_includes/head.html +70 -11
  22. data/_includes/layouts/default/article.html +76 -46
  23. data/_includes/meta/dataset-json.html +171 -0
  24. data/_includes/meta/math-config.html +15 -1
  25. data/_includes/meta/package-json.html +69 -0
  26. data/_includes/meta/person-json.html +36 -13
  27. data/_includes/meta/publisher.html +15 -0
  28. data/_includes/meta/schema.html +108 -23
  29. data/_includes/meta/scholarly.html +10 -5
  30. data/_includes/post/related-posts.html +17 -43
  31. data/_includes/scripts.html +2 -2
  32. data/_includes/search/index-data.json +10 -1
  33. data/_includes/search/page.html +11 -6
  34. data/_layouts/archive.html +67 -0
  35. data/_layouts/dataset.html +2 -1
  36. data/_layouts/default.html +13 -11
  37. data/_layouts/docs.html +53 -0
  38. data/_layouts/home.html +30 -1
  39. data/_layouts/notebook.html +5 -1
  40. data/_layouts/package.html +38 -21
  41. data/_layouts/portfolio.html +1 -0
  42. data/_layouts/post.html +21 -62
  43. data/_layouts/project.html +2 -1
  44. data/_layouts/research.html +14 -2
  45. data/_plugins/analytics_dashboard.rb +4 -1
  46. data/_plugins/archive.rb +114 -0
  47. data/_plugins/authors.rb +50 -1
  48. data/_plugins/citation_exports.rb +12 -1
  49. data/_plugins/config_validator.rb +77 -2
  50. data/_plugins/correction_fallback.rb +110 -0
  51. data/_plugins/image_optimizer.rb +129 -22
  52. data/_plugins/math_preprocessor.rb +7 -48
  53. data/_plugins/notebook_converter.rb +44 -5
  54. data/_plugins/packages.rb +84 -0
  55. data/_plugins/page_dates.rb +37 -0
  56. data/_plugins/references.rb +16 -2
  57. data/_plugins/related_posts.rb +149 -0
  58. data/_plugins/search_sections.rb +110 -0
  59. data/_plugins/site_identity.rb +42 -0
  60. data/_plugins/social_cards.rb +17 -0
  61. data/_sass/_academic-dashboard.scss +18 -30
  62. data/_sass/_base.scss +2 -0
  63. data/_sass/_components.scss +21 -23
  64. data/_sass/_docs.scss +143 -0
  65. data/_sass/_features.scss +6 -0
  66. data/_sass/_layout.scss +65 -6
  67. data/_sass/_mathematical.scss +61 -19
  68. data/_sass/_notebooks.scss +1 -32
  69. data/_sass/_package-docs.scss +6 -9
  70. data/_sass/_post-components.scss +23 -23
  71. data/_sass/_print.scss +1 -1
  72. data/_sass/_search-page.scss +75 -0
  73. data/_sass/_search.scss +17 -26
  74. data/_sass/_syntax-highlighting.scss +23 -1
  75. data/_sass/_theme.scss +1 -0
  76. data/_sass/_utilities.scss +87 -40
  77. data/_sass/_variables.scss +31 -29
  78. data/assets/css/main.scss +2 -1
  79. data/assets/js/dist/academic.js +1 -1
  80. data/assets/js/dist/analytics-dashboard.js +1 -1
  81. data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
  82. data/assets/js/dist/comments.js +1 -1
  83. data/assets/js/dist/contact.js +1 -1
  84. data/assets/js/dist/core.js +1 -1
  85. data/assets/js/dist/corrections.js +5 -1
  86. data/assets/js/dist/loader.js +1 -1
  87. data/assets/js/dist/math.js +2 -1
  88. data/assets/js/dist/moderation.js +1 -1
  89. data/assets/js/dist/reactions.js +1 -1
  90. data/assets/js/dist/search.js +1 -1
  91. data/assets/js/dist/sources.json +20 -16
  92. data/assets/js/dist/subscriptions.js +1 -1
  93. data/assets/js/loader.js +6 -2
  94. data/datalog-theme.gemspec +1 -1
  95. data/lib/datalog/audit/checks.rb +269 -0
  96. data/lib/datalog/audit/known_keys.rb +61 -0
  97. data/lib/datalog/audit/site_reader.rb +118 -0
  98. data/lib/datalog/audit/source_file.rb +85 -0
  99. data/lib/datalog/audit.rb +145 -0
  100. data/lib/datalog/citations/bibtex.rb +200 -0
  101. data/lib/datalog/citations/entry.rb +333 -0
  102. data/lib/datalog/citations/markup.rb +79 -0
  103. data/lib/datalog/cli.rb +164 -89
  104. data/lib/datalog/critical_css.rb +126 -21
  105. data/lib/datalog/latex_speech/words.json +88 -0
  106. data/lib/datalog/latex_speech.rb +355 -0
  107. data/lib/datalog/packages/command.rb +51 -0
  108. data/lib/datalog/packages/refresh.rb +187 -0
  109. data/lib/datalog/packages.rb +177 -0
  110. data/lib/datalog/plugin_system.rb +5 -0
  111. data/lib/datalog/plugins/citations.rb +325 -109
  112. data/lib/datalog/site_config.rb +26 -0
  113. data/lib/datalog/social_cards/font.rb +286 -0
  114. data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
  115. data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
  116. data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
  117. data/lib/datalog/social_cards/geometry.rb +51 -0
  118. data/lib/datalog/social_cards/outline.rb +54 -0
  119. data/lib/datalog/social_cards/plain_text.rb +258 -0
  120. data/lib/datalog/social_cards/template.rb +322 -0
  121. data/lib/datalog/social_cards/template.svg +17 -0
  122. data/lib/datalog/social_cards/typesetter.rb +178 -0
  123. data/lib/datalog/social_cards.rb +369 -0
  124. data/lib/datalog/theme/updater.rb +307 -0
  125. data/lib/datalog/theme/version.rb +1 -1
  126. metadata +46 -9
  127. data/_includes/components/enhanced-code-block.html +0 -212
  128. data/_includes/components/performance-monitor.html +0 -170
  129. data/_includes/components/viz-table-fallback.html +0 -19
  130. data/_plugins/datalog_bibliography.rb +0 -21
  131. data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +0 -1
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Datalog
6
+ # The date a page was published, when it has one. Jekyll gives a collection
7
+ # document without a `date:` the time of the build, so `page.date` cannot
8
+ # tell a dated page from an undated one, and an undated package, dataset or
9
+ # notebook was "published" again by every build (#411). A post's date, from
10
+ # its file name, and any `date:` in front matter are real.
11
+ module PageDates
12
+ module_function
13
+
14
+ # The page's date, or nil when it has none or its date is the build's.
15
+ def published(page, build_time)
16
+ date = page.respond_to?(:[]) && !page.is_a?(String) ? page["date"] : nil
17
+ return if date.nil? || date.to_s.strip.empty?
18
+
19
+ time = date.respond_to?(:to_time) ? date.to_time : Time.parse(date.to_s)
20
+ return if build_time && time.to_i == build_time.to_i
21
+
22
+ date
23
+ rescue ArgumentError
24
+ nil
25
+ end
26
+ end
27
+
28
+ module PageDateFilters
29
+ # `{{ page | published_date | localize_date }}`, nil for an undated page.
30
+ def published_date(page)
31
+ site = @context.registers[:site]
32
+ PageDates.published(page, site&.time)
33
+ end
34
+ end
35
+ end
36
+
37
+ Liquid::Template.register_filter(Datalog::PageDateFilters)
@@ -196,15 +196,29 @@ module Datalog
196
196
  raise Liquid::ArgumentError, "{% figure id=\"#{id}\" %} needs alt text for its image"
197
197
  end
198
198
 
199
- src = "#{context.registers[:site].config['baseurl'].to_s.chomp('/')}#{src}" if src.start_with?("/")
199
+ baseurl = context.registers[:site].config["baseurl"].to_s.chomp("/")
200
+ src = "#{baseurl}#{src}" if src.start_with?("/")
200
201
  caption = References.inline(References.markdown(context, super))
201
202
  classes = ["datalog-figure", attributes["class"]].compact.join(" ")
202
203
 
203
204
  alt = CGI.escapeHTML(attributes["alt"])
205
+ dark = dark_source(attributes, baseurl)
204
206
  %(<figure class="#{CGI.escapeHTML(classes)}" id="#{id}" data-ref-target="#{id}" data-ref-kind="figure">) +
205
- %(<img src="#{CGI.escapeHTML(src)}" alt="#{alt}" loading="lazy" decoding="async">) +
207
+ %(<img src="#{CGI.escapeHTML(src)}" alt="#{alt}" loading="lazy" decoding="async"#{dark}>) +
206
208
  %(<figcaption><span class="datalog-ref-label" data-ref-for="#{id}"></span> #{caption}</figcaption></figure>\n)
207
209
  end
210
+
211
+ # The same figure exported for a dark page, as `dark_src`. The image
212
+ # pipeline turns it into a <source media="(prefers-color-scheme: dark)">
213
+ # with the variants it built for it. The alt text stays on the one <img>:
214
+ # it is the same figure, and describing it twice would be wrong.
215
+ def dark_source(attributes, baseurl)
216
+ dark = attributes["dark_src"].to_s.strip
217
+ return "" if dark.empty?
218
+
219
+ dark = "#{baseurl}#{dark}" if dark.start_with?("/")
220
+ %( data-dark-src="#{CGI.escapeHTML(dark)}")
221
+ end
208
222
  end
209
223
 
210
224
  class TableTag < Liquid::Block
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ module RelatedPosts
5
+ # Select recommendations once after Jekyll has read and normalized series.
6
+ # Each field has an inverted index, so a post visits matching candidates
7
+ # rather than comparing itself with every other post on the site.
8
+ class Index
9
+ FIELDS = %w[tags categories keywords].freeze
10
+ DEFAULT_WEIGHTS = { "tags" => 3.0, "categories" => 0.35, "keywords" => 1.5, "text" => 0.0 }.freeze
11
+
12
+ def initialize(site)
13
+ @posts = site.posts.docs
14
+ settings = site.config["related_posts"].is_a?(Hash) ? site.config["related_posts"] : {}
15
+ @limit = Integer(settings.fetch("limit", 3))
16
+ @min_score = Float(settings.fetch("min_score", 0.1))
17
+ @weights = DEFAULT_WEIGHTS.merge(string_keys(settings["weights"] || {}))
18
+ @weights.transform_values! { |value| Float(value) }
19
+ @terms = {}
20
+ @index = Hash.new { |hash, field| hash[field] = Hash.new { |terms, term| terms[term] = [] } }
21
+ @lookup = Hash.new { |hash, key| hash[key] = [] }
22
+ @excluded = @posts.to_h { |post| [post, post.data["related"] == false] }
23
+ index_posts
24
+ end
25
+
26
+ def assign!
27
+ results = @posts.to_h do |post|
28
+ [post, post.data["related_posts"] == false ? [] : recommendations(post)]
29
+ end
30
+ results.each { |post, recommendations| post.data["related"] = recommendations.map { |item| summary(item) } }
31
+ end
32
+
33
+ def recommendations(post)
34
+ selected = []
35
+ used_series = {}
36
+ manual = Array(post.data["related_posts"]).map { |pick| resolve(post, pick) }
37
+ manual.each do |candidate|
38
+ validate_pick!(post, candidate)
39
+ add(selected, used_series, candidate)
40
+ end
41
+ return selected.first(@limit) if selected.size >= @limit
42
+
43
+ ranked = scores(post).sort_by { |candidate, score| [-score, -candidate.date.to_i, candidate.url] }
44
+ ranked.each do |candidate, score|
45
+ next if score < @min_score || !eligible?(post, candidate)
46
+
47
+ add(selected, used_series, candidate)
48
+ break if selected.size >= @limit
49
+ end
50
+ selected
51
+ end
52
+
53
+ private
54
+
55
+ def index_posts
56
+ @posts.each do |post|
57
+ @terms[post] = {}
58
+ fields = FIELDS + (@weights["text"].positive? ? ["text"] : [])
59
+ fields.each do |field|
60
+ terms = field == "text" ? text_terms(post) : terms_for(post.data[field])
61
+ @terms[post][field] = terms
62
+ terms.each { |term| @index[field][term] << post }
63
+ end
64
+ [post.url, post.relative_path, post.data["title"]].compact.each do |key|
65
+ @lookup[key.to_s.strip.downcase] << post
66
+ end
67
+ end
68
+ end
69
+
70
+ def scores(post)
71
+ scores = Hash.new(0.0)
72
+ @terms.fetch(post).each do |field, terms|
73
+ weight = @weights.fetch(field)
74
+ next if weight.zero?
75
+
76
+ terms.each do |term|
77
+ matches = @index[field][term]
78
+ rarity = field == "tags" ? Math.log((@posts.size + 1.0) / (matches.size + 1)) + 1.0 : 1.0
79
+ matches.each { |candidate| scores[candidate] += weight * rarity unless candidate.equal?(post) }
80
+ end
81
+ end
82
+ scores
83
+ end
84
+
85
+ def resolve(post, pick)
86
+ key = pick.to_s.strip.downcase
87
+ matches = @lookup[key].uniq
88
+ return matches.first if matches.one?
89
+
90
+ problem = matches.empty? ? "matches no post" : "matches several posts; use a URL or path"
91
+ raise Jekyll::Errors::FatalException, "#{post.relative_path} related_posts pick #{pick.inspect} #{problem}"
92
+ end
93
+
94
+ def validate_pick!(post, candidate)
95
+ return if eligible?(post, candidate)
96
+
97
+ raise Jekyll::Errors::FatalException,
98
+ "#{post.relative_path} related_posts pick #{candidate.url.inspect} is the post itself, " \
99
+ "excluded, or already appears in its series navigation"
100
+ end
101
+
102
+ def eligible?(post, candidate)
103
+ own_series = series_id(post)
104
+ !candidate.equal?(post) && !@excluded[candidate] && (own_series.nil? || series_id(candidate) != own_series)
105
+ end
106
+
107
+ def add(selected, used_series, candidate)
108
+ id = series_id(candidate)
109
+ return if selected.include?(candidate) || (id && used_series[id])
110
+
111
+ selected << candidate
112
+ used_series[id] = true if id
113
+ end
114
+
115
+ def series_id(post)
116
+ value = post.data["series"]
117
+ value["id"] if value.is_a?(Hash)
118
+ end
119
+
120
+ def terms_for(value)
121
+ Array(value).map { |term| term.to_s.strip.downcase }.reject(&:empty?).uniq
122
+ end
123
+
124
+ def text_terms(post)
125
+ text = "#{post.data['title']} #{post.data['excerpt']}"
126
+ text.downcase.scan(/[[:alnum:]]{3,}/).uniq
127
+ end
128
+
129
+ def string_keys(value)
130
+ value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
131
+ end
132
+
133
+ def summary(post)
134
+ excerpt = post.data["excerpt"] || (post.excerpt if post.respond_to?(:excerpt))
135
+ { "title" => post.data["title"], "url" => post.url, "teaser" => post.data["teaser"],
136
+ "date" => post.date, "excerpt" => excerpt.to_s, "tags" => post.data["tags"] }
137
+ end
138
+ end
139
+ end
140
+
141
+ class RelatedPostsGenerator < Jekyll::Generator
142
+ safe true
143
+ priority :low
144
+
145
+ def generate(site)
146
+ RelatedPosts::Index.new(site).assign!
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Datalog
6
+ # Splits a rendered document into the passages a reader would recognise: the
7
+ # text under each h2 or h3, keyed by the id the heading already carries.
8
+ #
9
+ # A search result used to point at a whole page. For the long methods posts
10
+ # this theme is built for, that hands the reader a second search problem:
11
+ # find the paragraph. The headings and their ids were already in the page —
12
+ # kramdown writes them, the contents list links to them — and the index was
13
+ # the only thing that never recorded which passage sat under which (#336).
14
+ module SearchSections
15
+ HEADINGS = %w[h2 h3].freeze
16
+ # Their text is markup, not prose: a MathJax configuration block or a
17
+ # JSON-LD blob is not something anyone searches for.
18
+ IGNORED = %w[script style template].freeze
19
+ # What cannot sit in a URL fragment. A page whose Liquid has not run when
20
+ # the index renders can carry a heading id of "{{ group_name | slugify }}",
21
+ # and a link to that fragment would go nowhere; the section still indexes,
22
+ # pointing at the page.
23
+ UNUSABLE_ANCHOR = %r{[\s"'<>{}#%\\/]}
24
+
25
+ module_function
26
+
27
+ # Takes the document's own rendered HTML, not the finished page, so the
28
+ # headings a layout puts around the article — "About this post", "Reading
29
+ # notes" — cannot turn up as a section of every result.
30
+ def split(html)
31
+ sections = [section_for(nil)]
32
+ collect(Nokogiri::HTML5.fragment(html.to_s), sections)
33
+ sections.filter_map { |section| finish(section) }
34
+ end
35
+
36
+ # Walks in document order, opening a section at every heading. An element
37
+ # holding a heading somewhere inside it is walked into rather than taken
38
+ # whole, so a wrapper around part of the article does not swallow the
39
+ # boundaries within it.
40
+ def collect(node, sections)
41
+ node.children.each do |child|
42
+ next unless child.element? || child.text?
43
+ next if child.element? && IGNORED.include?(child.name)
44
+
45
+ if child.element? && HEADINGS.include?(child.name)
46
+ sections << section_for(child)
47
+ elsif child.element? && child.css(HEADINGS.join(", ")).any?
48
+ collect(child, sections)
49
+ else
50
+ sections.last[:parts] << prose(child)
51
+ end
52
+ end
53
+ end
54
+
55
+ # An element taken whole may still hold a script or a style somewhere
56
+ # inside it — a visualization block carries the code that draws it — and
57
+ # that code is not prose. Liquid's `strip_html` drops those blocks with
58
+ # their contents, so the flattened text of the page does not have them
59
+ # either.
60
+ def prose(node)
61
+ return node.text unless node.element? && node.css(IGNORED.join(", ")).any?
62
+
63
+ copy = node.dup
64
+ copy.css(IGNORED.join(", ")).each(&:remove)
65
+ copy.text
66
+ end
67
+
68
+ def section_for(heading)
69
+ {
70
+ title: heading ? squeeze(heading.text) : "",
71
+ # A heading with no id — a site that turns kramdown's auto_ids off —
72
+ # still indexes; its section simply links to the page.
73
+ anchor: heading && present(heading["id"]),
74
+ level: heading ? heading.name[1].to_i : 0,
75
+ parts: []
76
+ }
77
+ end
78
+
79
+ # The lead paragraphs before the first heading are a section of their own,
80
+ # untitled; so is the whole of a page that has no headings at all.
81
+ def finish(section)
82
+ content = squeeze(section[:parts].join(" "))
83
+ return if section[:title].empty? && content.empty?
84
+
85
+ { "title" => section[:title], "anchor" => section[:anchor],
86
+ "level" => section[:level], "content" => content }
87
+ end
88
+
89
+ def squeeze(text)
90
+ text.to_s.gsub(/\s+/, " ").strip
91
+ end
92
+
93
+ def present(value)
94
+ value = value.to_s.strip
95
+ return if value.empty? || value.match?(UNUSABLE_ANCHOR)
96
+
97
+ value
98
+ end
99
+ end
100
+
101
+ # `{{ content | markdownify | search_sections }}` in the search index, over
102
+ # the same HTML the flattened text is taken from.
103
+ module SearchSectionsFilter
104
+ def search_sections(html)
105
+ SearchSections.split(html)
106
+ end
107
+ end
108
+ end
109
+
110
+ Liquid::Template.register_filter(Datalog::SearchSectionsFilter)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ # The site as an entity of its own, for the `WebSite` node that
5
+ # meta/schema.html writes on the homepage. Search engines read that node as
6
+ # the strongest statement of a site's name, and its `alternateName` as the
7
+ # other names people know it by, such as a handle (#322).
8
+ #
9
+ # Off unless `site_identity` in _config.yml asks for it, so a site that wrote
10
+ # its own WebSite JSON-LD does not end up with two that disagree:
11
+ #
12
+ # site_identity: true # the site's title is its name
13
+ # site_identity:
14
+ # name: Jane Doe # default: title
15
+ # alternate_names: [janedoe] # or alternate_name: janedoe
16
+ module SiteIdentity
17
+ module_function
18
+
19
+ # Nil when the site has not asked for it, else its name and other names.
20
+ def resolve(site)
21
+ config = Authors.value(site, "site_identity")
22
+ config = {} if config == true
23
+ return unless config.is_a?(Hash) && config["enabled"] != false
24
+
25
+ name = config["name"].to_s.strip
26
+ name = Authors.value(site, "title").to_s.strip if name.empty?
27
+ return if name.empty?
28
+
29
+ others = Authors.list(config, "alternate_names", "alternate_name")
30
+ { "name" => name, "alternate_names" => (others - [name]).uniq }
31
+ end
32
+ end
33
+
34
+ module SiteIdentityFilters
35
+ # `{% assign identity = site | site_identity %}`, nil when it is off.
36
+ def site_identity(_input = nil)
37
+ SiteIdentity.resolve(@context["site"])
38
+ end
39
+ end
40
+ end
41
+
42
+ Liquid::Template.register_filter(Datalog::SiteIdentityFilters)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authors"
4
+ require_relative "i18n"
5
+ require_relative "image_optimizer"
6
+ require_relative "../lib/datalog/social_cards"
7
+
8
+ # Draws the share cards after every other generator, so the pages that
9
+ # generators add are there, and the series and authors are resolved.
10
+ class Datalog::SocialCardsGenerator < Jekyll::Generator
11
+ safe true
12
+ priority :lowest
13
+
14
+ def generate(site)
15
+ Datalog::SocialCards.generate(site)
16
+ end
17
+ end
@@ -1,5 +1,6 @@
1
- // The academic dashboard (_includes/components/academic-dashboard.html): citation
2
- // metrics, bibliography export, and the submission, calendar and funding lists.
1
+ // The academic dashboard (_includes/components/academic-dashboard.html), which
2
+ // the demo renders at /academic/: profiles, citation metrics, the bibliography
3
+ // export, and the submission, calendar and funding lists.
3
4
  // _components.scss loads this only when features.academic_dashboard is on.
4
5
 
5
6
  @use 'sass:map';
@@ -13,21 +14,6 @@
13
14
  gap: 2.5rem;
14
15
  }
15
16
 
16
- .academic-dashboard__header {
17
- text-align: center;
18
- max-width: 60ch;
19
- margin: 0 auto;
20
- }
21
-
22
- .academic-dashboard__header h1 {
23
- font-size: clamp(1.8rem, 3vw, 2.4rem);
24
- margin-bottom: 0.5rem;
25
- }
26
-
27
- .academic-dashboard__header p {
28
- color: var(--muted-foreground, #4a5568);
29
- }
30
-
31
17
  .academic-dashboard__profiles {
32
18
  background: var(--surface-card, #fff);
33
19
  border-radius: 1.25rem;
@@ -47,10 +33,10 @@
47
33
  }
48
34
 
49
35
  .academic-profile {
50
- border: 1px solid rgba(12, 35, 64, 0.12);
36
+ border: 1px solid var(--color-border);
51
37
  border-radius: 1rem;
52
38
  padding: 1.5rem;
53
- background: rgba(12, 35, 64, 0.03);
39
+ background: var(--surface-elevated);
54
40
  display: grid;
55
41
  gap: 1rem;
56
42
  }
@@ -69,7 +55,7 @@
69
55
  .academic-profile a {
70
56
  font-size: 0.875rem;
71
57
  font-weight: 500;
72
- color: var(--accent, #3277f6);
58
+ color: var(--accent);
73
59
  }
74
60
 
75
61
  .academic-profile dl {
@@ -97,12 +83,13 @@
97
83
  gap: 1.5rem;
98
84
  border-radius: 1.25rem;
99
85
  padding: 2rem;
100
- background: rgba(50, 119, 246, 0.08);
86
+ background: rgba($color-accent-primary, 0.08);
101
87
  }
102
88
 
103
89
  .citation-metrics__totals {
104
90
  display: grid;
105
91
  gap: 1rem;
92
+ margin: 0;
106
93
  }
107
94
 
108
95
  @media (min-width: 40rem) {
@@ -112,7 +99,7 @@
112
99
  }
113
100
 
114
101
  .citation-metrics .metric {
115
- background: rgba(255, 255, 255, 0.8);
102
+ background: var(--surface-card);
116
103
  border-radius: 1rem;
117
104
  padding: 1rem;
118
105
  text-align: center;
@@ -133,7 +120,7 @@
133
120
  }
134
121
 
135
122
  .citation-metrics__details {
136
- background: rgba(255, 255, 255, 0.92);
123
+ background: var(--surface-card);
137
124
  border-radius: 1rem;
138
125
  padding: 1rem 1.5rem;
139
126
  }
@@ -164,7 +151,7 @@
164
151
  gap: 1rem;
165
152
  padding: 0.5rem 0.75rem;
166
153
  border-radius: 0.75rem;
167
- background: linear-gradient(90deg, rgba(50, 119, 246, 0.15) var(--timeline-value, 35%), transparent 0);
154
+ background: linear-gradient(90deg, rgba($color-accent-primary, 0.15) var(--timeline-value, 0%), transparent 0);
168
155
  }
169
156
 
170
157
  .bibliography-export {
@@ -177,9 +164,9 @@
177
164
  min-height: 16rem;
178
165
  font-family: var(--font-mono, "Fira Code", monospace);
179
166
  border-radius: 0.75rem;
180
- border: 1px solid rgba(12, 35, 64, 0.2);
167
+ border: 1px solid var(--color-border);
181
168
  padding: 1.25rem;
182
- background: rgba(12, 35, 64, 0.04);
169
+ background: var(--surface-elevated);
183
170
  }
184
171
 
185
172
  .bibliography-export__actions {
@@ -221,10 +208,10 @@
221
208
  .opportunity-list li,
222
209
  .mentorship-list li,
223
210
  .workflow-list li {
224
- border: 1px solid rgba(12, 35, 64, 0.12);
211
+ border: 1px solid var(--color-border);
225
212
  border-radius: 1rem;
226
213
  padding: 1.25rem;
227
- background: rgba(255, 255, 255, 0.92);
214
+ background: var(--surface-card);
228
215
  }
229
216
 
230
217
  .status-badge {
@@ -233,8 +220,8 @@
233
220
  gap: 0.4rem;
234
221
  padding: 0.2rem 0.65rem;
235
222
  border-radius: 999px;
236
- background: rgba(50, 119, 246, 0.15);
237
- color: var(--accent-foreground, #0b1d3d);
223
+ background: rgba($color-accent-primary, 0.15);
224
+ color: var(--color-accent-text);
238
225
  font-size: 0.85rem;
239
226
  font-weight: 600;
240
227
  }
@@ -255,6 +242,7 @@
255
242
  gap: 1.25rem;
256
243
  }
257
244
 
245
+
258
246
  @media (min-width: 48rem) {
259
247
  .networking-grid {
260
248
  grid-template-columns: repeat(2, minmax(0, 1fr));
data/_sass/_base.scss CHANGED
@@ -23,6 +23,7 @@ html {
23
23
  }
24
24
 
25
25
  body {
26
+ color-scheme: light;
26
27
  margin: 0;
27
28
  font-family: $font-family-sans;
28
29
  color: $color-ink-900;
@@ -33,6 +34,7 @@ body {
33
34
  }
34
35
 
35
36
  body.dark-mode {
37
+ color-scheme: dark;
36
38
  color: $dark-ink-900;
37
39
  background-color: $dark-surface-100;
38
40
  }
@@ -143,11 +143,11 @@ body.dark-mode .btn--ghost:hover {
143
143
  transition: all 0.2s ease;
144
144
 
145
145
  &--primary {
146
- background: var(--color-accent, #3b82f6);
147
- color: white;
146
+ background: var(--color-accent);
147
+ color: var(--accent-foreground, white);
148
148
 
149
149
  &:hover {
150
- background: var(--color-accent-dark, #2563eb);
150
+ background: var(--color-accent-dark);
151
151
  }
152
152
  }
153
153
 
@@ -968,11 +968,15 @@ body.dark-mode {
968
968
  box-shadow: 0 20px 48px rgba($dark-ink-900, 0.32);
969
969
  }
970
970
 
971
+ .notebook-article__label {
972
+ color: $dark-accent-primary;
973
+ }
974
+
971
975
  .notebook-article__summary,
972
976
  .notebook-article__meta,
973
977
  .notebook-sidebar__metadata dd,
974
978
  .notebook-sidebar__stats li {
975
- color: rgba($dark-ink-200, 0.9);
979
+ color: $dark-ink-500;
976
980
  }
977
981
 
978
982
  .notebook-article__meta span,
@@ -986,34 +990,28 @@ body.dark-mode {
986
990
  }
987
991
 
988
992
  .notebook-cell {
989
- background: rgba($dark-accent-primary, 0.14);
993
+ background: rgba($dark-accent-primary, 0.22);
990
994
  box-shadow: inset 0 0 0 1px rgba($dark-ink-900, 0.35);
991
995
  }
992
996
 
997
+ // Code and outputs sit a shade darker than their cell, as in the light
998
+ // theme. $dark-ink-900 is the lightest ink, so it lightened them instead.
993
999
  .notebook-cell__code {
994
- background: rgba($dark-ink-900, 0.45);
1000
+ background: rgba($dark-surface-100, 0.45);
995
1001
  }
996
1002
 
997
1003
  .notebook-output {
998
- background: rgba($dark-ink-900, 0.35);
1004
+ background: rgba($dark-surface-100, 0.35);
999
1005
  color: $dark-ink-050;
1000
1006
  }
1001
1007
 
1002
1008
  .notebook-output__warning {
1003
- color: rgba($dark-ink-100, 0.8);
1004
- }
1005
-
1006
- .notebook-output--widget {
1007
- color: $dark-accent-secondary;
1009
+ color: $dark-ink-300;
1008
1010
  }
1009
1011
 
1010
1012
  .notebook-output-plotly,
1011
1013
  .notebook-output-vega {
1012
- background: rgba($dark-ink-900, 0.32);
1013
- }
1014
-
1015
- .notebook-cell {
1016
- background: rgba($dark-accent-primary, 0.22);
1014
+ background: rgba($dark-surface-100, 0.32);
1017
1015
  }
1018
1016
 
1019
1017
  .reference-list li::before,
@@ -1112,7 +1110,7 @@ body.dark-mode {
1112
1110
 
1113
1111
  div {
1114
1112
  padding: 0.75rem;
1115
- background: rgba(50, 119, 246, 0.08);
1113
+ background: rgba($color-accent-primary, 0.08);
1116
1114
  border-radius: 0.75rem;
1117
1115
  text-align: center;
1118
1116
  }
@@ -1134,7 +1132,7 @@ body.dark-mode {
1134
1132
 
1135
1133
  body.dark-mode & {
1136
1134
  div {
1137
- background: rgba(50, 119, 246, 0.2);
1135
+ background: rgba($dark-accent-primary, 0.2);
1138
1136
  }
1139
1137
 
1140
1138
  dt {
@@ -1168,7 +1166,7 @@ body.dark-mode {
1168
1166
  align-items: center;
1169
1167
  padding: 0.25rem 0.75rem;
1170
1168
  border-radius: 999px;
1171
- background: rgba(50, 119, 246, 0.1);
1169
+ background: rgba($color-accent-primary, 0.1);
1172
1170
  color: inherit;
1173
1171
  text-decoration: none;
1174
1172
  font-size: 0.8125rem;
@@ -1176,16 +1174,16 @@ body.dark-mode {
1176
1174
 
1177
1175
  a:hover,
1178
1176
  a:focus-visible {
1179
- background: rgba(50, 119, 246, 0.2);
1177
+ background: rgba($color-accent-primary, 0.2);
1180
1178
  }
1181
1179
 
1182
1180
  body.dark-mode & a {
1183
- background: rgba(50, 119, 246, 0.25);
1181
+ background: rgba($dark-accent-primary, 0.25);
1184
1182
  }
1185
1183
 
1186
1184
  body.dark-mode & a:hover,
1187
1185
  body.dark-mode & a:focus-visible {
1188
- background: rgba(50, 119, 246, 0.35);
1186
+ background: rgba($dark-accent-primary, 0.35);
1189
1187
  }
1190
1188
  }
1191
1189