@kenjura/ursa 0.90.0 → 0.93.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.
@@ -1,248 +1,257 @@
1
- /* Image hover controls and the full-screen viewer (see lightbox.js). */
2
-
3
- /* --- hover controls --- */
4
-
5
- .ursa-image-frame {
6
- position: relative;
7
- display: inline-block;
8
- max-width: 100%;
9
- line-height: 0;
10
- }
1
+ /* Image hover controls and the full-screen viewer (see lightbox.js).
2
+
3
+ Both are Ursa's furniture: the hover controls are injected into the
4
+ document's own markup and the viewer is appended to <body>. Like the
5
+ breadcrumbs in default.css they are scoped but *not* layered, so a broad
6
+ site rule cannot bleed into them, and they stay out of `.ursa-unstyled`.
7
+ (lightbox.js skips images inside `.ursa-unstyled` for the same reason.) */
8
+
9
+ @scope (body) to (.ursa-unstyled) {
10
+ /* --- hover controls --- */
11
+
12
+ .ursa-image-frame {
13
+ position: relative;
14
+ display: inline-block;
15
+ max-width: 100%;
16
+ line-height: 0;
17
+ }
11
18
 
12
- .ursa-image-controls {
13
- position: absolute;
14
- top: 8px;
15
- right: 8px;
16
- display: flex;
17
- gap: 6px;
18
- opacity: 0;
19
- transition: opacity 0.15s ease;
20
- line-height: 0;
21
- }
19
+ .ursa-image-controls {
20
+ position: absolute;
21
+ top: 8px;
22
+ right: 8px;
23
+ display: flex;
24
+ gap: 6px;
25
+ opacity: 0;
26
+ transition: opacity 0.15s ease;
27
+ line-height: 0;
28
+ }
22
29
 
23
- .ursa-image-frame:hover .ursa-image-controls,
24
- .ursa-image-frame:focus-within .ursa-image-controls {
25
- opacity: 1;
26
- }
30
+ .ursa-image-frame:hover .ursa-image-controls,
31
+ .ursa-image-frame:focus-within .ursa-image-controls {
32
+ opacity: 1;
33
+ }
27
34
 
28
- /* No hover to reveal them on touch devices, so keep them visible but quiet. */
29
- @media (hover: none) {
30
- .ursa-image-controls {
31
- opacity: 0.65;
35
+ /* No hover to reveal them on touch devices, so keep them visible but quiet. */
36
+ @media (hover: none) {
37
+ .ursa-image-controls {
38
+ opacity: 0.65;
39
+ }
32
40
  }
33
- }
34
41
 
35
- .ursa-image-btn {
36
- display: flex;
37
- align-items: center;
38
- justify-content: center;
39
- width: 30px;
40
- height: 30px;
41
- padding: 0;
42
- border: none;
43
- border-radius: 50%;
44
- background: rgba(0, 0, 0, 0.55);
45
- color: white;
46
- cursor: pointer;
47
- backdrop-filter: blur(2px);
48
- transition: background-color 0.15s ease;
49
- }
42
+ .ursa-image-btn {
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ width: 30px;
47
+ height: 30px;
48
+ padding: 0;
49
+ border: none;
50
+ border-radius: 50%;
51
+ background: rgba(0, 0, 0, 0.55);
52
+ color: white;
53
+ cursor: pointer;
54
+ backdrop-filter: blur(2px);
55
+ transition: background-color 0.15s ease;
56
+ }
50
57
 
51
- .ursa-image-btn:hover,
52
- .ursa-image-btn:focus-visible {
53
- background: rgba(0, 0, 0, 0.8);
54
- }
58
+ .ursa-image-btn:hover,
59
+ .ursa-image-btn:focus-visible {
60
+ background: rgba(0, 0, 0, 0.8);
61
+ }
55
62
 
56
- .ursa-image-btn svg {
57
- width: 17px;
58
- height: 17px;
59
- fill: none;
60
- stroke: currentColor;
61
- stroke-width: 2;
62
- stroke-linecap: round;
63
- stroke-linejoin: round;
64
- }
63
+ .ursa-image-btn svg {
64
+ width: 17px;
65
+ height: 17px;
66
+ fill: none;
67
+ stroke: currentColor;
68
+ stroke-width: 2;
69
+ stroke-linecap: round;
70
+ stroke-linejoin: round;
71
+ }
65
72
 
66
- /* --- full-screen viewer --- */
73
+ /* --- full-screen viewer --- */
67
74
 
68
- body.ursa-lightbox-open {
69
- overflow: hidden;
70
- }
75
+ /* `:scope` is <body>: a bare `body` here would mean a body inside the body. */
76
+ :scope.ursa-lightbox-open {
77
+ overflow: hidden;
78
+ }
71
79
 
72
- .ursa-lightbox {
73
- position: fixed;
74
- inset: 0;
75
- z-index: 2000;
76
- display: flex;
77
- }
80
+ .ursa-lightbox {
81
+ position: fixed;
82
+ inset: 0;
83
+ z-index: 2000;
84
+ display: flex;
85
+ }
78
86
 
79
- .ursa-lightbox[hidden] {
80
- display: none;
81
- }
87
+ .ursa-lightbox[hidden] {
88
+ display: none;
89
+ }
82
90
 
83
- .ursa-lightbox-backdrop {
84
- position: absolute;
85
- inset: 0;
86
- background: rgba(0, 0, 0, 0.85);
87
- }
91
+ .ursa-lightbox-backdrop {
92
+ position: absolute;
93
+ inset: 0;
94
+ background: rgba(0, 0, 0, 0.85);
95
+ }
88
96
 
89
- .ursa-lightbox-stage {
90
- position: relative;
91
- flex: 1;
92
- display: flex;
93
- overflow: auto;
94
- padding: 32px;
95
- box-sizing: border-box;
96
- outline: none;
97
- overscroll-behavior: contain;
98
- }
97
+ .ursa-lightbox-stage {
98
+ position: relative;
99
+ flex: 1;
100
+ display: flex;
101
+ overflow: auto;
102
+ padding: 32px;
103
+ box-sizing: border-box;
104
+ outline: none;
105
+ overscroll-behavior: contain;
106
+ }
99
107
 
100
- /* margin:auto centres the image while it fits, and collapses to 0 once it is
101
- larger than the stage, so the overflow stays scrollable in both directions. */
102
- .ursa-lightbox-image {
103
- margin: auto;
104
- display: block;
105
- user-select: none;
106
- -webkit-user-drag: none;
107
- }
108
+ /* margin:auto centres the image while it fits, and collapses to 0 once it is
109
+ larger than the stage, so the overflow stays scrollable in both directions. */
110
+ .ursa-lightbox-image {
111
+ margin: auto;
112
+ display: block;
113
+ user-select: none;
114
+ -webkit-user-drag: none;
115
+ }
108
116
 
109
- .ursa-lightbox-image.is-placeholder {
110
- filter: blur(1px);
111
- }
117
+ .ursa-lightbox-image.is-placeholder {
118
+ filter: blur(1px);
119
+ }
112
120
 
113
- .ursa-lightbox-stage.is-pannable {
114
- cursor: grab;
115
- }
121
+ .ursa-lightbox-stage.is-pannable {
122
+ cursor: grab;
123
+ }
116
124
 
117
- .ursa-lightbox-stage.is-panning {
118
- cursor: grabbing;
119
- }
125
+ .ursa-lightbox-stage.is-panning {
126
+ cursor: grabbing;
127
+ }
120
128
 
121
- /* Centred over the blurred placeholder, clear of the toolbar. */
122
- .ursa-lightbox-loading {
123
- position: absolute;
124
- left: 50%;
125
- top: 50%;
126
- transform: translate(-50%, -50%);
127
- display: flex;
128
- padding: 10px;
129
- border-radius: 50%;
130
- background: rgba(0, 0, 0, 0.55);
131
- pointer-events: none;
132
- }
129
+ /* Centred over the blurred placeholder, clear of the toolbar. */
130
+ .ursa-lightbox-loading {
131
+ position: absolute;
132
+ left: 50%;
133
+ top: 50%;
134
+ transform: translate(-50%, -50%);
135
+ display: flex;
136
+ padding: 10px;
137
+ border-radius: 50%;
138
+ background: rgba(0, 0, 0, 0.55);
139
+ pointer-events: none;
140
+ }
133
141
 
134
- .ursa-lightbox-loading[hidden] {
135
- display: none;
136
- }
142
+ .ursa-lightbox-loading[hidden] {
143
+ display: none;
144
+ }
137
145
 
138
- .ursa-lightbox-loading .ursa-spinner {
139
- width: 18px;
140
- height: 18px;
141
- border-color: rgba(255, 255, 255, 0.3);
142
- border-top-color: rgba(255, 255, 255, 0.9);
143
- }
146
+ .ursa-lightbox-loading .ursa-spinner {
147
+ width: 18px;
148
+ height: 18px;
149
+ border-color: rgba(255, 255, 255, 0.3);
150
+ border-top-color: rgba(255, 255, 255, 0.9);
151
+ }
144
152
 
145
- .ursa-lightbox-toolbar {
146
- position: absolute;
147
- left: 50%;
148
- bottom: 20px;
149
- transform: translateX(-50%);
150
- display: flex;
151
- align-items: center;
152
- gap: 4px;
153
- padding: 4px;
154
- border-radius: 999px;
155
- background: rgba(0, 0, 0, 0.6);
156
- backdrop-filter: blur(2px);
157
- }
153
+ .ursa-lightbox-toolbar {
154
+ position: absolute;
155
+ left: 50%;
156
+ bottom: 20px;
157
+ transform: translateX(-50%);
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 4px;
161
+ padding: 4px;
162
+ border-radius: 999px;
163
+ background: rgba(0, 0, 0, 0.6);
164
+ backdrop-filter: blur(2px);
165
+ }
158
166
 
159
- .ursa-lightbox-zoom {
160
- display: flex;
161
- align-items: center;
162
- gap: 4px;
163
- }
167
+ .ursa-lightbox-zoom {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 4px;
171
+ }
164
172
 
165
- .ursa-lightbox-zoom[hidden] {
166
- display: none;
167
- }
173
+ .ursa-lightbox-zoom[hidden] {
174
+ display: none;
175
+ }
168
176
 
169
- .ursa-lightbox-level {
170
- min-width: 3.5em;
171
- text-align: center;
172
- color: white;
173
- font-family: sans-serif;
174
- font-size: 0.8rem;
175
- font-variant-numeric: tabular-nums;
176
- }
177
+ .ursa-lightbox-level {
178
+ min-width: 3.5em;
179
+ text-align: center;
180
+ color: white;
181
+ font-family: sans-serif;
182
+ font-size: 0.8rem;
183
+ font-variant-numeric: tabular-nums;
184
+ }
177
185
 
178
- .ursa-lightbox-btn {
179
- display: flex;
180
- align-items: center;
181
- justify-content: center;
182
- width: 34px;
183
- height: 34px;
184
- padding: 0;
185
- border: none;
186
- border-radius: 50%;
187
- background: transparent;
188
- color: white;
189
- font-size: 1.2rem;
190
- font-family: sans-serif;
191
- line-height: 1;
192
- cursor: pointer;
193
- transition: background-color 0.15s ease, opacity 0.15s ease;
194
- }
186
+ .ursa-lightbox-btn {
187
+ display: flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ width: 34px;
191
+ height: 34px;
192
+ padding: 0;
193
+ border: none;
194
+ border-radius: 50%;
195
+ background: transparent;
196
+ color: white;
197
+ font-size: 1.2rem;
198
+ font-family: sans-serif;
199
+ line-height: 1;
200
+ cursor: pointer;
201
+ transition: background-color 0.15s ease, opacity 0.15s ease;
202
+ }
195
203
 
196
- .ursa-lightbox-btn:hover,
197
- .ursa-lightbox-btn:focus-visible {
198
- background: rgba(255, 255, 255, 0.18);
199
- }
204
+ .ursa-lightbox-btn:hover,
205
+ .ursa-lightbox-btn:focus-visible {
206
+ background: rgba(255, 255, 255, 0.18);
207
+ }
200
208
 
201
- .ursa-lightbox-btn:disabled {
202
- opacity: 0.35;
203
- cursor: default;
204
- background: transparent;
205
- }
209
+ .ursa-lightbox-btn:disabled {
210
+ opacity: 0.35;
211
+ cursor: default;
212
+ background: transparent;
213
+ }
206
214
 
207
- .ursa-lightbox-btn svg {
208
- width: 18px;
209
- height: 18px;
210
- fill: none;
211
- stroke: currentColor;
212
- stroke-width: 2;
213
- stroke-linecap: round;
214
- stroke-linejoin: round;
215
- }
215
+ .ursa-lightbox-btn svg {
216
+ width: 18px;
217
+ height: 18px;
218
+ fill: none;
219
+ stroke: currentColor;
220
+ stroke-width: 2;
221
+ stroke-linecap: round;
222
+ stroke-linejoin: round;
223
+ }
216
224
 
217
- .ursa-lightbox-close {
218
- position: absolute;
219
- top: 16px;
220
- right: 16px;
221
- display: flex;
222
- align-items: center;
223
- justify-content: center;
224
- width: 44px;
225
- height: 44px;
226
- padding: 0;
227
- border: none;
228
- border-radius: 50%;
229
- background: rgba(0, 0, 0, 0.6);
230
- color: white;
231
- cursor: pointer;
232
- backdrop-filter: blur(2px);
233
- transition: background-color 0.15s ease;
234
- }
225
+ .ursa-lightbox-close {
226
+ position: absolute;
227
+ top: 16px;
228
+ right: 16px;
229
+ display: flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ width: 44px;
233
+ height: 44px;
234
+ padding: 0;
235
+ border: none;
236
+ border-radius: 50%;
237
+ background: rgba(0, 0, 0, 0.6);
238
+ color: white;
239
+ cursor: pointer;
240
+ backdrop-filter: blur(2px);
241
+ transition: background-color 0.15s ease;
242
+ }
235
243
 
236
- .ursa-lightbox-close:hover,
237
- .ursa-lightbox-close:focus-visible {
238
- background: rgba(255, 255, 255, 0.22);
239
- }
244
+ .ursa-lightbox-close:hover,
245
+ .ursa-lightbox-close:focus-visible {
246
+ background: rgba(255, 255, 255, 0.22);
247
+ }
240
248
 
241
- .ursa-lightbox-close svg {
242
- width: 22px;
243
- height: 22px;
244
- fill: none;
245
- stroke: currentColor;
246
- stroke-width: 2;
247
- stroke-linecap: round;
249
+ .ursa-lightbox-close svg {
250
+ width: 22px;
251
+ height: 22px;
252
+ fill: none;
253
+ stroke: currentColor;
254
+ stroke-width: 2;
255
+ stroke-linecap: round;
256
+ }
248
257
  }
@@ -101,6 +101,9 @@
101
101
  function decorate(img) {
102
102
  if (img.dataset.ursaLightbox) return;
103
103
  if (img.closest('[data-no-lightbox]')) return;
104
+ // Ursa's CSS does not reach inside .ursa-unstyled, so controls injected
105
+ // there would render unstyled. Leave those images alone entirely.
106
+ if (img.closest('.ursa-unstyled')) return;
104
107
 
105
108
  const url = fullSizeUrl(img);
106
109
  if (!url) return;
@@ -501,7 +501,7 @@ document.addEventListener('DOMContentLoaded', () => {
501
501
 
502
502
  if (menuButton) {
503
503
  function updateButtonIcon(isOpen) {
504
- menuButton.textContent = isOpen ? '' : '';
504
+ menuButton.dataset.icon = isOpen ? 'close' : 'menu';
505
505
  }
506
506
 
507
507
  menuButton.addEventListener('click', (e) => {
@@ -574,8 +574,11 @@ function initTopMenu() {
574
574
 
575
575
  /**
576
576
  * Set up the menu button:
577
- * - Desktop: shows 🏠 (home icon), navigates to "/" on click
578
- * - Mobile: shows (hamburger), toggles mobile side menu on click
577
+ * - Desktop: shows the house, navigates to "/" on click
578
+ * - Mobile: shows the hamburger, toggles mobile side menu on click
579
+ *
580
+ * All three icons are in the button already (index.html); data-icon is how one
581
+ * of them is chosen, so nothing here has to know what any of them look like.
579
582
  */
580
583
  function setupMenuButton(menuButton, navMain) {
581
584
  if (!menuButton) return;
@@ -585,10 +588,10 @@ function setupMenuButton(menuButton, navMain) {
585
588
  function updateButtonIcon() {
586
589
  if (isMobile()) {
587
590
  const isActive = navMain?.classList.contains('active');
588
- menuButton.textContent = isActive ? '' : '';
591
+ menuButton.dataset.icon = isActive ? 'close' : 'menu';
589
592
  menuButton.setAttribute('aria-label', isActive ? 'Close menu' : 'Menu');
590
593
  } else {
591
- menuButton.textContent = '🏠';
594
+ menuButton.dataset.icon = 'home';
592
595
  menuButton.setAttribute('aria-label', 'Home');
593
596
  }
594
597
  }
@@ -606,7 +609,7 @@ function setupMenuButton(menuButton, navMain) {
606
609
  // Mobile: toggle side menu
607
610
  if (navMain) {
608
611
  const isNowActive = navMain.classList.toggle('active');
609
- menuButton.textContent = isNowActive ? '' : '';
612
+ menuButton.dataset.icon = isNowActive ? 'close' : 'menu';
610
613
  menuButton.setAttribute('aria-label', isNowActive ? 'Close menu' : 'Menu');
611
614
  }
612
615
  } else {
@@ -103,6 +103,64 @@ document.addEventListener('DOMContentLoaded', () => {
103
103
  }
104
104
  }
105
105
 
106
+ /* --- Keeping the current entry in view -------------------------------
107
+ *
108
+ * Below the panel breakpoint the stylesheet lays this same list on its
109
+ * side: one line tall, along the bottom of the viewport. A line that long
110
+ * cannot show every heading at once, so the entry the reader is currently
111
+ * under has to be brought to them rather than looked for.
112
+ *
113
+ * It goes to the middle where there is room to put it there. Where there is
114
+ * not — a document with too few headings to fill the line, or a reader
115
+ * still near the top of a long one — asking for the middle would mean
116
+ * scrolling past the start of the list and showing blank strip before the
117
+ * first entry. Nothing below special-cases either of those: scrollLeft is
118
+ * clamped to the content by the browser, so both come to rest in their
119
+ * natural position on their own. In the vertical panel there is no
120
+ * horizontal overflow to begin with and the same call does nothing at all.
121
+ *
122
+ * Measured from bounding rects rather than offsetLeft because the offset
123
+ * parent here is the widget dropdown, not the scrolling element, and a
124
+ * theme is free to move one without the other.
125
+ */
126
+ function centreActiveEntry(behavior) {
127
+ const active = tocTarget.querySelector('a.active');
128
+ if (!active) return;
129
+
130
+ const container = tocTarget.getBoundingClientRect();
131
+ const entry = active.getBoundingClientRect();
132
+ if (!container.width) return; // panel is closed; nothing is laid out
133
+
134
+ const delta = (entry.left + entry.width / 2) - (container.left + container.width / 2);
135
+ tocTarget.scrollTo({ left: tocTarget.scrollLeft + delta, behavior: behavior || 'smooth' });
136
+ }
137
+
138
+ // Both highlighters — the scroll handler below and the sentinel observer in
139
+ // toc.js — say which entry is current by putting .active on its link, and
140
+ // neither knows the strip exists. Watching for the class is what lets the
141
+ // two stay that way. Coalesced to a frame because marking the new entry
142
+ // means unmarking every other one, which is a mutation apiece.
143
+ let centreQueued = false;
144
+ function queueCentreActiveEntry() {
145
+ if (centreQueued) return;
146
+ centreQueued = true;
147
+ requestAnimationFrame(() => {
148
+ centreQueued = false;
149
+ centreActiveEntry();
150
+ });
151
+ }
152
+
153
+ new MutationObserver(queueCentreActiveEntry)
154
+ .observe(tocList, { subtree: true, attributeFilter: ['class'] });
155
+
156
+ // Nothing has a width until the widget is open, so the first placement has
157
+ // to wait for it — and it is a first appearance, not a move, so it does not
158
+ // animate. Same for a resize, which can change what fits either way.
159
+ document.addEventListener('widget-opened', (event) => {
160
+ if (event.detail.widget === 'toc') centreActiveEntry('auto');
161
+ });
162
+ window.addEventListener('resize', () => centreActiveEntry('auto'), { passive: true });
163
+
106
164
  // Listen for heading stuck state changes from sticky.js
107
165
  document.addEventListener('headingStuckStateChanged', (event) => {
108
166
  if (event.detail.currentStuckHeading) {