@design.estate/dees-catalog 12.3.0 → 12.5.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 (81) hide show
  1. package/dist_bundle/bundle.js +2483 -1137
  2. package/dist_bundle/bundle.js.map +1 -1
  3. package/dist_ts_web/00_commitinfo_data.js +3 -3
  4. package/dist_ts_web/elements/00group-appui/dees-appui-mainmenu/dees-appui-mainmenu.d.ts +5 -0
  5. package/dist_ts_web/elements/00group-form/dees-form/dees-form.d.ts +2 -1
  6. package/dist_ts_web/elements/00group-form/dees-form/dees-form.js +2 -1
  7. package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.d.ts +13 -0
  8. package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.js +76 -7
  9. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.d.ts +42 -0
  10. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.d.ts +1 -0
  11. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.js +8 -0
  12. package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.js +347 -0
  13. package/dist_ts_web/elements/00group-input/dees-input-reference/document.d.ts +5 -0
  14. package/dist_ts_web/elements/00group-input/dees-input-reference/document.js +45 -0
  15. package/dist_ts_web/elements/00group-input/dees-input-reference/index.d.ts +2 -0
  16. package/dist_ts_web/elements/00group-input/dees-input-reference/index.js +3 -0
  17. package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.d.ts +16 -0
  18. package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.js +2 -0
  19. package/dist_ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.d.ts +12 -2
  20. package/dist_ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.js +11 -1
  21. package/dist_ts_web/elements/00group-input/index.d.ts +1 -0
  22. package/dist_ts_web/elements/00group-input/index.js +2 -1
  23. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +58 -0
  24. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.d.ts +1 -0
  25. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.js +9 -0
  26. package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +536 -0
  27. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +20 -0
  28. package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +157 -0
  29. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +23 -0
  30. package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +192 -0
  31. package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.d.ts +24 -0
  32. package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.js +211 -0
  33. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.d.ts +35 -0
  34. package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.js +269 -0
  35. package/dist_ts_web/elements/00group-kanban/index.d.ts +6 -0
  36. package/dist_ts_web/elements/00group-kanban/index.js +7 -0
  37. package/dist_ts_web/elements/00group-kanban/interfaces.d.ts +84 -0
  38. package/dist_ts_web/elements/00group-kanban/interfaces.js +2 -0
  39. package/dist_ts_web/elements/00group-kanban/kanban.demodata.d.ts +7 -0
  40. package/dist_ts_web/elements/00group-kanban/kanban.demodata.js +60 -0
  41. package/dist_ts_web/elements/00group-kanban/kanban.helpers.d.ts +6 -0
  42. package/dist_ts_web/elements/00group-kanban/kanban.helpers.js +11 -0
  43. package/dist_ts_web/elements/00group-kanban/kanban.styles.d.ts +1 -0
  44. package/dist_ts_web/elements/00group-kanban/kanban.styles.js +18 -0
  45. package/dist_ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.js +7 -2
  46. package/dist_ts_web/elements/00group-workspace/dees-workspace/dees-workspace.js +3 -3
  47. package/dist_ts_web/elements/00group-workspace/dees-workspace/styles.js +10 -2
  48. package/dist_ts_web/elements/index.d.ts +1 -0
  49. package/dist_ts_web/elements/index.js +2 -1
  50. package/dist_ts_web/services/DeesServiceLibLoader.d.ts +1 -0
  51. package/dist_ts_web/services/DeesServiceLibLoader.js +2 -1
  52. package/package.json +1 -1
  53. package/readme.md +154 -0
  54. package/scripts/check-packed-consumer.mjs +1 -1
  55. package/ts_web/00_commitinfo_data.ts +3 -3
  56. package/ts_web/elements/00group-appui/dees-appui-mainmenu/dees-appui-mainmenu.ts +6 -0
  57. package/ts_web/elements/00group-form/dees-form/dees-form.ts +2 -1
  58. package/ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.ts +40 -5
  59. package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.ts +9 -0
  60. package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.ts +221 -0
  61. package/ts_web/elements/00group-input/dees-input-reference/document.ts +38 -0
  62. package/ts_web/elements/00group-input/dees-input-reference/index.ts +2 -0
  63. package/ts_web/elements/00group-input/dees-input-reference/interfaces.ts +8 -0
  64. package/ts_web/elements/00group-input/dees-input-wysiwyg/dees-input-wysiwyg.ts +11 -1
  65. package/ts_web/elements/00group-input/index.ts +1 -0
  66. package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.ts +9 -0
  67. package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +315 -0
  68. package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +58 -0
  69. package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +60 -0
  70. package/ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.ts +81 -0
  71. package/ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.ts +114 -0
  72. package/ts_web/elements/00group-kanban/index.ts +6 -0
  73. package/ts_web/elements/00group-kanban/interfaces.ts +67 -0
  74. package/ts_web/elements/00group-kanban/kanban.demodata.ts +56 -0
  75. package/ts_web/elements/00group-kanban/kanban.helpers.ts +13 -0
  76. package/ts_web/elements/00group-kanban/kanban.styles.ts +18 -0
  77. package/ts_web/elements/00group-simple/dees-simple-appdash/dees-simple-appdash.ts +6 -1
  78. package/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts +2 -2
  79. package/ts_web/elements/00group-workspace/dees-workspace/styles.ts +9 -1
  80. package/ts_web/elements/index.ts +1 -0
  81. package/ts_web/services/DeesServiceLibLoader.ts +2 -0
