@wix/himalaya-cli 0.1036.0 → 0.1037.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/portfolio/src/index.d.ts +46 -9
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-portfolio.mjs +14 -2
  3. package/dist/cli.mjs +476 -109
  4. package/dist/preview-assets.generated.bin +0 -0
  5. package/fixtures/wix-canned.json +51 -2
  6. package/guides/sdk-reference.json +16 -10
  7. package/guides/wix-business-context.md +13 -1
  8. package/guides/wix-site-to-himi-app.md +10 -1
  9. package/package.json +2 -2
  10. package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +14 -8
  11. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +12 -1
  12. package/templates/beauty-salon-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  13. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-appointments/index.ts +4 -1
  14. package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +14 -8
  15. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +12 -1
  16. package/templates/branded-lite/tier5-src/himi-branded-lite-portfolio/index.ts +2 -2
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-portfolio-project/index.ts +3 -3
  18. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +12 -1
  19. package/templates/community-club-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  20. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +12 -1
  21. package/templates/events-meetups-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  22. package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +14 -8
  23. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +12 -1
  24. package/templates/gym-fitness-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  25. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-appointments/index.ts +4 -1
  26. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +12 -1
  27. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  28. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-appointments/index.ts +4 -1
  29. package/templates/owner-lite/tier5-src/_shared/owner-module-data.ts +2 -2
  30. package/templates/owner-lite/tier5-src/himi-owner-lite-appointments/index.ts +4 -1
  31. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +12 -1
  32. package/templates/podcast-creator-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  33. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +12 -1
  34. package/templates/portfolio-creative/tier5-src/himi-branded-lite-portfolio/index.ts +2 -2
  35. package/templates/portfolio-creative/tier5-src/himi-branded-lite-portfolio-project/index.ts +3 -3
  36. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  37. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-appointments/index.ts +4 -1
  38. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +12 -1
  39. package/templates/restaurant-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  40. package/templates/restaurant-owner/tier5-src/himi-owner-lite-appointments/index.ts +4 -1
  41. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +12 -1
  42. package/templates/retail-store-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  43. package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +14 -8
  44. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +12 -1
  45. package/templates/yoga-studio-owner/tier5-src/_shared/owner-module-data.ts +2 -2
  46. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-appointments/index.ts +4 -1
Binary file
@@ -1057,20 +1057,69 @@
1057
1057
  "title": "Roastery Rebuild",
1058
1058
  "description": "A 12-week fit-out, start to finish.",
1059
1059
  "slug": "roastery-rebuild",
1060
- "coverImage": "https://static.wixstatic.com/media/example-rebuild.jpg"
1060
+ "coverImage": {
1061
+ "type": "WIX_MEDIA",
1062
+ "imageInfo": {
1063
+ "id": "example-rebuild.jpg",
1064
+ "url": "https://static.wixstatic.com/media/example-rebuild.jpg",
1065
+ "width": 2752,
1066
+ "height": 1536
1067
+ },
1068
+ "secure": false
1069
+ }
1061
1070
  },
1062
1071
  {
1063
1072
  "id": "proj-0002",
1064
1073
  "title": "Harvest Field Notes",
1065
1074
  "description": "Photography from the 2026 trip.",
1066
1075
  "slug": "harvest-field-notes",
1067
- "coverImage": "https://static.wixstatic.com/media/example-fieldnotes.jpg"
1076
+ "coverImage": {
1077
+ "type": "WIX_MEDIA",
1078
+ "imageInfo": {
1079
+ "id": "example-fieldnotes.jpg",
1080
+ "url": "https://static.wixstatic.com/media/example-fieldnotes.jpg",
1081
+ "width": 1024,
1082
+ "height": 1024
1083
+ },
1084
+ "secure": false
1085
+ }
1068
1086
  }
1069
1087
  ],
1070
1088
  "pagingMetadata": {
1071
1089
  "count": 2
1072
1090
  }
1073
1091
  },
