@tuturuuu/ui 0.18.0 → 0.19.1

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 (68) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +76 -71
  3. package/src/components/ui/calendar-app/calendar-client-page.tsx +19 -1
  4. package/src/components/ui/calendar-app/calendar-page-shell.tsx +37 -16
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager-helpers.ts +70 -0
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +516 -578
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +1 -0
  8. package/src/components/ui/calendar-app/components/calendar-sync-settings-panel.tsx +155 -0
  9. package/src/components/ui/calendar-app/components/merge-provider-calendars.test.ts +46 -0
  10. package/src/components/ui/calendar-app/components/merge-provider-calendars.ts +81 -0
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +22 -129
  12. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +17 -3
  13. package/src/components/ui/chat/chat-agent-details-utils.tsx +2 -0
  14. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +17 -6
  15. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +35 -0
  16. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +17 -0
  17. package/src/components/ui/custom/nav-link.test.tsx +1 -0
  18. package/src/components/ui/custom/nav-link.tsx +4 -1
  19. package/src/components/ui/custom/settings-dialog-shell.tsx +23 -14
  20. package/src/components/ui/custom/statistic-card.tsx +3 -5
  21. package/src/components/ui/custom/structure.test.tsx +4 -0
  22. package/src/components/ui/custom/user-filters.tsx +4 -7
  23. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +1 -1
  24. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +1 -1
  25. package/src/components/ui/custom/workspace-select.tsx +38 -53
  26. package/src/components/ui/finance/analytics/category-spending-chart.test.tsx +3 -3
  27. package/src/components/ui/finance/budgets/budget-card.test.tsx +3 -3
  28. package/src/components/ui/finance/debts/debt-loan-card.test.tsx +3 -3
  29. package/src/components/ui/finance/debts/debt-loan-detail-cards.test.tsx +3 -3
  30. package/src/components/ui/finance/invoices/charts/invoice-totals-chart.test.tsx +3 -3
  31. package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
  32. package/src/components/ui/finance/invoices/components/invoice-customer-select-card.tsx +1 -1
  33. package/src/components/ui/finance/shared/numbers-visibility-toggle.test.tsx +1 -1
  34. package/src/components/ui/finance/tags/tag-manager.test.tsx +2 -2
  35. package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
  36. package/src/components/ui/finance/transactions/columns.test.tsx +2 -2
  37. package/src/components/ui/finance/transactions/confidential-field.test.tsx +2 -2
  38. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +2 -2
  39. package/src/components/ui/finance/transactions/transaction-statistics.test.tsx +3 -3
  40. package/src/components/ui/finance/wallets/form.test.tsx +3 -3
  41. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +10 -0
  42. package/src/components/ui/legacy/calendar/calendar-cell.tsx +1 -1
  43. package/src/components/ui/storefront/accent-button.tsx +4 -1
  44. package/src/components/ui/storefront/browse-panel.tsx +6 -1
  45. package/src/components/ui/storefront/cart-summary-parts.tsx +18 -4
  46. package/src/components/ui/storefront/cart-summary.tsx +22 -6
  47. package/src/components/ui/storefront/hero-panel.tsx +13 -17
  48. package/src/components/ui/storefront/index.ts +2 -0
  49. package/src/components/ui/storefront/listing-card.tsx +6 -4
  50. package/src/components/ui/storefront/product-detail.tsx +51 -21
  51. package/src/components/ui/storefront/product-dialog.tsx +8 -1
  52. package/src/components/ui/storefront/storefront-surface.test.tsx +79 -3
  53. package/src/components/ui/storefront/storefront-surface.tsx +151 -100
  54. package/src/components/ui/storefront/types.ts +3 -0
  55. package/src/components/ui/storefront/utils.ts +5 -1
  56. package/src/components/ui/text-editor/__tests__/task-mention-checkbox.test.ts +23 -6
  57. package/src/components/ui/text-editor/__tests__/upload-placeholder.test.ts +1 -10
  58. package/src/components/ui/text-editor/color-controls.tsx +1 -1
  59. package/src/globals.css +20 -0
  60. package/src/globals.test.ts +75 -0
  61. package/src/hooks/__tests__/use-calendar-sync.test.tsx +103 -1
  62. package/src/hooks/use-calendar-sync.tsx +51 -14
  63. package/src/lib/task-personal-external.ts +1 -1
  64. package/vendor/xlsx/LICENSE +201 -0
  65. package/vendor/xlsx/types/index.d.ts +1065 -0
  66. package/vendor/xlsx/xlsx.js +28103 -0
  67. package/vendor/xlsx/xlsx.mjs +28228 -0
  68. package/vendor/xlsx-0.20.3.tgz +0 -0
