@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.9

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 (119) hide show
  1. package/package.json +89 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +57 -3
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/collectionImagePaste.ts +19 -1
  14. package/src/bridge/context.mjs +32 -0
  15. package/src/bridge/credentials.d.mts +62 -0
  16. package/src/bridge/credentials.mjs +53 -0
  17. package/src/bridge/documents.d.mts +76 -0
  18. package/src/bridge/documents.mjs +40 -0
  19. package/src/bridge/events.d.mts +1 -0
  20. package/src/bridge/events.mjs +1 -0
  21. package/src/bridge/googleAuth.ts +7 -152
  22. package/src/bridge/mcpClient.d.mts +74 -0
  23. package/src/bridge/mcpClient.mjs +79 -0
  24. package/src/bridge/methods.d.mts +45 -0
  25. package/src/bridge/methods.mjs +46 -0
  26. package/src/bridge/notifications.d.mts +12 -0
  27. package/src/bridge/notifications.mjs +36 -0
  28. package/src/bridge/operations.d.mts +44 -0
  29. package/src/bridge/operations.mjs +40 -0
  30. package/src/bridge/pureRender/base.css +44 -0
  31. package/src/bridge/pureRender/document.ts +29 -2
  32. package/src/bridge/pureRender/index.ts +3 -0
  33. package/src/bridge/pureRender/normalize.ts +167 -0
  34. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  35. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  36. package/src/bridge/pureRender/styles.ts +125 -5
  37. package/src/bridge/pureRender/theme.test.ts +259 -0
  38. package/src/bridge/pureRender/theme.ts +19 -1
  39. package/src/bridge/pureRender/types.ts +33 -0
  40. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  41. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  42. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  43. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  44. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  45. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  46. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  47. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  48. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  49. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  50. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  51. package/src/bridge/storage.d.mts +23 -10
  52. package/src/bridge/storage.mjs +6 -3
  53. package/src/bridge/storage.test.ts +6 -6
  54. package/src/bridge/types.ts +7 -0
  55. package/src/components/agents/AgentComposer.tsx +10 -6
  56. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  57. package/src/components/agents/AgentDrawerPanel.tsx +23 -62
  58. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  59. package/src/components/agents/AgentMessageList.tsx +4 -4
  60. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  61. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  62. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  63. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  64. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  65. package/src/components/agents/ContextPicker.tsx +239 -0
  66. package/src/components/agents/QuestionRequestList.tsx +278 -0
  67. package/src/components/agents/agentPanelStyles.ts +8 -31
  68. package/src/components/agents/agentTypes.ts +25 -41
  69. package/src/components/agents/index.ts +4 -0
  70. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  71. package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
  72. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  73. package/src/components/common/chat/ChatBox.tsx +9 -9
  74. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  75. package/src/components/common/chat/ChatThread.tsx +23 -7
  76. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  77. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  78. package/src/components/common/connections/index.ts +5 -0
  79. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  80. package/src/components/common/containers/AppFrame.tsx +26 -10
  81. package/src/components/common/containers/AppHeader.tsx +37 -15
  82. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  83. package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
  84. package/src/components/common/documents/index.ts +8 -0
  85. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  86. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  87. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  88. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  89. package/src/components/common/overlays/Modal.tsx +13 -2
  90. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  91. package/src/components/common/research/index.ts +2 -0
  92. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  93. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  94. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  95. package/src/components/print-design/index.ts +2 -0
  96. package/src/components/print-design/previewContent.test.ts +32 -0
  97. package/src/components/print-design/previewContent.ts +31 -0
  98. package/src/context/buildContextDocument.test.ts +240 -0
  99. package/src/context/buildContextDocument.ts +309 -0
  100. package/src/context/contextAccess.ts +66 -0
  101. package/src/context/contextConfig.ts +72 -0
  102. package/src/context/contextDocument.test.ts +155 -0
  103. package/src/context/contextDocument.ts +300 -0
  104. package/src/context/contextSections.test.ts +88 -0
  105. package/src/context/contextSections.ts +84 -0
  106. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  107. package/src/context/htmlToContextMarkdown.ts +369 -0
  108. package/src/ics/generateIcs.test.ts +153 -0
  109. package/src/ics/generateIcs.ts +197 -0
  110. package/src/index.ts +3 -0
  111. package/src/net/httpRetry.test.ts +117 -0
  112. package/src/net/httpRetry.ts +111 -0
  113. package/src/theme/appAccents.test.ts +36 -35
  114. package/src/theme/appAccents.ts +93 -104
  115. package/src/theme/appIdentityCss.mjs +178 -229
  116. package/src/theme/appIdentityCss.ts +222 -231
  117. package/src/theme/themes/dark.ts +3 -1
  118. package/src/theme/themes/light.ts +3 -1
  119. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Minimal iCalendar (RFC 5545) writer for the mail↔calendar invite loop:
