utopia-project 0.36.0 → 0.37.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 684a750f24a6ee8464e5d0f9a8d97ddca456faad4370660a7cc89af4893f7e0e
4
- data.tar.gz: f01d30c525b3a4dacb0fb2533580a45cc437eac597f26a907ccdb2a1784708e7
3
+ metadata.gz: 75761109578cafa260705da53ebe35f0cdf617339e4291aa5e45ac0ee732a598
4
+ data.tar.gz: 01e21571bdb13616d8fe59618c83ee85d689f3519919adf980377118cfc912e6
5
5
  SHA512:
6
- metadata.gz: 56fa2bab4641e1731846878b80af87d21150b9b66cff416cf1a58969fbcd5b8baf688c1e70a5440cb33518b7147761050511e253c5a7705769d9b72b601656c3
7
- data.tar.gz: 8c1e81410e786b271a94a6850eb747b4908458565c116eeb3b62d9584cfd3fe0dcec8233cf7aeec7b549421e78513db7e07c623dcfdddf5e3ddcf395af89ec21
6
+ metadata.gz: 5083d276b08113645f82979721c42fd02167373ca4402aa236b96c16620f4b063f1e8ab910a3f4bd58bcaa079c506a38f4c861546eba054a377d772549de0619
7
+ data.tar.gz: f1df6623efe3335d6db6888a958d5dcc345e08279b2ca4f5d818d66328884ada55dcbcf87e4bfb8e4199284b309961f9c1b3d9f8d8e66bf5efc0dc2010e2dcd1
checksums.yaml.gz.sig CHANGED
Binary file
@@ -39,7 +39,7 @@ def serve(port: nil, bind: nil)
39
39
  end
40
40
 
41
41
  if port
42
- options << "--port" << port
42
+ options << "--port" << port.to_s
43
43
  end
44
44
 
45
45
  system("falcon", "serve", "--config", config_path, "--preload", preload_path, *options)
@@ -7,6 +7,8 @@ require "utopia/path"
7
7
  require "xrb/reference"
8
8
  require "decode"
9
9
 
10
+ require_relative "sidebar"
11
+
10
12
  module Utopia
11
13
  module Project
12
14
  # Provides structured access to a directory which contains documentation and source code to explain a specific process.
@@ -127,6 +129,12 @@ module Utopia
127
129
  @documentation ||= self.best_documentation
128
130
  end
129
131
 
132
+ # Generate a navigation from the guide's document.
133
+ # @returns [Sidebar]
134
+ def navigation
135
+ @navigation ||= Sidebar.build(self.document)
136
+ end
137
+
130
138
  # All files associated with this guide.
131
139
  # @returns [Array(String)] The file-system paths.
132
140
  def files
@@ -4,6 +4,7 @@
4
4
  # Copyright, 2024-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "document"
7
+ require_relative "sidebar"
7
8
 
8
9
  module Utopia
9
10
  module Project
@@ -107,6 +108,19 @@ module Utopia
107
108
  yield release(name)
108
109
  end
109
110
  end
111
+
112
+ # Generate a navigation from the releases.
113
+ # @returns [Sidebar]
114
+ def navigation
115
+ @navigation ||= begin
116
+ entries = release_names.map do |name|
117
+ anchor = name.downcase.gsub(/\s+/, "-")
118
+ Sidebar::Entry.new(name, 2, anchor)
119
+ end
120
+
121
+ Sidebar.new(entries)
122
+ end
123
+ end
110
124
  end
111
125
  end
