@zeyos/zx 4.3.4 → 5.0.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 (231) hide show
  1. package/CHANGELOG.md +438 -0
  2. package/README.md +49 -10
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +217 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
  6. package/dist/types/components/avatar/avatar.d.ts +46 -7
  7. package/dist/types/components/badge/badge.d.ts +21 -1
  8. package/dist/types/components/button/button.d.ts +23 -0
  9. package/dist/types/components/calendar/calendar.d.ts +19 -0
  10. package/dist/types/components/card/card.d.ts +7 -0
  11. package/dist/types/components/card-view/card-view.d.ts +11 -3
  12. package/dist/types/components/card-view/record-card.d.ts +7 -1
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +149 -0
  16. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  17. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  18. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  19. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  20. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  21. package/dist/types/components/datebox/datebox.d.ts +7 -2
  22. package/dist/types/components/dialog/dialog.d.ts +75 -6
  23. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  24. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  25. package/dist/types/components/field/field.d.ts +24 -2
  26. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  27. package/dist/types/components/file-item/file-item.d.ts +262 -0
  28. package/dist/types/components/file-list/file-list.d.ts +171 -0
  29. package/dist/types/components/filter/filter.d.ts +91 -36
  30. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  31. package/dist/types/components/form/form.d.ts +12 -1
  32. package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
  33. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  34. package/dist/types/components/launcher/launcher.d.ts +278 -26
  35. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  36. package/dist/types/components/menu-button/menu-button.d.ts +37 -42
  37. package/dist/types/components/message/message.d.ts +126 -17
  38. package/dist/types/components/modal/modal.d.ts +61 -2
  39. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  40. package/dist/types/components/panel/panel.d.ts +7 -0
  41. package/dist/types/components/search/search.d.ts +7 -0
  42. package/dist/types/components/select/color.d.ts +12 -0
  43. package/dist/types/components/select/glyphs.d.ts +124 -0
  44. package/dist/types/components/select/select.d.ts +15 -3
  45. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  46. package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
  47. package/dist/types/components/tabbox/tabbox.d.ts +16 -0
  48. package/dist/types/components/table/table.d.ts +190 -1
  49. package/dist/types/components/table-view/table-view.d.ts +11 -5
  50. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  51. package/dist/types/components/timebox/timebox.d.ts +7 -0
  52. package/dist/types/components/tree/tree.d.ts +13 -0
  53. package/dist/types/components/value-list/value-list.d.ts +8 -0
  54. package/dist/types/components/view/record-view.d.ts +96 -5
  55. package/dist/types/core/breakpoint.d.ts +1 -1
  56. package/dist/types/core/icons.d.ts +37 -22
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +15 -1
  59. package/dist/types/index.d.ts +16 -3
  60. package/dist/types/internal/app-rail.d.ts +10 -0
  61. package/dist/types/internal/menu-items.d.ts +134 -0
  62. package/dist/types/zeyos/connect.d.ts +6 -0
  63. package/dist/types/zeyos/form.d.ts +50 -9
  64. package/dist/types/zeyos/index.d.ts +1 -1
  65. package/dist/types/zeyos/query.d.ts +79 -0
  66. package/dist/types/zeyos/schema.d.ts +24 -1
  67. package/dist/types/zeyos/table.d.ts +100 -14
  68. package/dist/types/zeyos/view.d.ts +10 -0
  69. package/dist/zx-zeyos.esm.js +2206 -489
  70. package/dist/zx-zeyos.esm.min.js +56 -1
  71. package/dist/zx.css +3322 -724
  72. package/dist/zx.esm.js +12244 -5809
  73. package/dist/zx.esm.min.js +58 -3
  74. package/dist/zx.global.js +12246 -5811
  75. package/dist/zx.global.min.js +58 -3
  76. package/dist/zx.min.css +1 -1
  77. package/docs/api.json +1683 -146
  78. package/docs/llms.md +777 -110
  79. package/docs/llms.txt +14 -8
  80. package/package.json +1 -1
  81. package/src/components/account-menu/account-menu.css +35 -10
  82. package/src/components/activity-item/activity-item.css +137 -0
  83. package/src/components/activity-item/activity-item.js +602 -0
  84. package/src/components/activity-list/activity-list.css +99 -0
  85. package/src/components/activity-list/activity-list.js +409 -0
  86. package/src/components/app-icon/app-icon.css +1 -1
  87. package/src/components/app-rail/app-rail.css +191 -22
  88. package/src/components/app-sidebar/app-sidebar.css +349 -24
  89. package/src/components/app-sidebar/app-sidebar.js +86 -11
  90. package/src/components/aurora/aurora.css +5 -3
  91. package/src/components/avatar/avatar.css +32 -7
  92. package/src/components/avatar/avatar.js +72 -8
  93. package/src/components/badge/badge.css +54 -2
  94. package/src/components/badge/badge.js +29 -2
  95. package/src/components/button/button.css +74 -8
  96. package/src/components/button/button.js +44 -1
  97. package/src/components/calendar/calendar.css +46 -46
  98. package/src/components/calendar/calendar.js +33 -3
  99. package/src/components/card/card.css +2 -2
  100. package/src/components/card/card.js +12 -1
  101. package/src/components/card-view/card-view.css +10 -7
  102. package/src/components/card-view/card-view.js +25 -9
  103. package/src/components/card-view/record-card.js +7 -3
  104. package/src/components/chart/chart.css +4 -1
  105. package/src/components/chart/chart.js +12 -0
  106. package/src/components/check-button/check-button.css +10 -2
  107. package/src/components/checklist/checklist.css +9 -2
  108. package/src/components/checklist/checklist.js +11 -5
  109. package/src/components/checklist/field-adapter.js +3 -1
  110. package/src/components/code-editor/code-editor.css +68 -0
  111. package/src/components/code-editor/code-editor.js +306 -0
  112. package/src/components/code-editor/field-adapter.js +34 -0
  113. package/src/components/context-menu/context-menu.css +87 -5
  114. package/src/components/context-menu/context-menu.js +22 -30
  115. package/src/components/copy/copy-button.css +6 -0
  116. package/src/components/copy/copy-input.css +8 -3
  117. package/src/components/data-filter/data-filter.css +5 -4
  118. package/src/components/date-picker/date-picker.css +6 -6
  119. package/src/components/date-picker/date-picker.js +22 -8
  120. package/src/components/date-picker/date-range-picker.css +3 -3
  121. package/src/components/date-picker/month-picker.js +3 -3
  122. package/src/components/date-picker/time-picker.css +1 -1
  123. package/src/components/date-picker/time-picker.js +20 -8
  124. package/src/components/datebox/date-range-box.css +79 -2
  125. package/src/components/datebox/date-range-box.js +13 -2
  126. package/src/components/datebox/datebox.css +79 -2
  127. package/src/components/datebox/datebox.js +13 -2
  128. package/src/components/dialog/dialog.css +26 -0
  129. package/src/components/dialog/dialog.js +174 -70
  130. package/src/components/dropdown/dropdown.css +1 -1
  131. package/src/components/dropdown/dropdown.js +7 -5
  132. package/src/components/empty-state/empty-state.css +2 -2
  133. package/src/components/entity-ref/entity-ref.css +182 -0
  134. package/src/components/entity-ref/entity-ref.js +496 -0
  135. package/src/components/field/field.css +28 -9
  136. package/src/components/field/field.js +68 -26
  137. package/src/components/field-upload/field-upload.css +1 -1
  138. package/src/components/fieldset/fieldset.css +5 -3
  139. package/src/components/fieldset/fieldset.js +28 -5
  140. package/src/components/file-item/file-item.css +88 -0
  141. package/src/components/file-item/file-item.js +505 -0
  142. package/src/components/file-list/file-list.css +51 -0
  143. package/src/components/file-list/file-list.js +320 -0
  144. package/src/components/filter/filter.css +27 -1
  145. package/src/components/filter/filter.js +205 -47
  146. package/src/components/filter-panel/filter-panel.css +104 -0
  147. package/src/components/filter-panel/filter-panel.js +964 -0
  148. package/src/components/finder/finder.css +15 -13
  149. package/src/components/form/form.css +5 -4
  150. package/src/components/form/form.js +11 -2
  151. package/src/components/grid/grid.css +1 -1
  152. package/src/components/groupbox/groupbox.css +1 -1
  153. package/src/components/kanban-view/kanban-card.js +247 -0
  154. package/src/components/kanban-view/kanban-view.css +226 -8
  155. package/src/components/kanban-view/kanban-view.js +255 -12
  156. package/src/components/launcher/launcher.css +107 -25
  157. package/src/components/launcher/launcher.js +557 -63
  158. package/src/components/list-toolbar/list-toolbar.css +194 -0
  159. package/src/components/list-toolbar/list-toolbar.js +851 -0
  160. package/src/components/master-panel/master-panel.css +2 -0
  161. package/src/components/menu-button/menu-button.css +87 -5
  162. package/src/components/menu-button/menu-button.js +86 -46
  163. package/src/components/message/message.css +38 -5
  164. package/src/components/message/message.js +230 -17
  165. package/src/components/modal/modal.css +15 -0
  166. package/src/components/modal/modal.js +159 -3
  167. package/src/components/navigation-bar/navigation-bar.css +95 -11
  168. package/src/components/navigation-bar/navigation-bar.js +171 -4
  169. package/src/components/number-field/field-adapter.js +1 -0
  170. package/src/components/number-field/number-field.css +70 -0
  171. package/src/components/number-field/number-field.js +2 -0
  172. package/src/components/pagination/pagination.css +8 -6
  173. package/src/components/panel/panel.css +1 -1
  174. package/src/components/panel/panel.js +13 -2
  175. package/src/components/rating/rating.css +7 -0
  176. package/src/components/search/search.js +17 -5
  177. package/src/components/select/color.js +22 -0
  178. package/src/components/select/field-adapter.js +6 -1
  179. package/src/components/select/glyphs.js +165 -0
  180. package/src/components/select/select.css +138 -45
  181. package/src/components/select/select.js +86 -47
  182. package/src/components/sheet/sheet.css +11 -8
  183. package/src/components/slider/slider.css +16 -2
  184. package/src/components/slider/slider.js +1 -1
  185. package/src/components/sort-control/sort-control.css +20 -0
  186. package/src/components/sort-control/sort-control.js +140 -0
  187. package/src/components/stat-tile/stat-tile.css +208 -0
  188. package/src/components/stat-tile/stat-tile.js +601 -0
  189. package/src/components/tabbox/tabbox.css +32 -27
  190. package/src/components/tabbox/tabbox.js +17 -2
  191. package/src/components/table/table.css +155 -31
  192. package/src/components/table/table.js +386 -33
  193. package/src/components/table-view/table-view.css +8 -2
  194. package/src/components/table-view/table-view.js +21 -6
  195. package/src/components/tag-picker/tag-picker.css +86 -6
  196. package/src/components/tag-picker/tag-picker.js +25 -8
  197. package/src/components/timebox/timebox.css +22 -2
  198. package/src/components/timebox/timebox.js +18 -5
  199. package/src/components/toggle/toggle.css +12 -1
  200. package/src/components/toolbar/toolbar.js +2 -2
  201. package/src/components/tooltip/tooltip.css +4 -0
  202. package/src/components/tooltip/tooltip.js +10 -3
  203. package/src/components/tree/tree.css +25 -16
  204. package/src/components/tree/tree.js +31 -0
  205. package/src/components/value-list/value-list.css +3 -3
  206. package/src/components/value-list/value-list.js +22 -6
  207. package/src/components/view/record-view.css +5 -0
  208. package/src/components/view/record-view.js +100 -7
  209. package/src/core/breakpoint.js +1 -1
  210. package/src/core/icons.js +259 -57
  211. package/src/core/motion.js +147 -0
  212. package/src/core/overlay-host.js +21 -2
  213. package/src/index.js +30 -3
  214. package/src/internal/app-rail.js +18 -1
  215. package/src/internal/menu-items.js +142 -0
  216. package/src/zeyos/connect.js +6 -2
  217. package/src/zeyos/form.js +67 -27
  218. package/src/zeyos/index.js +1 -1
  219. package/src/zeyos/query.js +153 -0
  220. package/src/zeyos/schema.js +144 -27
  221. package/src/zeyos/table.js +68 -5
  222. package/src/zeyos/view.js +6 -0
  223. package/styles/base.css +56 -0
  224. package/styles/icons.css +11 -1
  225. package/styles/print.css +192 -0
  226. package/styles/tokens/dark.css +113 -37
  227. package/styles/tokens/global.css +121 -27
  228. package/styles/tokens/semantic.css +100 -31
  229. package/styles/tokens/themes.css +7 -6
  230. package/styles/zx.css +11 -1
  231. package/styles/tokens/density.css +0 -9
