@sprig-and-prose/sprig-ui 0.1.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.
- package/README.md +120 -0
- package/dist/assets/index-B6I7oo2K.js +1 -0
- package/dist/assets/index-O71xRVzw.js +4 -0
- package/dist/assets/index-coj3G3o6.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +33 -0
- package/src/App.svelte +277 -0
- package/src/cli.js +146 -0
- package/src/lib/components/ContentsCard.svelte +283 -0
- package/src/lib/components/FooterStatus.svelte +83 -0
- package/src/lib/components/GlobalSearch.svelte +428 -0
- package/src/lib/components/NodePageLayout.svelte +65 -0
- package/src/lib/components/PageHeader.svelte +116 -0
- package/src/lib/components/Prose.svelte +260 -0
- package/src/lib/components/RelationshipsSection.svelte +122 -0
- package/src/lib/components/UniverseHeader.svelte +20 -0
- package/src/lib/data/universeStore.js +350 -0
- package/src/lib/format/kind.js +26 -0
- package/src/lib/format/title.js +97 -0
- package/src/lib/router.js +39 -0
- package/src/lib/stores/describeRenderMode.js +9 -0
- package/src/lib/stores/theme.js +98 -0
- package/src/main.js +154 -0
- package/src/pages/ConceptPage.svelte +88 -0
- package/src/pages/HomePage.svelte +48 -0
- package/src/server.js +115 -0
- package/src/styles/app.css +353 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/** @type {import('../data/universeStore.js').UniverseGraph | null} */
|
|
3
|
+
export let graph = null;
|
|
4
|
+
|
|
5
|
+
/** @param {Record<string, any> | null | undefined} obj */
|
|
6
|
+
const count = (obj) => (obj ? Object.keys(obj).length : 0);
|
|
7
|
+
$: dimensionCount = count(graph?.nodes && Object.fromEntries(
|
|
8
|
+
Object.entries(graph.nodes).filter(([, node]) => node?.kind === 'dimension'),
|
|
9
|
+
));
|
|
10
|
+
$: relationshipCount = count(graph?.relationshipsById) + count(graph?.relatesById);
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<footer class="footer">
|
|
14
|
+
<div class="left">
|
|
15
|
+
<span class="label">Grounding</span>
|
|
16
|
+
<span class="value">Coming soon</span>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="right">
|
|
20
|
+
<span class="label">Nodes</span>
|
|
21
|
+
<span class="value">{count(graph?.nodes)}</span>
|
|
22
|
+
<span class="dot">•</span>
|
|
23
|
+
<span class="label">Dimensions</span>
|
|
24
|
+
<span class="value">{dimensionCount}</span>
|
|
25
|
+
<span class="dot">•</span>
|
|
26
|
+
<span class="label">Relationships</span>
|
|
27
|
+
<span class="value">{relationshipCount}</span>
|
|
28
|
+
</div>
|
|
29
|
+
</footer>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.footer {
|
|
33
|
+
padding: 2rem 0;
|
|
34
|
+
border-top: 1px solid var(--border-color);
|
|
35
|
+
margin-top: 4rem;
|
|
36
|
+
width: 100%;
|
|
37
|
+
overflow-x: hidden;
|
|
38
|
+
display: flex;
|
|
39
|
+
justify-content: space-between;
|
|
40
|
+
gap: 18px;
|
|
41
|
+
font-size: var(--sp-font-tiny);
|
|
42
|
+
color: var(--text-tertiary);
|
|
43
|
+
max-width: 1200px;
|
|
44
|
+
margin-left: auto;
|
|
45
|
+
margin-right: auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.left, .right {
|
|
49
|
+
display: flex;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
align-items: center;
|
|
52
|
+
flex-wrap: wrap;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.label {
|
|
56
|
+
color: var(--text-tertiary);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.value {
|
|
60
|
+
color: var(--text-secondary);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.dot {
|
|
64
|
+
color: var(--text-tertiary);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@media (max-width: 768px) {
|
|
68
|
+
.footer {
|
|
69
|
+
padding: 2rem 0;
|
|
70
|
+
margin-top: 48px;
|
|
71
|
+
font-size: var(--sp-font-tiny);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@media (max-width: 480px) {
|
|
76
|
+
.footer {
|
|
77
|
+
padding: 1.5rem 0;
|
|
78
|
+
margin-top: 40px;
|
|
79
|
+
font-size: var(--sp-font-tiny);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
</style>
|
|
83
|
+
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { universeGraph } from '../data/universeStore.js';
|
|
4
|
+
import { getNodeRoute, getAncestorChain } from '../data/universeStore.js';
|
|
5
|
+
import { getDisplayTitle } from '../format/title.js';
|
|
6
|
+
import { getDisplayKind } from '../format/kind.js';
|
|
7
|
+
import { navigate } from '../router.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import('../data/universeStore.js').UniverseGraph} UniverseGraph
|
|
11
|
+
* @typedef {import('../data/universeStore.js').NodeModel} NodeModel
|
|
12
|
+
* @typedef {{ node: NodeModel | any, name: string, kind: string, id: string, route: string, context: string | null }} SearchItem
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {HTMLInputElement | null} */
|
|
16
|
+
let searchInput = null;
|
|
17
|
+
let query = '';
|
|
18
|
+
let searchIndex = /** @type {SearchItem[]} */ ([]);
|
|
19
|
+
let activeIndex = /** @type {number | null} */ (null);
|
|
20
|
+
let listboxId = 'search-results-listbox';
|
|
21
|
+
/** @type {HTMLDivElement | null} */
|
|
22
|
+
let resultsDropdown = null;
|
|
23
|
+
let isManuallyClosed = false;
|
|
24
|
+
|
|
25
|
+
function buildContext(graph, node) {
|
|
26
|
+
const ancestors = getAncestorChain(graph, node);
|
|
27
|
+
if (ancestors.length === 0) return null;
|
|
28
|
+
return `in ${getDisplayTitle(ancestors[0])}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build search index from universe graph nodes.
|
|
33
|
+
* @param {UniverseGraph | null} graph
|
|
34
|
+
* @returns {SearchItem[]}
|
|
35
|
+
*/
|
|
36
|
+
function buildSearchIndex(graph) {
|
|
37
|
+
if (!graph) return [];
|
|
38
|
+
|
|
39
|
+
const items = [];
|
|
40
|
+
const searchableKinds = ['concept', 'dimension', 'relationship', 'relates'];
|
|
41
|
+
|
|
42
|
+
if (graph.nodes) {
|
|
43
|
+
for (const node of Object.values(graph.nodes)) {
|
|
44
|
+
if (!node || !searchableKinds.includes(node.kind)) continue;
|
|
45
|
+
|
|
46
|
+
const route = getNodeRoute(node);
|
|
47
|
+
const context = buildContext(graph, node);
|
|
48
|
+
|
|
49
|
+
items.push({
|
|
50
|
+
node,
|
|
51
|
+
name: node.name,
|
|
52
|
+
kind: getDisplayKind(node),
|
|
53
|
+
id: node.id,
|
|
54
|
+
route,
|
|
55
|
+
context,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return items;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Search nodes with ranking
|
|
65
|
+
* @param {SearchItem[]} index
|
|
66
|
+
* @param {string} searchQuery
|
|
67
|
+
* @returns {SearchItem[]}
|
|
68
|
+
*/
|
|
69
|
+
function searchNodes(index, searchQuery) {
|
|
70
|
+
if (!searchQuery.trim()) return [];
|
|
71
|
+
|
|
72
|
+
const queryLower = searchQuery.toLowerCase().trim();
|
|
73
|
+
const prefixMatches = [];
|
|
74
|
+
const substringMatches = [];
|
|
75
|
+
|
|
76
|
+
for (const item of index) {
|
|
77
|
+
const nameLower = item.name.toLowerCase();
|
|
78
|
+
|
|
79
|
+
if (nameLower.startsWith(queryLower)) {
|
|
80
|
+
prefixMatches.push(item);
|
|
81
|
+
} else if (nameLower.includes(queryLower)) {
|
|
82
|
+
substringMatches.push(item);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
prefixMatches.sort((a, b) => a.name.localeCompare(b.name));
|
|
87
|
+
substringMatches.sort((a, b) => a.name.localeCompare(b.name));
|
|
88
|
+
|
|
89
|
+
const results = [...prefixMatches, ...substringMatches];
|
|
90
|
+
return results.slice(0, 10);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build search index reactively when graph changes
|
|
94
|
+
$: {
|
|
95
|
+
if ($universeGraph) {
|
|
96
|
+
searchIndex = buildSearchIndex($universeGraph);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Perform search reactively
|
|
101
|
+
$: results = query.trim() ? searchNodes(searchIndex, query) : [];
|
|
102
|
+
|
|
103
|
+
// Reset active index and manual close flag when query or results change
|
|
104
|
+
$: {
|
|
105
|
+
activeIndex = null;
|
|
106
|
+
if (query.trim().length > 0) {
|
|
107
|
+
isManuallyClosed = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Compute isOpen: show dropdown if we have results, query is not empty, and not manually closed
|
|
112
|
+
$: isOpen = results.length > 0 && query.trim().length > 0 && !isManuallyClosed;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scroll active item into view
|
|
116
|
+
*/
|
|
117
|
+
function scrollActiveIntoView() {
|
|
118
|
+
if (activeIndex === null || !resultsDropdown) return;
|
|
119
|
+
|
|
120
|
+
const optionId = `search-option-${activeIndex}`;
|
|
121
|
+
const optionElement = resultsDropdown.querySelector(`#${optionId}`);
|
|
122
|
+
if (optionElement) {
|
|
123
|
+
optionElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Handle keyboard navigation in search input
|
|
129
|
+
* @param {KeyboardEvent} event
|
|
130
|
+
*/
|
|
131
|
+
function handleInputKeydown(event) {
|
|
132
|
+
// Ignore "/" key since it's the focus key
|
|
133
|
+
if (event.key === '/') {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (event.key === 'Tab') {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (event.key === 'ArrowDown') {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
if (!isOpen || results.length === 0) return;
|
|
145
|
+
|
|
146
|
+
if (activeIndex === null) {
|
|
147
|
+
activeIndex = 0;
|
|
148
|
+
} else {
|
|
149
|
+
activeIndex = Math.min(activeIndex + 1, results.length - 1);
|
|
150
|
+
}
|
|
151
|
+
scrollActiveIntoView();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (event.key === 'ArrowUp') {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
if (!isOpen || results.length === 0) return;
|
|
158
|
+
|
|
159
|
+
if (activeIndex === null) {
|
|
160
|
+
activeIndex = results.length - 1;
|
|
161
|
+
} else {
|
|
162
|
+
activeIndex = Math.max(activeIndex - 1, 0);
|
|
163
|
+
}
|
|
164
|
+
scrollActiveIntoView();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (event.key === 'Enter' && isOpen && activeIndex !== null && results[activeIndex]) {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
const item = results[activeIndex];
|
|
171
|
+
handleResultClick(item);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (event.key === 'Escape') {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
isManuallyClosed = true;
|
|
178
|
+
activeIndex = null;
|
|
179
|
+
searchInput?.focus();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Handle global keyboard shortcuts (for `/` to focus)
|
|
186
|
+
* @param {KeyboardEvent} event
|
|
187
|
+
*/
|
|
188
|
+
function handleGlobalKeydown(event) {
|
|
189
|
+
const activeElement = document.activeElement;
|
|
190
|
+
const isInputElement = activeElement && (
|
|
191
|
+
activeElement.tagName === 'INPUT' ||
|
|
192
|
+
activeElement.tagName === 'TEXTAREA' ||
|
|
193
|
+
(activeElement instanceof HTMLElement && activeElement.isContentEditable)
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (event.key === '/' && !isInputElement) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
searchInput?.focus();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Handle result click
|
|
204
|
+
* @param {SearchItem} item
|
|
205
|
+
*/
|
|
206
|
+
function handleResultClick(item) {
|
|
207
|
+
query = '';
|
|
208
|
+
isOpen = false;
|
|
209
|
+
activeIndex = null;
|
|
210
|
+
navigate(item.route);
|
|
211
|
+
// Trigger route change event
|
|
212
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Handle mouse hover on result item
|
|
217
|
+
* @param {number} index
|
|
218
|
+
*/
|
|
219
|
+
function handleResultHover(index) {
|
|
220
|
+
activeIndex = index;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
onMount(() => {
|
|
224
|
+
window.addEventListener('keydown', handleGlobalKeydown);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
onDestroy(() => {
|
|
228
|
+
if (typeof window !== 'undefined') {
|
|
229
|
+
window.removeEventListener('keydown', handleGlobalKeydown);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
</script>
|
|
233
|
+
|
|
234
|
+
<div class="search-container">
|
|
235
|
+
<div class="search-wrapper">
|
|
236
|
+
<input
|
|
237
|
+
bind:this={searchInput}
|
|
238
|
+
type="text"
|
|
239
|
+
placeholder="Search concepts, dimensions, relationships… (Press / to focus)"
|
|
240
|
+
bind:value={query}
|
|
241
|
+
class="search-input"
|
|
242
|
+
role="combobox"
|
|
243
|
+
aria-label="Search"
|
|
244
|
+
aria-expanded={isOpen}
|
|
245
|
+
aria-controls={listboxId}
|
|
246
|
+
aria-activedescendant={activeIndex !== null ? `search-option-${activeIndex}` : undefined}
|
|
247
|
+
aria-autocomplete="list"
|
|
248
|
+
on:keydown={handleInputKeydown}
|
|
249
|
+
/>
|
|
250
|
+
|
|
251
|
+
{#if isOpen && results.length > 0}
|
|
252
|
+
<div
|
|
253
|
+
bind:this={resultsDropdown}
|
|
254
|
+
class="results-dropdown"
|
|
255
|
+
role="listbox"
|
|
256
|
+
id={listboxId}
|
|
257
|
+
>
|
|
258
|
+
{#each results as item, index (item.id)}
|
|
259
|
+
{@const optionId = `search-option-${index}`}
|
|
260
|
+
{@const isActive = activeIndex === index}
|
|
261
|
+
<a
|
|
262
|
+
href={item.route}
|
|
263
|
+
class="result-item"
|
|
264
|
+
class:result-item--active={isActive}
|
|
265
|
+
role="option"
|
|
266
|
+
id={optionId}
|
|
267
|
+
aria-selected={isActive}
|
|
268
|
+
on:click|preventDefault={() => handleResultClick(item)}
|
|
269
|
+
on:mouseenter={() => handleResultHover(index)}
|
|
270
|
+
>
|
|
271
|
+
<div class="result-content">
|
|
272
|
+
<div class="result-title">{item.node != null ? getDisplayTitle(item.node) : item.name}</div>
|
|
273
|
+
<div class="result-meta">
|
|
274
|
+
<span class="result-kind">{item.kind}</span>
|
|
275
|
+
{#if item.context}
|
|
276
|
+
<span class="result-context">{item.context}</span>
|
|
277
|
+
{/if}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</a>
|
|
281
|
+
{/each}
|
|
282
|
+
</div>
|
|
283
|
+
{/if}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<style>
|
|
288
|
+
.search-container {
|
|
289
|
+
display: flex;
|
|
290
|
+
flex: 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.search-wrapper {
|
|
294
|
+
position: relative;
|
|
295
|
+
max-width: 500px;
|
|
296
|
+
width: 100%;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.search-input {
|
|
300
|
+
width: 100%;
|
|
301
|
+
padding: 10px 14px;
|
|
302
|
+
font-family: var(--font-ui);
|
|
303
|
+
font-size: var(--sp-font-small);
|
|
304
|
+
color: var(--text-color);
|
|
305
|
+
background: var(--card-bg);
|
|
306
|
+
border: none;
|
|
307
|
+
border-radius: 4px;
|
|
308
|
+
outline: none;
|
|
309
|
+
transition: box-shadow 0.2s;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.search-input:focus {
|
|
313
|
+
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@media (prefers-color-scheme: dark) {
|
|
317
|
+
.search-input:focus {
|
|
318
|
+
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
:global(html[data-theme="dark"]) .search-input:focus {
|
|
323
|
+
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.search-input::placeholder {
|
|
327
|
+
color: var(--text-tertiary);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.results-dropdown {
|
|
331
|
+
position: absolute;
|
|
332
|
+
top: calc(100% + 4px);
|
|
333
|
+
left: 0;
|
|
334
|
+
right: 0;
|
|
335
|
+
background: var(--card-bg);
|
|
336
|
+
border: 1px solid var(--border-color);
|
|
337
|
+
border-radius: 4px;
|
|
338
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
339
|
+
max-height: 400px;
|
|
340
|
+
overflow-y: auto;
|
|
341
|
+
z-index: 1000;
|
|
342
|
+
margin-top: 4px;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@media (prefers-color-scheme: dark) {
|
|
346
|
+
.results-dropdown {
|
|
347
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
:global(html[data-theme="dark"]) .results-dropdown {
|
|
352
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.result-item {
|
|
356
|
+
display: block;
|
|
357
|
+
padding: 12px 14px;
|
|
358
|
+
text-decoration: none;
|
|
359
|
+
color: inherit;
|
|
360
|
+
border-bottom: 1px solid var(--hairline);
|
|
361
|
+
transition: background-color 0.15s;
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.result-item:last-child {
|
|
366
|
+
border-bottom: none;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.result-item:hover,
|
|
370
|
+
.result-item:focus {
|
|
371
|
+
background: var(--hover);
|
|
372
|
+
outline: none;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.result-item:focus-visible {
|
|
376
|
+
outline: 2px solid var(--text-tertiary);
|
|
377
|
+
outline-offset: -2px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.result-item--active {
|
|
381
|
+
background: var(--hover);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.result-item--active .result-title {
|
|
385
|
+
text-decoration: underline;
|
|
386
|
+
text-decoration-thickness: 1px;
|
|
387
|
+
text-underline-offset: 2px;
|
|
388
|
+
text-decoration-color: var(--text-secondary);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.result-content {
|
|
392
|
+
display: flex;
|
|
393
|
+
flex-direction: column;
|
|
394
|
+
gap: 4px;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.result-title {
|
|
398
|
+
font-family: var(--font-prose);
|
|
399
|
+
font-size: var(--sp-font-body);
|
|
400
|
+
color: var(--text-color);
|
|
401
|
+
font-weight: 400;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.result-meta {
|
|
405
|
+
display: flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
gap: 8px;
|
|
408
|
+
font-family: var(--font-ui);
|
|
409
|
+
font-size: var(--sp-font-tiny);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.result-kind {
|
|
413
|
+
color: var(--text-tertiary);
|
|
414
|
+
text-transform: capitalize;
|
|
415
|
+
font-weight: 400;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.result-context {
|
|
419
|
+
color: var(--text-secondary);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.result-context::before {
|
|
423
|
+
content: '·';
|
|
424
|
+
margin-right: 4px;
|
|
425
|
+
color: var(--text-tertiary);
|
|
426
|
+
}
|
|
427
|
+
</style>
|
|
428
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import PageHeader from './PageHeader.svelte';
|
|
3
|
+
import FooterStatus from './FooterStatus.svelte';
|
|
4
|
+
|
|
5
|
+
export let title;
|
|
6
|
+
export let subtitle;
|
|
7
|
+
export let graph;
|
|
8
|
+
export let showIndex = false;
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<PageHeader {title} {subtitle} />
|
|
12
|
+
|
|
13
|
+
<div class="grid">
|
|
14
|
+
<section class="narrative">
|
|
15
|
+
<slot name="narrative" />
|
|
16
|
+
</section>
|
|
17
|
+
|
|
18
|
+
{#if showIndex}
|
|
19
|
+
<aside class="index">
|
|
20
|
+
<slot name="index" />
|
|
21
|
+
</aside>
|
|
22
|
+
{/if}
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<slot name="sections" />
|
|
26
|
+
|
|
27
|
+
{#if graph}
|
|
28
|
+
<FooterStatus {graph} />
|
|
29
|
+
{/if}
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.grid {
|
|
33
|
+
display: grid;
|
|
34
|
+
grid-template-columns: 1fr 350px;
|
|
35
|
+
gap: 64px;
|
|
36
|
+
align-items: start;
|
|
37
|
+
max-width: 1200px;
|
|
38
|
+
margin: 0 auto;
|
|
39
|
+
width: 100%;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.narrative {
|
|
43
|
+
max-width: 680px;
|
|
44
|
+
min-width: 0;
|
|
45
|
+
width: 100%;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.index {
|
|
49
|
+
min-width: 0;
|
|
50
|
+
width: 100%;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (max-width: 768px) {
|
|
54
|
+
.grid {
|
|
55
|
+
grid-template-columns: 1fr;
|
|
56
|
+
gap: 32px;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@media (max-width: 480px) {
|
|
61
|
+
.grid {
|
|
62
|
+
gap: 32px;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getDisplayTitle } from '../format/title.js';
|
|
3
|
+
|
|
4
|
+
/** @type {string} */
|
|
5
|
+
export let title;
|
|
6
|
+
|
|
7
|
+
/** @type {string | null} */
|
|
8
|
+
export let subtitle = null;
|
|
9
|
+
|
|
10
|
+
/** @type {any} */
|
|
11
|
+
export let subtitleNode = null;
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<header class="header">
|
|
15
|
+
<div>
|
|
16
|
+
<h1 class="title">{title}</h1>
|
|
17
|
+
{#if subtitle}
|
|
18
|
+
<p class="subtitle">{@html subtitle}</p>
|
|
19
|
+
{:else if subtitleNode}
|
|
20
|
+
<p class="subtitle">{getDisplayTitle(subtitleNode)}</p>
|
|
21
|
+
{/if}
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
.header {
|
|
27
|
+
margin-bottom: 48px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.title {
|
|
31
|
+
font-size: var(--sp-font-h1);
|
|
32
|
+
font-weight: 400;
|
|
33
|
+
line-height: 1.2;
|
|
34
|
+
margin-bottom: 0;
|
|
35
|
+
color: var(--text-color);
|
|
36
|
+
letter-spacing: -0.5px;
|
|
37
|
+
font-family: var(--font-prose);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.subtitle {
|
|
41
|
+
margin: 0;
|
|
42
|
+
font-size: var(--sp-font-body);
|
|
43
|
+
line-height: 1.6;
|
|
44
|
+
color: var(--text-color);
|
|
45
|
+
font-family: var(--font-ui);
|
|
46
|
+
margin-top: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.subtitle :global(a) {
|
|
50
|
+
color: inherit;
|
|
51
|
+
text-decoration: underline;
|
|
52
|
+
text-decoration-thickness: 1px;
|
|
53
|
+
text-underline-offset: 0.25rem;
|
|
54
|
+
text-decoration-color: rgba(0, 0, 0, 0.25);
|
|
55
|
+
transition: text-decoration-thickness 150ms ease, text-decoration-color 150ms ease;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@media (prefers-color-scheme: dark) {
|
|
59
|
+
:root:not([data-theme="light"]) .subtitle :global(a) {
|
|
60
|
+
text-decoration-color: rgba(255, 255, 255, 0.35);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
:global(html[data-theme="dark"]) .subtitle :global(a) {
|
|
65
|
+
text-decoration-color: rgba(255, 255, 255, 0.35);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
:global(html[data-theme="light"]) .subtitle :global(a) {
|
|
69
|
+
text-decoration-color: rgba(0, 0, 0, 0.25);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.subtitle :global(a:hover) {
|
|
73
|
+
text-decoration-thickness: 1.5px;
|
|
74
|
+
text-decoration-color: rgba(0, 0, 0, 0.4);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@media (prefers-color-scheme: dark) {
|
|
78
|
+
:root:not([data-theme="light"]) .subtitle :global(a:hover) {
|
|
79
|
+
text-decoration-color: rgba(255, 255, 255, 0.5);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
:global(html[data-theme="dark"]) .subtitle :global(a:hover) {
|
|
84
|
+
text-decoration-color: rgba(255, 255, 255, 0.5);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
:global(html[data-theme="light"]) .subtitle :global(a:hover) {
|
|
88
|
+
text-decoration-color: rgba(0, 0, 0, 0.4);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@media (max-width: 768px) {
|
|
92
|
+
.title {
|
|
93
|
+
font-size: 36px;
|
|
94
|
+
margin-bottom: 32px;
|
|
95
|
+
letter-spacing: -0.3px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.subtitle {
|
|
99
|
+
font-size: var(--sp-font-body);
|
|
100
|
+
line-height: 1.65;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@media (max-width: 480px) {
|
|
105
|
+
.title {
|
|
106
|
+
font-size: 32px;
|
|
107
|
+
margin-bottom: 28px;
|
|
108
|
+
letter-spacing: -0.2px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.subtitle {
|
|
112
|
+
font-size: var(--sp-font-body);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
</style>
|
|
116
|
+
|