@grove-dev/astro 0.7.0 → 0.9.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.
Files changed (110) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +22 -33
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/display.d.ts +1 -1
  6. package/dist/lib/display.js +1 -1
  7. package/dist/lib/format.d.ts +1 -1
  8. package/dist/lib/format.js +1 -1
  9. package/dist/lib/icon-kinds.d.ts +1 -1
  10. package/dist/lib/icon-kinds.js +43 -43
  11. package/dist/lib/icon-registry.d.ts +2 -2
  12. package/dist/lib/icon-registry.js +23 -23
  13. package/dist/lib/index.d.ts +9 -9
  14. package/dist/lib/index.d.ts.map +1 -1
  15. package/dist/lib/index.js +9 -9
  16. package/dist/lib/index.js.map +1 -1
  17. package/dist/lib/lenses.d.ts +1 -1
  18. package/dist/lib/lenses.js +1 -1
  19. package/dist/lib/packaged-icons.d.ts +1 -1
  20. package/dist/lib/packaged-icons.js +4 -4
  21. package/dist/lib/repo.d.ts +1 -1
  22. package/dist/lib/repo.js +1 -1
  23. package/dist/lib/search.d.ts +1 -1
  24. package/dist/lib/search.js +1 -1
  25. package/dist/lib/taxonomy-counts.d.ts +1 -1
  26. package/dist/lib/taxonomy-counts.js +1 -1
  27. package/package.json +7 -17
  28. package/src/index.ts +23 -38
  29. package/src/lib/display.ts +1 -1
  30. package/src/lib/format.ts +1 -1
  31. package/src/lib/icon-kinds.ts +44 -44
  32. package/src/lib/icon-registry.ts +25 -25
  33. package/src/lib/index.ts +9 -9
  34. package/src/lib/lenses.ts +1 -1
  35. package/src/lib/packaged-icons.ts +8 -8
  36. package/src/lib/repo.ts +1 -1
  37. package/src/lib/search.ts +1 -1
  38. package/src/lib/taxonomy-counts.ts +1 -1
  39. package/src/server/collections.ts +53 -42
  40. package/src/server/contrast.ts +14 -9
  41. package/src/server/directory.ts +22 -17
  42. package/src/server/index.ts +3 -2
  43. package/src/server/models.ts +425 -218
  44. package/src/server/seo.ts +34 -31
  45. package/src/styles.css +235 -91
  46. package/dist/layouts/index.d.ts +0 -25
  47. package/dist/layouts/index.d.ts.map +0 -1
  48. package/dist/layouts/index.js +0 -25
  49. package/dist/layouts/index.js.map +0 -1
  50. package/dist/ui/button.d.ts +0 -53
  51. package/dist/ui/button.d.ts.map +0 -1
  52. package/dist/ui/button.js +0 -90
  53. package/dist/ui/button.js.map +0 -1
  54. package/src/components/CardGrid.astro +0 -41
  55. package/src/components/CardIcon.astro +0 -67
  56. package/src/components/CategoryGrid.astro +0 -114
  57. package/src/components/CollectionCard.astro +0 -56
  58. package/src/components/CollectionIndex.astro +0 -102
  59. package/src/components/CollectionPage.astro +0 -128
  60. package/src/components/CollectionRow.astro +0 -81
  61. package/src/components/CollectionTeaser.astro +0 -47
  62. package/src/components/ContributorsGrid.astro +0 -143
  63. package/src/components/DirectoryIndexClient.astro +0 -510
  64. package/src/components/EditorialSummary.astro +0 -58
  65. package/src/components/FilterGroupMenu.astro +0 -69
  66. package/src/components/FilterOptions.astro +0 -49
  67. package/src/components/FinalCta.astro +0 -43
  68. package/src/components/Hero.astro +0 -243
  69. package/src/components/Icon.astro +0 -191
  70. package/src/components/IndexRow.astro +0 -37
  71. package/src/components/LanguageBreakdown.astro +0 -67
  72. package/src/components/MarkdownBody.astro +0 -37
  73. package/src/components/OriginalCollection.astro +0 -117
  74. package/src/components/Pagination.astro +0 -50
  75. package/src/components/PoweredBy.astro +0 -60
  76. package/src/components/ProjectCard.astro +0 -321
  77. package/src/components/RecordHeader.astro +0 -221
  78. package/src/components/RecordSection.astro +0 -128
  79. package/src/components/RecordSidebar.astro +0 -387
  80. package/src/components/RefinePanel.astro +0 -168
  81. package/src/components/SmartLensTabs.astro +0 -81
  82. package/src/components/StackGrid.astro +0 -151
  83. package/src/components/StackPlatformChips.astro +0 -73
  84. package/src/components/SubmissionClient.astro +0 -254
  85. package/src/components/TableOfContents.astro +0 -140
  86. package/src/components/WhyThisExists.astro +0 -116
  87. package/src/layouts/BaseLayout.astro +0 -415
  88. package/src/layouts/Container.astro +0 -18
  89. package/src/layouts/Footer.astro +0 -231
  90. package/src/layouts/Header.astro +0 -214
  91. package/src/layouts/SectionHeader.astro +0 -43
  92. package/src/layouts/Seo.astro +0 -171
  93. package/src/layouts/ThemeToggle.astro +0 -118
  94. package/src/layouts/astro-shim.d.ts +0 -29
  95. package/src/layouts/index.ts +0 -24
  96. package/src/server/collections.test.ts +0 -212
  97. package/src/server/contrast.test.ts +0 -82
  98. package/src/server/directory.test.ts +0 -177
  99. package/src/server/github-repo.test.ts +0 -88
  100. package/src/server/models-home.test.ts +0 -101
  101. package/src/server/models.test.ts +0 -135
  102. package/src/server/seo.test.ts +0 -123
  103. package/src/ui/Badge.astro +0 -38
  104. package/src/ui/Button.astro +0 -42
  105. package/src/ui/EmptyState.astro +0 -38
  106. package/src/ui/FilterDrawer.astro +0 -124
  107. package/src/ui/PageHeader.astro +0 -39
  108. package/src/ui/SearchField.astro +0 -106
  109. package/src/ui/button.test.ts +0 -54
  110. package/src/ui/button.ts +0 -107
