handzon-core 0.6.1 → 0.6.2

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.
@@ -0,0 +1,99 @@
1
+ import * as Select from "@radix-ui/react-select";
2
+ import { Check, ChevronDown, ChevronUp } from "lucide-react";
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface DropdownOption<V extends string = string> {
6
+ value: V;
7
+ label: string;
8
+ /** Optional leading glyph rendered before the label inside the item. */
9
+ icon?: ReactNode;
10
+ }
11
+
12
+ export interface DropdownProps<V extends string = string> {
13
+ value: V;
14
+ onChange: (value: V) => void;
15
+ options: DropdownOption<V>[];
16
+ /** Visible label glued to the left of the trigger (uppercase mono chip). */
17
+ label?: string;
18
+ /** Optional leading icon rendered inside the trigger before the label. */
19
+ triggerIcon?: ReactNode;
20
+ /** aria-label for the trigger when no `label` is shown. */
21
+ ariaLabel?: string;
22
+ /** Placeholder shown when `value` doesn't match any option. */
23
+ placeholder?: string;
24
+ /** Extra class on the outer wrapper for layout tweaks. */
25
+ className?: string;
26
+ id?: string;
27
+ }
28
+
29
+ /**
30
+ * Project-wide dropdown. Wraps Radix Select so we never ship the native
31
+ * browser dropdown UI — those look out-of-place against the brutalist
32
+ * mono/hard-edge palette and vary wildly across OSes. Every new select
33
+ * in handzon-core should use this; do not introduce raw `<select>`.
34
+ */
35
+ export default function Dropdown<V extends string = string>({
36
+ value,
37
+ onChange,
38
+ options,
39
+ label,
40
+ triggerIcon,
41
+ ariaLabel,
42
+ placeholder,
43
+ className,
44
+ id,
45
+ }: DropdownProps<V>) {
46
+ return (
47
+ <div className={`hz-dd${className ? ` ${className}` : ""}`}>
48
+ {label && (
49
+ <span className="hz-dd-label" id={id ? `${id}-label` : undefined}>
50
+ {label}
51
+ </span>
52
+ )}
53
+ <Select.Root value={value} onValueChange={(v) => onChange(v as V)}>
54
+ <Select.Trigger
55
+ id={id}
56
+ className="hz-dd-trigger"
57
+ aria-label={ariaLabel ?? label}
58
+ aria-labelledby={label && id ? `${id}-label` : undefined}
59
+ >
60
+ {triggerIcon && <span className="hz-dd-tricon">{triggerIcon}</span>}
61
+ <Select.Value placeholder={placeholder ?? "Select…"} />
62
+ <Select.Icon className="hz-dd-caret">
63
+ <ChevronDown size={14} aria-hidden="true" />
64
+ </Select.Icon>
65
+ </Select.Trigger>
66
+
67
+ <Select.Portal>
68
+ <Select.Content
69
+ className="hz-dd-content"
70
+ position="popper"
71
+ sideOffset={6}
72
+ >
73
+ <Select.ScrollUpButton className="hz-dd-scroll">
74
+ <ChevronUp size={14} aria-hidden="true" />
75
+ </Select.ScrollUpButton>
76
+ <Select.Viewport className="hz-dd-viewport">
77
+ {options.map((opt) => (
78
+ <Select.Item
79
+ key={opt.value}
80
+ value={opt.value}
81
+ className="hz-dd-item"
82
+ >
83
+ {opt.icon && <span className="hz-dd-icon">{opt.icon}</span>}
84
+ <Select.ItemText>{opt.label}</Select.ItemText>
85
+ <Select.ItemIndicator className="hz-dd-check">
86
+ <Check size={14} aria-hidden="true" />
87
+ </Select.ItemIndicator>
88
+ </Select.Item>
89
+ ))}
90
+ </Select.Viewport>
91
+ <Select.ScrollDownButton className="hz-dd-scroll">
92
+ <ChevronDown size={14} aria-hidden="true" />
93
+ </Select.ScrollDownButton>
94
+ </Select.Content>
95
+ </Select.Portal>
96
+ </Select.Root>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,219 @@
1
+ import * as Popover from "@radix-ui/react-popover";
2
+ import { Check, ChevronDown, Search, X } from "lucide-react";
3
+ import {
4
+ type KeyboardEvent,
5
+ type ReactNode,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+
12
+ export interface MultiSelectOption {
13
+ value: string;
14
+ label: string;
15
+ count: number;
16
+ }
17
+
18
+ export interface MultiSelectProps {
19
+ values: Set<string>;
20
+ onChange: (next: Set<string>) => void;
21
+ options: MultiSelectOption[];
22
+ /** Label chip glued to the left of the trigger (uppercase mono). */
23
+ label: string;
24
+ /** Optional icon rendered inside the trigger. */
25
+ triggerIcon?: ReactNode;
26
+ /** Adds a "Filter values…" input inside the popover. Use for the
27
+ * Topics popover (long lists); skip for Level (only 3 options). */
28
+ searchable?: boolean;
29
+ /** Trigger aria-label override (falls back to `label`). */
30
+ ariaLabel?: string;
31
+ id?: string;
32
+ }
33
+
34
+ /**
35
+ * Project-wide multi-select dropdown. Wraps Radix Popover so we never
36
+ * ship the browser-native UI and so every dropdown across the app
37
+ * shares the same visual chrome (mono label chip + hard-edge trigger
38
+ * + accent-on-focus). Pairs with Dropdown.tsx — that one handles
39
+ * single-select, this one handles multi.
40
+ *
41
+ * Layout: trigger summarises selection count; popover holds the full
42
+ * list with optional in-popover search, count badges per option, and
43
+ * a footer "Clear" that empties this facet (doesn't close).
44
+ *
45
+ * Keyboard: arrow keys move focus across options (roving tabIndex),
46
+ * Space toggles, Esc closes. Radix Popover doesn't provide list-nav,
47
+ * so we implement it explicitly on each row.
48
+ */
49
+ export default function MultiSelect({
50
+ values,
51
+ onChange,
52
+ options,
53
+ label,
54
+ triggerIcon,
55
+ searchable = false,
56
+ ariaLabel,
57
+ id,
58
+ }: MultiSelectProps) {
59
+ const [open, setOpen] = useState(false);
60
+ const [query, setQuery] = useState("");
61
+ const optionRefs = useRef<Array<HTMLDivElement | null>>([]);
62
+ const [focusIdx, setFocusIdx] = useState(0);
63
+
64
+ // Sort by count desc so "weighty" facets bubble up. Stable
65
+ // alphabetical secondary sort keeps neighbours predictable.
66
+ const sorted = useMemo(
67
+ () =>
68
+ [...options].sort(
69
+ (a, b) => b.count - a.count || a.label.localeCompare(b.label),
70
+ ),
71
+ [options],
72
+ );
73
+
74
+ const filtered = useMemo(() => {
75
+ if (!query.trim()) return sorted;
76
+ const q = query.trim().toLowerCase();
77
+ return sorted.filter(
78
+ (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
79
+ );
80
+ }, [sorted, query]);
81
+
82
+ // Reset the search field every time the popover closes so the next
83
+ // open starts clean. Keep focus index in range as `filtered` shrinks.
84
+ useEffect(() => {
85
+ if (!open) {
86
+ setQuery("");
87
+ setFocusIdx(0);
88
+ }
89
+ }, [open]);
90
+ useEffect(() => {
91
+ if (focusIdx >= filtered.length) setFocusIdx(Math.max(0, filtered.length - 1));
92
+ }, [filtered.length, focusIdx]);
93
+
94
+ function toggle(value: string) {
95
+ const next = new Set(values);
96
+ if (next.has(value)) next.delete(value);
97
+ else next.add(value);
98
+ onChange(next);
99
+ }
100
+
101
+ function clear() {
102
+ if (values.size === 0) return;
103
+ onChange(new Set());
104
+ }
105
+
106
+ function summary(): string {
107
+ // The label chip on the left already names the facet ("LEVEL",
108
+ // "TOPICS"), so the trigger body only carries the *value* — no
109
+ // repeat. Empty state reads "Any" (meaning "no filter applied").
110
+ if (values.size === 0) return "Any";
111
+ if (values.size === 1) {
112
+ const only = [...values][0];
113
+ const match = options.find((o) => o.value === only);
114
+ return match?.label ?? only;
115
+ }
116
+ return `${values.size} selected`;
117
+ }
118
+
119
+ function onOptionKey(e: KeyboardEvent<HTMLDivElement>, idx: number) {
120
+ if (e.key === "ArrowDown") {
121
+ e.preventDefault();
122
+ const next = Math.min(idx + 1, filtered.length - 1);
123
+ setFocusIdx(next);
124
+ optionRefs.current[next]?.focus();
125
+ } else if (e.key === "ArrowUp") {
126
+ e.preventDefault();
127
+ const next = Math.max(idx - 1, 0);
128
+ setFocusIdx(next);
129
+ optionRefs.current[next]?.focus();
130
+ } else if (e.key === " " || e.key === "Enter") {
131
+ e.preventDefault();
132
+ toggle(filtered[idx].value);
133
+ }
134
+ }
135
+
136
+ return (
137
+ <Popover.Root open={open} onOpenChange={setOpen}>
138
+ <Popover.Trigger asChild>
139
+ <button
140
+ id={id}
141
+ type="button"
142
+ className="hz-dd hz-ms-trigger"
143
+ aria-label={ariaLabel ?? label}
144
+ data-active={values.size > 0 || undefined}
145
+ >
146
+ <span className="hz-dd-label hz-ms-label">
147
+ {triggerIcon && <span className="hz-ms-label-icon">{triggerIcon}</span>}
148
+ <span>{label}</span>
149
+ </span>
150
+ <span className="hz-dd-trigger hz-ms-trigger-body">
151
+ <span className="hz-ms-value" data-empty={values.size === 0 || undefined}>
152
+ {summary()}
153
+ </span>
154
+ <span className="hz-dd-caret">
155
+ <ChevronDown size={14} aria-hidden="true" />
156
+ </span>
157
+ </span>
158
+ </button>
159
+ </Popover.Trigger>
160
+ <Popover.Portal>
161
+ <Popover.Content
162
+ className="hz-ms-content"
163
+ align="start"
164
+ sideOffset={6}
165
+ >
166
+ {searchable && (
167
+ <label className="hz-ms-search">
168
+ <Search size={14} aria-hidden="true" />
169
+ <input
170
+ type="search"
171
+ placeholder={`Filter ${label.toLowerCase()}…`}
172
+ value={query}
173
+ onChange={(e) => setQuery(e.target.value)}
174
+ aria-label={`Filter ${label.toLowerCase()}`}
175
+ />
176
+ </label>
177
+ )}
178
+ <div className="hz-ms-viewport" role="listbox" aria-multiselectable="true">
179
+ {filtered.length === 0 ? (
180
+ <div className="hz-ms-empty">No matches.</div>
181
+ ) : (
182
+ filtered.map((opt, idx) => {
183
+ const checked = values.has(opt.value);
184
+ return (
185
+ <div
186
+ key={opt.value}
187
+ ref={(el) => {
188
+ optionRefs.current[idx] = el;
189
+ }}
190
+ role="option"
191
+ aria-selected={checked}
192
+ tabIndex={idx === focusIdx ? 0 : -1}
193
+ className="hz-ms-option"
194
+ data-checked={checked || undefined}
195
+ onClick={() => toggle(opt.value)}
196
+ onKeyDown={(e) => onOptionKey(e, idx)}
197
+ >
198
+ <span className="hz-ms-check" aria-hidden="true">
199
+ {checked && <Check size={12} />}
200
+ </span>
201
+ <span className="hz-ms-option-label">{opt.label}</span>
202
+ <span className="hz-ms-count">{opt.count}</span>
203
+ </div>
204
+ );
205
+ })
206
+ )}
207
+ </div>
208
+ {values.size > 0 && (
209
+ <div className="hz-ms-footer">
210
+ <button type="button" className="hz-ms-clear" onClick={clear}>
211
+ <X size={12} aria-hidden="true" /> Clear {label.toLowerCase()}
212
+ </button>
213
+ </div>
214
+ )}
215
+ </Popover.Content>
216
+ </Popover.Portal>
217
+ </Popover.Root>
218
+ );
219
+ }
@@ -85,7 +85,11 @@ const desc = description ?? tagline;
85
85
  <UserMenu />
86
86
  </div>
87
87
  )}
88
- <slot />
88
+ {/* Wrap the slot in a flex-growing region so the footer hugs the
89
+ bottom of the viewport even when the page content is short. */}
90
+ <div class="hz-page">
91
+ <slot />
92
+ </div>
89
93
  {showFooter && <Footer repoUrl={repoUrl} />}
90
94
  <script>
91
95
  // Render any <pre class="mermaid"> blocks emitted by rehype-mermaid.
@@ -98,6 +102,25 @@ const desc = description ?? tagline;
98
102
  </script>
99
103
 
100
104
  <style is:global>
105
+ /* Sticky-footer layout: body is a flex column at viewport height
106
+ * minimum; the page-content region grows to fill, pushing the
107
+ * footer to the bottom when content is short. */
108
+ html, body {
109
+ min-height: 100dvh;
110
+ }
111
+ body {
112
+ display: flex;
113
+ flex-direction: column;
114
+ }
115
+ .hz-page {
116
+ flex: 1 0 auto;
117
+ display: flex;
118
+ flex-direction: column;
119
+ }
120
+ .hz-page > * {
121
+ flex: 1 0 auto;
122
+ }
123
+
101
124
  .hz-topbar {
102
125
  position: fixed;
103
126
  top: 0.75rem;
@@ -41,6 +41,16 @@ const stepsByTutorial = new Map(
41
41
  const difficulties = ["beginner", "intermediate", "advanced"];
42
42
  const tags = Array.from(new Set(tutorials.flatMap((t) => t.data.tags))).sort();
43
43
  const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title }));
44
+
45
+ // Hit counts per facet. Computed server-side over the already-loaded
46
+ // tutorials list (no extra IO). Passed to FilterBar as plain Records;
47
+ // Maps don't serialize across the SSR/island boundary.
48
+ const difficultyCounts: Record<string, number> = {};
49
+ const tagCounts: Record<string, number> = {};
50
+ for (const t of tutorials) {
51
+ difficultyCounts[t.data.difficulty] = (difficultyCounts[t.data.difficulty] ?? 0) + 1;
52
+ for (const tag of t.data.tags) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
53
+ }
44
54
  ---
45
55
  <BaseLayout
46
56
  siteName={siteName}
@@ -57,13 +67,21 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
57
67
  <ResumeRail client:load tutorials={compactTutorials} />
58
68
  )}
