@escape-game-over/atlas 0.1.76 → 0.1.78

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.
@@ -36,7 +36,7 @@ finding them.
36
36
  | ------------------ | -------------------------------------------------------------- | ----------------------------------------------- |
37
37
  | `carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
38
38
  | `filters` | which items match, and what the address bar says | every DOM read and write |
39
- | `filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
39
+ | `filters-view` | the DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
40
40
  | `youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
41
41
  | `background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
42
42
  | `consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
@@ -249,11 +249,12 @@ re-clicking the current tab does not add a history step to walk back through.
249
249
 
250
250
  ## `filters-view`
251
251
 
252
- Two helpers, and the bar for being here is not "a list might want this" but
252
+ Three helpers, and the bar for being here is not "a list might want this" but
253
253
  "every list we have already written did, character for character, and getting it
254
254
  wrong was silent". Three filtered lists in one consuming project — a catalogue, a
255
- location directory and an FAQ — agree on exactly these and disagree about
256
- everything else they draw.
255
+ location directory and an FAQ — agree on `searchBox` and `hideEmpty` and
256
+ disagree about everything else they draw; `filtering` was copied between two
257
+ sites before it came here.
257
258
 
258
259
  ### `searchBox`
259
260
 
@@ -334,6 +335,49 @@ recorded as behaviour rather than only as prose.
334
335
  A group with no children at all is hidden, by the same rule: there is nothing
335
336
  visible in it.
336
337
 
338
+ ### `filtering`
339
+
340
+ A chip that narrows a grid in place, animated: what stays slides to its new
341
+ place, what leaves or arrives fades. It wraps the `set` or `reset` the chip does
342
+ in a view transition, and runs it plainly where the API is missing.
343
+
344
+ ```ts
345
+ const items = refs(root, "[data-filter-item]");
346
+ chip.addEventListener(
347
+ "click",
348
+ () => filtering(items, () => list.set("category", data(chip, "category"))),
349
+ { signal }
350
+ );
351
+ ```
352
+
353
+ Every item needs its own view-transition name for the move to animate, and
354
+ `match-element` gives each one without the page minting ids. **Only while it
355
+ runs**: named for good, every card would also pair up across page navigations,
356
+ and one with no partner on the next page would linger over it. So the helper
357
+ writes the name inline, and puts back whatever inline name the item had when
358
+ the last transition ends.
359
+
360
+ **The last, not each.** A second click skips the first transition, and the
361
+ first one's end would otherwise strip the names out from under the one still
362
+ running, which then animates nothing. The helper counts. `tests/filtering.test.ts`
363
+ pins the overlap, including an item that had a name of its own.
364
+
365
+ **One rule stays the project's.** A site that stops unpaired names animating
366
+ across pages also stops filtering's fades, since a card hidden or shown has no
367
+ partner either. The helper marks `<html>` with `data-atlas-filtering`
368
+ (`FILTERING_ATTRIBUTE`) while it runs, for the site to scope that rule out:
369
+
370
+ ```css
371
+ :root:not([data-atlas-filtering])::view-transition-new(*):only-child {
372
+ animation: none;
373
+ }
374
+ :root:not([data-atlas-filtering])::view-transition-old(*):only-child {
375
+ display: none;
376
+ }
377
+ ```
378
+
379
+ A site without such a rule needs no CSS at all.
380
+
337
381
  ### Why these write instead of calling back
338
382
 
339
383
  `filters.onChange` is already the callback that hands a consumer the answer and
@@ -632,6 +676,9 @@ right properties is a faithful stand-in.
632
676
  pins the write-back guard by counting *writes* — so deleting the guard fails
633
677
  rather than passing on an identical value — and pins the nesting order in
634
678
  both directions.
679
+ - `tests/filtering.test.ts` stubs `document` with a `startViewTransition` whose
680
+ transitions finish when the test says, so two overlapping runs can be ended
681
+ in order.
635
682
  - `tests/youtube.test.ts` fakes the trigger, and a container whose
636
683
  `ownerDocument` makes the iframe — which is why the module reaches the
637
684
  document through the container rather than the global.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -48,7 +48,7 @@
48
48
  "check-fmt": "biome check . --error-on-warnings"
49
49
  },
50
50
  "dependencies": {
51
- "@types/google.maps": "3.66.4"
51
+ "@types/google.maps": "3.66.5"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@types/node": ">=22",
@@ -60,7 +60,7 @@
60
60
  "@biomejs/biome": "2.5.15",
61
61
  "@types/node": "26.6.4",
62
62
  "@vitest/coverage-istanbul": "5.0.3",
63
- "astro": "7.3.5",
63
+ "astro": "7.3.6",
64
64
  "typescript": "6.0.3",
65
65
  "vitest": "5.0.3"
66
66
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from "./consent-storage.ts";
9
9
  import googleTag from "./google-tag.ts?raw";
10
10
  import { type GoogleTagId, invalidId, type TagManagerId } from "./ids.ts";
11
+ import type { StorageVendor } from "./storage.ts";
11
12
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
12
13
 
13
14
  /**
@@ -150,6 +151,16 @@ export interface GoogleSettings {
150
151
  * ads has no click id to carry.
151
152
  */
152
153
  readonly urlPassthrough?: true;
154
+ /**
155
+ * The vendors these tags bring that their ids do not name: whatever a
156
+ * Tag Manager container fires, or a `GT-` tag's destinations. Lib cannot
157
+ * see inside either, so `storedItems` lists only what is stated here — a
158
+ * `G-` id already implies Google Analytics and an `AW-` id Google Ads.
159
+ *
160
+ * A statement about someone else's configuration, so it goes stale the day
161
+ * they add a tag: whoever runs the container should know it is here.
162
+ */
163
+ readonly alsoLoads?: readonly StorageVendor[];
153
164
  }
154
165
 
155
166
  /**
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The keys Atlas keeps on a visitor's device besides the consent record
3
+ * (`CONSENT_KEY`). On their own so the browser scripts that use them import
4
+ * two strings, not the catalog `storedItems` lists them from.
5
+ */
6
+
7
+ /**
8
+ * The `localStorage` key `RememberLocale` keeps the chosen locale under. Its
9
+ * script is inline and imports nothing; `inlineScript` declares this around it.
10
+ */
11
+ export const LOCALE_KEY = "atlas-locale";
12
+
13
+ /** The `sessionStorage` key `rememberCampaign` keeps a visit's `utm_*` under. */
14
+ export const CAMPAIGN_KEY = "atlas-campaign";
@@ -0,0 +1,363 @@
1
+ import {
2
+ CONSENT_KEY,
3
+ CONSENT_MONTHS,
4
+ type ConsentCategory,
5
+ } from "./consent-storage.ts";
6
+ import { type AnalyticsSettings, consentVendors } from "./index.ts";
7
+ import { CAMPAIGN_KEY, LOCALE_KEY } from "./storage-keys.ts";
8
+
9
+ /**
10
+ * What a deployment keeps on a visitor's device, row by row, for the table a
11
+ * privacy notice owes them: each name, who sets it, how long it stays, and
12
+ * what has to be granted first.
13
+ *
14
+ * Here because the facts are the vendors' and the library's, not a site's.
15
+ * Cookie names and lifetimes change when a vendor changes its tag, and the
16
+ * three keys Atlas sets itself change when Atlas does; a table copied into
17
+ * each site is a table that is wrong in each site on a different day. The
18
+ * words — what each row is *for*, in each language — stay with the project,
19
+ * keyed by `StoredItem.id`.
20
+ *
21
+ * Built from the same settings that load the tags, so a vendor cannot run
22
+ * without a row. What lib cannot see — the tags inside a Tag Manager
23
+ * container — comes from `GoogleSettings.alsoLoads`.
24
+ */
25
+
26
+ /** A vendor whose tag sets storage, by product rather than by company. */
27
+ export type StorageVendor =
28
+ | "google-analytics"
29
+ | "google-ads"
30
+ | "linkedin"
31
+ | "meta"
32
+ | "tiktok"
33
+ | "snapchat"
34
+ | "axon"
35
+ | "drip"
36
+ | "clarity";
37
+
38
+ /**
39
+ * Each vendor's product as it calls itself, for a table's "set by" column.
40
+ * Names, not copy: the same in every language, so a project translates only
41
+ * the site's own label.
42
+ */
43
+ export const STORAGE_VENDOR_NAMES = {
44
+ "google-analytics": "Google Analytics",
45
+ "google-ads": "Google Ads",
46
+ linkedin: "LinkedIn",
47
+ meta: "Meta",
48
+ tiktok: "TikTok",
49
+ snapchat: "Snapchat",
50
+ axon: "AppLovin Axon",
51
+ drip: "Drip",
52
+ clarity: "Microsoft Clarity",
53
+ } as const satisfies Record<StorageVendor, string>;
54
+
55
+ /** A span `Intl.NumberFormat` can word in any locale: `{ 2, "year" }`. */
56
+ export interface Lifetime {
57
+ readonly count: number;
58
+ readonly unit: "day" | "month" | "year";
59
+ }
60
+
61
+ /** The vendor-domain row of a vendor that has one. */
62
+ type OwnRow = `${VendorWithOwn}-own`;
63
+
64
+ /** The keys Atlas itself sets. */
65
+ export type AtlasStoredId = "atlas-locale" | "atlas-campaign" | "atlas-consent";
66
+
67
+ /** One row's id: what a project keys the row's wording by. */
68
+ export type StoredId = AtlasStoredId | StorageVendor | OwnRow;
69
+
70
+ export interface StoredItem {
71
+ readonly id: StoredId;
72
+ /** As the browser shows them; a pattern like `_ga_<id>` where the vendor appends one. */
73
+ readonly names: readonly string[];
74
+ /** Who sets it: this site, or the vendor. */
75
+ readonly provider: "site" | StorageVendor;
76
+ /** Set on the vendor's own domains rather than the site's; absent for the site's. */
77
+ readonly domains?: readonly string[];
78
+ /** A span; `tab` for session storage; `kept` until the visitor clears it. */
79
+ readonly lasts: Lifetime | "tab" | "kept";
80
+ /** What has to be granted first; `none` is kept without asking. */
81
+ readonly consent: ConsentCategory | "none";
82
+ }
83
+
84
+ export interface StoredItemsOptions {
85
+ /** As passed to `Document`, and with its default: `true`. */
86
+ readonly rememberLocale?: boolean;
87
+ /** As passed to `Document`, and with its default: `false`. */
88
+ readonly rememberCampaign?: boolean;
89
+ /**
90
+ * Rows the site sets first, then by what has to be granted — nothing,
91
+ * analytics, marketing — and otherwise in catalog order. Off, the rows
92
+ * come in catalog order: Atlas's own, then each vendor's site row before
93
+ * its vendor-domain one.
94
+ */
95
+ readonly sort?: boolean;
96
+ }
97
+
98
+ interface Stored {
99
+ readonly names: readonly string[];
100
+ /** The longest of the group's. */
101
+ readonly lasts: Lifetime;
102
+ }
103
+
104
+ interface VendorStorage {
105
+ readonly consent: ConsentCategory;
106
+ /** On the site's domain. */
107
+ readonly site: Stored;
108
+ /** On the vendor's, when its tag sets any there. */
109
+ readonly own?: Stored & { readonly domains: readonly string[] };
110
+ }
111
+
112
+ const day = (count: number): Lifetime => ({ count, unit: "day" });
113
+ const month = (count: number): Lifetime => ({ count, unit: "month" });
114
+ const year = (count: number): Lifetime => ({ count, unit: "year" });
115
+
116
+ /**
117
+ * Each vendor's storage, from its own cookie documentation, read 2026-10-06.
118
+ * These go stale without anything failing, so each entry names its source;
119
+ * where the vendor's docs were silent, the source is its tag's own code.
120
+ *
121
+ * What a tag sets only in a mode Atlas does not configure — server-side
122
+ * tagging's `FPID`, Drip's pop-ups — is left out, and so is what a vendor
123
+ * documents only for its own sites or never names.
124
+ */
125
+ const CATALOG = {
126
+ // support.google.com/analytics/answer/11397207. Two years as Google
127
+ // states it; Chrome caps any cookie at 400 days.
128
+ "google-analytics": {
129
+ consent: "analytics",
130
+ site: { names: ["_ga", "_ga_<id>"], lasts: year(2) },
131
+ },
132
+ // business.safety.google/adscookies, support.google.com/tagmanager/answer/7549390.
133
+ // `IDE` is 13 months in the EEA and UK, 24 elsewhere: the EEA's, since a
134
+ // notice that needs this table is written for it. Seen live 2026-10-06.
135
+ "google-ads": {
136
+ consent: "marketing",
137
+ site: {
138
+ names: [
139
+ "_gcl_au",
140
+ "_gcl_aw",
141
+ "_gcl_gs",
142
+ "_gcl_dc",
143
+ "_gcl_gb",
144
+ "_gcl_ag",
145
+ "_gcl_ls",
146
+ ],
147
+ lasts: day(90),
148
+ },
149
+ own: {
150
+ names: ["IDE", "GCL_AW_P", "DSID", "test_cookie", "ar_debug"],
151
+ lasts: month(13),
152
+ domains: ["doubleclick.net", "googleadservices.com", "google.com"],
153
+ },
154
+ },
155
+ // linkedin.com/legal/l/cookie-table. Seen live 2026-10-06.
156
+ linkedin: {
157
+ consent: "marketing",
158
+ site: { names: ["li_fat_id", "li_giant", "ln_or"], lasts: day(30) },
159
+ own: {
160
+ names: [
161
+ "bcookie",
162
+ "bscookie",
163
+ "li_gc",
164
+ "lidc",
165
+ "li_sugr",
166
+ "UserMatchHistory",
167
+ "AnalyticsSyncHistory",
168
+ "lms_ads",
169
+ "lms_analytics",
170
+ "_guid",
171
+ ],
172
+ lasts: year(1),
173
+ domains: ["linkedin.com"],
174
+ },
175
+ },
176
+ // developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc,
177
+ // facebook.com/privacy/policies/cookies. `datr` is facebook.com's own,
178
+ // there only for someone who has visited it; the pixel does not set it.
179
+ meta: {
180
+ consent: "marketing",
181
+ site: { names: ["_fbp", "_fbc"], lasts: day(90) },
182
+ own: { names: ["fr"], lasts: day(90), domains: ["facebook.com"] },
183
+ },
184
+ // ads.tiktok.com/help/article/using-cookies-with-tiktok-pixel.
185
+ tiktok: {
186
+ consent: "marketing",
187
+ site: {
188
+ names: [
189
+ "_ttp",
190
+ "_tt_enable_cookie",
191
+ "ttcsid",
192
+ "ttcsid_<id>",
193
+ "ttclid",
194
+ ],
195
+ lasts: month(13),
196
+ },
197
+ own: { names: ["_ttp"], lasts: month(13), domains: ["tiktok.com"] },
198
+ },
199
+ // snap.com/en-US/privacy/cookie-information gives `_scid` and `_sctr` a
200
+ // year; the pixel's own code sets 13 months, which is what lands.
201
+ snapchat: {
202
+ consent: "marketing",
203
+ site: {
204
+ names: ["_scid", "_sctr", "u_sclid", "u_scsid"],
205
+ lasts: month(13),
206
+ },
207
+ own: { names: ["sc_at"], lasts: year(1), domains: ["snapchat.com"] },
208
+ },
209
+ // support.applovin.com/en/ecommerce/google-tag-manager names `_axwrt`;
210
+ // the rest and the 365 days are from s.axon.ai/pixel.js. AppLovin lists
211
+ // no cookies of its own domains.
212
+ axon: {
213
+ consent: "marketing",
214
+ site: {
215
+ names: ["_axwrt", "_axeid", "_axart", "ax_visitor", "_axsid"],
216
+ lasts: day(365),
217
+ },
218
+ },
219
+ // help.drip.com/hc/en-us/articles/4424702605453 gives the year but not
220
+ // the name, which is cookiedatabase.org's.
221
+ drip: {
222
+ consent: "marketing",
223
+ site: { names: ["_drip_client_<id>"], lasts: year(1) },
224
+ },
225
+ // learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-cookies
226
+ // names them; the lifetimes are from microsoft/clarity's source, and
227
+ // `MUID`'s year from cookiedatabase.org.
228
+ clarity: {
229
+ consent: "analytics",
230
+ site: { names: ["_clck", "_clsk", "_cltk"], lasts: year(1) },
231
+ own: {
232
+ names: ["CLID", "MUID", "ANONCHK", "MR", "SM"],
233
+ lasts: year(1),
234
+ domains: ["clarity.ms"],
235
+ },
236
+ },
237
+ } as const satisfies Record<StorageVendor, VendorStorage>;
238
+
239
+ type VendorWithOwn = {
240
+ [V in keyof typeof CATALOG]: (typeof CATALOG)[V] extends { own: object }
241
+ ? V
242
+ : never;
243
+ }[keyof typeof CATALOG];
244
+
245
+ /** Catalog order, which is also the order rows come in unsorted. */
246
+ const VENDOR_ORDER: readonly StorageVendor[] = [
247
+ "google-analytics",
248
+ "google-ads",
249
+ "linkedin",
250
+ "meta",
251
+ "tiktok",
252
+ "snapchat",
253
+ "axon",
254
+ "drip",
255
+ "clarity",
256
+ ];
257
+
258
+ /** The vendors a deployment's settings load, in catalog order. */
259
+ function loadedVendors(
260
+ analytics: AnalyticsSettings | undefined
261
+ ): readonly StorageVendor[] {
262
+ const loaded = new Set<StorageVendor>();
263
+ const google = analytics?.google;
264
+ for (const id of google?.tagIds ?? []) {
265
+ if (id.startsWith("G-")) loaded.add("google-analytics");
266
+ if (id.startsWith("AW-")) loaded.add("google-ads");
267
+ }
268
+ for (const vendor of google?.alsoLoads ?? []) loaded.add(vendor);
269
+ if (analytics?.meta !== undefined) loaded.add("meta");
270
+ if (analytics?.tiktok !== undefined) loaded.add("tiktok");
271
+ if (analytics?.snapchat !== undefined) loaded.add("snapchat");
272
+ if (analytics?.axon !== undefined) loaded.add("axon");
273
+ if (analytics?.drip !== undefined) loaded.add("drip");
274
+ if (analytics?.clarity !== undefined) loaded.add("clarity");
275
+ return VENDOR_ORDER.filter((vendor) => loaded.has(vendor));
276
+ }
277
+
278
+ const CONSENT_ORDER: readonly StoredItem["consent"][] = [
279
+ "none",
280
+ "analytics",
281
+ "marketing",
282
+ ];
283
+
284
+ /** The rows the `sort` option puts first come first; ties keep their order. */
285
+ export function compareStoredItems(a: StoredItem, b: StoredItem): number {
286
+ return (
287
+ Number(b.provider === "site") - Number(a.provider === "site") ||
288
+ CONSENT_ORDER.indexOf(a.consent) - CONSENT_ORDER.indexOf(b.consent)
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Everything this deployment keeps on a device: Atlas's own keys, then each
294
+ * loaded vendor's.
295
+ *
296
+ * Atlas's rows follow what the page renders, which `Document` decides from
297
+ * its props, so pass the same `rememberLocale` and `rememberCampaign` here.
298
+ * The consent record is listed whenever there is a banner to answer.
299
+ */
300
+ export function storedItems(
301
+ analytics: AnalyticsSettings | undefined,
302
+ options: StoredItemsOptions = {}
303
+ ): readonly StoredItem[] {
304
+ const {
305
+ rememberLocale = true,
306
+ rememberCampaign = false,
307
+ sort = false,
308
+ } = options;
309
+ const asked = consentVendors(analytics).length > 0;
310
+
311
+ const rows: StoredItem[] = [];
312
+ if (rememberLocale) {
313
+ rows.push({
314
+ id: "atlas-locale",
315
+ names: [LOCALE_KEY],
316
+ provider: "site",
317
+ lasts: "kept",
318
+ consent: "none",
319
+ });
320
+ }
321
+ // Kept only once analytics is granted, so only where it can be.
322
+ if (rememberCampaign && asked) {
323
+ rows.push({
324
+ id: "atlas-campaign",
325
+ names: [CAMPAIGN_KEY],
326
+ provider: "site",
327
+ lasts: "tab",
328
+ consent: "analytics",
329
+ });
330
+ }
331
+ if (asked) {
332
+ rows.push({
333
+ id: "atlas-consent",
334
+ names: [CONSENT_KEY],
335
+ provider: "site",
336
+ lasts: month(CONSENT_MONTHS),
337
+ consent: "none",
338
+ });
339
+ }
340
+
341
+ for (const vendor of loadedVendors(analytics)) {
342
+ const entry: VendorStorage = CATALOG[vendor];
343
+ rows.push({
344
+ id: vendor,
345
+ names: entry.site.names,
346
+ provider: vendor,
347
+ lasts: entry.site.lasts,
348
+ consent: entry.consent,
349
+ });
350
+ if (entry.own !== undefined) {
351
+ rows.push({
352
+ id: `${vendor}-own` as OwnRow,
353
+ names: entry.own.names,
354
+ provider: vendor,
355
+ domains: entry.own.domains,
356
+ lasts: entry.own.lasts,
357
+ consent: entry.consent,
358
+ });
359
+ }
360
+ }
361
+
362
+ return sort ? rows.toSorted(compareStoredItems) : rows;
363
+ }
@@ -11,10 +11,11 @@
11
11
  * Rendered by `Document` after the head tags, whose alternates it reads.
12
12
  * Inline and blocking, so the wrong language is never painted.
13
13
  */
14
+ import { LOCALE_KEY } from "../analytics/storage-keys.ts";
14
15
  import { inlineScript } from "./inline-script.ts";
15
16
  import source from "./remember-locale.ts?raw";
16
17
 
17
- const code = await inlineScript(source);
18
+ const code = await inlineScript(source, { LOCALE_KEY });
18
19
  ---
19
20
 
20
21
  <script
@@ -19,6 +19,8 @@
19
19
  * in its own `_gcl_aw` cookie once granted. `adClick()` reads it from there.
20
20
  */
21
21
 
22
+ import { CAMPAIGN_KEY } from "../analytics/storage-keys.ts";
23
+
22
24
  /** The campaign tags a link can carry. */
23
25
  export type CampaignTag =
24
26
  | "utm_source"
@@ -43,9 +45,6 @@ const AD_CLICK_IDS: readonly AdClickId[] = ["gclid", "gbraid", "wbraid"];
43
45
  export type Campaign = Partial<Record<CampaignTag, string>>;
44
46
  export type AdClick = Partial<Record<AdClickId, string>>;
45
47
 
46
- /** The `sessionStorage` key the tags are kept under. */
47
- const KEY = "atlas-campaign";
48
-
49
48
  /** The given parameters present on this page's URL. */
50
49
  function fromUrl<K extends string>(
51
50
  names: readonly K[]
@@ -64,7 +63,7 @@ export function rememberCampaign(): void {
64
63
  const landed = fromUrl(CAMPAIGN_TAGS);
65
64
  if (Object.keys(landed).length === 0) return;
66
65
  try {
67
- sessionStorage.setItem(KEY, JSON.stringify(landed));
66
+ sessionStorage.setItem(CAMPAIGN_KEY, JSON.stringify(landed));
68
67
  } catch {
69
68
  // Storage refused: the tags still count on the page they arrived on.
70
69
  }
@@ -75,7 +74,9 @@ export function campaign(): Campaign {
75
74
  const here = fromUrl(CAMPAIGN_TAGS);
76
75
  if (Object.keys(here).length > 0) return here;
77
76
  try {
78
- const kept: unknown = JSON.parse(sessionStorage.getItem(KEY) ?? "{}");
77
+ const kept: unknown = JSON.parse(
78
+ sessionStorage.getItem(CAMPAIGN_KEY) ?? "{}"
79
+ );
79
80
  if (typeof kept !== "object" || kept === null) return {};
80
81
  const found: Campaign = {};
81
82
  for (const tag of CAMPAIGN_TAGS) {
@@ -1,14 +1,15 @@
1
1
  /**
2
- * The two DOM writes every filtered list turned out to share.
2
+ * The DOM writes every filtered list turned out to share.
3
3
  *
4
4
  * `filters` decides which items match; what a page does about that is its own.
5
- * These are the parts three lists wrote identically, and getting wrong was
6
- * silent. Both take elements, never selectors, so no attribute name in this
5
+ * These are the parts the lists wrote identically, and getting wrong was
6
+ * silent. All take elements, never selectors, so no attribute name in this
7
7
  * package has to be matched by a consumer.
8
8
  *
9
- * They write only what is binary and derived — an input's `value`, and `hidden`
10
- * on a clear button, an empty message or a section with nothing left. No class,
11
- * style or `aria`: that is where the choices live.
9
+ * They write only what is binary and derived — an input's `value`, `hidden` on
10
+ * a clear button, an empty message or a section with nothing left, and a
11
+ * view-transition name while a filter animates. No class or `aria`: that is
12
+ * where the choices live.
12
13
  *
13
14
  * **`hidden` needs help in a grid.** Any author rule setting `display` beats it.
14
15
  * Tailwind v4's preflight ships `[hidden] { display: none !important }`; without
@@ -124,3 +125,73 @@ export function hideEmpty<T extends HTMLElement>(
124
125
  group.hidden = !visible;
125
126
  }
126
127
  }
128
+
129
+ /**
130
+ * On `<html>` while a `filtering` transition runs: what a stylesheet that
131
+ * treats unpaired view-transition names one way across pages tells the two
132
+ * cases apart by.
133
+ */
134
+ export const FILTERING_ATTRIBUTE = "data-atlas-filtering";
135
+
136
+ /** Each named item, and the inline name it had before, to put back after. */
137
+ const named = new Map<HTMLElement, string>();
138
+ let running = 0;
139
+
140
+ /**
141
+ * Runs `update` — the `set` or `reset` a chip or tab does — as a view
142
+ * transition, so what stays slides into place and what leaves or arrives
143
+ * fades. Without the API, `update` just runs.
144
+ *
145
+ * ```ts
146
+ * chip.addEventListener("click", () =>
147
+ * filtering(items, () => list.set("category", data(chip, "category")))
148
+ * );
149
+ * ```
150
+ *
151
+ * **`items` are named only while it runs**, `match-element` each, and put
152
+ * back after. Named for good, every one of them would be a pair across pages
153
+ * too, and linger over the next page on navigation.
154
+ *
155
+ * **Counted, not just started**: a second click skips the first transition,
156
+ * and that one's end would otherwise strip the names from the one still
157
+ * running.
158
+ *
159
+ * **One rule is the project's**, because only the project knows it wrote the
160
+ * other half. A site that stops unpaired names animating across pages — so a
161
+ * card on one page does not fade over the next — stops filtering's fades with
162
+ * it, since an item hidden or shown is unpaired too. Scope it out:
163
+ *
164
+ * ```css
165
+ * :root:not([data-atlas-filtering])::view-transition-new(*):only-child {
166
+ * animation: none;
167
+ * }
168
+ * ```
169
+ */
170
+ export function filtering(
171
+ items: Iterable<HTMLElement>,
172
+ update: () => void
173
+ ): void {
174
+ const doc = globalThis.document;
175
+ if (typeof doc?.startViewTransition !== "function") {
176
+ update();
177
+ return;
178
+ }
179
+
180
+ for (const item of items) {
181
+ if (!named.has(item)) named.set(item, item.style.viewTransitionName);
182
+ item.style.viewTransitionName = "match-element";
183
+ }
184
+
185
+ const root = doc.documentElement;
186
+ running += 1;
187
+ root.setAttribute(FILTERING_ATTRIBUTE, "");
188
+ doc.startViewTransition(update).finished.finally(() => {
189
+ running -= 1;
190
+ if (running > 0) return;
191
+ root.removeAttribute(FILTERING_ATTRIBUTE);
192
+ for (const [item, before] of named) {
193
+ item.style.viewTransitionName = before;
194
+ }
195
+ named.clear();
196
+ });
197
+ }
@@ -7,9 +7,20 @@ import { minifySync, transformWithOxc } from "vite";
7
7
  * Astro writes an inline script exactly as written, comments and all. Both
8
8
  * steps are needed — the minifier accepts TypeScript and silently keeps its
9
9
  * annotations, which a browser then fails to parse.
10
+ *
11
+ * `constants` are declared around the source, which `declare const`s them: a
12
+ * classic script imports nothing, so this is how a value spelled once in the
13
+ * library reaches it. The outer block keeps them out of the page's globals.
10
14
  */
11
- export async function inlineScript(source: string): Promise<string> {
12
- const { code } = await transformWithOxc(source, "inline.ts");
15
+ export async function inlineScript(
16
+ source: string,
17
+ constants: Readonly<Record<string, unknown>> = {}
18
+ ): Promise<string> {
19
+ const declared = Object.entries(constants)
20
+ .map(([name, value]) => `const ${name}=${JSON.stringify(value)};`)
21
+ .join("");
22
+ const wrapped = declared === "" ? source : `{${declared}{\n${source}\n}}`;
23
+ const { code } = await transformWithOxc(wrapped, "inline.ts");
13
24
  // A classic script, not a module: its top-level names are globals other
14
25
  // scripts call — `gtag` above all — and a module's may be renamed.
15
26
  const minified = minifySync("inline.js", code, { module: false });
@@ -1,18 +1,21 @@
1
1
  // The body of `RememberLocale.astro`'s inline script. A classic script: no
2
2
  // imports or exports, and the block keeps its names out of the global scope.
3
+ // `LOCALE_KEY` is declared around it by `inlineScript`, from `storage-keys.ts`.
4
+
5
+ declare const LOCALE_KEY: typeof import("../analytics/storage-keys.ts").LOCALE_KEY;
6
+
3
7
  {
4
- const KEY = "atlas-locale";
5
8
  const current = document.documentElement.lang;
6
9
 
7
10
  const remember = () => {
8
11
  // Came from this site: the visitor is browsing, or just switched
9
12
  // language. Either way, this locale is their choice.
10
13
  if (document.referrer.startsWith(`${location.origin}/`)) {
11
- localStorage.setItem(KEY, current);
14
+ localStorage.setItem(LOCALE_KEY, current);
12
15
  return;
13
16
  }
14
17
 
15
- const chosen = localStorage.getItem(KEY);
18
+ const chosen = localStorage.getItem(LOCALE_KEY);
16
19
  if (chosen === null || chosen === current) return;
17
20
 
18
21
  // No alternate means this page is not built in that locale.
package/src/index.ts CHANGED
@@ -43,6 +43,17 @@ export {
43
43
  type UmamiTracker,
44
44
  type Uuid,
45
45
  } from "./analytics/index.ts";
46
+ export {
47
+ type AtlasStoredId,
48
+ compareStoredItems,
49
+ type Lifetime,
50
+ STORAGE_VENDOR_NAMES,
51
+ type StorageVendor,
52
+ type StoredId,
53
+ type StoredItem,
54
+ type StoredItemsOptions,
55
+ storedItems,
56
+ } from "./analytics/storage.ts";
46
57
  export type {
47
58
  LanguageTag,
48
59
  LocaleMeta,