@workerdeck/ui 2.15.0 → 3.1.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 (75) hide show
  1. package/README.md +6 -5
  2. package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
  3. package/build/index.d.mts +194 -49
  4. package/build/index.mjs +562 -260
  5. package/build/index.mjs.map +1 -1
  6. package/build/scoped.css +170 -21
  7. package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
  8. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  9. package/build/workspace.d.mts +3 -3
  10. package/build/workspace.mjs +3 -3
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +4 -4
  13. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  14. package/src/components/agent/Composer.tsx +25 -47
  15. package/src/components/agent/ContextDialog.tsx +75 -67
  16. package/src/components/agent/FrameStrip.tsx +74 -0
  17. package/src/components/agent/HostFilesDialog.tsx +3 -2
  18. package/src/components/agent/McpDialog.tsx +112 -78
  19. package/src/components/agent/PermissionPrompt.tsx +22 -17
  20. package/src/components/agent/QuestionPrompt.tsx +6 -41
  21. package/src/components/agent/Response.tsx +8 -1
  22. package/src/components/agent/Scrubber.tsx +6 -75
  23. package/src/components/agent/SessionBrowser.tsx +131 -214
  24. package/src/components/agent/SessionFilters.tsx +266 -0
  25. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  26. package/src/components/agent/SessionItem.tsx +29 -6
  27. package/src/components/agent/SessionPanel.tsx +249 -498
  28. package/src/components/agent/SessionSteps.tsx +98 -14
  29. package/src/components/agent/SessionWorkspace.tsx +3 -3
  30. package/src/components/agent/ShellStrip.tsx +43 -36
  31. package/src/components/agent/ShellTerminal.tsx +1 -1
  32. package/src/components/agent/SubagentStrip.tsx +13 -30
  33. package/src/components/agent/ToolCallCard.tsx +18 -41
  34. package/src/components/agent/Transcript.tsx +111 -93
  35. package/src/components/agent/TranscriptItemView.tsx +80 -67
  36. package/src/components/agent/TranscriptRows.tsx +4 -2
  37. package/src/components/agent/composer-commands.ts +80 -1
  38. package/src/components/agent/image-viewer.tsx +326 -0
  39. package/src/components/agent/markdown-image.tsx +139 -0
  40. package/src/components/agent/scrubber-marks.ts +33 -11
  41. package/src/components/agent/scrubber-peek.tsx +122 -0
  42. package/src/components/agent/session-panel-providers.tsx +41 -0
  43. package/src/components/agent/shell-actions.tsx +38 -1
  44. package/src/components/agent/tool-result-image.tsx +40 -0
  45. package/src/components/agent/transcript-variant.tsx +3 -21
  46. package/src/components/agent/use-catch-up.ts +20 -0
  47. package/src/components/agent/use-frame.ts +88 -0
  48. package/src/components/agent/use-host-image.ts +81 -0
  49. package/src/components/agent/use-shell-frame.ts +12 -70
  50. package/src/components/agent/use-subagent-frame.ts +19 -75
  51. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  52. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  53. package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
  54. package/src/components/terminal/PermissionPrompt.tsx +31 -28
  55. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  56. package/src/components/terminal/TerminalTranscript.tsx +23 -89
  57. package/src/components/terminal/affordances.tsx +100 -74
  58. package/src/components/terminal/height.ts +48 -32
  59. package/src/components/terminal/image-box.ts +31 -0
  60. package/src/components/terminal/items.tsx +70 -30
  61. package/src/components/terminal/markdown.tsx +4 -1
  62. package/src/components/terminal/scrubber.tsx +25 -272
  63. package/src/components/terminal/shell-row.ts +17 -3
  64. package/src/components/ui/Popover.tsx +29 -0
  65. package/src/components/ui/Select.tsx +25 -0
  66. package/src/index.ts +65 -12
  67. package/src/lib/file-link.ts +3 -16
  68. package/src/lib/permission-prompt.ts +57 -0
  69. package/src/lib/question-answers.ts +65 -0
  70. package/src/lib/shell-request.ts +115 -0
  71. package/src/lib/tool-icon.ts +13 -1
  72. package/src/styles/terminal.css +129 -22
  73. package/src/styles/theme.css +4 -0
  74. package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
  75. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -0,0 +1,57 @@