3
+ * `REQUEST` (send/update an invite), `REPLY` (RSVP), `CANCEL`.
4
+ *
5
+ * All date-times are emitted in UTC (`...Z`) so no VTIMEZONE component is
6
+ * needed; all-day events use DATE values. Output round-trips through the
7
+ * ICAL.js parser used by PureMail's invite reader.
8
+ */
9
+
10
+ export type IcsMethod = 'REQUEST' | 'REPLY' | 'CANCEL'
11
+
12
+ export type IcsPartstat =
13
+ | 'ACCEPTED'
14
+ | 'DECLINED'
15
+ | 'TENTATIVE'
16
+ | 'NEEDS-ACTION'
17
+
18
+ export interface IcsPerson {
19
+ email: string
20
+ name?: string
21
+ partstat?: IcsPartstat
22
+ /** RSVP requested from this attendee (REQUEST invites). */
23
+ rsvp?: boolean
24
+ }
25
+
26
+ export interface IcsEventInput {
27
+ uid: string
28
+ /**
29
+ * iTIP method. Omit for a plain (non-iTIP) VCALENDAR — e.g. "Download .ics"
30
+ * of an event to share. When undefined, no `METHOD:` line is emitted.
31
+ */
32
+ method?: IcsMethod
33
+ /** Bumped by the organizer on every update; echoed in replies. */
34
+ sequence?: number
35
+ /** ISO timestamp for DTSTAMP — pass explicitly so output is deterministic. */
36
+ timestamp: string
37
+ title: string
38
+ description?: string
39
+ location?: string
40
+ /** ISO date-time (or YYYY-MM-DD when allDay). */
41
+ startsAt: string
42
+ endsAt: string
43
+ allDay?: boolean
44
+ organizer?: IcsPerson
45
+ attendees?: IcsPerson[]
46
+ status?: 'CONFIRMED' | 'CANCELLED' | 'TENTATIVE'
47
+ /** Full RRULE value, e.g. `FREQ=WEEKLY;INTERVAL=1` (no `RRULE:` prefix). */
48
+ rrule?: string
49
+ }
50
+
51
+ /** RFC 5545 §3.3.11 TEXT escaping. */
52
+ export function escapeIcsText(value: string): string {
53
+ return value
54
+ .replace(/\\/g, '\\\\')
55
+ .replace(/;/g, '\\;')
56
+ .replace(/,/g, '\\,')
57
+ .replace(/\r?\n/g, '\\n')
58
+ }
59
+
60
+ /** RFC 5545 §3.1 line folding at 75 octets (approximated per char). */
61
+ export function foldIcsLine(line: string): string {
62
+ if (line.length <= 75) return line
63
+ const parts: string[] = []
64
+ let rest = line
65
+ parts.push(rest.slice(0, 75))
66
+ rest = rest.slice(75)
67
+ while (rest.length > 74) {
68
+ parts.push(` ${rest.slice(0, 74)}`)
69
+ rest = rest.slice(74)
70
+ }
71
+ if (rest) parts.push(` ${rest}`)
72
+ return parts.join('\r\n')
73
+ }
74
+
75
+ function utcBasic(iso: string): string {
76
+ const date = new Date(iso)
77
+ if (Number.isNaN(date.getTime())) {
78
+ throw new Error(`Invalid ICS date-time: ${iso}`)
79
+ }
80
+ return date
81
+ .toISOString()
82
+ .replace(/[-:]/g, '')
83
+ .replace(/\.\d{3}Z$/, 'Z')
84
+ }
85
+
86
+ function dateBasic(iso: string): string {
87
+ const day = iso.slice(0, 10)
88
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
89
+ throw new Error(`Invalid ICS date: ${iso}`)
90
+ }
91
+ return day.replace(/-/g, '')
92
+ }
93
+
94
+ function personProperty(
95
+ property: 'ORGANIZER' | 'ATTENDEE',
96
+ person: IcsPerson,
97
+ ): string {
98
+ const params: string[] = []
99
+ if (person.name) params.push(`CN=${escapeIcsText(person.name)}`)
100
+ if (property === 'ATTENDEE') {
101
+ if (person.partstat) params.push(`PARTSTAT=${person.partstat}`)
102
+ if (person.rsvp) params.push('RSVP=TRUE')
103
+ }
104
+ const paramText = params.length ? `;${params.join(';')}` : ''
105
+ return `${property}${paramText}:mailto:${person.email}`
106
+ }
107
+
108
+ export function generateIcsCalendar(input: IcsEventInput): string {
109
+ const lines: string[] = [
110
+ 'BEGIN:VCALENDAR',
111
+ 'PRODID:-//PureScience//PureDesktop//EN',
112
+ 'VERSION:2.0',
113
+ ...(input.method ? [`METHOD:${input.method}`] : []),
114
+ 'BEGIN:VEVENT',
115
+ `UID:${escapeIcsText(input.uid)}`,
116
+ `DTSTAMP:${utcBasic(input.timestamp)}`,
117
+ `SEQUENCE:${input.sequence ?? 0}`,
118
+ input.allDay
119
+ ? `DTSTART;VALUE=DATE:${dateBasic(input.startsAt)}`
120
+ : `DTSTART:${utcBasic(input.startsAt)}`,
121
+ input.allDay
122
+ ? `DTEND;VALUE=DATE:${dateBasic(input.endsAt)}`
123
+ : `DTEND:${utcBasic(input.endsAt)}`,
124
+ `SUMMARY:${escapeIcsText(input.title)}`,
125
+ ]
126
+ if (input.description) {
127
+ lines.push(`DESCRIPTION:${escapeIcsText(input.description)}`)
128
+ }
129
+ if (input.location) {
130
+ lines.push(`LOCATION:${escapeIcsText(input.location)}`)
131
+ }
132
+ if (input.status ?? input.method === 'CANCEL') {
133
+ lines.push(`STATUS:${input.status ?? 'CANCELLED'}`)
134
+ }
135
+ if (input.rrule) lines.push(`RRULE:${input.rrule}`)
136
+ if (input.organizer) {
137
+ lines.push(personProperty('ORGANIZER', input.organizer))
138
+ }
139
+ for (const attendee of input.attendees ?? []) {
140
+ lines.push(personProperty('ATTENDEE', attendee))
141
+ }
142
+ lines.push('END:VEVENT', 'END:VCALENDAR')
143
+ return `${lines.map(foldIcsLine).join('\r\n')}\r\n`
144
+ }
145
+
146
+ /**
147
+ * Plain event export: a VCALENDAR with no iTIP METHOD, for "Download .ics"
148
+ * of an event to share.
149
+ */
150
+ export function generateIcsEventExport(
151
+ input: Omit<IcsEventInput, 'method'>,
152
+ ): string {
153
+ return generateIcsCalendar(input)
154
+ }
155
+
156
+ export interface IcsReplyInput {
157
+ /** UID of the invite being answered — must match the original. */
158
+ uid: string
159
+ /** SEQUENCE of the invite being answered — echoed back. */
160
+ sequence?: number
161
+ timestamp: string
162
+ title: string
163
+ startsAt: string
164
+ endsAt: string
165
+ allDay?: boolean
166
+ organizer: IcsPerson
167
+ /** The account answering the invite. */
168
+ attendee: IcsPerson
169
+ response: 'accepted' | 'declined' | 'tentative'
170
+ }
171
+
172
+ const PARTSTAT_BY_RESPONSE: Record<IcsReplyInput['response'], IcsPartstat> = {
173
+ accepted: 'ACCEPTED',
174
+ declined: 'DECLINED',
175
+ tentative: 'TENTATIVE',
176
+ }
177
+
178
+ /** iMIP RSVP: a `METHOD:REPLY` VCALENDAR mailed back to the organizer. */
179
+ export function generateIcsReply(input: IcsReplyInput): string {
180
+ return generateIcsCalendar({
181
+ uid: input.uid,
182
+ method: 'REPLY',
183
+ ...(input.sequence !== undefined ? { sequence: input.sequence } : {}),
184
+ timestamp: input.timestamp,
185
+ title: input.title,
186
+ startsAt: input.startsAt,
187
+ endsAt: input.endsAt,
188
+ ...(input.allDay ? { allDay: true } : {}),
189
+ organizer: input.organizer,
190
+ attendees: [
191
+ {
192
+ ...input.attendee,
193
+ partstat: PARTSTAT_BY_RESPONSE[input.response],
194
+ },
195
+ ],
196
+ })
197
+ }
package/src/index.ts CHANGED
@@ -140,11 +140,14 @@ export {
140
140
  ConnectionSetupGrid,
141
141
  ConnectionSetupPage,
142
142
  ConnectionStepList,
143
+ ProviderConnection,
143
144
  type ConnectionOptionCardProps,
144
145
  type ConnectionOptionTone,
145
146
  type ConnectionSavedCardProps,
146
147
  type ConnectionSetupPageProps,
147
148
  type ConnectionStep,
149
+ type ProviderConnectionClient,
150
+ type ProviderConnectionProps,
148
151
  } from './components/common/connections/index.js'
