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.
- package/package.json +3 -1
- package/src/components/auth/UserMenu.tsx +14 -7
- package/src/components/home/ActiveFilterChips.tsx +91 -0
- package/src/components/home/FilterBar.tsx +128 -77
- package/src/components/home/SortBar.tsx +65 -0
- package/src/components/home/TutorialCard.astro +57 -8
- package/src/components/ui/Dropdown.tsx +99 -0
- package/src/components/ui/MultiSelect.tsx +219 -0
- package/src/layouts/BaseLayout.astro +24 -1
- package/src/pages/Home.astro +65 -129
- package/styles/components/dropdown.css +144 -0
- package/styles/components/filterbar.css +128 -0
- package/styles/components/multiselect.css +206 -0
- package/styles/components.css +3 -0
|
@@ -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
|
-
|
|
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;
|
package/src/pages/Home.astro
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
const
|
|
103
|
-
if (total === 0)
|
|
104
|
-
|
|
105
|
-
|
|
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:
|
|
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
|
-
/*
|
|
256
|
-
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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>
|