@peektravel/app-utilities 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,13 +10,13 @@
10
10
  */
11
11
 
12
12
  /* ---- base ---------------------------------------------------------------- */
13
- ody-icon, ody-button, ody-tag, ody-alert, ody-card, ody-divider,
13
+ ody-icon, ody-button, ody-tag, ody-alert, ody-card, ody-divider, ody-horizontal-divider,
14
14
  ody-status-dot, ody-message, ody-loading-spinner, ody-loading-bar {
15
15
  font-family: var(--ody-font-family);
16
16
  box-sizing: border-box;
17
17
  }
18
18
  ody-button, ody-tag, ody-status-dot, ody-message, ody-loading-spinner { display: inline-block; }
19
- ody-alert, ody-card, ody-divider, ody-loading-bar { display: block; }
19
+ ody-alert, ody-card, ody-divider, ody-horizontal-divider, ody-loading-bar { display: block; }
20
20
 
21
21
  @keyframes ody-spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
22
22
  .ody-spin { animation: ody-spin 1s linear infinite; }
@@ -183,6 +183,10 @@ ody-alert, ody-card, ody-divider, ody-loading-bar { display: block; }
183
183
 
184
184
  /* ---- divider ------------------------------------------------------------- */
185
185
  .ody-divider { display: block; width: 100%; height: 1px; background-color: var(--color-neutral-200); }
186
+ /* horizontal-divider — padded successor to divider; carries its own rhythm. */
187
+ .ody-horizontal-divider { display: block; width: 100%; height: 1px; background-color: var(--color-neutral-200); margin-block: var(--gap16); }
188
+ .ody-horizontal-divider--tight { margin-block: var(--gap8); }
189
+ .ody-horizontal-divider--none { margin-block: 0; }
186
190
 
187
191
  /* ---- status-dot ---------------------------------------------------------- */
188
192
  .status-dot-container { color: var(--color-neutral-350); font-size: 14px; display: inline-flex; align-items: center; }
@@ -385,6 +389,24 @@ ody-percentage-input, ody-checkbox, ody-radio-button-group, ody-checkbox-group {
385
389
  ody-page-container { display: block; width: 100%; }
386
390
  .ody-page-container { width: 100%; max-width: 1310px; margin: 0 auto; container-type: inline-size; container-name: ody-page; }
387
391
 
392
+ /* ===== app-page-container (padded successor to page-container) ===== */
393
+ /* The container context is on the host so the padded inner div is a descendant
394
+ of the `ody-page` container and its gutter can respond to the container's own
395
+ width (an element cannot query the container it establishes). */
396
+ ody-app-page-container {
397
+ display: block;
398
+ width: 100%;
399
+ max-width: var(--layout-page-width-wide);
400
+ margin: 0 auto;
401
+ container-type: inline-size;
402
+ container-name: ody-page;
403
+ }
404
+ .ody-app-page-container { box-sizing: border-box; width: 100%; padding: var(--gap24); }
405
+ @container ody-page (max-width: 868px) {
406
+ .ody-app-page-container { padding: var(--gap16); }
407
+ }
408
+ .ody-app-page-container--flush { padding: 0; }
409
+
388
410
  /* ===== collapsible-section ===== */
389
411
  .ody-collapsible-section .ody-collapsible-container .ody-collapsible { margin: 0; }
390
412
  .ody-collapsible-section .ody-collapsible__header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
@@ -98,7 +98,16 @@
98
98
  --ody-z-wormhole: 2002;
99
99
  --ody-z-tooltip: 2004;
100
100
 
101
- /* layout */
101
+ /* spacing scale — the canonical gaps used across the design system. Prefer
102
+ these over raw px so spacing stays consistent across apps. */
103
+ --gap8: 8px;
104
+ --gap16: 16px;
105
+ --gap24: 24px;
106
+ --gap32: 32px;
107
+
108
+ /* layout — the two canonical settings-iframe widths the host renders at. */
109
+ --layout-page-width-narrow: 868px;
110
+ --layout-page-width-wide: 1310px;
102
111
  --layout-breadcrumb-height: 26px;
103
112
  --layout-top-bar-height: 56px;
104
113
  }
