@at-flux/astroflare 1.0.8 → 1.0.10

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/dist/core.cjs CHANGED
@@ -2,11 +2,11 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_forms = require("./forms-8uIdWmMD.cjs");
3
3
  //#region src/tag-colors.ts
4
4
  const DEFAULT_OVERRIDES = {
5
- shoots: "#dc3545",
6
- tech: "#8a95a5",
7
- ai: "#6b7b8d",
8
- sites: "#7dd3c0",
9
- flow: "#b367e0"
5
+ shoots: "#c4786a",
6
+ tech: "#4d8c7a",
7
+ ai: "#d9773a",
8
+ sites: "#b8942e",
9
+ flow: "#9b6bc9"
10
10
  };
11
11
  const hashStringToHue = (input) => {
12
12
  let hash = 0;
@@ -56,9 +56,9 @@ const resolveHue = (tag, options) => {
56
56
  const getTagPalette = (tag, options = {}) => {
57
57
  const hue = resolveHue(tag, options);
58
58
  return {
59
- bg: `hsla(${hue} 48% 58% / 0.12)`,
60
- border: `hsla(${hue} 52% 58% / 0.34)`,
61
- text: `hsl(${hue} 50% 66%)`
59
+ bg: `hsla(${hue} 58% 56% / 0.16)`,
60
+ border: `hsla(${hue} 56% 46% / 0.42)`,
61
+ text: `hsl(${hue} 58% 32%)`
62
62
  };
63
63
  };
64
64
  //#endregion
@@ -79,6 +79,13 @@ const formatDisplayDate = (date, config = {}) => {
79
79
  //#region src/collection-query.ts
80
80
  const normalizeFilterToken = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
81
81
  const parseFilterValueList = (value) => (value ?? "").split(",").map(normalizeFilterToken).filter(Boolean).filter((token, index, list) => list.indexOf(token) === index);
82
+ const normalizeFilterValue = (key, value) => {
83
+ if (key === "date_from" || key === "date_to") {
84
+ const canonical = value.trim().replace(/_/g, "-");
85
+ return /^\d{4}-\d{2}-\d{2}$/.test(canonical) ? canonical : "";
86
+ }
87
+ return parseFilterValueList(value).join(",");
88
+ };
82
89
  const formatFilterValueListLabel = (value) => parseFilterValueList(value).map((token) => token.replace(/_/g, " ").toUpperCase()).join(", ");
83
90
  const toInt = (value, fallback) => {
84
91
  const parsed = Number.parseInt(value ?? "", 10);
@@ -95,7 +102,7 @@ const parseCollectionQuery = (searchParams, options = {}) => {
95
102
  let filters = {};
96
103
  if (rawFilters) try {
97
104
  const parsed = JSON.parse(rawFilters);
98
- filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]));
105
+ filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, normalizeFilterValue(key, String(value))]).filter(([, value]) => value.length > 0));
99
106
  } catch {
100
107
  filters = {};
101
108
  }
@@ -131,7 +138,7 @@ const buildCollectionHref = (pathname, query, overrides = {}) => {
131
138
  offset: String(offsetFromPage)
132
139
  });
133
140
  if (Object.keys(next.filters ?? {}).length > 0) {
134
- const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, parseFilterValueList(value).join(",")]));
141
+ const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, normalizeFilterValue(key, value)]));
135
142
  params.set("filters", JSON.stringify(normalizedFilters));
136
143
  }
137
144
  return `${pathname}?${params.toString()}`;
package/dist/core.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { composeEmailAddress, generateFormResultHtml, generateFormSectionHtml, renderEmailTemplate, sendEmail, t as forms_exports } from "./forms/index.js";
2
2
  //#region src/tag-colors.ts
3
3
  const DEFAULT_OVERRIDES = {
4
- shoots: "#dc3545",
5
- tech: "#8a95a5",
6
- ai: "#6b7b8d",
7
- sites: "#7dd3c0",
8
- flow: "#b367e0"
4
+ shoots: "#c4786a",
5
+ tech: "#4d8c7a",
6
+ ai: "#d9773a",
7
+ sites: "#b8942e",
8
+ flow: "#9b6bc9"
9
9
  };
10
10
  const hashStringToHue = (input) => {
11
11
  let hash = 0;
@@ -55,9 +55,9 @@ const resolveHue = (tag, options) => {
55
55
  const getTagPalette = (tag, options = {}) => {
56
56
  const hue = resolveHue(tag, options);
57
57
  return {
58
- bg: `hsla(${hue} 48% 58% / 0.12)`,
59
- border: `hsla(${hue} 52% 58% / 0.34)`,
60
- text: `hsl(${hue} 50% 66%)`
58
+ bg: `hsla(${hue} 58% 56% / 0.16)`,
59
+ border: `hsla(${hue} 56% 46% / 0.42)`,
60
+ text: `hsl(${hue} 58% 32%)`
61
61
  };
62
62
  };
63
63
  //#endregion
@@ -78,6 +78,13 @@ const formatDisplayDate = (date, config = {}) => {
78
78
  //#region src/collection-query.ts
79
79
  const normalizeFilterToken = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
80
80
  const parseFilterValueList = (value) => (value ?? "").split(",").map(normalizeFilterToken).filter(Boolean).filter((token, index, list) => list.indexOf(token) === index);
81
+ const normalizeFilterValue = (key, value) => {
82
+ if (key === "date_from" || key === "date_to") {
83
+ const canonical = value.trim().replace(/_/g, "-");
84
+ return /^\d{4}-\d{2}-\d{2}$/.test(canonical) ? canonical : "";
85
+ }
86
+ return parseFilterValueList(value).join(",");
87
+ };
81
88
  const formatFilterValueListLabel = (value) => parseFilterValueList(value).map((token) => token.replace(/_/g, " ").toUpperCase()).join(", ");
82
89
  const toInt = (value, fallback) => {
83
90
  const parsed = Number.parseInt(value ?? "", 10);
@@ -94,7 +101,7 @@ const parseCollectionQuery = (searchParams, options = {}) => {
94
101
  let filters = {};
95
102
  if (rawFilters) try {
96
103
  const parsed = JSON.parse(rawFilters);
97
- filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]));
104
+ filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, normalizeFilterValue(key, String(value))]).filter(([, value]) => value.length > 0));
98
105
  } catch {
99
106
  filters = {};
100
107
  }
@@ -130,7 +137,7 @@ const buildCollectionHref = (pathname, query, overrides = {}) => {
130
137
  offset: String(offsetFromPage)
131
138
  });
132
139
  if (Object.keys(next.filters ?? {}).length > 0) {
133
- const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, parseFilterValueList(value).join(",")]));
140
+ const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, normalizeFilterValue(key, value)]));
134
141
  params.set("filters", JSON.stringify(normalizedFilters));
135
142
  }
