@xenide-io/the-old-ui-theme 0.9.18 → 0.11.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 (79) hide show
  1. package/dist/{Chip-BzuNgJIa.d.mts → Chip-Dw2g2t8D.d.mts} +18 -4
  2. package/dist/{Chip-BzuNgJIa.d.ts → Chip-Dw2g2t8D.d.ts} +18 -4
  3. package/dist/{chunk-6SVJAU5K.mjs → chunk-RIPGN6FG.mjs} +1333 -526
  4. package/dist/index.d.mts +3 -2
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +490 -119
  7. package/dist/index.mjs +11 -3
  8. package/dist/suite-analytics--Mf1RZbw.d.mts +29 -0
  9. package/dist/suite-analytics--Mf1RZbw.d.ts +29 -0
  10. package/dist/suite.d.mts +36 -4
  11. package/dist/suite.d.ts +36 -4
  12. package/dist/suite.js +4617 -4001
  13. package/dist/suite.mjs +1456 -868
  14. package/dist/tailwind-preset.js +10 -1
  15. package/dist/tailwind-preset.mjs +10 -1
  16. package/dist/ui.d.mts +33 -4
  17. package/dist/ui.d.ts +33 -4
  18. package/dist/ui.js +1364 -571
  19. package/dist/ui.mjs +15 -1
  20. package/docs/macos-identity-audit.md +279 -0
  21. package/package.json +1 -1
  22. package/src/app/globals.css +390 -44
  23. package/src/components/ui/Accordion.tsx +4 -7
  24. package/src/components/ui/Avatar.tsx +5 -5
  25. package/src/components/ui/BrandSpinner.tsx +256 -0
  26. package/src/components/ui/Calendar.tsx +7 -7
  27. package/src/components/ui/CodeBlock.tsx +14 -11
  28. package/src/components/ui/CollapsibleSection.tsx +4 -4
  29. package/src/components/ui/CommandPalette.tsx +7 -10
  30. package/src/components/ui/EmptyState.tsx +2 -2
  31. package/src/components/ui/Input.tsx +1 -1
  32. package/src/components/ui/Marketing.tsx +144 -0
  33. package/src/components/ui/MarketingIllustrations.tsx +287 -0
  34. package/src/components/ui/Panel.tsx +1 -1
  35. package/src/components/ui/SegmentedControl.tsx +59 -13
  36. package/src/components/ui/SettingsLayout.tsx +4 -0
  37. package/src/components/ui/ShowcaseWrapper.tsx +3 -3
  38. package/src/components/ui/Table.tsx +45 -10
  39. package/src/components/ui/Typography.tsx +4 -4
  40. package/src/components/ui/avatar-stat.test.tsx +14 -1
  41. package/src/components/ui/brand-spinner.test.tsx +62 -0
  42. package/src/components/ui/index.ts +19 -11
  43. package/src/components/ui/interaction-primitives.test.tsx +12 -6
  44. package/src/components/ui/marketing.test.tsx +39 -0
  45. package/src/components/ui/table.test.tsx +47 -0
  46. package/src/styles/suite-skin.css +471 -74
  47. package/src/styles/themes.css +45 -45
  48. package/src/styles/typography.css +25 -1
  49. package/src/suite/components/ai-draft-editor.tsx +184 -0
  50. package/src/suite/components/ai-panel-workflows.test.tsx +179 -0
  51. package/src/suite/components/ai-panel.tsx +201 -31
  52. package/src/suite/components/app-switcher.tsx +22 -9
  53. package/src/suite/components/command-palette-host.tsx +20 -2
  54. package/src/suite/components/suite-app-layout.tsx +7 -4
  55. package/src/suite/components/suite-bottom-nav.tsx +1 -1
  56. package/src/suite/components/suite-clients-projects-directory.tsx +6 -6
  57. package/src/suite/components/suite-integration-picker.tsx +7 -7
  58. package/src/suite/components/suite-integration-rules.tsx +5 -5
  59. package/src/suite/components/suite-layout.test.tsx +32 -3
  60. package/src/suite/components/suite-layout.tsx +4 -4
  61. package/src/suite/components/suite-mobile-drawer.tsx +2 -2
  62. package/src/suite/components/suite-mobile-header.tsx +1 -1
  63. package/src/suite/components/suite-notification-bell.tsx +27 -6
  64. package/src/suite/components/suite-notification-settings.tsx +182 -0
  65. package/src/suite/components/suite-palette-library.test.tsx +46 -0
  66. package/src/suite/components/suite-palette-library.tsx +82 -15
  67. package/src/suite/components/suite-settings-layout.tsx +2 -2
  68. package/src/suite/components/suite-settings-mobile-nav.tsx +10 -10
  69. package/src/suite/components/suite-sidebar.test.tsx +39 -0
  70. package/src/suite/components/suite-sidebar.tsx +41 -19
  71. package/src/suite/components/suite-skeleton.tsx +6 -6
  72. package/src/suite/components/suite-user-menu.tsx +2 -2
  73. package/src/suite/components/theme-provider.tsx +2 -0
  74. package/src/suite/components/today-calibrating.tsx +3 -3
  75. package/src/suite/components/today-ui.tsx +42 -37
  76. package/src/suite/components/workspace-switcher.tsx +3 -3
  77. package/src/suite/index.ts +14 -0
  78. package/src/suite/lib/suite-notifications.tsx +5 -0
  79. package/tailwind-preset.ts +9 -0