package/docs/ui.md CHANGED
@@ -50,6 +50,29 @@ import {
50
50
  } from '@peektravel/app-utilities/ui';
51
51
  ```
52
52
 
53
+ ### 2.1 Spacing & layout tokens
54
+
55
+ `tokens.css` publishes the canonical spacing scale and the two page widths.
56
+ Reach for these instead of magic numbers so spacing stays consistent across
57
+ apps even for one-off gaps:
58
+
59
+ | Token | Value | Use for |
60
+ | --- | --- | --- |
61
+ | `--gap8` | `8px` | tight gaps |
62
+ | `--gap16` | `16px` | the default gap between elements |
63
+ | `--gap24` | `24px` | section-level spacing / page gutters |
64
+ | `--gap32` | `32px` | large section spacing |
65
+ | `--layout-page-width-narrow` | `868px` | the narrow settings-iframe width (design for this first) |
66
+ | `--layout-page-width-wide` | `1310px` | the wide settings-iframe width |
67
+
68
+ ```css
69
+ .my-card { padding: var(--gap24); gap: var(--gap16); }
70
+ ```
71
+
72
+ The layout primitives already bake these in: `ody-app-page-container` uses the
73
+ gap tokens for its responsive gutter, and `ody-section-*` / `ody-horizontal-divider`
74
+ expose them through `gap-size` / `spacing` attributes.
75
+
53
76
  ---
54
77
 
55
78
  ## 3. Core conventions (read this — it is how all components work)
@@ -208,7 +231,7 @@ flagged — assign only via JSX props / refs as above.
208
231
  | Empty / error / no-results view | `ody-empty-state` |
209
232
  | KPI figures | `ody-stat-summary` + `ody-stat` |
210
233
  | Expand/collapse | `ody-accordion`, `ody-collapsible`, `ody-collapsible-section` |
211
- | Full-page wrapper for an **app settings UI** | `ody-page-container` (required — see below) |
234
+ | Full-page wrapper for an **app settings UI** | `ody-app-page-container` (required — see below; `ody-page-container` is the deprecated full-bleed predecessor) |
212
235
  | Master/detail page layout | `ody-two-column` |
213
236
  | Row/column section layout | `ody-section-rows` / `ody-section-columns` |
214
237
  | Progress | `ody-loading-bar`, `ody-loading-spinner` |
@@ -299,8 +322,21 @@ carries meaning (e.g. status).
299
322
  <ody-card bar-color="var(--color-interaction-300)"><p>Card body</p></ody-card>
300
323
  ```
301
324
 
302
- #### `<ody-divider>`
325
+ #### `<ody-horizontal-divider>`
326
+ **Use when** you need a 1px horizontal rule that separates content. Unlike the
327
+ deprecated `<ody-divider>`, it carries its own vertical rhythm
328
+ (`margin-block: var(--gap16)`) so you don't have to add spacing around it.
329
+ **Attributes**
330
+ - `spacing` — `tight` (`--gap8`) | `none` (`0`); default is the normal `--gap16`.
331
+ ```html
332
+ <ody-horizontal-divider></ody-horizontal-divider>
333
+ <ody-horizontal-divider spacing="tight"></ody-horizontal-divider>
334
+ ```
335
+
336
+ #### `<ody-divider>` _(deprecated — use `<ody-horizontal-divider>`)_
303
337
  **Use when** you need a 1px horizontal rule. No attributes, no content.
338
+ **Deprecated:** renders with **zero** margin, so every caller has to add its own
339
+ spacing. Prefer `<ody-horizontal-divider>`. Still works and unchanged.
304
340
  ```html
305
341
  <ody-divider></ody-divider>
306
342
  ```
@@ -497,11 +533,11 @@ Extra content (e.g. a tag) is the child content.
497
533
  </ody-two-column>