136
143
  return `${pathname}?${params.toString()}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@at-flux/astroflare",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "type": "module",
5
5
  "description": "Reusable headless components, styles, and utilities for Astro + Tailwind v4 + Cloudflare projects",
6
6
  "author": "atflux <dev@atflux.uk>",
@@ -62,14 +62,14 @@
62
62
  "README.md"
63
63
  ],
64
64
  "devDependencies": {
65
- "@astrojs/node": "^9.5.5",
66
- "jsdom": "^25.0.1",
67
- "tsdown": "^0.21.7",
68
- "typescript": "^5.0.0",
69
- "vitest": "^3.2.4"
65
+ "@astrojs/node": "9.5.5",
66
+ "jsdom": "25.0.1",
67
+ "tsdown": "0.21.8",
68
+ "typescript": "5.9.3",
69
+ "vitest": "3.2.4"
70
70
  },
71
71
  "dependencies": {
72
- "resend": "^4.0.0"
72
+ "resend": "4.8.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "astro": ">=5.0.0"
@@ -34,6 +34,14 @@ export const parseFilterValueList = (value: string | undefined): string[] =>
34
34
  .filter(Boolean)
35
35
  .filter((token, index, list) => list.indexOf(token) === index);
36
36
 
37
+ const normalizeFilterValue = (key: string, value: string): string => {
38
+ if (key === "date_from" || key === "date_to") {
39
+ const canonical = value.trim().replace(/_/g, "-");
40
+ return /^\d{4}-\d{2}-\d{2}$/.test(canonical) ? canonical : "";
41
+ }
42
+ return parseFilterValueList(value).join(",");
43
+ };
44
+
37
45
  export const formatFilterValueListLabel = (value: string | undefined): string =>
38
46
  parseFilterValueList(value)
39
47
  .map((token) => token.replace(/_/g, " ").toUpperCase())
@@ -71,7 +79,8 @@ export const parseCollectionQuery = (
71
79
  filters = Object.fromEntries(
72
80
  Object.entries(parsed)
73
81
  .filter(([, value]) => typeof value === "string")
74
- .map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]),
82
+ .map(([key, value]) => [key, normalizeFilterValue(key, String(value))])
83
+ .filter(([, value]) => value.length > 0),
75
84
  );
76
85
  } catch {
77
86
  filters = {};
@@ -123,7 +132,7 @@ export const buildCollectionHref = (
123
132
  const normalizedFilters = Object.fromEntries(
124
133
  Object.entries(next.filters).map(([key, value]) => [
125
134
  key,
126
- parseFilterValueList(value).join(","),
135
+ normalizeFilterValue(key, value),
127
136
  ]),
128
137
  );
129
138
  params.set("filters", JSON.stringify(normalizedFilters));
@@ -9,8 +9,10 @@ import { buildCollectionHref, buildPageSequence, type CollectionQueryState } fro
9
9
  interface Props {
10
10
  pathname: string;
11
11
  query: CollectionQueryState;
12
+ stickyParams?: Record<string, string>;
12
13
  totalPages: number;
13
14
  currentPage: number;
15
+ totalItems?: number;
14
16
  sizeOptions?: number[];
15
17
  maxPageButtons?: number;
16
18
  class?: string;
@@ -19,8 +21,10 @@ interface Props {
19
21
  const {
20
22
  pathname,
21
23
  query,
24
+ stickyParams = {},
22
25
  totalPages,
23
26
  currentPage,
27
+ totalItems = 0,
24
28
  sizeOptions = [4, 2, 8, 12, 16],
25
29
  maxPageButtons = 7,
26
30
  class: className = "",
@@ -40,34 +44,58 @@ const pagerItems = buildPageSequence(totalPages, currentPage, maxPageButtons).ma
40
44
  const sanitizedSizeOptions = Array.from(
41
45
  new Set([query.size, ...sizeOptions].filter((value) => Number.isFinite(value) && value > 0)),
42
46
  ).sort((a, b) => a - b);
47
+ const pageSizeDisabled = totalItems === 0;
48
+ const pageSizeDisabledTitle = "Unavailable ⛔ Nothing to show. Reset filters to change page size.";
49
+ const withStickyParams = (href: string): string => {
50
+ if (Object.keys(stickyParams).length === 0) return href;
51
+ const url = new URL(href, "https://astroflare.local");
52
+ for (const [key, value] of Object.entries(stickyParams)) {
53
+ url.searchParams.set(key, value);
54
+ }
55
+ return `${url.pathname}?${url.searchParams.toString()}`;
56
+ };
57
+ const pagerItemsWithSticky = pagerItems.map((item) =>
58
+ "href" in item && typeof item.href === "string"
59
+ ? { ...item, href: withStickyParams(item.href) }
60
+ : item,
61
+ );
43
62
  ---
44
63
 
45
- <div class:list={["af-collection-footer grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center", className]}>
46
- <div class="af-collection-summary text-sm text-light-text-subtle dark:text-dark-text-subtle">
64
+ <div class:list={["af-collection-footer grid gap-3", className]}>
65
+ <div class="af-collection-controls-row flex items-center justify-between gap-3">
66
+ <Pager items={pagerItemsWithSticky} />
67
+ <form method="get" action={pathname} data-astro-reload>
68
+ <input type="hidden" name="page" value="1" />
69
+ <input type="hidden" name="offset" value="0" />
70
+ {Object.entries(stickyParams).map(([key, value]) => (
71
+ <input type="hidden" name={key} value={value} />
72
+ ))}
73
+ {Object.keys(activeFilters).length > 0 && (
74
+ <input type="hidden" name="filters" value={JSON.stringify(activeFilters)} />
75
+ )}
76
+ <label
77
+ class:list={["af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle", pageSizeDisabled && "is-disabled"]}
78
+ title={pageSizeDisabled ? pageSizeDisabledTitle : undefined}
79
+ >
80
+ <span>Page size</span>
81
+ <select
82
+ name="size"
83
+ disabled={pageSizeDisabled}
84
+ class="select-none rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
85
+ onchange="this.form?.submit()"
86
+ >
87
+ {sanitizedSizeOptions.map((size) => (
88
+ <option value={String(size)} selected={size === query.size}>
89
+ {size}
90
+ </option>
91
+ ))}
92
+ </select>
93
+ </label>
94
+ </form>
95
+ </div>
96
+ <div class="af-collection-summary text-right text-sm text-light-text-subtle dark:text-dark-text-subtle">
47
97
  <slot name="summary" />
48
98
  </div>
49
- <Pager items={pagerItems} class="md:col-start-2 md:justify-center" />
50
- <form method="get" action={pathname} data-astro-reload class="md:col-start-3 md:justify-self-end">
51
- <input type="hidden" name="page" value="1" />
52
- <input type="hidden" name="offset" value="0" />
53
- {Object.keys(activeFilters).length > 0 && (
54
- <input type="hidden" name="filters" value={JSON.stringify(activeFilters)} />
55
- )}
56
- <label class="af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
57
- <span>Page size</span>
58
- <select
59
- name="size"
60
- class="select-none rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
61
- onchange="this.form?.submit()"
62
- >
63
- {sanitizedSizeOptions.map((size) => (
64
- <option value={String(size)} selected={size === query.size}>
65
- {size}
66
- </option>
67
- ))}
68
- </select>
69
- </label>
70
- </form>
71
99
  </div>
72
100
 
73
101
  <style is:global>
@@ -81,4 +109,12 @@ const sanitizedSizeOptions = Array.from(
81
109
  user-select: none;
82
110
  -webkit-user-select: none;
83
111
  }
112
+ .af-page-size-label.is-disabled {
113
+ opacity: 0.58;
114
+ cursor: not-allowed;
115
+ }
116
+ .af-page-size-label.is-disabled select {
117
+ cursor: not-allowed;
118
+ border-style: dashed;
119
+ }
84
120
  </style>
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import FilterPills from "./FilterPills.astro";
3
+ import FilterPanel from "./FilterPanel.astro";
4
+ import FilterRow from "./FilterRow.astro";
3
5
  import Pager from "./Pager.astro";
4
6
  import {
5
7
  buildCollectionHref,
@@ -13,10 +15,15 @@ type Props = CollectionQueryProps;
13
15
  const {
14
16
  useServer = false,
15
17
  filters = [],
18
+ filtersTitle = "Filters",
19
+ filtersLabel = "Tags",
20
+ extraFiltersLabel = "Date",
21
+ showExtraFilters = false,
16
22
  maxPageButtons = 7,
17
23
  filtersClass = "mb-8",
18
24
  pagerClass = "mt-6",
19
25
  sizeOptions = [4, 2, 8, 12, 16],
26
+ stickyParams = {},
20
27
  showPageSize = true,
21
28
  pageSizeClass = "",
22
29
  perPage = 9,
@@ -37,7 +44,10 @@ const normalize = (value: string): string => value.trim().toLowerCase();
37
44
  const maxTagFilters = 5;
38
45
  const activeTagTokens = parseFilterValueList(activeFilters.tag);
39
46
  const lockFiltersToAll = totalItems === 0 && activeTagTokens.length >= 2;
40
- const allHref = buildCollectionHref(pathname, query, { filters: {}, page: 1, offset: 0 });
47
+ const clearTagFilters = Object.fromEntries(
48
+ Object.entries(activeFilters).filter(([key]) => key !== "tag"),
49
+ );
50
+ const allHref = buildCollectionHref(pathname, query, { filters: clearTagFilters, page: 1, offset: 0 });
41
51
  const serverFilterItems = filters.map((option) => {
42
52
  const isTagFilter = option.key === "tag";
43
53
  const normalizedOptionValue = normalizeFilterToken(option.value);
@@ -92,6 +102,26 @@ const pagerItems = buildPageSequence(totalPages, currentPage, maxPageButtons).ma
92
102
  const sanitizedSizeOptions = Array.from(
93
103
  new Set([query.size, ...sizeOptions].filter((value) => Number.isFinite(value) && value > 0)),
94
104
  ).sort((a, b) => a - b);
105
+ const pageSizeDisabled = totalItems === 0;
106
+ const pageSizeDisabledTitle = "Unavailable ⛔ Nothing to show. Reset filters to change page size.";
107
+ const withStickyParams = (href: string): string => {
108
+ if (Object.keys(stickyParams).length === 0) return href;
109
+ const url = new URL(href, "https://astroflare.local");
110
+ for (const [key, value] of Object.entries(stickyParams)) {
111
+ url.searchParams.set(key, value);
112
+ }
113
+ return `${url.pathname}?${url.searchParams.toString()}`;
114
+ };
115
+ const allHrefWithSticky = withStickyParams(allHref);
116
+ const serverFilterItemsWithSticky = serverFilterItems.map((item) => ({
117
+ ...item,
118
+ href: item.href ? withStickyParams(item.href) : item.href,
119
+ }));
120
+ const pagerItemsWithSticky = pagerItems.map((item) =>
121
+ "href" in item && typeof item.href === "string"
122
+ ? { ...item, href: withStickyParams(item.href) }
123
+ : item,
124
+ );
95
125
  ---
96
126
 
97
127
  {useServer && (
@@ -100,30 +130,46 @@ const sanitizedSizeOptions = Array.from(
100
130
  name="filters"
101
131
  >
102
132
  {serverFilterItems.length > 0 && (
103
- <FilterPills
104
- items={serverFilterItems}
105
- allHref={allHref}
106
- active={Object.keys(activeFilters).length === 0 ? "all" : "__filtered__"}
107
- class={filtersClass}
108
- />
133
+ <FilterPanel title={filtersTitle}>
134
+ <FilterRow label={filtersLabel}>
135
+ <FilterPills
136
+ items={serverFilterItemsWithSticky}
137
+ allHref={allHrefWithSticky}
138
+ active={activeTagTokens.length === 0 ? "all" : "__filtered__"}
139
+ class={filtersClass}
140
+ />
141
+ </FilterRow>
142
+ {showExtraFilters && Astro.slots.has("extra-filters") && (
143
+ <FilterRow label={extraFiltersLabel}>
144
+ <slot name="extra-filters" />
145
+ </FilterRow>
146
+ )}
147
+ </FilterPanel>
109
148
  )}
110
149
  </slot>
111
150
  <slot />
112
151
  <slot name="pager">
113
- <div class:list={["af-query-pager-row grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center", pagerClass]}>
114
- <Pager items={pagerItems} class="md:col-start-2 md:justify-center" />
152
+ <div class:list={["af-query-pager-row flex items-center justify-between gap-3", pagerClass]}>
153
+ <Pager items={pagerItemsWithSticky} />
115
154
  {showPageSize && (
116
155
  <form method="get" action={pathname} data-astro-reload class:list={["af-query-size-form md:col-start-3 md:justify-self-end", pageSizeClass]}>
117
156
  <input type="hidden" name="page" value="1" />
118
157
  <input type="hidden" name="offset" value="0" />
158
+ {Object.entries(stickyParams).map(([key, value]) => (
159
+ <input type="hidden" name={key} value={value} />
160
+ ))}
119
161
  {Object.keys(activeFilters).length > 0 && (
120
162
  <input type="hidden" name="filters" value={JSON.stringify(activeFilters)} />
121
163
  )}
122
- <label class="af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
164
+ <label
165
+ class:list={["af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle", pageSizeDisabled && "is-disabled"]}
166
+ title={pageSizeDisabled ? pageSizeDisabledTitle : undefined}
167
+ >
123
168
  <span>Page size</span>
124
169
  <select
125
170
  name="size"
126
171
  class="select-none rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
172
+ disabled={pageSizeDisabled}
127
173
  onchange="this.form?.submit()"
128
174
  >
129
175
  {sanitizedSizeOptions.map((size) => (
@@ -184,4 +230,12 @@ const sanitizedSizeOptions = Array.from(
184
230
  user-select: none;
185
231
  -webkit-user-select: none;
186
232
  }
233
+ .af-page-size-label.is-disabled {
234
+ opacity: 0.58;
235
+ cursor: not-allowed;
236
+ }
237
+ .af-page-size-label.is-disabled select {
238
+ cursor: not-allowed;
239
+ border-style: dashed;
240
+ }
187
241
  </style>
@@ -0,0 +1,106 @@
1
+ ---
2
+ import FilterPills from "./FilterPills.astro";
3
+
4
+ interface DatePresetItem {
5
+ value: string;
6
+ label: string;
7
+ href: string;
8
+ active?: boolean;
9
+ colorKey?: string;
10
+ }
11
+
12
+ interface Props {
13
+ pathname: string;
14
+ size: number;
15
+ activeDateFrom?: string;
16
+ activeDateTo?: string;
17
+ baseFilters: Record<string, string>;
18
+ anyDateHref: string;
19
+ datePresets: DatePresetItem[];
20
+ stickyParams?: Record<string, string>;
21
+ class?: string;
22
+ }
23
+
24
+ const {
25
+ pathname,
26
+ size,
27
+ activeDateFrom = "",
28
+ activeDateTo = "",
29
+ baseFilters,
30
+ anyDateHref,
31
+ datePresets,
32
+ stickyParams = {},
33
+ class: className = "",
34
+ } = Astro.props;
35
+ ---
36
+
37
+ <div class:list={["af-date-range-controls space-y-3", className]}>
38
+ <form
39
+ method="get"
40
+ action={pathname}
41
+ data-astro-reload
42
+ class="af-date-range-form flex flex-wrap items-end gap-2"
43
+ data-base-filters={JSON.stringify(baseFilters)}
44
+ onsubmit="const f=this;const base=JSON.parse(f.dataset.baseFilters||'{}');const from=f.querySelector('[data-date-from]')?.value?.trim();const to=f.querySelector('[data-date-to]')?.value?.trim();if(from)base.date_from=from;else delete base.date_from;if(to)base.date_to=to;else delete base.date_to;const hidden=f.querySelector('[data-filters-json]');if(Object.keys(base).length===0){hidden.removeAttribute('name');hidden.value='';}else{hidden.setAttribute('name','filters');hidden.value=JSON.stringify(base);}"
45
+ >
46
+ <input type="hidden" name="page" value="1" />
47
+ <input type="hidden" name="offset" value="0" />
48
+ <input type="hidden" name="size" value={String(size)} />
49
+ {Object.entries(stickyParams).map(([key, value]) => (
50
+ <input type="hidden" name={key} value={value} />
51
+ ))}
52
+ <input type="hidden" data-filters-json value={JSON.stringify(baseFilters)} />
53
+ <label class="inline-flex items-center gap-2 text-[0.68rem] font-medium uppercase tracking-[0.06em] text-light-text-subtle dark:text-dark-text-subtle">
54
+ <span>From</span>
55
+ <input
56
+ type="date"
57
+ data-date-from
58
+ value={activeDateFrom}
59
+ oninput="if(this.value===''||/^\d{4}-\d{2}-\d{2}$/.test(this.value)){this.form?.requestSubmit?.() ?? this.form?.submit()}"
60
+ onchange="this.form?.requestSubmit?.() ?? this.form?.submit()"
61
+ class="af-date-chip-input rounded-full border border-light-border bg-light-surface px-2.5 py-0.5 text-[0.68rem] font-medium tracking-[0.06em] text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
62
+ />
63
+ </label>
64
+ <label class="inline-flex items-center gap-2 text-[0.68rem] font-medium uppercase tracking-[0.06em] text-light-text-subtle dark:text-dark-text-subtle">
65
+ <span>To</span>
66
+ <input
67
+ type="date"
68
+ data-date-to
69
+ value={activeDateTo}
70
+ oninput="if(this.value===''||/^\d{4}-\d{2}-\d{2}$/.test(this.value)){this.form?.requestSubmit?.() ?? this.form?.submit()}"
71
+ onchange="this.form?.requestSubmit?.() ?? this.form?.submit()"
72
+ class="af-date-chip-input rounded-full border border-light-border bg-light-surface px-2.5 py-0.5 text-[0.68rem] font-medium tracking-[0.06em] text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
73
+ />
74
+ </label>
75
+ </form>
76
+ <FilterPills
77
+ includeAll
78
+ allLabel="Any Date"
79
+ allHref={anyDateHref}
80
+ active={datePresets.some((item) => item.active) ? "__filtered__" : "all"}
81
+ itemCase="none"
82
+ items={datePresets}
83
+ />
84
+ </div>
85
+
86
+ <style is:global>
87
+ .af-date-range-form input[type="date"] {
88
+ color-scheme: light;
89
+ min-height: 1.625rem;
90
+ }
91
+ [data-theme="dark"] .af-date-range-form input[type="date"] {
92
+ color-scheme: dark;
93
+ }
94
+ .af-date-range-form input[type="date"]::-webkit-calendar-picker-indicator {
95
+ opacity: 0.95;
96
+ cursor: pointer;
97
+ filter: saturate(0) brightness(0.38) contrast(1.2);
98
+ }
99
+ [data-theme="dark"] .af-date-range-form input[type="date"]::-webkit-calendar-picker-indicator {
100
+ filter: saturate(0) brightness(1.15) contrast(1.05);
101
+ }
102
+ .af-date-range-form input[type="date"]:focus-visible {
103
+ outline: 2px solid color-mix(in oklab, var(--color-secondary, #7c3aed) 55%, transparent);
104
+ outline-offset: 2px;
105
+ }
106
+ </style>
@@ -0,0 +1,118 @@
1
+ ---
2
+ interface Props {
3
+ summary: string;
4
+ open?: boolean;
5
+ class?: string;
6
+ summaryClass?: string;
7
+ contentClass?: string;
8
+ }
9
+
10
+ const {
11
+ summary,
12
+ open = false,
13
+ class: className = "",
14
+ summaryClass = "",
15
+ contentClass = "",
16
+ } = Astro.props;
17
+ ---
18
+
19
+ <details class:list={["af-disclosure", className]} open={open}>
20
+ <summary class:list={["af-disclosure-summary", summaryClass]}>
21
+ <span>{summary}</span>
22
+ <span class="af-disclosure-chevron" aria-hidden="true">
23
+ <svg viewBox="0 0 16 16" width="16" height="16" focusable="false" aria-hidden="true">
24
+ <path d="M4 6.5L8 10.5L12 6.5" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" />
25
+ </svg>
26
+ </span>
27
+ </summary>
28
+ <div class:list={["af-disclosure-content", contentClass]}>
29
+ <slot />
30
+ </div>
31
+ </details>
32
+
33
+ <script>
34
+ const parseHasActiveFilters = () => {
35
+ const params = new URLSearchParams(window.location.search);
36
+ const rawFilters = params.get("filters");
37
+ if (!rawFilters) return false;
38
+ try {
39
+ const parsed = JSON.parse(rawFilters);
40
+ return Boolean(parsed && typeof parsed === "object" && Object.keys(parsed).length > 0);
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+
46
+ const ensureStickyInput = (form) => {
47
+ let input = form.querySelector('input[name="filters_open"]');
48
+ if (!input) {
49
+ input = document.createElement("input");
50
+ input.setAttribute("type", "hidden");
51
+ input.setAttribute("name", "filters_open");
52
+ form.appendChild(input);
53
+ }
54
+ input.setAttribute("value", "1");
55
+ };
56
+
57
+ const syncFiltersOpenState = (isOpen) => {
58
+ const hasActiveFilters = parseHasActiveFilters();
59
+ const shouldPersistOpen = isOpen || hasActiveFilters;
60
+ const forms = document.querySelectorAll('form[method="get"]');
61
+
62
+ for (const formNode of forms) {
63
+ if (!(formNode instanceof HTMLFormElement)) continue;
64
+ if (shouldPersistOpen) {
65
+ ensureStickyInput(formNode);
66
+ } else {
67
+ const input = formNode.querySelector('input[name="filters_open"]');
68
+ if (input) input.remove();
69
+ }
70
+ }
71
+
72
+ const nextUrl = new URL(window.location.href);
73
+ if (shouldPersistOpen) nextUrl.searchParams.set("filters_open", "1");
74
+ else nextUrl.searchParams.delete("filters_open");
75
+ if (nextUrl.toString() !== window.location.href) {
76
+ window.history.replaceState(window.history.state, "", nextUrl.toString());
77
+ }
78
+ };
79
+
80
+ const disclosures = document.querySelectorAll("details.af-disclosure");
81
+ for (const disclosureNode of disclosures) {
82
+ if (!(disclosureNode instanceof HTMLDetailsElement)) continue;
83
+ syncFiltersOpenState(disclosureNode.open);
84
+ disclosureNode.addEventListener("toggle", () => {
85
+ syncFiltersOpenState(disclosureNode.open);
86
+ });
87
+ }
88
+ </script>
89
+
90
+ <style is:global>
91
+ .af-disclosure-summary {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 0.5rem;
96
+ cursor: pointer;
97
+ list-style: none;
98
+ }
99
+ .af-disclosure-summary::-webkit-details-marker {
100
+ display: none;
101
+ }
102
+ .af-disclosure-chevron {
103
+ display: inline-flex;
104
+ width: 1.75rem;
105
+ height: 1.75rem;
106
+ align-items: center;
107
+ justify-content: center;
108
+ border-radius: 9999px;
109
+ border: 1px solid currentColor;
110
+ flex: 0 0 auto;
111
+ opacity: 0.88;
112
+ transition: transform 140ms ease;
113
+ transform-origin: center;
114
+ }
115
+ .af-disclosure[open] .af-disclosure-chevron {
116
+ transform: rotate(180deg);
117
+ }
118
+ </style>
@@ -0,0 +1,29 @@
1
+ ---
2
+ interface Props {
3
+ title?: string;
4
+ class?: string;
5
+ }
6
+
7
+ const { title = "Filters", class: className = "" } = Astro.props;
8
+ ---
9
+
10
+ <section class:list={["af-filter-panel", className]} aria-label={title}>
11
+ {title && <p class="af-filter-panel-title">{title}</p>}
12
+ <div class="af-filter-panel-body">
13
+ <slot />
14
+ </div>
15
+ </section>
16
+
17
+ <style is:global>
18
+ .af-filter-panel-title {
19
+ margin: 0 0 0.8rem 0;
20
+ font-size: 0.82rem;
21
+ font-weight: 500;
22
+ letter-spacing: 0.04em;
23
+ text-transform: uppercase;
24
+ color: color-mix(in oklab, var(--color-light-text-title, #1f2937) 72%, var(--color-light-text-subtle, #6b7280));
25
+ }
26
+ [data-theme="dark"] .af-filter-panel-title {
27
+ color: color-mix(in oklab, var(--color-dark-text-title, #f3f4f6) 72%, var(--color-dark-text-subtle, #9ca3af));
28
+ }
29
+ </style>
@@ -112,9 +112,9 @@ const normalizedItems = items.map((item) =>
112
112
  type="submit"
113
113
  class:list={["af-filter-pill", isActive && "is-active"]}
114
114
  style={{
115
- "--af-pill-bg": palette.bg,
116
- "--af-pill-border": palette.border,
117
- "--af-pill-text": palette.text,
115
+ "--af-tag-bg": palette.bg,
116
+ "--af-tag-border": palette.border,
117
+ "--af-tag-text": palette.text,
118
118
  }}
119
119
  >
120
120
  {label}
@@ -127,11 +127,12 @@ const normalizedItems = items.map((item) =>
127
127
  type="button"
128
128
  disabled
129
129
  aria-disabled="true"
130
+ title="Nothing to show with current filters. Reset or remove filters."
130
131
  class:list={["af-filter-pill", isActive && "is-active"]}
131
132
  style={{
132
- "--af-pill-bg": palette.bg,
133
- "--af-pill-border": palette.border,
134
- "--af-pill-text": palette.text,
133
+ "--af-tag-bg": palette.bg,
134
+ "--af-tag-border": palette.border,
135
+ "--af-tag-text": palette.text,
135
136
  }}
136
137
  >
137
138
  {label}
@@ -142,9 +143,9 @@ const normalizedItems = items.map((item) =>
142
143
  data-filter={item.value}
143
144
  class:list={["af-filter-pill", isActive && "is-active"]}
144
145
  style={{
145
- "--af-pill-bg": palette.bg,
146
- "--af-pill-border": palette.border,
147
- "--af-pill-text": palette.text,
146
+ "--af-tag-bg": palette.bg,
147
+ "--af-tag-border": palette.border,
148
+ "--af-tag-text": palette.text,
148
149
  }}
149
150
  >
150
151
  {label}
@@ -157,13 +158,13 @@ const normalizedItems = items.map((item) =>
157
158
  <style>
158
159
  .af-filter-pill {
159
160
  border-radius: 9999px;
160
- border: 1px solid var(--af-pill-border, var(--color-light-border));
161
- background: var(--af-pill-bg, color-mix(in oklab, var(--color-light-surface) 80%, transparent));
162
- color: var(--af-pill-text, color-mix(in oklab, var(--color-light-text-subtle) 78%, transparent));
163
- padding: 0.35rem 0.9rem;
164
- font-size: 0.72rem;
161
+ border: 1px solid var(--af-tag-border, var(--color-light-border));
162
+ background: var(--af-tag-bg, color-mix(in oklab, var(--color-light-surface) 80%, transparent));
163
+ color: var(--af-tag-text, color-mix(in oklab, var(--color-light-text-body, #111827) 88%, transparent));
164
+ padding: 0.125rem 0.625rem;
165
+ font-size: 0.68rem;
165
166
  font-weight: 500;
166
- letter-spacing: 0.08em;
167
+ letter-spacing: 0.06em;
167
168
  text-transform: uppercase;
168
169
  cursor: pointer;
169
170
  transition: transform 130ms ease, filter 130ms ease;
@@ -179,19 +180,20 @@ const normalizedItems = items.map((item) =>
179
180
  }
180
181
 
181
182
  .af-filter-pill:hover {
182
- filter: brightness(1.04);
183
+ filter: brightness(0.97) saturate(1.04);
183
184
  }
184
185
  .af-filter-pill:disabled,
185
186
  .af-filter-pill[aria-disabled="true"] {
186
187
  cursor: not-allowed;
187
- opacity: 0.48;
188
- filter: grayscale(0.3);
188
+ opacity: 0.34;
189
+ filter: grayscale(0.45) contrast(0.86);
190
+ color: color-mix(in oklab, var(--af-tag-text, var(--color-light-text-subtle, #6b7280)) 42%, var(--color-light-text-subtle, #6b7280));
189
191
  }
190
192
 
191
193
  .af-filter-pill.is-active {
192
- border-color: color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 80%, transparent);
193
- box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 36%, transparent);
194
- outline: 2px solid color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 48%, transparent);
194
+ border-color: color-mix(in oklab, var(--af-tag-text, var(--color-secondary)) 80%, transparent);
195
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--af-tag-text, var(--color-secondary)) 36%, transparent);
196
+ outline: 2px solid color-mix(in oklab, var(--af-tag-text, var(--color-secondary)) 48%, transparent);
195
197
  outline-offset: 1px;
196
198
  font-weight: 600;
197
199
  filter: saturate(1.14) brightness(1.06);
@@ -200,28 +202,34 @@ const normalizedItems = items.map((item) =>
200
202
  text-underline-offset: 0.15em;
201
203
  }
202
204
  .af-filter-pill.af-filter-pill-all {
203
- --af-pill-bg: transparent;
204
- --af-pill-border: color-mix(in oklab, var(--color-light-text-subtle) 45%, transparent);
205
- --af-pill-text: color-mix(in oklab, var(--color-light-text-body) 92%, transparent);
205
+ --af-tag-bg: transparent;
206
+ --af-tag-border: color-mix(in oklab, var(--color-light-text-subtle) 45%, transparent);
207
+ --af-tag-text: color-mix(in oklab, var(--color-light-text-body) 92%, transparent);
206
208
  }
207
209
  .af-filter-pill.af-filter-pill-all.is-active {
208
- --af-pill-bg: color-mix(in oklab, var(--color-light-text-body) 7%, transparent);
209
- --af-pill-border: color-mix(in oklab, var(--color-light-text-body) 72%, transparent);
210
- --af-pill-text: var(--color-light-text-body);
210
+ --af-tag-bg: color-mix(in oklab, var(--color-light-text-body) 7%, transparent);
211
+ --af-tag-border: color-mix(in oklab, var(--color-light-text-body) 72%, transparent);
212
+ --af-tag-text: var(--color-light-text-body);
211
213
  }
212
214
  :global([data-theme="dark"]) .af-filter-pill {
213
- color: var(--af-pill-text, color-mix(in oklab, var(--color-dark-text-subtle) 85%, transparent));
214
- border-color: var(--af-pill-border, var(--color-dark-border));
215
- background: var(--af-pill-bg, color-mix(in oklab, var(--color-dark-surface) 70%, transparent));
215
+ color: color-mix(in oklab, var(--af-tag-text, #d1d5db) 34%, var(--color-dark-text-body, #f3f4f6));
216
+ border-color: color-mix(in oklab, var(--af-tag-border, var(--color-dark-border)) 62%, var(--color-dark-border) 38%);
217
+ background: color-mix(in oklab, var(--af-tag-bg, transparent) 48%, var(--color-dark-surface, #1f2937) 52%);
216
218
  }
217
219
  :global([data-theme="dark"]) .af-filter-pill.af-filter-pill-all {
218
- --af-pill-bg: transparent;
219
- --af-pill-border: color-mix(in oklab, var(--color-dark-text-subtle) 48%, transparent);
220
- --af-pill-text: color-mix(in oklab, var(--color-dark-text-body) 90%, transparent);
220
+ --af-tag-bg: transparent;
221
+ --af-tag-border: color-mix(in oklab, var(--color-dark-text-subtle) 48%, transparent);
222
+ --af-tag-text: color-mix(in oklab, var(--color-dark-text-body) 90%, transparent);
221
223
  }
222
224
  :global([data-theme="dark"]) .af-filter-pill.af-filter-pill-all.is-active {
223
- --af-pill-bg: color-mix(in oklab, var(--color-dark-text-body) 10%, transparent);
224
- --af-pill-border: color-mix(in oklab, var(--color-dark-text-body) 70%, transparent);
225
- --af-pill-text: var(--color-dark-text-body);
225
+ --af-tag-bg: color-mix(in oklab, var(--color-dark-text-body) 10%, transparent);
226
+ --af-tag-border: color-mix(in oklab, var(--color-dark-text-body) 70%, transparent);
227
+ --af-tag-text: var(--color-dark-text-body);
228
+ }
229
+ :global([data-theme="dark"]) .af-filter-pill:disabled,
230
+ :global([data-theme="dark"]) .af-filter-pill[aria-disabled="true"] {
231
+ opacity: 0.42;
232
+ filter: grayscale(0.35) contrast(0.9);
233
+ color: color-mix(in oklab, var(--color-dark-text-subtle, #9ca3af) 68%, var(--af-tag-text, #d1d5db));
226
234
  }
227
235
  </style>
@@ -0,0 +1,57 @@
1
+ ---
2
+ interface Props {
3
+ label: string;
4
+ class?: string;
5
+ }
6
+
7
+ const { label, class: className = "" } = Astro.props;
8
+ ---
9
+
10
+ <div class:list={["af-filter-row", className]}>
11
+ <p class="af-filter-row-label">{label}</p>
12
+ <div class="af-filter-row-content">
13
+ <slot />
14
+ </div>
15
+ </div>
16
+
17
+ <style is:global>
18
+ .af-filter-row {
19
+ display: grid;
20
+ grid-template-columns: 4.75rem 1fr;
21
+ align-items: start;
22
+ column-gap: 0.75rem;
23
+ }
24
+ .af-filter-row + .af-filter-row {
25
+ margin-top: 0.95rem;
26
+ padding-top: 0.9rem;
27
+ border-top: 1px solid color-mix(in oklab, var(--color-area-divider-light, var(--color-light-border, #d1d5db)) 58%, transparent);
28
+ }
29
+ .af-filter-row-label {
30
+ margin: 0;
31
+ padding-top: 0.35rem;
32
+ font-size: 0.75rem;
33
+ font-weight: 600;
34
+ letter-spacing: 0.08em;
35
+ text-transform: uppercase;
36
+ color: var(--color-area-ui-label-light, color-mix(in oklab, var(--color-light-text-subtle, #6b7280) 92%, transparent));
37
+ }
38
+ .af-filter-row-content {
39
+ min-width: 0;
40
+ padding-bottom: 0.15rem;
41
+ }
42
+ [data-theme="dark"] .af-filter-row-label {
43
+ color: var(--color-area-ui-label-dark, color-mix(in oklab, var(--color-dark-text-body, #f3f4f6) 68%, var(--color-dark-text-subtle, #9ca3af) 32%));
44
+ }
45
+ [data-theme="dark"] .af-filter-row + .af-filter-row {
46
+ border-top-color: color-mix(in oklab, var(--color-area-divider-dark, var(--color-dark-border, #374151)) 58%, transparent);
47
+ }
48
+ @media (max-width: 640px) {
49
+ .af-filter-row {
50
+ grid-template-columns: 1fr;
51
+ row-gap: 0.35rem;
52
+ }
53
+ .af-filter-row-label {
54
+ padding-top: 0;
55
+ }
56
+ }
57
+ </style>
@@ -0,0 +1,172 @@
1
+ ---
2
+ /**
3
+ * ImageFade — generic image loading wrapper.
4
+ *
5
+ * Wraps any slotted `<img>`, `<picture>`, or Astro `<Image />` (which renders
6
+ * an `<img>`). Shows a spinner (or shimmer skeleton) placeholder until the
7
+ * image finishes loading, then fades the image in and the placeholder out.
8
+ * Cached/already-complete images resolve instantly (no flash).
9
+ *
10
+ * Reserve layout space with `aspectRatio` to avoid CLS. Styling is theme-aware
11
+ * via `--color-brand` (override with `spinnerColor`).
12
+ *
13
+ * @example
14
+ * <ImageFade aspectRatio="16/9" rounded="rounded-2xl">
15
+ * <Image src={hero} alt="…" />
16
+ * </ImageFade>
17
+ */
18
+ interface Props {
19
+ /** Extra classes merged onto the wrapper. */
20
+ class?: string;
21
+ /** Radius class/utility applied to the wrapper (clips the image), e.g. `rounded-2xl`. */
22
+ rounded?: string;
23
+ /** CSS `aspect-ratio` to reserve space and prevent layout shift, e.g. `"16/9"`, `"1"`. */
24
+ aspectRatio?: string;
25
+ /** Fade-in duration in ms. */
26
+ duration?: number;
27
+ /** Spinner/skeleton accent colour (defaults to `--color-brand`). */
28
+ spinnerColor?: string;
29
+ /** Show a shimmer skeleton instead of a spinner. */
30
+ skeleton?: boolean;
31
+ }
32
+
33
+ const {
34
+ class: className = '',
35
+ rounded = '',
36
+ aspectRatio,
37
+ duration = 500,
38
+ spinnerColor,
39
+ skeleton = false,
40
+ } = Astro.props;
41
+
42
+ const style = [
43
+ aspectRatio ? `aspect-ratio:${aspectRatio}` : '',
44
+ spinnerColor ? `--if-accent:${spinnerColor}` : '',
45
+ `--if-duration:${duration}ms`,
46
+ ]
47
+ .filter(Boolean)
48
+ .join(';');
49
+ ---
50
+
51
+ <image-fade class:list={['image-fade', skeleton && 'is-skeleton', rounded, className]} style={style}>
52
+ <span class='image-fade__placeholder' aria-hidden='true'>
53
+ {!skeleton && <span class='image-fade__ring'></span>}
54
+ </span>
55
+ <slot />
56
+ </image-fade>
57
+
58
+ <style is:global>
59
+ image-fade.image-fade {
60
+ position: relative;
61
+ display: block;
62
+ overflow: hidden;
63
+ background: color-mix(in oklab, var(--if-accent, var(--color-brand, #7c5cff)) 8%, transparent);
64
+ }
65
+
66
+ /* Slotted image starts hidden, fades in once loaded */
67
+ image-fade.image-fade > img,
68
+ image-fade.image-fade > picture > img {
69
+ display: block;
70
+ width: 100%;
71
+ height: 100%;
72
+ object-fit: cover;
73
+ opacity: 0;
74
+ transition: opacity var(--if-duration, 500ms) ease-out;
75
+ }
76
+ image-fade.image-fade.is-loaded > img,
77
+ image-fade.image-fade.is-loaded > picture > img {
78
+ opacity: 1;
79
+ }
80
+
81
+ .image-fade__placeholder {
82
+ position: absolute;
83
+ inset: 0;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ opacity: 1;
88
+ transition: opacity var(--if-duration, 500ms) ease-out;
89
+ pointer-events: none;
90
+ }
91
+ image-fade.image-fade.is-loaded .image-fade__placeholder {
92
+ opacity: 0;
93
+ }
94
+
95
+ .image-fade__ring {
96
+ width: 2.5rem;
97
+ height: 2.5rem;
98
+ border: 3px solid transparent;
99
+ border-top-color: var(--if-accent, var(--color-brand, #7c5cff));
100
+ border-right-color: color-mix(
101
+ in oklab,
102
+ var(--if-accent, var(--color-brand, #7c5cff)) 50%,
103
+ transparent
104
+ );
105
+ border-radius: 50%;
106
+ animation: image-fade-spin 0.9s linear infinite;
107
+ }
108
+
109
+ /* Skeleton variant: shimmer sweep instead of a spinner */
110
+ image-fade.image-fade.is-skeleton .image-fade__placeholder {
111
+ background: linear-gradient(
112
+ 100deg,
113
+ color-mix(in oklab, var(--if-accent, var(--color-brand, #7c5cff)) 6%, transparent) 30%,
114
+ color-mix(in oklab, var(--if-accent, var(--color-brand, #7c5cff)) 16%, transparent) 50%,
115
+ color-mix(in oklab, var(--if-accent, var(--color-brand, #7c5cff)) 6%, transparent) 70%
116
+ );
117
+ background-size: 200% 100%;
118
+ animation: image-fade-shimmer 1.4s ease-in-out infinite;
119
+ }
120
+
121
+ @keyframes image-fade-spin {
122
+ to {
123
+ transform: rotate(360deg);
124
+ }
125
+ }
126
+ @keyframes image-fade-shimmer {
127
+ from {
128
+ background-position: 200% 0;
129
+ }
130
+ to {
131
+ background-position: -200% 0;
132
+ }
133
+ }
134
+
135
+ @media (prefers-reduced-motion: reduce) {
136
+ image-fade.image-fade > img,
137
+ image-fade.image-fade > picture > img,
138
+ .image-fade__placeholder {
139
+ transition: none;
140
+ }
141
+ .image-fade__ring {
142
+ animation: none;
143
+ }
144
+ image-fade.image-fade.is-skeleton .image-fade__placeholder {
145
+ animation: none;
146
+ }
147
+ }
148
+ </style>
149
+
150
+ <script>
151
+ class ImageFade extends HTMLElement {
152
+ connectedCallback() {
153
+ const img = this.querySelector('img');
154
+ if (!img) {
155
+ this.classList.add('is-loaded');
156
+ return;
157
+ }
158
+ const done = () => this.classList.add('is-loaded');
159
+ // Already cached / decoded
160
+ if (img.complete && img.naturalWidth > 0) {
161
+ done();
162
+ return;
163
+ }
164
+ img.addEventListener('load', done, { once: true });
165
+ // On error, still reveal (broken-image icon beats a permanent spinner)
166
+ img.addEventListener('error', done, { once: true });
167
+ }
168
+ }
169
+ if (!customElements.get('image-fade')) {
170
+ customElements.define('image-fade', ImageFade);
171
+ }
172
+ </script>
@@ -53,6 +53,7 @@ const formPartsFromHref = (href: string): { action: string; fields: Array<{ name
53
53
  data-page={String(item.page)}
54
54
  disabled={item.disabled ?? item.active}
55
55
  aria-disabled={item.disabled ?? item.active}
56
+ title={(item.disabled ?? item.active) ? "Unavailable ⛔" : undefined}
56
57
  class:list={["af-pager-pill", item.active && "is-active"]}
57
58
  >
58
59
  {item.label ?? item.page}
@@ -97,7 +98,6 @@ const formPartsFromHref = (href: string): { action: string; fields: Array<{ name
97
98
  .af-pager .af-pager-pill[aria-disabled="true"] {
98
99
  cursor: not-allowed;
99
100
  opacity: 0.5;
100
- pointer-events: none;
101
101
  border-color: color-mix(in oklab, var(--color-light-border) 65%, transparent);
102
102
  color: color-mix(in oklab, var(--color-light-text-subtle) 50%, transparent);
103
103
  background: color-mix(in oklab, var(--color-light-surface) 35%, transparent);
@@ -43,12 +43,12 @@ const overflowTooltip = overflowItems.map(formatItem).join(", ");
43
43
  return (
44
44
  <span
45
45
  style={{
46
- backgroundColor: palette.bg,
47
- borderColor: palette.border,
48
- color: palette.text,
46
+ "--af-tag-bg": palette.bg,
47
+ "--af-tag-border": palette.border,
48
+ "--af-tag-text": palette.text,
49
49
  }}
50
50
  class:list={[
51
- "inline-block rounded-full border px-2.5 py-0.5 text-[0.68rem] font-medium uppercase tracking-[0.06em]",
51
+ "af-tag-tone inline-block rounded-full border px-2.5 py-0.5 text-[0.68rem] font-medium uppercase tracking-[0.06em]",
52
52
  itemClass,
53
53
  ]}
54
54
  >
@@ -69,3 +69,16 @@ const overflowTooltip = overflowItems.map(formatItem).join(", ");
69
69
  </Tooltip>
70
70
  )}
71
71
  </div>
72
+
73
+ <style is:global>
74
+ .af-tag-tone {
75
+ background: var(--af-tag-bg, color-mix(in oklab, var(--color-light-surface) 86%, transparent));
76
+ border-color: var(--af-tag-border, color-mix(in oklab, var(--color-light-border) 72%, transparent));
77
+ color: var(--af-tag-text, color-mix(in oklab, var(--color-light-text-title) 84%, var(--color-light-text-subtle) 16%));
78
+ }
79
+ [data-theme="dark"] .af-tag-tone {
80
+ background: color-mix(in oklab, var(--af-tag-bg, transparent) 48%, var(--color-dark-surface) 52%);
81
+ border-color: color-mix(in oklab, var(--af-tag-border, var(--color-dark-border)) 62%, var(--color-dark-border) 38%);
82
+ color: color-mix(in oklab, var(--af-tag-text, var(--color-dark-text-body)) 38%, var(--color-dark-text-body) 62%);
83
+ }
84
+ </style>
@@ -7,6 +7,16 @@ export interface CollectionQueryFilterOption {
7
7
  }
8
8
 
9
9
  interface CollectionQueryBaseProps {
10
+ /** Extra query params to preserve across filter/pager/size interactions. */
11
+ stickyParams?: Record<string, string>;
12
+ /** Optional title shown above filters block in server mode. */
13
+ filtersTitle?: string;
14
+ /** Label shown left of tag pills in server mode. */
15
+ filtersLabel?: string;
16
+ /** Label shown left of optional extra filters slot in server mode. */
17
+ extraFiltersLabel?: string;
18
+ /** Controls whether the optional extra-filters row renders. */
19
+ showExtraFilters?: boolean;
10
20
  /** Available filters for filter pill rendering. */
11
21
  filters?: CollectionQueryFilterOption[];
12
22
  /** Max visible pager buttons before ellipsis. */
package/src/tag-colors.ts CHANGED
@@ -13,11 +13,11 @@ export interface TagColorOptions {
13
13
  }
14
14
 
15
15
  const DEFAULT_OVERRIDES: Record<string, string> = {
16
- shoots: "#dc3545",
17
- tech: "#8a95a5",
18
- ai: "#6b7b8d",
19
- sites: "#7dd3c0",
20
- flow: "#b367e0",
16
+ shoots: "#c4786a",
17
+ tech: "#4d8c7a",
18
+ ai: "#d9773a",
19
+ sites: "#b8942e",
20
+ flow: "#9b6bc9",
21
21
  };
22
22
 
23
23
  const hashStringToHue = (input: string): number => {
@@ -82,8 +82,8 @@ export const getTagPalette = (
82
82
  ): TagPalette => {
83
83
  const hue = resolveHue(tag, options);
84
84
  return {
85
- bg: `hsla(${hue} 48% 58% / 0.12)`,
86
- border: `hsla(${hue} 52% 58% / 0.34)`,
87
- text: `hsl(${hue} 50% 66%)`,
85
+ bg: `hsla(${hue} 58% 56% / 0.16)`,
86
+ border: `hsla(${hue} 56% 46% / 0.42)`,
87
+ text: `hsl(${hue} 58% 32%)`,
88
88
  };
89
89
  };