@@ -17,7 +17,7 @@
17
17
  flex: none;
18
18
  min-inline-size: 0;
19
19
  flex-direction: column;
20
- border-inline-end: 1px solid var(--zx-color-border);
20
+ border-inline-end: 1px solid var(--zx-color-border-subtle);
21
21
  }
22
22
 
23
23
  .zx-finder__list {
@@ -32,9 +32,12 @@
32
32
  display: flex;
33
33
  align-items: center;
34
34
  gap: var(--zx-space-2);
35
- min-block-size: calc(var(--zx-control-height) - 6px);
36
- padding-inline: var(--zx-space-2);
37
- border-radius: var(--zx-radius-md);
35
+ /* A browsing row: a space-1 margin round one line of text, and the smallest radius, so a
36
+ column of them reads as a list rather than a stack of buttons. */
37
+ min-block-size: calc(var(--zx-control-height) - var(--zx-space-1));
38
+ padding: var(--zx-space-1) var(--zx-space-2);
39
+ border-radius: var(--zx-radius-sm);
40
+ line-height: 1.25;
38
41
  cursor: pointer;
39
42
  }
40
43
 
@@ -42,10 +45,12 @@
42
45
  background: var(--zx-color-bg-hover);
43
46
  }
44
47
 
48
+ /* The selected row of each column is the path you are on, not a pick: the neutral current fill,
49
+ the full text colour and a heavier label. */
45
50
  .zx-finder__row[aria-selected="true"] {
46
- background: var(--zx-color-bg-selected);
47
- color: var(--zx-color-accent);
48
- font-weight: 550;
51
+ background: var(--zx-color-bg-current);
52
+ color: var(--zx-color-text);
53
+ font-weight: var(--zx-weight-semibold);
49
54
  }