498
534
  ```
499
535
 
500
- #### `<ody-page-container>`
536
+ #### `<ody-app-page-container>`
501
537
  > [!IMPORTANT]
502
- > **Every app settings UI must wrap its content in `<ody-page-container>`.** It is
503
- > the standard responsive page wrapper that keeps the settings page sized to the
504
- > two widths the settings host iframe renders at — **868px** (narrow) and
538
+ > **Every app settings UI must wrap its content in `<ody-app-page-container>`.**
539
+ > It is the standard responsive page wrapper that keeps the settings page sized
540
+ > to the two widths the settings host iframe renders at — **868px** (narrow) and
505
541
  > **1310px** (wide).
506
542
  >
507
543
  > **Design for 868px first — it is the default.** Optimise every settings layout
@@ -514,20 +550,24 @@ Extra content (e.g. a tag) is the child content.
514
550
  full-page UI embedded in the settings host).
515
551
  The container is `width: 100%`, so it fills whichever of the two widths the
516
552
  parent iframe gives it; a `max-width` of `1310px` caps and centres it if it is
517
- ever embedded somewhere wider. Content is **full-bleed** (edge-to-edge, no side
518
- gutters). It also establishes a CSS **container context** named `ody-page`, so
519
- the content inside can adapt between the two widths with container queries
520
- instead of viewport media queries — treat 868px as the base styles and use a
521
- container query to *enhance* for the wider view.
522
- **Attributes** — none; the width is driven entirely by the parent.
553
+ ever embedded somewhere wider. Unlike the deprecated `<ody-page-container>`, it
554
+ ships a **default responsive gutter** — `var(--gap24)` at the wide width,
555
+ tightening to `var(--gap16)` at/below 868px — so you no longer wrap the page in
556
+ a padded `<div>`. It also establishes a CSS **container context** named
557
+ `ody-page`, so the content inside can adapt between the two widths with container
558
+ queries instead of viewport media queries — treat 868px as the base styles and
559
+ use a container query to *enhance* for the wider view.
560
+ **Attributes**
561
+ - `flush` — drop the default gutter and render edge-to-edge, for the rare layout
562
+ that truly wants full-bleed content.
523
563
  **Slots / content** — child nodes are the page content.
524
564
  **Example**
525
565
  ```html
526
- <ody-page-container>
566
+ <ody-app-page-container>
527
567
  <ody-section-rows gap-size="24">
528
568
  …settings UI…
529
569
  </ody-section-rows>
530
- </ody-page-container>
570
+ </ody-app-page-container>
531
571
  ```
532
572
  ```css
533
573
  /* Base styles target the default 868px width… */
@@ -538,6 +578,15 @@ container query to *enhance* for the wider view.
538
578
  }