112
126
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2020-2025, by Samuel Williams.
5
+
6
+ module Utopia
7
+ module Project
8
+ # Generates a sidebar navigation from markdown document headings.
9
+ class Sidebar
10
+ # Represents a sidebar navigation entry with title, level, and anchor.
11
+ class Entry
12
+ def initialize(title, level, anchor)
13
+ @title = title
14
+ @level = level
15
+ @anchor = anchor
16
+ end
17
+
18
+ # The text content of the heading.
19
+ # @attribute [String]
20
+ attr :title
21
+
22
+ # The heading level (1-6).
23
+ # @attribute [Integer]
24
+ attr :level
25
+
26
+ # The anchor ID for linking to this heading.
27
+ # @attribute [String]
28
+ attr :anchor
29
+ end
30
+ # Build a sidebar from a markdown document by extracting headings.
31
+ # @parameter document [Document] The document to extract headings from
32
+ # @returns [Sidebar]
33
+ def self.build(document)
34
+ entries = extract_headings_from_document(document)
35
+ new(entries)
36
+ end
37
+
38
+ # Initialize with an array of entries.
39
+ # @parameter entries [Array(Entry)] The navigation entries
40
+ def initialize(entries)
41
+ @entries = entries
42
+ end
43
+
44
+ # The navigation entries.
45
+ # @attribute [Array(Entry)]
46
+ attr :entries
47
+
48
+ # Check if there are any navigation entries.
49
+ # @returns [Boolean]
50
+ def any?
51
+ !entries.empty?
52
+ end
53
+
54
+ # Generate HTML markup for the sidebar navigation.
55
+ # @parameter sidebar [Boolean] Whether this is rendered in a sidebar layout (unused, kept for compatibility)
56
+ # @parameter title [String] The title for the navigation section
57
+ # @returns [XRB::MarkupString]
58
+ def to_html(sidebar: false, title: "Table of Contents")
59
+ return XRB::Markup.raw("") unless any?
60
+
61
+ XRB::Builder.fragment do |builder|
62
+ builder.tag :nav do
63
+ builder.tag :heading do
64
+ builder.text title
65
+ end
66
+ builder.tag :ul do
67
+ entries.each do |entry|
68
+ if entry.level > 2
69
+ builder.tag :li, {class: "level-#{entry.level}"} do
70
+ builder.tag :a, {href: "##{entry.anchor}"} do
71
+ builder.text entry.title
72
+ end
73
+ end
74
+ else
75
+ builder.tag :li do
76
+ builder.tag :a, {href: "##{entry.anchor}"} do
77
+ builder.text entry.title
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def self.extract_headings_from_document(document)
90
+ headings = []
91
+ return headings unless document&.root
92
+
93
+ document.root.walk do |node|
94
+ if node.type == :header
95
+ title = node.first_child&.to_plaintext
96
+ level = node.header_level
97
+ anchor = generate_anchor(title)
98
+
99
+ # Only include H2 and below in sidebar (skip H1 main title)
100
+ if title && !title.empty? && level >= 2
101
+ headings << Entry.new(title, level, anchor)
102
+ end
103
+ end
104
+ end
105
+
106
+ headings
107
+ end
108
+
109
+ def self.generate_anchor(title)
110
+ # Generate anchor ID exactly like Markly renderer does it
111
+ title.downcase
112
+ .gsub(/\s+/, "-") # Replace spaces with hyphens
113
+ .gsub("&", "&amp;") # HTML encode ampersand
114
+ .gsub('"', "&quot;") # HTML encode quotes
115
+ .gsub("'", "&#39;") # HTML encode single quotes
116
+ .gsub("<", "&lt;") # HTML encode less than
117
+ .gsub(">", "&gt;") # HTML encode greater than
118
+ end
119
+
120
+ end
121
+ end
122
+ end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Utopia
7
7
  module Project
8
- VERSION = "0.36.0"
8
+ VERSION = "0.37.0"
9
9
  end
10
10
  end
data/pages/_page.xnode CHANGED
@@ -39,10 +39,8 @@
39
39
  <body>
40
40
  <content:header />
41
41
 
42
- <main>
42
+ <main class="#{attributes[:class]}">
43
43
  <utopia:content/>
44
-
45
- <footer>Documentation generated by <a href="https://github.com/socketry/utopia-project">Utopia::Project</a>.</footer>
46
44
  </main>
47
45
  </body>
48
46
  </html>
@@ -1,24 +1,32 @@
1
- <content:page>
1
+ <content:page class="with-sidebar">
2
2
  <?r
3
3
  base = self[:base]