@@ -0,0 +1,58 @@
1
+ import { DeesElement, customElement, property, html, css } from '@design.estate/dees-element';
2
+ import type { IKanbanTicket, IKanbanColumn } from '../interfaces.js';
3
+ import { kanbanStyles } from '../kanban.styles.js';
4
+ import { kanbanEvent } from '../kanban.helpers.js';
5
+ import '../../00group-button/dees-button/dees-button.js';
6
+ import '../../00group-utility/dees-icon/dees-icon.js';
7
+
8
+ declare global { interface HTMLElementTagNameMap { 'dees-kanban-card': DeesKanbanCard; } }
9
+
10
+ @customElement('dees-kanban-card')
11
+ export class DeesKanbanCard extends DeesElement {
12
+ @property({ attribute: false }) accessor ticket: IKanbanTicket | undefined;
13
+ @property({ attribute: false }) accessor epic: IKanbanTicket | undefined;
14
+ @property({ attribute: false }) accessor subtasks: IKanbanTicket[] = [];
15
+ @property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
16
+ @property({ type: Boolean }) accessor movable = false;
17
+ @property({ type: Boolean }) accessor pending = false;
18
+ public static styles = [...kanbanStyles, css`
19
+ article { padding: 14px; border: 1px solid var(--dees-color-border-default); border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-primary); box-shadow: var(--dees-shadow-xs); }
20
+ article:hover { border-color: var(--dees-color-border-strong); }
21
+ article[aria-busy=true] { opacity: .65; }
22
+ .title { display: block; width: 100%; border: 0; background: transparent; padding: 8px 0 12px; color: inherit; text-align: left; cursor: pointer; font: inherit; font-weight: 550; line-height: 1.45; overflow-wrap: anywhere; }
23
+ .key { font-variant-numeric: tabular-nums; }
24
+ .link.epic { margin-bottom: 5px; max-width: 100%; font-size: var(--dees-font-caption1-size); }
25
+ .labels { margin-bottom: 10px; }
26
+ .priority[data-priority=urgent], .priority[data-priority=high] { color: var(--dees-color-text-warning); }
27
+ .avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--dees-color-badge-default-bg); display: inline-grid; place-items: center; font-size: 10px; font-weight: 600; }
28
+ summary { cursor: pointer; padding: 10px 0 2px; font-size: var(--dees-font-footnote-size); color: var(--dees-color-text-secondary); }
29
+ .subtask { display: flex; gap: 6px; align-items: baseline; padding-top: 8px; font-size: var(--dees-font-footnote-size); }
30
+ `];
31
+ private open(ticketId: string): void { kanbanEvent(this, 'kanban-ticket-open', { ticketId }); }
32
+ public render() {
33
+ const ticket = this.ticket;
34
+ if (!ticket) return html``;
35
+ const completed = this.subtasks.filter(task => this.columns.some(column => column.id === task.columnId && column.done)).length;
36
+ return html`<article aria-busy=${String(this.pending)} draggable=${this.movable && !this.pending ? 'true' : 'false'}
37
+ @dragstart=${(event: DragEvent) => {
38
+ if (!this.movable || this.pending || !event.dataTransfer) { event.preventDefault(); return; }
39
+ event.dataTransfer.setData('application/x-dees-kanban-ticket', ticket.id);
40
+ event.dataTransfer.effectAllowed = 'move';
41
+ kanbanEvent(this, 'kanban-drag-start', { ticketId: ticket.id });
42
+ }} @dragend=${() => kanbanEvent(this, 'kanban-drag-end', {})}>
43
+ ${this.epic ? html`<button class="link epic" @click=${() => this.open(this.epic!.id)}>${this.epic.title}</button>` : ''}
44
+ <div class="row caption"><span class="key">${ticket.key}</span><span class="spacer"></span>
45
+ ${ticket.priority && ticket.priority !== 'none' ? html`<span class="priority" data-priority=${ticket.priority}>${ticket.priority}</span>` : ''}</div>
46
+ <button class="title" @click=${() => this.open(ticket.id)}>${ticket.title}</button>
47
+ ${ticket.labels?.length ? html`<div class="row labels">${ticket.labels.map(label => html`<span class="badge">${label}</span>`)}</div>` : ''}
48
+ <div class="row caption"><span class="avatar" title=${ticket.assignee?.name ?? 'Unassigned'}>${ticket.assignee?.name.split(/\s+/).map(part => part[0]).join('').slice(0, 2) ?? '–'}</span>
49
+ <span>${ticket.assignee?.name ?? 'Unassigned'}</span><span class="spacer"></span>
50
+ ${this.movable ? html`<dees-button size="icon" type="ghost" icon="lucide:Ellipsis" text="Move ticket" .disabled=${this.pending}
51
+ @clicked=${(event: Event) => kanbanEvent(this, 'kanban-move-menu', { ticketId: ticket.id, anchor: event.currentTarget as HTMLElement })}></dees-button>` : ''}
52
+ </div>
53
+ ${this.subtasks.length ? html`<details><summary>${completed}/${this.subtasks.length} subtasks complete</summary>
54
+ ${this.subtasks.map(task => html`<div class="subtask"><span>${this.columns.some(column => column.id === task.columnId && column.done) ? '✓' : '○'}</span>
55
+ <button class="link" @click=${() => this.open(task.id)}>${task.title}</button></div>`)}</details>` : ''}
56
+ </article>`;
57
+ }
58
+ }
@@ -0,0 +1,60 @@
1
+ import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
2
+ import type { IKanbanColumn, IKanbanTicket } from '../interfaces.js';
3
+ import { kanbanStyles } from '../kanban.styles.js';
4
+ import { kanbanEvent, sortKanbanTickets } from '../kanban.helpers.js';
5
+ import '../dees-kanban-card/dees-kanban-card.js';
6
+
7
+ declare global { interface HTMLElementTagNameMap { 'dees-kanban-column': DeesKanbanColumn; } }
8
+
9
+ @customElement('dees-kanban-column')
10
+ export class DeesKanbanColumn extends DeesElement {
11
+ @property({ attribute: false }) accessor column: IKanbanColumn | undefined;
12
+ @property({ attribute: false }) accessor tickets: IKanbanTicket[] = [];
13
+ @property({ attribute: false }) accessor allTickets: IKanbanTicket[] = [];
14
+ @property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
15
+ @property({ type: Boolean }) accessor movable = false;
16
+ @property({ type: Boolean }) accessor canCreate = false;
17
+ @property({ type: String }) accessor pendingTicketId = '';
18
+ @property({ type: String }) accessor draggingTicketId = '';
19
+ @state() private accessor dropBefore: string | undefined;
20
+ @state() private accessor draggingOver = false;
21
+ public static styles = [...kanbanStyles, css`
22
+ :host { display: flex; flex-direction: column; width: 300px; min-width: 270px; flex: 1 0 270px; }
23
+ section { min-height: 220px; height: 100%; border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-secondary); padding: 10px; }
24
+ header { display: flex; align-items: center; gap: 8px; padding: 3px 3px 12px; }
25
+ h3 { font-size: var(--dees-font-body-size); font-weight: 600; }
26
+ .count { font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
27
+ .stack { display: grid; gap: 10px; min-height: 80px; align-content: start; }
28
+ .target { position: relative; }
29
+ .target.before::before, .end-target.before::before { content: ''; display: block; height: 3px; background: var(--dees-color-accent-primary); border-radius: 2px; margin-bottom: 7px; }
30
+ .end-target { min-height: 20px; padding-top: 10px; }
31
+ section.over { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 1px; }
32
+ `];
33
+ public updated(): void { if (!this.draggingTicketId) this.draggingOver = false; }
34
+ public render() {
35
+ if (!this.column) return html``;
36
+ return html`<section class=${this.draggingOver ? 'over' : ''} aria-label=${this.column.title}
37
+ @dragover=${(event: DragEvent) => { if (this.draggingTicketId && this.movable) { event.preventDefault(); this.draggingOver = true; if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; } }}
38
+ @dragleave=${(event: DragEvent) => { if (!(event.relatedTarget instanceof Node) || !(event.currentTarget as Node).contains(event.relatedTarget)) this.draggingOver = false; }}
39
+ @drop=${(event: DragEvent) => {
40
+ event.preventDefault(); this.draggingOver = false;
41
+ if (this.movable && this.draggingTicketId && event.dataTransfer?.getData('application/x-dees-kanban-ticket') === this.draggingTicketId)
42
+ kanbanEvent(this, 'kanban-ticket-move', { ticketId: this.draggingTicketId, columnId: this.column!.id, beforeTicketId: this.dropBefore });
43
+ }}>
44
+ <header><h3>${this.column.title}</h3><span class="count">${this.tickets.length}</span><span class="spacer"></span>
45
+ ${this.canCreate ? html`<dees-button type="ghost" size="icon" icon="lucide:Plus" text=${`Add task to ${this.column.title}`}
46
+ @clicked=${() => kanbanEvent(this, 'kanban-ticket-create', { kind: 'task', columnId: this.column!.id })}></dees-button>` : ''}</header>
47
+ <div class="stack">${sortKanbanTickets(this.tickets).map(ticket => html`
48
+ <div class="target ${this.draggingOver && this.dropBefore === ticket.id ? 'before' : ''}"
49
+ @dragover=${() => { this.dropBefore = ticket.id; }}>
50
+ <dees-kanban-card .ticket=${ticket} .columns=${this.columns}
51
+ .epic=${this.allTickets.find(item => item.id === ticket.parentId && item.kind === 'epic')}
52
+ .subtasks=${sortKanbanTickets(this.allTickets.filter(item => item.parentId === ticket.id && item.kind === 'subtask'))}
53
+ .movable=${this.movable && ticket.permissions?.move !== false} .pending=${this.pendingTicketId === ticket.id}></dees-kanban-card>
54
+ </div>`)}
55
+ ${!this.tickets.length ? html`<div class="empty" @dragover=${() => { this.dropBefore = undefined; }}>No tasks</div>` : ''}
56
+ </div>
57
+ <div class="end-target ${this.draggingOver && !this.dropBefore ? 'before' : ''}" @dragover=${() => { this.dropBefore = undefined; }}></div>
58
+ </section>`;
59
+ }
60
+ }
@@ -0,0 +1,81 @@
1
+ import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
2
+ import type { IKanbanComment, IKanbanProvider } from '../interfaces.js';
3
+ import { kanbanStyles } from '../kanban.styles.js';
4
+ import { kanbanError, kanbanEvent } from '../kanban.helpers.js';
5
+ import { referenceText, type TReferenceContent, type TReferenceSearch } from '../../00group-input/dees-input-reference/index.js';
6
+ import '../../00group-button/dees-button/dees-button.js';
7
+
8
+ declare global { interface HTMLElementTagNameMap { 'dees-kanban-discussion': DeesKanbanDiscussion; } }
9
+
10
+ @customElement('dees-kanban-discussion')
11
+ export class DeesKanbanDiscussion extends DeesElement {
12
+ @property({ type: String }) accessor ticketId = '';
13
+ @property({ attribute: false }) accessor comments: IKanbanComment[] = [];
14
+ @property({ attribute: false }) accessor draft: TReferenceContent = [];
15
+ @property({ attribute: false }) accessor searchTickets: TReferenceSearch | undefined;
16
+ @property({ attribute: false }) accessor addComment: IKanbanProvider['addComment'];
17
+ @property({ type: Boolean }) accessor loading = false;
18
+ @property({ type: Boolean }) accessor readOnly = false;
19
+ @property({ type: String }) accessor error = '';
20
+ @state() private accessor pending = new Set<string>();
21
+ @state() private accessor failures = new Map<string, string>();
22
+ public static styles = [...kanbanStyles, css`
23
+ header { display: flex; align-items: center; margin-bottom: 16px; }
24
+ h3 { font-size: var(--dees-font-body-size); font-weight: 600; }
25
+ ol { list-style: none; padding: 0; margin: 0; }
26
+ li { padding: 0 0 20px; }
27
+ .author { font-weight: 550; font-size: var(--dees-font-footnote-size); }
28
+ time { font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
29
+ .message { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; margin-top: 6px; }
30
+ .reference { display: inline; border: 0; border-radius: var(--dees-radius-sm); padding: 1px 4px; color: var(--dees-color-link); background: var(--dees-color-badge-default-bg); font: inherit; cursor: pointer; }
31
+ .compose { padding-top: 4px; }
32
+ .compose .row { padding-top: 8px; }
33
+ `];
34
+ public render() {
35
+ const pending = this.pending.has(this.ticketId);
36
+ const comments = this.comments.filter(comment => comment.ticketId === this.ticketId)
37
+ .slice().sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
38
+ return html`<header><h3>Discussion <span class="caption">${comments.length}</span></h3><span class="spacer"></span>
39
+ <dees-button size="sm" type="ghost" icon="lucide:RefreshCw" text="Refresh" .disabled=${this.loading}
40
+ @clicked=${() => kanbanEvent(this, 'kanban-comments-refresh', { ticketId: this.ticketId })}></dees-button></header>
41
+ ${this.loading ? html`<p class="caption" role="status">Loading comments…</p>` : ''}
42
+ ${this.error ? html`<p class="error" role="alert">${this.error}</p>` : ''}
43
+ <ol aria-label="Comments">${comments.map(comment => html`<li>
44
+ <div class="row"><span class="author">${comment.author.name}</span><time datetime=${comment.createdAt}>${new Date(comment.createdAt).toLocaleString()}</time></div>
45
+ <div class="message">${comment.body.map(part => part.type === 'text' ? part.text : html`<button class="reference"
46
+ @click=${() => kanbanEvent(this, 'kanban-ticket-open', { ticketId: part.id })}>${part.label}</button>`)}</div>
47
+ </li>`)}</ol>
48
+ ${!comments.length && !this.loading && !this.error ? html`<p class="empty">Start the conversation.</p>` : ''}
49
+ ${this.addComment && !this.readOnly ? html`<div class="compose">
50
+ <dees-input-reference label="Comment" placeholder="Write a comment…" .documentKey=${this.ticketId} .value=${this.draft} .disabled=${pending}
51
+ .searchReferences=${this.searchTickets}
52
+ @reference-input=${(event: CustomEvent<{ value: TReferenceContent }>) => {
53
+ this.draft = event.detail.value;
54
+ kanbanEvent(this, 'kanban-comment-draft', { ticketId: this.ticketId, body: this.draft });
55
+ }}
56
+ @reference-open=${(event: CustomEvent<{ id: string }>) => kanbanEvent(this, 'kanban-ticket-open', { ticketId: event.detail.id })}
57
+ @reference-submit=${() => { void this.submit(); }}></dees-input-reference>
58
+ <div class="row"><span class="caption">Type #_ to link a ticket · Ctrl/⌘ Enter to send</span><span class="spacer"></span>
59
+ <dees-button type="accent" size="sm" text="Comment" .disabled=${pending || !referenceText(this.draft).trim()}
60
+ .status=${pending ? 'pending' : 'normal'} @clicked=${() => { void this.submit(); }}></dees-button></div>
61
+ ${this.failures.get(this.ticketId) ? html`<p class="error" role="alert">${this.failures.get(this.ticketId)}</p>` : ''}
62
+ </div>` : html`<p class="caption">Comments are read-only.</p>`}`;
63
+ }
64
+ public async submit(): Promise<void> {
65
+ const ticketId = this.ticketId;
66
+ if (!ticketId || !this.addComment || this.readOnly || this.pending.has(ticketId) || !referenceText(this.draft).trim()) return;
67
+ const body = this.draft.map(part => ({ ...part }));
68
+ this.pending = new Set(this.pending).add(ticketId);
69
+ this.failures = new Map(this.failures); this.failures.delete(ticketId);
70
+ try {
71
+ const comment = await this.addComment({ ticketId, body });
72
+ if (comment.ticketId !== ticketId) throw new Error('The saved comment belongs to a different ticket.');
73
+ if (this.ticketId === ticketId && JSON.stringify(this.draft) === JSON.stringify(body)) this.draft = [];
74
+ kanbanEvent(this, 'kanban-comment-added', { comment, submittedBody: body });
75
+ } catch (error) {
76
+ this.failures = new Map(this.failures).set(ticketId, kanbanError(error));
77
+ } finally {
78
+ this.pending = new Set(this.pending); this.pending.delete(ticketId);
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,114 @@
1
+ import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
2
+ import type { IKanbanColumn, IKanbanComment, IKanbanPerson, IKanbanProvider, IKanbanTicket, IKanbanTicketDraft, TKanbanPriority } from '../interfaces.js';
3
+ import type { TReferenceContent, TReferenceSearch } from '../../00group-input/dees-input-reference/interfaces.js';
4
+ import { kanbanStyles } from '../kanban.styles.js';
5
+ import { kanbanEvent, sortKanbanTickets, ticketDraft, ticketLabel } from '../kanban.helpers.js';
6
+ import type { DeesInputText } from '../../00group-input/dees-input-text/dees-input-text.js';
7
+ import type { DeesInputTextarea } from '../../00group-input/dees-input-textarea/dees-input-textarea.js';
8
+ import '../../00group-input/dees-input-text/dees-input-text.js';
9
+ import '../../00group-input/dees-input-textarea/dees-input-textarea.js';
10
+ import '../../00group-input/dees-input-dropdown/dees-input-dropdown.js';
11
+ import '../dees-kanban-discussion/dees-kanban-discussion.js';
12
+
13
+ declare global { interface HTMLElementTagNameMap { 'dees-kanban-ticket-detail': DeesKanbanTicketDetail; } }
14
+
15
+ @customElement('dees-kanban-ticket-detail')
16
+ export class DeesKanbanTicketDetail extends DeesElement {
17
+ @property({ attribute: false }) accessor ticket: IKanbanTicket | undefined;
18
+ @property({ attribute: false }) accessor tickets: IKanbanTicket[] = [];
19
+ @property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
20
+ @property({ attribute: false }) accessor people: IKanbanPerson[] = [];
21
+ @property({ attribute: false }) accessor provider: IKanbanProvider = {};
22
+ @property({ attribute: false }) accessor draft: IKanbanTicketDraft | undefined;
23
+ @property({ attribute: false }) accessor commentDraft: TReferenceContent = [];
24
+ @property({ attribute: false }) accessor comments: IKanbanComment[] = [];
25
+ @property({ attribute: false }) accessor searchTickets: TReferenceSearch | undefined;
26
+ @property({ type: Boolean }) accessor loadingComments = false;
27
+ @property({ type: String }) accessor commentsError = '';
28
+ @property({ type: String }) accessor error = '';
29
+ @property({ type: Boolean }) accessor saving = false;
30
+ @property({ type: Boolean }) accessor canGoBack = false;
31
+ @state() private accessor editing = false;
32
+ public static styles = [...kanbanStyles, css`
33
+ .layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 28px; }
34
+ .main { min-width: 0; }
35
+ .toolbar { margin-bottom: 16px; }
36
+ h2 { font-size: var(--dees-font-title2-size); line-height: 1.3; font-weight: 600; margin-bottom: 18px; overflow-wrap: anywhere; }
37
+ .description { white-space: pre-wrap; line-height: 1.65; overflow-wrap: anywhere; margin-bottom: 24px; }
38
+ aside { border-left: 1px solid var(--dees-color-border-subtle); padding-left: 22px; }
39
+ .fields { display: grid; gap: 16px; }
40
+ .fields label { display: block; margin-bottom: 6px; }
41
+ h3 { font-size: var(--dees-font-body-size); margin-bottom: 12px; }
42
+ .children { margin-bottom: 24px; }
43
+ .child { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--dees-color-border-subtle); }
44
+ .discussion { border-top: 1px solid var(--dees-color-border-subtle); padding-top: 22px; }
45
+ .edit { display: grid; gap: 14px; margin-bottom: 22px; }
46
+ @media (max-width: 700px) { .layout { grid-template-columns: minmax(0, 1fr); gap: 24px; } aside { grid-row: 1; border-left: 0; padding-left: 0; } .fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
47
+ `];
48
+ public updated(changed: Map<string, unknown>): void {
49
+ const previous = changed.get('ticket') as IKanbanTicket | undefined;
50
+ if (changed.has('ticket') && previous?.id !== this.ticket?.id) this.editing = Boolean(this.draft);
51
+ }
52
+ private changeDraft(change: Partial<IKanbanTicketDraft>): void {
53
+ this.draft = { ...(this.draft ?? ticketDraft(this.ticket!)), ...change };
54
+ kanbanEvent(this, 'kanban-ticket-draft', { ticketId: this.ticket!.id, draft: this.draft });
55
+ }
56
+ private addComment = (input: Parameters<NonNullable<IKanbanProvider['addComment']>>[0]) => this.provider.addComment!(input);
57
+ public render() {
58
+ const ticket = this.ticket;
59
+ if (!ticket) return html``;
60
+ const draft = this.draft ?? ticketDraft(ticket);
61
+ const parent = this.tickets.find(item => item.id === ticket.parentId);
62
+ const children = sortKanbanTickets(this.tickets.filter(item => item.parentId === ticket.id));
63
+ const editable = Boolean(this.provider.updateTicket) && ticket.permissions?.edit !== false;
64
+ const movable = Boolean(this.provider.moveTicket) && ticket.permissions?.move !== false;
65
+ const parents = this.tickets.filter(item => item.kind === (ticket.kind === 'subtask' ? 'task' : 'epic'));
66
+ return html`<div class="toolbar row">
67
+ ${this.canGoBack ? html`<dees-button size="sm" type="ghost" icon="lucide:ArrowLeft" text="Back" @clicked=${() => kanbanEvent(this, 'kanban-detail-back', {})}></dees-button>` : ''}
68
+ <span class="badge">${ticket.kind}</span><span class="caption">${ticket.key}</span>
69
+ ${parent ? html`<span class="caption">/</span><button class="link caption" @click=${() => kanbanEvent(this, 'kanban-ticket-open', { ticketId: parent.id })}>${parent.title}</button>` : ''}
70
+ <span class="spacer"></span>${editable && !this.editing ? html`<dees-button size="sm" type="ghost" text="Edit ticket" icon="lucide:Pencil" @clicked=${() => { this.editing = true; this.changeDraft({}); }}></dees-button>` : ''}
71
+ </div>
72
+ ${this.error ? html`<p class="error" role="alert">${this.error}</p>` : ''}
73
+ <div class="layout"><div class="main">
74
+ ${this.editing && editable ? html`<div class="edit">
75
+ <dees-input-text label="Title" .value=${draft.title} .disabled=${this.saving}
76
+ @input=${(event: Event) => this.changeDraft({ title: String((event.currentTarget as DeesInputText).value ?? '') })}></dees-input-text>
77
+ <dees-input-textarea label="Description" .value=${draft.description} .rows=${5} .disabled=${this.saving}
78
+ @input=${(event: Event) => this.changeDraft({ description: (event.currentTarget as DeesInputTextarea).value })}></dees-input-textarea>
79
+ <div class="row"><dees-button size="sm" type="accent" text="Save changes" .disabled=${this.saving || !draft.title.trim()} .status=${this.saving ? 'pending' : 'normal'}
80
+ @clicked=${() => kanbanEvent(this, 'kanban-ticket-save', { ticketId: ticket.id, draft })}></dees-button>
81
+ <dees-button size="sm" type="ghost" text="Cancel" .disabled=${this.saving} @clicked=${() => {
82
+ this.editing = false; this.draft = undefined; kanbanEvent(this, 'kanban-ticket-draft', { ticketId: ticket.id, draft: undefined });
83
+ }}></dees-button></div>
84
+ </div>` : html`<h2>${ticket.title}</h2><div class="description ${ticket.description ? '' : 'muted'}">${ticket.description || 'No description yet.'}</div>`}
85
+ ${ticket.kind !== 'subtask' ? html`<section class="children"><div class="row"><h3>${ticket.kind === 'epic' ? 'Tasks' : 'Subtasks'}</h3><span class="spacer"></span>
86
+ ${this.provider.createTicket && ticket.permissions?.createChild !== false ? html`<dees-button size="sm" type="ghost" icon="lucide:Plus" text=${ticket.kind === 'epic' ? 'Add task' : 'Add subtask'}
87
+ @clicked=${() => kanbanEvent(this, 'kanban-ticket-create', { kind: ticket.kind === 'epic' ? 'task' : 'subtask', parentId: ticket.id, columnId: this.columns[0]?.id ?? ticket.columnId })}></dees-button>` : ''}</div>
88
+ ${children.map(child => html`<div class="child"><button class="link" @click=${() => kanbanEvent(this, 'kanban-ticket-open', { ticketId: child.id })}>${ticketLabel(child)}</button>
89
+ <span class="badge">${this.columns.find(column => column.id === child.columnId)?.title ?? child.columnId}</span></div>`)}
90
+ ${!children.length ? html`<p class="caption">No ${ticket.kind === 'epic' ? 'tasks' : 'subtasks'} yet.</p>` : ''}</section>` : ''}
91
+ <dees-kanban-discussion class="discussion" .ticketId=${ticket.id} .comments=${this.comments} .draft=${this.commentDraft}
92
+ .loading=${this.loadingComments} .error=${this.commentsError} .readOnly=${ticket.permissions?.comment === false}
93
+ .searchTickets=${this.searchTickets} .addComment=${this.provider.addComment ? this.addComment : undefined}></dees-kanban-discussion>
94
+ </div><aside><div class="fields">
95
+ <dees-input-dropdown label="Status" .options=${this.columns.map(column => ({ key: column.id, option: column.title }))}
96
+ .value=${{ key: ticket.columnId, option: this.columns.find(column => column.id === ticket.columnId)?.title ?? ticket.columnId }}
97
+ .disabled=${!movable || this.saving} @selectedOption=${(event: CustomEvent<{ key: string }>) => kanbanEvent(this, 'kanban-ticket-move', { ticketId: ticket.id, columnId: event.detail.key })}></dees-input-dropdown>
98
+ ${this.editing && editable ? html`
99
+ <dees-input-dropdown label="Priority" .disabled=${this.saving} .value=${{ key: draft.priority, option: draft.priority }}
100
+ .options=${['none', 'low', 'normal', 'high', 'urgent'].map(key => ({ key, option: key }))}
101
+ @selectedOption=${(event: CustomEvent<{ key: TKanbanPriority }>) => this.changeDraft({ priority: event.detail.key })}></dees-input-dropdown>
102
+ <dees-input-dropdown label="Assignee" .disabled=${this.saving} .value=${{ key: draft.assigneeId, option: this.people.find(person => person.id === draft.assigneeId)?.name ?? ticket.assignee?.name ?? 'Unassigned' }}
103
+ .options=${[{ key: '', option: 'Unassigned' }, ...this.people.map(person => ({ key: person.id, option: person.name }))]}
104
+ @selectedOption=${(event: CustomEvent<{ key: string }>) => this.changeDraft({ assigneeId: event.detail.key })}></dees-input-dropdown>
105
+ ${ticket.kind !== 'epic' ? html`<dees-input-dropdown label=${ticket.kind === 'task' ? 'Epic' : 'Parent task'} .disabled=${this.saving}
106
+ .value=${{ key: draft.parentId, option: parents.find(item => item.id === draft.parentId)?.title ?? 'None' }}
107
+ .options=${[...(ticket.kind === 'task' ? [{ key: '', option: 'None' }] : []), ...parents.map(item => ({ key: item.id, option: ticketLabel(item) }))]}
108
+ @selectedOption=${(event: CustomEvent<{ key: string }>) => this.changeDraft({ parentId: event.detail.key })}></dees-input-dropdown>` : ''}
109
+ ` : html`<div><label class="caption">Priority</label>${ticket.priority ?? 'none'}</div><div><label class="caption">Assignee</label>${ticket.assignee?.name ?? 'Unassigned'}</div>`}
110
+ ${ticket.labels?.length ? html`<div><label class="caption">Labels</label><div class="row">${ticket.labels.map(label => html`<span class="badge">${label}</span>`)}</div></div>` : ''}
111
+ </div></aside></div>`;
112
+ }
113
+ public finishEditing(): void { this.editing = false; }
114
+ }
@@ -0,0 +1,6 @@
1
+ export * from './interfaces.js';
2
+ export * from './dees-kanban/dees-kanban.js';
3
+ export * from './dees-kanban-card/dees-kanban-card.js';
4
+ export * from './dees-kanban-column/dees-kanban-column.js';
5
+ export * from './dees-kanban-ticket-detail/dees-kanban-ticket-detail.js';
6
+ export * from './dees-kanban-discussion/dees-kanban-discussion.js';
@@ -0,0 +1,67 @@
1
+ import type { TReferenceContent } from '../00group-input/dees-input-reference/interfaces.js';
2
+
3
+ export type TKanbanTicketKind = 'epic' | 'task' | 'subtask';
4
+ export type TKanbanPriority = 'none' | 'low' | 'normal' | 'high' | 'urgent';
5
+ export interface IKanbanPerson { id: string; name: string; }
6
+ export interface IKanbanColumn { id: string; title: string; /** Marks completed subtasks. */ done?: boolean; }
7
+ export interface IKanbanTicket {
8
+ id: string;
9
+ key: string;
10
+ kind: TKanbanTicketKind;
11
+ title: string;
12
+ description?: string;
13
+ /** Task -> epic, subtask -> task. Epics have no parent. */
14
+ parentId?: string;
15
+ columnId: string;
16
+ order: number;
17
+ priority?: TKanbanPriority;
18
+ assignee?: IKanbanPerson;
19
+ labels?: string[];
20
+ /** UI capabilities; the provider must enforce authorization independently. */
21
+ permissions?: { edit?: boolean; move?: boolean; comment?: boolean; createChild?: boolean };
22
+ }
23
+ export interface IKanbanComment {
24
+ id: string;
25
+ ticketId: string;
26
+ author: IKanbanPerson;
27
+ /** ISO 8601 timestamp. */
28
+ createdAt: string;
29
+ body: TReferenceContent;
30
+ }
31
+ export interface IKanbanTicketChanges {
32
+ title?: string;
33
+ description?: string;
34
+ priority?: TKanbanPriority;
35
+ assignee?: IKanbanPerson | null;
36
+ parentId?: string | null;
37
+ }
38
+ export interface IKanbanTicketDraft {
39
+ title: string;
40
+ description: string;
41
+ priority: TKanbanPriority;
42
+ assigneeId: string;
43
+ parentId: string;
44
+ }
45
+ export interface IKanbanMove {
46
+ ticketId: string;
47
+ columnId: string;
48
+ /** Omitted means append. Provider assigns ordering atomically with the move. */
49
+ beforeTicketId?: string;
50
+ }
51
+ export interface IKanbanCreate {
52
+ kind: TKanbanTicketKind;
53
+ title: string;
54
+ columnId: string;
55
+ parentId?: string;
56
+ }
57
+ /** Missing mutation callbacks make the corresponding controls read-only. AbortSignals cancel obsolete reads. */
58
+ export interface IKanbanProvider {
59
+ getTicket?: (id: string, signal: AbortSignal) => Promise<IKanbanTicket>;
60
+ searchTickets?: (query: string, signal: AbortSignal) => Promise<IKanbanTicket[]>;
61
+ loadComments?: (ticketId: string, signal: AbortSignal) => Promise<IKanbanComment[]>;
62
+ addComment?: (input: { ticketId: string; body: TReferenceContent }) => Promise<IKanbanComment>;
63
+ updateTicket?: (input: { ticketId: string; changes: IKanbanTicketChanges }) => Promise<IKanbanTicket>;
64
+ /** Return all records whose order or column changed. */
65
+ moveTicket?: (input: IKanbanMove) => Promise<IKanbanTicket[]>;
66
+ createTicket?: (input: IKanbanCreate) => Promise<IKanbanTicket>;
67
+ }
@@ -0,0 +1,56 @@
1
+ import type { IKanbanColumn, IKanbanComment, IKanbanPerson, IKanbanProvider, IKanbanTicket } from './interfaces.js';
2
+
3
+ export const kanbanDemoPeople: IKanbanPerson[] = [{ id: 'mara', name: 'Mara Chen' }, { id: 'jonas', name: 'Jonas Berg' }, { id: 'alex', name: 'Alex Rivera' }];
4
+ export const kanbanDemoColumns: IKanbanColumn[] = [{ id: 'backlog', title: 'Backlog' }, { id: 'progress', title: 'In progress' }, { id: 'review', title: 'In review' }, { id: 'done', title: 'Done', done: true }];
5
+ export const createKanbanDemo = (): { tickets: IKanbanTicket[]; provider: IKanbanProvider } => {
6
+ let tickets: IKanbanTicket[] = [
7
+ { id: 'epic-1', key: 'CAT-100', kind: 'epic', title: 'A calmer workspace', description: 'Make daily work feel focused, connected and easy to navigate.', columnId: 'progress', order: 0 },
8
+ { id: 'epic-2', key: 'CAT-200', kind: 'epic', title: 'Built for teams', description: 'Keep everyone close to the work and the decisions behind it.', columnId: 'progress', order: 1 },
9
+ { id: 'task-1', key: 'CAT-101', kind: 'task', title: 'Design the project overview', description: 'Give each project a clear home. Surface the next action, recent activity and open questions.', parentId: 'epic-1', columnId: 'backlog', order: 0, priority: 'normal', assignee: kanbanDemoPeople[0], labels: ['Design'] },
10
+ { id: 'task-2', key: 'CAT-102', kind: 'task', title: 'Connect related tickets', description: 'Type #_ in a comment to search for another ticket. Keep the reference connected when its title changes.', parentId: 'epic-2', columnId: 'progress', order: 0, priority: 'high', assignee: kanbanDemoPeople[1], labels: ['Collaboration'] },
11
+ { id: 'task-3', key: 'CAT-103', kind: 'task', title: 'Make every action keyboard accessible', description: 'Support focus, selection, navigation and moving tickets without a mouse.', parentId: 'epic-1', columnId: 'progress', order: 1, priority: 'normal', assignee: kanbanDemoPeople[2], labels: ['Accessibility'] },
12
+ { id: 'task-4', key: 'CAT-104', kind: 'task', title: 'Review the ticket detail experience', description: 'Check hierarchy navigation, discussion drafts and small-screen layouts.', parentId: 'epic-2', columnId: 'review', order: 0, priority: 'low', assignee: kanbanDemoPeople[0] },
13
+ { id: 'task-5', key: 'CAT-105', kind: 'task', title: 'Unify status and priority styles', description: 'Use shared catalog tokens in light and dark themes.', parentId: 'epic-1', columnId: 'done', order: 0, assignee: kanbanDemoPeople[2], labels: ['Foundations'] },
14
+ { id: 'sub-1', key: 'CAT-106', kind: 'subtask', title: 'Anchor search to the cursor', parentId: 'task-2', columnId: 'done', order: 0, assignee: kanbanDemoPeople[1] },
15
+ { id: 'sub-2', key: 'CAT-107', kind: 'subtask', title: 'Restore focus after selection', parentId: 'task-2', columnId: 'progress', order: 1, assignee: kanbanDemoPeople[1] },
16
+ { id: 'sub-3', key: 'CAT-108', kind: 'subtask', title: 'Check arrow-key navigation', parentId: 'task-3', columnId: 'done', order: 0, assignee: kanbanDemoPeople[2] },
17
+ ];
18
+ let comments: IKanbanComment[] = [
19
+ { id: 'comment-1', ticketId: 'task-2', author: kanbanDemoPeople[0], createdAt: '2026-09-18T09:30:00Z', body: [{ type: 'text', text: 'The picker should feel like part of the conversation. Can we check this together with ' }, { type: 'reference', id: 'task-3', label: '#CAT-103 Keyboard accessibility' }, { type: 'text', text: '?' }] },
20
+ { id: 'comment-2', ticketId: 'task-2', author: kanbanDemoPeople[1], createdAt: '2026-09-18T09:42:00Z', body: [{ type: 'text', text: 'Yes. Search receives focus immediately, and choosing a ticket returns you to exactly where you were writing.' }] },
21
+ ];
22
+ let nextKey = 300;
23
+ // Demo-only in-memory provider. Applications persist through their own authorized APIs.
24
+ const provider: IKanbanProvider = {
25
+ getTicket: async id => {
26
+ const ticket = tickets.find(item => item.id === id);
27
+ if (!ticket) throw new Error('Ticket not found.');
28
+ return ticket;
29
+ },
30
+ searchTickets: async query => tickets.filter(ticket => `${ticket.key} ${ticket.title}`.toLowerCase().includes(query.toLowerCase())),
31
+ loadComments: async id => comments.filter(comment => comment.ticketId === id),
32
+ addComment: async input => {
33
+ const comment: IKanbanComment = { ...input, id: crypto.randomUUID(), author: kanbanDemoPeople[0], createdAt: new Date().toISOString() };
34
+ comments = [...comments, comment]; return comment;
35
+ },
36
+ updateTicket: async ({ ticketId, changes }) => {
37
+ const previous = tickets.find(ticket => ticket.id === ticketId)!;
38
+ const ticket: IKanbanTicket = { ...previous, ...changes, assignee: changes.assignee === null ? undefined : changes.assignee ?? previous.assignee,
39
+ parentId: changes.parentId === null ? undefined : changes.parentId ?? previous.parentId };
40
+ tickets = tickets.map(item => item.id === ticketId ? ticket : item); return ticket;
41
+ },
42
+ moveTicket: async ({ ticketId, columnId, beforeTicketId }) => {
43
+ const ticket = tickets.find(item => item.id === ticketId)!;
44
+ const destination = tickets.filter(item => item.id !== ticketId && item.columnId === columnId && item.kind === ticket.kind).sort((a, b) => a.order - b.order);
45
+ const index = beforeTicketId ? destination.findIndex(item => item.id === beforeTicketId) : destination.length;
46
+ destination.splice(index < 0 ? destination.length : index, 0, { ...ticket, columnId });
47
+ const changed = destination.map((item, order) => ({ ...item, order }));
48
+ tickets = tickets.map(item => changed.find(record => record.id === item.id) ?? item); return changed;
49
+ },
50
+ createTicket: async input => {
51
+ const ticket: IKanbanTicket = { ...input, id: crypto.randomUUID(), key: `CAT-${nextKey++}`, order: tickets.length };
52
+ tickets = [...tickets, ticket]; return ticket;
53
+ },
54
+ };
55
+ return { tickets, provider };
56
+ };
@@ -0,0 +1,13 @@
1
+ import type { IKanbanTicket, IKanbanTicketDraft } from './interfaces.js';
2
+
3
+ export const sortKanbanTickets = (tickets: readonly IKanbanTicket[]): IKanbanTicket[] =>
4
+ [...tickets].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
5
+ export const ticketLabel = (ticket: IKanbanTicket): string => `#${ticket.key} ${ticket.title}`;
6
+ export const ticketDraft = (ticket: IKanbanTicket): IKanbanTicketDraft => ({
7
+ title: ticket.title, description: ticket.description ?? '', priority: ticket.priority ?? 'none',
8
+ assigneeId: ticket.assignee?.id ?? '', parentId: ticket.parentId ?? '',
9
+ });
10
+ export const kanbanEvent = <T>(owner: HTMLElement, name: string, detail: T): void => {
11
+ owner.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
12
+ };
13
+ export const kanbanError = (error: unknown): string => error instanceof Error ? error.message : String(error);
@@ -0,0 +1,18 @@
1
+ import { css, cssManager } from '@design.estate/dees-element';
2
+ import { themeDefaultStyles } from '../00theme.js';
3
+
4
+ export const kanbanStyles = [themeDefaultStyles, cssManager.defaultStyles, css`
5
+ :host { display: block; min-width: 0; font-family: var(--dees-font-family); color: var(--dees-color-text-primary); font-size: var(--dees-font-body-size); }
6
+ * { box-sizing: border-box; }
7
+ h2, h3, p { margin: 0; }
8
+ .muted { color: var(--dees-color-text-secondary); }
9
+ .caption { font-size: var(--dees-font-footnote-size); color: var(--dees-color-text-secondary); }
10
+ .error { color: var(--dees-color-text-error); padding: 10px 0; font-size: var(--dees-font-footnote-size); }
11
+ .empty { padding: 24px 12px; text-align: center; color: var(--dees-color-text-secondary); font-size: var(--dees-font-footnote-size); }
12
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
13
+ .spacer { flex: 1; }
14
+ button.link { border: 0; background: transparent; font: inherit; color: var(--dees-color-link); padding: 2px 0; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
15
+ button:focus-visible, summary:focus-visible { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 3px; border-radius: var(--dees-radius-sm); }
16
+ dees-icon { font-size: 16px; }
17
+ .badge { display: inline-flex; align-items: center; gap: 4px; background: var(--dees-color-badge-default-bg); padding: 3px 7px; border-radius: var(--dees-radius-sm); font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
18
+ `];
@@ -109,7 +109,12 @@ export class DeesSimpleAppDash extends DeesElement {
109
109
  .controlbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: flex-end; border-top: 1px solid var(--dees-color-border-subtle); background: var(--dees-color-bg-secondary); font-size: 11px; color: var(--dees-color-text-secondary); }
110
110
  .control { display: flex; align-items: center; gap: 6px; padding: 0 12px; height: 100%; }
111
111
  .status-terminal { border-left: 1px solid var(--dees-color-border-subtle); }
112
- .status-terminal[aria-pressed='true'] { color: var(--dees-color-accent-primary); background: var(--dees-color-nav-selected); }
112
+ /* The on face is the catalog's toggle-on face: the shared selected surface plus the accent
113
+ *text* colour, not --dees-color-accent-primary, which only reaches 3.6:1 as a label. The
114
+ hovered rule exists because the shared button:hover rule below outranks a plain
115
+ [aria-pressed] rule and would wash the on state away under the pointer. */
116
+ .status-terminal[aria-pressed='true'] { color: var(--dees-color-link); background: var(--dees-color-nav-selected); }
117
+ .status-terminal[aria-pressed='true']:hover { color: var(--dees-color-link); background: color-mix(in srgb, var(--dees-color-nav-selected) 88%, var(--dees-color-text-primary)); }
113
118
  .collapsed .sidebar-header { justify-content: center; padding: 0; }
114
119
  .collapsed .sidebar-header > dees-icon, .collapsed .appName, .collapsed .section-label, .collapsed .viewTab span, .collapsed .viewTab .chevron, .collapsed .action span { display: none; }
115
120
  .collapsed .collapseToggle { margin: 0; width: 40px; height: 38px; }
@@ -281,8 +281,8 @@ export class DeesWorkspace extends DeesElement {
281
281
  <span class="save-state" role="status">${this.isInitializing ? 'Starting…' : this.initializationError ? 'Connection failed' : !ready ? 'Not connected' : modifiedCount ? `${modifiedCount} unsaved` : 'All changes saved'}</span>
282
282
  <div class="toolbar-actions">
283
283
  <button class="panel-action" type="button" aria-label="Save all files" title="Save all files" ?disabled=${!ready || !modifiedCount} @click=${this.saveAllFiles}><dees-icon icon="lucide:Save" iconSize="16"></dees-icon></button>
284
- ${this.showFileTree ? html`<button class="panel-action" type="button" aria-label="Toggle files" aria-controls="workspace-files" aria-expanded=${!this.isFileTreeCollapsed} aria-pressed=${!this.isFileTreeCollapsed} title="Toggle files" @click=${() => this.isFileTreeCollapsed = !this.isFileTreeCollapsed}><dees-icon icon="lucide:PanelLeft" iconSize="16"></dees-icon></button>` : ''}
285
- ${this.showTerminal ? html`<button class="panel-action" type="button" aria-label="Toggle terminal" aria-controls="workspace-terminal" aria-expanded=${!this.isTerminalCollapsed} aria-pressed=${!this.isTerminalCollapsed} title="Toggle terminal" @click=${this.toggleTerminal}><dees-icon icon="lucide:PanelBottom" iconSize="16"></dees-icon></button>` : ''}
284
+ ${this.showFileTree ? html`<button class="panel-action panel-toggle" type="button" aria-label="Toggle files" aria-controls="workspace-files" aria-expanded=${!this.isFileTreeCollapsed} title="Toggle files" @click=${() => this.isFileTreeCollapsed = !this.isFileTreeCollapsed}><dees-icon icon="lucide:PanelLeft" iconSize="16"></dees-icon></button>` : ''}
285
+ ${this.showTerminal ? html`<button class="panel-action panel-toggle" type="button" aria-label="Toggle terminal" aria-controls="workspace-terminal" aria-expanded=${!this.isTerminalCollapsed} title="Toggle terminal" @click=${this.toggleTerminal}><dees-icon icon="lucide:PanelBottom" iconSize="16"></dees-icon></button>` : ''}
286
286
  <button class="panel-action" type="button" aria-label="Editor options" aria-haspopup="menu" title="Editor options" @click=${this.showEditorMenu}><dees-icon icon="lucide:Ellipsis" iconSize="16"></dees-icon></button>
287
287
  </div>
288
288
  </header>
@@ -15,7 +15,15 @@ export const workspaceStyles = css`
15
15
  .toolbar-actions, .panel-header-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
16
16
  .panel-action { width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--dees-radius-md); color: var(--dees-color-text-secondary); transition: color 140ms ease, background-color 140ms ease; }
17
17
  .panel-action:hover:not(:disabled) { color: var(--dees-color-text-primary); background: var(--dees-color-hover); }
18
- .panel-action[aria-pressed=true] { color: var(--dees-color-link); background: var(--dees-color-hover); }
18
+ /* A panel toggle announces one state, aria-expanded, and paints it with the shared selected
19
+ surface — the same wash and accent label dees-button gives its pressed state. It cannot
20
+ reuse the hover background: an expanded toggle would then be indistinguishable from a
21
+ hovered collapsed one. Both on-state rules repeat the hover rule's :not(:disabled), which
22
+ puts them at (0,4,0) and (0,5,0) against that rule's (0,3,0), so the on face outranks hover
23
+ on specificity instead of on source order, and the hovered-expanded rule keeps it under the
24
+ pointer. A disabled toggle falls back to the base face, the way it does for hover. */
25
+ .panel-action.panel-toggle[aria-expanded='true']:not(:disabled) { color: var(--dees-color-link); background: var(--dees-color-nav-selected); }
26
+ .panel-action.panel-toggle[aria-expanded='true']:not(:disabled):hover { color: var(--dees-color-link); background: color-mix(in srgb, var(--dees-color-nav-selected) 88%, var(--dees-color-text-primary)); }
19
27
  .workspace-container { display: flex; flex: 1; min-height: 0; min-width: 0; position: relative; }
20
28
  .editor-area { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; overflow: hidden; }
21
29
  .filetree-panel { position: relative; flex-shrink: 0; min-width: 0; overflow: hidden; background: var(--dees-color-bg-secondary); }
@@ -10,6 +10,7 @@ export * from './00group-dataview/index.js';
10
10
  export * from './00group-feedback/index.js';
11
11
  export * from './00group-form/index.js';
12
12
  export * from './00group-harness/index.js';
13
+ export * from './00group-kanban/index.js';
13
14
  export * from './00group-input/index.js';
14
15
  export * from './00group-layout/index.js';
15
16
  export * from './00group-media/index.js';
@@ -101,6 +101,7 @@ export interface IEchartsBundle {
101
101
  */
102
102
  export interface ITiptapBundle {
103
103
  Editor: typeof Editor;
104
+ Node: typeof import('@tiptap/core').Node;
104
105
  StarterKit: { configure: (options?: Partial<StarterKitOptions>) => any };
105
106
  Underline: { configure: (options?: Partial<UnderlineOptions>) => any };
106
107
  TextAlign: { configure: (options?: Partial<TextAlignOptions>) => any };
@@ -464,6 +465,7 @@ export class DeesServiceLibLoader {
464
465
 
465
466
  this.tiptapLib = {
466
467
  Editor: coreModule.Editor,
468
+ Node: coreModule.Node,
467
469
  StarterKit: starterKitModule.default || starterKitModule.StarterKit,
468
470
  Underline: underlineModule.default || underlineModule.Underline,
469
471
  TextAlign: textAlignModule.default || textAlignModule.TextAlign,