59
69
 
60
- <FilterBar client:load difficulties={difficulties} tags={tags} />
70
+ <FilterBar
71
+ client:load
72
+ difficulties={difficulties}
73
+ tags={tags}
74
+ difficultyCounts={difficultyCounts}
75
+ tagCounts={tagCounts}
76
+ />
77
+
78
+ <div class="results-status" data-results-status aria-live="polite"></div>
61
79
 
62
80
  <section class="grid">
63
81
  {tutorials.map((tut) => {
64
82
  const steps = stepsByTutorial.get(tut.id) ?? [];
65
83
  const dur = tut.data.estimatedDuration ?? sumDurations(steps);
66
- return <TutorialCard tutorial={tut} duration={dur} />;
84
+ return <TutorialCard tutorial={tut} duration={dur} stepCount={steps.length} />;
67
85
  })}
68
86
  </section>
69
87
 
@@ -88,21 +106,39 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
88
106
  import { getStore } from "../lib/progress/local.ts";
89
107
  function refresh() {
90
108
  const state = getStore().get();
91
- const totals = new Map<string, number>();
109
+ // Only the *numerator* (completed-step count) is computed from
110
+ // localStorage. The *denominator* comes from the card's
111
+ // server-rendered `data-step-count` so a learner who's only
112
+ // visited 1 of 4 steps still sees "1/4", not "1/1".
92
113
  const completed = new Map<string, number>();
93
114
  for (const [key, value] of Object.entries(state.steps)) {
115
+ if (value !== "complete") continue;
94
116
  const slug = key.split("/")[0];
95
117
  if (!slug) continue;
96
- totals.set(slug, (totals.get(slug) ?? 0) + 1);
97
- if (value === "complete") completed.set(slug, (completed.get(slug) ?? 0) + 1);
118
+ completed.set(slug, (completed.get(slug) ?? 0) + 1);
98
119
  }
120
+ // SVG ring geometry: r=9 → circumference 2π·9 ≈ 56.5485.
121
+ const R = 9;
122
+ const C = 2 * Math.PI * R;
99
123
  document.querySelectorAll<HTMLElement>("[data-tutorial-slug]").forEach((el) => {
100
124
  const slug = el.dataset.tutorialSlug!;
101
- const done = completed.get(slug) ?? 0;
102
- const total = totals.get(slug) ?? 0;
103
- if (total === 0) return;
104
- const pct = Math.round((done / total) * 100);
105
- el.innerHTML = `<div class="mini-bar"><div style="width:${pct}%"></div></div><span>${done}/${total} steps</span>`;
125
+ const total = Number(el.dataset.stepCount ?? "0");
126
+ const done = Math.min(completed.get(slug) ?? 0, total);
127
+ if (total === 0 || done === 0) {
128
+ el.innerHTML = "";
129
+ return;
130
+ }
131
+ const pct = done / total;
132
+ const offset = C * (1 - pct);
133
+ el.setAttribute("title", `${done} of ${total} steps complete`);
134
+ el.innerHTML =
135
+ `<span class="ring-label">${done}/${total}</span>` +
136
+ `<svg class="ring" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">` +
137
+ `<circle class="ring-track" cx="12" cy="12" r="${R}"></circle>` +
138
+ `<circle class="ring-fill" cx="12" cy="12" r="${R}" ` +
139
+ `stroke-dasharray="${C.toFixed(2)}" ` +
140
+ `stroke-dashoffset="${offset.toFixed(2)}"></circle>` +
141
+ `</svg>`;
106
142
  });
107
143
  }