539
579
  ```
540
580
 
581
+ #### `<ody-page-container>` _(deprecated — use `<ody-app-page-container>`)_
582
+ **Deprecated:** the original page wrapper is **full-bleed** (edge-to-edge, no
583
+ side gutters), so every settings UI had to add its own edge padding. Prefer
584
+ `<ody-app-page-container>`, which bakes in a responsive gutter (with a `flush`
585
+ opt-out) while keeping the same widths and `ody-page` container context. This
586
+ element still works and is unchanged; migrate by renaming the tag (add `flush`
587
+ if you genuinely relied on the full-bleed behaviour).
588
+ **Attributes** — none; the width is driven entirely by the parent.
589
+
541
590
  #### `<ody-collapsible-section>` + `<ody-collapsible-collapsed>` + `<ody-collapsible-content>`
542
591
  **Use when** a titled section that expands/collapses on header click.
543
592
  - `ody-collapsible-section` **Attributes**: `header-text`, `secondary-header-text`, `icon` (icon name), `expanded` (boolean; reflected). **Events**: `expanded` → `{ expanded }`.
package/docs/webhooks.md CHANGED
@@ -22,13 +22,12 @@ install webhook is different: it carries a signed JWT, so `parseInstallWebhook`
22
22
  checks the signature for you before returning the merged event.
23
23
 
24
24
  **The install webhook delivers two payloads at once.** A signed
25
- `app_registry_v2` JWT authenticates the notification and carries the account
26
- **id**, install id, status, version, and the acting `user`; a plain JSON body
27
- alongside it adds the account's **name**, **platform**, and **test** flag.
28
- `parseInstallWebhook` verifies the token and merges both into one flat
29
- `InstallWebhook`, so you make a single call. The verified token is authoritative
30
- for the fields it carries; the unsigned body is trusted only for name/platform/
31
- test.
25
+ `app_registry_v2` JWT authenticates the notification; a plain JSON body alongside
26
+ it carries the **event data** — the full account block (name, platform,
27
+ timezone, test flag), the per-install `api.url`, the acting user, and the install
28
+ id / status / version. `parseInstallWebhook` verifies the token and reads the
29
+ event from the body — falling back to the token for the fields it also carries —
30
+ merging both into one flat `InstallWebhook`, so you make a single call.
32
31
 
33
32
  # Booking webhooks
34
33
 
@@ -173,10 +172,11 @@ a **signed `app_registry_v2` JWT** and a plain **JSON body**.
173
172
 
174
173
  - The **JWT** is the security boundary. Verifying its signature proves the
175
174
  notification really came from Peek before your app acts on it (for example,
176
- de-provisioning an uninstall). It carries the install id, account **id**,
177
- status, version, and the acting `user`.
178
- - The **JSON body** is the enrichment channel. It adds the account's **name**,
179
- **platform**, and **test** flag — the fields the token does not carry.
175
+ de-provisioning an uninstall) — it authenticates the whole delivery, body
176
+ included.
177
+ - The **JSON body** is the event payload. It carries the full account block
178
+ (name, platform, timezone, test flag), the per-install `api.url`, the acting
179
+ user, and the install id / status / version.
180
180
 
181
181
  This webhook is also where an app first learns **who the install belongs to** —
182
182
  and the only place it ever learns it: no GraphQL read returns the account id, so
@@ -184,9 +184,9 @@ whatever you persist from this delivery is all you will ever have.
184
184
 
185
185
  `parseInstallWebhook(token, body, secret)` verifies the token (issuer, audience,
186
186
  expiry, HMAC — the security-critical boilerplate you'd otherwise hand-roll) and
187
- merges both payloads into one flat, JSON-safe `InstallWebhook` you store as a
188
- unit. The **verified token** is authoritative for the fields it carries; the
189
- unsigned body is trusted only for name/platform/test.
187
+ reads the event from the body, **falling back to the token** for the fields it
188
+ also carries (`installId`, `accountId`, `status`, `displayVersion`, `user`),
189
+ returning one flat, JSON-safe `InstallWebhook` you store as a unit.
190
190
 
191
191
  ## Step 1 — subscribe to the event (external, one-time)
192
192
 
@@ -249,21 +249,25 @@ The resulting `InstallWebhook` is flat:
249
249
 
250
250
  | Field | Type | Source | From | Notes |
251
251
  | --- | --- | --- | --- | --- |
252
- | `installId` | `string` | token | `sub` | Peek-assigned install UUID |
253
- | `accountId` | `string` | token | `account.id` | **also called the partner ID** |
252
+ | `installId` | `string` | body → token | `install_id` / `sub` | Peek-assigned install UUID |
253
+ | `accountId` | `string` | body → token | `account.id` | **also called the partner ID** |
254
254
  | `accountName` | `string` | body | `account.name` | partner display name; `""` when absent |
255
255
  | `platform` | `PeekPlatform \| null` | body | `account.platform` | **persist per install** — see below; `null` when unrecognised |
256
256
  | `isTest` | `boolean` | body | `account.is_test` | defaults to `false` |
257
- | `status` | `InstallStatus \| null` | token | `status` | `null` when unrecognised — see below |
258
- | `rawStatus` | `string` | token | `status` | always the wire value, for logging |
259
- | `displayVersion` | `string` | token | `display_version` | `""` when absent |
260
- | `user` | `PeekAuthTokenUser \| null` | token | `user` | `null` for system-initiated events |
261
-
262
- Because the four fields present in **both** payloads (`installId`, `accountId`,
263
- `status`, `displayVersion`) are read from the **verified token**, a mismatched or
264
- forged body can never override an authenticated identity. Unlike
265
- `verifyPeekAuthToken`, `user` is nullable: install lifecycle events are often
266
- system-initiated (no acting user), so the parser tolerates `user: null`.
257
+ | `timezone` | `string` | body | `account.timezone` | IANA zone (e.g. `America/New_York`); **persist per install**; `""` when absent |
258
+ | `apiUrl` | `string` | body | `api.url` | per-install backoffice API base URL; **persist per install**; `""` when absent |
259
+ | `status` | `InstallStatus \| null` | body → token | `status` | `null` when unrecognised — see below |
260
+ | `rawStatus` | `string` | body → token | `status` | always the wire value, for logging |
261
+ | `displayVersion` | `string` | body → token | `display_version` | `""` when absent |
262
+ | `user` | `PeekAuthTokenUser \| null` | body → token | `modified_by` / `user` | `null` for system-initiated events |
263
+
264
+ The **JSON body is the source of the event data**; the fields it shares with the
265
+ token (`installId`, `accountId`, `status`, `displayVersion`, `user`) fall back to
266
+ the **verified token** when the body omits them. The token authenticates the
267
+ whole delivery — only Peek can mint a valid one — so the body is trusted within a
268
+ verified request. Unlike `verifyPeekAuthToken`, `user` is nullable: install
269
+ lifecycle events are often system-initiated (no acting user), so the parser
270
+ tolerates a missing `modified_by`/`user`.
267
271
 
268
272
  > **Migrating from `verifyInstallWebhook` / `parseInstallEvent`?**
269
273
  > `verifyInstallWebhook(token, secret)` still works but is `@deprecated` — it
@@ -288,6 +292,85 @@ Two installs of the same app can sit on different platforms, so this cannot be
288
292
  inferred from app-level config — it is per install, it has no source other than
289
293
  this event, and it must be stored alongside `installId` and `accountId`.
290
294
 
295
+ ## Also persist `timezone` and `apiUrl` per install
296
+
297
+ Like `platform`, these are per-install facts with **no source other than this
298
+ event**, so persist them alongside `installId`/`accountId`:
299
+
300
+ - **`timezone`** — the account's IANA zone (e.g. `America/New_York`). Downstream
301
+ date/time handling for the install (scheduling, day boundaries, display) needs
302
+ the account's own zone, not the server's.
303
+ - **`apiUrl`** — the **app endpoint URL** the registry serves this install from.
304
+ Use it **as given** for this install's API calls: hit it unmodified, do not
305
+ decompose it or append your own app id. If the registry tags traffic with an
306
+ app id in the path, that is the registry's concern and opaque to you. Store it
307
+ and pass it as the access service's **`apiUrl`** (for Peek it is the sole
308
+ request URL; for CNG/ACME it is the base and the REST path is appended) — or
309
+ hand the whole install to `createAccessServiceForInstall` (below), which wires
310
+ it for you.
311
+
312
+ Both default to `""` when a delivery omits them, so treat empty as "not
313
+ reported" and keep any value you previously stored.
314
+
315
+ ## Every install event is a full snapshot — upsert by `installId`
316
+
317
+ An install webhook is not just a one-time "who is this install" signal. **Every
318
+ event carries the complete, current record**, and an `update_installed` event
319
+ delivers the *same fields* as the original `installed` event. So the registry
320
+ uses the webhook to push changes: a new `apiUrl`, a renamed account, a new
321
+ `displayVersion`, a platform move.
322
+
323
+ Treat each delivery as an **upsert keyed by `installId`** and overwrite your
324
+ stored fields with the incoming ones:
325
+
326
+ ```ts
327
+ const event = parseInstallWebhook(token, req.body, secret);
328
+ await installs.upsert(event.installId, {
329
+ accountId: event.accountId,
330
+ accountName: event.accountName,
331
+ platform: event.platform,
332
+ isTest: event.isTest,
333
+ timezone: event.timezone,
334
+ apiUrl: event.apiUrl, // may change between events — always take the latest
335
+ displayVersion: event.displayVersion,
336
+ });
337
+ ```
338
+
339
+ A consumer that reads only the first `installed` event and ignores later
340
+ `update_installed` deliveries will keep stale data — most importantly a stale
341
+ `apiUrl`, which would send its API calls to the wrong endpoint.
342
+
343
+ ## Build the install's access service from `platform` + `apiUrl`
344
+
345
+ A persisted install carries the two facts needed to reach it: `platform` picks
346
+ the access service class, and `apiUrl` is its endpoint. `createAccessServiceForInstall`
347
+ encodes that mapping, so you go from a stored install to the right client in one
348
+ call — no `switch` on platform, no URL wiring:
349
+
350
+ ```ts
351
+ import { createAccessServiceForInstall } from "@peektravel/app-utilities";
352
+
353
+ const install = await installs.get(installId); // { platform, apiUrl, installId, … }
354
+
355
+ const service = createAccessServiceForInstall(install, {
356
+ jwtSecret: process.env.PEEK_INTERNAL_SECRET!,
357
+ issuer: process.env.APP_NAME!,
358
+ // gatewayKey?, logger?, fetch?, accessOptions?, … (per-app, not per-install)
359
+ });
360
+ // → PeekAccessService | CngAccessService | AcmeAccessService, wired to install.apiUrl.
361
+ // Narrow by install.platform (or `instanceof`) to reach a platform-specific surface.
362
+ ```
363
+
364
+ It throws if `install.platform` is `null` or unrecognised — an install with an
365
+ unknown platform has no service to route to, and silently defaulting would point
366
+ it at the wrong gateway.
367
+
368
+ Prefer this (or the config's `apiUrl` field) over `baseUrl`/`appId`, which are
369
+ deprecated: they reconstruct the URL with a hardcoded gateway default that cannot
370
+ be correct for every install. **The hardcoded base-URL fallbacks will be removed
371
+ and a URL will become required in a future release** — source it from the install
372
+ webhook's `apiUrl` now.
373
+
291
374
  ## Why `status` and `platform` can be `null`
292
375
 
293
376
  `parseInstallWebhook` never throws on the *body* (only token verification can
package/llms.txt CHANGED
@@ -22,14 +22,24 @@ const peek = new PeekAccessService({
22
22
  installId, // JWT subject
23
23
  jwtSecret, // HMAC secret signing the JWT
24
24
  issuer, // app name (JWT issuer)
25
- appId, // gateway path segment
26
- gatewayKey, // pk-api-key header
27
- // optional: baseUrl, tokenTtlSeconds, tokenRefreshLeewaySeconds,
25
+ apiUrl, // the install webhook's apiUrl — the app endpoint, used as given
26
+ // optional: gatewayKey, tokenTtlSeconds, tokenRefreshLeewaySeconds,
28
27
  // retryDelaysMs, logger, fetch, itemOptionsPageSize,
29
28
  // accessOptions: { fullCustomerAccess: false } // default — see "Access options / PII"
30
29
  });
31
30
  ```