1092
+ "POST /portfolio/v1/items/query": {
1093
+ "items": [
1094
+ {
1095
+ "id": "item-0001",
1096
+ "projectId": "proj-0001",
1097
+ "sortOrder": 1,
1098
+ "type": "IMAGE",
1099
+ "title": "The finished bar",
1100
+ "image": {
1101
+ "type": "WIX_MEDIA",
1102
+ "imageInfo": {
1103
+ "id": "example-rebuild-bar.jpg",
1104
+ "url": "https://static.wixstatic.com/media/example-rebuild-bar.jpg",
1105
+ "width": 1600,
1106
+ "height": 1067
1107
+ }
1108
+ }
1109
+ },
1110
+ {
1111
+ "id": "item-0002",
1112
+ "projectId": "proj-0001",
1113
+ "sortOrder": 2,
1114
+ "type": "TEXT",
1115
+ "title": "Twelve weeks",
1116
+ "description": "Stripped to the brick, rebuilt around the roaster."
1117
+ }
1118
+ ],
1119
+ "metadata": {
1120
+ "count": 2
1121
+ }
1122
+ },
1074
1123
  "POST /badges/v4/badges/query": {
1075
1124
  "badges": [
1076
1125
  {
@@ -19564,11 +19564,23 @@
19564
19564
  "source": "stdlib/wix-sdk/portfolio/src/index.ts",
19565
19565
  "description": "Portfolio — projects and collections",
19566
19566
  "api": [
19567
+ {
19568
+ "name": "WixMediaImage",
19569
+ "kind": "interface",
19570
+ "jsdoc": "/** A Wix Portfolio media image — a project / collection cover or a project item's image. */",
19571
+ "decl": "export interface WixMediaImage {\n type?: string;\n imageInfo?: { id?: string; url?: string; width?: number; height?: number };\n}"
19572
+ },
19573
+ {
19574
+ "name": "portfolioCoverRef",
19575
+ "kind": "function",
19576
+ "jsdoc": "/**\n * The Wix media reference of a Portfolio cover or item image — its `imageInfo.url`, else\n * its `imageInfo.id` — for `wixMediaUrl` to resolve and size. `\"\"` when there is none.\n * A bare string is still accepted, so a cover stored as a url string keeps rendering.\n *\n * @example\n * const image = wixMediaUrl(portfolioCoverRef(project.coverImage));\n */",
19577
+ "decl": "export function portfolioCoverRef(cover: WixMediaImage | string | null | undefined): string;"
19578
+ },
19567
19579
  {
19568
19580
  "name": "WixPortfolioProject",
19569
19581
  "kind": "interface",
19570
19582
  "jsdoc": "",
19571
- "decl": "export interface WixPortfolioProject {\n id: string;\n title?: string;\n description?: string;\n /** Project cover. coverImage is a Wix media url/id. */\n coverImage?: string;\n slug?: string;\n hidden?: boolean;\n createdDate?: string;\n updatedDate?: string;\n}"
19583
+ "decl": "export interface WixPortfolioProject {\n id: string;\n title?: string;\n description?: string;\n /**\n * Project cover. The live wire shape is a media OBJECT\n * (`{type:\"WIX_MEDIA\", imageInfo:{id,url,width,height}}`), not a url string —\n * read it with {@link portfolioCoverRef}; `wixMediaUrl` returns `\"\"` for the object.\n * A url string is still accepted, for projects constructed by hand.\n */\n coverImage?: WixMediaImage | string;\n slug?: string;\n hidden?: boolean;\n createdDate?: string;\n updatedDate?: string;\n}"
19572
19584
  },
19573
19585
  {
19574
19586
  "name": "WixListProjectsResponse",
@@ -19583,12 +19595,6 @@
19583
19595
  "decl": "export function listProjects(limit = 100, cursor?: string): Promise<WixListProjectsResponse>;",
19584
19596
  "http": "GET /portfolio/v1/projects?{…}"
19585
19597
  },
19586
- {
19587
- "name": "WixMediaImage",
19588
- "kind": "interface",
19589
- "jsdoc": "",
19590
- "decl": "export interface WixMediaImage { imageInfo?: { id?: string; url?: string } }"
19591
- },
19592
19598
  {
19593
19599
  "name": "WixPortfolioCollection",
19594
19600
  "kind": "interface",
@@ -19618,14 +19624,14 @@
19618
19624
  "name": "WixListProjectItemsResponse",
19619
19625
  "kind": "interface",
19620
19626
  "jsdoc": "",
19621
- "decl": "export interface WixListProjectItemsResponse {\n items?: WixPortfolioProjectItem[];\n pagingMetadata?: { count?: number; cursors?: { next?: string }; hasNext?: boolean };\n}"
19627
+ "decl": "export interface WixListProjectItemsResponse {\n items?: WixPortfolioProjectItem[];\n /**\n * Paging, under the name this function always documented. A Wix query reports it as\n * `metadata`; {@link listProjectItems} copies that here when `pagingMetadata` is absent.\n */\n pagingMetadata?: { count?: number; cursors?: { next?: string }; hasNext?: boolean };\n /** The query endpoint's own name for the paging block. */\n metadata?: { count?: number; cursors?: { next?: string }; hasNext?: boolean };\n}"
19622
19628
  },
19623
19629
  {
19624
19630
  "name": "listProjectItems",
19625
19631
  "kind": "function",
19626
- "jsdoc": "/** GET /portfolio/v1/projects/{projectId}/items — list a project's items. */",
19632
+ "jsdoc": "/**\n * POST /portfolio/v1/items/query — list a project's items.\n *\n * A first page filters on `projectId` with `paging.limit`. Passing a `cursor` (a previous\n * page's `pagingMetadata.cursors.next`) switches to `cursorPaging` and drops the filter,\n * because a Wix query cursor already carries it. Items come back in the order Wix returns\n * them — sort on `sortOrder` for the author's order.\n */",
19627
19633
  "decl": "export function listProjectItems(projectId: string, limit = 100, cursor?: string): Promise<WixListProjectItemsResponse>;",
19628
- "http": "GET /portfolio/v1/projects/{…}/items?{…}"
19634
+ "http": "POST /portfolio/v1/items/query"
19629
19635
  }
19630
19636
  ]
19631
19637
  },
@@ -122,9 +122,21 @@ never change. If nothing is reachable, keep the default kit.
122
122
  RFC 0048 refused a **content dump** on the runtime facade. The dump lives here, at **build time**:
123
123
 
124
124
  ```bash
125
- himi site analyze --client-id <id> --site <msid> --url https://… --out .himi/business-book [--apply]
125
+ himi site analyze --url https://… --out .himi/business-book # visitor-only pass
126
+ himi site analyze --url https://… --client-id <id> [--site <msid>] --out .himi/business-book [--apply]
126
127
  ```
127
128
 
129
+ `--url` alone is the **visitor-only pass**: the published pages, their look (screenshots,
130
+ `--wst-*`), the Copy chapter and the Placeholders chapter (template phone/email, Wix editor text, a
131
+ second business name, repeated images). It needs no OAuth client, so no anonymous token is minted
132
+ and every vertical probe reports `visitor skipped` — the Catalog has no counts. `--client-id` (the
133
+ site's Headless client) adds those counts, each Bookings service's price and payment mode
134
+ (`pay online only`, `pay in person only`, `free`, …), and the `wix` binding `--apply` writes;
135
+ `--apply` without it exits 2. `--site` adds the owner-only chapters (CMS, media, roles); an
136
+ ambient `WIX_SITE_ID` does so only beside an explicit `--client-id` flag (an exported
137
+ `WIX_CLIENT_ID` does not count), so a URL-only pass is never silently upgraded to an owner read
138
+ of whichever site that variable names.
139
+
128
140
  Same collector, three doors:
129
141
 
130
142
  | Door | Artifact |
@@ -59,14 +59,23 @@ banner. Keep **only the first ASCII line** (`head -1`) or HTTP headers throw
59
59
  ### 1. Analyze — do not re-fetch Wix
60
60
 
61
61
  ```sh
62
+ # Visitor-only pass — the URL is all it needs (published pages, look, copy, template leftovers):
63
+ himi site analyze --url https://customer.wixsite.com/site --out .himi/business-book
64
+
65
+ # Full pass — --client-id adds catalog counts, service payment modes and the --apply binding;
66
+ # --site adds the owner chapters (CMS, media, roles):
62
67
  himi site analyze \
63
- --client-id <headless-oauth-client-id> \
64
68
  --url https://customer.wixsite.com/site \
69
+ --client-id <headless-oauth-client-id> \
65
70
  --site <msid> \
66
71
  --out .himi/business-book
67
72
  # screenshots + --wst-*: npx playwright install chromium
68
73
  ```
69
74
 
75
+ Check `book.md` → **Placeholders** before reusing any copy: a template phone (`123-456-7890`),
76
+ Wix editor text, a second business name or one photo repeated across a gallery is the
77
+ template's, not the customer's.
78
+
70
79
  Read `book.json`, `book.md`, `evidence/pages/`, `media/`, `velo/` (via
71
80
  `book.velo.files[].localPath`). Write `.himi/business-book/look.notes.md` for mood,
72
81
  overlay tie-break, language. **Never rewrite `book.json`.** Never log
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1036.0",
3
+ "version": "0.1037.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -65,5 +65,5 @@
65
65
  "artifactId": "himalaya-cli"
66
66
  }
67
67
  },
68
- "falconPackageHash": "4823bad3388426fd2f4488b8d0571503d30ba55aafb5a033ecbdfac6"
68
+ "falconPackageHash": "e35236017e826d4e25212e7448f29f8004d84cec687b348a8231366e"
69
69
  }