@@ -1,124 +0,0 @@
1
- ---
2
- /**
3
- * FilterDrawer — mobile filter surface built on native `<dialog>`.
4
- *
5
- * `showModal()` gives focus trapping, Escape handling, and the
6
- * top-layer backdrop for free — no dependency, no hand-rolled trap.
7
- * On viewports ≥ md the trigger button hides and the slotted filter
8
- * controls render inline through the same slot used on desktop, so
9
- * there is exactly one copy of the filter markup.
10
- *
11
- * No-JS fallback: the slot content stays in normal flow (the dialog
12
- * only wraps it once the client script moves it), so filters remain
13
- * usable without JavaScript.
14
- */
15
- import { buttonClass } from "./button.js";
16
-
17
- interface Props {
18
- /** Trigger button label. */
19
- label?: string;
20
- /** Count of active filters shown on the trigger. */
21
- activeCount?: number;
22
- /** URL that clears every filter — offered inside the sheet. */
23
- clearHref?: string;
24
- class?: string;
25
- }
26
-
27
- const {
28
- label = "Filters",
29
- activeCount = 0,
30
- clearHref,
31
- class: className = "",
32
- } = Astro.props;
33
- ---
34
-
35
- <div class={`grove-filter-drawer ${className}`} data-grove-filter-drawer>
36
- <button
37
- type="button"
38
- data-drawer-open
39
- class={buttonClass("secondary", "md", "w-full md:hidden")}
40
- aria-haspopup="dialog"
41
- >
42
- {label}
43
- {/* Always in the DOM (hidden at zero) so the client script can
44
- update it from the live URL state on this prerendered page. */}
45
- <span
46
- data-drawer-count
47
- class:list={[
48
- "inline-flex min-w-5 items-center justify-center rounded-full bg-selected px-1.5 py-0.5 font-mono text-2xs leading-none text-selected-foreground",
49
- activeCount === 0 && "hidden",
50
- ]}
51
- >
52
- {activeCount}
53
- </span>
54
- </button>
55
-
56
- {/* On mobile the client script moves this content into the dialog;
57
- on ≥ md it stays inline. Not hidden by CSS — without JS the
58
- filters must remain visible in flow on every viewport. */}
59
- <div data-drawer-content class="md:contents">
60
- <slot />
61
- </div>
62
-
63
- <dialog
64
- data-drawer-dialog
65
- class="m-0 mt-auto w-full max-w-none rounded-t-[calc(var(--radius)+0.5rem)] border-0 bg-surface-overlay p-0 text-foreground backdrop:bg-black/40 md:hidden"
66
- aria-label={label}
67
- >
68
- <div class="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
69
- <p class="m-0 text-sm font-semibold">{label}</p>
70
- <div class="flex items-center gap-2">
71
- {clearHref && (
72
- /* Clearing from inside the sheet is the only way out of a
73
- stack of filters on mobile — the chips row is behind it. */
74
- <a
75
- href={clearHref}
76
- data-drawer-clear
77
- class:list={[buttonClass("ghost", "sm"), activeCount === 0 && "hidden"]}
78
- >
79
- Clear all
80
- </a>
81
- )}
82
- <button type="button" data-drawer-close class={buttonClass("ghost", "sm")}>
83
- Done
84
- </button>
85
- </div>
86
- </div>
87
- <div data-drawer-body class="max-h-[70vh] overflow-y-auto p-4"></div>
88
- </dialog>
89
- </div>
90
-
91
- <script>
92
- document.querySelectorAll<HTMLElement>("[data-grove-filter-drawer]").forEach((root) => {
93
- const dialog = root.querySelector<HTMLDialogElement>("[data-drawer-dialog]");
94
- const content = root.querySelector<HTMLElement>("[data-drawer-content]");
95
- const body = root.querySelector<HTMLElement>("[data-drawer-body]");
96
- const open = root.querySelector<HTMLButtonElement>("[data-drawer-open]");
97
- const close = root.querySelector<HTMLButtonElement>("[data-drawer-close]");
98
- if (!dialog || !content || !body || !open) return;
99
-
100
- const mobile = window.matchMedia("(max-width: 47.999rem)");
101
- const place = () => {
102
- // One copy of the filter markup, moved between the inline slot
103
- // (desktop) and the dialog body (mobile).
104
- if (mobile.matches) {
105
- body.append(...content.children);
106
- content.classList.add("hidden");
107
- } else {
108
- if (dialog.open) dialog.close();
109
- content.append(...body.children);
110
- content.classList.remove("hidden");
111
- }
112
- };
113
- place();
114
- mobile.addEventListener("change", place);
115
-
116
- open.addEventListener("click", () => dialog.showModal());
117
- close?.addEventListener("click", () => dialog.close());
118
- dialog.addEventListener("click", (event) => {
119
- // Backdrop click closes: the dialog element itself is the
120
- // target only when the click lands outside the panel content.
121
- if (event.target === dialog) dialog.close();
122
- });
123
- });
124
- </script>
@@ -1,39 +0,0 @@
1
- ---
2
- /**
3
- * PageHeader — the page's title block. Always renders a semantic
4
- * heading (h1 by default — every route needs exactly one) with an
5
- * optional eyebrow and description.
6
- *
7
- * For section headings INSIDE a page keep using `SectionHeader`
8
- * (layouts/), which defaults to h2/h3.
9
- */
10
-
11
- interface Props {
12
- eyebrow?: string;
13
- title: string;
14
- description?: string;
15
- /** Escape hatch for embedded use; the default 1 is the point. */
16
- level?: 1 | 2;
17
- class?: string;
18
- }
19
-
20
- const { eyebrow, title, description, level = 1, class: className = "" } = Astro.props;
21
- const Heading = `h${level}` as "h1" | "h2";
22
- ---
23
-
24
- <header class={`flex flex-col gap-1 ${className}`}>
25
- {eyebrow && (
26
- <span class="text-2xs font-medium uppercase tracking-wider text-muted-foreground">
27
- {eyebrow}
28
- </span>
29
- )}
30
- <Heading class="m-0 text-[1.875rem] font-semibold tracking-tight text-foreground sm:text-[2.125rem]">
31
- {title}
32
- </Heading>
33
- {description && (
34
- <p class="m-0 mt-1 max-w-[64ch] text-[0.9375rem] leading-relaxed text-muted-foreground">
35
- {description}
36
- </p>
37
- )}
38
- <slot />
39
- </header>
@@ -1,106 +0,0 @@
1
- ---
2
- /**
3
- * SearchField — labeled search input with the leading glass icon.
4
- *
5
- * The label is visually hidden but always present; placeholder text
6
- * is display copy, not the accessible name. Pass the derived
7
- * placeholder from `getDirectoryIndexModel().searchPlaceholder` so it
8
- * only names ENABLED facets.
9
- *
10
- * `shortcut` renders the `/` hint and wires the global key handler.
11
- * The hint is hidden once the field has focus — it is an invitation,
12
- * not a persistent label — and on coarse pointers, where there is no
13
- * key to press.
14
- */
15
-
16
- interface Props {
17
- label: string;
18
- name?: string;
19
- id?: string;
20
- value?: string;
21
- placeholder?: string;
22
- /** Show the `/` hint and focus this field when `/` is pressed. */
23
- shortcut?: boolean;
24
- class?: string;
25
- }
26
-
27
- const {
28
- label,
29
- name = "q",
30
- id = "search-input",
31
- value = "",
32
- placeholder,
33
- shortcut = false,
34
- class: className = "",
35
- } = Astro.props;
36
- ---
37
-
38
- <label class={`grove-search relative block ${className}`}>
39
- <span class="sr-only">{label}</span>
40
- <svg
41
- viewBox="0 0 16 16"
42
- width="16"
43
- height="16"
44
- aria-hidden="true"
45
- class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
46
- fill="currentColor"
47
- >
48
- <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
49
- </svg>
50
- <input
51
- id={id}
52
- type="search"
53
- name={name}
54
- value={value}
55
- placeholder={placeholder}
56
- autocomplete="off"
57
- data-search-input
58
- class="peer h-10 w-full rounded-md border border-input bg-background py-2 pl-9 pr-10 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20 [&::-webkit-search-cancel-button]:appearance-none"
59
- />
60
- {shortcut && (
61
- <kbd
62
- data-search-hint
63
- aria-hidden="true"
64
- class="pointer-events-none absolute right-2 top-1/2 hidden h-6 w-6 -translate-y-1/2 select-none items-center justify-center rounded-md border border-border bg-secondary font-mono text-[11px] leading-none text-muted-foreground peer-focus:!hidden peer-not-placeholder-shown:hidden sm:inline-flex"
65
- >/</kbd
66
- >
67
- )}
68
- <button
69
- type="button"
70
- data-search-clear
71
- hidden={!value}
72
- aria-label="Clear search"
73
- class="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
74
- >
75
- <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" fill="currentColor">
76
- <path
77
- d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
78
- />
79
- </svg>
80
- </button>
81
- </label>
82
-
83
- {shortcut && (
84
- <script>
85
- // `/` focuses the directory search from anywhere on the page, but
86
- // never while the visitor is already typing somewhere — otherwise
87
- // the shortcut eats a literal slash out of another field.
88
- document.addEventListener("keydown", (event) => {
89
- if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
90
- const active = document.activeElement as HTMLElement | null;
91
- if (
92
- active &&
93
- (active.tagName === "INPUT" ||
94
- active.tagName === "TEXTAREA" ||
95
- active.tagName === "SELECT" ||
96
- active.isContentEditable)
97
- )
98
- return;
99
- const input = document.querySelector("[data-search-input]");
100
- if (!(input instanceof HTMLInputElement)) return;
101
- event.preventDefault();
102
- input.focus();
103
- input.select();
104
- });
105
- </script>
106
- )}
@@ -1,54 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { describe, expect, it } from "vitest";
4
- import { buttonClass, chipClass, filterTriggerClass, lensTabClass } from "./button.js";
5
-
6
- describe("button class builders", () => {
7
- it("keeps selection state on the selected tokens, never the brand primary", () => {
8
- for (const cls of [
9
- buttonClass("selected"),
10
- filterTriggerClass(true),
11
- lensTabClass(true),
12
- ]) {
13
- expect(cls).toContain("bg-selected");
14
- expect(cls).not.toContain("bg-primary");
15
- }
16
- });
17
-
18
- it("uses the brand primary only for the primary variant", () => {
19
- expect(buttonClass("primary")).toContain("bg-primary");
20
- expect(buttonClass("secondary")).not.toContain("bg-primary");
21
- });
22
-
23
- it("guarantees a 44px mobile hit target on md and lg sizes", () => {
24
- expect(buttonClass("secondary", "md")).toContain("max-sm:min-h-11");
25
- expect(buttonClass("secondary", "lg")).toContain("h-11");
26
- expect(filterTriggerClass(false)).toContain("max-sm:min-h-11");
27
- expect(lensTabClass(false)).toContain("max-sm:min-h-11");
28
- });
29
-
30
- it("keeps the filter-trigger hook class through client reassignment", () => {
31
- expect(filterTriggerClass(true)).toContain("grove-filter-trigger");
32
- expect(filterTriggerClass(false)).toContain("grove-filter-trigger");
33
- });
34
-
35
- it("is the single source for chip classes on both server and client", () => {
36
- // The browse page (server) and DirectoryIndexClient (client JS)
37
- // must render byte-identical chips. Both now import chipClass —
38
- // assert neither has regressed to an inline copy of the string.
39
- const clientSource = readFileSync(
40
- resolve(import.meta.dirname, "../components/DirectoryIndexClient.astro"),
41
- "utf8",
42
- );
43
- const serverPage = readFileSync(
44
- resolve(import.meta.dirname, "../../../../apps/example/src/components/DirectoryBrowse.astro"),
45
- "utf8",
46
- );
47
- expect(clientSource).toContain("chipClass()");
48
- expect(serverPage).toContain("chipClass()");
49
- const inlineChip = "min-h-5 items-center justify-center gap-1 rounded-full";
50
- expect(clientSource).not.toContain(inlineChip);
51
- expect(serverPage).not.toContain(inlineChip);
52
- expect(chipClass()).toContain(inlineChip);
53
- });
54
- });
package/src/ui/button.ts DELETED
@@ -1,107 +0,0 @@
1
- /**
2
- * Button / chip class builders — the single source for interactive
3
- * control styling.
4
- *
5
- * Plain TS (not an .astro component) on purpose: `DirectoryIndexClient`
6
- * rebuilds pagination links and active-filter chips in CLIENT-side JS,
7
- * and those class strings must stay byte-identical with the server
8
- * render. Both sides import these builders, so they cannot drift.
9
- *
10
- * Variant semantics (see styles.css):
11
- * - `primary` — the brand action (config `theme.primaryColor`).
12
- * - `secondary` — default chrome buttons.
13
- * - `outline` / `ghost` — quieter chrome.
14
- * - `selected` — "you are here" state (active filter, current lens,
15
- * current page). Neutral inversion via the `selected` tokens —
16
- * deliberately NOT the brand primary, so green never doubles as a
17
- * selection indicator.
18
- */
19
-
20
- export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "selected";
21
- /** `bare` emits no size classes — pass custom geometry via `extra`. */
22
- export type ButtonSize = "sm" | "md" | "lg" | "bare";
23
-
24
- const BASE =
25
- "inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35";
26
-
27
- /**
28
- * `md`/`lg` guarantee a ≥44px hit target on small viewports
29
- * (`max-sm:min-h-11`) while keeping the compact desktop height.
30
- */
31
- const SIZES: Record<ButtonSize, string> = {
32
- sm: "h-8 px-3 text-sm",
33
- md: "h-9 px-3 text-sm max-sm:min-h-11",
34
- lg: "h-11 px-4 text-sm",
35
- bare: "",
36
- };
37
-
38
- const VARIANTS: Record<ButtonVariant, string> = {
39
- primary: "bg-primary text-primary-foreground hover:bg-primary/90",
40
- secondary: "border-border bg-secondary text-foreground hover:bg-accent",
41
- outline: "border-border bg-transparent text-foreground hover:bg-accent",
42
- ghost: "text-foreground hover:bg-accent",
43
- selected: "bg-selected text-selected-foreground hover:bg-selected/90",
44
- };
45
-
46
- export function buttonClass(
47
- variant: ButtonVariant = "secondary",
48
- size: ButtonSize = "sm",
49
- extra = "",
50
- ): string {
51
- return [BASE, SIZES[size], VARIANTS[variant], extra].filter(Boolean).join(" ");
52
- }
53
-
54
- /**
55
- * Layout extras for pagination controls — shared by `Pagination.astro`
56
- * and the client-side pagination rebuild in `DirectoryIndexClient`.
57
- */
58
- export const PAGINATION_EXTRA = "min-w-9 px-2 py-1.5 text-2xs";
59
-
60
- /**
61
- * The dead end of a pagination row — "Previous" on page 1.
62
- *
63
- * Dimming to `opacity-40` put it at 3.37:1 against the dark surface,
64
- * which is a contrast failure, not a disabled state. 60% still reads
65
- * as unavailable and clears 4.5:1.
66
- */
67
- export const PAGINATION_DISABLED = "pointer-events-none opacity-60";
68
-
69
- /**
70
- * Facet filter trigger (the "Stack: Any" dropdown buttons). Includes
71
- * the `grove-filter-trigger` hook class because the client script
72
- * reassigns the whole className when the active state changes — the
73
- * hook must survive that reassignment.
74
- */
75
- export function filterTriggerClass(active: boolean): string {
76
- return [
77
- "grove-filter-trigger",
78
- BASE,
79
- "h-9 px-3 text-[0.8125rem] max-sm:min-h-11",
80
- active ? `${VARIANTS.selected} hover:text-selected-foreground` : VARIANTS.secondary,
81
- ].join(" ");
82
- }
83
-
84
- /**
85
- * Lens tab (the All / Hot / Mature row). Same geometry as the filter
86
- * trigger; `data-lens-id` targeting keeps its own hook attribute.
87
- */
88
- export function lensTabClass(active: boolean): string {
89
- return [
90
- BASE,
91
- "h-9 px-3 text-[0.8125rem] max-sm:min-h-11",
92
- active ? `${VARIANTS.selected} hover:text-selected-foreground` : VARIANTS.secondary,
93
- ].join(" ");
94
- }
95
-
96
- /**
97
- * Active-filter chip (the removable "Stack: Python ×" pills). One
98
- * definition for the server render and the client rebuild.
99
- */
100
- export function chipClass(extra = ""): string {
101
- return [
102
- "inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground transition-colors hover:border-ink-400",
103
- extra,
104
- ]
105
- .filter(Boolean)
106
- .join(" ");
107
- }