32
31
 
32
+ Targeting an install by URL: pass the install webhook's `apiUrl` as the config
33
+ `apiUrl` — for Peek it is the **sole** request URL (every call POSTs to it,
34
+ unmodified); for CNG/ACME it is the base and the REST path is appended. No
35
+ app-id/gateway segment is inserted (`appId` is unused). Any app id in the path is
36
+ the registry's own traffic tag, opaque to callers. `createAccessServiceForInstall(install, config)`
37
+ builds the right service for `install.platform` (`peek`/`cng`/`acme`) wired to
38
+ `install.apiUrl` in one call (throws on unknown/`null` platform). `appId` +
39
+ `baseUrl` (and the hardcoded gateway default) are **`@deprecated`** — they
40
+ reconstruct the URL with a compiled-in default that can't be right for every
41
+ install; the fallbacks will be removed and a URL will become **required**.
42
+
33
43
  ## Access options / PII — `fullCustomerAccess`
34
44
 
35
45
  **The most commonly-misunderstood option in this package. Read this before
@@ -124,8 +134,10 @@ final authority and may still reject the request.
124
134
  Two brand-parallel accessors sit alongside `PeekAccessService` and share the same
125
135
  auth/transport/UI plumbing. Both are **REST** and deliberately minimal today
126
136
  (products only). Construct one per install, same config shape **minus `gatewayKey`**
