beads-ui 0.1.2 → 0.2.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.
package/app/router.js CHANGED
@@ -1,14 +1,31 @@
1
+ import { issueHashFor } from './utils/issue-url.js';
2
+
1
3
  /**
2
4
  * Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
3
5
  */
4
6
 
5
7
  /**
6
8
  * Parse an application hash and extract the selected issue id.
9
+ * Supports canonical form "#/(issues|epics|board)?issue=<id>" and legacy
10
+ * "#/issue/<id>" which we will rewrite to the canonical form.
7
11
  * @param {string} hash
8
12
  * @returns {string | null}
9
13
  */
10
14
  export function parseHash(hash) {
11
- const m = /^#\/issue\/([^\s?#]+)/.exec(hash || '');
15
+ const h = String(hash || '');
16
+ // Extract the fragment sans leading '#'
17
+ const frag = h.startsWith('#') ? h.slice(1) : h;
18
+ const qIndex = frag.indexOf('?');
19
+ const query = qIndex >= 0 ? frag.slice(qIndex + 1) : '';
20
+ if (query) {
21
+ const params = new URLSearchParams(query);
22
+ const id = params.get('issue');
23
+ if (id) {
24
+ return decodeURIComponent(id);
25
+ }
26
+ }
27
+ // Legacy pattern: #/issue/<id>
28
+ const m = /^\/issue\/([^\s?#]+)/.exec(frag);
12
29
  return m && m[1] ? decodeURIComponent(m[1]) : null;
13
30
  }
14
31
 
@@ -30,18 +47,26 @@ export function parseView(hash) {
30
47
  }
31
48
 
32
49
  /**
33
- * Create and start the hash router.
34
50
  * @param {{ getState: () => any, setState: (patch: any) => void }} store
35
- * @returns {{ start: () => void, stop: () => void, gotoIssue: (id: string) => void, gotoView: (v: 'issues'|'epics'|'board') => void }}
36
51
  */
37
52
  export function createHashRouter(store) {
38
53
  /** @type {(ev?: HashChangeEvent) => any} */
39
54
  const onHashChange = () => {
40
55
  const hash = window.location.hash || '';
56
+ // Rewrite legacy #/issue/<id> to canonical #/issues?issue=<id>
57
+ const legacyMatch = /^#\/issue\/([^\s?#]+)/.exec(hash);
58
+ if (legacyMatch && legacyMatch[1]) {
59
+ const id = decodeURIComponent(legacyMatch[1]);
60
+ // Update state immediately for consumers expecting sync selection
61
+ store.setState({ selected_id: id, view: 'issues' });
62
+ const next = `#/issues?issue=${encodeURIComponent(id)}`;
63
+ if (window.location.hash !== next) {
64
+ window.location.hash = next;
65
+ return; // will trigger handler again
66
+ }
67
+ }
41
68
  const id = parseHash(hash);
42
- // Preserve current view when navigating to a detail route so tabs remain stable
43
- const current = store.getState ? store.getState() : { view: 'issues' };
44
- const view = id ? current.view || 'issues' : parseView(hash);
69
+ const view = parseView(hash);
45
70
  store.setState({ selected_id: id, view });
46
71
  };
47
72
 
@@ -53,21 +78,32 @@ export function createHashRouter(store) {
53
78
  stop() {
54
79
  window.removeEventListener('hashchange', onHashChange);
55
80
  },
81
+ /**
82
+ * @param {string} id
83
+ */
56
84
  gotoIssue(id) {
57
- const next = `#/issue/${encodeURIComponent(id)}`;
85
+ // Keep current view in hash and append issue param via helper
86
+ const s = store.getState ? store.getState() : { view: 'issues' };
87
+ const view = s.view || 'issues';
88
+ const next = issueHashFor(view, id);
58
89
  if (window.location.hash !== next) {
59
90
  window.location.hash = next;
60
91
  } else {
61
92
  // Force state update even if hash is the same
62
- store.setState({ selected_id: id, view: 'issues' });
93
+ store.setState({ selected_id: id, view });
63
94
  }
64
95
  },
65
96
  /**
66
97
  * Navigate to a top-level view.
67
98
  * @param {'issues'|'epics'|'board'} view
68
99
  */
100
+ /**
101
+ * @param {'issues'|'epics'|'board'} view
102
+ */
69
103
  gotoView(view) {
70
- const next = `#/${view}`;
104
+ const s = store.getState ? store.getState() : { selected_id: null };
105
+ const id = s.selected_id;
106
+ const next = id ? issueHashFor(view, id) : `#/${view}`;
71
107
  if (window.location.hash !== next) {
72
108
  window.location.hash = next;
73
109
  } else {
package/app/state.js CHANGED
@@ -15,7 +15,15 @@
15
15
  */
16
16
 
17
17
  /**
18
- * @typedef {{ selected_id: string | null, view: ViewName, filters: Filters }} AppState
18
+ * @typedef {'today'|'3'|'7'} ClosedFilter
19
+ */
20
+
21
+ /**
22
+ * @typedef {{ closed_filter: ClosedFilter }} BoardState
23
+ */
24
+
25
+ /**
26
+ * @typedef {{ selected_id: string | null, view: ViewName, filters: Filters, board: BoardState }} AppState
19
27
  */
20
28
 
21
29
  /**
@@ -26,15 +34,21 @@
26
34
  export function createStore(initial = {}) {
27
35
  /** @type {AppState} */
28
36
  let state = {
29
- selected_id: /** @type {any} */ (initial).selected_id ?? null,
30
- view: /** @type {any} */ (initial).view ?? 'issues',
37
+ selected_id: initial.selected_id ?? null,
38
+ view: initial.view ?? 'issues',
31
39
  filters: {
32
- status: /** @type {any} */ (initial).filters?.status ?? 'all',
33
- search: /** @type {any} */ (initial).filters?.search ?? '',
40
+ status: initial.filters?.status ?? 'all',
41
+ search: initial.filters?.search ?? '',
34
42
  type:
35
- typeof (/** @type {any} */ (initial).filters?.type) === 'string'
36
- ? /** @type {any} */ (initial).filters?.type
37
- : ''
43
+ typeof initial.filters?.type === 'string' ? initial.filters?.type : ''
44
+ },
45
+ board: {
46
+ closed_filter:
47
+ initial.board?.closed_filter === '3' ||
48
+ initial.board?.closed_filter === '7' ||
49
+ initial.board?.closed_filter === 'today'
50
+ ? initial.board?.closed_filter
51
+ : 'today'
38
52
  }
39
53
  };
40
54
 
@@ -57,14 +71,15 @@ export function createStore(initial = {}) {
57
71
  },
58
72
  /**
59
73
  * Update state. Nested filters can be partial.
60
- * @param {{ selected_id?: string | null, filters?: Partial<Filters> }} patch
74
+ * @param {{ selected_id?: string | null, filters?: Partial<Filters>, board?: Partial<BoardState> }} patch
61
75
  */
62
76
  setState(patch) {
63
77
  /** @type {AppState} */
64
78
  const next = {
65
79
  ...state,
66
80
  ...patch,
67
- filters: { ...state.filters, ...(patch.filters || {}) }
81
+ filters: { ...state.filters, ...(patch.filters || {}) },
82
+ board: { ...state.board, ...(patch.board || {}) }
68
83
  };
69
84
  // Avoid emitting if nothing changed (shallow compare)
70
85
  if (
@@ -72,7 +87,8 @@ export function createStore(initial = {}) {
72
87
  next.view === state.view &&
73
88
  next.filters.status === state.filters.status &&
74
89
  next.filters.search === state.filters.search &&
75
- next.filters.type === state.filters.type
90
+ next.filters.type === state.filters.type &&
91
+ next.board.closed_filter === state.board.closed_filter
76
92
  ) {
77
93
  return;
78
94
  }
package/app/styles.css CHANGED
@@ -158,6 +158,7 @@ a:focus {
158
158
  .header-nav .tab:hover,
159
159
  .header-nav .tab:focus {
160
160
  color: var(--fg);
161
+ outline-offset: 0px;
161
162
  }
162
163
  .header-nav .tab.active {
163
164
  color: var(--fg);
@@ -282,7 +283,7 @@ a:focus {
282
283
  outline-offset: var(--outline-offset);
283
284
  }
284
285
  .editable:focus-within {
285
- outline: 2px solid #93c5fd;
286
+ outline: 2px solid color-mix(in srgb, var(--link) 60%, transparent);
286
287
  outline-offset: var(--outline-offset);
287
288
  cursor: text;
288
289
  }
@@ -385,6 +386,19 @@ button {
385
386
  border-color 140ms ease,
386
387
  transform 60ms ease;
387
388
  }
389
+ /* Inline issue ID renderer: looks like plain text */
390
+ button.id-copy {
391
+ background: transparent;
392
+ color: inherit;
393
+ border: none;
394
+ padding: 0;
395
+ margin: 0;
396
+ line-height: inherit;
397
+ font: inherit;
398
+ }
399
+ button.id-copy:hover {
400
+ text-decoration: underline;
401
+ }
388
402
  button:hover {
389
403
  filter: brightness(1.02);
390
404
  }
@@ -687,7 +701,7 @@ input.inline-edit {
687
701
  outline-offset: var(--outline-offset);
688
702
  }
689
703
  input.inline-edit:focus {
690
- outline: 2px solid #93c5fd;
704
+ outline: 2px solid color-mix(in srgb, var(--link) 50%, transparent);
691
705
  outline-offset: var(--outline-offset);
692
706
  box-shadow: none;
693
707
  }
@@ -739,7 +753,7 @@ input.inline-edit:focus {
739
753
  /* Keyboard focus ring helper */
740
754
  :focus-visible {
741
755
  outline: 2px solid color-mix(in srgb, var(--link) 60%, transparent);
742
- outline-offset: var(--outline-offset);
756
+ outline-offset: 2px;
743
757
  }
744
758
 
745
759
  /* Subtle scrollbars (WebKit/Blink) */
@@ -779,6 +793,7 @@ input.inline-edit:focus {
779
793
 
780
794
  /* Spacing for acceptance and notes sections in details */
781
795
  #detail-root .acceptance,
796
+ #detail-root .design,
782
797
  #detail-root .notes {
783
798
  margin-top: 32px;
784
799
  }
@@ -791,10 +806,17 @@ input.inline-edit:focus {
791
806
  line-height: inherit;
792
807
  }
793
808
  /* Title input fills available width and stays inline with buttons */
794
- #detail-root .detail-title input[type='text'] {
795
- flex: 1 1 auto;
809
+ #detail-root .detail-title h2 {
810
+ display: flex;
811
+ align-items: center;
812
+ }
813
+ #detail-root .detail-title h2 input[type='text'] {
814
+ flex: 1;
796
815
  padding: 0;
797
816
  min-width: 10px;
817
+ font-size: inherit;
818
+ line-height: inherit;
819
+ margin-right: 8px;
798
820
  }
799
821
  /* Remove borders in detail view; rely on global :focus-visible outline */
800
822
  #detail-root input[type='text'],
@@ -885,6 +907,15 @@ input.inline-edit:focus {
885
907
  gap: 16px;
886
908
  padding: 12px;
887
909
  }
910
+
911
+ /* UI-121: stack two board columns vertically in a single grid cell */
912
+ .board-stack-2 {
913
+ display: grid;
914
+ grid-template-rows: 1fr 1fr;
915
+ gap: 16px;
916
+ min-width: 380px;
917
+ min-height: 0;
918
+ }
888
919
  .board-column {
889
920
  display: flex;
890
921
  flex-direction: column;
@@ -896,6 +927,10 @@ input.inline-edit:focus {
896
927
  background: color-mix(in srgb, var(--panel-bg) 92%, transparent);
897
928
  }
898
929
  .board-column__header {
930
+ display: flex;
931
+ align-items: center;
932
+ justify-content: space-between;
933
+ gap: 8px;
899
934
  position: sticky;
900
935
  top: 0;
901
936
  z-index: 1;
@@ -905,6 +940,16 @@ input.inline-edit:focus {
905
940
  background: inherit;
906
941
  backdrop-filter: saturate(140%) blur(4px);
907
942
  }
943
+ /* Small, unobtrusive select for closed filter */
944
+ .board-closed-filter {
945
+ margin: -8px 0;
946
+ }
947
+ .board-closed-filter select {
948
+ font-size: 13px;
949
+ border-radius: 999px;
950
+ border-color: var(--border);
951
+ padding: 2px 18px 2px 12px;
952
+ }
908
953
  .board-column__body {
909
954
  padding: 10px;
910
955
  overflow: visible;
@@ -936,6 +981,10 @@ input.inline-edit:focus {
936
981
  0 2px 8px color-mix(in srgb, #000 16%, transparent),
937
982
  0 4px 16px color-mix(in srgb, #000 12%, transparent);
938
983
  }
984
+ .board-card:focus {
985
+ outline: 2px solid color-mix(in srgb, var(--link) 50%, transparent);
986
+ outline-offset: 0px;
987
+ }
939
988
  .board-card__title {
940
989
  font-weight: 600;
941
990
  margin-bottom: 4px;
@@ -955,6 +1004,16 @@ input.inline-edit:focus {
955
1004
  }
956
1005
  }
957
1006
 
1007
+ /* a11y helper */
1008
+ .visually-hidden {
1009
+ position: absolute !important;
1010
+ height: 1px;
1011
+ width: 1px;
1012
+ overflow: hidden;
1013
+ clip: rect(1px, 1px, 1px, 1px);
1014
+ white-space: nowrap;
1015
+ }
1016
+
958
1017
  @media (prefers-color-scheme: dark) {
959
1018
  :root {
960
1019
  --fg: #e5e7eb;
@@ -1152,7 +1211,7 @@ html[data-theme='dark'] {
1152
1211
  }
1153
1212
  .detail-side {
1154
1213
  position: sticky;
1155
- top: 70px;
1214
+ top: 18px;
1156
1215
  }
1157
1216
  .detail-main h2 {
1158
1217
  font-size: 28px;
@@ -1220,6 +1279,14 @@ html[data-theme='dark'] {
1220
1279
  padding: 0;
1221
1280
  margin: 0 0 8px;
1222
1281
  }
1282
+ .props-card ul li {
1283
+ display: grid;
1284
+ grid-template-columns: auto auto 1fr auto;
1285
+ gap: 6px;
1286
+ align-items: center;
1287
+ padding: 2px 0;
1288
+ cursor: pointer;
1289
+ }
1223
1290
  .props-card__title {
1224
1291
  font-weight: 700;
1225
1292
  font-size: 13px;
@@ -1248,6 +1315,103 @@ html[data-theme='dark'] {
1248
1315
  gap: 8px;
1249
1316
  }
1250
1317
 
1318
+ /* --- Issue Details Dialog (UI-104) --- */
1319
+ #issue-dialog {
1320
+ padding: 0;
1321
+ border: 1px solid var(--border);
1322
+ border-radius: 8px;
1323
+ background: var(--bg);
1324
+ color: var(--fg);
1325
+ width: min(96vw, 1200px);
1326
+ max-height: 96vh;
1327
+ margin: 2vh auto;
1328
+ overflow: hidden;
1329
+ }
1330
+ #issue-dialog::backdrop {
1331
+ background: color-mix(in srgb, #000 45%, transparent);
1332
+ backdrop-filter: blur(1px);
1333
+ }
1334
+ .issue-dialog__header {
1335
+ display: flex;
1336
+ align-items: center;
1337
+ justify-content: space-between;
1338
+ gap: 10px;
1339
+ padding: 8px 12px;
1340
+ border-bottom: 1px solid var(--border);
1341
+ }
1342
+ .issue-dialog__title {
1343
+ font-weight: 700;
1344
+ }
1345
+ .issue-dialog__close {
1346
+ border-radius: 4px;
1347
+ padding: 4px 8px;
1348
+ min-width: 28px;
1349
+ line-height: 1.2;
1350
+ }
1351
+ .issue-dialog__body {
1352
+ min-height: 200px;
1353
+ max-height: calc(96vh - 44px);
1354
+ overflow: auto;
1355
+ }
1356
+
1357
+ .new-issue__body {
1358
+ padding: 12px;
1359
+ }
1360
+ .new-issue__form {
1361
+ display: grid;
1362
+ grid-template-columns: 120px 1fr;
1363
+ gap: 10px 12px;
1364
+ align-items: center;
1365
+ }
1366
+ .new-issue__form label {
1367
+ color: var(--muted);
1368
+ font-size: 12px;
1369
+ }
1370
+ .new-issue__actions {
1371
+ display: flex;
1372
+ justify-content: flex-end;
1373
+ gap: 8px;
1374
+ margin-top: 8px;
1375
+ }
1376
+ .new-issue__error {
1377
+ color: var(--danger, #b00020);
1378
+ font-size: 12px;
1379
+ }
1380
+
1381
+ /* --- New Issue Dialog (UI-106) --- */
1382
+ #new-issue-dialog {
1383
+ padding: 0;
1384
+ border: 1px solid var(--border);
1385
+ border-radius: 8px;
1386
+ background: var(--bg);
1387
+ color: var(--fg);
1388
+ width: min(640px, 96vw);
1389
+ max-height: 96vh;
1390
+ margin: 2vh auto;
1391
+ overflow: hidden;
1392
+ }
1393
+ #new-issue-dialog::backdrop {
1394
+ background: color-mix(in srgb, #000 45%, transparent);
1395
+ backdrop-filter: blur(1px);
1396
+ }
1397
+ .new-issue__header {
1398
+ display: flex;
1399
+ align-items: center;
1400
+ justify-content: space-between;
1401
+ gap: 10px;
1402
+ padding: 8px 12px;
1403
+ border-bottom: 1px solid var(--border);
1404
+ }
1405
+ .new-issue__title {
1406
+ font-weight: 700;
1407
+ }
1408
+ .new-issue__close {
1409
+ border-radius: 4px;
1410
+ padding: 4px 8px;
1411
+ min-width: 28px;
1412
+ line-height: 1.2;
1413
+ }
1414
+
1251
1415
  .prop.labels {
1252
1416
  align-items: start;
1253
1417
  }
@@ -0,0 +1,71 @@
1
+ import { issueDisplayId } from './issue-id.js';
2
+
3
+ /**
4
+ * Create a reusable, copy-to-clipboard issue ID renderer.
5
+ * Looks like the current inline ID (monospace `#123`) but acts as a button
6
+ * that copies the full, prefixed ID (e.g., `UI-123`) when activated.
7
+ * Shows transient "Copied" feedback and then restores the ID.
8
+ * @param {string} id - Full issue id including the prefix (e.g., "UI-123").
9
+ * @param {{ class_name?: string, duration_ms?: number }} [opts]
10
+ * @returns {HTMLButtonElement}
11
+ */
12
+ export function createIssueIdRenderer(id, opts) {
13
+ /** @type {number} */
14
+ const duration =
15
+ typeof opts?.duration_ms === 'number' ? opts.duration_ms : 1200;
16
+ /** @type {HTMLButtonElement} */
17
+ const btn = document.createElement('button');
18
+ // Visual: match inline ID look; keep it neutral and text-like
19
+ btn.className =
20
+ (opts?.class_name ? opts.class_name + ' ' : '') + 'mono id-copy';
21
+ btn.type = 'button';
22
+ btn.setAttribute('aria-live', 'polite');
23
+ btn.setAttribute('title', 'Copy issue ID');
24
+ btn.setAttribute('aria-label', `Copy issue ID ${id}`);
25
+ const label = issueDisplayId(id);
26
+ btn.textContent = label;
27
+
28
+ /** Copy handler with feedback */
29
+ async function doCopy() {
30
+ // Prevent accidental row navigation and parent handlers
31
+ // (click/key handlers call this inside an event context)
32
+ try {
33
+ if (
34
+ navigator.clipboard &&
35
+ typeof navigator.clipboard.writeText === 'function'
36
+ ) {
37
+ await navigator.clipboard.writeText(String(id));
38
+ }
39
+ const prev = btn.textContent || label;
40
+ btn.textContent = 'Copied';
41
+ // Keep accessible label consistent with feedback
42
+ const oldAria = btn.getAttribute('aria-label') || '';
43
+ btn.setAttribute('aria-label', 'Copied');
44
+ setTimeout(
45
+ () => {
46
+ btn.textContent = prev;
47
+ btn.setAttribute('aria-label', oldAria);
48
+ },
49
+ Math.max(80, duration)
50
+ );
51
+ } catch {
52
+ // On failure, leave text as-is; no throw to avoid disruptive UX
53
+ }
54
+ }
55
+
56
+ btn.addEventListener('click', (ev) => {
57
+ ev.preventDefault();
58
+ ev.stopPropagation();
59
+ void doCopy();
60
+ });
61
+ btn.addEventListener('keydown', (ev) => {
62
+ // Ensure keyboard activation works even in non-interactive test envs
63
+ if (ev.key === 'Enter' || ev.key === ' ') {
64
+ ev.preventDefault();
65
+ ev.stopPropagation();
66
+ void doCopy();
67
+ }
68
+ });
69
+
70
+ return btn;
71
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Build a canonical issue hash that retains the view.
3
+ * @param {'issues'|'epics'|'board'} view
4
+ * @param {string} id
5
+ */
6
+ export function issueHashFor(view, id) {
7
+ const v = view === 'epics' || view === 'board' ? view : 'issues';
8
+ return `#/${v}?issue=${encodeURIComponent(id)}`;
9
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Show a transient global toast message anchored to the viewport.
3
+ * @param {string} text - Message text.
4
+ * @param {'info'|'success'|'error'} [variant] - Visual variant.
5
+ * @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
6
+ */
7
+ export function showToast(text, variant = 'info', duration_ms = 2800) {
8
+ /** @type {HTMLDivElement} */
9
+ const el = document.createElement('div');
10
+ el.className = 'toast';
11
+ el.textContent = text;
12
+ el.style.position = 'fixed';
13
+ el.style.right = '12px';
14
+ el.style.bottom = '12px';
15
+ el.style.zIndex = '1000';
16
+ el.style.color = '#fff';
17
+ el.style.padding = '8px 10px';
18
+ el.style.borderRadius = '4px';
19
+ el.style.fontSize = '12px';
20
+ if (variant === 'success') {
21
+ el.style.background = '#156d36';
22
+ } else if (variant === 'error') {
23
+ el.style.background = '#9f2011';
24
+ } else {
25
+ el.style.background = 'rgba(0,0,0,0.85)';
26
+ }
27
+ (document.body || document.documentElement).appendChild(el);
28
+ setTimeout(() => {
29
+ try {
30
+ el.remove();
31
+ } catch {
32
+ /* ignore */
33
+ }
34
+ }, duration_ms);
35
+ }