handzon-core 0.6.1 → 0.7.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.
@@ -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>
@@ -5,8 +5,9 @@
5
5
  * export const getStaticPaths = getTutorialLandingPaths;
6
6
  * export const getStaticPaths = getTutorialStepPaths;
7
7
  */
8
- import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
8
+
9
9
  import type { StepEntry, TutorialEntry } from "../lib/content.ts";
10
+ import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
10
11
 
11
12
  export async function getTutorialLandingPaths() {
12
13
  const tutorials = await getTutorials();
@@ -10,8 +10,9 @@
10
10
  * GitHub is the only provider in 0.2; email/password and others are
11
11
  * out of scope (see plan: github-auth_d52529d5).
12
12
  */
13
- import { DrizzleAdapter } from "@auth/drizzle-adapter";
13
+
14
14
  import GitHub from "@auth/core/providers/github";
15
+ import { DrizzleAdapter } from "@auth/drizzle-adapter";
15
16
  import { defineConfig } from "auth-astro";
16
17
  import { accounts, sessions, users, verificationTokens } from "./schema.ts";
17
18
 
@@ -6,14 +6,7 @@
6
6
  * The `learners` row that maps a signed-in user to local progress lives
7
7
  * in `../db/schema.ts` — it adds a nullable `user_id` FK to `users` here.
8
8
  */
9
- import {
10
- integer,
11
- pgTable,
12
- primaryKey,
13
- text,
14
- timestamp,
15
- uuid,
16
- } from "drizzle-orm/pg-core";
9
+ import { integer, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
17
10
 
18
11
  export const users = pgTable("users", {
19
12
  id: uuid("id").primaryKey().defaultRandom(),
@@ -61,11 +61,7 @@ export async function getOrCreateLearner(
61
61
  // Anonymous path — unchanged from pre-auth behaviour.
62
62
  let deviceId = cookies.get(COOKIE)?.value;
63
63
  if (deviceId) {
64
- const found = await db
65
- .select()
66
- .from(learners)
67
- .where(eq(learners.deviceId, deviceId))
68
- .limit(1);
64
+ const found = await db.select().from(learners).where(eq(learners.deviceId, deviceId)).limit(1);
69
65
  if (found[0]) return { id: found[0].id, deviceId };
70
66
  }
71
67
  deviceId = randomDeviceId();
@@ -13,7 +13,7 @@ import { users } from "../auth/schema.ts";
13
13
 
14
14
  // Re-export the Auth.js tables so consumers (and drizzle-kit) see one
15
15
  // schema barrel.
16
- export { users, accounts, sessions, verificationTokens } from "../auth/schema.ts";
16
+ export { accounts, sessions, users, verificationTokens } from "../auth/schema.ts";
17
17
 
18
18
  export const learners = pgTable(
19
19
  "learners",
@@ -1,5 +1,5 @@
1
1
  import type { APIRoute } from "astro";
2
- import { eq, sql } from "drizzle-orm";
2
+ import { and, eq, sql } from "drizzle-orm";
3
3
  import { z } from "zod";
4
4
  import { getOrCreateLearner } from "../auth.ts";
5
5
  import { getDb } from "../db/client.ts";
@@ -61,30 +61,53 @@ export const POST: APIRoute = async ({ cookies, request }) => {
61
61
  const learner = await getOrCreateLearner(cookies, request);
62
62
  const db = getDb();
63
63
  const now = new Date();
64
- const rows = parsed.map((b) => ({
65
- learnerId: learner.id,
66
- kind: b.kind,
67
- scope: b.scope,
68
- key: b.key,
69
- value: b.value,
70
- updatedAt: now,
71
- }));
72
- await db
73
- .insert(progressEntries)
74
- .values(rows)
75
- .onConflictDoUpdate({
76
- target: [
77
- progressEntries.learnerId,
78
- progressEntries.kind,
79
- progressEntries.scope,
80
- progressEntries.key,
81
- ],
82
- set: {
83
- // `excluded` is the row Postgres would have inserted — without
84
- // this the SET was a no-op (`value = progress_entries.value`).
85
- value: sql`excluded.value`,
86
- updatedAt: sql`excluded.updated_at`,
87
- },
88
- });
89
- return json({ written: rows.length });
64
+
65
+ // `value: null` is the tombstone signal for "this entry was undone"
66
+ // (e.g. unchecking a checkpoint). The `value` column is NOT NULL, so
67
+ // we DELETE these rows instead of upserting them.
68
+ const deletes = parsed.filter((b) => b.value === null);
69
+ const upserts = parsed.filter((b) => b.value !== null);
70
+
71
+ for (const d of deletes) {
72
+ await db
73
+ .delete(progressEntries)
74
+ .where(
75
+ and(
76
+ eq(progressEntries.learnerId, learner.id),
77
+ eq(progressEntries.kind, d.kind),
78
+ eq(progressEntries.scope, d.scope),
79
+ eq(progressEntries.key, d.key),
80
+ ),
81
+ );
82
+ }
83
+
84
+ if (upserts.length > 0) {
85
+ const rows = upserts.map((b) => ({
86
+ learnerId: learner.id,
87
+ kind: b.kind,
88
+ scope: b.scope,
89
+ key: b.key,
90
+ value: b.value,
91
+ updatedAt: now,
92
+ }));
93
+ await db
94
+ .insert(progressEntries)
95
+ .values(rows)
96
+ .onConflictDoUpdate({
97
+ target: [
98
+ progressEntries.learnerId,
99
+ progressEntries.kind,
100
+ progressEntries.scope,
101
+ progressEntries.key,
102
+ ],
103
+ set: {
104
+ // `excluded` is the row Postgres would have inserted — without
105
+ // this the SET was a no-op (`value = progress_entries.value`).
106
+ value: sql`excluded.value`,
107
+ updatedAt: sql`excluded.updated_at`,
108
+ },
109
+ });
110
+ }
111
+
112
+ return json({ written: parsed.length });
90
113
  };
@@ -29,9 +29,12 @@ let cache: CacheEntry | null = null;
29
29
  */
30
30
  export const GET: APIRoute = async () => {
31
31
  if (!process.env.DATABASE_URL) {
32
- return json({ stats: [] satisfies TutorialStat[] }, {
33
- headers: { "Cache-Control": "public, max-age=60" },
34
- });
32
+ return json(
33
+ { stats: [] satisfies TutorialStat[] },
34
+ {
35
+ headers: { "Cache-Control": "public, max-age=60" },
36
+ },
37
+ );
35
38
  }
36
39
  const now = Date.now();
37
40
  if (cache && cache.expiresAt > now) {
@@ -0,0 +1,144 @@
1
+ /* Shared dropdown component (handzon-core/src/components/ui/Dropdown.tsx).
2
+ *
3
+ * Wraps Radix Select so we never ship the browser-native dropdown UI.
4
+ * Every dropdown in handzon-core inherits these styles — keep the look
5
+ * mono/brutalist (hard edges, uppercase mono label chip, accent border
6
+ * on focus / open) so dropdowns sit alongside the search input, pills,
7
+ * and pagination buttons without visual drift.
8
+ */
9
+
10
+ .hz-dd {
11
+ display: inline-flex;
12
+ align-items: stretch;
13
+ font-family: var(--font-mono);
14
+ font-size: 0.78em;
15
+ }
16
+
17
+ /* Uppercase mono label chip glued to the trigger. */
18
+ .hz-dd-label {
19
+ display: inline-flex;
20
+ align-items: center;
21
+ padding: 0 0.6rem;
22
+ border: 1px solid var(--color-border);
23
+ border-right: 0;
24
+ color: var(--color-muted);
25
+ text-transform: uppercase;
26
+ letter-spacing: 0.06em;
27
+ user-select: none;
28
+ }
29
+
30
+ .hz-dd-trigger {
31
+ display: inline-flex;
32
+ align-items: center;
33
+ gap: 0.45rem;
34
+ padding: 0.4rem 0.6rem;
35
+ /* Match the search input's natural height so the whole toolbar row
36
+ * shares a single baseline. Without this, the Sort dropdown reads
37
+ * ~6px shorter than the multi-select triggers which stretch via
38
+ * the toolbar's align-items: stretch. */
39
+ min-height: 2.5rem;
40
+ background: transparent;
41
+ border: 1px solid var(--color-border);
42
+ color: var(--color-fg);
43
+ font: inherit;
44
+ cursor: pointer;
45
+ outline: none;
46
+ transition: border-color 0.12s ease, color 0.12s ease;
47
+ }
48
+ .hz-dd-trigger:hover {
49
+ border-color: var(--color-accent);
50
+ }
51
+ .hz-dd-trigger:focus-visible,
52
+ .hz-dd-trigger[data-state="open"] {
53
+ border-color: var(--color-accent);
54
+ color: var(--color-accent);
55
+ }
56
+ .hz-dd-trigger[data-placeholder] {
57
+ color: var(--color-muted);
58
+ }
59
+
60
+ .hz-dd-tricon {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ color: var(--color-muted);
64
+ }
65
+
66
+ .hz-dd-caret {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ margin-left: 0.15rem;
70
+ color: var(--color-muted);
71
+ transition: transform 0.15s ease;
72
+ }
73
+ .hz-dd-trigger[data-state="open"] .hz-dd-caret {
74
+ transform: rotate(180deg);
75
+ color: var(--color-accent);
76
+ }
77
+
78
+ /* ---------- Popover ---------- */
79
+
80
+ .hz-dd-content {
81
+ /* Radix Portal renders this outside the normal flow — float it above
82
+ * the page chrome, including the sticky sidebar (z=10) and the fixed
83
+ * topbar (z=50). */
84
+ z-index: 100;
85
+ min-width: var(--radix-select-trigger-width);
86
+ max-height: var(--radix-select-content-available-height);
87
+ background: var(--color-bg);
88
+ border: 1px solid var(--color-border);
89
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
90
+ overflow: hidden;
91
+ }
92
+
93
+ .hz-dd-viewport {
94
+ padding: 0.25rem 0;
95
+ }
96
+
97
+ .hz-dd-item {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 0.5rem;
101
+ padding: 0.45rem 0.75rem;
102
+ color: var(--color-fg);
103
+ cursor: pointer;
104
+ user-select: none;
105
+ outline: none;
106
+ }
107
+ .hz-dd-item[data-highlighted] {
108
+ background: var(--color-surface);
109
+ color: var(--color-accent);
110
+ }
111
+ .hz-dd-item[data-state="checked"] {
112
+ color: var(--color-accent);
113
+ }
114
+ .hz-dd-item[data-disabled] {
115
+ opacity: 0.4;
116
+ pointer-events: none;
117
+ }
118
+
119
+ .hz-dd-icon {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ color: var(--color-muted);
123
+ }
124
+ .hz-dd-item[data-highlighted] .hz-dd-icon,
125
+ .hz-dd-item[data-state="checked"] .hz-dd-icon {
126
+ color: var(--color-accent);
127
+ }
128
+
129
+ .hz-dd-check {
130
+ margin-left: auto;
131
+ display: inline-flex;
132
+ align-items: center;
133
+ color: var(--color-accent);
134
+ }
135
+
136
+ .hz-dd-scroll {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ height: 1.5rem;
141
+ color: var(--color-muted);
142
+ background: var(--color-bg);
143
+ cursor: default;
144
+ }
@@ -0,0 +1,128 @@
1
+ /* Home-page filter bar.
2
+ *
3
+ * One toolbar row with search + Level dropdown + Topics dropdown + a
4
+ * vertical divider + Sort dropdown. Beneath it: optional "popular
5
+ * topics" pill row (top tags by count) and an "active filters" chip
6
+ * row that only appears when at least one filter is set.
7
+ *
8
+ * The dropdowns themselves (their trigger chrome and popover content)
9
+ * are styled in dropdown.css and multiselect.css respectively — this
10
+ * file only handles the toolbar layout + the inline pill + chip rows.
11
+ */
12
+
13
+ .filterbar {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 0.75rem;
17
+ margin-top: 1.5rem;
18
+ }
19
+
20
+ /* ---------- Toolbar (search + facets + sort) ---------- */
21
+
22
+ .fb-toolbar {
23
+ display: flex;
24
+ flex-wrap: wrap;
25
+ align-items: stretch;
26
+ gap: 0.75rem;
27
+ }
28
+
29
+ .fb-toolbar .search {
30
+ flex: 1 1 16rem;
31
+ min-width: 12rem;
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 0.5rem;
35
+ padding: 0.45rem 0.7rem;
36
+ border: 1px solid var(--color-border);
37
+ color: var(--color-muted);
38
+ transition: border-color 0.12s ease, color 0.12s ease;
39
+ }
40
+ .fb-toolbar .search:focus-within {
41
+ border-color: var(--color-accent);
42
+ color: var(--color-fg);
43
+ }
44
+ .fb-toolbar .search input {
45
+ background: transparent;
46
+ border: 0;
47
+ color: var(--color-fg);
48
+ font: inherit;
49
+ outline: none;
50
+ width: 100%;
51
+ }
52
+
53
+ /* Visual separator marking "sort isn't a filter". The sort cluster
54
+ * pushes itself to the right edge so the divider falls in a natural
55
+ * spot. */
56
+ .fb-divider {
57
+ width: 0;
58
+ border-left: 1px solid var(--color-border);
59
+ align-self: stretch;
60
+ margin: 0 0.25rem;
61
+ }
62
+ .fb-sort-slot {
63
+ display: inline-flex;
64
+ align-items: center;
65
+ flex-shrink: 0;
66
+ }
67
+
68
+ /* On narrow viewports the toolbar wraps; sort drops to its own line.
69
+ * Keep the divider visible only when the row hasn't wrapped — when it
70
+ * has, the layout already reads as distinct rows. Cheap heuristic:
71
+ * hide the divider below ~640px viewport. */
72
+ @media (max-width: 640px) {
73
+ .fb-divider { display: none; }
74
+ }
75
+
76
+ /* ---------- Active filter chips ---------- */
77
+
78
+ .active-filters {
79
+ display: flex;
80
+ flex-wrap: wrap;
81
+ gap: 0.5rem;
82
+ padding-top: 0.75rem;
83
+ border-top: 1px solid var(--color-border);
84
+ }
85
+
86
+ .active-filter-chip {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ gap: 0.35rem;
90
+ padding: 0.25rem 0.55rem;
91
+ background: transparent;
92
+ border: 1px solid var(--color-border);
93
+ color: var(--color-muted);
94
+ font-family: var(--font-mono);
95
+ font-size: 0.72em;
96
+ cursor: pointer;
97
+ transition: color 0.12s ease, border-color 0.12s ease;
98
+ }
99
+ .active-filter-chip:hover,
100
+ .active-filter-chip:focus-visible {
101
+ color: var(--color-accent);
102
+ border-color: var(--color-accent);
103
+ outline: none;
104
+ }
105
+ .active-filter-chip .afc-label { text-transform: lowercase; }
106
+
107
+ .active-filter-clear {
108
+ margin-left: 0.25rem;
109
+ padding: 0.25rem 0.55rem;
110
+ background: transparent;
111
+ border: 0;
112
+ color: var(--color-muted);
113
+ font-family: var(--font-mono);
114
+ font-size: 0.72em;
115
+ text-transform: uppercase;
116
+ letter-spacing: 0.06em;
117
+ cursor: pointer;
118
+ text-decoration: underline;
119
+ text-decoration-color: transparent;
120
+ text-underline-offset: 3px;
121
+ transition: color 0.12s ease, text-decoration-color 0.12s ease;
122
+ }
123
+ .active-filter-clear:hover,
124
+ .active-filter-clear:focus-visible {
125
+ color: var(--color-fg);
126
+ text-decoration-color: currentColor;
127
+ outline: none;
128
+ }