127
- (`installId`, `jwtSecret`, `issuer`, `appId`); both default the base URL to the
128
- app-registry installations API.
137
+ (`installId`, `jwtSecret`, `issuer`, `apiUrl`). With `apiUrl` set, it is the REST
138
+ base and only the path is appended; the deprecated `baseUrl`/`appId` fall back to
139
+ the app-registry installations API default. `createAccessServiceForInstall` routes
140
+ `platform` `"cng"`/`"acme"` to these.
129
141
 
130
142
  - `CngAccessService` — `getProductService().getAllActivities()` (short-form
131
143
  `getAllActivities()`) → `Activity[]` (`Activity`/`ActivityTicket` models).
@@ -187,12 +199,14 @@ an external system** (App Store config), not from code.
187
199
  `verifyPeekAuthToken`, throwing the underlying `jsonwebtoken` error on failure,
188
200
  one `try/catch` → `401`) and merges both into one flat, JSON-safe
189
201
  `InstallWebhook`: `installId`, `accountId` (**also called the partner ID**),
190
- `accountName`, `platform`, `isTest`, `status`, `rawStatus`, `displayVersion`,
191
- `user` (nullable, for system-initiated events). The **verified token** is
192
- authoritative for `installId`/`accountId`/`status`/`displayVersion`/`user`; the
193
- unsigned body supplies only `accountName`/`platform`/`isTest`, so a forged body
194
- can't override an authenticated field. This webhook is the **only** source of
195
- `accountId` in the package — no GraphQL read returns it. `status` and `platform`
202
+ `accountName`, `platform`, `isTest`, `timezone`, `apiUrl`, `status`,
203
+ `rawStatus`, `displayVersion`, `user` (nullable, for system-initiated events).
204
+ The **JSON body is the source of the event data**; the fields it shares with
205
+ the token (`installId`/`accountId`/`status`/`displayVersion`/`user`) fall back
206
+ to the **verified token** when the body omits them. The token authenticates the
207
+ whole delivery, so the body is trusted within a verified request. This webhook
208
+ is the **only** source of `accountId`, `timezone`, and `apiUrl` in the package
209
+ — no GraphQL read returns them; **persist them per install**. `status` and `platform`
196
210
  are **nullable**: a value this version does not recognise maps to `null` (wire
197
211
  value kept on `rawStatus`) rather than being coerced, because Peek does not