149
152
  export {
150
153
  OpenReviewCard,
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ retryAfterMs,
4
+ withHttpRetry,
5
+ type HttpRetryOptions,
6
+ } from './httpRetry.js'
7
+
8
+ function response(status: number, headers?: Record<string, string>) {
9
+ return { status, ok: status >= 200 && status < 300, headers }
10
+ }
11
+
12
+ function options(overrides: HttpRetryOptions = {}): HttpRetryOptions {
13
+ return {
14
+ sleep: vi.fn(async () => undefined),
15
+ random: () => 1,
16
+ now: () => 1_800_000_000_000,
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('withHttpRetry', () => {
22
+ it('returns a success immediately', async () => {
23
+ const attempt = vi.fn(async () => response(200))
24
+ const result = await withHttpRetry(attempt, options())
25
+ expect(result.status).toBe(200)
26
+ expect(attempt).toHaveBeenCalledTimes(1)
27
+ })
28
+
29
+ it('does not retry non-retryable client errors', async () => {
30
+ const attempt = vi.fn(async () => response(403))
31
+ const result = await withHttpRetry(attempt, options())
32
+ expect(result.status).toBe(403)
33
+ expect(attempt).toHaveBeenCalledTimes(1)
34
+ })
35
+
36
+ it('retries 429 and 5xx with exponential backoff until success', async () => {
37
+ const sleep = vi.fn(async (_ms: number) => undefined)
38
+ const responses = [response(429), response(500), response(200)]
39
+ const attempt = vi.fn(async () => responses.shift() ?? response(200))
40
+ const result = await withHttpRetry(
41
+ attempt,
42
+ options({ sleep, maxAttempts: 3 }),
43
+ )
44
+ expect(result.status).toBe(200)
45
+ expect(attempt).toHaveBeenCalledTimes(3)
46
+ expect(sleep).toHaveBeenCalledTimes(2)
47
+ // Full-jitter with random()=1 → the raw step: 500ms then 1000ms.
48
+ expect(sleep.mock.calls.map(call => call[0])).toEqual([500, 1000])
49
+ })
50
+
51
+ it('returns the last failure when the budget is exhausted', async () => {
52
+ const attempt = vi.fn(async () => response(503))
53
+ const result = await withHttpRetry(attempt, options({ maxAttempts: 2 }))
54
+ expect(result.status).toBe(503)
55
+ expect(attempt).toHaveBeenCalledTimes(2)
56
+ })
57
+
58
+ it('honors Retry-After seconds over computed backoff', async () => {
59
+ const sleep = vi.fn(async (_ms: number) => undefined)
60
+ const responses = [response(429, { 'retry-after': '2' }), response(200)]
61
+ const attempt = vi.fn(async () => responses.shift() ?? response(200))
62
+ await withHttpRetry(attempt, options({ sleep }))
63
+ expect(sleep).toHaveBeenCalledWith(2000)
64
+ })
65
+
66
+ it('retries a 401 exactly once', async () => {
67
+ const responses = [response(401), response(401)]
68
+ const attempt = vi.fn(async () => responses.shift() ?? response(200))
69
+ const result = await withHttpRetry(attempt, options())
70
+ // Second 401 is returned, not retried again.
71
+ expect(result.status).toBe(401)
72
+ expect(attempt).toHaveBeenCalledTimes(2)
73
+ })
74
+
75
+ it('recovers when the 401 retry succeeds', async () => {
76
+ const responses = [response(401), response(200)]
77
+ const attempt = vi.fn(async () => responses.shift() ?? response(200))
78
+ const result = await withHttpRetry(attempt, options())
79
+ expect(result.status).toBe(200)
80
+ })
81
+
82
+ it('retries thrown network errors and rethrows the last one', async () => {
83
+ const attempt = vi.fn(async () => {
84
+ throw new Error('socket hang up')
85
+ })
86
+ await expect(
87
+ withHttpRetry(attempt, options({ maxAttempts: 3 })),
88
+ ).rejects.toThrow('socket hang up')
89
+ expect(attempt).toHaveBeenCalledTimes(3)
90
+ })
91
+
92
+ it('recovers from a transient network error', async () => {
93
+ let first = true
94
+ const attempt = vi.fn(async () => {
95
+ if (first) {
96
+ first = false
97
+ throw new Error('reset')
98
+ }
99
+ return response(200)
100
+ })
101
+ const result = await withHttpRetry(attempt, options())
102
+ expect(result.status).toBe(200)
103
+ })
104
+ })
105
+
106
+ describe('retryAfterMs', () => {
107
+ const NOW = Date.parse('2026-07-04T10:00:00.000Z')
108
+
109
+ it('parses seconds and http-dates and rejects garbage', () => {
110
+ expect(retryAfterMs({ 'retry-after': '3' }, NOW)).toBe(3000)
111
+ expect(
112
+ retryAfterMs({ 'Retry-After': 'Sat, 04 Jul 2026 10:00:05 GMT' }, NOW),
113
+ ).toBe(5000)
114
+ expect(retryAfterMs({ 'retry-after': 'soon' }, NOW)).toBeNull()
115
+ expect(retryAfterMs(undefined, NOW)).toBeNull()
116
+ })
117
+ })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Retry policy for provider API calls (Gmail, Google Calendar):
3
+ * - 429 and 5xx retry with exponential backoff, honoring `Retry-After`
4
+ * (seconds or HTTP-date) when the server sends one.
5
+ * - 401 retries exactly once — the attempt callback re-requests its access
6
+ * token per attempt, and the shell refreshes transparently, so a single
7
+ * retry covers a token revoked mid-flight without loops.
8
+ * - Thrown attempt errors (network failures) retry like 5xx.
9
+ * The final response (or error) is returned/rethrown untouched so callers
10
+ * keep their existing error handling.
11
+ */
12
+
13
+ export interface RetryableHttpResponse {
14
+ status: number
15
+ ok: boolean
16
+ headers?: Record<string, string>
17
+ }
18
+
19
+ export interface HttpRetryOptions {
20
+ /** Total attempts including the first (default 3). */
21
+ maxAttempts?: number
22
+ baseDelayMs?: number
23
+ maxDelayMs?: number
24
+ sleep?: (ms: number) => Promise<void>
25
+ /** 0..1 jitter source; injectable for tests. */
26
+ random?: () => number
27
+ now?: () => number
28
+ }
29
+
30
+ const DEFAULT_MAX_ATTEMPTS = 3
31
+ const DEFAULT_BASE_DELAY_MS = 500
32
+ const DEFAULT_MAX_DELAY_MS = 8_000
33
+
34
+ function defaultSleep(ms: number): Promise<void> {
35
+ return new Promise(resolve => setTimeout(resolve, ms))
36
+ }
37
+
38
+ export function retryAfterMs(
39
+ headers: Record<string, string> | undefined,
40
+ now: number,
41
+ ): number | null {
42
+ const raw =
43
+ headers?.['retry-after'] ??
44
+ headers?.['Retry-After'] ??
45
+ headers?.['RETRY-AFTER']
46
+ if (!raw) return null
47
+ const seconds = Number(raw)
48
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
49
+ const date = Date.parse(raw)
50
+ if (!Number.isNaN(date)) return Math.max(0, date - now)
51
+ return null
52
+ }
53
+
54
+ function isRetryableStatus(status: number): boolean {
55
+ return status === 429 || (status >= 500 && status <= 599)
56
+ }
57
+
58
+ export async function withHttpRetry<T extends RetryableHttpResponse>(
59
+ attempt: (attemptIndex: number) => Promise<T>,
60
+ options: HttpRetryOptions = {},
61
+ ): Promise<T> {
62
+ const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)
63
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
64
+ const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
65
+ const sleep = options.sleep ?? defaultSleep
66
+ const random = options.random ?? Math.random
67
+ const now = options.now ?? Date.now
68
+
69
+ let unauthorizedRetried = false
70
+ let attemptIndex = 0
71
+ // Retry budget counts backoff retries; the single 401 retry is separate.
72
+ let retriesLeft = maxAttempts - 1
73
+
74
+ for (;;) {
75
+ let response: T | null = null
76
+ let failure: unknown = null
77
+ try {
78
+ response = await attempt(attemptIndex)
79
+ } catch (error) {
80
+ failure = error
81
+ }
82
+ attemptIndex += 1
83
+
84
+ if (response) {
85
+ if (response.ok || !isRetryableStatus(response.status)) {
86
+ if (response.status === 401 && !unauthorizedRetried) {
87
+ unauthorizedRetried = true
88
+ continue
89
+ }
90
+ return response
91
+ }
92
+ }
93
+
94
+ if (retriesLeft <= 0) {
95
+ if (failure !== null) throw failure
96
+ return response as T
97
+ }
98
+ retriesLeft -= 1
99
+
100
+ const serverDelay = response
101
+ ? retryAfterMs(response.headers, now())
102
+ : null
103
+ const backoffStep = Math.min(
104
+ maxDelayMs,
105
+ baseDelayMs * 2 ** (maxAttempts - 2 - retriesLeft),
106
+ )
107
+ // Half-to-full jitter keeps concurrent clients from thundering.
108
+ const jittered = backoffStep * (0.5 + random() / 2)
109
+ await sleep(Math.min(maxDelayMs, serverDelay ?? jittered))
110
+ }
111
+ }
@@ -3,48 +3,49 @@ import { appAccentForSlug, applyAppAccentToTheme } from './appAccents.js'
3
3
  import { lightTheme } from './themes/light.js'
4
4
 
5
5
  describe('appAccents', () => {
6
- it('allocates identity hues to all suite apps', () => {
7
- expect(appAccentForSlug('teams')?.accent).toBe('oklch(0.58 0.13 8)')
8
- expect(appAccentForSlug('vibes')?.accent).toBe('oklch(0.50 0.14 26)')
9
- expect(appAccentForSlug('mail')?.accent).toBe('oklch(0.60 0.12 44)')
10
- expect(appAccentForSlug('files')?.accent).toBe('oklch(0.50 0.13 64)')
11
- expect(appAccentForSlug('slides')?.accent).toBe('oklch(0.61 0.11 84)')
12
- expect(appAccentForSlug('writer')?.accent).toBe('oklch(0.50 0.12 102)')
13
- expect(appAccentForSlug('manuscript')?.accent).toBe('oklch(0.61 0.12 124)')
14
- expect(appAccentForSlug('book')?.accent).toBe('oklch(0.48 0.14 148)')
15
- expect(appAccentForSlug('reports')?.accent).toBe('oklch(0.61 0.12 166)')
16
- expect(appAccentForSlug('chart')?.accent).toBe('oklch(0.48 0.13 184)')
17
- expect(appAccentForSlug('knowledge')?.accent).toBe('oklch(0.46 0.11 198)')
18
- expect(appAccentForSlug('research')?.accent).toBe('oklch(0.63 0.12 222)')
19
- expect(appAccentForSlug('sheets')?.accent).toBe('oklch(0.50 0.13 244)')
20
- expect(appAccentForSlug('calendar')?.accent).toBe('oklch(0.63 0.13 258)')
21
- expect(appAccentForSlug('desktop')?.accent).toBe('oklch(0.48 0.13 274)')
22
- expect(appAccentForSlug('plan')?.accent).toBe('oklch(0.61 0.14 290)')
23
- expect(appAccentForSlug('review')?.accent).toBe('oklch(0.50 0.15 308)')
24
- expect(appAccentForSlug('tasks')?.accent).toBe('oklch(0.63 0.15 324)')
25
- expect(appAccentForSlug('assistant')?.accent).toBe('oklch(0.50 0.15 340)')
26
- expect(appAccentForSlug('people')?.accent).toBe('oklch(0.61 0.14 356)')
6
+ it('allocates an identity hue to every app on the 20-step ring', () => {
7
+ expect(appAccentForSlug('mail')?.accent).toBe('oklch(0.55 0.125 48)')
8
+ expect(appAccentForSlug('knowledge')?.accent).toBe('oklch(0.55 0.125 192)')
9
+ expect(appAccentForSlug('research')?.accent).toBe('oklch(0.55 0.125 210)')
10
+ expect(appAccentForSlug('whiteboard')?.accent).toBe('oklch(0.55 0.125 282)')
11
+ expect(appAccentForSlug('tasks')?.accent).toBe('oklch(0.55 0.125 318)')
12
+ expect(appAccentForSlug('teams')?.accent).toBe('oklch(0.55 0.125 12)')
13
+ expect(appAccentForSlug('people')?.accent).toBe('oklch(0.55 0.125 354)')
14
+ expect(appAccentForSlug('desktop')?.accent).toBe('oklch(0.55 0.125 264)')
27
15
  })
28
16
 
29
- it('exposes the deep block tone for large fills', () => {
30
- expect(appAccentForSlug('research')?.accentBlock).toBe(
31
- 'oklch(0.40 0.06 222)',
32
- )
33
- expect(appAccentForSlug('tasks')?.accentBlock).toBe('oklch(0.40 0.06 324)')
17
+ it('warms Mail toward terracotta with a dedicated tinted background', () => {
18
+ expect(appAccentForSlug('mail')?.accentMuted).toBe('oklch(0.93 0.05 48)')
34
19
  })
35
20
 
36
- it('normalizes pure-prefixed slugs', () => {
37
- expect(appAccentForSlug('purefiles')?.accent).toBe(
38
- appAccentForSlug('files')?.accent,
39
- )
40
- expect(appAccentForSlug('pureknowledge')?.accent).toBe(
41
- appAccentForSlug('knowledge')?.accent,
42
- )
43
- expect(appAccentForSlug('pureslides')?.accent).toBe(
44
- appAccentForSlug('slides')?.accent,
21
+ it('gives previously house-ink apps their own hue', () => {
22
+ expect(appAccentForSlug('writer')?.accent).toBe('oklch(0.55 0.125 102)')
23
+ expect(appAccentForSlug('book')?.accent).toBe('oklch(0.55 0.125 138)')
24
+ expect(appAccentForSlug('calendar')?.accent).toBe('oklch(0.55 0.125 246)')
25
+ expect(appAccentForSlug('reports')?.accent).toBe('oklch(0.55 0.125 156)')
26
+ expect(appAccentForSlug('slides')?.accent).toBe('oklch(0.55 0.125 84)')
27
+ })
28
+
29
+ it('splits Sheets and Chart into distinct hues', () => {
30
+ expect(appAccentForSlug('sheets')?.accent).toBe('oklch(0.55 0.125 228)')
31
+ expect(appAccentForSlug('chart')?.accent).toBe('oklch(0.55 0.125 174)')
32
+ expect(appAccentForSlug('chart')?.accent).not.toBe(
33
+ appAccentForSlug('sheets')?.accent,
45
34
  )
46
35
  })
47
36
 
37
+ it('normalizes the optional pure- prefix', () => {
38
+ expect(appAccentForSlug('purefiles')?.accent).toBe('oklch(0.55 0.125 66)')
39
+ expect(appAccentForSlug('files')?.accent).toBe('oklch(0.55 0.125 66)')
40
+ })
41
+
42
+ it('leaves unassigned apps neutral', () => {
43
+ expect(appAccentForSlug('chat')).toBeNull()
44
+ expect(appAccentForSlug('pages')).toBeNull()
45
+ expect(appAccentForSlug(null)).toBeNull()
46
+ expect(appAccentForSlug(undefined)).toBeNull()
47
+ })
48
+
48
49
  it('does not mutate the platform theme accent', () => {
49
50
  expect(applyAppAccentToTheme(lightTheme, 'tasks')).toBe(lightTheme)
50
51
  })