108
144
  refresh();
@@ -190,6 +226,12 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
190
226
 
191
227
  <style>
192
228
  .home {
229
+ /* width: 100% defeats the auto-margin space-absorption that
230
+ * BaseLayout's flex-column body would otherwise apply, so the
231
+ * page actually reaches its max-width at wide viewports. Without
232
+ * this, auto margins eat all the slack and the column collapses
233
+ * to its content width. */
234
+ width: 100%;
193
235
  max-width: 80rem;
194
236
  margin: 0 auto;
195
237
  padding: 0 clamp(1rem, 4vw, 2rem) 4rem;
@@ -198,8 +240,16 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
198
240
  display: grid;
199
241
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
200
242
  gap: 1rem;
201
- margin-top: 1.5rem;
243
+ margin-top: 0.75rem;
244
+ }
245
+ .results-status {
246
+ margin-top: 1rem;
247
+ font-family: var(--font-mono);
248
+ font-size: 0.78em;
249
+ color: var(--color-muted);
250
+ min-height: 1em;
202
251
  }
252
+ .results-status:empty { display: none; }
203
253
  .empty {
204
254
  margin: 2rem 0;
205
255
  padding: 2rem;
@@ -252,107 +302,10 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
252
302
  }
253
303
  .resume-rail:hover .rr-arrow { transform: translateX(3px); }
