@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5

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 (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Shared behavior of the board's transient surfaces (the task detail overlay
3
+ * and every modal that opens above it).
4
+ *
5
+ * Two concerns live here because they are one contract:
6
+ *
7
+ * - `usePresence` keeps a surface mounted through its exit leg. The board's
8
+ * overlays are mounted by a boolean at the call site; the moment that
9
+ * boolean flips the node would vanish, so the enter animation had no
10
+ * reverse. Presence turns the boolean into `open` -> `closing` -> gone and
11
+ * paints the phase on the surface, which is what the CSS transition pair
12
+ * reads. Dimensions never change across the pair, so nothing around the
13
+ * overlay reflows.
14
+ * - `useDialog` owns the keyboard and focus contract of a modal surface:
15
+ * focus moves into the dialog when it opens and returns to the control that
16
+ * opened it when it closes, Tab cycles inside it, and Escape asks the owner
17
+ * to close.
18
+ *
19
+ * @module @linxin666/dsh-client-ui-task-board/client/board/overlay
20
+ */
21
+ import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'
22
+
23
+ /**
24
+ * Exit duration in milliseconds. It matches `--dsh-tb-motion-close` in
25
+ * `board.module.css`; under `prefers-reduced-motion` the stylesheet keeps the
26
+ * same duration and drops only the spatial travel, so the unmount delay stays
27
+ * valid for both motion settings.
28
+ */
29
+ export const OVERLAY_EXIT_MS = 140
30
+
31
+ /** Lifecycle phase of one transient surface. */
32
+ export type OverlayPhase = 'open' | 'closing'
33
+
34
+ /** Presence of one transient surface. */
35
+ export interface OverlayPresence {
36
+ /** Whether the surface should render at all (true through the exit leg). */
37
+ mounted: boolean
38
+ /** Which leg of the motion pair the surface is on. */
39
+ phase: OverlayPhase
40
+ }
41
+
42
+ /**
43
+ * Keep a surface mounted until its exit transition has run.
44
+ *
45
+ * `open=false` starts the exit leg immediately (the surface paints
46
+ * `data-state="closing"`) and unmounts it one exit duration later. Re-opening
47
+ * during the exit leg cancels the unmount, so a fast toggle never leaves a
48
+ * half-faded surface behind.
49
+ * @param open - the owner's mount request.
50
+ * @param exitMs - exit duration; defaults to {@link OVERLAY_EXIT_MS}.
51
+ * @returns the mount decision and the phase to paint.
52
+ */
53
+ export function usePresence(open: boolean, exitMs: number = OVERLAY_EXIT_MS): OverlayPresence {
54
+ const [presence, setPresence] = useState<OverlayPresence>(
55
+ () => ({ mounted: open, phase: open ? 'open' : 'closing' }),
56
+ )
57
+ useEffect(() => {
58
+ if (open) {
59
+ setPresence(current => (current.mounted && current.phase === 'open' ? current : { mounted: true, phase: 'open' }))
60
+ return
61
+ }
62
+ setPresence(current => (current.mounted ? { mounted: true, phase: 'closing' } : current))
63
+ const timer = setTimeout(() => { setPresence({ mounted: false, phase: 'closing' }) }, exitMs)
64
+ return () => { clearTimeout(timer) }
65
+ }, [open, exitMs])
66
+ return presence
67
+ }
68
+
69
+ /** Tab-order selector of the elements a modal surface hands focus to. */
70
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
71
+
72
+ /**
73
+ * Open modal surfaces in mount order. Keyboard handling belongs to the last
74
+ * entry only: the board nests modals (a confirm or an edit form above the task
75
+ * detail), and without the stack one Escape press would close both layers.
76
+ */
77
+ const MODAL_STACK: symbol[] = []
78
+
79
+ /** The surface that currently owns the keyboard, if any. */
80
+ function topMostDialog(): symbol | undefined {
81
+ return MODAL_STACK[MODAL_STACK.length - 1]
82
+ }
83
+
84
+ /** The dialog's keyboard/focus handle. */
85
+ export interface DialogHandle<T extends HTMLElement> {
86
+ /** Callback ref for the dialog element itself (it must accept focus). */
87
+ attach: (node: T | null) => void
88
+ /** Backdrop press handler: closes only on a press of the backdrop itself. */
89
+ onMouseDown: (event: MouseEvent<HTMLElement>) => void
90
+ }
91
+
92
+ /**
93
+ * Bind a modal surface's keyboard and focus contract.
94
+ *
95
+ * Focus enters the dialog on mount (its first focusable control, or the dialog
96
+ * itself) and returns to the control that opened it on unmount. Tab wraps
97
+ * inside the dialog, and Escape asks the owner to close — but only while the
98
+ * surface is on its enter leg, so a repeat press during the exit cannot queue
99
+ * a second close.
100
+ * @param onClose - the owner's close request.
101
+ * @param phase - the surface's current motion phase.
102
+ * @returns the ref to attach and the backdrop press handler.
103
+ */
104
+ export function useDialog<T extends HTMLElement>(onClose: () => void, phase: OverlayPhase): DialogHandle<T> {
105
+ const nodeRef = useRef<T | null>(null)
106
+ const openerRef = useRef<HTMLElement | null>(null)
107
+ const [attached, setAttached] = useState(false)
108
+ const closeRef = useRef(onClose)
109
+ closeRef.current = onClose
110
+ // Identity in the document-wide stack of open surfaces (see MODAL_STACK).
111
+ const [token] = useState(() => Symbol('board-dialog'))
112
+
113
+ /** Callback ref: records the node and triggers the initial focus pass. */
114
+ const attach = useCallback((node: T | null): void => {
115
+ nodeRef.current = node
116
+ setAttached(node !== null)
117
+ }, [])
118
+
119
+ // Focus enters the surface on its first attach; the element that opened it
120
+ // is remembered and gets focus back when the surface unmounts, so a keyboard
121
+ // user returns to the control they came from instead of to the document.
122
+ //
123
+ // A surface that already landed focus inside itself keeps it: React's own
124
+ // `autoFocus` (the create form's title field) commits before this pass and
125
+ // names the field the user actually starts on. Otherwise focus goes to the
126
+ // surface itself, which announces the dialog and its accessible name; the
127
+ // first Tab then reaches its first control.
128
+ useEffect(() => {
129
+ if (!attached) return
130
+ const opener = document.activeElement
131
+ openerRef.current = opener instanceof HTMLElement ? opener : null
132
+ const node = nodeRef.current
133
+ if (node === null) return
134
+ if (opener instanceof HTMLElement && node.contains(opener)) return
135
+ const target = node.querySelector<HTMLElement>('[data-dsh-autofocus]')
136
+ ;(target ?? node).focus()
137
+ }, [attached])
138
+
139
+ useEffect(() => {
140
+ MODAL_STACK.push(token)
141
+ return () => {
142
+ const index = MODAL_STACK.indexOf(token)
143
+ if (index >= 0) MODAL_STACK.splice(index, 1)
144
+ const restore = openerRef.current
145
+ if (restore !== null && restore.isConnected) restore.focus()
146
+ }
147
+ }, [token])
148
+
149
+ useEffect(() => {
150
+ const onKeyDown = (event: KeyboardEvent): void => {
151
+ // Only the topmost surface answers: a modal opened from the detail view
152
+ // must swallow Escape and Tab itself instead of closing both layers.
153
+ if (topMostDialog() !== token) return
154
+ if (event.key === 'Escape') {
155
+ if (phase === 'closing') return
156
+ event.stopPropagation()
157
+ closeRef.current()
158
+ return
159
+ }
160
+ if (event.key !== 'Tab') return
161
+ const node = nodeRef.current
162
+ if (node === null) return
163
+ const focusable = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE))
164
+ if (focusable.length === 0) return
165
+ const first = focusable[0]
166
+ const last = focusable[focusable.length - 1]
167
+ const active = document.activeElement
168
+ if (event.shiftKey) {
169
+ if (active === first || active === node) {
170
+ event.preventDefault()
171
+ last.focus()
172
+ }
173
+ return
174
+ }
175
+ if (active === last) {
176
+ event.preventDefault()
177
+ first.focus()
178
+ }
179
+ }
180
+ document.addEventListener('keydown', onKeyDown, true)
181
+ return () => { document.removeEventListener('keydown', onKeyDown, true) }
182
+ }, [phase, token])
183
+
184
+ const onMouseDown = useCallback((event: MouseEvent<HTMLElement>): void => {
185
+ if (event.target === event.currentTarget) closeRef.current()
186
+ }, [])
187
+
188
+ return { attach, onMouseDown }
189
+ }
@@ -1,4 +1,5 @@
1
1
  import type { TaskStatus } from '../../core/tasks.ts'
