@akinon/projectzero 2.0.43 → 2.0.44

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 (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/app-template/CHANGELOG.md +33 -1
  3. package/app-template/package.json +29 -29
  4. package/app-template/src/app/[pz]/orders/checkout/layout.tsx +30 -11
  5. package/app-template/src/app/[pz]/orders/checkout/page.tsx +31 -18
  6. package/app-template/src/components/checkbox.tsx +21 -2
  7. package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +121 -0
  8. package/app-template/src/views/checkout/step-button.tsx +13 -2
  9. package/app-template/src/views/checkout/step-list.tsx +5 -1
  10. package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
  11. package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
  12. package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
  13. package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
  14. package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
  15. package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
  16. package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
  17. package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
  18. package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
  19. package/app-template/src/views/checkout/summary.tsx +56 -18
  20. package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
  21. package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
  22. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +7 -4
  23. package/app-template/src/views/checkout/variants/multi-step/index.tsx +7 -2
  24. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +22 -0
  25. package/app-template/src/views/checkout/variants/one-page/index.tsx +7 -4
  26. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +80 -18
  27. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +34 -22
  28. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +2 -0
  29. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +2 -0
  30. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +2 -0
  31. package/app-template/src/views/checkout/variants/registry.ts +13 -2
  32. package/app-template/src/views/checkout/variants/use-resolved-config.ts +77 -5
  33. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { useAppSelector } from '@akinon/next/redux/hooks';
2
2
  import { useLocalization } from '@akinon/next/hooks';
3
3
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
4
4
  import { Price, Link } from '@theme/components';
5
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
5
6
  import { Image } from '@akinon/next/components/image';
6
7
  import { StoreCredits } from '@theme/views/checkout/steps/payment/options/store-credit';
7
8
  import type { RootState } from '@theme/redux/store';
@@ -37,9 +38,18 @@ const OnePageSummary = ({
37
38
  />
38
39
  <StoreCredits />
39
40
 
40
- <section className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]">
41
- <header className="flex items-baseline justify-between px-6 py-5">
42
- <span className="text-lg font-semibold tracking-tight text-black-800">
41
+ <section
42
+ className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]"
43
+ {...partAttrs('summary')}
44
+ >
45
+ <header
46
+ className="flex items-baseline justify-between px-6 py-5"
47
+ {...partAttrs('summary-header')}
48
+ >
49
+ <span
50
+ className="text-lg font-semibold tracking-tight text-black-800"
51
+ {...partAttrs('summary-title')}
52
+ >
43
53
  {t('checkout.summary.title')}
44
54
  </span>
45
55
  <span className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
@@ -52,6 +62,7 @@ const OnePageSummary = ({
52
62
  <li
53
63
  key={`one-page-summary-item-${index}`}
54
64
  className="flex gap-3 px-6 py-4"
65
+ {...partAttrs('summary-item')}
55
66
  >
56
67
  <Link
57
68
  href={item.product.absolute_url || '#'}
@@ -100,30 +111,67 @@ const OnePageSummary = ({
100
111
  </ul>
101
112
 
102
113
  <dl className="space-y-2.5 border-t border-gray-100 px-6 py-5 text-sm text-gray-600">
103
- <div className="flex items-center justify-between">
104
- <dt>{t('checkout.summary.subtotal')}</dt>
105
- <dd className="font-medium tabular-nums text-black-800">
114
+ <div
115
+ className="flex items-center justify-between"
116
+ {...partAttrs('summary-row')}
117
+ >
118
+ <dt {...partAttrs('summary-row-label')}>
119
+ {t('checkout.summary.subtotal')}
120
+ </dt>
121
+ {/*
122
+ The amount declares its own weight and colour, so a rule on the
123
+ row can never reach it by inheritance — it gets its own part
124
+ instead of leaving the row's Text Color a dead control.
125
+ */}
126
+ <dd
127
+ className="font-medium tabular-nums text-black-800"
128
+ {...partAttrs('summary-row-value')}
129
+ >
106
130
  <Price value={preOrder.basket.total_amount} />
107
131
  </dd>
108
132
  </div>
109
- <div className="flex items-center justify-between">
110
- <dt>{t('checkout.summary.shipping')}</dt>
111
- <dd className="font-medium tabular-nums text-black-800">
133
+ <div
134
+ className="flex items-center justify-between"
135
+ {...partAttrs('summary-row')}
136
+ >
137
+ <dt {...partAttrs('summary-row-label')}>
138
+ {t('checkout.summary.shipping')}
139
+ </dt>
140
+ <dd
141
+ className="font-medium tabular-nums text-black-800"
142
+ {...partAttrs('summary-row-value')}
143
+ >
112
144
  <Price value={preOrder.shipping_amount} />
113
145
  </dd>
114
146
  </div>
115
147
  {parseFloat(preOrder.loyalty_money || '0') > 0 && (
116
- <div className="flex items-center justify-between">
117
- <dt>{t('checkout.summary.loyalty_money_total')}</dt>
118
- <dd className="font-medium tabular-nums text-black-800">
148
+ <div
149
+ className="flex items-center justify-between"
150
+ {...partAttrs('summary-row')}
151
+ >
152
+ <dt {...partAttrs('summary-row-label')}>
153
+ {t('checkout.summary.loyalty_money_total')}
154
+ </dt>
155
+ <dd
156
+ className="font-medium tabular-nums text-black-800"
157
+ {...partAttrs('summary-row-value')}
158
+ >
119
159
  <Price value={preOrder.loyalty_money} />
120
160
  </dd>
121
161
  </div>
122
162
  )}
123
163
  {parseFloat(preOrder.basket.total_discount_amount || '0') > 0 && (
124
- <div className="flex items-center justify-between">
125
- <dt>{t('checkout.summary.discounts_total')}</dt>
126
- <dd className="font-medium tabular-nums text-black-800">
164
+ <div
165
+ className="flex items-center justify-between"
166
+ {...partAttrs('summary-row')}
167
+ >
168
+ <dt {...partAttrs('summary-row-label')}>
169
+ {t('checkout.summary.discounts_total')}
170
+ </dt>
171
+ <dd
172
+ className="font-medium tabular-nums text-black-800"
173
+ {...partAttrs('summary-row-value')}
174
+ >
127
175
  <Price
128
176
  value={preOrder.basket.total_discount_amount}
129
177
  useNegative
@@ -133,11 +181,22 @@ const OnePageSummary = ({
133
181
  )}
134
182
  </dl>
135
183
 
136
- <div className="flex items-baseline justify-between border-t border-gray-100 px-6 py-5">
137
- <span className="text-sm font-medium uppercase tracking-wider text-gray-700">
184
+ {/*
185
+ The row carries the baseline typography that used to live on the
186
+ label span, so the row's own controls have something to change; the
187
+ amount keeps its distinct treatment and answers to its own part.
188
+ */}
189
+ <div
190
+ className="flex items-baseline justify-between border-t border-gray-100 px-6 py-5 text-sm font-medium uppercase tracking-wider text-gray-700"
191
+ {...partAttrs('summary-total')}
192
+ >
193
+ <span {...partAttrs('summary-total-label')}>
138
194
  {t('checkout.summary.total')}
139
195
  </span>
140
- <span className="text-2xl font-bold tracking-tight text-black-800 tabular-nums">
196
+ <span
197
+ className="text-2xl font-bold tracking-tight text-black-800 tabular-nums"
198
+ {...partAttrs('summary-total-value')}
199
+ >
141
200
  <Price value={preOrder.unpaid_amount} />
142
201
  </span>
143
202
  </div>
@@ -175,6 +234,7 @@ const OnePageSummary = ({
175
234
  onClick={() => onEditSection('address')}
176
235
  className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
177
236
  data-testid="one-page-summary-edit-address"
237
+ {...partAttrs('summary-edit')}
178
238
  >
179
239
  {t('checkout.one_page.edit')}
180
240
  </button>
@@ -201,6 +261,7 @@ const OnePageSummary = ({
201
261
  onClick={() => onEditSection('shipping')}
202
262
  className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
203
263
  data-testid="one-page-summary-edit-shipping"
264
+ {...partAttrs('summary-edit')}
204
265
  >
205
266
  {t('checkout.one_page.edit')}
206
267
  </button>
@@ -222,6 +283,7 @@ const OnePageSummary = ({
222
283
  onClick={() => onEditSection('payment')}
223
284
  className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
224
285
  data-testid="one-page-summary-edit-payment"
286
+ {...partAttrs('summary-edit')}
225
287
  >
226
288
  {t('checkout.one_page.edit')}
227
289
  </button>
@@ -4,6 +4,7 @@ import { useAppSelector } from '@akinon/next/redux/hooks';
4
4
  import { useSetPaymentOptionMutation } from '@akinon/next/data/client/checkout';
5
5
  import { usePaymentOptions } from '@akinon/next/hooks/use-payment-options';
6
6
  import { useLocalization } from '@akinon/next/hooks';
7
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
7
8
  import { Icon } from '@theme/components';
8
9
  import type { RootState } from '@theme/redux/store';
9
10
  import type { CheckoutPaymentOption } from '@akinon/next/types';
@@ -138,6 +139,7 @@ const PaymentOptionsGrid = () => {
138
139
  role="radiogroup"
139
140
  aria-label={t('checkout.one_page.payment.title')}
140
141
  onKeyDown={handleKeyDown}
142
+ {...partAttrs('payment-options')}
141
143
  >
142
144
  {visibleOptions.map((option, index) => {
143
145
  const selected = preOrder?.payment_option?.pk === option.pk;
@@ -152,45 +154,55 @@ const PaymentOptionsGrid = () => {
152
154
  className={clsx(
153
155
  'group relative flex min-h-[5rem] items-center gap-3',
154
156
  'rounded-xl border px-4 py-3 text-left',
157
+ // Typography and colour sit on the button — the element the
158
+ // payment-option part is stamped on — instead of on the label
159
+ // span. A part rule can only reach the label by inheritance, and
160
+ // a utility class declared on the span outranks an inherited
161
+ // value however !important the rule is, so every Text Color /
162
+ // Font Size / Font Weight control in the panel was a silent
163
+ // no-op while these lived one level down.
164
+ 'text-sm font-medium leading-tight',
155
165
  'transition-all duration-200 ease-out',
156
166
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-black-800 focus-visible:ring-offset-2',
157
167
  selected
158
- ? 'border-black-800 bg-black-800/[0.03] shadow-[inset_0_0_0_1px_rgb(31,31,31)]'
159
- : 'border-gray-200 bg-white hover:border-gray-400'
168
+ ? 'border-black-800 bg-black-800/[0.03] text-black-800 shadow-[inset_0_0_0_1px_rgb(31,31,31)]'
169
+ : 'border-gray-200 bg-white text-gray-700 hover:border-gray-400'
160
170
  )}
161
171
  data-testid={`one-page-payment-card-${option.pk}`}
162
172
  data-payment-type={option.payment_type}
173
+ {...partAttrs('payment-option', { selected })}
163
174
  >
175
+ {/*
176
+ `Icon` renders an icon FONT (<i class="pz-icon-…">), so the
177
+ `fill-*` utilities that used to sit here never coloured
178
+ anything — fill only applies to SVG. The glyph takes its colour
179
+ from `color`, which now comes from the button, so the icon
180
+ follows both the selected state and the panel's Text Color.
181
+ */}
164
182
  <Icon
165
183
  name={getOptionIcon(option)}
166
184
  size={20}
167
- className={clsx(
168
- 'flex-shrink-0 transition-colors',
169
- selected ? 'fill-black-800' : 'fill-gray-600'
170
- )}
185
+ className="flex-shrink-0 transition-colors"
171
186
  aria-hidden="true"
187
+ {...partAttrs('payment-option-icon')}
172
188
  />
189
+ <span className="flex-1">{option.name}</span>
190
+ {/*
191
+ A ring of `currentColor` with a transparent centre: the marker
192
+ follows whatever colour the composition sets on the button and
193
+ the middle shows the card's own background, so it stays legible
194
+ on any palette. The geometry is what the filled circle plus its
195
+ 6px inner dot drew before.
196
+ */}
173
197
  <span
174
198
  className={clsx(
175
- 'flex-1 text-sm font-medium leading-tight',
176
- selected ? 'text-black-800' : 'text-gray-700'
177
- )}
178
- >
179
- {option.name}
180
- </span>
181
- <span
182
- className={clsx(
183
- 'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 transition-all',
199
+ 'flex h-4 w-4 flex-shrink-0 rounded-full transition-all',
184
200
  selected
185
- ? 'border-black-800 bg-black-800'
186
- : 'border-gray-300 bg-white group-hover:border-gray-400'
201
+ ? 'border-[5px] border-current'
202
+ : 'border-2 border-gray-300 group-hover:border-gray-400'
187
203
  )}
188
204
  aria-hidden="true"
189
- >
190
- {selected && (
191
- <span className="block h-1.5 w-1.5 rounded-full bg-white" />
192
- )}
193
- </span>
205
+ />
194
206
  </button>
195
207
  );
196
208
  })}
@@ -4,6 +4,7 @@ import { useAppSelector } from '@akinon/next/redux/hooks';
4
4
  import { useSetAddressesMutation } from '@akinon/next/data/client/checkout';
5
5
  import { useAddAddressMutation } from '@akinon/next/data/client/address';
6
6
  import { Button, Checkbox, Icon, Modal, Radio } from '@theme/components';
7
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
7
8
  import { AddressForm } from '@theme/views/account/address-form';
8
9
  import { useLocalization } from '@akinon/next/hooks';
9
10
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
@@ -295,6 +296,7 @@ const AddressSection = ({
295
296
  }
296
297
  onClick={onComplete}
297
298
  data-testid="one-page-address-continue"
299
+ {...partAttrs('continue-button')}
298
300
  >
299
301
  {t('checkout.one_page.continue_to_shipping')}
300
302
  </Button>
@@ -2,6 +2,7 @@ import clsx from 'clsx';
2
2
  import { useAppSelector } from '@akinon/next/redux/hooks';
3
3
  import { useLocalization } from '@akinon/next/hooks';
4
4
  import SelectedPaymentOptionView from '@akinon/next/components/selected-payment-option-view';
5
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
5
6
  import type { RootState } from '@theme/redux/store';
6
7
  import AccordionSection, { SectionStatus } from '../accordion-section';
7
8
  import PaymentOptionsGrid from '../payment-options-grid';
@@ -51,6 +52,7 @@ const PaymentSection = ({ status, onEdit }: PaymentSectionProps) => {
51
52
  <div
52
53
  className="border-t border-gray-200"
53
54
  data-testid="one-page-selected-payment"
55
+ {...partAttrs('payment-panel')}
54
56
  >
55
57
  <SelectedPaymentOptionView />
56
58
  </div>
@@ -9,6 +9,7 @@ import {
9
9
  import { setSelectedShippingOptions } from '@akinon/next/redux/reducers/checkout';
10
10
  import { useLocalization } from '@akinon/next/hooks';
11
11
  import { Button, Price, Radio } from '@theme/components';
12
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
12
13
  import type { RootState } from '@theme/redux/store';
13
14
  import AccordionSection, { SectionStatus } from '../accordion-section';
14
15
  import { ShippingOptionsSkeleton } from '../skeletons/shipping-skeleton';
@@ -284,6 +285,7 @@ const ShippingSection = ({
284
285
  <Button
285
286
  onClick={onComplete}
286
287
  data-testid="one-page-shipping-continue"
288
+ {...partAttrs('continue-button')}
287
289
  >
288
290
  {t('checkout.one_page.continue_to_payment')}
289
291
  </Button>
@@ -23,10 +23,21 @@ export const UNIFIED_VARIANTS: ReadonlySet<string> = new Set([
23
23
  ]);
24
24
 
25
25
  export const getCheckoutVariant = (variant: CheckoutVariantId | undefined) => {
26
- const loader = variantLoaders[variant ?? 'multi-step'] ?? variantLoaders['multi-step'];
26
+ // Own-key lookup only (see `isKnownVariant`): an inherited member would
27
+ // resolve to a truthy non-loader and `dynamic()` would never render a flow.
28
+ const loader = isKnownVariant(variant)
29
+ ? variantLoaders[variant]
30
+ : variantLoaders['multi-step'];
27
31
 
28
32
  return dynamic(loader, { ssr: false });
29
33
  };
30
34
 
35
+ /**
36
+ * `in` also answers true for inherited Object.prototype members ('toString',
37
+ * 'constructor', '__proto__', …), which would let a saved setting name a
38
+ * "known" variant with no loader — `getCheckoutVariant` would then hand
39
+ * `dynamic()` a non-function and the flow would be lost. Own keys only.
40
+ */
31
41
  export const isKnownVariant = (value: unknown): value is CheckoutVariantId =>
32
- typeof value === 'string' && value in variantLoaders;
42
+ typeof value === 'string' &&
43
+ Object.prototype.hasOwnProperty.call(variantLoaders, value);
@@ -1,7 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState } from 'react';
3
+ import {
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useState,
8
+ useSyncExternalStore
9
+ } from 'react';
4
10
  import settings from '@theme/settings';
11
+ import {
12
+ getCheckoutFlowVariantOverride,
13
+ isThemeEditorDesigner,
14
+ noFlowVariantOnServer,
15
+ subscribeCheckoutPieces,
16
+ type CheckoutFlowVariantPreference
17
+ } from '@akinon/pz-theme/src/chrome/checkout-pieces';
5
18
  import { isKnownVariant } from './registry';
6
19
  import type { CheckoutVariantId, CheckoutVariantOptions } from './types';
7
20
 
@@ -17,6 +30,21 @@ interface RawCheckoutConfig {
17
30
 
18
31
  const FALLBACK_VARIANT: CheckoutVariantId = 'multi-step';
19
32
 
33
+ /**
34
+ * The only two layouts a composition may steer to, mapped to variant ids. A
35
+ * closed record on purpose: an unknown preference resolves to `undefined` here
36
+ * and the storefront's configured variant survives — and the unified
37
+ * basket-and-checkout family stays unreachable from a composition, so the
38
+ * basket-to-checkout redirect pair can never be broken by a page edit.
39
+ */
40
+ const COMPOSITION_VARIANT_MAP: Record<
41
+ CheckoutFlowVariantPreference,
42
+ CheckoutVariantId
43
+ > = {
44
+ 'one-page': 'one-page',
45
+ 'step-by-step': 'multi-step'
46
+ };
47
+
20
48
  const coerceOptions = (
21
49
  raw: Record<string, unknown> | undefined
22
50
  ): CheckoutVariantOptions => {
@@ -56,17 +84,30 @@ const mergeOverride = (
56
84
  };
57
85
 
58
86
  /**
59
- * Resolves checkout config from two layers (each optional):
87
+ * Resolves checkout config from three layers (each optional):
60
88
  *
61
89
  * 1. settings.js (sync, always available — baseline / fallback)
62
90
  * 2. /api/theme-settings (backend theme-config widget — persistent override)
91
+ * 3. the composition's checkout flow outlet (`flowVariant`) — highest
63
92
  *
64
93
  * Layer 2 falls back silently if backend is unreachable or no config is saved.
65
94
  * The editor is OPTIONAL — when no theme-config is saved, the storefront keeps
66
95
  * using its built-in settings.js.
96
+ *
97
+ * Layer 3 only ever names a LAYOUT, never options, and only through the
98
+ * closed `COMPOSITION_VARIANT_MAP`. It is read ONCE, in a layout effect, so a
99
+ * composed checkout resolves its variant before first paint (the outlet's own
100
+ * registration layout effect runs in an earlier sibling subtree of the same
101
+ * hydration commit) and never flips mid-session — a late registration, or the
102
+ * theme-settings response landing afterwards, can no longer swap the flow out
103
+ * from under a shopper. On the designer canvas the live value is followed
104
+ * instead, so a `Checkout Type` edit previews immediately.
105
+ *
106
+ * With no outlet registered, nothing overrides: the resolution is exactly the
107
+ * two-layer result the storefront had before the bridge existed.
67
108
  */
68
109
  export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
69
- const [config, setConfig] = useState<ResolvedCheckoutConfig>(readBaseFromSettings);
110
+ const [merged, setMerged] = useState<ResolvedCheckoutConfig>(readBaseFromSettings);
70
111
 
71
112
  useEffect(() => {
72
113
  let cancelled = false;
@@ -75,7 +116,7 @@ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
75
116
  .then((data: { checkout?: RawCheckoutConfig } | null) => {
76
117
  if (cancelled) return;
77
118
  if (!data?.checkout) return;
78
- setConfig(() => mergeOverride(readBaseFromSettings(), data.checkout));
119
+ setMerged(() => mergeOverride(readBaseFromSettings(), data.checkout));
79
120
  })
80
121
  .catch(() => {
81
122
  // Silent: backend not reachable or widget missing — keep settings.js baseline
@@ -85,5 +126,36 @@ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
85
126
  };
86
127
  }, []);
87
128
 
88
- return config;
129
+ const liveOverride = useSyncExternalStore(
130
+ subscribeCheckoutPieces,
131
+ getCheckoutFlowVariantOverride,
132
+ noFlowVariantOnServer
133
+ );
134
+
135
+ const [latchedOverride, setLatchedOverride] =
136
+ useState<CheckoutFlowVariantPreference | null>(null);
137
+
138
+ useLayoutEffect(() => {
139
+ setLatchedOverride(getCheckoutFlowVariantOverride());
140
+ }, []);
141
+
142
+ // The designer flag rides a postMessage and can arrive after mount; every
143
+ // claim change notifies the pieces store, which re-runs this render, so the
144
+ // canvas picks it up before an outlet edit could matter.
145
+ const composition = isThemeEditorDesigner() ? liveOverride : latchedOverride;
146
+ // Own-key lookup: an inherited member ('constructor', 'toString', …) would
147
+ // otherwise resolve to a truthy non-variant and lose the flow entirely.
148
+ const compositionVariant =
149
+ composition &&
150
+ Object.prototype.hasOwnProperty.call(COMPOSITION_VARIANT_MAP, composition)
151
+ ? COMPOSITION_VARIANT_MAP[composition]
152
+ : undefined;
153
+
154
+ return useMemo(
155
+ () =>
156
+ compositionVariant
157
+ ? { ...merged, variantId: compositionVariant }
158
+ : merged,
159
+ [merged, compositionVariant]
160
+ );
89
161
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/projectzero",
3
- "version": "2.0.43",
3
+ "version": "2.0.44",
4
4
  "private": false,
5
5
  "description": "CLI tool to manage your Project Zero Next project",
6
6
  "bin": {