@@ -11,9 +11,14 @@ const input = (globalThis as { himalaya?: { input?: Record<string, unknown> } })
11
11
  const validDate = (value: unknown): value is string => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && dayKey(new Date(`${value}T12:00:00`)) === value;
12
12
  const views = localPreferences("booking.schedule", value => ["agenda", "day", "month"].includes(String(value)) ? String(value) : "agenda");
13
13
  let mode = validDate(input?.date) ? "day" : views.read();
14
- let selectedDate = validDate(input?.date) ? input.date : dayKey(new Date());
14
+ // "" until the schedule is first shown, NOT today's date: the release build hashes this worker's
15
+ // initial state, so a date read off the build machine's clock made every build of unchanged
16
+ // source a different release (#3268, the `nondeterministic-initial-state` lint). `derive()` fills
17
+ // in today the first time the schedule is visible, which is never at build time (no member).
18
+ let selectedDate = validDate(input?.date) ? input.date : "";
19
+ const today = () => dayKey(new Date());
15
20
  let pageMember: MemberAccess | undefined;
16
- let queryNow = new Date().toISOString();
21
+ let queryNow = ""; // set by the first-page fetch, never at boot (see selectedDate)
17
22
  let cancelTarget: { row: MemberBookingRow; member: MemberAccess } | undefined;
18
23
  let cancelling = false;