254
304
 
255
- /* ---------- Filter bar ---------- */
256
- /* Two rows: search + level + clear on top, tag pills on a wrapping
257
- * second row. Replaces the previous one-line flex-wrap that crammed
258
- * 20+ pills into the same row as the input. */
259
- /* Consistent vertical rhythm on the home page:
260
- * - Every block (resume rail, filterbar, grid) gets 1.5rem above.
261
- * - Inside the filterbar, the search row → tags row gap is 1rem.
262
- * The previous design also had padding-bottom + border-bottom on
263
- * the filterbar, which doubled the spacing below the tags
264
- * relative to the 1.5rem above the search bar — that visible
265
- * imbalance is gone now. */
266
- .filterbar {
267
- display: flex;
268
- flex-direction: column;
269
- gap: 1.5rem;
270
- margin-top: 1.5rem;
271
- }
272
- .fb-row-primary {
273
- display: flex;
274
- gap: 0.75rem;
275
- align-items: stretch;
276
- flex-wrap: wrap;
277
- }
278
- /* Stretch the pills group itself so each pill can flex to row height */
279
- .fb-row-primary .pills {
280
- align-items: stretch;
281
- }
282
- .filterbar .search {
283
- flex: 1 1 22rem;
284
- display: inline-flex;
285
- align-items: center;
286
- gap: 0.5rem;
287
- padding: 0.45rem 0.7rem;
288
- border: 1px solid var(--color-border);
289
- color: var(--color-muted);
290
- transition: border-color 0.12s ease, color 0.12s ease;
291
- }
292
- .filterbar .search:focus-within {
293
- border-color: var(--color-accent);
294
- color: var(--color-fg);
295
- }
296
- .filterbar input {
297
- background: transparent;
298
- border: 0;
299
- color: var(--color-fg);
300
- font: inherit;
301
- outline: none;
302
- width: 100%;
303
- }
304
- .pills {
305
- display: inline-flex;
306
- flex-wrap: wrap;
307
- gap: 0.3rem;
308
- align-items: center;
309
- }
310
- .pill {
311
- padding: 0.3rem 0.65rem;
312
- border: 1px solid var(--color-border);
313
- background: transparent;
314
- color: var(--color-muted);
315
- font-family: var(--font-mono);
316
- font-size: 0.75em;
317
- cursor: pointer;
318
- text-transform: lowercase;
319
- transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
320
- }
321
- /* Pills in the primary row stretch to match the search input's
322
- * height so the whole row reads as one strip. align-items on the
323
- * inner flex re-centers the text within each grown pill. Tag pills
324
- * stay compact on the secondary row. */
325
- .fb-row-primary .pill,
326
- .fb-row-primary .clear {
327
- padding: 0 0.85rem;
328
- align-items: center;
329
- }
330
- .pill:hover {
331
- border-color: var(--color-accent);
332
- color: var(--color-fg);
333
- }
334
- .pill.is-active {
335
- background: var(--color-accent);
336
- color: var(--color-accent-fg);
337
- border-color: var(--color-accent);
338
- }
339
- .clear {
340
- display: inline-flex;
341
- align-items: center;
342
- gap: 0.3rem;
343
- padding: 0.3rem 0.6rem;
344
- background: transparent;
345
- border: 1px solid var(--color-border);
346
- color: var(--color-muted);
347
- font-family: var(--font-mono);
348
- font-size: 0.75em;
349
- cursor: pointer;
350
- transition: color 0.12s ease, border-color 0.12s ease;
351
- }
352
- .clear:hover { color: var(--color-fg); border-color: var(--color-fg); }
353
-
354
- /* Cards hidden by filter and/or pagination. Both layers set their
355
- * own data-attribute so they compose without stomping each other. */
305
+ /* Filterbar + multi-select popover + pill styles live in
306
+ * packages/core/styles/components/filterbar.css and multiselect.css.
307
+ * Cards hidden by filter and/or pagination both layers compose
308
+ * via their own data-attribute. */
356
309
  [data-filter-hidden],
357
310
  [data-page-hidden] {
358
311
  display: none !important;
@@ -388,21 +341,4 @@ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title
388
341
  }
389
342
  .pg-status strong { color: var(--color-fg); }
390
343
 
391
- /* ---------- Per-card mini progress bar ---------- */
392
- [data-tutorial-slug] {
393
- display: flex;
394
- align-items: center;
395
- gap: 0.5rem;
396
- margin-top: 0.75rem;
397
- font-family: var(--font-mono);
398
- font-size: 0.7em;
399
- color: var(--color-muted);
400
- }
401
- .mini-bar {
402
- flex: 1;
403
- height: 3px;
404
- background: var(--color-surface);
405
- overflow: hidden;
406
- }
407
- .mini-bar div { background: var(--color-accent); height: 100%; }
408
344
  </style>