2
+ import { verificationPhase, type ExecutionVerification, type VerificationPhase } from '../../core/verification.ts'
2
3
  import type { TaskBoardKey } from '../locales.ts'
3
4
 
4
5
  /** Task status → locale key (board column titles and the detail badge). */
@@ -9,3 +10,29 @@ export const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
9
10
  done: 'board.status.done',
10
11
  failed: 'board.status.failed',
11
12
  }
13
+
14
+ /**
15
+ * Acceptance phase to the label the running column shows: executing,
16
+ * verifying, or fixing a failed acceptance. The card keeps one running label;
17
+ * this decides which one.
18
+ */
19
+ export const VERIFICATION_PHASE_KEY: Record<VerificationPhase, TaskBoardKey> = {
20
+ off: 'running.executing',
21
+ executing: 'running.executing',
22
+ verifying: 'running.verifying',
23
+ repairing: 'running.repairing',
24
+ passed: 'running.verificationPassed',
25
+ failed: 'running.verificationFailed',
26
+ }
27
+
28
+ /**
29
+ * The acceptance label for one open execution, or undefined when acceptance
30
+ * does not govern it (the switch was off at start, the run never became a goal
31
+ * run, or a teammate is covered by its Lead).
32
+ * @param verification - the execution's acceptance state.
33
+ * @returns the locale key to render, or undefined for the historical label.
34
+ */
35
+ export function verificationRunningKey(verification: ExecutionVerification | undefined): TaskBoardKey | undefined {
36
+ if (verification === undefined || verification.applicability !== 'enforced') return undefined
37
+ return VERIFICATION_PHASE_KEY[verificationPhase(verification)]
38
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Styles for task detail safe Markdown rendering.
3
+ * Respects dsh --dsw-* theme tokens.
4
+ */
5
+
6
+ .markdownRoot {
7
+ font-size: 13px;
8
+ line-height: 1.55;
9
+ color: var(--dsw-alias-label-primary);
10
+ overflow-wrap: anywhere;
11
+ }
12
+
13
+ .markdownRoot > :first-child {
14
+ margin-top: 0;
15
+ }
16
+
17
+ .markdownRoot > :last-child {
18
+ margin-bottom: 0;
19
+ }
20
+
21
+ .paragraph {
22
+ margin: 0 0 8px;
23
+ line-height: 1.55;
24
+ }
25
+
26
+ .heading1,
27
+ .heading2,
28
+ .heading3,
29
+ .heading4,
30
+ .heading5,
31
+ .heading6 {
32
+ margin: 12px 0 6px;
33
+ font-weight: 600;
34
+ color: var(--dsw-alias-label-primary);
35
+ line-height: 1.35;
36
+ }
37
+
38
+ .heading1 { font-size: 16px; }
39
+ .heading2 { font-size: 15px; }
40
+ .heading3 { font-size: 14px; }
41
+ .heading4 { font-size: 13px; }
42
+ .heading5 { font-size: 12.5px; }
43
+ .heading6 { font-size: 12px; }
44
+
45
+ .list {
46
+ margin: 0 0 8px;
47
+ padding-left: 20px;
48
+ }
49
+
50
+ .listItem {
51
+ margin: 2px 0;
52
+ }
53
+
54
+ .taskItem {
55
+ list-style-type: none;
56
+ margin-left: -18px;
57
+ display: flex;
58
+ align-items: baseline;
59
+ gap: 6px;
60
+ }
61
+
62
+ .taskCheckbox {
63
+ margin: 0;
64
+ cursor: default;
65
+ vertical-align: middle;
66
+ }
67
+
68
+ .codeBlock {
69
+ margin: 8px 0;
70
+ padding: 8px 10px;
71
+ font-size: 12px;
72
+ line-height: 1.45;
73
+ font-family: var(--dsw-font-markdown-code-block-small, monospace);
74
+ color: var(--dsw-alias-label-primary);
75
+ background: var(--dsw-alias-markdown-code-block, rgba(0, 0, 0, 0.05));
76
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
77
+ border-radius: 6px;
78
+ overflow-x: auto;
79
+ white-space: pre-wrap;
80
+ overflow-wrap: anywhere;
81
+ }
82
+
83
+ .inlineCode {
84
+ padding: 1px 4px;
85
+ font-size: 12px;
86
+ font-family: var(--dsw-font-markdown-code-block-small, monospace);
87
+ color: var(--dsw-alias-label-primary);
88
+ background: var(--dsw-alias-markdown-code-block, rgba(0, 0, 0, 0.05));
89
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
90
+ border-radius: 4px;
91
+ }
92
+
93
+ .link {
94
+ color: var(--dsw-alias-brand-primary, #3b82f6);
95
+ text-decoration: underline;
96
+ text-underline-offset: 2px;
97
+ }
98
+
99
+ .link:hover {
100
+ opacity: 0.85;
101
+ }
102
+
103
+ .imagePlaceholder {
104
+ display: inline-block;
105
+ padding: 2px 6px;
106
+ font-size: 11px;
107
+ color: var(--dsw-alias-label-secondary, #666);
108
+ background: var(--dsw-alias-bg-subtle, rgba(0, 0, 0, 0.04));
109
+ border: 1px dashed var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.15));
110
+ border-radius: 4px;
111
+ }
112
+
113
+ .blockquote {
114
+ margin: 8px 0;
115
+ padding-left: 10px;
116
+ border-left: 3px solid var(--dsw-alias-border-l1, #ccc);
117
+ color: var(--dsw-alias-label-secondary, #666);
118
+ }
119
+
120
+ .table {
121
+ border-collapse: collapse;
122
+ width: 100%;
123
+ margin: 8px 0;
124
+ font-size: 12.5px;
125
+ }
126
+
127
+ .table th,
128
+ .table td {
129
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
130
+ padding: 6px 8px;
131
+ text-align: left;
132
+ }
133
+
134
+ .table th {
135
+ background: var(--dsw-alias-bg-subtle, rgba(0, 0, 0, 0.03));
136
+ font-weight: 600;
137
+ }
@@ -0,0 +1,106 @@
1
+ import { Fragment, createElement, useMemo, type ReactNode } from 'react'
2
+ import { Lexer, type Token, type Tokens } from 'marked'
3
+ import { EMPTY_FIELD, HEADING_COLON, SUMMARY_HEADING } from '../../core/issue-form.ts'
4
+ import css from './task-markdown.module.css'
5
+
6
+ function safeHref(value: string): string | undefined {
7
+ try {
8
+ const url = new URL(value)
9
+ return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : undefined
10
+ } catch { return undefined }
11
+ }
12
+
13
+ function plain(tokens: Token[], separator = ''): string {
14
+ return tokens.map(token => {
15
+ if (token.type === 'html' || token.type === 'def' || token.type === 'hr') return ''
16
+ if (token.type === 'list') return (token as Tokens.List).items.map(item => plain(item.tokens)).join(' ')
17
+ if (token.type === 'table') {
18
+ const table = token as Tokens.Table
19
+ return [table.header, ...table.rows].map(row => row.map(cell => plain(cell.tokens)).join(' ')).join(' ')
20
+ }
21
+ if ('tokens' in token && Array.isArray(token.tokens)) return plain(token.tokens)
22
+ return 'text' in token ? String(token.text) : ' '
23
+ }).join(separator)
24
+ }
25
+
26
+ /** Readable summary without changing the task source or creating DOM HTML. */
27
+ export function markdownToPlainText(source: string): string {
28
+ return plain(Lexer.lex(source, { gfm: true }), ' ').replace(/\s+/g, ' ').trim()
29
+ }
30
+
31
+
32
+ /**
33
+ * Card excerpt: the plain text a reader can tell cards apart by.
34
+ *
35
+ * Issue-form bodies open with identical boilerplate (a duplicate-search
36
+ * checkbox, a plugin field, an issue-type field), so the plain-text prefix of
37
+ * every synced card read the same. The excerpt drops section headings, lists
38
+ * made only of checkboxes, raw HTML, rules and empty-field placeholders; when a
39
+ * summary-like section exists its body wins. A description that loses
40
+ * everything to those rules falls back to its full plain text, so no card goes
41
+ * blank. The stored description is never changed.
42
+ * @param source - the task description (Markdown).
43
+ */
44
+ export function markdownExcerpt(source: string): string {
45
+ const blocks: string[] = []
46
+ const summary: string[] = []
47
+ let inSummary = false
48
+ for (const token of Lexer.lex(source, { gfm: true })) {
49
+ if (token.type === 'heading') {
50
+ inSummary = SUMMARY_HEADING.test(plain([token]).replace(HEADING_COLON, '').trim())
51
+ continue
52
+ }
53
+ if (token.type === 'space' || token.type === 'html' || token.type === 'hr' || token.type === 'def') continue
54
+ if (token.type === 'list' && (token as Tokens.List).items.every(item => item.task)) continue
55
+ const text = plain([token], ' ').replace(/\s+/g, ' ').trim()
56
+ if (text === '' || EMPTY_FIELD.test(text)) continue
57
+ ;(inSummary ? summary : blocks).push(text)
58
+ }
59
+ const picked = (summary.length > 0 ? summary : blocks).join(' ').trim()
60
+ return picked === '' ? markdownToPlainText(source) : picked
61
+ }
62
+
63
+ function renderTokens(tokens: Token[]): ReactNode {
64
+ return tokens.map((token, index) => {
65
+ const children = 'tokens' in token && Array.isArray(token.tokens) ? renderTokens(token.tokens) : 'text' in token ? String(token.text) : null
66
+ let node: ReactNode
67
+ switch (token.type) {
68
+ case 'space': case 'def': case 'html': node = null; break
69
+ case 'heading': node = createElement(`h${(token as Tokens.Heading).depth}`, { className: css[`heading${(token as Tokens.Heading).depth}`] }, children); break
70
+ case 'paragraph': node = <p className={css.paragraph}>{children}</p>; break
71
+ case 'strong': node = <strong>{children}</strong>; break
72
+ case 'em': node = <em>{children}</em>; break
73
+ case 'del': node = <del>{children}</del>; break
74
+ case 'codespan': node = <code className={css.inlineCode}>{token.text}</code>; break
75
+ case 'code': node = <pre className={css.codeBlock}><code>{token.text}</code></pre>; break
76
+ case 'br': node = <br />; break
77
+ case 'hr': node = <hr />; break
78
+ case 'blockquote': node = <blockquote className={css.blockquote}>{children}</blockquote>; break
79
+ case 'link': {
80
+ const href = safeHref((token as Tokens.Link).href)
81
+ node = href ? <a className={css.link} href={href} target="_blank" rel="noopener noreferrer">{children}</a> : children
82
+ break
83
+ }
84
+ case 'image': node = <span>{token.text}</span>; break
85
+ case 'list': {
86
+ const list = token as Tokens.List
87
+ const items = list.items.map((item, i) => <li key={i} className={css.listItem}>{item.task && <input type="checkbox" checked={item.checked === true} disabled readOnly />} {renderTokens(item.tokens)}</li>)
88
+ node = list.ordered ? <ol className={css.list} start={list.start || 1}>{items}</ol> : <ul className={css.list}>{items}</ul>
89
+ break
90
+ }
91
+ case 'table': {
92
+ const table = token as Tokens.Table
93
+ node = <div style={{ overflowX: 'auto' }}><table className={css.table}><thead><tr>{table.header.map((cell, i) => <th key={i}>{renderTokens(cell.tokens)}</th>)}</tr></thead><tbody>{table.rows.map((row, i) => <tr key={i}>{row.map((cell, j) => <td key={j}>{renderTokens(cell.tokens)}</td>)}</tr>)}</tbody></table></div>
94
+ break
95
+ }
96
+ default: node = children
97
+ }
98
+ return <Fragment key={index}>{node}</Fragment>
99
+ })
100
+ }
101
+
102
+ /** Markdown rendered as React nodes: raw HTML and automatic image loads are disabled. */
103
+ export function TaskMarkdown({ source }: { source: string }) {
104
+ const content = useMemo(() => renderTokens(Lexer.lex(source, { gfm: true })), [source])
105
+ return <div className={css.markdownRoot}>{content}</div>
106
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Board-owned variant of the family plugin-card seat that can declare child
3
+ * slots on the registration.
4
+ *
5
+ * The shared `installPluginCard` helper owns seat selection but cannot forward
6
+ * a `children` declaration, and the task board's settings card declares the
7
+ * `task-board.settings.section` seat a provider renders into. This wrapper
8
+ * reuses the shared seat decision (`familyGroupLoaded` and the two seat keys)
9
+ * and adds the declaration; it should collapse back into the shared helper once
10
+ * that helper accepts children.
11
+ *
12
+ * @module dsh-task-board/client/board-card-seat
13
+ */
14
+ import {
15
+ FAMILY_PLUGIN_CARD_SEAT,
16
+ OFFICIAL_PLUGIN_CARD_SEAT,
17
+ familyGroupLoaded,
18
+ type PluginCardContext,
19
+ } from './plugin-card-seat.ts'
20
+
21
+ /** One board card contribution, with the child seats it declares. */
22
+ export interface BoardCardSeat {
23
+ bundle: string
24
+ id: string
25
+ order?: number
26
+ label?: () => string
27
+ locale: string
28
+ /** Child slots this entry declares and renders. */
29
+ children: Record<string, unknown>
30
+ inject?: () => object
31
+ component: unknown
32
+ }
33
+
34
+ function warnRefusedSeat(seat: string, error: unknown): void {
35
+ try {
36
+ console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
37
+ } catch {
38
+ // Best-effort console write; a failed warning must not break the plugin.
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Contribute the board's settings card to the seat this host renders, declaring
44
+ * the child slots it owns. Mirrors the shared helper's reconcile discipline: the
45
+ * entry moves when the family group appears, and is never in two seats at once.
46
+ * @param ctx - client context (its slot registry decides the seat).
47
+ * @param seat - the card contribution and its children declaration.
48
+ */
49
+ export function installBoardCard(ctx: PluginCardContext, seat: BoardCardSeat): void {
50
+ const slots = ctx.slots
51
+ const component = seat.component as never
52
+ const inject = seat.inject as never
53
+
54
+ let dispose: (() => void) | undefined
55
+ let current: string | undefined
56
+ let reconciling = false
57
+
58
+ const reconcile = (): void => {
59
+ if (reconciling) return
60
+ const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
61
+ if (current === target) return
62
+ reconciling = true
63
+ const previous = dispose
64
+ dispose = undefined
65
+ current = undefined
66
+ previous?.()
67
+ try {
68
+ dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
69
+ ? {
70
+ name: FAMILY_PLUGIN_CARD_SEAT,
71
+ id: seat.id,
72
+ ...(seat.order === undefined ? {} : { order: seat.order }),
73
+ ...(seat.label === undefined ? {} : { label: seat.label }),
74
+ locale: seat.locale,
75
+ children: seat.children,
76
+ ...(seat.inject === undefined ? {} : { inject }),
77
+ }
78
+ : {
79
+ name: OFFICIAL_PLUGIN_CARD_SEAT,
80
+ key: seat.bundle,
81
+ locale: seat.locale,
82
+ children: seat.children,
83
+ ...(seat.inject === undefined ? {} : { inject }),
84
+ }) as never, component) as () => void
85
+ current = target
86
+ } catch (error) {
87
+ warnRefusedSeat(target, error)
88
+ } finally {
89
+ reconciling = false
90
+ }
91
+ }
92
+
93
+ if (typeof ctx.on === 'function') {
94
+ try {
95
+ ctx.on('slots/changed', () => { reconcile() })
96
+ } catch {
97
+ // An event seat that refuses the subscription leaves the initial
98
+ // decision in place; the card still renders in the seat chosen below.
99
+ }
100
+ }
101
+ reconcile()
102
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Local styles of the task-board settings card.
3
+ *
4
+ * The card's chrome belongs to the shared family slice
5
+ * (`settings-card.module.css`, generated); this file owns only the nested
6
+ * disclosure list and the acceptance block's quiet notes, on theme tokens
7
+ * instead of inline literals.
8
+ */
9
+
10
+ .note {
11
+ margin: 4px 0;
12
+ font-size: 12px;
13
+ color: var(--dsw-alias-label-secondary);
14
+ }
15
+
16
+ .error {
17
+ margin: 4px 0;
18
+ font-size: 12px;
19
+ color: var(--dsw-alias-state-error-primary);
20
+ }
21
+
22
+ .resolvedList {
23
+ margin: 4px 0 8px;
24
+ padding-left: 16px;
25
+ font-size: 12px;
26
+ color: var(--dsw-alias-label-secondary);
27
+ }
28
+
29
+ /* Live power facts plus the battery caveat, below the topic cards: quiet
30
+ small print that follows the disclosures instead of competing with them.
31
+ No rule of its own — the last topic card already closes with its border. */
32
+ .power {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 2px;
36
+ margin-top: 14px;
37
+ }
38
+
39
+ /* Both the live power line and the battery caveat: secondary ink at the small
40
+ size the 12-13px roles use, so neither competes with the topic cards. */
41
+ .powerLine {
42
+ margin: 0;
43
+ font-size: 12px;
44
+ line-height: 1.5;
45
+ color: var(--dsw-alias-label-secondary);
46
+ }
47
+
48
+ .powerError {
49
+ margin: 0;
50
+ font-size: 12px;
51
+ line-height: 1.5;
52
+ color: var(--dsw-alias-state-error-primary);
53
+ }
54
+
55
+ /* The nested disclosure cards of this card: the board and its runtime
56
+ behavior, goal acceptance, and the sections a provider extension
57
+ contributes. The gap keeps the stacked card
58
+ borders apart; an empty list draws nothing. */
59
+ .nestedCards {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 8px;
63
+ list-style: none;
64
+ margin: 0;
65
+ padding: 0;
66
+ }