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

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -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/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The board's own vector glyphs.
3
+ *
4
+ * Every control glyph the board draws comes from here, so one action always
5
+ * has one metaphor and one stroke recipe: a 16-unit viewBox, `currentColor`,
6
+ * 1.3 stroke, round caps and joins — the same recipe the sidebar panel glyph
7
+ * (`native-panel.tsx`) uses, which is why the board's chrome and its sidebar
8
+ * row read as one product. Icon-only controls pass a localized `title` and an
9
+ * accessible name at the call site; the glyph itself is always decorative.
10
+ *
11
+ * @module @linxin666/dsh-client-ui-task-board/client/board/icons
12
+ */
13
+ import type { ReactNode, ReactElement } from 'react'
14
+
15
+ /** Stroke geometry shared by every board glyph. */
16
+ const GLYPH = {
17
+ viewBox: '0 0 16 16',
18
+ fill: 'none',
19
+ stroke: 'currentColor',
20
+ strokeWidth: 1.3,
21
+ strokeLinecap: 'round',
22
+ strokeLinejoin: 'round',
23
+ 'aria-hidden': true,
24
+ } as const
25
+
26
+ /** Props of one board glyph: the shared recipe plus an explicit pixel size. */
27
+ export interface BoardIconProps {
28
+ /** Rendered square edge in CSS pixels. */
29
+ size?: number
30
+ }
31
+
32
+ /** Render one board glyph through the shared stroke recipe. */
33
+ function Glyph({ size = 14, children }: BoardIconProps & { children: ReactNode }): ReactElement {
34
+ return (
35
+ <svg {...GLYPH} width={size} height={size}>
36
+ {children}
37
+ </svg>
38
+ )
39
+ }
40
+
41
+ /** Leave the board: the back-to-chat chevron. */
42
+ export function IconChevronLeft(props: BoardIconProps): ReactElement {
43
+ return <Glyph {...props}><path d="M10 3.5 5.5 8 10 12.5" /></Glyph>
44
+ }
45
+
46
+ /** Dismiss a transient surface. */
47
+ export function IconClose(props: BoardIconProps): ReactElement {
48
+ return <Glyph {...props}><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" /></Glyph>
49
+ }
50
+
51
+ /** Add: create a task, a label row, or a subtask. */
52
+ export function IconPlus(props: BoardIconProps): ReactElement {
53
+ return <Glyph {...props}><path d="M8 3.5v9M3.5 8h9" /></Glyph>
54
+ }
55
+
56
+ /** Open the execution's session transcript. */
57
+ export function IconSession(props: BoardIconProps): ReactElement {
58
+ return (
59
+ <Glyph {...props}>
60
+ <rect x="2.2" y="3.2" width="11.6" height="9.6" rx="1.6" />
61
+ <path d="M5.3 6.6l1.9 1.7-1.9 1.7M8.8 10h2.4" />
62
+ </Glyph>
63
+ )
64
+ }
65
+
66
+ /** Switch a column to single-line cards. */
67
+ export function IconCompactRows(props: BoardIconProps): ReactElement {
68
+ return <Glyph {...props}><path d="M3 4.5h10M3 8h10M3 11.5h10" /></Glyph>
69
+ }
70
+
71
+ /** Switch a column back to full cards. */
72
+ export function IconExpandRows(props: BoardIconProps): ReactElement {
73
+ return (
74
+ <Glyph {...props}>
75
+ <rect x="2.6" y="2.6" width="10.8" height="4.4" rx="1.2" />
76
+ <rect x="2.6" y="9" width="10.8" height="4.4" rx="1.2" />
77
+ </Glyph>
78
+ )
79
+ }
80
+
81
+ /** A scheduled run: the recurrence mark on a card. */
82
+ export function IconClock(props: BoardIconProps): ReactElement {
83
+ return (
84
+ <Glyph {...props}>
85
+ <circle cx="8" cy="8" r="5.4" />
86
+ <path d="M8 5.2V8l2 1.4" />
87
+ </Glyph>
88
+ )
89
+ }
@@ -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,32 @@ 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
+ // Held for a human: the judge produced no usable veto, so this is NOT the
27
+ // quality-failure label.
28
+ invalid: 'running.verificationInvalid',
29
+ }
30
+
31
+ /**
32
+ * The acceptance label for one open execution, or undefined when acceptance
33
+ * does not govern it (the switch was off at start, the run never became a goal
34
+ * run, or a teammate is covered by its Lead).
35
+ * @param verification - the execution's acceptance state.
36
+ * @returns the locale key to render, or undefined for the historical label.
37
+ */
38
+ export function verificationRunningKey(verification: ExecutionVerification | undefined): TaskBoardKey | undefined {
39
+ if (verification === undefined || verification.applicability !== 'enforced') return undefined
40
+ return VERIFICATION_PHASE_KEY[verificationPhase(verification)]
41
+ }
@@ -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,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
+ }