50
55
 
51
56
  .zx-finder__row:focus-visible {
@@ -53,19 +58,16 @@
53
58
  box-shadow: var(--zx-focus-ring);
54
59
  }
55
60
 
61
+ /* Icons are quieter than labels, folders included; the current row's icon takes the text colour. */
56
62
  .zx-finder__icon {
57
63
  display: inline-grid;
58
64
  place-items: center;
59
65
  flex: none;
60
- color: var(--zx-color-text-muted);
61
- }
62
-
63
- .zx-finder__row[data-branch] .zx-finder__icon {
64
- color: var(--zx-color-warning);
66
+ color: var(--zx-color-text-subtle);
65
67
  }
66
68
 
67
69
  .zx-finder__row[aria-selected="true"] .zx-finder__icon {
68
- color: inherit;
70
+ color: var(--zx-color-text);
69
71
  }
70
72
 
71
73
  .zx-finder__label {
@@ -29,11 +29,12 @@
29
29
  }
30
30
 
31
31
  .zx-form__actions .zx-btn[type="submit"] {
32
- border-color: var(--zx-color-accent);
33
- background: var(--zx-color-accent);
34
- color: var(--zx-color-on-accent);
32
+ border-color: var(--zx-color-accent-solid);
33
+ background: var(--zx-color-accent-solid);
34
+ color: var(--zx-color-on-accent-solid);
35
35
  }
36
36
 
37
37
  .zx-form__actions .zx-btn[type="submit"]:hover {
38
- background: var(--zx-color-accent-hover);
38
+ border-color: var(--zx-color-accent-solid-hover);
39
+ background: var(--zx-color-accent-solid-hover);
39
40
  }
@@ -1,6 +1,7 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
3
  import { icon as createIcon } from '../../core/icons.js';
4
+ import { uid } from '../../core/util.js';
4
5
  import { Fieldset } from '../fieldset/fieldset.js';
5
6
  import { coerceFloat, coerceInteger } from '../field/field.js';
6
7
 
@@ -22,6 +23,9 @@ import { coerceFloat, coerceInteger } from '../field/field.js';
22
23
  * @property {Array<Fieldset|import('../fieldset/fieldset.js').FieldsetOptions>} [fieldsets=[]] Initial fieldsets.
23
24
  * @property {FormAction[]} [actions=[]] Form action descriptors.
24
25
  * @property {boolean} [novalidate=true] Native `novalidate` state.
26
+ * @property {string|null} [idPrefix=null] Prefix of the DOM ids generated for the fields of the
27
+ * fieldsets this form builds, as `<idPrefix>-<key>`. Null generates one per form instance, so a
28
+ * form mounted twice never repeats an id. A fieldset's own `idPrefix` wins.
25
29
  * @property {(event: CustomEvent<{values: Record<string, unknown>}>) => void} [onsubmit] Submit listener.
26
30
  * @property {(event: CustomEvent<{errors: Record<string, string>}>) => void} [oninvalid] Invalid listener.
