handzon-core 0.6.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -46,6 +46,8 @@
46
46
  "@fontsource-variable/geist": "^5.2.9",
47
47
  "@fontsource-variable/geist-mono": "^5.2.8",
48
48
  "@radix-ui/react-dialog": "^1.1.15",
49
+ "@radix-ui/react-popover": "^1.1.15",
50
+ "@radix-ui/react-select": "^2.1.6",
49
51
  "auth-astro": "^4.2.0",
50
52
  "cookie": "^1.0.2",
51
53
  "diff": "^9.0.0",
@@ -73,22 +73,29 @@ export default function UserMenu() {
73
73
  const user = session?.user;
74
74
  const callbackUrl = typeof window !== "undefined" ? window.location.href : "/";
75
75
 
76
+ // Compact label for the topbar: first word of `name`, falling back
77
+ // to the local part of `email`, falling back to a generic. Full name
78
+ // / email stays in the `alt` text and `title` for accessibility +
79
+ // long-form context.
80
+ const fullLabel = user?.name ?? user?.email ?? "Signed in";
81
+ const displayName = user
82
+ ? (user.name ? user.name.trim().split(/\s+/)[0] : null) ??
83
+ (user.email ? user.email.split("@")[0] : null) ??
84
+ "Signed in"
85
+ : "";
86
+
76
87
  return (
77
88
  <div className="user-menu">
78
89
  {user ? (
79
90
  <>
80
91
  {user.image ? (
81
- <img
82
- className="um-avatar"
83
- src={user.image}
84
- alt={user.name ?? user.email ?? "Signed in"}
85
- />
92
+ <img className="um-avatar" src={user.image} alt={fullLabel} />
86
93
  ) : (
87
94
  <span className="um-avatar um-avatar-fallback" aria-hidden="true">
88
- {(user.name ?? user.email ?? "?").trim().charAt(0).toUpperCase()}
95
+ {fullLabel.trim().charAt(0).toUpperCase()}
89
96
  </span>
90
97
  )}
91
- <span className="um-name">{user.name ?? user.email ?? "Signed in"}</span>
98
+ <span className="um-name" title={fullLabel}>{displayName}</span>
92
99
  <form method="post" action="/api/auth/signout">
93
100
  <input type="hidden" name="csrfToken" value={csrfToken} />
94
101
  <input type="hidden" name="callbackUrl" value={callbackUrl} />
@@ -0,0 +1,91 @@
1
+ import { X } from "lucide-react";
2
+ import type { KeyboardEvent } from "react";
3
+
4
+ interface Props {
5
+ q: string;
6
+ levels: Set<string>;
7
+ tags: Set<string>;
8
+ onClearQ: () => void;
9
+ onRemoveLevel: (level: string) => void;
10
+ onRemoveTag: (tag: string) => void;
11
+ onClearAll: () => void;
12
+ }
13
+
14
+ /**
15
+ * Active-filter chip row. Pure presentation — the parent FilterBar
16
+ * owns state mutations and URL writes. Shown only when the parent
17
+ * has determined at least one filter is active.
18
+ *
19
+ * Each chip is a button. Clicking removes the facet value.
20
+ * Backspace/Delete on a focused chip removes it via keyboard.
21
+ *
22
+ * The trailing "Clear all" sits at the end and resets everything
23
+ * (search + levels + tags). This is the only "clear" affordance now;
24
+ * the in-toolbar Clear button was removed.
25
+ */
26
+ export default function ActiveFilterChips({
27
+ q,
28
+ levels,
29
+ tags,
30
+ onClearQ,
31
+ onRemoveLevel,
32
+ onRemoveTag,
33
+ onClearAll,
34
+ }: Props) {
35
+ function chipKey(e: KeyboardEvent<HTMLButtonElement>, remove: () => void) {
36
+ if (e.key === "Backspace" || e.key === "Delete") {
37
+ e.preventDefault();
38
+ remove();
39
+ }
40
+ }
41
+
42
+ return (
43
+ <div className="active-filters" role="group" aria-label="Active filters">
44
+ {q && (
45
+ <button
46
+ type="button"
47
+ className="active-filter-chip"
48
+ aria-label={`Remove search: ${q}`}
49
+ onClick={onClearQ}
50
+ onKeyDown={(e) => chipKey(e, onClearQ)}
51
+ >
52
+ <span className="afc-label">search: {q}</span>
53
+ <X size={12} aria-hidden="true" />
54
+ </button>
55
+ )}
56
+ {[...levels].sort().map((level) => (
57
+ <button
58
+ key={`l:${level}`}
59
+ type="button"
60
+ className="active-filter-chip"
61
+ aria-label={`Remove level: ${level}`}
62
+ onClick={() => onRemoveLevel(level)}
63
+ onKeyDown={(e) => chipKey(e, () => onRemoveLevel(level))}
64
+ >
65
+ <span className="afc-label">{level}</span>
66
+ <X size={12} aria-hidden="true" />
67
+ </button>
68
+ ))}
69
+ {[...tags].sort().map((tag) => (
70
+ <button
71
+ key={`t:${tag}`}
72
+ type="button"
73
+ className="active-filter-chip"
74
+ aria-label={`Remove topic: ${tag}`}
75
+ onClick={() => onRemoveTag(tag)}
76
+ onKeyDown={(e) => chipKey(e, () => onRemoveTag(tag))}
77
+ >
78
+ <span className="afc-label">#{tag}</span>
79
+ <X size={12} aria-hidden="true" />
80
+ </button>
81
+ ))}
82
+ <button
83
+ type="button"
84
+ className="active-filter-clear"
85
+ onClick={onClearAll}
86
+ >
87
+ Clear all
88
+ </button>
89
+ </div>
90
+ );
91
+ }
@@ -1,27 +1,43 @@
1
- import { Flame, Search, X } from "lucide-react";
1
+ import { GraduationCap, Hash, Search } from "lucide-react";
2
2
  import { useEffect, useState } from "react";
3
+ import MultiSelect, { type MultiSelectOption } from "../ui/MultiSelect.tsx";
4
+ import ActiveFilterChips from "./ActiveFilterChips.tsx";
5
+ import SortBar from "./SortBar.tsx";
3
6
 
4
7
  interface Props {
5
8
  difficulties: string[];
6
9
  tags: string[];
10
+ /** Hit counts per difficulty value, server-computed. */
11
+ difficultyCounts: Record<string, number>;
12
+ /** Hit counts per tag, server-computed. */
13
+ tagCounts: Record<string, number>;
7
14
  }
8
15
 
9
16
  interface FilterState {
10
17
  q: string;
11
- difficulty: string;
12
- tag: string;
13
- /** "" = curated (numeric-prefix) order; "popular" = by data-popularity desc. */
14
- sort: string;
18
+ levels: Set<string>;
19
+ tags: Set<string>;
20
+ }
21
+
22
+ function parseCsv(value: string | null): Set<string> {
23
+ if (!value) return new Set();
24
+ return new Set(value.split(",").map((s) => s.trim()).filter(Boolean));
15
25
  }
16
26
 
17
27
  function readUrlState(): FilterState {
18
- if (typeof window === "undefined") return { q: "", difficulty: "", tag: "", sort: "" };
28
+ if (typeof window === "undefined") {
29
+ return { q: "", levels: new Set(), tags: new Set() };
30
+ }
19
31
  const url = new URL(window.location.href);
32
+ const levels = url.searchParams.get("level")
33
+ ? parseCsv(url.searchParams.get("level"))
34
+ : // Legacy single-value shape; honor on read so shared links keep
35
+ // working. The next interaction rewrites to ?level=.
36
+ parseCsv(url.searchParams.get("difficulty"));
20
37
  return {
21
38
  q: url.searchParams.get("q") ?? "",
22
- difficulty: url.searchParams.get("difficulty") ?? "",
23
- tag: url.searchParams.get("tag") ?? "",
24
- sort: url.searchParams.get("sort") ?? "",
39
+ levels,
40
+ tags: parseCsv(url.searchParams.get("tag")),
25
41
  };
26
42
  }
27
43
 
@@ -29,12 +45,13 @@ function writeUrlState(state: FilterState) {
29
45
  const url = new URL(window.location.href);
30
46
  if (state.q) url.searchParams.set("q", state.q);
31
47
  else url.searchParams.delete("q");
32
- if (state.difficulty) url.searchParams.set("difficulty", state.difficulty);
33
- else url.searchParams.delete("difficulty");
34
- if (state.tag) url.searchParams.set("tag", state.tag);
48
+ if (state.levels.size > 0) url.searchParams.set("level", [...state.levels].join(","));
49
+ else url.searchParams.delete("level");
50
+ if (state.tags.size > 0) url.searchParams.set("tag", [...state.tags].join(","));
35
51
  else url.searchParams.delete("tag");
36
- if (state.sort) url.searchParams.set("sort", state.sort);
37
- else url.searchParams.delete("sort");
52
+ // Always strip the legacy key so an upgraded URL doesn't carry both
53
+ // shapes. First user interaction "migrates" the link.
54
+ url.searchParams.delete("difficulty");
38
55
  window.history.replaceState({}, "", url.toString());
39
56
  }
40
57
 
@@ -44,9 +61,12 @@ function applyFilters(state: FilterState) {
44
61
  let visible = 0;
45
62
  cards.forEach((card) => {
46
63
  const matchesQ = !q || card.dataset.search!.includes(q);
47
- const matchesDiff = !state.difficulty || card.dataset.difficulty === state.difficulty;
48
- const matchesTag = !state.tag || (card.dataset.tags ?? "").split(",").includes(state.tag);
49
- const show = matchesQ && matchesDiff && matchesTag;
64
+ const matchesLevel =
65
+ state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
66
+ const cardTags = (card.dataset.tags ?? "").split(",");
67
+ const matchesTag =
68
+ state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
69
+ const show = matchesQ && matchesLevel && matchesTag;
50
70
  if (show) {
51
71
  card.removeAttribute("data-filter-hidden");
52
72
  visible += 1;
@@ -54,98 +74,129 @@ function applyFilters(state: FilterState) {
54
74
  card.setAttribute("data-filter-hidden", "");
55
75
  }
56
76
  });
77
+ // Empty state element controls its own visibility; we just set it.
78
+ // The Home.astro inline empty-state is shown when visible === 0.
57
79
  const empty = document.querySelector<HTMLElement>("[data-empty-state]");
58
80
  if (empty) empty.style.display = visible === 0 ? "" : "none";
59
- // Notify Pagination (and any other listeners) that the filtered set
60
- // changed so they can reset their slice.
81
+ // Results status line. Read total from rendered card count (server
82
+ // SSR'd them; localStorage / pagination don't change cards.length).
83
+ const status = document.querySelector<HTMLElement>("[data-results-status]");
84
+ if (status) {
85
+ const total = cards.length;
86
+ if (total === 0) {
87
+ status.textContent = "";
88
+ } else if (visible === total) {
89
+ status.textContent = `Showing all ${total} tutorial${total === 1 ? "" : "s"}`;
90
+ } else {
91
+ status.textContent = `Showing ${visible} of ${total} tutorial${total === 1 ? "" : "s"}`;
92
+ }
93
+ }
94
+ // Fire once per state transition so Pagination resets to page 1.
61
95
  window.dispatchEvent(new CustomEvent("hz:filter-changed"));
62
96
  }
63
97
 
64
- export default function FilterBar({ difficulties, tags }: Props) {
98
+ export default function FilterBar({
99
+ difficulties,
100
+ tags,
101
+ difficultyCounts,
102
+ tagCounts,
103
+ }: Props) {
65
104
  const [state, setState] = useState<FilterState>(readUrlState);
66
105
 
67
106
  useEffect(() => {
68
107
  applyFilters(state);
69
108
  writeUrlState(state);
70
- // Sort lives in a separate inline script on Home.astro because it
71
- // doesn't depend on React state — it just reads data-popularity.
72
- // Fire an event so it can re-run.
73
- window.dispatchEvent(
74
- new CustomEvent("hz:sort-changed", { detail: { sort: state.sort } }),
75
- );
76
109
  }, [state]);
77
110
 
78
- function set<K extends keyof FilterState>(key: K, value: FilterState[K]) {
79
- setState((prev) => ({ ...prev, [key]: value }));
111
+ function setQ(q: string) {
112
+ setState((prev) => ({ ...prev, q }));
80
113
  }
81
-
82
- function clear() {
83
- setState({ q: "", difficulty: "", tag: "", sort: "" });
114
+ function setLevels(levels: Set<string>) {
115
+ setState((prev) => ({ ...prev, levels }));
116
+ }
117
+ function setTags(next: Set<string>) {
118
+ setState((prev) => ({ ...prev, tags: next }));
84
119
  }
120
+ function removeLevel(level: string) {
121
+ setState((prev) => {
122
+ const next = new Set(prev.levels);
123
+ next.delete(level);
124
+ return { ...prev, levels: next };
125
+ });
126
+ }
127
+ function removeTag(tag: string) {
128
+ setState((prev) => {
129
+ const next = new Set(prev.tags);
130
+ next.delete(tag);
131
+ return { ...prev, tags: next };
132
+ });
133
+ }
134
+ function clearAll() {
135
+ setState({ q: "", levels: new Set(), tags: new Set() });
136
+ }
137
+
138
+ const levelOpts: MultiSelectOption[] = difficulties.map((d) => ({
139
+ value: d,
140
+ label: d,
141
+ count: difficultyCounts[d] ?? 0,
142
+ }));
143
+ const tagOpts: MultiSelectOption[] = tags.map((t) => ({
144
+ value: t,
145
+ label: `#${t}`,
146
+ count: tagCounts[t] ?? 0,
147
+ }));
85
148
 
86
- const hasFilters = state.q || state.difficulty || state.tag || state.sort;
149
+ const hasActive = state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0;
87
150
 
88
151
  return (
89
152
  <div className="filterbar">
90
- {/* Row 1: search takes the lion's share + level + clear */}
91
- <div className="fb-row fb-row-primary">
153
+ <div className="fb-toolbar">
92
154
  <label className="search">
93
155
  <Search size={16} aria-hidden="true" />
94
156
  <input
95
157
  type="search"
96
158
  placeholder="Search tutorials…"
97
159
  value={state.q}
98
- onChange={(e) => set("q", e.target.value)}
160
+ onChange={(e) => setQ(e.target.value)}
99
161
  aria-label="Search tutorials"
100
162
  />
101
163
  </label>
102
164
 
103
- <div className="pills" role="group" aria-label="Difficulty">
104
- {difficulties.map((d) => (
105
- <button
106
- key={d}
107
- type="button"
108
- className={`pill ${state.difficulty === d ? "is-active" : ""}`}
109
- onClick={() => set("difficulty", state.difficulty === d ? "" : d)}
110
- >
111
- {d}
112
- </button>
113
- ))}
114
- </div>
165
+ <MultiSelect
166
+ id="hz-level"
167
+ label="Level"
168
+ values={state.levels}
169
+ onChange={setLevels}
170
+ options={levelOpts}
171
+ triggerIcon={<GraduationCap size={14} aria-hidden="true" />}
172
+ />
115
173
 
116
- <div className="pills" role="group" aria-label="Sort">
117
- <button
118
- type="button"
119
- className={`pill ${state.sort === "popular" ? "is-active" : ""}`}
120
- onClick={() => set("sort", state.sort === "popular" ? "" : "popular")}
121
- aria-pressed={state.sort === "popular"}
122
- title="Sort by cross-learner popularity"
123
- >
124
- <Flame size={12} aria-hidden="true" /> popular
125
- </button>
126
- </div>
174
+ <MultiSelect
175
+ id="hz-topics"
176
+ label="Topics"
177
+ values={state.tags}
178
+ onChange={setTags}
179
+ options={tagOpts}
180
+ searchable
181
+ triggerIcon={<Hash size={14} aria-hidden="true" />}
182
+ />
127
183
 
128
- {hasFilters && (
129
- <button type="button" className="clear" onClick={clear}>
130
- <X size={14} aria-hidden="true" /> Clear
131
- </button>
132
- )}
184
+ <span className="fb-divider" aria-hidden="true" />
185
+ <div className="fb-sort-slot">
186
+ <SortBar />
187
+ </div>
133
188
  </div>
134
189
 
135
- {/* Row 2: tags wrap freely under the primary row */}
136
- {tags.length > 0 && (
137
- <div className="pills pills-tags" role="group" aria-label="Tags">
138
- {tags.map((t) => (
139
- <button
140
- key={t}
141
- type="button"
142
- className={`pill ${state.tag === t ? "is-active" : ""}`}
143
- onClick={() => set("tag", state.tag === t ? "" : t)}
144
- >
145
- #{t}
146
- </button>
147
- ))}
148
- </div>
190
+ {hasActive && (
191
+ <ActiveFilterChips
192
+ q={state.q}
193
+ levels={state.levels}
194
+ tags={state.tags}
195
+ onClearQ={() => setQ("")}
196
+ onRemoveLevel={removeLevel}
197
+ onRemoveTag={removeTag}
198
+ onClearAll={clearAll}
199
+ />
149
200
  )}
150
201
  </div>
151
202
  );
@@ -0,0 +1,65 @@
1
+ import { ArrowUpDown } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import Dropdown, { type DropdownOption } from "../ui/Dropdown.tsx";
4
+
5
+ /**
6
+ * Sort dropdown for the homepage tutorial grid. Lives above the grid
7
+ * (not inside the filterbar) so the UI affordance signals "order"
8
+ * rather than "filter".
9
+ *
10
+ * URL-driven via `?sort=`. Internally we use the sentinel "default"
11
+ * because Radix Select forbids "" as an item value; the URL still
12
+ * uses an absent param to mean "curated default order".
13
+ * Emits `hz:sort-changed` so the inline script on Home.astro that
14
+ * rewrites CSS `order` on each card re-runs without React coupling.
15
+ */
16
+ type SortValue = "default" | "popular";
17
+
18
+ const OPTIONS: DropdownOption<SortValue>[] = [
19
+ { value: "default", label: "Default" },
20
+ { value: "popular", label: "Most popular" },
21
+ ];
22
+
23
+ function readSortFromUrl(): SortValue {
24
+ if (typeof window === "undefined") return "default";
25
+ const v = new URL(window.location.href).searchParams.get("sort") ?? "";
26
+ return v === "popular" ? "popular" : "default";
27
+ }
28
+
29
+ function writeSortToUrl(value: SortValue) {
30
+ const url = new URL(window.location.href);
31
+ if (value === "popular") url.searchParams.set("sort", "popular");
32
+ else url.searchParams.delete("sort");
33
+ window.history.replaceState({}, "", url.toString());
34
+ }
35
+
36
+ export default function SortBar() {
37
+ const [sort, setSort] = useState<SortValue>("default");
38
+
39
+ // Read once on mount (client-only) so SSR doesn't try to touch
40
+ // `window`. After the initial sync, the dropdown owns the URL.
41
+ useEffect(() => {
42
+ setSort(readSortFromUrl());
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ writeSortToUrl(sort);
47
+ // Translate the internal sentinel back to "" on the wire so the
48
+ // existing Home.astro inline reorder script (which checks for the
49
+ // "popular" string) keeps working without changes.
50
+ const wire = sort === "popular" ? "popular" : "";
51
+ window.dispatchEvent(new CustomEvent("hz:sort-changed", { detail: { sort: wire } }));
52
+ }, [sort]);
53
+
54
+ return (
55
+ <Dropdown<SortValue>
56
+ id="hz-sort"
57
+ value={sort}
58
+ onChange={setSort}
59
+ options={OPTIONS}
60
+ label="Sort"
61
+ triggerIcon={<ArrowUpDown size={14} aria-hidden="true" />}
62
+ ariaLabel="Sort tutorials"
63
+ />
64
+ );
65
+ }
@@ -4,8 +4,14 @@ import type { TutorialEntry } from "../../lib/content";
4
4
  interface Props {
5
5
  tutorial: TutorialEntry;
6
6
  duration?: string;
7
+ /** Total number of steps in this tutorial. Used as the denominator
8
+ * for the per-card progress bar — has to come from the server-side
9
+ * content collection because localStorage only holds states for the
10
+ * steps the user has visited (so a fresh learner who completed 1 of
11
+ * 4 steps would otherwise see 1/1). */
12
+ stepCount: number;
7
13
  }
8
- const { tutorial, duration } = Astro.props;
14
+ const { tutorial, duration, stepCount } = Astro.props;
9
15
  const data = tutorial.data;
10
16
  const slug = tutorial.id;
11
17
  ---
@@ -42,6 +48,11 @@ const slug = tutorial.id;
42
48
  {duration}
43
49
  </span>
44
50
  )}
51
+ <span
52
+ class="card-progress"
53
+ data-tutorial-slug={slug}
54
+ data-step-count={stepCount}
55
+ ></span>
45
56
  </div>
46
57
  {data.tags.length > 0 && (
47
58
  <div class="tags">
@@ -53,7 +64,6 @@ const slug = tutorial.id;
53
64
  data-tutorial-stats-slug={slug}
54
65
  aria-hidden="true"
55
66
  ></div>
56
- <div class="card-progress" data-tutorial-slug={slug}></div>
57
67
  </div>
58
68
  </a>
59
69
  </article>
@@ -83,14 +93,16 @@ const slug = tutorial.id;
83
93
  flex: 1;
84
94
  }
85
95
 
86
- /* Metadata row: difficulty + duration, sitting *below* the
87
- * description (was above). Keeps the title as the lead element and
88
- * groups all the meta badges, tags, progress in the lower half
89
- * of the card. */
96
+ /* Metadata row: difficulty + duration with a divider beneath it.
97
+ * `margin-top: auto` pins this row (and everything below it tags,
98
+ * stats, progress) to the bottom of the card so all cards in the
99
+ * grid share the same bottom alignment regardless of how short the
100
+ * description is. */
90
101
  .badges {
91
102
  display: flex;
92
103
  gap: 0.4rem;
93
- margin-top: 0.85rem;
104
+ margin-top: auto;
105
+ padding-top: 0.85rem;
94
106
  padding-bottom: 0.85rem;
95
107
  border-bottom: 1px solid var(--color-border);
96
108
  flex-wrap: wrap;
@@ -159,7 +171,44 @@ const slug = tutorial.id;
159
171
  color: var(--color-muted);
160
172
  }
161
173
 
162
- .card-progress:not(:empty) { padding-top: 0.5rem; }
174
+ /* Per-card completion ring, hydrated by the home-page script.
175
+ * Sits inside the `.badges` row, pushed to the right edge so it
176
+ * lines up with the level + duration chips on the same baseline. */
177
+ .card-progress {
178
+ margin-left: auto;
179
+ display: inline-flex;
180
+ align-items: center;
181
+ gap: 0.4rem;
182
+ font-family: var(--font-mono);
183
+ font-size: 0.68em;
184
+ color: var(--color-muted);
185
+ text-transform: uppercase;
186
+ letter-spacing: 0.08em;
187
+ }
188
+ .card-progress:empty { display: none; }
189
+ /* The ring + label live inside `.card-progress` but are injected
190
+ * via innerHTML at runtime, so Astro's scope hash doesn't apply to
191
+ * them. Use :global() so the rules still match. */
192
+ .card-progress :global(.ring) {
193
+ /* Start the fill at 12 o'clock. */
194
+ transform: rotate(-90deg);
195
+ flex-shrink: 0;
196
+ }
197
+ .card-progress :global(.ring-track) {
198
+ fill: none;
199
+ stroke: var(--color-border);
200
+ stroke-width: 2.5;
201
+ }
202
+ .card-progress :global(.ring-fill) {
203
+ fill: none;
204
+ stroke: var(--color-accent);
205
+ stroke-width: 2.5;
206
+ stroke-linecap: round;
207
+ transition: stroke-dashoffset 0.3s ease;
208
+ }
209
+ .card-progress :global(.ring-label) {
210
+ color: var(--color-accent);
211
+ }
163
212
 
164
213
  /* Cross-learner popularity, hydrated from /api/tutorials/stats.
165
214
  * Hidden until the script populates content so the card height