19
24
  interface S {
@@ -26,11 +31,12 @@ interface S {
26
31
  nextCursor?: string; complete?: boolean; loadingMore?: boolean; pageError?: boolean; total?: number;
27
32
  }
28
33
  function derive(s: S): S {
29
- const dayRows = s.rows.filter(row => row.day === selectedDate);
34
+ const hideSchedule = !s.isMember || s.isLoading;
35
+ if (!selectedDate && !hideSchedule) selectedDate = today();
36
+ const dayRows = selectedDate ? s.rows.filter(row => row.day === selectedDate) : [];
30
37
  const counts = new Map<string, number>();
31
38
  for (const row of s.rows) if (row.day) counts.set(row.day, (counts.get(row.day) ?? 0) + 1);
32
- const hideSchedule = !s.isMember || s.isLoading;
33
- return { ...s, scheduleMode: mode, selectedDate, dayRows, dayHeading: dayLabel(selectedDate), hideSchedule,
39
+ return { ...s, scheduleMode: mode, selectedDate, dayRows, dayHeading: selectedDate ? dayLabel(selectedDate) : "", hideSchedule,
34
40
  hideAgenda: hideSchedule || mode !== "agenda", hideCalendar: hideSchedule || mode === "agenda", hideMonth: mode !== "month",
35
41
  decorations: [...counts].map(([date, n]) => ({ date, dotCount: Math.min(n, 3), dotColorToken: "accentPrimary" })),
36
42
  dayEmptyText: dayRows.length ? "" : s.complete ? copyParam("bookings.emptyDay", "No bookings on this day.") : copyParam("bookings.emptyLoadedDay", "No bookings on this day in loaded results. Load more to check later pages."),
@@ -48,7 +54,7 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
48
54
  async fetch(criteria, cursor) {
49
55
  const member = await memberAccess();
50
56
  if (cursor && (!pageMember || pageMember.email !== member.email || pageMember.scope !== member.scope)) throw memberAccessChanged();
51
- if (!cursor) queryNow = new Date().toISOString();
57
+ if (!cursor || !queryNow) queryNow = new Date().toISOString();
52
58
  return fetchMemberBookings(criteria, cursor, queryNow, member);
53
59
  },
54
60
  },
@@ -65,8 +71,8 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
65
71
  actions: {
66
72
  scheduleView(s, arg) { const value = (arg as { selectedId?: string })?.selectedId; if (["agenda", "day", "month"].includes(value || "")) { mode = value!; views.write(mode); } return derive(s); },
67
73
  selectDate(s, arg) { const date = (arg as { date?: unknown })?.date; if (validDate(date)) selectedDate = date; return derive(s); },
68
- previousDay(s) { const date = new Date(`${selectedDate}T12:00:00`); date.setDate(date.getDate() - 1); selectedDate = dayKey(date); return derive(s); },
69
- nextDay(s) { const date = new Date(`${selectedDate}T12:00:00`); date.setDate(date.getDate() + 1); selectedDate = dayKey(date); return derive(s); },
74
+ previousDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() - 1); selectedDate = dayKey(date); return derive(s); },
75
+ nextDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() + 1); selectedDate = dayKey(date); return derive(s); },
70
76
  async rebook(s, arg) {
71
77
  const member = pageMember, row = s.rows.find(row => row.id === (arg as { id?: string })?.id);
72
78
  if (!member || !row?.serviceId) return s;
@@ -388,6 +388,10 @@ function resolveEffectiveLayout(arg: unknown, productCount: number): LayoutId {
388
388
  });
389
389
  }
390
390
 