@@ -7,7 +7,7 @@ import type {
7
7
  InventoryStorefrontListing,
8
8
  } from '@tuturuuu/internal-api/inventory';
9
9
  import { cn } from '@tuturuuu/utils/format';
10
- import { type ReactNode, useState } from 'react';
10
+ import { type ReactNode, useMemo, useState } from 'react';
11
11
  import {
12
12
  Dialog,
13
13
  DialogContent,
@@ -29,6 +29,7 @@ import { StorefrontProductDialog } from './product-dialog';
29
29
  import type {
30
30
  StorefrontBuyerDefaults,
31
31
  StorefrontCartLine,
32
+ StorefrontLinkComponent,
32
33
  StorefrontSurfaceLabels,
33
34
  StorefrontSurfaceMode,
34
35
  } from './types';
@@ -59,6 +60,7 @@ export function StorefrontSurface({
59
60
  isDemo: _isDemo = false,
60
61
  isRedirecting = false,
61
62
  isSubmitting = false,
63
+ linkComponent,
62
64
  labels: labelOverrides,
63
65
  listings,
64
66
  mode,
@@ -75,6 +77,7 @@ export function StorefrontSurface({
75
77
  selectedListingId,
76
78
  storefront,
77
79
  storefrontHref,
80
+ withinSharedShell = false,
78
81
  }: {
79
82
  bundles?: InventoryBundle[];
80
83
  buyerDefaults?: StorefrontBuyerDefaults;
@@ -90,6 +93,7 @@ export function StorefrontSurface({
90
93
  isDemo?: boolean;
91
94
  isRedirecting?: boolean;
92
95
  isSubmitting?: boolean;
96
+ linkComponent?: StorefrontLinkComponent;
93
97
  labels?: Partial<StorefrontSurfaceLabels>;
94
98
  listings: InventoryStorefrontListing[];
95
99
  mode: StorefrontSurfaceMode;
@@ -110,77 +114,112 @@ export function StorefrontSurface({
110
114
  selectedListingId?: string;
111
115
  storefront: InventoryStorefront;
112
116
  storefrontHref?: string;
117
+ withinSharedShell?: boolean;
113
118
  }) {
114
119
  const labels = mergeStorefrontSurfaceLabels(labelOverrides);
120
+ const NavigationLink = linkComponent ?? 'a';
115
121
  const [isCartPopoverOpen, setIsCartPopoverOpen] = useState(false);
116
122
  const [bundleSelectionListingId, setBundleSelectionListingId] = useState<
117
123
  string | null
118
124
  >(null);
119
125
  const accentColor = sanitizeStorefrontAccentColor(storefront.accentColor);
120
126
  const radius = storefrontRadiusClasses[storefront.cornerStyle];
121
- const resolveVariant = (
122
- listing: InventoryStorefrontListing,
123
- variantId?: string | null
124
- ) =>
125
- variantId
126
- ? (listing.variants ?? []).find((variant) => variant.id === variantId)
127
- : undefined;
128
- const cartEntries = cartLines.flatMap((line) => {
129
- const listing = listings.find((item) => item.id === line.listingId);
130
- if (!listing) return [];
131
- return [
132
- {
133
- bundle: listing.bundleId
134
- ? bundles.find((bundle) => bundle.id === listing.bundleId)
135
- : undefined,
136
- line,
137
- listing,
138
- variant: resolveVariant(listing, line.variantId),
139
- },
140
- ];
141
- });
142
- const lineLimit = (entry: (typeof cartEntries)[number]) =>
143
- entry.variant
144
- ? getStorefrontVariantLimit(entry.listing, entry.variant)
145
- : getStorefrontListingLimit(entry.listing);
146
- const checkoutEntries = cartEntries.filter(
147
- (entry) => Math.min(entry.line.quantity, lineLimit(entry)) > 0
127
+ const listingsById = useMemo(
128
+ () => new Map(listings.map((listing) => [listing.id, listing])),
129
+ [listings]
130
+ );
131
+ const bundlesById = useMemo(
132
+ () => new Map(bundles.map((bundle) => [bundle.id, bundle])),
133
+ [bundles]
134
+ );
135
+ const cartEntries = useMemo(
136
+ () =>
137
+ cartLines.flatMap((line) => {
138
+ const listing = listingsById.get(line.listingId);
139
+ if (!listing) return [];
140
+ return [
141
+ {
142
+ bundle: listing.bundleId
143
+ ? bundlesById.get(listing.bundleId)
144
+ : undefined,
145
+ line,
146
+ listing,
147
+ variant: line.variantId
148
+ ? (listing.variants ?? []).find(
149
+ (variant) => variant.id === line.variantId
150
+ )
151
+ : undefined,
152
+ },
153
+ ];
154
+ }),
155
+ [bundlesById, cartLines, listingsById]
156
+ );
157
+ const checkoutEntries = useMemo(
158
+ () =>
159
+ cartEntries.filter((entry) => {
160
+ const limit = entry.variant
161
+ ? getStorefrontVariantLimit(entry.listing, entry.variant)
162
+ : getStorefrontListingLimit(entry.listing);
163
+ return Math.min(entry.line.quantity, limit) > 0;
164
+ }),
165
+ [cartEntries]
166
+ );
167
+ const total = useMemo(
168
+ () =>
169
+ checkoutEntries.reduce((sum, entry) => {
170
+ const limit = entry.variant
171
+ ? getStorefrontVariantLimit(entry.listing, entry.variant)
172
+ : getStorefrontListingLimit(entry.listing);
173
+ const quantity = Math.min(entry.line.quantity, limit);
174
+ return (
175
+ sum +
176
+ getStorefrontCartLineSubtotal({
177
+ bundle: entry.bundle,
178
+ line: { ...entry.line, quantity },
179
+ listing: entry.listing,
180
+ variant: entry.variant,
181
+ })
182
+ );
183
+ }, 0),
184
+ [checkoutEntries]
185
+ );
186
+ const cartQuantityByListing = useMemo(() => {
187
+ const quantities = new Map<string, number>();
188
+
189
+ for (const line of cartLines) {
190
+ quantities.set(
191
+ line.listingId,
192
+ (quantities.get(line.listingId) ?? 0) + line.quantity
193
+ );
194
+ }
195
+
196
+ return quantities;
197
+ }, [cartLines]);
198
+ const cartQuantity = useMemo(
199
+ () => cartLines.reduce((sum, line) => sum + line.quantity, 0),
200
+ [cartLines]
148
201
  );
149
- const total = checkoutEntries.reduce((sum, entry) => {
150
- const quantity = Math.min(entry.line.quantity, lineLimit(entry));
151
- return (
152
- sum +
153
- getStorefrontCartLineSubtotal({
154
- bundle: entry.bundle,
155
- line: { ...entry.line, quantity },
156
- listing: entry.listing,
157
- variant: entry.variant,
158
- })
159
- );
160
- }, 0);
161
- const cartQuantity = cartLines.reduce((sum, line) => sum + line.quantity, 0);
162
202
  const detailListing = detailListingId
163
- ? listings.find((listing) => listing.id === detailListingId)
203
+ ? listingsById.get(detailListingId)
164
204
  : undefined;
165
205
  const selectedListing = selectedListingId
166
- ? listings.find((listing) => listing.id === selectedListingId)
206
+ ? listingsById.get(selectedListingId)
167
207
  : undefined;
168
208
  const isProductDetail = mode === 'product' && Boolean(selectedListing);
169
- const visibleListings =
170
- mode === 'product' && selectedListingId
171
- ? listings.filter((listing) => listing.id === selectedListingId)
172
- : listings;
209
+ const visibleListings = useMemo(
210
+ () =>
211
+ mode === 'product' && selectedListing ? [selectedListing] : listings,
212
+ [listings, mode, selectedListing]
213
+ );
173
214
  const isCheckout = mode === 'checkout';
174
215
  const isCheckoutDialogOpen = checkoutOpen ?? isCheckout;
175
216
  const isPreview = mode === 'preview';
176
217
  const isCartPage = mode === 'cart';
177
218
  const bundleSelectionListing = bundleSelectionListingId
178
- ? listings.find((listing) => listing.id === bundleSelectionListingId)
219
+ ? listingsById.get(bundleSelectionListingId)
179
220
  : null;
180
221
  const bundleSelectionBundle = bundleSelectionListing?.bundleId
181
- ? (bundles.find(
182
- (bundle) => bundle.id === bundleSelectionListing.bundleId
183
- ) ?? null)
222
+ ? (bundlesById.get(bundleSelectionListing.bundleId) ?? null)
184
223
  : null;
185
224
  const currency = storefront.currency ?? 'USD';
186
225
  const handleCheckoutOpen = () => {
@@ -198,6 +237,7 @@ export function StorefrontSurface({
198
237
  isPreview={isPreview}
199
238
  isSubmitting={isSubmitting}
200
239
  labels={labels}
240
+ linkComponent={linkComponent}
201
241
  onCheckoutOpen={handleCheckoutOpen}
202
242
  onCheckoutSubmit={onCheckoutSubmit}
203
243
  onInstantCheckout={mode === 'cart' ? onInstantCheckout : undefined}
@@ -216,6 +256,7 @@ export function StorefrontSurface({
216
256
  isPreview={isPreview}
217
257
  isSubmitting={isSubmitting}
218
258
  labels={labels}
259
+ linkComponent={linkComponent}
219
260
  onCheckoutOpen={handleCheckoutOpen}
220
261
  onCheckoutSubmit={onCheckoutSubmit}
221
262
  radius={radius}
@@ -234,6 +275,7 @@ export function StorefrontSurface({
234
275
  isPreview={isPreview}
235
276
  isSubmitting={isSubmitting}
236
277
  labels={labels}
278
+ linkComponent={linkComponent}
237
279
  onCheckoutSubmit={onCheckoutSubmit}
238
280
  radius={radius}
239
281
  storefront={storefront}
@@ -244,7 +286,9 @@ export function StorefrontSurface({
244
286
  return (
245
287
  <main
246
288
  className={cn(
247
- 'min-h-dvh bg-background text-foreground',
289
+ withinSharedShell
290
+ ? 'min-h-[calc(100dvh-4.3125rem)] bg-background text-foreground'
291
+ : 'min-h-dvh bg-background text-foreground',
248
292
  storefrontThemeClasses[storefront.themePreset],
249
293
  className
250
294
  )}
@@ -256,51 +300,53 @@ export function StorefrontSurface({
256
300
  </div>
257
301
  ) : null}
258
302
 
259
- <header className="sticky top-0 z-30 border-border border-b bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/85">
260
- <div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-5 py-3 sm:px-6">
261
- <div className="min-w-0">
262
- <h1 className="truncate font-semibold text-base tracking-tight">
263
- {storefrontHref ? (
264
- <a
265
- className="flex min-w-0 items-center gap-3 rounded-sm transition hover:text-[var(--storefront-accent-text,var(--primary))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
266
- href={storefrontHref}
267
- title={storefront.name}
268
- >
269
- <span
270
- aria-hidden
271
- className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-muted/35 font-mono text-xs uppercase"
272
- >
273
- {storefront.name.slice(0, 1)}
274
- </span>
275
- <span className="truncate">{storefront.name}</span>
276
- </a>
277
- ) : (
278
- <span className="flex min-w-0 items-center gap-3">
279
- <span
280
- aria-hidden
281
- className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-muted/35 font-mono text-xs uppercase"
303
+ {withinSharedShell ? null : (
304
+ <header className="sticky top-0 z-30 border-border border-b bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/85">
305
+ <div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-5 py-3 sm:px-6">
306
+ <div className="min-w-0">
307
+ <h1 className="truncate font-semibold text-base tracking-tight">
308
+ {storefrontHref ? (
309
+ <NavigationLink
310
+ className="flex min-w-0 items-center gap-3 rounded-sm transition hover:text-[var(--storefront-accent-text,var(--primary))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
311
+ href={storefrontHref}
312
+ title={storefront.name}
282
313
  >
283
- {storefront.name.slice(0, 1)}
314
+ <span
315
+ aria-hidden
316
+ className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-muted/35 font-mono text-xs uppercase"
317
+ >
318
+ {storefront.name.slice(0, 1)}
319
+ </span>
320
+ <span className="truncate">{storefront.name}</span>
321
+ </NavigationLink>
322
+ ) : (
323
+ <span className="flex min-w-0 items-center gap-3">
324
+ <span
325
+ aria-hidden
326
+ className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-muted/35 font-mono text-xs uppercase"
327
+ >
328
+ {storefront.name.slice(0, 1)}
329
+ </span>
330
+ <span className="truncate">{storefront.name}</span>
284
331
  </span>
285
- <span className="truncate">{storefront.name}</span>
286
- </span>
287
- )}
288
- </h1>
332
+ )}
333
+ </h1>
334
+ </div>
335
+ <div className="flex items-center gap-2">
336
+ {headerActions}
337
+ <StorefrontCartPopover
338
+ cartQuantity={cartQuantity}
339
+ labels={labels}
340
+ onOpenChange={setIsCartPopoverOpen}
341
+ open={isCartPopoverOpen}
342
+ radius={radius}
343
+ >
344
+ {cartPopoverSummary}
345
+ </StorefrontCartPopover>
346
+ </div>
289
347
  </div>
290
- <div className="flex items-center gap-2">
291
- {headerActions}
292
- <StorefrontCartPopover
293
- cartQuantity={cartQuantity}
294
- labels={labels}
295
- onOpenChange={setIsCartPopoverOpen}
296
- open={isCartPopoverOpen}
297
- radius={radius}
298
- >
299
- {cartPopoverSummary}
300
- </StorefrontCartPopover>
301
- </div>
302
- </div>
303
- </header>
348
+ </header>
349
+ )}
304
350
 
305
351
  <section
306
352
  className={cn(
@@ -315,13 +361,13 @@ export function StorefrontSurface({
315
361
  {isProductDetail && selectedListing ? (
316
362
  <>
317
363
  {storefrontHref ? (
318
- <a
364
+ <NavigationLink
319
365
  className="mb-4 inline-flex items-center gap-1.5 text-muted-foreground text-sm transition hover:text-foreground"
320
366
  href={storefrontHref}
321
367
  >
322
368
  <ArrowLeft aria-hidden className="size-4" />
323
369
  {labels.browse}
324
- </a>
370
+ </NavigationLink>
325
371
  ) : null}
326
372
  <StorefrontProductDetail
327
373
  cartHref={cartHref}
@@ -330,6 +376,7 @@ export function StorefrontSurface({
330
376
  currency={currency}
331
377
  isSubmitting={isSubmitting}
332
378
  labels={labels}
379
+ linkComponent={linkComponent}
333
380
  listing={selectedListing}
334
381
  onBuyNow={onBuyNow}
335
382
  onDecrement={onDecrement}
@@ -370,15 +417,18 @@ export function StorefrontSurface({
370
417
  radius={radius}
371
418
  />
372
419
  }
420
+ flushTop={
421
+ !storefront.coverImageUrl &&
422
+ !storefront.heroImageUrl &&
423
+ (storefront.sections?.length ?? 0) === 0
424
+ }
373
425
  labels={labels}
374
426
  listings={visibleListings}
375
427
  renderListing={(listing) => {
376
428
  const listingBundle = listing.bundleId
377
- ? bundles.find((bundle) => bundle.id === listing.bundleId)
429
+ ? bundlesById.get(listing.bundleId)
378
430
  : undefined;
379
- const quantity = cartLines
380
- .filter((item) => item.listingId === listing.id)
381
- .reduce((sum, item) => sum + item.quantity, 0);
431
+ const quantity = cartQuantityByListing.get(listing.id) ?? 0;
382
432
 
383
433
  return (
384
434
  <StorefrontListingCard
@@ -422,6 +472,7 @@ export function StorefrontSurface({
422
472
  currency={currency}
423
473
  isSubmitting={isSubmitting}
424
474
  labels={labels}
475
+ linkComponent={linkComponent}
425
476
  listing={detailListing ?? null}
426
477
  onBuyNow={onBuyNow}
427
478
  onDecrement={onDecrement}
@@ -4,6 +4,9 @@ import type {
4
4
  InventoryListingVariant,
5
5
  InventoryStorefrontListing,
6
6
  } from '@tuturuuu/internal-api/inventory';
7
+ import type { ElementType } from 'react';
8
+
9
+ export type StorefrontLinkComponent = ElementType<{ href: string }>;
7
10
 
8
11
  export type StorefrontCartLine = {
9
12
  listingId: string;
@@ -187,7 +187,11 @@ export function getStorefrontBundleSelectionSubtotal(
187
187
  bundle: InventoryBundle | undefined,
188
188
  line: StorefrontCartLine
189
189
  ) {
190
- if (!bundle?.categoryComponents?.length || !line.bundleSelections) {
190
+ if (
191
+ !bundle?.categoryComponents?.length ||
192
+ !line.bundleSelections ||
193
+ bundle.pricingMode === 'fixed_price'
194
+ ) {
191
195
  return null;
192
196
  }
193
197
 
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
+ function resolveMentionChecked(
4
+ manualOverride: boolean | null,
5
+ nodeChecked: boolean | undefined,
6
+ allMentionedTasksCompleted: boolean
7
+ ) {
8
+ return manualOverride ?? nodeChecked ?? allMentionedTasksCompleted;
9
+ }
10
+
3
11
  // Mock the Supabase client
4
12
  vi.mock('@tuturuuu/supabase/next/client', () => ({
5
13
  createClient: () => ({
@@ -220,8 +228,11 @@ describe('Task Mention Detection', () => {
220
228
  const nodeChecked: boolean | undefined = false;
221
229
  const allMentionedTasksCompleted = false;
222
230
 
223
- const result =
224
- manualOverride ?? nodeChecked ?? allMentionedTasksCompleted;
231
+ const result = resolveMentionChecked(
232
+ manualOverride,
233
+ nodeChecked,
234
+ allMentionedTasksCompleted
235
+ );
225
236
 
226
237
  expect(result).toBe(true);
227
238
  });
@@ -231,8 +242,11 @@ describe('Task Mention Detection', () => {
231
242
  const nodeChecked: boolean | undefined = true;
232
243
  const allMentionedTasksCompleted = false;
233
244
 
234
- const result =
235
- manualOverride ?? nodeChecked ?? allMentionedTasksCompleted;
245
+ const result = resolveMentionChecked(
246
+ manualOverride,
247
+ nodeChecked,
248
+ allMentionedTasksCompleted
249
+ );
236
250
 
237
251
  expect(result).toBe(true);
238
252
  });
@@ -242,8 +256,11 @@ describe('Task Mention Detection', () => {
242
256
  const nodeChecked: boolean | undefined = undefined;
243
257
  const allMentionedTasksCompleted = true;
244
258
 
245
- const result =
246
- manualOverride ?? nodeChecked ?? allMentionedTasksCompleted;
259
+ const result = resolveMentionChecked(
260
+ manualOverride,
261
+ nodeChecked,
262
+ allMentionedTasksCompleted
263
+ );
247
264
 
248
265
  expect(result).toBe(true);
249
266
  });
@@ -1,5 +1,5 @@
1
1
  import { PluginKey } from '@tiptap/pm/state';
2
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import {
4
4
  createLoadingPlaceholder,
5
5
  findUploadPlaceholder,
@@ -68,19 +68,10 @@ describe('upload-placeholder', () => {
68
68
  });
69
69
 
70
70
  describe('createLoadingPlaceholder', () => {
71
- let styleElement: HTMLStyleElement | null;
72
-
73
71
  beforeEach(() => {
74
72
  // Clean up any existing style elements
75
73
  const existing = document.querySelector('#upload-spinner-styles');
76
74
  if (existing) existing.remove();
77
- styleElement = null;
78
- });
79
-
80
- afterEach(() => {
81
- if (styleElement) {
82
- styleElement.remove();
83
- }
84
75
  });
85
76
 
86
77
  it('should create placeholder for image', () => {
@@ -269,7 +269,7 @@ function SwatchButton({
269
269
  type="button"
270
270
  variant="ghost"
271
271
  >
272
- {swatchKind === 'text' ? (
272
+ {swatchKind === 'text' || swatchKind === 'highlight' ? (
273
273
  <TextIcon className="size-4" />
274
274
  ) : (
275
275
  <span
package/src/globals.css CHANGED
@@ -29,6 +29,26 @@
29
29
  }
30
30
  }
31
31
 
32
+ /*
33
+ * iOS Safari zooms the viewport when a focused editable control computes below
34
+ * 16px. Protect touch forms across every app that consumes the shared UI styles
35
+ * without disabling the user's normal pinch-to-zoom controls.
36
+ */
37
+ @supports (-webkit-touch-callout: none) {
38
+ @media (pointer: coarse) {
39
+ input:not([type="button"]):not([type="checkbox"]):not([type="color"]):not(
40
+ [type="file"]
41
+ ):not([type="hidden"]):not([type="image"]):not([type="radio"]):not(
42
+ [type="range"]
43
+ ):not([type="reset"]):not([type="submit"]),
44
+ select[class],
45
+ textarea[class],
46
+ [contenteditable="true"][class] {
47
+ font-size: 16px;
48
+ }
49
+ }
50
+ }
51
+
32
52
  @layer utilities {
33
53
  body {
34
54
  font-family: Arial, Helvetica, sans-serif;
@@ -0,0 +1,75 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const repoRoot = existsSync(resolve(process.cwd(), 'packages/ui/package.json'))
6
+ ? process.cwd()
7
+ : resolve(process.cwd(), '../..');
8
+
9
+ function readRepoFile(path: string) {
10
+ return readFileSync(resolve(repoRoot, path), 'utf8');
11
+ }
12
+
13
+ function sourceFiles(directory: string): string[] {
14
+ if (!existsSync(directory)) return [];
15
+
16
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
17
+ const path = resolve(directory, entry.name);
18
+ if (entry.isDirectory()) return sourceFiles(path);
19
+ return /\.(css|ts|tsx)$/u.test(entry.name) ? [path] : [];
20
+ });
21
+ }
22
+
23
+ describe('shared mobile form ergonomics', () => {
24
+ it('exports an iOS focus-zoom guard without disabling pinch zoom', () => {
25
+ const packageJson = JSON.parse(
26
+ readRepoFile('packages/ui/package.json')
27
+ ) as {
28
+ exports: Record<string, string>;
29
+ };
30
+ const styles = readRepoFile('packages/ui/src/globals.css');
31
+
32
+ expect(packageJson.exports['./globals.css']).toBe('./src/globals.css');
33
+ expect(styles).toContain('@supports (-webkit-touch-callout: none)');
34
+ expect(styles).toContain('@media (pointer: coarse)');
35
+ expect(styles).toContain('input:not([type="button"])');
36
+ expect(styles).toContain('select[class],');
37
+ expect(styles).toContain('textarea[class],');
38
+ expect(styles).toContain('[contenteditable="true"][class]');
39
+ expect(styles).toMatch(/font-size:\s*16px/u);
40
+ expect(styles).not.toMatch(/user-scalable|maximum-scale/iu);
41
+ });
42
+
43
+ it('reaches every web app that consumes the shared UI package', () => {
44
+ const appsDirectory = resolve(repoRoot, 'apps');
45
+ const appNames = readdirSync(appsDirectory, { withFileTypes: true })
46
+ .filter((entry) => entry.isDirectory())
47
+ .map((entry) => entry.name)
48
+ .filter((appName) => {
49
+ const manifestPath = resolve(appsDirectory, appName, 'package.json');
50
+ if (!existsSync(manifestPath)) return false;
51
+
52
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
53
+ dependencies?: Record<string, string>;
54
+ };
55
+ return Boolean(manifest.dependencies?.['@tuturuuu/ui']);
56
+ });
57
+
58
+ expect(appNames.length).toBeGreaterThan(0);
59
+
60
+ for (const appName of appNames) {
61
+ const appSources = sourceFiles(resolve(appsDirectory, appName, 'src'));
62
+ const importsSharedStyles = appSources.some((path) => {
63
+ const source = readFileSync(path, 'utf8');
64
+ return (
65
+ source.includes('@tuturuuu/ui/globals.css') ||
66
+ source.includes('@tuturuuu/tasks-ui/globals.css')
67
+ );
68
+ });
69
+
70
+ expect(importsSharedStyles, `${appName} must load shared UI styles`).toBe(
71
+ true
72
+ );
73
+ }
74
+ });
75
+ });