4
4
  guide = self[:guide]
5
+ navigation = guide.navigation
5
6
  ?>
6
- <content:heading>#{guide.title}</content:heading>
7
7
 
8
- <?r
9
- if document = guide.document
10
- ?>#{document.to_html}<?r
11
- end
8
+ <aside class="sidebar">
9
+ #{navigation.to_html}
10
+ </aside>
11
+ <div class="content">
12
+ <content:heading>#{guide.title}</content:heading>
12
13
 
13
- guide.sources.each do |source|
14
- ?><h2><code>#{File.basename source.path}</code></h2><?r
15
- source.segments.each do |segment|
16
- if documentation = segment.documentation
17
- ?>#{base.format(documentation.text, language: source.language)}<?r
14
+ <?r
15
+ if document = guide.document
16
+ ?>#{document.to_html}<?r
17
+ end
18
+
19
+ guide.sources.each do |source|
20
+ ?><h2><code>#{File.basename source.path}</code></h2><?r
21
+ source.segments.each do |segment|
22
+ if documentation = segment.documentation
23
+ ?>#{base.format(documentation.text, language: source.language)}<?r
24
+ end
25
+
26
+ ?><pre><code class="language-#{source.language.name}">#{segment.code}</code></pre><?r
18
27
  end
19
-
20
- ?><pre><code class="language-#{source.language.name}">#{segment.code}</code></pre><?r
21
28
  end
22
- end
23
- ?>
29
+ ?>
30
+ </div>
31
+ <script src="/_static/sidebar.js"></script>
24
32
  </content:page>
@@ -1,13 +1,18 @@
1
- <content:page>
2
- <?r
3
- if document = self[:document]
4
- ?>#{Markup.raw document.to_html}<?r
5
- else
6
- ?>
7
- <content:heading>Project</content:heading>
1
+ <?r if document = self[:document] ?>
2
+ <content:page class="with-sidebar">
3
+ <?r navigation = document.navigation ?>
4
+ <aside class="sidebar">
5
+ #{navigation.to_html(sidebar: true, title: "Releases")}
6
+ </aside>
7
+ <div class="content">
8
+ #{Markup.raw document.to_html}
9
+ </div>
10
+ <script src="/_static/sidebar.js"></script>
11
+ </content:page>
12
+ <?r else ?>
13
+ <content:page>
14
+ <content:heading>Releases</content:heading>
8
15
 
9
16
  <p>This project does not have a <code>releases.md</code> file.</p>