391
+ /** The time-of-day greeting. Read at REFRESH time only, never at boot: the release build hashes
392
+ * the initial state, so a greeting read off the build machine's clock made every build of an
393
+ * unchanged template a different release (#3268, the `nondeterministic-initial-state` lint).
394
+ * The initial state carries `neutralWelcome()` instead, and the first refresh swaps this in. */
391
395
  function welcome(): string {
392
396
  const hour = new Date().getHours();
393
397
  if (hour < 12) return copyParam("home.greetingMorning", "Good morning");
@@ -395,6 +399,11 @@ function welcome(): string {
395
399
  return copyParam("home.greetingEvening", "Good evening");
396
400
  }
397
401
 
402
+ /** The boot-time greeting: clock-free, so the baked initial state is the same on every build. */
403
+ function neutralWelcome(): string {
404
+ return copyParam("home.greetingNeutral", "Welcome");
405
+ }
406
+
398
407
  function feature(p: StoreProduct): ProductFeature {
399
408
  const name = cleanTitle(p.name) || copyParam("home.productFallbackName", "Featured item");
400
409
  const image = productImage(p);
@@ -871,7 +880,9 @@ function baseState(): State {
871
880
  hideHeroInitial: true,
872
881
  hideHeroFallback: true,
873
882
  hideFeaturedSkeleton: false,
874
- welcome: welcome(),
883
+ // Not welcome(): that reads the clock, and this state is baked into the release. A neutral
884
+ // greeting keeps the hero pill painted (same size, no reflow) until the first refresh.
885
+ welcome: neutralWelcome(),
875
886
  refreshedAt: "",
876
887
  effectiveLayout: resolveEffectiveLayout(undefined, 0),
877
888
  accentColor: resolveAccent(undefined),
@@ -8,7 +8,7 @@ import { queryPrograms, getProgram } from '@himalaya/wix-online-programs';
8
8
  import { queryReviews, getReview } from '@himalaya/wix-reviews';
9
9
  import { queryReservations, getReservation } from '@himalaya/wix-table-reservations';
10
10
  import { queryForms, getForm } from '@himalaya/wix-forms';
11
- import { listProjects, listProjectItems } from '@himalaya/wix-portfolio';
11
+ import { listProjects, listProjectItems, portfolioCoverRef, type WixMediaImage } from '@himalaya/wix-portfolio';
12
12
  import { queryGiftCards, getGiftCard } from '@himalaya/wix-gift-cards';
13
13
  import { queryDonationCampaigns, getDonationCampaign } from '@himalaya/wix-donations';
14
14
  import { query as queryBadges, get as getBadge } from '@himalaya/wix-badges';
@@ -208,7 +208,7 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
208
208
  details = [['Namespace', subtitle]];
209
209
  break;
210
210
  case 'portfolio':
211
- title = field(raw, 'title'); subtitle = field(raw, 'description'); image = field(raw, 'coverImage');
211
+ title = field(raw, 'title'); subtitle = field(raw, 'description'); image = portfolioCoverRef(at(raw, 'coverImage') as WixMediaImage | string | undefined);
212
212
  statusLabel = typeof at(raw, 'hidden') === 'boolean' ? at(raw, 'hidden') ? 'Hidden' : 'Visible' : '';
213
213
  break;
214
214
  case 'donationCampaigns':
@@ -311,7 +311,10 @@ function shape(s: Partial<AppointmentsState>): AppointmentsState {
311
311
  // Until the owner picks a day, follow the soonest booked one, so a freshly
312
312
  // loaded month never opens on an empty day list.
313
313
  const soonest = all.find((r) => r._day)?._day ?? "";
314
- const focus = (s._picked ? s.selectedDate : "") || soonest || dayKey(new Date());
314
+ // Today only once the list is READY (the month is hidden until then): the initial state is baked
315
+ // into the release, and a day read off the build machine's clock made every build of unchanged
316
+ // source a different release (#3268, the `nondeterministic-initial-state` lint).
317
+ const focus = (s._picked ? s.selectedDate : "") || soonest || (status === "ready" ? dayKey(new Date()) : "");
315
318
  const dayAppointments = all.filter((r) => r._day === focus);
316
319
  const dayEvents = eventsOf(dayAppointments);
317
320
  const ready = status === "ready";
@@ -11,9 +11,14 @@ const input = (globalThis as { himalaya?: { input?: Record<string, unknown> } })
11
11
  const validDate = (value: unknown): value is string => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && dayKey(new Date(`${value}T12:00:00`)) === value;
12
12
  const views = localPreferences("booking.schedule", value => ["agenda", "day", "month"].includes(String(value)) ? String(value) : "agenda");
13
13
  let mode = validDate(input?.date) ? "day" : views.read();
14
- let selectedDate = validDate(input?.date) ? input.date : dayKey(new Date());
14
+ // "" until the schedule is first shown, NOT today's date: the release build hashes this worker's
15
+ // initial state, so a date read off the build machine's clock made every build of unchanged
16
+ // source a different release (#3268, the `nondeterministic-initial-state` lint). `derive()` fills
17
+ // in today the first time the schedule is visible, which is never at build time (no member).
18
+ let selectedDate = validDate(input?.date) ? input.date : "";
19
+ const today = () => dayKey(new Date());
15
20
  let pageMember: MemberAccess | undefined;
16
- let queryNow = new Date().toISOString();
21
+ let queryNow = ""; // set by the first-page fetch, never at boot (see selectedDate)
17
22
  let cancelTarget: { row: MemberBookingRow; member: MemberAccess } | undefined;
18
23
  let cancelling = false;
19
24
  interface S {
@@ -26,11 +31,12 @@ interface S {
26
31
  nextCursor?: string; complete?: boolean; loadingMore?: boolean; pageError?: boolean; total?: number;
27
32
  }
28
33
  function derive(s: S): S {
29
- const dayRows = s.rows.filter(row => row.day === selectedDate);
34
+ const hideSchedule = !s.isMember || s.isLoading;
35
+ if (!selectedDate && !hideSchedule) selectedDate = today();
36
+ const dayRows = selectedDate ? s.rows.filter(row => row.day === selectedDate) : [];
30
37
  const counts = new Map<string, number>();
31
38
  for (const row of s.rows) if (row.day) counts.set(row.day, (counts.get(row.day) ?? 0) + 1);
32
- const hideSchedule = !s.isMember || s.isLoading;
33
- return { ...s, scheduleMode: mode, selectedDate, dayRows, dayHeading: dayLabel(selectedDate), hideSchedule,
39
+ return { ...s, scheduleMode: mode, selectedDate, dayRows, dayHeading: selectedDate ? dayLabel(selectedDate) : "", hideSchedule,
34
40
  hideAgenda: hideSchedule || mode !== "agenda", hideCalendar: hideSchedule || mode === "agenda", hideMonth: mode !== "month",
35
41
  decorations: [...counts].map(([date, n]) => ({ date, dotCount: Math.min(n, 3), dotColorToken: "accentPrimary" })),
36
42
  dayEmptyText: dayRows.length ? "" : s.complete ? copyParam("bookings.emptyDay", "No bookings on this day.") : copyParam("bookings.emptyLoadedDay", "No bookings on this day in loaded results. Load more to check later pages."),
@@ -48,7 +54,7 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
48
54
  async fetch(criteria, cursor) {
49
55
  const member = await memberAccess();
50
56
  if (cursor && (!pageMember || pageMember.email !== member.email || pageMember.scope !== member.scope)) throw memberAccessChanged();
51
- if (!cursor) queryNow = new Date().toISOString();
57
+ if (!cursor || !queryNow) queryNow = new Date().toISOString();
52
58
  return fetchMemberBookings(criteria, cursor, queryNow, member);
53
59
  },
54
60
  },
@@ -65,8 +71,8 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
65
71
  actions: {
66
72
  scheduleView(s, arg) { const value = (arg as { selectedId?: string })?.selectedId; if (["agenda", "day", "month"].includes(value || "")) { mode = value!; views.write(mode); } return derive(s); },
67
73
  selectDate(s, arg) { const date = (arg as { date?: unknown })?.date; if (validDate(date)) selectedDate = date; return derive(s); },
68
- previousDay(s) { const date = new Date(`${selectedDate}T12:00:00`); date.setDate(date.getDate() - 1); selectedDate = dayKey(date); return derive(s); },
69
- nextDay(s) { const date = new Date(`${selectedDate}T12:00:00`); date.setDate(date.getDate() + 1); selectedDate = dayKey(date); return derive(s); },
74
+ previousDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() - 1); selectedDate = dayKey(date); return derive(s); },
75
+ nextDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() + 1); selectedDate = dayKey(date); return derive(s); },
70
76
  async rebook(s, arg) {
71
77
  const member = pageMember, row = s.rows.find(row => row.id === (arg as { id?: string })?.id);
72
78
  if (!member || !row?.serviceId) return s;
@@ -388,6 +388,10 @@ function resolveEffectiveLayout(arg: unknown, productCount: number): LayoutId {
388
388
  });
389
389
  }
390
390
 
391
+ /** The time-of-day greeting. Read at REFRESH time only, never at boot: the release build hashes
392
+ * the initial state, so a greeting read off the build machine's clock made every build of an
393
+ * unchanged template a different release (#3268, the `nondeterministic-initial-state` lint).
394
+ * The initial state carries `neutralWelcome()` instead, and the first refresh swaps this in. */
391
395
  function welcome(): string {
392
396
  const hour = new Date().getHours();
393
397
  if (hour < 12) return copyParam("home.greetingMorning", "Good morning");
@@ -395,6 +399,11 @@ function welcome(): string {
395
399
  return copyParam("home.greetingEvening", "Good evening");
396
400
  }
397
401
 
402
+ /** The boot-time greeting: clock-free, so the baked initial state is the same on every build. */
403
+ function neutralWelcome(): string {
404
+ return copyParam("home.greetingNeutral", "Welcome");
405
+ }
406
+
398
407
  function feature(p: StoreProduct): ProductFeature {
399
408
  const name = cleanTitle(p.name) || copyParam("home.productFallbackName", "Featured item");
400
409
  const image = productImage(p);
@@ -871,7 +880,9 @@ function baseState(): State {
871
880
  hideHeroInitial: true,
872
881
  hideHeroFallback: true,
873
882
  hideFeaturedSkeleton: false,
874
- welcome: welcome(),
883
+ // Not welcome(): that reads the clock, and this state is baked into the release. A neutral
884
+ // greeting keeps the hero pill painted (same size, no reflow) until the first refresh.
885
+ welcome: neutralWelcome(),
875
886
  refreshedAt: "",
876
887
  effectiveLayout: resolveEffectiveLayout(undefined, 0),
877
888
  accentColor: resolveAccent(undefined),
@@ -1,7 +1,7 @@
1
1
  // himi-branded-lite-portfolio — real, public Wix Portfolio projects.
2
2
  // Empty and error states stay explicit; media IDs are normalized for every host.
3
3
  import { defineService } from "../_shared/collection-service.js";
4
- import { listProjects, type WixPortfolioProject } from "@himalaya/wix-portfolio";
4
+ import { listProjects, portfolioCoverRef, type WixPortfolioProject } from "@himalaya/wix-portfolio";
5
5
  import { wixMediaUrl } from "@himalaya/wix-media";
6
6
 
7
7
  interface Row {
@@ -24,7 +24,7 @@ interface S {
24
24
 
25
25
  function norm(project: WixPortfolioProject): Row {
26
26
  const title = (project.title || "Project").trim() || "Project";
27
- const image = wixMediaUrl(project.coverImage || "");
27
+ const image = wixMediaUrl(portfolioCoverRef(project.coverImage));
28
28
  return {
29
29
  id: project.id || project.slug || title,
30
30
  title,
@@ -2,7 +2,7 @@
2
2
  // The public project row is resolved again from Wix, then its ordered project
3
3
  // items supply the actual image/video/text story. No sample project is invented.
4
4
  import { defineService } from "@himalaya/state";
5
- import { listProjectItems, listProjects, type WixPortfolioProjectItem } from "@himalaya/wix-portfolio";
5
+ import { listProjectItems, listProjects, portfolioCoverRef, type WixPortfolioProjectItem } from "@himalaya/wix-portfolio";
6
6
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
7
7
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
8
8
  import { presence, type PresenceFlags } from "../_shared/detail-presence.js";
@@ -48,7 +48,7 @@ function inputId(): string {
48
48
  }
49
49
 
50
50
  function imageOf(item: WixPortfolioProjectItem): string {
51
- return wixMediaUrl(item.image?.imageInfo?.url ?? item.image?.imageInfo?.id, WIX_DETAIL_IMAGE);
51
+ return wixMediaUrl(portfolioCoverRef(item.image), WIX_DETAIL_IMAGE);
52
52
  }
53
53
 
54
54
  function row(item: WixPortfolioProjectItem, index: number): MediaRow {
@@ -133,7 +133,7 @@ const svc = defineService<State, { load: (state: State, arg?: unknown) => Promis
133
133
  title: (project.title || copyParam("portfolioProject.projectFallbackName", "Project")).trim(),
134
134
  initial: ((project.title || "P").trim()[0] || "P").toUpperCase(),
135
135
  description: (project.description ?? "").trim(),
136
- coverImage: wixMediaUrl(project.coverImage || "", WIX_DETAIL_IMAGE),
136
+ coverImage: wixMediaUrl(portfolioCoverRef(project.coverImage), WIX_DETAIL_IMAGE),
137
137
  items,
138
138
  emptyText: items.length ? "" : copyParam("portfolioProject.noMediaText", "This project has no published media yet."),
139
139
  ...presence("found"),
@@ -388,6 +388,10 @@ function resolveEffectiveLayout(arg: unknown, productCount: number): LayoutId {
388
388
  });
389
389
  }
390
390
 
391
+ /** The time-of-day greeting. Read at REFRESH time only, never at boot: the release build hashes
392
+ * the initial state, so a greeting read off the build machine's clock made every build of an
393
+ * unchanged template a different release (#3268, the `nondeterministic-initial-state` lint).
394
+ * The initial state carries `neutralWelcome()` instead, and the first refresh swaps this in. */
391
395
  function welcome(): string {
392
396
  const hour = new Date().getHours();
393
397
  if (hour < 12) return copyParam("home.greetingMorning", "Good morning");
@@ -395,6 +399,11 @@ function welcome(): string {
395
399
  return copyParam("home.greetingEvening", "Good evening");
396
400
  }
397
401
 
402
+ /** The boot-time greeting: clock-free, so the baked initial state is the same on every build. */
403
+ function neutralWelcome(): string {
404
+ return copyParam("home.greetingNeutral", "Welcome");
405
+ }
406
+
398
407
  function feature(p: StoreProduct): ProductFeature {
399
408
  const name = cleanTitle(p.name) || copyParam("home.productFallbackName", "Featured item");
400
409
  const image = productImage(p);
@@ -871,7 +880,9 @@ function baseState(): State {
871
880
  hideHeroInitial: true,
872
881
  hideHeroFallback: true,
873
882
  hideFeaturedSkeleton: false,
874
- welcome: welcome(),
883
+ // Not welcome(): that reads the clock, and this state is baked into the release. A neutral
884
+ // greeting keeps the hero pill painted (same size, no reflow) until the first refresh.
885
+ welcome: neutralWelcome(),
875
886
  refreshedAt: "",
876
887
  effectiveLayout: resolveEffectiveLayout(undefined, 0),
877
888
  accentColor: resolveAccent(undefined),
@@ -8,7 +8,7 @@ import { queryPrograms, getProgram } from '@himalaya/wix-online-programs';
8
8
  import { queryReviews, getReview } from '@himalaya/wix-reviews';
9
9
  import { queryReservations, getReservation } from '@himalaya/wix-table-reservations';
10
10
  import { queryForms, getForm } from '@himalaya/wix-forms';
11
- import { listProjects, listProjectItems } from '@himalaya/wix-portfolio';
11
+ import { listProjects, listProjectItems, portfolioCoverRef, type WixMediaImage } from '@himalaya/wix-portfolio';
12
12
  import { queryGiftCards, getGiftCard } from '@himalaya/wix-gift-cards';
13
13
  import { queryDonationCampaigns, getDonationCampaign } from '@himalaya/wix-donations';
14
14
  import { query as queryBadges, get as getBadge } from '@himalaya/wix-badges';
@@ -208,7 +208,7 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
208
208
  details = [['Namespace', subtitle]];
209
209
  break;
210
210
  case 'portfolio':
211
- title = field(raw, 'title'); subtitle = field(raw, 'description'); image = field(raw, 'coverImage');
211
+ title = field(raw, 'title'); subtitle = field(raw, 'description'); image = portfolioCoverRef(at(raw, 'coverImage') as WixMediaImage | string | undefined);
212
212
  statusLabel = typeof at(raw, 'hidden') === 'boolean' ? at(raw, 'hidden') ? 'Hidden' : 'Visible' : '';
213
213
  break;
214
214
  case 'donationCampaigns':
@@ -388,6 +388,10 @@ function resolveEffectiveLayout(arg: unknown, productCount: number): LayoutId {
388
388
  });
389
389
  }
390
390
 
391
+ /** The time-of-day greeting. Read at REFRESH time only, never at boot: the release build hashes
392
+ * the initial state, so a greeting read off the build machine's clock made every build of an
393
+ * unchanged template a different release (#3268, the `nondeterministic-initial-state` lint).
394
+ * The initial state carries `neutralWelcome()` instead, and the first refresh swaps this in. */
391
395
  function welcome(): string {
392
396
  const hour = new Date().getHours();
393
397
  if (hour < 12) return copyParam("home.greetingMorning", "Good morning");
@@ -395,6 +399,11 @@ function welcome(): string {
395
399
  return copyParam("home.greetingEvening", "Good evening");
396
400
  }
397
401
 
402
+ /** The boot-time greeting: clock-free, so the baked initial state is the same on every build. */
403
+ function neutralWelcome(): string {
404
+ return copyParam("home.greetingNeutral", "Welcome");
405
+ }
406
+
398
407
  function feature(p: StoreProduct): ProductFeature {
399
408
  const name = cleanTitle(p.name) || copyParam("home.productFallbackName", "Featured item");
400
409
  const image = productImage(p);
@@ -871,7 +880,9 @@ function baseState(): State {
871
880
  hideHeroInitial: true,
872
881
  hideHeroFallback: true,
873
882
  hideFeaturedSkeleton: false,
874
- welcome: welcome(),
883
+ // Not welcome(): that reads the clock, and this state is baked into the release. A neutral
884
+ // greeting keeps the hero pill painted (same size, no reflow) until the first refresh.
885
+ welcome: neutralWelcome(),
875
886
  refreshedAt: "",
876
887
  effectiveLayout: resolveEffectiveLayout(undefined, 0),
877
888
  accentColor: resolveAccent(undefined),
@@ -8,7 +8,7 @@ import { queryPrograms, getProgram } from '@himalaya/wix-online-programs';
8
8
  import { queryReviews, getReview } from '@himalaya/wix-reviews';
9
9
  import { queryReservations, getReservation } from '@himalaya/wix-table-reservations';
10
10
  import { queryForms, getForm } from '@himalaya/wix-forms';
11
- import { listProjects, listProjectItems } from '@himalaya/wix-portfolio';
11
+ import { listProjects, listProjectItems, portfolioCoverRef, type WixMediaImage } from '@himalaya/wix-portfolio';
12
12
  import { queryGiftCards, getGiftCard } from '@himalaya/wix-gift-cards';
13
13
  import { queryDonationCampaigns, getDonationCampaign } from '@himalaya/wix-donations';
14
14
  import { query as queryBadges, get as getBadge } from '@himalaya/wix-badges';
@@ -208,7 +208,7 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
208
208
  details = [['Namespace', subtitle]];
209
209
  break;
210
210
  case 'portfolio':
211
- title = field(raw, 'title'); subtitle = field(raw, 'description'); image = field(raw, 'coverImage');
211
+ title = field(raw, 'title'); subtitle = field(raw, 'description'); image = portfolioCoverRef(at(raw, 'coverImage') as WixMediaImage | string | undefined);
212
212
  statusLabel = typeof at(raw, 'hidden') === 'boolean' ? at(raw, 'hidden') ? 'Hidden' : 'Visible' : '';
213
213
  break;
214
214
  case 'donationCampaigns':
@@ -11,9 +11,14 @@ const input = (globalThis as { himalaya?: { input?: Record<string, unknown> } })
11
11
  const validDate = (value: unknown): value is string => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && dayKey(new Date(`${value}T12:00:00`)) === value;
12
12
  const views = localPreferences("booking.schedule", value => ["agenda", "day", "month"].includes(String(value)) ? String(value) : "agenda");
13
13
  let mode = validDate(input?.date) ? "day" : views.read();
14
- let selectedDate = validDate(input?.date) ? input.date : dayKey(new Date());
14
+ // "" until the schedule is first shown, NOT today's date: the release build hashes this worker's
15
+ // initial state, so a date read off the build machine's clock made every build of unchanged
16
+ // source a different release (#3268, the `nondeterministic-initial-state` lint). `derive()` fills
17
+ // in today the first time the schedule is visible, which is never at build time (no member).
18
+ let selectedDate = validDate(input?.date) ? input.date : "";
19
+ const today = () => dayKey(new Date());
15
20
  let pageMember: MemberAccess | undefined;
16
- let queryNow = new Date().toISOString();
21
+ let queryNow = ""; // set by the first-page fetch, never at boot (see selectedDate)
17
22
  let cancelTarget: { row: MemberBookingRow; member: MemberAccess } | undefined;
18
23
  let cancelling = false;
19
24
  interface S {
@@ -26,11 +31,12 @@ interface S {
26
31
  nextCursor?: string; complete?: boolean; loadingMore?: boolean; pageError?: boolean; total?: number;
27
32
  }
28
33
  function derive(s: S): S {
29
- const dayRows = s.rows.filter(row => row.day === selectedDate);
34
+ const hideSchedule = !s.isMember || s.isLoading;
35
+ if (!selectedDate && !hideSchedule) selectedDate = today();
36
+ const dayRows = selectedDate ? s.rows.filter(row => row.day === selectedDate) : [];
30
37
  const counts = new Map<string, number>();
31
38
  for (const row of s.rows) if (row.day) counts.set(row.day, (counts.get(row.day) ?? 0) + 1);
32
- const hideSchedule = !s.isMember || s.isLoading;
33
- return { ...s, scheduleMode: mode, selectedDate, dayRows, dayHeading: dayLabel(selectedDate), hideSchedule,
39
+ return { ...s, scheduleMode: mode, selectedDate, dayRows, dayHeading: selectedDate ? dayLabel(selectedDate) : "", hideSchedule,
34
40
  hideAgenda: hideSchedule || mode !== "agenda", hideCalendar: hideSchedule || mode === "agenda", hideMonth: mode !== "month",
35
41
  decorations: [...counts].map(([date, n]) => ({ date, dotCount: Math.min(n, 3), dotColorToken: "accentPrimary" })),
36
42
  dayEmptyText: dayRows.length ? "" : s.complete ? copyParam("bookings.emptyDay", "No bookings on this day.") : copyParam("bookings.emptyLoadedDay", "No bookings on this day in loaded results. Load more to check later pages."),
@@ -48,7 +54,7 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
48
54
  async fetch(criteria, cursor) {
49
55
  const member = await memberAccess();
50
56
  if (cursor && (!pageMember || pageMember.email !== member.email || pageMember.scope !== member.scope)) throw memberAccessChanged();
51
- if (!cursor) queryNow = new Date().toISOString();
57
+ if (!cursor || !queryNow) queryNow = new Date().toISOString();
52
58
  return fetchMemberBookings(criteria, cursor, queryNow, member);
53
59
  },
54
60
  },
@@ -65,8 +71,8 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
65
71
  actions: {
66
72
  scheduleView(s, arg) { const value = (arg as { selectedId?: string })?.selectedId; if (["agenda", "day", "month"].includes(value || "")) { mode = value!; views.write(mode); } return derive(s); },
67
73
  selectDate(s, arg) { const date = (arg as { date?: unknown })?.date; if (validDate(date)) selectedDate = date; return derive(s); },
68
- previousDay(s) { const date = new Date(`${selectedDate}T12:00:00`); date.setDate(date.getDate() - 1); selectedDate = dayKey(date); return derive(s); },
69
- nextDay(s) { const date = new Date(`${selectedDate}T12:00:00`); date.setDate(date.getDate() + 1); selectedDate = dayKey(date); return derive(s); },
74
+ previousDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() - 1); selectedDate = dayKey(date); return derive(s); },
75
+ nextDay(s) { const date = new Date(`${selectedDate || today()}T12:00:00`); date.setDate(date.getDate() + 1); selectedDate = dayKey(date); return derive(s); },
70
76
  async rebook(s, arg) {
71
77
  const member = pageMember, row = s.rows.find(row => row.id === (arg as { id?: string })?.id);
72
78
  if (!member || !row?.serviceId) return s;