@@ -117,7 +117,7 @@ function DirectoryIconPicker({
117
117
  return (
118
118
  <div className="space-y-2">
119
119
  <span className="mb-1.5 block text-sm font-medium text-ph-ink">Icon</span>
120
- <div className="rounded-lg border border-ph-border bg-ph-muted p-3">
120
+ <div className="rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] p-3">
121
121
  <div className="flex items-center gap-3">
122
122
  <SuiteEntityIcon
123
123
  imageUrl={preview}
@@ -178,7 +178,7 @@ function DirectoryIcon({
178
178
  color={item.color}
179
179
  tinted
180
180
  className="h-8 w-8 text-sm"
181
- roundedClass="rounded-lg"
181
+ roundedClass="rounded-[var(--ph-radius-control)]"
182
182
  />
183
183
  );
184
184
  }
@@ -458,7 +458,7 @@ export function SuiteClientsProjectsDirectory({
458
458
  {loading ? (
459
459
  <p className="text-sm text-ph-mutedtext">Loading directory...</p>
460
460
  ) : (
461
- <div className="overflow-hidden rounded-xl border border-ph-border bg-ph-surface lg:grid lg:min-h-[22rem] lg:grid-cols-[minmax(14rem,18rem)_1fr]">
461
+ <div className="overflow-hidden rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface lg:grid lg:min-h-[22rem] lg:grid-cols-[minmax(14rem,18rem)_1fr]">
462
462
  <section
463
463
  className={cn(
464
464
  "border-ph-border lg:border-r",
@@ -515,7 +515,7 @@ export function SuiteClientsProjectsDirectory({
515
515
  clientButtonRefs.current[client.id] = element;
516
516
  }}
517
517
  className={cn(
518
- "flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left",
518
+ "flex w-full items-center gap-2.5 rounded-[var(--ph-radius-control)] px-2.5 py-2 text-left",
519
519
  selected ? "bg-ph-muted" : "hover:bg-ph-canvas",
520
520
  )}
521
521
  >
@@ -627,12 +627,12 @@ export function SuiteClientsProjectsDirectory({
627
627
  selectedProjects.map((project) => (
628
628
  <li key={project.id}>
629
629
  <div
630
- className="flex items-center gap-3 rounded-lg px-2 py-1 hover:bg-ph-canvas"
630
+ className="flex items-center gap-3 rounded-[var(--ph-radius-control)] px-2 py-1 hover:bg-ph-canvas"
631
631
  onClick={() => openEdit(project, "project")}
632
632
  >
633
633
  <button
634
634
  type="button"
635
- className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
635
+ className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--ph-radius-control)] py-1 text-left focus-visible:outline-none focus-visible:shadow-[var(--ph-focus-halo)]"
636
636
  onClick={() => openEdit(project, "project")}
637
637
  >
638
638
  <DirectoryIcon item={project} />
@@ -91,7 +91,7 @@ const DESCRIPTION_LIMIT = 90;
91
91
  function IntegrationSkeletonCard() {
92
92
  return (
93
93
  <div
94
- className="flex min-h-36 min-w-0 animate-pulse flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4"
94
+ className="flex min-h-36 min-w-0 animate-pulse flex-col justify-between rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface p-4"
95
95
  aria-hidden="true"
96
96
  >
97
97
  <div className="flex items-start gap-3">
@@ -608,7 +608,7 @@ export function SuiteIntegrationPicker({
608
608
  </button>
609
609
  {categoryMenuOpen ? (
610
610
  <div
611
- className="absolute z-20 mt-2 max-h-[min(22rem,calc(100dvh-12rem))] w-full max-w-full overflow-hidden rounded-lg border border-ph-border bg-ph-surface p-2 shadow-lg"
611
+ className="absolute z-20 mt-2 max-h-[min(22rem,calc(100dvh-12rem))] w-full max-w-full overflow-hidden rounded-[var(--ph-radius-overlay)] border border-[var(--ph-border-control)] bg-[var(--ph-surface-overlay)] p-2 shadow-[var(--ph-shadow-floating)]"
612
612
  >
613
613
  <Input
614
614
  ref={categorySearchRef}
@@ -643,7 +643,7 @@ export function SuiteIntegrationPicker({
643
643
  ref={(element) => {
644
644
  categoryOptionRefs.current[value] = element;
645
645
  }}
646
- className={`flex w-full min-w-0 items-center rounded-md px-3 py-2 text-left text-sm break-words ${activeCategory === value ? "bg-ph-brand text-[var(--ph-on-accent)]" : "text-ph-ink hover:bg-ph-muted"}`}
646
+ className={`flex w-full min-w-0 items-center rounded-[var(--ph-radius-control)] px-3 py-2 text-left text-sm break-words focus-visible:outline-none focus-visible:shadow-[var(--ph-focus-halo)] ${activeCategory === value ? "bg-[var(--ph-selected-bg)] text-[var(--ph-selected-text)] shadow-[inset_0_0_0_1px_var(--ph-selected-border)]" : "text-ph-ink hover:bg-[var(--ph-surface-hover)]"}`}
647
647
  onClick={() => selectCategory(value)}
648
648
  onKeyDown={(event) => {
649
649
  if (event.key === "Escape") {
@@ -692,7 +692,7 @@ export function SuiteIntegrationPicker({
692
692
  return (
693
693
  <div
694
694
  key={item.id}
695
- className="flex min-h-36 min-w-0 flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4"
695
+ className="flex min-h-36 min-w-0 flex-col justify-between rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface p-4"
696
696
  >
697
697
  <button
698
698
  type="button"
@@ -701,7 +701,7 @@ export function SuiteIntegrationPicker({
701
701
  longDescription &&
702
702
  setExpanded(isExpanded ? null : item.id)
703
703
  }
704
- aria-expanded={isExpanded}
704
+ aria-expanded={longDescription ? isExpanded : undefined}
705
705
  >
706
706
  <span className="flex items-start gap-3">
707
707
  <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-ph-muted text-ph-ink">
@@ -801,7 +801,7 @@ export function SuiteIntegrationPicker({
801
801
  {connectedItems.map((item) => (
802
802
  <div
803
803
  key={item.id}
804
- className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted p-3"
804
+ className="flex items-center justify-between gap-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] p-3"
805
805
  >
806
806
  <span className="flex min-w-0 items-center gap-2 text-sm text-ph-ink">
807
807
  {item.icon}
@@ -885,7 +885,7 @@ export function SuiteIntegrationPicker({
885
885
  <button
886
886
  key={scheme}
887
887
  type="button"
888
- className="flex w-full items-center justify-between rounded-lg border border-ph-border bg-ph-surface px-3 py-3 text-left text-sm text-ph-ink transition hover:border-ph-brand hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
888
+ className="flex w-full items-center justify-between rounded-[var(--ph-radius-control)] border border-ph-border bg-ph-surface px-3 py-3 text-left text-sm text-ph-ink transition-[background-color,border-color,box-shadow] hover:border-[var(--ph-selected-border)] hover:bg-[var(--ph-surface-hover)] focus-visible:outline-none focus-visible:shadow-[var(--ph-focus-halo)]"
889
889
  onClick={() => void prepareConnection(methodItem, scheme)}
890
890
  data-test={`${dataTest}-choose-${scheme.toLowerCase()}`}
891
891
  >
@@ -221,7 +221,7 @@ export function SuiteIntegrationRules({
221
221
  <p className="mt-4 text-sm text-ph-subtle">Loading rules...</p>
222
222
  ) : rules.length === 0 ? (
223
223
  <div
224
- className="mt-4 rounded-lg border border-dashed border-ph-border px-4 py-6 text-center"
224
+ className="mt-4 rounded-[var(--ph-radius-surface)] border border-dashed border-ph-border bg-[var(--ph-surface-inset)] px-4 py-6 text-center"
225
225
  data-test={`${dataTest}-empty`}
226
226
  >
227
227
  <p className="text-sm text-ph-subtle">
@@ -234,7 +234,7 @@ export function SuiteIntegrationRules({
234
234
  {rules.map((rule) => (
235
235
  <li
236
236
  key={rule.id}
237
- className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted px-3 py-3"
237
+ className="flex items-center justify-between gap-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] px-3 py-3"
238
238
  data-test={`${dataTest}-rule-${rule.id}`}
239
239
  >
240
240
  <div className="min-w-0">
@@ -282,7 +282,7 @@ export function SuiteIntegrationRules({
282
282
 
283
283
  {canManage && formOpen && onCreate ? (
284
284
  <form
285
- className="mt-4 space-y-3 rounded-lg border border-ph-border bg-ph-surface p-4"
285
+ className="mt-4 space-y-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-ph-surface p-4"
286
286
  onSubmit={(event) => void submitForm(event)}
287
287
  data-test={`${dataTest}-form`}
288
288
  >
@@ -435,7 +435,7 @@ export function SuiteIntegrationRules({
435
435
  </p>
436
436
  {events.length === 0 ? (
437
437
  <p
438
- className="mt-4 rounded-lg border border-dashed border-ph-border px-4 py-6 text-center text-sm text-ph-subtle"
438
+ className="mt-4 rounded-[var(--ph-radius-surface)] border border-dashed border-ph-border bg-[var(--ph-surface-inset)] px-4 py-6 text-center text-sm text-ph-subtle"
439
439
  data-test={`${dataTest}-events-empty`}
440
440
  >
441
441
  No integration events yet.
@@ -445,7 +445,7 @@ export function SuiteIntegrationRules({
445
445
  {events.map((event) => (
446
446
  <li
447
447
  key={event.id}
448
- className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted px-3 py-2"
448
+ className="flex items-center justify-between gap-3 rounded-[var(--ph-radius-surface)] border border-ph-border bg-[var(--ph-surface-inset)] px-3 py-2"
449
449
  >
450
450
  <div className="min-w-0">
451
451
  <p className="truncate text-sm text-ph-ink">
@@ -1,7 +1,11 @@
1
- import { cleanup, render, screen, within } from "@testing-library/react";
2
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
 
4
- import { sidebarColumnWidth } from "../lib/use-sidebar-width";
4
+ import {
5
+ SIDEBAR_MAX_WIDTH,
6
+ SIDEBAR_RAIL_WIDTH,
7
+ sidebarColumnWidth,
8
+ } from "../lib/use-sidebar-width";
5
9
  import { SuiteAppLayout } from "./suite-app-layout";
6
10
  import { SuitePageHeader } from "./suite-layout";
7
11
 
@@ -36,6 +40,31 @@ describe("sidebar resize handle", () => {
36
40
  expect(handle?.previousElementSibling).toHaveClass("isolate");
37
41
  expect(handle).toHaveClass("z-20");
38
42
  });
43
+
44
+ it("supports range-style keyboard minimum, maximum, and steps", () => {
45
+ const onResizeBy = vi.fn();
46
+ render(
47
+ <SuiteAppLayout
48
+ sidebarWidth={240}
49
+ onStartResize={() => {}}
50
+ onResizeBy={onResizeBy}
51
+ sidebar={<div>Sidebar</div>}
52
+ >
53
+ Page
54
+ </SuiteAppLayout>,
55
+ );
56
+
57
+ const handle = screen.getByRole("separator", { name: "Resize sidebar" });
58
+ fireEvent.keyDown(handle, { key: "Home" });
59
+ fireEvent.keyDown(handle, { key: "End" });
60
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
61
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
62
+
63
+ expect(onResizeBy).toHaveBeenNthCalledWith(1, SIDEBAR_RAIL_WIDTH - 240);
64
+ expect(onResizeBy).toHaveBeenNthCalledWith(2, SIDEBAR_MAX_WIDTH - 240);
65
+ expect(onResizeBy).toHaveBeenNthCalledWith(3, -16);
66
+ expect(onResizeBy).toHaveBeenNthCalledWith(4, 16);
67
+ });
39
68
  });
40
69
 
41
70
  describe("suite scroll ownership", () => {
@@ -192,7 +192,7 @@ export function SuitePageHeader({
192
192
  data-test={dataTest}
193
193
  data-stuck={sticky ? (stuck ? 'true' : 'false') : undefined}
194
194
  className={cn(
195
- 'flex min-w-0 shrink-0 flex-col items-start gap-3 pb-2 sm:flex-row sm:justify-between sm:gap-4 sm:pb-3',
195
+ 'suite-page-header flex min-w-0 shrink-0 flex-col items-start gap-3 pb-2 sm:flex-row sm:justify-between sm:gap-4 sm:pb-3',
196
196
  // No extra pt here — page inset sets the shared title baseline.
197
197
  sticky && 'suite-page-header-sticky',
198
198
  className,
@@ -213,7 +213,7 @@ export function SuitePageHeader({
213
213
  <div className="flex min-w-0 items-center gap-3">
214
214
  {resolvedIcon ? (
215
215
  <div
216
- className="hidden shrink-0 sm:flex sm:items-center [&_svg]:h-6 [&_svg]:w-6"
216
+ className="suite-page-header__icon hidden shrink-0 sm:flex sm:items-center [&_svg]:h-6 [&_svg]:w-6"
217
217
  aria-hidden="true"
218
218
  >
219
219
  {resolvedIcon}
@@ -222,14 +222,14 @@ export function SuitePageHeader({
222
222
  <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
223
223
  <h1
224
224
  className={cn(
225
- 'min-w-0 break-words font-display text-[1.375rem] font-semibold leading-tight tracking-tight text-ph-ink text-balance sm:text-2xl',
225
+ 'ph-page-title min-w-0 break-words font-display font-semibold text-ph-ink text-balance',
226
226
  titleClassName,
227
227
  )}
228
228
  >
229
229
  {title}
230
230
  </h1>
231
231
  {badge ? (
232
- <span className="rounded-md bg-ph-muted px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-ph-subtle">
232
+ <span className="rounded-[var(--ph-radius-control)] bg-ph-muted px-2 py-0.5 text-[length:var(--ph-type-overline)] font-semibold uppercase tracking-wide text-ph-subtle">
233
233
  {badge}
234
234
  </span>
235
235
  ) : null}
@@ -98,7 +98,7 @@ export function SuiteMobileDrawer({
98
98
  data-test={panelDataTest}
99
99
  aria-modal="true"
100
100
  className={cn(
101
- "fixed inset-y-0 left-0 z-50 flex h-dvh max-h-dvh w-[86%] max-w-72 flex-col overflow-hidden bg-ph-surface shadow-xl outline-none motion-safe:transition-transform motion-safe:ease-spring-fast lg:hidden",
101
+ "fixed inset-y-0 left-0 z-50 flex h-dvh max-h-dvh w-[86%] max-w-72 flex-col overflow-hidden border-r border-ph-border bg-ph-surface shadow-[var(--ph-shadow-floating)] outline-none motion-safe:transition-transform motion-safe:ease-spring-fast lg:hidden",
102
102
  side === "right" && "left-auto right-0",
103
103
  shown
104
104
  ? "translate-x-0"
@@ -127,7 +127,7 @@ export function SuiteMobileDrawer({
127
127
  type="button"
128
128
  id={closeButtonId}
129
129
  data-test={closeButtonDataTest}
130
- className="-mr-1.5 inline-flex h-11 w-11 items-center justify-center rounded-lg text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
130
+ className="ph-btn ph-btn-ghost ph-btn-square -mr-1.5 h-11 w-11 text-ph-mutedtext"
131
131
  aria-label={closeLabel}
132
132
  >
133
133
  <X className="h-4 w-4" aria-hidden />
@@ -54,7 +54,7 @@ export function SuiteMobileHeader({
54
54
  type="button"
55
55
  id={menuId}
56
56
  data-test={menuDataTest}
57
- className="suite-press inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-ph-ink transition-colors hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
57
+ className="ph-btn ph-btn-ghost ph-btn-square suite-press h-11 w-11 shrink-0 text-ph-ink"
58
58
  onClick={onMenuClick}
59
59
  aria-label={menuLabel}
60
60
  >
@@ -3,6 +3,7 @@
3
3
  import { useState } from "react";
4
4
  import { Bell } from "iconoir-react";
5
5
 
6
+ import { trackSuiteEvent } from "../../lib/suite-analytics";
6
7
  import type { SuiteDropdownMenuComponent } from "../lib/injected";
7
8
  import { useSuiteNotifications } from "../lib/suite-notifications";
8
9
 
@@ -50,7 +51,14 @@ export function SuiteNotificationBell({
50
51
 
51
52
  const onOpenChange = (next: boolean) => {
52
53
  setOpen(next);
53
- if (next) refresh();
54
+ if (next) {
55
+ trackSuiteEvent("UI Opened", {
56
+ view: "notification_bell",
57
+ kind: "notifications",
58
+ unread_count: unread,
59
+ });
60
+ refresh();
61
+ }
54
62
  };
55
63
 
56
64
  return (
@@ -65,7 +73,7 @@ export function SuiteNotificationBell({
65
73
  open={open}
66
74
  onOpenChange={onOpenChange}
67
75
  trigger={
68
- <span className="relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
76
+ <span className="relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink">
69
77
  <span className="relative inline-flex h-5 w-5">
70
78
  <Bell className="h-5 w-5" strokeWidth={1.75} aria-hidden />
71
79
  {unread > 0 ? (
@@ -84,8 +92,14 @@ export function SuiteNotificationBell({
84
92
  type="button"
85
93
  id={`${dataTest}-mark-all`}
86
94
  data-test={`${dataTest}-mark-all`}
87
- onClick={markAllRead}
88
- className="text-xs font-medium text-ph-brand hover:underline"
95
+ onClick={() => {
96
+ trackSuiteEvent("Notification Read", {
97
+ operation: "all",
98
+ count: unread,
99
+ });
100
+ markAllRead();
101
+ }}
102
+ className="ph-btn ph-btn-link ph-btn-xs text-xs"
89
103
  >
90
104
  Mark all read
91
105
  </button>
@@ -104,9 +118,16 @@ export function SuiteNotificationBell({
104
118
  data-test={`${dataTest}-item-${n.id}`}
105
119
  onClick={() => {
106
120
  setOpen(false);
121
+ trackSuiteEvent("Notification Read", {
122
+ operation: "read",
123
+ notification_id: n.id,
124
+ kind: n.kind,
125
+ source_app: n.source_app,
126
+ unread: !n.read_at,
127
+ });
107
128
  select(n);
108
129
  }}
109
- className="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition hover:bg-ph-muted"
130
+ className="ph-dropdown-item flex w-full flex-col items-stretch gap-0.5 px-3 py-2 text-left"
110
131
  >
111
132
  <span className="flex items-center gap-2">
112
133
  {!n.read_at ? (
@@ -124,7 +145,7 @@ export function SuiteNotificationBell({
124
145
  {n.body}
125
146
  </span>
126
147
  ) : null}
127
- <span className="text-[11px] uppercase tracking-wide text-ph-subtle">
148
+ <span className="text-xs text-ph-subtle">
128
149
  {n.source_app} · {timeAgo(n.created_at)}
129
150
  </span>
130
151
  </button>
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { Alert, Badge, Button, Card } from "@/components/ui";
5
+ import { trackSuiteEvent } from "../../lib/suite-analytics";
6
+
7
+ export type SuiteWebPushState =
8
+ | "unsupported"
9
+ | "unconfigured"
10
+ | "default"
11
+ | "granted"
12
+ | "denied"
13
+ | "subscribed";
14
+
15
+ /** Structural contract matching `createWebPushClient` from @xenide-io/suite-client. */
16
+ export interface SuiteWebPushController {
17
+ supported(): boolean;
18
+ getState(): Promise<SuiteWebPushState>;
19
+ enable(): Promise<void>;
20
+ disable(): Promise<void>;
21
+ }
22
+
23
+ export interface SuiteNotificationSettingsProps {
24
+ controller: SuiteWebPushController;
25
+ title?: string;
26
+ description?: string;
27
+ dataTest?: string;
28
+ className?: string;
29
+ }
30
+
31
+ const STATUS_COPY: Record<SuiteWebPushState, string> = {
32
+ unsupported: "This browser does not support browser notifications.",
33
+ unconfigured: "Browser notifications are not configured on the server yet.",
34
+ default: "Get notified on this device when a suite notification arrives.",
35
+ granted: "Permission granted — turn on browser notifications for this device.",
36
+ denied:
37
+ "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.",
38
+ subscribed: "This device receives browser notifications.",
39
+ };
40
+
41
+ /**
42
+ * Settings card that lets a user enable or disable browser notifications for
43
+ * this device. State and delivery live in the app's suite-client web push
44
+ * controller; this component is presentational.
45
+ */
46
+ export function SuiteNotificationSettings({
47
+ controller,
48
+ title = "Notifications",
49
+ description = "Get suite notifications — tasks, documents and AI runs — as OS notifications on this device, even when the app is closed.",
50
+ dataTest = "suite-notification-settings",
51
+ className,
52
+ }: SuiteNotificationSettingsProps) {
53
+ const [state, setState] = useState<SuiteWebPushState | null>(null);
54
+ const [busy, setBusy] = useState(false);
55
+ const [error, setError] = useState("");
56
+
57
+ const refresh = useCallback(async () => {
58
+ setState(await controller.getState());
59
+ }, [controller]);
60
+
61
+ useEffect(() => {
62
+ let active = true;
63
+ controller.getState().then((next) => {
64
+ if (active) setState(next);
65
+ });
66
+ return () => {
67
+ active = false;
68
+ };
69
+ }, [controller]);
70
+
71
+ const handleEnable = async () => {
72
+ setBusy(true);
73
+ setError("");
74
+ try {
75
+ await controller.enable();
76
+ await refresh();
77
+ trackSuiteEvent("Push Notifications Configured", {
78
+ enabled: true,
79
+ permission:
80
+ typeof Notification !== "undefined" ? Notification.permission : "unknown",
81
+ });
82
+ } catch (err) {
83
+ setError(
84
+ err instanceof Error ? err.message : "Could not enable notifications.",
85
+ );
86
+ await refresh();
87
+ } finally {
88
+ setBusy(false);
89
+ }
90
+ };
91
+
92
+ const handleDisable = async () => {
93
+ setBusy(true);
94
+ setError("");
95
+ try {
96
+ await controller.disable();
97
+ await refresh();
98
+ trackSuiteEvent("Push Notifications Configured", {
99
+ enabled: false,
100
+ permission:
101
+ typeof Notification !== "undefined" ? Notification.permission : "unknown",
102
+ });
103
+ } catch (err) {
104
+ setError(
105
+ err instanceof Error ? err.message : "Could not disable notifications.",
106
+ );
107
+ } finally {
108
+ setBusy(false);
109
+ }
110
+ };
111
+
112
+ const subscribed = state === "subscribed";
113
+ const canEnable = state === "default" || state === "granted";
114
+
115
+ return (
116
+ <div className={className}>
117
+ <div>
118
+ <h2 className="text-xl font-semibold text-ph-ink">{title}</h2>
119
+ <p className="mt-1 text-sm text-ph-subtle">{description}</p>
120
+ </div>
121
+
122
+ {error ? (
123
+ <Alert
124
+ id={`${dataTest}-error`}
125
+ data-test={`${dataTest}-error`}
126
+ className="mt-4"
127
+ status="danger"
128
+ title="Notifications unavailable"
129
+ description={error}
130
+ live="assertive"
131
+ />
132
+ ) : null}
133
+
134
+ <Card
135
+ id={`${dataTest}-card`}
136
+ data-test={`${dataTest}-card`}
137
+ variant="outlined"
138
+ bodyClassName="p-5"
139
+ className="mt-4"
140
+ >
141
+ <div className="flex flex-wrap items-center justify-between gap-3">
142
+ <div className="flex items-center gap-2">
143
+ <p className="text-sm font-medium text-ph-ink">
144
+ Browser notifications
145
+ </p>
146
+ {subscribed ? <Badge variant="success">On</Badge> : null}
147
+ </div>
148
+
149
+ {controller.supported() && canEnable ? (
150
+ <Button
151
+ id={`${dataTest}-enable`}
152
+ data-test={`${dataTest}-enable`}
153
+ variant="primary"
154
+ size="sm"
155
+ disabled={busy}
156
+ onClick={handleEnable}
157
+ >
158
+ {busy ? "Enabling…" : "Enable on this device"}
159
+ </Button>
160
+ ) : null}
161
+
162
+ {subscribed ? (
163
+ <Button
164
+ id={`${dataTest}-disable`}
165
+ data-test={`${dataTest}-disable`}
166
+ variant="secondary"
167
+ size="sm"
168
+ disabled={busy}
169
+ onClick={handleDisable}
170
+ >
171
+ {busy ? "Disabling…" : "Disable"}
172
+ </Button>
173
+ ) : null}
174
+ </div>
175
+
176
+ <p className="mt-3 text-sm text-ph-subtle">
177
+ {state === null ? "Checking this device…" : STATUS_COPY[state]}
178
+ </p>
179
+ </Card>
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,46 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { SuitePaletteLibrary } from "./suite-palette-library";
6
+
7
+ vi.mock("@/suite/lib/palette-api", () => ({
8
+ fetchPaletteLibrary: vi.fn().mockResolvedValue({
9
+ collections: [
10
+ {
11
+ slug: "office",
12
+ name: "Office",
13
+ style: "line",
14
+ featured: true,
15
+ count: 1,
16
+ preview: ["/office.svg"],
17
+ },
18
+ ],
19
+ }),
20
+ fetchPaletteCollection: vi.fn(),
21
+ findPaletteIcons: vi.fn(),
22
+ }));
23
+
24
+ afterEach(cleanup);
25
+
26
+ describe("SuitePaletteLibrary", () => {
27
+ it("moves from search into options and restores trigger focus on Escape", async () => {
28
+ const user = userEvent.setup();
29
+ render(<SuitePaletteLibrary onPick={vi.fn()} />);
30
+
31
+ const trigger = screen.getByRole("button", { name: "All libraries" });
32
+ await user.click(trigger);
33
+ await waitFor(() => expect(screen.getByText("Office")).toBeInTheDocument());
34
+
35
+ const search = screen.getByRole("searchbox", { name: "Search libraries" });
36
+ expect(search).toHaveFocus();
37
+ await user.keyboard("{ArrowDown}");
38
+ expect(screen.getByRole("option", { name: "All libraries" })).toHaveFocus();
39
+ await user.keyboard("{ArrowDown}");
40
+ expect(screen.getByRole("option", { name: /Office/ })).toHaveFocus();
41
+ await user.keyboard("{Escape}");
42
+
43
+ await waitFor(() => expect(trigger).toHaveFocus());
44
+ expect(screen.queryByRole("listbox", { name: "Libraries" })).not.toBeInTheDocument();
45
+ });
46
+ });