handzon-core 0.6.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/package.json +74 -0
- package/src/collections.ts +150 -0
- package/src/components/Footer.astro +85 -0
- package/src/components/Navbar.astro +74 -0
- package/src/components/Progress.tsx +36 -0
- package/src/components/Sidebar.astro +162 -0
- package/src/components/StepNav.astro +107 -0
- package/src/components/ai/ByokSetup.tsx +90 -0
- package/src/components/ai/ChatButton.tsx +30 -0
- package/src/components/ai/ChatPanel.tsx +244 -0
- package/src/components/auth/SignInButton.astro +41 -0
- package/src/components/auth/UserMenu.astro +79 -0
- package/src/components/auth/UserMenu.tsx +136 -0
- package/src/components/home/FilterBar.tsx +152 -0
- package/src/components/home/Hero.astro +60 -0
- package/src/components/home/Pagination.tsx +89 -0
- package/src/components/home/ResumeRail.tsx +50 -0
- package/src/components/home/TutorialCard.astro +185 -0
- package/src/components/mdx/Callout.astro +77 -0
- package/src/components/mdx/Checkpoint.astro +14 -0
- package/src/components/mdx/Checkpoint.tsx +49 -0
- package/src/components/mdx/Diff.astro +6 -0
- package/src/components/mdx/Diff.tsx +100 -0
- package/src/components/mdx/Download.astro +37 -0
- package/src/components/mdx/Embed.astro +56 -0
- package/src/components/mdx/File.astro +28 -0
- package/src/components/mdx/FileTree.astro +6 -0
- package/src/components/mdx/FileTree.tsx +71 -0
- package/src/components/mdx/Hint.astro +51 -0
- package/src/components/mdx/Mermaid.astro +6 -0
- package/src/components/mdx/Mermaid.tsx +47 -0
- package/src/components/mdx/Playground.astro +6 -0
- package/src/components/mdx/Playground.tsx +34 -0
- package/src/components/mdx/Quiz.astro +6 -0
- package/src/components/mdx/Quiz.tsx +102 -0
- package/src/components/mdx/Recap.astro +65 -0
- package/src/components/mdx/Reveal.astro +7 -0
- package/src/components/mdx/Reveal.tsx +25 -0
- package/src/components/mdx/Step.astro +12 -0
- package/src/components/mdx/Steps.astro +40 -0
- package/src/components/mdx/Tab.astro +22 -0
- package/src/components/mdx/Tabs.astro +67 -0
- package/src/components/mdx/Terminal.astro +6 -0
- package/src/components/mdx/Terminal.tsx +47 -0
- package/src/index.ts +55 -0
- package/src/layouts/BaseLayout.astro +112 -0
- package/src/layouts/TutorialLayout.astro +218 -0
- package/src/lib/ai/client.ts +92 -0
- package/src/lib/ai/context.ts +97 -0
- package/src/lib/content.ts +73 -0
- package/src/lib/mdx-components.ts +47 -0
- package/src/lib/progress/local.ts +89 -0
- package/src/lib/progress/remote.ts +199 -0
- package/src/lib/progress/types.ts +63 -0
- package/src/lib/progress/useProgress.ts +117 -0
- package/src/lib/rehype-mermaid-passthrough.ts +31 -0
- package/src/pages/Home.astro +408 -0
- package/src/pages/TutorialLanding.astro +324 -0
- package/src/pages/TutorialStep.astro +67 -0
- package/src/pages/paths.ts +36 -0
- package/src/server/auth/config.ts +102 -0
- package/src/server/auth/schema.ts +66 -0
- package/src/server/auth/session.ts +27 -0
- package/src/server/auth.ts +127 -0
- package/src/server/db/client.ts +14 -0
- package/src/server/db/migrate.ts +29 -0
- package/src/server/db/schema.ts +65 -0
- package/src/server/handlers/healthz.ts +6 -0
- package/src/server/handlers/progress.ts +90 -0
- package/src/server/handlers/tutorialStats.ts +67 -0
- package/src/server/http.ts +33 -0
- package/src/types/ai.ts +17 -0
- package/styles/base.css +127 -0
- package/styles/components/a11y.css +12 -0
- package/styles/components/byok.css +50 -0
- package/styles/components/chat.css +304 -0
- package/styles/components/checkpoint.css +49 -0
- package/styles/components/diff.css +44 -0
- package/styles/components/expressive-code.css +61 -0
- package/styles/components/filetree.css +68 -0
- package/styles/components/mermaid.css +19 -0
- package/styles/components/modal.css +25 -0
- package/styles/components/progress.css +19 -0
- package/styles/components/quiz.css +101 -0
- package/styles/components/reveal.css +25 -0
- package/styles/components/tabs.css +60 -0
- package/styles/components/terminal.css +55 -0
- package/styles/components.css +28 -0
- package/styles/global.css +15 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Element, Root, Text } from "hast";
|
|
2
|
+
import { visit } from "unist-util-visit";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rewrite <pre><code class="language-mermaid">…</code></pre> into
|
|
6
|
+
* <pre class="mermaid">…</pre> so the client-side mermaid loader in
|
|
7
|
+
* BaseLayout can render it. Zero build-time deps (no playwright).
|
|
8
|
+
*/
|
|
9
|
+
export default function rehypeMermaidPassthrough() {
|
|
10
|
+
return (tree: Root) => {
|
|
11
|
+
visit(tree, "element", (node: Element, index, parent) => {
|
|
12
|
+
if (node.tagName !== "pre" || !parent || index === undefined) return;
|
|
13
|
+
const code = node.children.find(
|
|
14
|
+
(c): c is Element => c.type === "element" && c.tagName === "code",
|
|
15
|
+
);
|
|
16
|
+
if (!code) return;
|
|
17
|
+
const classes = (code.properties?.className as string[] | undefined) ?? [];
|
|
18
|
+
if (!classes.includes("language-mermaid")) return;
|
|
19
|
+
const source = code.children
|
|
20
|
+
.filter((c): c is Text => c.type === "text")
|
|
21
|
+
.map((c) => c.value)
|
|
22
|
+
.join("");
|
|
23
|
+
(parent.children as Element[])[index] = {
|
|
24
|
+
type: "element",
|
|
25
|
+
tagName: "pre",
|
|
26
|
+
properties: { className: ["mermaid"] },
|
|
27
|
+
children: [{ type: "text", value: source }],
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
3
|
+
import Hero from "../components/home/Hero.astro";
|
|
4
|
+
import TutorialCard from "../components/home/TutorialCard.astro";
|
|
5
|
+
import FilterBar from "../components/home/FilterBar.tsx";
|
|
6
|
+
import Pagination from "../components/home/Pagination.tsx";
|
|
7
|
+
import ResumeRail from "../components/home/ResumeRail.tsx";
|
|
8
|
+
import { getTutorials, getStepsForTutorial, sumDurations } from "../lib/content.ts";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
siteName?: string;
|
|
12
|
+
tagline?: string;
|
|
13
|
+
hero?: { title?: string; subtitle?: string };
|
|
14
|
+
logoUrl?: string;
|
|
15
|
+
faviconUrl?: string;
|
|
16
|
+
repoUrl?: string;
|
|
17
|
+
showResumeRail?: boolean;
|
|
18
|
+
emptyStateCommand?: string;
|
|
19
|
+
/** Tutorials per page on the grid. */
|
|
20
|
+
pageSize?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
siteName = "Handzon",
|
|
25
|
+
tagline = "Hands-on tutorials",
|
|
26
|
+
hero,
|
|
27
|
+
logoUrl,
|
|
28
|
+
faviconUrl,
|
|
29
|
+
repoUrl,
|
|
30
|
+
showResumeRail = true,
|
|
31
|
+
emptyStateCommand = "pnpm handzon:new",
|
|
32
|
+
pageSize = 9,
|
|
33
|
+
} = Astro.props;
|
|
34
|
+
|
|
35
|
+
const tutorials = await getTutorials();
|
|
36
|
+
const stepsByTutorial = new Map(
|
|
37
|
+
await Promise.all(
|
|
38
|
+
tutorials.map(async (t) => [t.id, await getStepsForTutorial(t.id)] as const),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
const difficulties = ["beginner", "intermediate", "advanced"];
|
|
42
|
+
const tags = Array.from(new Set(tutorials.flatMap((t) => t.data.tags))).sort();
|
|
43
|
+
const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title }));
|
|
44
|
+
---
|
|
45
|
+
<BaseLayout
|
|
46
|
+
siteName={siteName}
|
|
47
|
+
tagline={tagline}
|
|
48
|
+
logoUrl={logoUrl}
|
|
49
|
+
faviconUrl={faviconUrl}
|
|
50
|
+
repoUrl={repoUrl}
|
|
51
|
+
nav="userMenu"
|
|
52
|
+
>
|
|
53
|
+
<div class="home">
|
|
54
|
+
<Hero title={hero?.title} subtitle={hero?.subtitle} logoUrl={logoUrl} />
|
|
55
|
+
|
|
56
|
+
{showResumeRail && (
|
|
57
|
+
<ResumeRail client:load tutorials={compactTutorials} />
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
<FilterBar client:load difficulties={difficulties} tags={tags} />
|
|
61
|
+
|
|
62
|
+
<section class="grid">
|
|
63
|
+
{tutorials.map((tut) => {
|
|
64
|
+
const steps = stepsByTutorial.get(tut.id) ?? [];
|
|
65
|
+
const dur = tut.data.estimatedDuration ?? sumDurations(steps);
|
|
66
|
+
return <TutorialCard tutorial={tut} duration={dur} />;
|
|
67
|
+
})}
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<Pagination client:load pageSize={pageSize} />
|
|
71
|
+
|
|
72
|
+
<div class="empty" data-empty-state hidden>
|
|
73
|
+
<p>No tutorials match the current filters.</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{tutorials.length === 0 && (
|
|
77
|
+
<div class="empty">
|
|
78
|
+
<p>
|
|
79
|
+
No tutorials yet. Run <code>{emptyStateCommand}</code> to create your first one.
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<script>
|
|
86
|
+
// Hydrate per-card progress mini-bars from localStorage. Use a subpath
|
|
87
|
+
// import to avoid pulling the server-only barrel into the client bundle.
|
|
88
|
+
import { getStore } from "../lib/progress/local.ts";
|
|
89
|
+
function refresh() {
|
|
90
|
+
const state = getStore().get();
|
|
91
|
+
const totals = new Map<string, number>();
|
|
92
|
+
const completed = new Map<string, number>();
|
|
93
|
+
for (const [key, value] of Object.entries(state.steps)) {
|
|
94
|
+
const slug = key.split("/")[0];
|
|
95
|
+
if (!slug) continue;
|
|
96
|
+
totals.set(slug, (totals.get(slug) ?? 0) + 1);
|
|
97
|
+
if (value === "complete") completed.set(slug, (completed.get(slug) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
document.querySelectorAll<HTMLElement>("[data-tutorial-slug]").forEach((el) => {
|
|
100
|
+
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>`;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
refresh();
|
|
109
|
+
getStore().subscribe(refresh);
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<script>
|
|
113
|
+
// Hydrate cross-learner popularity numbers and the `data-popularity`
|
|
114
|
+
// attribute used by the FilterBar's "Popular" sort. Single fetch on
|
|
115
|
+
// mount; Cache-Control: max-age=60 on the response handles SPA-like
|
|
116
|
+
// navigations. Failures are silent — cards just show no numbers,
|
|
117
|
+
// which is the desired state on Tier 1 anyway.
|
|
118
|
+
type Stat = { slug: string; started: number; completed: number };
|
|
119
|
+
function fmt(n: number): string {
|
|
120
|
+
if (n < 1000) return String(n);
|
|
121
|
+
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
122
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
123
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
124
|
+
}
|
|
125
|
+
function popularityScore(stat: Stat): number {
|
|
126
|
+
// Weight completion higher than start. Completed learners are a
|
|
127
|
+
// much stronger signal than visitors who bounced after step 1.
|
|
128
|
+
return stat.completed * 3 + stat.started;
|
|
129
|
+
}
|
|
130
|
+
async function hydrateStats() {
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch("/api/tutorials/stats", { credentials: "same-origin" });
|
|
133
|
+
if (!res.ok) return;
|
|
134
|
+
const data = (await res.json()) as { stats: Stat[] };
|
|
135
|
+
for (const stat of data.stats) {
|
|
136
|
+
const card = document.querySelector<HTMLElement>(
|
|
137
|
+
`[data-card-slug="${CSS.escape(stat.slug)}"]`,
|
|
138
|
+
);
|
|
139
|
+
if (!card) continue;
|
|
140
|
+
card.dataset.popularity = String(popularityScore(stat));
|
|
141
|
+
const slot = card.querySelector<HTMLElement>(
|
|
142
|
+
`[data-tutorial-stats-slug="${CSS.escape(stat.slug)}"]`,
|
|
143
|
+
);
|
|
144
|
+
if (!slot) continue;
|
|
145
|
+
if (stat.started === 0 && stat.completed === 0) continue;
|
|
146
|
+
slot.innerHTML =
|
|
147
|
+
`<span class="stat" title="Unique learners started">▶ ${fmt(stat.started)} started</span>` +
|
|
148
|
+
`<span class="stat-divider">·</span>` +
|
|
149
|
+
`<span class="stat" title="Unique learners completed">✓ ${fmt(stat.completed)} finished</span>`;
|
|
150
|
+
}
|
|
151
|
+
window.dispatchEvent(new CustomEvent("hz:stats-loaded"));
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
hydrateStats();
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
<script>
|
|
160
|
+
// Popularity sort: re-orders cards in place by writing CSS `order`
|
|
161
|
+
// from `data-popularity`. Composes with the existing filter/page
|
|
162
|
+
// hide attributes because order is a flex/grid layout concern, not
|
|
163
|
+
// a visibility concern.
|
|
164
|
+
function applySort(mode: string) {
|
|
165
|
+
const cards = document.querySelectorAll<HTMLElement>("[data-card-slug]");
|
|
166
|
+
if (mode === "popular") {
|
|
167
|
+
const sorted = Array.from(cards).sort((a, b) => {
|
|
168
|
+
return Number(b.dataset.popularity ?? 0) - Number(a.dataset.popularity ?? 0);
|
|
169
|
+
});
|
|
170
|
+
sorted.forEach((card, idx) => {
|
|
171
|
+
card.style.order = String(idx);
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
cards.forEach((card) => {
|
|
175
|
+
card.style.removeProperty("order");
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function syncFromUrl() {
|
|
180
|
+
const url = new URL(window.location.href);
|
|
181
|
+
applySort(url.searchParams.get("sort") ?? "");
|
|
182
|
+
}
|
|
183
|
+
syncFromUrl();
|
|
184
|
+
window.addEventListener("hz:sort-changed", (e) => {
|
|
185
|
+
applySort((e as CustomEvent<{ sort: string }>).detail.sort);
|
|
186
|
+
});
|
|
187
|
+
window.addEventListener("hz:stats-loaded", syncFromUrl);
|
|
188
|
+
</script>
|
|
189
|
+
</BaseLayout>
|
|
190
|
+
|
|
191
|
+
<style>
|
|
192
|
+
.home {
|
|
193
|
+
max-width: 80rem;
|
|
194
|
+
margin: 0 auto;
|
|
195
|
+
padding: 0 clamp(1rem, 4vw, 2rem) 4rem;
|
|
196
|
+
}
|
|
197
|
+
.grid {
|
|
198
|
+
display: grid;
|
|
199
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
200
|
+
gap: 1rem;
|
|
201
|
+
margin-top: 1.5rem;
|
|
202
|
+
}
|
|
203
|
+
.empty {
|
|
204
|
+
margin: 2rem 0;
|
|
205
|
+
padding: 2rem;
|
|
206
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
207
|
+
text-align: center;
|
|
208
|
+
color: var(--color-muted);
|
|
209
|
+
}
|
|
210
|
+
</style>
|
|
211
|
+
|
|
212
|
+
<style is:global>
|
|
213
|
+
/* ---------- Resume rail ---------- */
|
|
214
|
+
/* One coherent link, not a stack of three competing styles. Border
|
|
215
|
+
* lights up on hover; the trailing → is the accent. Everything else
|
|
216
|
+
* sits on a single muted/foreground baseline. */
|
|
217
|
+
.resume-rail {
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: baseline;
|
|
220
|
+
gap: 0.5rem;
|
|
221
|
+
margin-top: 1.5rem;
|
|
222
|
+
padding: 0.7rem 0.95rem;
|
|
223
|
+
border: 1px solid var(--color-border);
|
|
224
|
+
color: var(--color-fg);
|
|
225
|
+
font-size: 0.95em;
|
|
226
|
+
text-decoration: none;
|
|
227
|
+
transition: border-color 0.12s ease, background 0.12s ease;
|
|
228
|
+
}
|
|
229
|
+
.resume-rail:hover {
|
|
230
|
+
border-color: var(--color-accent);
|
|
231
|
+
background: color-mix(in oklab, var(--color-accent) 6%, transparent);
|
|
232
|
+
}
|
|
233
|
+
.rr-prefix {
|
|
234
|
+
font-family: var(--font-mono);
|
|
235
|
+
font-size: 0.78em;
|
|
236
|
+
text-transform: uppercase;
|
|
237
|
+
letter-spacing: 0.08em;
|
|
238
|
+
color: var(--color-muted);
|
|
239
|
+
}
|
|
240
|
+
.rr-title { font-weight: 600; }
|
|
241
|
+
.rr-step {
|
|
242
|
+
color: var(--color-muted);
|
|
243
|
+
overflow: hidden;
|
|
244
|
+
text-overflow: ellipsis;
|
|
245
|
+
white-space: nowrap;
|
|
246
|
+
}
|
|
247
|
+
.rr-arrow {
|
|
248
|
+
margin-left: auto;
|
|
249
|
+
color: var(--color-accent);
|
|
250
|
+
font-family: var(--font-mono);
|
|
251
|
+
transition: transform 0.12s ease;
|
|
252
|
+
}
|
|
253
|
+
.resume-rail:hover .rr-arrow { transform: translateX(3px); }
|
|
254
|
+
|
|
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. */
|
|
356
|
+
[data-filter-hidden],
|
|
357
|
+
[data-page-hidden] {
|
|
358
|
+
display: none !important;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ---------- Pagination ---------- */
|
|
362
|
+
.pagination {
|
|
363
|
+
margin-top: 1.5rem;
|
|
364
|
+
display: flex;
|
|
365
|
+
justify-content: center;
|
|
366
|
+
align-items: center;
|
|
367
|
+
gap: 1rem;
|
|
368
|
+
font-family: var(--font-mono);
|
|
369
|
+
font-size: 0.85em;
|
|
370
|
+
color: var(--color-muted);
|
|
371
|
+
}
|
|
372
|
+
.pg-btn {
|
|
373
|
+
padding: 0.45rem 0.85rem;
|
|
374
|
+
background: transparent;
|
|
375
|
+
border: 1px solid var(--color-border);
|
|
376
|
+
color: var(--color-fg);
|
|
377
|
+
font: inherit;
|
|
378
|
+
cursor: pointer;
|
|
379
|
+
transition: border-color 0.12s ease, color 0.12s ease;
|
|
380
|
+
}
|
|
381
|
+
.pg-btn:hover:not(:disabled) {
|
|
382
|
+
border-color: var(--color-accent);
|
|
383
|
+
color: var(--color-accent);
|
|
384
|
+
}
|
|
385
|
+
.pg-btn:disabled {
|
|
386
|
+
opacity: 0.4;
|
|
387
|
+
cursor: not-allowed;
|
|
388
|
+
}
|
|
389
|
+
.pg-status strong { color: var(--color-fg); }
|
|
390
|
+
|
|
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
|
+
</style>
|