@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.8

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 (64) hide show
  1. package/package.json +55 -1
  2. package/src/bridge/agentTypes.ts +21 -1
  3. package/src/bridge/collectionImagePaste.ts +19 -1
  4. package/src/bridge/context.mjs +32 -0
  5. package/src/bridge/documents.d.mts +76 -0
  6. package/src/bridge/documents.mjs +40 -0
  7. package/src/bridge/methods.d.mts +23 -0
  8. package/src/bridge/methods.mjs +23 -0
  9. package/src/bridge/pureRender/base.css +44 -0
  10. package/src/bridge/pureRender/document.ts +29 -2
  11. package/src/bridge/pureRender/index.ts +3 -0
  12. package/src/bridge/pureRender/normalize.ts +167 -0
  13. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  14. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  15. package/src/bridge/pureRender/styles.ts +125 -5
  16. package/src/bridge/pureRender/theme.test.ts +259 -0
  17. package/src/bridge/pureRender/theme.ts +19 -1
  18. package/src/bridge/pureRender/types.ts +33 -0
  19. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  20. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  21. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  22. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  23. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  24. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  25. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  26. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  27. package/src/bridge/storage.d.mts +6 -6
  28. package/src/bridge/storage.test.ts +6 -6
  29. package/src/bridge/types.ts +7 -0
  30. package/src/components/agents/AgentDrawerPanel.tsx +2 -0
  31. package/src/components/agents/AgentMessageList.tsx +6 -1
  32. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  33. package/src/components/agents/ContextPicker.tsx +239 -0
  34. package/src/components/agents/agentPanelStyles.ts +3 -2
  35. package/src/components/agents/agentTypes.ts +2 -0
  36. package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
  37. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  38. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  39. package/src/components/common/containers/AppFrame.tsx +10 -1
  40. package/src/components/common/containers/AppHeader.tsx +22 -0
  41. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  42. package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
  43. package/src/components/common/documents/index.ts +8 -0
  44. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  45. package/src/components/common/research/index.ts +2 -0
  46. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  47. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  48. package/src/components/print-design/index.ts +2 -0
  49. package/src/components/print-design/previewContent.test.ts +32 -0
  50. package/src/components/print-design/previewContent.ts +31 -0
  51. package/src/context/buildContextDocument.test.ts +240 -0
  52. package/src/context/buildContextDocument.ts +309 -0
  53. package/src/context/contextAccess.ts +66 -0
  54. package/src/context/contextConfig.ts +72 -0
  55. package/src/context/contextDocument.test.ts +155 -0
  56. package/src/context/contextDocument.ts +300 -0
  57. package/src/context/contextSections.test.ts +88 -0
  58. package/src/context/contextSections.ts +84 -0
  59. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  60. package/src/context/htmlToContextMarkdown.ts +369 -0
  61. package/src/theme/appAccents.test.ts +36 -35
  62. package/src/theme/appAccents.ts +87 -104
  63. package/src/theme/appIdentityCss.mjs +169 -231
  64. package/src/theme/appIdentityCss.ts +213 -233
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { htmlToContextMarkdown } from './htmlToContextMarkdown.js'
4
+
5
+ describe('htmlToContextMarkdown', () => {
6
+ it('converts headings and paragraphs', () => {
7
+ expect(
8
+ htmlToContextMarkdown('<h1>Title</h1><p>Hello <strong>world</strong>.</p>'),
9
+ ).toBe('# Title\n\nHello **world**.\n')
10
+ })
11
+
12
+ it('keeps inline marks in headings', () => {
13
+ expect(htmlToContextMarkdown('<h2>My <em>Title</em></h2>')).toBe(
14
+ '## My *Title*\n',
15
+ )
16
+ })
17
+
18
+ it('converts links', () => {
19
+ expect(
20
+ htmlToContextMarkdown('<p>See <a href="https://x.test/a">the paper</a>.</p>'),
21
+ ).toBe('See [the paper](https://x.test/a).\n')
22
+ })
23
+
24
+ it('prefers the durable collection asset src for images', () => {
25
+ expect(
26
+ htmlToContextMarkdown(
27
+ '<p><img src="blob:ephemeral" data-collection-asset-src="assets/fig-01.png" alt="Figure 1"></p>',
28
+ ),
29
+ ).toBe('![Figure 1](assets/fig-01.png)\n')
30
+ })
31
+
32
+ it('drops data: image payloads', () => {
33
+ expect(
34
+ htmlToContextMarkdown('<p><img src="data:image/png;base64,AAAA" alt="P"></p>'),
35
+ ).toBe('![P]()\n')
36
+ })
37
+
38
+ it('converts unordered and ordered lists', () => {
39
+ expect(
40
+ htmlToContextMarkdown(
41
+ '<ul><li><p>One</p></li><li><p>Two</p></li></ul>',
42
+ ),
43
+ ).toBe('- One\n- Two\n')
44
+ expect(
45
+ htmlToContextMarkdown('<ol><li><p>First</p></li><li><p>Second</p></li></ol>'),
46
+ ).toBe('1. First\n2. Second\n')
47
+ })
48
+
49
+ it('nests lists with indentation', () => {
50
+ expect(
51
+ htmlToContextMarkdown('<ul><li>A<ul><li>B</li></ul></li></ul>'),
52
+ ).toBe('- A\n - B\n')
53
+ })
54
+
55
+ it('converts blockquotes', () => {
56
+ expect(
57
+ htmlToContextMarkdown('<blockquote><p>Quoted</p></blockquote>'),
58
+ ).toBe('> Quoted\n')
59
+ })
60
+
61
+ it('converts pre/code with language to fenced blocks', () => {
62
+ expect(
63
+ htmlToContextMarkdown(
64
+ '<pre><code class="language-js">const a = 1;\n</code></pre>',
65
+ ),
66
+ ).toBe('```js\nconst a = 1;\n```\n')
67
+ })
68
+
69
+ it('converts inline code', () => {
70
+ expect(htmlToContextMarkdown('<p>Run <code>npm test</code>.</p>')).toBe(
71
+ 'Run `npm test`.\n',
72
+ )
73
+ })
74
+
75
+ it('converts mermaid figures to mermaid fences', () => {
76
+ expect(
77
+ htmlToContextMarkdown(
78
+ '<figure data-type="mermaid"><code class="language-mermaid">graph TD</code></figure>',
79
+ ),
80
+ ).toBe('```mermaid\ngraph TD\n```\n')
81
+ })
82
+
83
+ it('converts block math via data-latex', () => {
84
+ expect(
85
+ htmlToContextMarkdown(
86
+ '<span data-type="block-math" data-latex="E=mc^2"></span>',
87
+ ),
88
+ ).toBe('$$E=mc^2$$\n')
89
+ })
90
+
91
+ it('converts tables to GFM', () => {
92
+ expect(
93
+ htmlToContextMarkdown(
94
+ '<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>',
95
+ ),
96
+ ).toBe('| A | B |\n| --- | --- |\n| 1 | 2 |\n')
97
+ })
98
+
99
+ it('decodes entities', () => {
100
+ expect(htmlToContextMarkdown('<p>a &amp; b &lt;c&gt; &#8212;</p>')).toBe(
101
+ 'a & b <c> —\n',
102
+ )
103
+ })
104
+
105
+ it('skips script and style content', () => {
106
+ expect(
107
+ htmlToContextMarkdown('<p>Hi</p><script>ignored()</script><p>Bye</p>'),
108
+ ).toBe('Hi\n\nBye\n')
109
+ })
110
+
111
+ it('renders figcaptions as emphasis', () => {
112
+ expect(
113
+ htmlToContextMarkdown(
114
+ '<figure><img data-collection-asset-src="assets/a.png" alt="A"><figcaption>Caption text</figcaption></figure>',
115
+ ),
116
+ ).toBe('![A](assets/a.png)\n\n*Caption text*\n')
117
+ })
118
+
119
+ it('converts hard breaks inside paragraphs', () => {
120
+ expect(htmlToContextMarkdown('<p>Line one<br>Line two</p>')).toBe(
121
+ 'Line one\nLine two\n',
122
+ )
123
+ })
124
+
125
+ it('converts horizontal rules', () => {
126
+ expect(htmlToContextMarkdown('<p>Above</p><hr><p>Below</p>')).toBe(
127
+ 'Above\n\n---\n\nBelow\n',
128
+ )
129
+ })
130
+
131
+ it('ignores inter-tag whitespace', () => {
132
+ expect(htmlToContextMarkdown('<p>A</p>\n <p>B</p>')).toBe('A\n\nB\n')
133
+ })
134
+ })
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Deterministic HTML → markdown conversion for context documents.
3
+ *
4
+ * Converts the well-formed HTML that suite apps persist (TipTap-style output)
5
+ * into readable markdown. Intentionally DOM-free (regex tokenizer + stack) so
6
+ * it runs identically in app iframes and in a headless shell process.
7
+ *
8
+ * Not a general-purpose sanitizer: input is trusted app-generated HTML.
9
+ * Binary/image references keep their durable relative paths
10
+ * (`data-collection-asset-src` wins over `src`); `data:` URLs are dropped —
11
+ * context files link binaries, never embed them.
12
+ */
13
+
14
+ const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'source', 'wbr', 'col', 'embed', 'area', 'base', 'track'])
15
+ const SKIP_TAGS = new Set(['script', 'style', 'head', 'title', 'meta', 'link', 'template'])
16
+ const TRANSPARENT_TAGS = new Set(['div', 'span', 'section', 'article', 'main', 'header', 'footer', 'aside', 'nav', 'body', 'html', 'u', 'mark', 'small', 'sub', 'sup', 'font', 'colgroup', 'col', 'tbody', 'thead', 'tfoot'])
17
+
18
+ const NAMED_ENTITIES: Record<string, string> = {
19
+ amp: '&',
20
+ lt: '<',
21
+ gt: '>',
22
+ quot: '"',
23
+ apos: "'",
24
+ nbsp: ' ',
25
+ mdash: '—',
26
+ ndash: '–',
27
+ hellip: '…',
28
+ rsquo: '’',
29
+ lsquo: '‘',
30
+ rdquo: '”',
31
+ ldquo: '“',
32
+ }
33
+
34
+ interface ListFrame {
35
+ type: 'ul' | 'ol'
36
+ index: number
37
+ itemBlockEmitted: boolean
38
+ }
39
+
40
+ interface TableState {
41
+ rows: string[][]
42
+ headerRows: number
43
+ currentRow: string[] | null
44
+ inHeader: boolean
45
+ }
46
+
47
+ interface Converter {
48
+ out: string[]
49
+ inline: string
50
+ linkHrefs: string[]
51
+ quoteDepth: number
52
+ lists: ListFrame[]
53
+ table: TableState | null
54
+ pre: { lang: string; text: string } | null
55
+ codeFence: { lang: string; text: string } | null
56
+ skipDepth: number
57
+ }
58
+
59
+ function decodeEntities(text: string): string {
60
+ return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, entity: string) => {
61
+ if (entity.startsWith('#x') || entity.startsWith('#X')) {
62
+ const code = Number.parseInt(entity.slice(2), 16)
63
+ return Number.isNaN(code) ? whole : String.fromCodePoint(code)
64
+ }
65
+ if (entity.startsWith('#')) {
66
+ const code = Number.parseInt(entity.slice(1), 10)
67
+ return Number.isNaN(code) ? whole : String.fromCodePoint(code)
68
+ }
69
+ return NAMED_ENTITIES[entity] ?? whole
70
+ })
71
+ }
72
+
73
+ function readAttributes(tag: string): Record<string, string> {
74
+ const attrs: Record<string, string> = {}
75
+ const pattern = /([a-zA-Z][-\w:]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g
76
+ let match: RegExpExecArray | null
77
+ while ((match = pattern.exec(tag)) !== null) {
78
+ attrs[match[1].toLowerCase()] = decodeEntities(match[3] ?? match[4] ?? match[5] ?? '')
79
+ }
80
+ return attrs
81
+ }
82
+
83
+ function currentList(state: Converter): ListFrame | null {
84
+ return state.lists.length > 0 ? state.lists[state.lists.length - 1] : null
85
+ }
86
+
87
+ /** Push finished block lines through list + blockquote prefixing into out. */
88
+ function emitBlock(state: Converter, lines: string[]): void {
89
+ if (lines.length === 0) return
90
+ let prefixed = lines
91
+ const list = currentList(state)
92
+ if (list) {
93
+ const indent = ' '.repeat(state.lists.length - 1)
94
+ const marker = list.type === 'ol' ? `${list.index}. ` : '- '
95
+ const continuation = ' '.repeat(marker.length)
96
+ prefixed = prefixed.map((line, index) => {
97
+ if (!list.itemBlockEmitted && index === 0) return `${indent}${marker}${line}`
98
+ return `${indent}${continuation}${line}`
99
+ })
100
+ list.itemBlockEmitted = true
101
+ }
102
+ if (state.quoteDepth > 0) {
103
+ const quote = '> '.repeat(state.quoteDepth)
104
+ prefixed = prefixed.map(line => `${quote}${line}`.trimEnd())
105
+ }
106
+ if (state.out.length > 0) {
107
+ const separator = list ? null : ''
108
+ if (separator !== null) state.out.push(separator)
109
+ }
110
+ state.out.push(...prefixed)
111
+ }
112
+
113
+ function flushInline(state: Converter, headingLevel?: number): void {
114
+ const text = state.inline.replace(/[ \t]+/g, ' ').trim()
115
+ state.inline = ''
116
+ if (!text) return
117
+ if (state.table?.currentRow) {
118
+ state.table.currentRow.push(text.replace(/\|/g, '\\|'))
119
+ return
120
+ }
121
+ if (headingLevel) {
122
+ emitBlock(state, [`${'#'.repeat(headingLevel)} ${text}`])
123
+ return
124
+ }
125
+ emitBlock(state, text.split('\n').map(line => line.trim()).filter(Boolean))
126
+ }
127
+
128
+ function flushTableCell(state: Converter): void {
129
+ if (!state.table) return
130
+ const text = state.inline.replace(/[ \t\n]+/g, ' ').trim()
131
+ state.inline = ''
132
+ state.table.currentRow?.push(text.replace(/\|/g, '\\|'))
133
+ }
134
+
135
+ function emitTable(state: Converter): void {
136
+ const table = state.table
137
+ state.table = null
138
+ if (!table || table.rows.length === 0) return
139
+ const width = Math.max(...table.rows.map(row => row.length))
140
+ const pad = (row: string[]): string[] => {
141
+ const cells = [...row]
142
+ while (cells.length < width) cells.push('')
143
+ return cells
144
+ }
145
+ const [first, ...rest] = table.rows
146
+ const lines = [
147
+ `| ${pad(first).join(' | ')} |`,
148
+ `| ${Array.from({ length: width }, () => '---').join(' | ')} |`,
149
+ ...rest.map(row => `| ${pad(row).join(' | ')} |`),
150
+ ]
151
+ emitBlock(state, lines)
152
+ }
153
+
154
+ function emitFence(state: Converter, lang: string, text: string): void {
155
+ const clean = text.replace(/^\n+/, '').replace(/\s+$/, '')
156
+ emitBlock(state, [`\`\`\`${lang}`, ...clean.split('\n'), '```'])
157
+ }
158
+
159
+ const HEADING_TAG = /^h([1-6])$/
160
+
161
+ /** Convert app-persisted HTML into context markdown. */
162
+ export function htmlToContextMarkdown(html: string): string {
163
+ const state: Converter = {
164
+ out: [],
165
+ inline: '',
166
+ linkHrefs: [],
167
+ quoteDepth: 0,
168
+ lists: [],
169
+ table: null,
170
+ pre: null,
171
+ codeFence: null,
172
+ skipDepth: 0,
173
+ }
174
+ const source = html.replace(/<!--[\s\S]*?-->/g, '')
175
+ const tokens = source.match(/<[^>]+>|[^<]+/g) ?? []
176
+ const openHeadings: number[] = []
177
+
178
+ for (const token of tokens) {
179
+ if (!token.startsWith('<')) {
180
+ if (state.skipDepth > 0) continue
181
+ const text = decodeEntities(token)
182
+ if (state.pre) state.pre.text += text
183
+ else if (state.codeFence) state.codeFence.text += text
184
+ else state.inline += text.replace(/\s+/g, ' ')
185
+ continue
186
+ }
187
+
188
+ const tagMatch = /^<\s*(\/)?\s*([a-zA-Z][-\w]*)/.exec(token)
189
+ if (!tagMatch) continue
190
+ const closing = Boolean(tagMatch[1])
191
+ const name = tagMatch[2].toLowerCase()
192
+
193
+ if (SKIP_TAGS.has(name)) {
194
+ if (VOID_TAGS.has(name)) continue
195
+ state.skipDepth = Math.max(0, state.skipDepth + (closing ? -1 : 1))
196
+ continue
197
+ }
198
+ if (state.skipDepth > 0) continue
199
+
200
+ if (state.pre) {
201
+ if (closing && name === 'pre') {
202
+ const pre = state.pre
203
+ state.pre = null
204
+ emitFence(state, pre.lang, pre.text)
205
+ } else if (!closing && name === 'code') {
206
+ const lang = /language-([-\w]+)/.exec(readAttributes(token).class ?? '')
207
+ if (lang) state.pre.lang = lang[1]
208
+ }
209
+ continue
210
+ }
211
+ if (state.codeFence) {
212
+ if (closing && name === 'code') {
213
+ const fence = state.codeFence
214
+ state.codeFence = null
215
+ emitFence(state, fence.lang, fence.text)
216
+ }
217
+ continue
218
+ }
219
+
220
+ const attrs = closing ? {} : readAttributes(token)
221
+ const headingMatch = HEADING_TAG.exec(name)
222
+
223
+ if (headingMatch) {
224
+ if (closing) {
225
+ flushInline(state, openHeadings.pop())
226
+ } else {
227
+ flushInline(state)
228
+ openHeadings.push(Number(headingMatch[1]))
229
+ }
230
+ continue
231
+ }
232
+
233
+ switch (name) {
234
+ case 'p':
235
+ flushInline(state)
236
+ break
237
+ case 'br':
238
+ state.inline += '\n'
239
+ break
240
+ case 'hr':
241
+ flushInline(state)
242
+ emitBlock(state, ['---'])
243
+ break
244
+ case 'strong':
245
+ case 'b':
246
+ state.inline += '**'
247
+ break
248
+ case 'em':
249
+ case 'i':
250
+ state.inline += '*'
251
+ break
252
+ case 's':
253
+ case 'del':
254
+ case 'strike':
255
+ state.inline += '~~'
256
+ break
257
+ case 'code':
258
+ if (!closing) {
259
+ const lang = /language-([-\w]+)/.exec(attrs.class ?? '')
260
+ if (lang) {
261
+ flushInline(state)
262
+ state.codeFence = { lang: lang[1], text: '' }
263
+ } else {
264
+ state.inline += '`'
265
+ }
266
+ } else {
267
+ state.inline += '`'
268
+ }
269
+ break
270
+ case 'pre':
271
+ if (!closing) {
272
+ flushInline(state)
273
+ state.pre = { lang: '', text: '' }
274
+ }
275
+ break
276
+ case 'a':
277
+ if (!closing) {
278
+ state.linkHrefs.push(attrs.href ?? '')
279
+ state.inline += '['
280
+ } else {
281
+ const href = state.linkHrefs.pop() ?? ''
282
+ state.inline += `](${href})`
283
+ }
284
+ break
285
+ case 'img': {
286
+ const src = attrs['data-collection-asset-src'] ?? attrs.src ?? ''
287
+ const target = src.startsWith('data:') ? '' : src
288
+ state.inline += `![${attrs.alt ?? ''}](${target})`
289
+ break
290
+ }
291
+ case 'figure':
292
+ flushInline(state)
293
+ break
294
+ case 'figcaption':
295
+ if (!closing) flushInline(state)
296
+ else {
297
+ const caption = state.inline.replace(/[ \t]+/g, ' ').trim()
298
+ state.inline = ''
299
+ if (caption) emitBlock(state, [`*${caption}*`])
300
+ }
301
+ break
302
+ case 'blockquote':
303
+ flushInline(state)
304
+ state.quoteDepth = Math.max(0, state.quoteDepth + (closing ? -1 : 1))
305
+ break
306
+ case 'ul':
307
+ case 'ol':
308
+ flushInline(state)
309
+ if (!closing) {
310
+ state.lists.push({ type: name, index: 0, itemBlockEmitted: true })
311
+ } else {
312
+ state.lists.pop()
313
+ }
314
+ break
315
+ case 'li': {
316
+ flushInline(state)
317
+ const list = currentList(state)
318
+ if (list && !closing) {
319
+ list.index += 1
320
+ list.itemBlockEmitted = false
321
+ }
322
+ break
323
+ }
324
+ case 'table':
325
+ flushInline(state)
326
+ if (!closing) {
327
+ state.table = { rows: [], headerRows: 0, currentRow: null, inHeader: false }
328
+ } else {
329
+ emitTable(state)
330
+ }
331
+ break
332
+ case 'tr':
333
+ if (!state.table) break
334
+ if (!closing) state.table.currentRow = []
335
+ else {
336
+ if (state.table.currentRow) state.table.rows.push(state.table.currentRow)
337
+ state.table.currentRow = null
338
+ }
339
+ break
340
+ case 'td':
341
+ case 'th':
342
+ if (!state.table) break
343
+ if (closing) flushTableCell(state)
344
+ else state.inline = ''
345
+ break
346
+ default: {
347
+ const latex = attrs['data-latex']
348
+ if (!closing && typeof latex === 'string' && latex.length > 0) {
349
+ if ((attrs['data-type'] ?? '').includes('block')) {
350
+ flushInline(state)
351
+ emitBlock(state, [`$$${latex}$$`])
352
+ } else {
353
+ state.inline += `$${latex}$`
354
+ }
355
+ break
356
+ }
357
+ if (!TRANSPARENT_TAGS.has(name)) {
358
+ // Unknown elements are transparent: keep their text content.
359
+ }
360
+ break
361
+ }
362
+ }
363
+ }
364
+
365
+ flushInline(state)
366
+ if (state.pre) emitFence(state, state.pre.lang, state.pre.text)
367
+ emitTable(state)
368
+ return `${state.out.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`
369
+ }
@@ -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
  })