10
- <?r
11
- end
12
- ?>
13
- </content:page>
17
+ </content:page>
18
+ <?r end ?>
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Sidebar Navigation Highlighting
3
+ * Progressive enhancement for sidebar navigation with scroll tracking
4
+ */
5
+
6
+ (function() {
7
+ 'use strict';
8
+
9
+ // Only run if we have a sidebar navigation
10
+ const sidebarNav = document.querySelector('.sidebar nav');
11
+ if (!sidebarNav) return;
12
+
13
+ const navLinks = sidebarNav.querySelectorAll('a[href*="#"]');
14
+ const sections = Array.from(navLinks).map(link => {
15
+ const href = link.getAttribute('href');
16
+ // Extract fragment from both "#section" and "page.html#section" formats
17
+ const fragmentIndex = href.indexOf('#');
18
+ if (fragmentIndex === -1) return null;
19
+ const fragment = href.substring(fragmentIndex + 1);
20
+ // Try to find the section element using the fragment
21
+ let sectionElement = document.getElementById(fragment);
22
+
23
+ // If not found, try with CSS.escape for special characters
24
+ if (!sectionElement && fragment !== CSS.escape(fragment)) {
25
+ sectionElement = document.querySelector(`#${CSS.escape(fragment)}`);
26
+ }
27
+
28
+ // The element we found should be the section container, not just the heading
29
+ // If it's a heading, find its parent section
30
+ if (sectionElement && sectionElement.tagName.match(/^H[1-6]$/)) {
31
+ sectionElement = sectionElement.closest('section') || sectionElement.parentElement;
32
+ }
33
+
34
+ return { link, sectionElement, id: fragment };
35
+ }).filter(item => item && item.sectionElement);
36
+
37
+ if (sections.length === 0) return;
38
+
39
+ let currentActive = null;
40
+
41
+ function updateActiveLink(updatePageState = true) {
42
+ let activeSectionData = null;
43
+ let smallestValidBottom = Infinity;
44
+
45
+ // Find the section with the smallest bottom position that is still >= 0
46
+ // This gives us the section that we're currently reading through
47
+ sections.forEach((sectionData) => {
48
+ const { sectionElement } = sectionData;
49
+ const rect = sectionElement.getBoundingClientRect();
50
+ const sectionBottom = rect.bottom;
51
+
52
+ // Get the actual bottom padding for this section
53
+ const bottomPadding = parseFloat(getComputedStyle(sectionElement).paddingBottom);
54
+
55
+ // We want the section whose bottom is closest to the top but still visible
56
+ if (sectionBottom > bottomPadding && sectionBottom < smallestValidBottom) {
57
+ smallestValidBottom = sectionBottom;
58
+ activeSectionData = sectionData;
59
+ }
60
+ });
61
+
62
+ // If no section has bottom >= 0, fall back to the last section
63
+ // (meaning we've scrolled past all content)
64
+ if (!activeSectionData && sections.length > 0) {
65
+ activeSectionData = sections[sections.length - 1];
66
+ }
67
+
68
+ // Update active link
69
+ if (activeSectionData !== currentActive) {
70
+ // Remove previous active
71
+ if (currentActive) {
72
+ currentActive.link.classList.remove('active');
73
+ }
74
+
75
+ // Set new active
76
+ if (activeSectionData) {
77
+ activeSectionData.link.classList.add('active');
78
+ currentActive = activeSectionData;
79
+
80
+ if (updatePageState) {
81
+ // Update URL fragment to reflect current section
82
+ const newFragment = '#' + activeSectionData.id;
83
+ if (window.location.hash !== newFragment) {
84
+ history.replaceState(null, null, newFragment);
85
+ }
86
+
87
+ // Auto-scroll sidebar to keep active item visible
88
+ scrollToActiveItem(activeSectionData.link);
89
+ }
90
+ } else {
91
+ currentActive = null;
92
+
93
+ if (updatePageState) {
94
+ // Clear fragment if no active section
95
+ if (window.location.hash) {
96
+ history.replaceState(null, null, window.location.pathname);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ function scrollToActiveItem(activeLink) {
104
+ const sidebar = activeLink.closest('.sidebar');
105
+ if (!sidebar) return;
106
+
107
+ const sidebarRect = sidebar.getBoundingClientRect();
108
+ const linkRect = activeLink.getBoundingClientRect();
109
+
110
+ // Check if the active link is outside the visible area
111
+ const isAbove = linkRect.top < sidebarRect.top;
112
+ const isBelow = linkRect.bottom > sidebarRect.bottom;
113
+
114
+ if (isAbove || isBelow) {
115
+ // Calculate the ideal scroll position to center the active item
116
+ const sidebarScrollTop = sidebar.scrollTop;
117
+ const linkOffsetTop = linkRect.top - sidebarRect.top + sidebarScrollTop;
118
+ const sidebarHeight = sidebarRect.height;
119
+ const targetScrollTop = linkOffsetTop - (sidebarHeight / 2) + (linkRect.height / 2);
120
+
121
+ // Smooth scroll the sidebar
122
+ sidebar.scrollTo({
123
+ top: Math.max(0, targetScrollTop),
124
+ behavior: 'smooth'
125
+ });
126
+ }
127
+ }
128
+
129
+ // Throttled scroll handler
130
+ let scrollTimer = null;
131
+
132
+ function onScroll() {
133
+ if (scrollTimer) return;
134
+
135
+ scrollTimer = setTimeout(() => {
136
+ updateActiveLink();
137
+ scrollTimer = null;
138
+ }, 100);
139
+ }
140
+
141
+ // Initialize and set up event listeners
142
+ window.addEventListener('scroll', onScroll, { passive: true });
143
+ window.addEventListener('resize', updateActiveLink, { passive: true });
144
+
145
+ // Initial update without changing page state (preserves URL fragments):
146
+ updateActiveLink(false);
147
+
148
+ // Smooth scroll enhancement for sidebar navigation links
149
+ navLinks.forEach(link => {
150
+ link.addEventListener('click', (event) => {
151
+ const href = link.getAttribute('href');
152
+ // Extract fragment from both "#section" and "page.html#section" formats
153
+ const fragmentIndex = href.indexOf('#');
154
+ if (fragmentIndex === -1) return;
155
+ const fragment = href.substring(fragmentIndex + 1);
156
+
157
+ // Try to find the target using the fragment
158
+ let target = document.getElementById(fragment);
159
+
160
+ // If not found, try with CSS.escape for special characters
161
+ if (!target && fragment !== CSS.escape(fragment)) {
162
+ target = document.querySelector(`#${CSS.escape(fragment)}`);
163
+ }
164
+
165
+ if (target) {
166
+ event.preventDefault();
167
+
168
+ // Update URL to trigger :target and let browser handle scrolling
169
+ window.location.hash = fragment;
170
+ }
171
+
172
+ link.focus();
173
+ });
174
+ });
175
+ })();
@@ -103,21 +103,118 @@ main {
103
103
  max-width: 48rem;
104
104
  }
105
105
 
106
+ @media (min-width: 64em) {
107
+ main.with-sidebar {
108
+ display: grid;
109
+ grid-template-columns: 16rem 1fr;
110
+ gap: 1rem;
111
+ align-items: start;
112
+
113
+ /* 48rem content + 16rem sidebar + 1rem gap */
114
+ max-width: 65rem;
115
+ }
116
+
117
+ main.with-sidebar .content {
118
+ max-width: 48rem;
119
+ }
120
+ }
121
+
122
+ .sidebar {
123
+ display: none; /* Hidden on small screens */
124
+ }
125
+
126
+ @media (min-width: 64em) {
127
+ .sidebar {
128
+ display: block;
129
+ position: sticky;
130
+ top: 2rem;
131
+ max-height: calc(100vh - 4rem);
132
+ overflow-y: auto;
133
+
134
+ /* Custom scrollbar styling */
135
+ scrollbar-width: thin;
136
+ scrollbar-color: var(--underlay-color) transparent;
137
+ }
138
+ }
139
+
140
+ /* Sidebar navigation styles */
141
+ .sidebar nav {
142
+ background-color: transparent;
143
+ border: none;
144
+ border-radius: 0;
145
+ padding: 0;
146
+ margin: 1rem;
147
+ position: sticky;
148
+ top: 0;
149
+ }
150
+
151
+ .sidebar nav h2 {
152
+ font-size: 0.9rem;
153
+ margin-bottom: 1rem;
154
+ padding-bottom: 0.5rem;
155
+ border-bottom: 1px solid var(--underlay-color);
156
+ }
157
+
158
+ .sidebar nav ul {
159
+ margin: 0;
160
+ padding: 0;
161
+ list-style: none;
162
+ }
163
+
164
+ .sidebar nav li {
165
+ margin: 0.1rem 0;
166
+ }
167
+
168
+ .sidebar nav li.level-2 {
169
+ padding-left: 0;
170
+ }
171
+
172
+ .sidebar nav li.level-3 {
173
+ padding-left: 1rem;
174
+ }
175
+
176
+ .sidebar nav li.level-4 {
177
+ padding-left: 2rem;
178
+ }
179
+
180
+ .sidebar nav li.level-5 {
181
+ padding-left: 3rem;
182
+ }
183
+
184
+ .sidebar nav li.level-6 {
185
+ padding-left: 4rem;
186
+ }
187
+
188
+ .sidebar nav a {
189
+ padding: 0.4rem 0.75rem;
190
+ font-size: 0.9rem;
191
+ border-left: 3px solid transparent;
192
+ color: var(--main-color);
193
+ text-decoration: none;
194
+ display: block;
195
+ border-radius: 0.25rem;
196
+ transition: all 0.2s ease;
197
+ }
198
+
199
+ .sidebar nav a:hover {
200
+ border-left-color: var(--accent-color);
201
+ background-color: var(--header-color);
202
+ }
203
+
106
204
  main img {
107
205
  max-width: 100%;
108
206
  }
109
207
 
110
- :target {
111
- background-color: var(--header-color);
208
+ /* Ensure last section has enough height to be scrollable */
209
+ .content section:last-of-type {
210
+ min-height: 100vh;
112
211
  }
113
212
 
114
213
  section {
115
- border-radius: 1rem;
116
- padding: 0.1rem 0;
214
+ padding: 1rem 0;
117
215
  }
118
216
 
119
217
  main > section {
120
- border-radius: 1rem;
121
218
  margin: 4rem 0;
122
219
  }
123
220
 
@@ -223,7 +320,7 @@ summary {
223
320
  }
224
321
 
225
322
  details[open] {
226
- /* padding: .5rem; */
323
+ padding: .5rem;
227
324
  }
228
325
 
229
326
  details[open] summary {
@@ -313,3 +410,191 @@ ul.pragmas li.asynchronous {
313
410
  box-shadow: 0 0 0.3rem #8C00FF;
314
411
  color: #8C00FF;
315
412
  }
413
+
414
+ /* Navigation - Inline styles (boxed display for small screens) */
415
+ .inline-nav nav {
416
+ background-color: var(--header-color);
417
+ border-radius: 0.5rem;
418
+ padding: 1rem;
419
+ margin: 1rem;
420
+ border-left: 0.25rem solid var(--accent-color);
421
+ }
422
+
423
+ .inline-nav nav heading {
424
+ margin-top: 0;
425
+ margin-bottom: 0.5rem;
426
+ color: var(--accent-color);
427
+ font-size: 1rem;
428
+ display: block;
429
+ font-weight: 500;
430
+ }
431
+
432
+ .inline-nav nav ul {
433
+ margin: 0;
434
+ padding: 0;
435
+ list-style: none;
436
+ }
437
+
438
+ .inline-nav nav li {
439
+ margin: 0.25rem 0;
440
+ }
441
+
442
+ .inline-nav nav li.level-2 {
443
+ padding-left: 0;
444
+ }
445
+
446
+ .inline-nav nav li.level-3 {
447
+ padding-left: 1rem;
448
+ }
449
+
450
+ .inline-nav nav li.level-4 {
451
+ padding-left: 2rem;
452
+ }
453
+
454
+ .inline-nav nav li.level-5 {
455
+ padding-left: 3rem;
456
+ }
457
+
458
+ .inline-nav nav li.level-6 {
459
+ padding-left: 4rem;
460
+ }
461
+
462
+ .inline-nav nav a {
463
+ color: var(--main-color);
464
+ text-decoration: none;
465
+ display: block;
466
+ padding: 0.25rem 0.5rem;
467
+ border-radius: 0.25rem;
468
+ transition: all 0.2s ease;
469
+ }
470
+
471
+ .inline-nav nav a:hover {
472
+ color: var(--accent-hover-color);
473
+ background-color: var(--overlay-color);
474
+ text-decoration: none;
475
+ }
476
+
477
+ /* Sidebar navigation styles */
478
+ .sidebar nav,
479
+ .sidebar details nav {
480
+ background-color: transparent;
481
+ border: none;
482
+ border-radius: 0;
483
+ padding: 0;
484
+ margin: 1rem;
485
+ position: sticky;
486
+ top: 0;
487
+ }
488
+
489
+ .sidebar nav heading {
490
+ font-size: 0.9rem;
491
+ margin-bottom: 1rem;
492
+ padding-bottom: 0.5rem;
493
+ border-bottom: 1px solid var(--underlay-color);
494
+ display: block;
495
+ font-weight: 500;
496
+ color: var(--accent-color);
497
+ }
498
+
499
+ .sidebar nav li,
500
+ .sidebar details nav li {
501
+ margin: 0.1rem 0;
502
+ }
503
+
504
+ .sidebar nav li.level-2 {
505
+ padding-left: 0;
506
+ }
507
+
508
+ .sidebar nav li.level-3 {
509
+ padding-left: 1rem;
510
+ }
511
+
512
+ .sidebar nav li.level-4 {
513
+ padding-left: 2rem;
514
+ }
515
+
516
+ .sidebar nav li.level-5 {
517
+ padding-left: 3rem;
518
+ }
519
+
520
+ .sidebar nav li.level-6 {
521
+ padding-left: 4rem;
522
+ }
523
+
524
+ .sidebar nav a,
525
+ .sidebar details nav a {
526
+ padding: 0.4rem 0.75rem;
527
+ font-size: 0.9rem;
528
+ border-left: 3px solid transparent;
529
+ color: var(--main-color);
530
+ text-decoration: none;
531
+ display: block;
532
+ border-radius: 0.25rem;
533
+ transition: all 0.2s ease;
534
+ }
535
+
536
+ /* Compact mode for long navigation lists */
537
+ .sidebar nav.compact li {
538
+ margin: 0.05rem 0;
539
+ }
540
+
541
+ .sidebar nav.compact a {
542
+ padding: 0.25rem 0.5rem;
543
+ font-size: 0.85rem;
544
+ line-height: 1.3;
545
+ }
546
+
547
+ .sidebar nav.compact heading {
548
+ font-size: 0.8rem;
549
+ margin-bottom: 0.75rem;
550
+ }
551
+
552
+ .sidebar nav.compact li.level-3 {
553
+ padding-left: 0.75rem;
554
+ }
555
+
556
+ .sidebar nav.compact li.level-4 {
557
+ padding-left: 1.5rem;
558
+ }
559
+
560
+ .sidebar nav.compact li.level-5 {
561
+ padding-left: 2.25rem;
562
+ }
563
+
564
+ .sidebar nav.compact li.level-6 {
565
+ padding-left: 3rem;
566
+ }
567
+
568
+ .sidebar nav ul {
569
+ margin: 0;
570
+ padding: 0;
571
+ list-style: none;
572
+ }
573
+
574
+ .sidebar nav a:hover {
575
+ border-left-color: var(--accent-color);
576
+ background-color: var(--header-color);
577
+ }
578
+
579
+ /* Subtle focus indicator for keyboard navigation */
580
+ .sidebar nav a:focus {
581
+ outline: 2px solid var(--accent-color);
582
+ outline-offset: 2px;
583
+ border-radius: 0.25rem;
584
+ }
585
+
586
+ /* Brief click feedback */
587
+ .sidebar nav a:active {
588
+ background-color: var(--accent-hover-color);
589
+ color: white;
590
+ transform: scale(0.98);
591
+ transition: transform 0.1s ease;
592
+ }
593
+
594
+ /* Active section highlighting (set by JavaScript) */
595
+ .sidebar nav a.active {
596
+ background-color: var(--accent-color);
597
+ color: white;
598
+ border-left-color: var(--accent-color);
599
+ font-weight: 500;
600
+ }
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: utopia-project
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.36.0
4
+ version: 0.37.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -144,6 +144,7 @@ files:
144
144
  - lib/utopia/project/linkify.rb
145
145
  - lib/utopia/project/releases_document.rb
146
146
  - lib/utopia/project/renderer.rb
147
+ - lib/utopia/project/sidebar.rb
147
148
  - lib/utopia/project/version.rb
148
149
  - license.md
149
150
  - pages/_header.xnode
@@ -255,6 +256,7 @@ files:
255
256
  - public/_components/mermaid/mermaid.min.js.map
256
257
  - public/_static/icon.png
257
258
  - public/_static/links.js
259
+ - public/_static/sidebar.js
258
260
  - public/_static/site.css
259
261
  - public/robots.txt
260
262
  - readme.md
metadata.gz.sig CHANGED
Binary file