27
31
  * @property {(event: CustomEvent<{id: string, value: unknown}>) => void} [onchange] Field change listener.
@@ -42,6 +46,7 @@ export class Form extends Component {
42
46
  fieldsets: [],
43
47
  actions: [],
44
48
  novalidate: true,
49
+ idPrefix: null,
45
50
  msg: {
46
51
  'form.required': 'This field is required.',
47
52
  'form.int': 'Enter a whole number.',
@@ -56,6 +61,7 @@ export class Form extends Component {
56
61
  this._ownedFieldsets = new Set();
57
62
  this._origins = new Map();
58
63
  this._ownedNodes = [];
64
+ this._idPrefix = this.options.idPrefix ? String(this.options.idPrefix) : uid('zx-form');
59
65
  this._novalidateAttribute = root.getAttribute('novalidate');
60
66
  root.toggleAttribute('novalidate', Boolean(this.options.novalidate));
61
67
 
@@ -81,13 +87,16 @@ export class Form extends Component {
81
87
  }
82
88
 
83
89
  /**
84
- * Adds an existing fieldset or constructs one from options.
90
+ * Adds an existing fieldset or constructs one from options. A constructed fieldset generates its
91
+ * field ids from the form's `idPrefix` unless its options carry their own.
85
92
  * @param {Fieldset|import('../fieldset/fieldset.js').FieldsetOptions} fieldset Fieldset source.
86
93
  * @returns {Fieldset}
87
94
  */
88
95
  addFieldset(fieldset) {
89
96
  const owned = !(fieldset instanceof Fieldset);
90
- const instance = owned ? new Fieldset(null, fieldset ?? {}) : fieldset;
97
+ const instance = owned
98
+ ? new Fieldset(null, { ...(fieldset ?? {}), idPrefix: fieldset?.idPrefix ?? this._idPrefix })
99
+ : fieldset;
91
100
  if (this._fieldsets.includes(instance)) return instance;
92
101
  if (owned) this._ownedFieldsets.add(instance);
93
102
  else this._origins.set(instance, { parent: instance.el.parentNode, next: instance.el.nextSibling });
@@ -2,5 +2,5 @@
2
2
  the lowercase `.zx-grid` layout helper remains a separate, unchanged primitive. */
3
3
  .zx-table[data-preset="billing-items"] tr.zx-grid-billing__summary > td {
4
4
  background: var(--zx-color-bg-muted);
5
- font-weight: 700;
5
+ font-weight: 650;
6
6
  }
@@ -11,7 +11,7 @@
11
11
  gap: var(--zx-space-2);
12
12
  min-block-size: var(--zx-control-height);
13
13
  padding: var(--zx-space-2) var(--zx-space-4);
14
- font-weight: 650;
14
+ font-weight: var(--zx-weight-semibold);
15
15
  cursor: pointer;
16
16
  user-select: none;
17
17
  }
@@ -0,0 +1,247 @@
1
+ // @ts-check
2
+ import { h, safeHref } from '../../core/dom.js';
3
+ import { icon } from '../../core/icons.js';
4
+ import { AVATAR_TONES, avatarInitials, avatarTone } from '../avatar/avatar.js';
5
+ import { badge } from '../badge/badge.js';
6
+
7
+ /** @typedef {Record<string, any>} KanbanRecord */
8
+
9
+ /**
10
+ * @typedef {Object} KanbanIndicator
11
+ * @property {string} label Visible text.
12
+ * @property {'neutral'|'accent'|'success'|'warning'|'danger'|'info'} [tone='neutral'] Semantic intent.
13
+ * @property {string|null} [icon=null] Optional leading icon name, used instead of the status dot.
14
+ * @property {boolean} [dot=true] Whether a leading status dot is drawn.
15
+ * @property {string} [title] Native title, useful when the label alone is terse.
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} KanbanProgress
20
+ * @property {number} value Completed amount, clamped into `[0, max]`.
21
+ * @property {number} max Amount that counts as finished.
22
+ * @property {number} percent Rounded completion percentage.
23
+ * @property {string|null} label Visible meter label, or null for the caller's default.
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} KanbanAssignee
28
+ * @property {string} name Person's name, used as the accessible label.
29
+ * @property {string|null} src Safe image URL, or null for the initials fallback.
30
+ * @property {string} initials Fallback initials.
31
+ * @property {import('../avatar/avatar.js').AvatarTone} tone Fallback tint: a descriptor's own
32
+ * `tone` when it names one of `AVATAR_TONES`, otherwise `avatarTone(name)` — the colour the same
33
+ * person has in every Avatar.
34
+ */
35
+
36
+ /** Semantic intents an indicator may carry, in the order they are documented. */
37
+ const TONES = Object.freeze(['neutral', 'accent', 'success', 'warning', 'danger', 'info']);
38
+
39
+ /**
40
+ * Normalizes one indicator value into the descriptor the badge is built from. A plain value
41
+ * becomes the label and looks its tone up in the supplied map, so an application expresses
42
+ * "High means danger" as data rather than as a renderer.
43
+ * @param {unknown} value Raw field value, descriptor, or null.
44
+ * @param {Record<string, string>} [tones={}] Value-to-tone map.
45
+ * @returns {KanbanIndicator|null} Descriptor, or null when there is nothing to show.
46
+ */
47
+ export function resolveKanbanIndicator(value, tones = {}) {
48
+ if (value == null || value === '') return null;
49
+ const map = tones && typeof tones === 'object' ? tones : {};
50
+ const source = typeof value === 'object' && !('nodeType' in value)
51
+ ? /** @type {Record<string, any>} */ (value) : { label: value };
52
+ const label = source.label == null ? '' : String(source.label);
53
+ if (!label) return null;
54
+ const mapped = map[label];
55
+ const tone = TONES.includes(source.tone) ? source.tone
56
+ : TONES.includes(mapped) ? mapped : 'neutral';
57
+ return {
58
+ label,
59
+ tone: /** @type {KanbanIndicator['tone']} */ (tone),
60
+ icon: source.icon == null ? null : String(source.icon),
61
+ dot: source.dot !== false && source.icon == null,
62
+ ...(source.title == null ? {} : { title: String(source.title) })
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Builds one indicator badge.
68
+ * @param {KanbanIndicator} indicator Normalized descriptor.
69
+ * @returns {HTMLElement} Badge element.
70
+ */
71
+ export function createKanbanIndicator(indicator) {
72
+ return badge({
73
+ label: indicator.label,
74
+ kind: indicator.tone,
75
+ icon: indicator.icon ?? undefined,
76
+ dot: indicator.dot,
77
+ size: 'sm',
78
+ variant: 'soft',
79
+ ...(indicator.title == null ? {} : { title: indicator.title })
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Normalizes a completion value against an explicit scale. The scale is never guessed: a `0.72`
85
+ * on a 0–100 board is seven-tenths of a percent, and only the application knows which it meant.
86
+ * @param {unknown} value Raw field value, or `{value, max, label}`.
87
+ * @param {number} [max=100] Amount that counts as finished.
88
+ * @returns {KanbanProgress|null} Descriptor, or null when there is nothing to show.
89
+ */
90
+ export function resolveKanbanProgress(value, max = 100) {
91
+ if (value == null || value === '') return null;
92
+ const source = typeof value === 'object' && !('nodeType' in value)
93
+ ? /** @type {Record<string, any>} */ (value) : { value };
94
+ const limit = Number(source.max ?? max);
95
+ const amount = Number(source.value);
96
+ if (!Number.isFinite(amount) || !Number.isFinite(limit) || limit <= 0) return null;
97
+ const clamped = Math.max(0, Math.min(limit, amount));
98
+ // A blank label must not win over the localized default: it would become aria-label="".
99
+ const label = source.label == null ? '' : String(source.label).trim();
100
+ return {
101
+ value: clamped,
102
+ max: limit,
103
+ percent: Math.round(clamped / limit * 100),
104
+ label: label || null
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Builds the card's completion meter. The track is the accessible progress element itself, so the
110
+ * value is available without depending on the visible percentage text.
111
+ * @param {KanbanProgress} progress Normalized descriptor.
112
+ * @param {string} label Meter label.
113
+ * @param {string} valueText Formatted readout drawn opposite the label.
114
+ * @returns {HTMLElement} Meter element.
115
+ */
116
+ export function createKanbanProgress(progress, label, valueText) {
117
+ const track = h('div', {
118
+ class: 'zx-kanban-view__progress-track',
119
+ role: 'progressbar',
120
+ ariaValuemin: '0',
121
+ ariaValuemax: String(progress.max),
122
+ ariaValuenow: String(progress.value),
123
+ ariaValuetext: valueText,
124
+ ariaLabel: label
125
+ }, h('div', { class: 'zx-kanban-view__progress-fill' }));
126
+ // The fill is the only inline style on a card: a percentage cannot be expressed as a token.
127
+ /** @type {HTMLElement} */ (track.firstElementChild).style.inlineSize = `${progress.percent}%`;
128
+ return h('div', { class: 'zx-kanban-view__progress' },
129
+ h('div', { class: 'zx-kanban-view__progress-head' },
130
+ h('span', { class: 'zx-kanban-view__progress-label' }, label),
131
+ h('span', { class: 'zx-kanban-view__progress-value' }, valueText)),
132
+ track);
133
+ }
134
+
135
+ /**
136
+ * Normalizes one or more responsible people. Names, name lists, and descriptor objects all
137
+ * resolve to the same shape, and an unsafe image URL degrades to initials rather than failing.
138
+ * A descriptor may name its `tone`; otherwise the tone is derived from the name exactly as Avatar
139
+ * derives it.
140
+ * @param {unknown} value Raw field value.
141
+ * @returns {KanbanAssignee[]} Normalized people, in the order supplied.
142
+ */
143
+ export function resolveKanbanAssignees(value) {
144
+ if (value == null || value === '') return [];
145
+ const entries = Array.isArray(value) ? value : [value];
146
+ return /** @type {KanbanAssignee[]} */ (entries.map((entry) => {
147
+ if (entry == null || entry === '') return null;
148
+ const source = typeof entry === 'object' && !('nodeType' in entry)
149
+ ? /** @type {Record<string, any>} */ (entry) : { name: entry };
150
+ const name = String(source.name ?? source.label ?? '').trim();
151
+ // An avatar with no name cannot be announced: it would render as an unidentified face inside a
152
+ // group labelled "Assigned to ". A picture without a person is worse than no picture.
153
+ if (!name) return null;
154
+ const src = safeHref(source.src ?? source.image ?? null);
155
+ const initials = source.initials == null ? avatarInitials(name) : String(source.initials);
156
+ const tone = AVATAR_TONES.includes(source.tone) ? source.tone : avatarTone(name);
157
+ return { name, src, initials, tone };
158
+ }).filter(Boolean));
159
+ }
160
+
161
+ /**
162
+ * Splits a list of people into the faces that are drawn and the number the overflow chip stands
163
+ * for. Exists as one function so the slice and the chip can never be computed from different
164
+ * normalizations of the same option — a fractional limit used to floor every face away while the
165
+ * chip still counted from the raw value.
166
+ * @param {number} max Requested face count.
167
+ * @param {number} total People available.
168
+ * @returns {{shown:number,hidden:number}} Normalized split.
169
+ */
170
+ export function kanbanAvatarLimit(max, total) {
171
+ // Exported, so a malformed total is normalized here rather than trusted from the caller.
172
+ const people = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0;
173
+ const limit = Number.isFinite(max) ? Math.max(1, Math.floor(max)) : people;
174
+ const shown = Math.min(people, limit);
175
+ return { shown, hidden: Math.max(0, people - shown) };
176
+ }
177
+
178
+ /**
179
+ * Builds the assignee avatar group. The group carries one accessible name listing everyone,
180
+ * including the people behind the overflow chip, and the chips themselves are hidden so a screen
181
+ * reader announces the list once rather than once per face.
182
+ * @param {KanbanAssignee[]} people Normalized people.
183
+ * @param {number} max Faces drawn before the overflow chip.
184
+ * @param {string} groupLabel Accessible name for the whole group.
185
+ * @param {(count: number) => string} overflowLabel Text for the overflow chip, given how many
186
+ * people it stands for. The count is derived here so it can never disagree with the slice.
187
+ * @returns {HTMLElement|null} Group element, or null when nobody is assigned.
188
+ */
189
+ export function createKanbanAvatars(people, max, groupLabel, overflowLabel) {
190
+ if (!people.length) return null;
191
+ const split = kanbanAvatarLimit(max, people.length);
192
+ const shown = people.slice(0, split.shown);
193
+ const hidden = split.hidden;
194
+ // Each face carries its tone, so the initials sit on the tint the person has in every Avatar.
195
+ const faces = shown.map((person) => h('span', {
196
+ class: 'zx-kanban-view__avatar', ariaHidden: 'true', title: person.name || undefined,
197
+ dataset: { tone: person.tone ?? avatarTone(person.name) }
198
+ }, person.src
199
+ ? h('img', { class: 'zx-kanban-view__avatar-image', src: person.src, alt: '', draggable: false })
200
+ : h('span', { class: 'zx-kanban-view__avatar-initials' }, person.initials)));
201
+ if (hidden > 0) {
202
+ faces.push(h('span', {
203
+ class: 'zx-kanban-view__avatar', ariaHidden: 'true', dataset: { overflow: 'true' }
204
+ }, h('span', { class: 'zx-kanban-view__avatar-initials' }, overflowLabel(hidden))));
205
+ }
206
+ return h('span', { class: 'zx-kanban-view__avatars', role: 'img', ariaLabel: groupLabel }, faces);
207
+ }
208
+
209
+ /**
210
+ * Builds the card's head row: what the record is, and how urgent it is.
211
+ * @param {Object} parts Row content.
212
+ * @param {string|null} parts.entityIcon Icon name, or null.
213
+ * @param {string} parts.identifier Record key, or an empty string.
214
+ * @param {{indicator:Node|KanbanIndicator|null,prefix:string}[]} parts.flags Trailing indicators,
215
+ * each with the visually hidden text that names what it measures.
216
+ * @param {string} parts.identifierPrefix Visually hidden text read before the record key.
217
+ * @returns {HTMLElement|null} Head row, or null when it would be empty.
218
+ */
219
+ export function createKanbanCardHead(parts) {
220
+ const lead = [];
221
+ if (parts.entityIcon) {
222
+ lead.push(h('span', { class: 'zx-kanban-view__entity', ariaHidden: 'true' },
223
+ icon(parts.entityIcon, { size: 12 })));
224
+ }
225
+ if (parts.identifier) {
226
+ // `aria-label` is prohibited on the generic role, so the prefix is real, clipped text.
227
+ lead.push(h('span', { class: 'zx-kanban-view__identifier' },
228
+ parts.identifierPrefix
229
+ ? h('span', { class: 'zx-kanban-view__hint' }, `${parts.identifierPrefix} `) : null,
230
+ parts.identifier));
231
+ }
232
+ const trailing = (parts.flags ?? []).map((flag) => {
233
+ if (flag.indicator == null) return null;
234
+ const node = typeof flag.indicator === 'object' && 'nodeType' in flag.indicator
235
+ ? /** @type {Node} */ (flag.indicator)
236
+ : createKanbanIndicator(/** @type {KanbanIndicator} */ (flag.indicator));
237
+ // "High" alone does not say high what; the prefix names the axis without adding visible noise.
238
+ return flag.prefix
239
+ ? h('span', { class: 'zx-kanban-view__flag' },
240
+ h('span', { class: 'zx-kanban-view__hint' }, `${flag.prefix} `), node)
241
+ : node;
242
+ }).filter(Boolean);
243
+ if (!lead.length && !trailing.length) return null;
244
+ return h('div', { class: 'zx-kanban-view__card-head' },
245
+ lead.length ? h('span', { class: 'zx-kanban-view__card-lead' }, lead) : null,
246
+ trailing.length ? h('span', { class: 'zx-kanban-view__card-flags' }, trailing) : null);
247
+ }
@@ -95,6 +95,12 @@
95
95
  border: 0;
96
96
  border-radius: var(--zx-radius-sm);
97
97
  background: transparent;
98
+ /* Both are icon-only squares with an explicit size. base.css styles native buttons through
99
+ zero-specificity `:where()`, which loses the cascade but still floors the box through
100
+ *different* properties — `padding-inline` and `min-block-size`. Without resetting those, these
101
+ buttons are neither square nor able to shrink when the keyboard handle clips itself away. */
102
+ padding: 0;
103
+ min-block-size: 0;
98
104
  color: var(--zx-color-text-muted);
99
105
  cursor: pointer;
100
106
  }
@@ -114,8 +120,8 @@
114
120
  }
115
121
 
116
122
  .zx-kanban-view__move-handle[aria-pressed="true"] {
117
- background: var(--zx-color-accent-subtle);
118
- color: var(--zx-color-accent);
123
+ background: var(--zx-color-bg-current);
124
+ color: var(--zx-color-text);
119
125
  cursor: grabbing;
120
126
  }
121
127
 
@@ -342,7 +348,7 @@
342
348
  }
343
349
 
344
350
  .zx-kanban-view .zx-record-card__eyebrow-group[data-card-slot="eyebrow-start"] .zx-record-card__field {
345
- border-radius: var(--zx-radius-full);
351
+ border-radius: var(--zx-radius-sm);
346
352
  background: var(--zx-color-bg-muted);
347
353
  padding: calc(var(--zx-space-1) / 2) var(--zx-space-2);
348
354
  }
@@ -413,6 +419,12 @@
413
419
  .zx-kanban-view .zx-record-card[data-grabbed="true"] {
414
420
  outline: 2px solid var(--zx-color-accent);
415
421
  outline-offset: 2px;
422
+ /* A card picked up from the keyboard is the item being dragged, so it lifts like one. */
423
+ box-shadow: var(--zx-drag-shadow);
424
+ }
425
+
426
+ .zx-kanban-view .zx-record-card[data-grabbed="true"]:focus-visible {
427
+ box-shadow: var(--zx-focus-ring), var(--zx-drag-shadow);
416
428
  }
417
429
 
418
430
  .zx-kanban-view__empty {
@@ -464,10 +476,6 @@
464
476
  }
465
477
  }
466
478
 
467
- [data-zx-density="compact"] .zx-kanban-view {
468
- --zx-kanban-card-padding-inline: var(--zx-space-2);
469
- }
470
-
471
479
  @media (prefers-reduced-motion: no-preference) {
472
480
  .zx-kanban-view__collapse,
473
481
  .zx-kanban-view__move-handle,
@@ -565,10 +573,13 @@
565
573
 
566
574
  /* ── Empty column placeholder ─────────────────────────────────────────────────────────────── */
567
575
 
576
+ /* An empty column reads as somewhere a card can go, not as a rendering gap. */
568
577
  .zx-kanban-view__column-empty {
569
578
  display: grid;
570
579
  place-items: center;
571
580
  min-block-size: 4rem;
581
+ border: 1px dashed var(--zx-color-border);
582
+ border-radius: var(--zx-radius-md);
572
583
  padding: var(--zx-space-2);
573
584
  color: var(--zx-color-text-muted);
574
585
  font-size: var(--zx-text-xs);
@@ -642,7 +653,8 @@
642
653
  border: 1px solid var(--zx-color-border);
643
654
  border-radius: var(--zx-radius-md);
644
655
  background: var(--zx-color-bg-raised);
645
- box-shadow: var(--zx-shadow-2);
656
+ /* The item in flight: lifted above every resting card by the shared drag shadow. */
657
+ box-shadow: var(--zx-drag-shadow);
646
658
  padding: var(--zx-space-1) var(--zx-space-2);
647
659
  color: var(--zx-color-text);
648
660
  font-size: var(--zx-text-xs);
@@ -658,3 +670,209 @@
658
670
  min-block-size: 2.75rem;
659
671
  }
660
672
  }
673
+
674
+ /* ── Card head: what the record is, and how urgent it is ──────────────────────────────────── */
675
+
676
+ .zx-kanban-view__card-head {
677
+ display: flex;
678
+ align-items: center;
679
+ gap: var(--zx-space-2);
680
+ min-block-size: 1.25rem;
681
+ min-inline-size: 0;
682
+ }
683
+
684
+ .zx-kanban-view__card-lead,
685
+ .zx-kanban-view__card-flags {
686
+ display: inline-flex;
687
+ align-items: center;
688
+ gap: var(--zx-space-1);
689
+ min-inline-size: 0;
690
+ }
691
+
692
+ .zx-kanban-view__card-flags {
693
+ flex: none;
694
+ margin-inline-start: auto;
695
+ }
696
+
697
+ .zx-kanban-view__entity {
698
+ display: inline-grid;
699
+ flex: none;
700
+ place-items: center;
701
+ color: var(--zx-color-text-muted);
702
+ }
703
+
704
+ .zx-kanban-view__identifier {
705
+ min-inline-size: 0;
706
+ overflow: hidden;
707
+ color: var(--zx-color-text-muted);
708
+ font-size: var(--zx-text-xs);
709
+ font-variant-numeric: tabular-nums;
710
+ text-overflow: ellipsis;
711
+ white-space: nowrap;
712
+ }
713
+
714
+ /* ── Card footer: completion, then the people responsible ─────────────────────────────────── */
715
+
716
+ .zx-kanban-view__card-footer {
717
+ display: flex;
718
+ flex-direction: column;
719
+ gap: var(--zx-space-2);
720
+ min-inline-size: 0;
721
+ }
722
+
723
+ .zx-kanban-view__progress-head {
724
+ display: flex;
725
+ align-items: baseline;
726
+ justify-content: space-between;
727
+ gap: var(--zx-space-2);
728
+ margin-block-end: calc(var(--zx-space-1) / 2);
729
+ }
730
+
731
+ .zx-kanban-view__progress-label,
732
+ .zx-kanban-view__progress-value {
733
+ color: var(--zx-color-text-muted);
734
+ font-size: var(--zx-text-xs);
735
+ line-height: 1.2;
736
+ white-space: nowrap;
737
+ }
738
+
739
+ .zx-kanban-view__progress-label {
740
+ min-inline-size: 0;
741
+ overflow: hidden;
742
+ text-overflow: ellipsis;
743
+ }
744
+
745
+ .zx-kanban-view__progress-value {
746
+ flex: none;
747
+ font-variant-numeric: tabular-nums;
748
+ }
749
+
750
+ .zx-kanban-view__progress-track {
751
+ block-size: 4px;
752
+ overflow: hidden;
753
+ border-radius: var(--zx-radius-full);
754
+ background: var(--zx-color-bg-muted);
755
+ }
756
+
757
+ .zx-kanban-view__progress-fill {
758
+ block-size: 100%;
759
+ border-radius: inherit;
760
+ background: var(--zx-color-accent);
761
+ }
762
+
763
+ /* ── Assignees ────────────────────────────────────────────────────────────────────────────── */
764
+
765
+ /* Faces overlap into a stack; each one lifts its own left edge clear of the previous. */
766
+ .zx-kanban-view__avatars {
767
+ display: inline-flex;
768
+ align-items: center;
769
+ min-inline-size: 0;
770
+ }
771
+
772
+ /*
773
+ * A face is a small Avatar: the initials on the tint of the tone the component derives from the
774
+ * name with Avatar's own hash (`data-tone`), so a person has the same colour on the board as
775
+ * everywhere else. The surface-coloured edge is what separates overlapping faces.
776
+ */
777
+ .zx-kanban-view__avatar {
778
+ display: inline-grid;
779
+ flex: none;
780
+ place-items: center;
781
+ inline-size: 1.5rem;
782
+ block-size: 1.5rem;
783
+ overflow: hidden;
784
+ border: 1px solid var(--zx-color-bg-surface);
785
+ border-radius: var(--zx-radius-full);
786
+ background: var(--zx-kanban-avatar-bg, var(--zx-color-bg-muted));
787
+ color: var(--zx-kanban-avatar-fg, var(--zx-color-text-muted));
788
+ }
789
+
790
+ .zx-kanban-view__avatar[data-tone="blue"] { --zx-kanban-avatar-bg: var(--zx-color-tone-blue-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-blue-fg); }
791
+ .zx-kanban-view__avatar[data-tone="teal"] { --zx-kanban-avatar-bg: var(--zx-color-tone-teal-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-teal-fg); }
792
+ .zx-kanban-view__avatar[data-tone="green"] { --zx-kanban-avatar-bg: var(--zx-color-tone-green-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-green-fg); }
793
+ .zx-kanban-view__avatar[data-tone="amber"] { --zx-kanban-avatar-bg: var(--zx-color-tone-amber-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-amber-fg); }
794
+ .zx-kanban-view__avatar[data-tone="orange"] { --zx-kanban-avatar-bg: var(--zx-color-tone-orange-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-orange-fg); }
795
+ .zx-kanban-view__avatar[data-tone="red"] { --zx-kanban-avatar-bg: var(--zx-color-tone-red-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-red-fg); }
796
+ .zx-kanban-view__avatar[data-tone="pink"] { --zx-kanban-avatar-bg: var(--zx-color-tone-pink-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-pink-fg); }
797
+ .zx-kanban-view__avatar[data-tone="violet"] { --zx-kanban-avatar-bg: var(--zx-color-tone-violet-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-violet-fg); }
798
+ .zx-kanban-view__avatar[data-tone="slate"] { --zx-kanban-avatar-bg: var(--zx-color-tone-slate-bg); --zx-kanban-avatar-fg: var(--zx-color-tone-slate-fg); }
799
+
800
+ .zx-kanban-view__avatar + .zx-kanban-view__avatar {
801
+ margin-inline-start: calc(var(--zx-space-2) * -1);
802
+ }
803
+
804
+ .zx-kanban-view__avatar[data-overflow="true"] {
805
+ background: var(--zx-color-bg-current);
806
+ color: var(--zx-color-text-muted);
807
+ }
808
+
809
+ .zx-kanban-view__avatar-image {
810
+ inline-size: 100%;
811
+ block-size: 100%;
812
+ object-fit: cover;
813
+ }
814
+
815
+ /* Avatar's initials at the small size: 0.4 of the face, never under 10px, semibold. */
816
+ .zx-kanban-view__avatar-initials {
817
+ font-size: max(10px, calc(1.5rem * .4));
818
+ font-weight: var(--zx-weight-semibold);
819
+ letter-spacing: 0;
820
+ line-height: 1;
821
+ text-transform: uppercase;
822
+ }
823
+
824
+ /* The overflow chip is a count, not a person: it keeps the text size it can be read at. */
825
+ .zx-kanban-view__avatar[data-overflow="true"] .zx-kanban-view__avatar-initials {
826
+ font-size: var(--zx-text-xs);
827
+ }
828
+
829
+ /* ── Move handle ──────────────────────────────────────────────────────────────────────────── */
830
+
831
+ /* The card is the pointer drag source, so the handle is keyboard furniture: announced and in tab
832
+ order, but out of sight and out of the layout until it takes focus or holds a grabbed card. */
833
+ .zx-kanban-view__move-handle[data-reveal="keyboard"] {
834
+ position: absolute;
835
+ /* base.css gives every native button a control-height floor; without resetting it the "hidden"
836
+ handle is still a full-height strip. */
837
+ inline-size: 1px;
838
+ block-size: 1px;
839
+ min-inline-size: 0;
840
+ min-block-size: 0;
841
+ overflow: hidden;
842
+ clip-path: inset(50%);
843
+ }
844
+
845
+ .zx-kanban-view__move-handle[data-reveal="keyboard"]:focus,
846
+ .zx-kanban-view__move-handle[data-reveal="keyboard"][aria-pressed="true"] {
847
+ position: static;
848
+ inline-size: calc(var(--zx-control-height) - var(--zx-space-1));
849
+ block-size: calc(var(--zx-control-height) - var(--zx-space-1));
850
+ min-block-size: 0;
851
+ overflow: visible;
852
+ clip-path: none;
853
+ }
854
+
855
+ @media (pointer: coarse) {
856
+ .zx-kanban-view__move-handle[data-reveal="keyboard"]:focus,
857
+ .zx-kanban-view__move-handle[data-reveal="keyboard"][aria-pressed="true"] {
858
+ inline-size: 2.75rem;
859
+ block-size: 2.75rem;
860
+ }
861
+ }
862
+
863
+ /* Text that names what a terse card value measures — "Priority" before "High" — carried as real
864
+ content because `aria-label` is prohibited on the generic role a span reports. */
865
+ .zx-kanban-view__hint {
866
+ position: absolute;
867
+ inline-size: 1px;
868
+ block-size: 1px;
869
+ overflow: hidden;
870
+ clip-path: inset(50%);
871
+ white-space: nowrap;
872
+ }
873
+
874
+ .zx-kanban-view__flag {
875
+ display: inline-flex;
876
+ align-items: center;
877
+ }
878
+ .zx-kanban-view .zx-record-card[data-selection-trigger="card"] { cursor: pointer; }