198
212
  redeliver a 2xx and a coerced status silently drops a lifecycle transition. Give
@@ -236,12 +250,18 @@ via a `ref`'s imperative methods). No `only a getter` crash. TS still types them
236
250
  read-only — the setters are a runtime safety net.
237
251
 
238
252
  Tags by tier (see `docs/ui.md` for attributes/events/examples per component):
239
- - display: `ody-button`, `ody-tag`, `ody-alert`, `ody-card`, `ody-divider`,
253
+ - display: `ody-button`, `ody-tag`, `ody-alert`, `ody-card`, `ody-horizontal-divider`
254
+ (1px rule with a default `margin-block: var(--gap16)`; `spacing="tight"|"none"`),
240
255
  `ody-status-dot`, `ody-message`, `ody-icon`, `ody-loading-spinner`, `ody-loading-bar`
241
256
  - layout: `ody-empty-state`, `ody-breadcrumb`(+`-item`), `ody-stat-summary`(+`ody-stat`),
242
257
  `ody-inline-list`(+`-item`), `ody-list-item`, `ody-product-indicator`,
243
258
  `ody-toggle-button`, `ody-section-rows`/`-columns`(+items), `ody-two-column`(+parts),
244
- `ody-collapsible-section`
259
+ `ody-collapsible-section`, `ody-app-page-container` (required page wrapper for a
260
+ settings UI — 868/1310px widths, `ody-page` container context, default responsive
261
+ gutter; `flush` opts out)
262
+ - deprecated (kept, unchanged): `ody-divider` → use `ody-horizontal-divider` (the old
263
+ one has zero margin); `ody-page-container` → use `ody-app-page-container` (the old
264
+ one is full-bleed with no gutter). Migrate by renaming the tag.
245
265
  - form: `ody-input`, `ody-inline-input`, `ody-search-input`, `ody-money-input`,
246
266
  `ody-percentage-input`, `ody-checkbox`, `ody-radio-button-group`, `ody-checkbox-group`
247
267
  - interactive: `ody-accordion`, `ody-collapsible`, `ody-tabs`, `ody-copy-button`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peektravel/app-utilities",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "GraphQL JS mapping utilities extracted from the Peek Pro Autopilot connector.",
5
5
  "license": "MIT",
6
6
  "repository": {