1
+ import type { PermissionRequest } from '@workerdeck/protocol'
2
+ import { planFromRequest } from './plan-request.ts'
3
+ import { shellRequestPayload, shellRequestTitle, type ShellRequestPayload } from './shell-request.ts'
4
+
5
+ export type PermissionChoiceKey = 'allow' | 'deny' | 'stop'
6
+
7
+ export type PermissionChoice = {
8
+ key: PermissionChoiceKey
9
+ button: string
10
+ option: string
11
+ danger?: boolean
12
+ }
13
+
14
+ export type PermissionPromptModel = {
15
+ plan?: string
16
+ shell?: ShellRequestPayload
17
+ heading: string
18
+ question: string
19
+ description?: string
20
+ decisionReason?: string
21
+ choices: [PermissionChoice, PermissionChoice, PermissionChoice]
22
+ denyPlaceholder: string
23
+ }
24
+
25
+ type PermissionRequestLike = Pick<PermissionRequest, 'toolName' | 'input' | 'title' | 'displayName' | 'description' | 'decisionReason'>
26
+
27
+ export function permissionPromptModel(request: PermissionRequestLike): PermissionPromptModel {
28
+ const plan = planFromRequest(request)
29
+ const shell = plan ? undefined : shellRequestPayload(request)
30
+ const context = { description: request.description, decisionReason: request.decisionReason }
31
+ if (plan) {
32
+ return {
33
+ plan,
34
+ heading: 'Plan ready for review',
35
+ question: 'Ready to implement this plan?',
36
+ ...context,
37
+ choices: [
38
+ { key: 'allow', button: 'Approve plan', option: 'Approve plan' },
39
+ { key: 'deny', button: 'Keep planning', option: 'Keep planning - tell it what to change' },
40
+ { key: 'stop', button: 'Stop the turn', option: 'No, and stop the turn', danger: true },
41
+ ],
42
+ denyPlaceholder: 'What should change? (optional) - the agent keeps planning and reads this',
43
+ }
44
+ }
45
+ return {
46
+ shell,
47
+ heading: shell ? shellRequestTitle(shell) : (request.title ?? request.displayName ?? 'Permission needed'),
48
+ question: shell ? 'Do you want to let it?' : 'Do you want to proceed?',
49
+ ...context,
50
+ choices: [
51
+ { key: 'allow', button: 'Allow', option: 'Yes' },
52
+ { key: 'deny', button: 'Deny', option: 'No, and tell the agent what to do differently' },
53
+ { key: 'stop', button: 'Deny & stop', option: 'No, and stop the turn', danger: true },
54
+ ],
55
+ denyPlaceholder: 'Reason (optional) - the agent reads this and can try something else',
56
+ }
57
+ }
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react'
2
+ import type { UserQuestion } from '@workerdeck/protocol'
3
+
4
+ export type Selection = { labels: string[]; other: string; otherActive: boolean }
5
+
6
+ export const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
7
+
8
+ export function answerFor(selection: Selection): string {
9
+ const parts = [...selection.labels]
10
+ if (selection.otherActive && selection.other.trim()) {
11
+ parts.push(selection.other.trim())
12
+ }
13
+ return parts.join(', ')
14
+ }
15
+
16
+ export function toggleLabel(selection: Selection, label: string, multiSelect: boolean): Selection {
17
+ if (!multiSelect) {
18
+ return { ...selection, labels: [label], otherActive: false }
19
+ }
20
+ const labels = selection.labels.includes(label) ? selection.labels.filter((l) => l !== label) : [...selection.labels, label]
21
+ return { ...selection, labels }
22
+ }
23
+
24
+ export function toggleOther(selection: Selection, multiSelect: boolean): Selection {
25
+ const otherActive = !selection.otherActive
26
+ return { ...selection, otherActive, labels: otherActive && !multiSelect ? [] : selection.labels }
27
+ }
28
+
29
+ export function answersFor(questions: readonly UserQuestion[], selections: readonly Selection[]): Record<string, string> {
30
+ const answers: Record<string, string> = {}
31
+ questions.forEach((question, index) => {
32
+ answers[question.question] = answerFor(selections[index] ?? EMPTY_SELECTION)
33
+ })
34
+ return answers
35
+ }
36
+
37
+ export interface QuestionAnswers {
38
+ selections: Selection[]
39
+ selectionFor: (index: number) => Selection
40
+ answered: (index: number) => boolean
41
+ complete: boolean
42
+ update: (index: number, patch: Partial<Selection>) => void
43
+ toggle: (index: number, label: string) => void
44
+ toggleOther: (index: number) => void
45
+ answers: () => Record<string, string>
46
+ }
47
+
48
+ export function useQuestionAnswers(questions: readonly UserQuestion[]): QuestionAnswers {
49
+ const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY_SELECTION))
50
+ const selectionFor = (index: number) => selections[index] ?? EMPTY_SELECTION
51
+ const answered = (index: number) => answerFor(selectionFor(index)) !== ''
52
+ const edit = (index: number, change: (selection: Selection) => Selection) =>
53
+ setSelections((prev) => questions.map((_, i) => (i === index ? change(prev[i] ?? EMPTY_SELECTION) : (prev[i] ?? EMPTY_SELECTION))))
54
+ const multiSelect = (index: number) => questions[index]?.multiSelect === true
55
+ return {
56
+ selections,
57
+ selectionFor,
58
+ answered,
59
+ complete: questions.every((_, index) => answered(index)),
60
+ update: (index, patch) => edit(index, (selection) => ({ ...selection, ...patch })),
61
+ toggle: (index, label) => edit(index, (selection) => toggleLabel(selection, label, multiSelect(index))),
62
+ toggleOther: (index) => edit(index, (selection) => toggleOther(selection, multiSelect(index))),
63
+ answers: () => answersFor(questions, selections),
64
+ }
65
+ }
@@ -0,0 +1,115 @@
1
+ // The agent's shell write tools on a permission card. Allow-lists key on the tool name, and the danger is in the
2
+ // payload, so the card shows the command, the typed text and the keys verbatim rather than a JSON summary.
3
+ export type ShellRequestPayload =
4
+ | { kind: 'run'; command: string }
5
+ | { kind: 'write'; shellId: string; data?: string; keys?: string[] }
6
+ | { kind: 'kill'; shellId: string }
7
+ | { kind: 'grant'; shellId: string; reason: string }
8
+
9
+ type RequestLike = { toolName?: unknown; input?: unknown }
10
+
11
+ const CONTROL_NAMES: Record<string, string> = {
12
+ '\r': 'enter',
13
+ '\n': 'newline',
14
+ '\t': 'tab',
15
+ '\x1b': 'escape',
16
+ '\x7f': 'backspace',
17
+ }
18
+
19
+ type ShellWriteTool = 'shell_run' | 'shell_write' | 'shell_kill' | 'shell_request_write'
20
+
21
+ const SHELL_WRITE_TOOLS: readonly string[] = ['shell_run', 'shell_write', 'shell_kill', 'shell_request_write']
22
+
23
+ export function shellToolOf(toolName: unknown): ShellWriteTool | undefined {
24
+ if (typeof toolName !== 'string') {
25
+ return undefined
26
+ }
27
+ const bare = toolName.startsWith('mcp__') ? toolName.slice(toolName.indexOf('__', 5) + 2) : toolName
28
+ return SHELL_WRITE_TOOLS.includes(bare) ? (bare as ShellWriteTool) : undefined
29
+ }
30
+
31
+ export function shellRequestPayload(request: RequestLike | null | undefined): ShellRequestPayload | undefined {
32
+ const tool = shellToolOf(request?.toolName)
33
+ if (!tool) {
34
+ return undefined
35
+ }
36
+ const input = request?.input !== null && typeof request?.input === 'object' ? (request.input as Record<string, unknown>) : {}
37
+ const shellId = typeof input.shellId === 'string' ? input.shellId : ''
38
+ switch (tool) {
39
+ case 'shell_run': {
40
+ return { kind: 'run', command: typeof input.command === 'string' ? input.command : '' }
41
+ }
42
+ case 'shell_write': {
43
+ const keys = Array.isArray(input.keys) ? input.keys.filter((key): key is string => typeof key === 'string') : undefined
44
+ return {
45
+ kind: 'write',
46
+ shellId,
47
+ ...(typeof input.data === 'string' ? { data: input.data } : {}),
48
+ ...(keys && keys.length > 0 ? { keys } : {}),
49
+ }
50
+ }
51
+ case 'shell_kill': {
52
+ return { kind: 'kill', shellId }
53
+ }
54
+ case 'shell_request_write': {
55
+ return { kind: 'grant', shellId, reason: typeof input.reason === 'string' ? input.reason : '' }
56
+ }
57
+ }
58
+ }
59
+
60
+ export function shellRequestTitle(payload: ShellRequestPayload): string {
61
+ switch (payload.kind) {
62
+ case 'run': {
63
+ return 'Agent wants to start a shell'
64
+ }
65
+ case 'write': {
66
+ return `Agent wants to type into shell ${payload.shellId}`
67
+ }
68
+ case 'kill': {
69
+ return `Agent wants to kill shell ${payload.shellId}`
70
+ }
71
+ case 'grant': {
72
+ return `Agent asks to type into your shell ${payload.shellId}`
73
+ }
74
+ }
75
+ }
76
+
77
+ // The lines drawn verbatim under the title. Typed text keeps its shape; a control character is named in brackets
78
+ // so a hidden Enter or Escape is visible on the card.
79
+ export function shellRequestLines(payload: ShellRequestPayload): string[] {
80
+ switch (payload.kind) {
81
+ case 'run': {
82
+ return payload.command.split('\n').map((line, index) => (index === 0 ? `$ ${line}` : ` ${line}`))
83
+ }
84
+ case 'write': {
85
+ const lines: string[] = []
86
+ if (payload.data !== undefined) {
87
+ lines.push(`types: ${visibleControls(payload.data)}`)
88
+ }
89
+ if (payload.keys) {
90
+ lines.push(`presses: ${payload.keys.map((key) => `[${key}]`).join(' ')}`)
91
+ }
92
+ return lines
93
+ }
94
+ case 'kill': {
95
+ return [`kill ${payload.shellId}`]
96
+ }
97
+ case 'grant': {
98
+ return [`why: ${visibleControls(payload.reason)}`, 'until the shell ends or you revoke it']
99
+ }
100
+ }
101
+ }
102
+
103
+ export function visibleControls(text: string): string {
104
+ let out = ''
105
+ for (const char of text) {
106
+ const code = char.codePointAt(0)!
107
+ if (code >= 0x20 && code !== 0x7f) {
108
+ out += char
109
+ continue
110
+ }
111
+ const named = CONTROL_NAMES[char]
112
+ out += named ? `[${named}]` : `[ctrl-${String.fromCharCode(code + 64).toLowerCase()}]`
113
+ }
114
+ return out
115
+ }
@@ -22,7 +22,19 @@ export function toolIcon(toolName: string): LucideIcon {
22
22
  switch (toolName) {
23
23
  case 'Bash':
24
24
  case 'BashOutput':
25
- case 'KillShell': {
25
+ case 'KillShell':
26
+ case 'shell_list':
27
+ case 'shell_read':
28
+ case 'shell_run':
29
+ case 'shell_write':
30
+ case 'shell_kill':
31
+ case 'shell_request_write':
32
+ case 'mcp__workerdeck__shell_list':
33
+ case 'mcp__workerdeck__shell_read':
34
+ case 'mcp__workerdeck__shell_run':
35
+ case 'mcp__workerdeck__shell_write':
36
+ case 'mcp__workerdeck__shell_kill':
37
+ case 'mcp__workerdeck__shell_request_write': {
26
38
  return Terminal
27
39
  }
28
40
  case 'Read': {
@@ -436,46 +436,144 @@
436
436
  .term-hoverable {
437
437
  position: relative;
438
438
  }
439
- /* Pinned to the block's first line at its right edge. Absolute, so the rows
440
- below are exactly where they would have been without it. */
441
- [data-terminal] .term-actions {
439
+ /* `below` sits in the blank line (cards: the row gap) under the block; `inline`
440
+ pins to the block's first line at its right edge, for blocks with no blank
441
+ line beneath them. Absolute either way, so no row moves when they appear. */
442
+ :is([data-terminal], [data-cards]) .term-actions {
442
443
  position: absolute;
443
- top: 0;
444
- right: 0;
445
- height: var(--term-line);
444
+ z-index: 1;
446
445
  display: flex;
446
+ align-items: center;
447
+ opacity: 0;
448
+ transition: opacity var(--motion-fast, 120ms);
449
+ }
450
+ [data-terminal] .term-actions {
451
+ height: var(--term-line);
447
452
  gap: 1ch;
453
+ }
454
+ [data-cards] .term-actions {
455
+ height: 20px;
456
+ gap: 6px;
457
+ }
458
+ :is([data-terminal], [data-cards]) [data-placement='below'] > .term-actions {
459
+ top: 100%;
460
+ }
461
+ [data-terminal] [data-placement='below'] > .term-actions {
462
+ left: var(--term-cell);
463
+ }
464
+ [data-cards] [data-placement='below'] > .term-actions {
465
+ left: 0;
466
+ }
467
+ :is([data-terminal], [data-cards]) [data-placement='inline'] > .term-actions {
468
+ top: 0;
469
+ right: 0;
448
470
  padding-inline: 1ch;
449
471
  background: var(--bg, transparent);
450
- opacity: 0;
451
- transition: opacity var(--motion-fast, 120ms);
452
472
  }
453
- [data-terminal] .term-hoverable:hover .term-actions,
454
- [data-terminal] .term-hoverable:focus-within .term-actions {
473
+ /* Only the innermost `below` block under the pointer shows its bar, so an
474
+ expanded chain does not light up its own and its parent's at once. */
475
+ :is([data-terminal], [data-cards])
476
+ [data-placement='below']:is(:hover, :focus-within):not(:has([data-placement='below']:is(:hover, :focus-within)))
477
+ > .term-actions,
478
+ :is([data-terminal], [data-cards]) [data-placement='inline']:is(:hover, :focus-within) > .term-actions {
455
479
  opacity: 1;
456
480
  }
481
+ /* A virtualized row paints under the next one, whose top padding is the gap
482
+ the bar sits in; lift the hovered row so the bar stays clickable. */
483
+ :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:not(.absolute) {
484
+ position: relative;
485
+ }
486
+ :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:is(:hover, :focus-within) {
487
+ z-index: 1;
488
+ }
457
489
  @media (hover: none) {
458
- /* No pointer to hover with: showing them always is the only way they exist at
459
- all, and a phone has the room. */
460
- [data-terminal] .term-actions {
490
+ :is([data-terminal], [data-cards]) .term-actions {
461
491
  opacity: 1;
462
492
  }
463
493
  }
494
+ [data-cards] .term-action {
495
+ font: inherit;
496
+ font-size: 12px;
497
+ font-weight: 500;
498
+ line-height: 1;
499
+ height: 18px;
500
+ min-width: 22px;
501
+ display: inline-flex;
502
+ align-items: center;
503
+ justify-content: center;
504
+ gap: 4px;
505
+ padding-inline: 5px;
506
+ color: var(--fg-3);
507
+ background: var(--surface);
508
+ border: 1px solid var(--border);
509
+ border-radius: 4px;
510
+ white-space: nowrap;
511
+ cursor: pointer;
512
+ }
513
+ [data-cards] .term-action-icon {
514
+ width: 14px;
515
+ height: 14px;
516
+ stroke-width: 2.25;
517
+ flex: none;
518
+ }
519
+ [data-cards] .term-action-word {
520
+ padding-right: 2px;
521
+ }
522
+ [data-cards] .term-action:hover,
523
+ [data-cards] .term-action:focus-visible {
524
+ color: var(--fg-1);
525
+ background: var(--row-hover);
526
+ }
527
+ [data-cards] .term-action[data-tone='magenta'] {
528
+ color: var(--wd-shell-accent);
529
+ }
530
+ [data-cards] .term-action[data-tone='yellow'] {
531
+ color: var(--warning);
532
+ }
533
+ [data-cards] .term-action[data-tone='danger']:hover {
534
+ color: var(--danger);
535
+ }
464
536
  [data-terminal] .term-action {
465
537
  font: inherit;
466
- line-height: var(--term-line);
538
+ font-weight: 500;
539
+ line-height: 1;
540
+ height: calc(var(--term-line) - 2px);
541
+ min-width: 3ch;
542
+ display: inline-flex;
543
+ align-items: center;
544
+ justify-content: center;
545
+ gap: 0.6ch;
546
+ padding-inline: 0.6ch;
467
547
  color: var(--term-dim);
468
- background: none;
469
- border: 0;
470
- padding: 0;
548
+ background: var(--term-band-bg);
549
+ border: 1px solid color-mix(in srgb, var(--term-faint) 55%, transparent);
550
+ border-radius: 3px;
551
+ white-space: pre;
471
552
  cursor: pointer;
472
553
  }
473
- /* The shell drill-in, in the same magenta as the row's `$`. */
554
+ [data-terminal] .term-action-icon {
555
+ width: calc(var(--term-font-size) + 1px);
556
+ height: calc(var(--term-font-size) + 1px);
557
+ stroke-width: 2.25;
558
+ flex: none;
559
+ }
560
+ [data-terminal] .term-action-word {
561
+ padding-right: 0.2ch;
562
+ }
563
+ [data-terminal] .term-action:hover,
564
+ [data-terminal] .term-action:focus-visible {
565
+ color: var(--term-bright);
566
+ border-color: var(--term-dim);
567
+ background: var(--term-row-hover);
568
+ }
474
569
  [data-terminal] .term-action[data-tone='magenta'] {
475
570
  color: var(--term-magenta);
476
571
  }
477
- [data-terminal] .term-action:hover {
478
- color: var(--term-fg);
572
+ [data-terminal] .term-action[data-tone='yellow'] {
573
+ color: var(--term-yellow);
574
+ }
575
+ [data-terminal] .term-action[data-tone='danger']:hover {
576
+ color: var(--term-red);
479
577
  }
480
578
 
481
579
  /* What you typed, on a band of its own - the CLI's own treatment. */
@@ -714,7 +812,7 @@ button.term-link {
714
812
  [data-terminal] [data-sticky-lane] > [data-sticky-headlane] {
715
813
  position: absolute;
716
814
  inset: 0;
717
- z-index: 1;
815
+ z-index: 2;
718
816
  pointer-events: none;
719
817
  }
720
818
 
@@ -1276,7 +1374,16 @@ button.term-link {
1276
1374
  display: block;
1277
1375
  overflow: hidden;
1278
1376
  }
1279
- .term-image > img {
1377
+ .term-image > [data-slot='viewable-image'] {
1378
+ display: block;
1379
+ height: 100%;
1380
+ max-width: 100%;
1381
+ padding: 0;
1382
+ border: 0;
1383
+ background: none;
1384
+ }
1385
+ .term-image img {
1386
+ display: block;
1280
1387
  max-width: 100%;
1281
1388
  height: 100%;
1282
1389
  object-fit: contain;
@@ -821,6 +821,10 @@
821
821
  left: 0;
822
822
  width: 12px;
823
823
  }
824
+ /* xterm.css paints its viewport #000 whatever the theme says; the drill-in shows the panel through. */
825
+ .wd-shell-term .xterm .xterm-viewport {
826
+ background-color: transparent;
827
+ }
824
828
  .wd-scrubber .wd-scrub-mark[data-kind='user'] {
825
829
  background: var(--accent);
826
830
  }