kanbanqube 1.0.10 → 1.0.12

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.
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("node:crypto");
4
+
3
5
  function createConfigController(config, gitService) {
4
6
  async function getConfig(_request, response) {
7
+ const gitUserEmail = await gitService.gitUserEmail(config.workspaceDir);
5
8
  response.json({
6
9
  boardFile: config.boardFileName,
7
10
  storagePath: config.boardDirName,
@@ -9,7 +12,8 @@ function createConfigController(config, gitService) {
9
12
  hasGitRepo: await gitService.hasGitRepository(config.workspaceDir),
10
13
  gitRemote: await gitService.gitRemoteOrigin(config.workspaceDir),
11
14
  gitUserName: await gitService.gitUserName(config.workspaceDir),
12
- gitUserEmail: await gitService.gitUserEmail(config.workspaceDir)
15
+ gitUserEmail,
16
+ gravatarUrl: gravatarUrlForEmail(gitUserEmail)
13
17
  });
14
18
  }
15
19
 
@@ -18,6 +22,13 @@ function createConfigController(config, gitService) {
18
22
  };
19
23
  }
20
24
 
25
+ function gravatarUrlForEmail(email) {
26
+ if (typeof email !== "string" || !email.trim()) return "";
27
+ const hash = crypto.createHash("md5").update(email.trim().toLowerCase()).digest("hex");
28
+ return `https://www.gravatar.com/avatar/${hash}?s=64&d=404`;
29
+ }
30
+
21
31
  module.exports = {
22
- createConfigController
32
+ createConfigController,
33
+ gravatarUrlForEmail
23
34
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbanqube",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Local-first Kanban board backed by normal files",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Mathias Conradt",
package/public/app.js CHANGED
@@ -61,12 +61,14 @@ const aboutImage = document.getElementById("aboutImage");
61
61
  const faviconLink = document.getElementById("faviconLink");
62
62
  const boardTitle = document.getElementById("boardTitle");
63
63
  const boardTitleInlineInput = document.getElementById("boardTitleInlineInput");
64
- const userBadge = document.getElementById("userBadge");
65
64
  const searchInput = document.getElementById("searchInput");
66
65
  const saveStatus = document.getElementById("saveStatus");
67
66
  const syncButton = document.getElementById("syncButton");
68
67
  const archiveButton = document.getElementById("archiveButton");
69
68
  const settingsButton = document.getElementById("settingsButton");
69
+ const userAvatarButton = document.getElementById("userAvatarButton");
70
+ const userAvatarImage = document.getElementById("userAvatarImage");
71
+ const userAvatarFallback = document.getElementById("userAvatarFallback");
70
72
  const syncLogDialog = document.getElementById("syncLogDialog");
71
73
  const syncLogContent = document.getElementById("syncLogContent");
72
74
  const syncLogTimestamp = document.getElementById("syncLogTimestamp");
@@ -97,6 +99,7 @@ const addLabelButton = document.getElementById("addLabelButton");
97
99
  const commentInput = document.getElementById("commentInput");
98
100
  const addCommentButton = document.getElementById("addCommentButton");
99
101
  const addChecklistButton = document.getElementById("addChecklistButton");
102
+ const archiveCardButton = document.getElementById("archiveCardButton");
100
103
  const deleteCardButton = document.getElementById("deleteCardButton");
101
104
  const closeCardButton = document.getElementById("closeCardButton");
102
105
 
@@ -179,8 +182,7 @@ async function bootstrap() {
179
182
  state.config = configResponse.ok ? await configResponse.json() : { boardFile: "board.json", gitRemote: null, gitUserName: null, gitUserEmail: null };
180
183
  hydrateIdentityFromGitConfig();
181
184
  applyIconStyle();
182
- const storageLabel = state.config.storagePath ? `${state.config.storagePath}/` : (state.config.boardFile || "board.json");
183
- settingsBoardFile.textContent = `Storage: ${storageLabel}`;
185
+ settingsBoardFile.textContent = `Storage: ${state.config.workspacePath || "current folder"}`;
184
186
  settingsRemote.textContent = state.config.gitRemote ? `Remote: ${state.config.gitRemote}` : "Remote: not configured";
185
187
 
186
188
  wireEvents();
@@ -244,6 +246,10 @@ function wireEvents() {
244
246
  });
245
247
 
246
248
  settingsButton.addEventListener("click", openSettingsDialog);
249
+ userAvatarImage.addEventListener("error", () => {
250
+ userAvatarImage.hidden = true;
251
+ userAvatarFallback.hidden = false;
252
+ });
247
253
  archiveButton.addEventListener("click", openArchiveDialog);
248
254
  boardTitle.addEventListener("click", startBoardTitleEdit);
249
255
  closeSettingsButton.addEventListener("click", () => settingsDialog.close());
@@ -266,6 +272,7 @@ function wireEvents() {
266
272
 
267
273
  closeCardButton.addEventListener("click", () => cardDialog.close());
268
274
  removeCoverButton.addEventListener("click", removeCoverFromSelectedCard);
275
+ archiveCardButton.addEventListener("click", archiveSelectedCard);
269
276
  deleteCardButton.addEventListener("click", deleteSelectedCard);
270
277
  addLabelButton.addEventListener("click", toggleLabelEditor);
271
278
  addCommentButton.addEventListener("click", addCommentToSelectedCard);
@@ -370,7 +377,7 @@ function renderHeader() {
370
377
  boardTitle.classList.toggle("is-inline-editable", true);
371
378
  boardTitleInlineInput.hidden = !state.editingBoardTitle;
372
379
  boardTitleInlineInput.value = state.editingBoardTitleValue;
373
- userBadge.textContent = state.currentUserName.trim() || "Guest";
380
+ renderUserAvatar();
374
381
  const archivedCount = archivedCards().length;
375
382
  archiveButton.textContent = archivedCount ? `Archive (${archivedCount})` : "Archive";
376
383
  const canSync = Boolean(state.config?.gitRemote);
@@ -420,8 +427,8 @@ function renderBoard() {
420
427
  laneNode.querySelector(".lane-count").textContent = String(cardsForList(list.id).length);
421
428
 
422
429
  const cardList = laneNode.querySelector(".card-list");
423
- for (const card of cards) {
424
- const cardNode = renderCard(card);
430
+ for (const [cardIndex, card] of cards.entries()) {
431
+ const cardNode = renderCard(card, { labelTooltipBelow: cardIndex === 0 });
425
432
  cardList.append(cardNode);
426
433
  }
427
434
 
@@ -473,7 +480,7 @@ async function createLane() {
473
480
  render();
474
481
  }
475
482
 
476
- function renderCard(card) {
483
+ function renderCard(card, options = {}) {
477
484
  const node = cardTemplate.content.firstElementChild.cloneNode(true);
478
485
  node.dataset.cardId = card.id;
479
486
  node.classList.toggle("is-done", isCardDone(card));
@@ -487,6 +494,8 @@ function renderCard(card) {
487
494
  labelNode.className = "card-label";
488
495
  labelNode.style.background = colorForLabel(label.color);
489
496
  labelNode.title = label.name || label.color;
497
+ labelNode.dataset.tooltip = label.name || label.color;
498
+ labelNode.classList.toggle("tooltip-below", Boolean(options.labelTooltipBelow));
490
499
  cardLabelsStrip.append(labelNode);
491
500
  }
492
501
 
@@ -508,10 +517,15 @@ function renderCard(card) {
508
517
  });
509
518
 
510
519
  const archiveButton = node.querySelector(".card-archive-button");
511
- archiveButton.hidden = !done;
512
520
  archiveButton.addEventListener("click", (event) => {
513
521
  event.stopPropagation();
514
- archiveCard(card);
522
+ archiveCard(card, { force: true });
523
+ });
524
+
525
+ const cardDeleteButton = node.querySelector(".card-delete-button");
526
+ cardDeleteButton.addEventListener("click", (event) => {
527
+ event.stopPropagation();
528
+ deleteBoardCard(card.id);
515
529
  });
516
530
 
517
531
  const titleNode = node.querySelector(".card-title");
@@ -612,6 +626,7 @@ function renderCardDialog() {
612
626
  cardDescriptionDisplay.hidden = state.descriptionEditing;
613
627
  cardDescriptionInput.hidden = !state.descriptionEditing;
614
628
  editDescriptionButton.textContent = state.descriptionEditing ? "Done" : "Edit";
629
+ archiveCardButton.hidden = isCardArchived(card);
615
630
 
616
631
  renderLabelsEditor(card);
617
632
  renderAttachments(card);
@@ -690,32 +705,48 @@ function renderArchiveDialog() {
690
705
  function renderLabelsEditor(card) {
691
706
  cardLabels.textContent = "";
692
707
  labelEditorContainer.textContent = "";
708
+ renderLabelSummary(card);
693
709
 
710
+ addLabelButton.textContent = state.labelEditorOpen ? "×" : "+";
711
+ addLabelButton.setAttribute("aria-label", state.labelEditorOpen ? "Close labels panel" : "Open labels panel");
712
+ if (!state.labelEditorOpen) return;
713
+
714
+ appendLabelEditorPanel(createLabelEditorPanel(card));
715
+ }
716
+
717
+ function renderLabelSummary(card) {
694
718
  const assignedLabels = labelsForCard(card);
695
719
  if (assignedLabels.length === 0) {
696
720
  if (!state.labelEditorOpen) {
697
- const empty = document.createElement("div");
698
- empty.className = "empty-state label-summary-trigger";
699
- empty.textContent = "No labels assigned.";
700
- empty.addEventListener("click", openLabelEditor);
701
- cardLabels.append(empty);
702
- }
703
- } else {
704
- for (const label of assignedLabels) {
705
- const pill = document.createElement("button");
706
- pill.type = "button";
707
- pill.className = "label-pill";
708
- pill.textContent = label.name || label.color;
709
- pill.style.background = colorForLabel(label.color);
710
- pill.addEventListener("click", openLabelEditor);
711
- cardLabels.append(pill);
721
+ cardLabels.append(createEmptyLabelSummary());
712
722
  }
723
+ return;
713
724
  }
714
725
 
715
- addLabelButton.textContent = state.labelEditorOpen ? "×" : "+";
716
- addLabelButton.setAttribute("aria-label", state.labelEditorOpen ? "Close labels panel" : "Open labels panel");
717
- if (!state.labelEditorOpen) return;
726
+ for (const label of assignedLabels) {
727
+ cardLabels.append(createLabelPill(label));
728
+ }
729
+ }
718
730
 
731
+ function createEmptyLabelSummary() {
732
+ const empty = document.createElement("div");
733
+ empty.className = "empty-state label-summary-trigger";
734
+ empty.textContent = "No labels assigned.";
735
+ empty.addEventListener("click", openLabelEditor);
736
+ return empty;
737
+ }
738
+
739
+ function createLabelPill(label) {
740
+ const pill = document.createElement("button");
741
+ pill.type = "button";
742
+ pill.className = "label-pill";
743
+ pill.textContent = label.name || label.color;
744
+ pill.style.background = colorForLabel(label.color);
745
+ pill.addEventListener("click", openLabelEditor);
746
+ return pill;
747
+ }
748
+
749
+ function createLabelEditorPanel(card) {
719
750
  const labels = sortedBoardLabels();
720
751
  const searchTerm = state.labelSearchTerm.trim().toLowerCase();
721
752
  const filteredLabels = searchTerm
@@ -724,127 +755,153 @@ function renderLabelsEditor(card) {
724
755
 
725
756
  const panel = document.createElement("section");
726
757
  panel.className = "label-editor-panel";
727
-
728
- const searchInput = document.createElement("input");
729
- searchInput.className = "label-search-input";
730
- searchInput.type = "search";
731
- searchInput.placeholder = "Search labels…";
732
- searchInput.value = state.labelSearchTerm;
733
- searchInput.addEventListener("keydown", stopLabelEditorShortcut);
734
- searchInput.addEventListener("input", () => {
735
- state.labelSearchTerm = searchInput.value;
736
- renderCardDialog();
737
- });
738
- panel.append(searchInput);
758
+ panel.append(createLabelSearchInput());
739
759
 
740
760
  if (labels.length === 0) {
741
- const empty = document.createElement("div");
742
- empty.className = "empty-state";
743
- empty.textContent = "No labels yet. Add one for this card.";
744
- panel.append(empty);
745
- const createButton = document.createElement("button");
746
- createButton.type = "button";
747
- createButton.className = "ghost-button";
748
- createButton.textContent = "Create a new label";
749
- createButton.addEventListener("click", addLabelToSelectedCard);
750
- panel.append(createButton);
751
- appendLabelEditorPanel(panel);
752
- return;
761
+ panel.append(createEmptyLabelsMessage(), createLabelCreateButton());
762
+ return panel;
753
763
  }
754
764
 
755
765
  const list = document.createElement("div");
756
766
  list.className = "label-editor-list";
757
-
758
767
  for (const label of filteredLabels) {
759
- const row = document.createElement("div");
760
- row.className = "label-editor-row";
761
-
762
- const toggle = document.createElement("input");
763
- toggle.className = "label-toggle";
764
- toggle.type = "checkbox";
765
- toggle.checked = card.idLabels.includes(label.id);
766
- toggle.addEventListener("change", () => {
767
- if (toggle.checked) {
768
- if (!card.idLabels.includes(label.id)) card.idLabels.push(label.id);
769
- } else {
770
- card.idLabels = card.idLabels.filter((labelId) => labelId !== label.id);
771
- }
772
- touchCard(card);
773
- queueSave("Labels updated");
774
- renderCardDialog();
775
- });
776
-
777
- const nameInput = document.createElement("input");
778
- nameInput.className = "label-name-input";
779
- nameInput.type = "text";
780
- nameInput.value = label.name || "";
781
- nameInput.placeholder = "Label name";
782
- nameInput.style.backgroundColor = colorForLabel(label.color);
783
- let committedName = label.name || "";
784
- nameInput.addEventListener("keydown", stopLabelEditorShortcut);
785
- nameInput.addEventListener("input", () => {
786
- label.name = nameInput.value;
787
- });
788
- nameInput.addEventListener("blur", () => {
789
- const trimmedName = nameInput.value.trim();
790
- if (label.name !== trimmedName) {
791
- label.name = trimmedName;
792
- }
793
- if (committedName !== label.name) {
794
- committedName = label.name;
795
- queueSave("Label updated");
796
- renderBoard();
797
- }
798
- });
799
-
800
- const colorSelect = document.createElement("select");
801
- colorSelect.className = "label-color-select";
802
- for (const color of labelColorOptions()) {
803
- const option = document.createElement("option");
804
- option.value = color;
805
- option.textContent = color.replaceAll("_", " ");
806
- option.selected = color === label.color;
807
- colorSelect.append(option);
808
- }
809
- colorSelect.addEventListener("keydown", stopLabelEditorShortcut);
810
- colorSelect.addEventListener("change", () => {
811
- label.color = colorSelect.value;
812
- queueSave("Label updated");
813
- renderCardDialog();
814
- renderBoard();
815
- });
816
-
817
- const removeButton = document.createElement("button");
818
- removeButton.type = "button";
819
- removeButton.className = "icon-button";
820
- removeButton.append(createIcon("trash"));
821
- removeButton.addEventListener("click", () => {
822
- deleteLabel(label.id);
823
- queueSave("Label removed");
824
- render();
825
- });
826
-
827
- row.append(toggle, nameInput, colorSelect, removeButton);
828
- list.append(row);
768
+ list.append(createLabelEditorRow(card, label));
829
769
  }
830
770
 
831
771
  if (filteredLabels.length === 0) {
832
- const empty = document.createElement("div");
833
- empty.className = "empty-state";
834
- empty.textContent = "No labels match that search.";
835
- panel.append(empty);
772
+ panel.append(createNoMatchingLabelsMessage());
836
773
  } else {
837
774
  panel.append(list);
838
775
  }
839
776
 
777
+ panel.append(createLabelCreateButton());
778
+ return panel;
779
+ }
780
+
781
+ function createLabelSearchInput() {
782
+ const searchInput = document.createElement("input");
783
+ searchInput.className = "label-search-input";
784
+ searchInput.type = "search";
785
+ searchInput.placeholder = "Search labels…";
786
+ searchInput.value = state.labelSearchTerm;
787
+ searchInput.addEventListener("keydown", stopLabelEditorShortcut);
788
+ searchInput.addEventListener("input", () => {
789
+ state.labelSearchTerm = searchInput.value;
790
+ renderCardDialog();
791
+ });
792
+ return searchInput;
793
+ }
794
+
795
+ function createLabelCreateButton() {
840
796
  const createButton = document.createElement("button");
841
797
  createButton.type = "button";
842
798
  createButton.className = "ghost-button";
843
799
  createButton.textContent = "Create a new label";
844
800
  createButton.addEventListener("click", addLabelToSelectedCard);
845
- panel.append(createButton);
801
+ return createButton;
802
+ }
803
+
804
+ function createEmptyLabelsMessage() {
805
+ const empty = document.createElement("div");
806
+ empty.className = "empty-state";
807
+ empty.textContent = "No labels yet. Add one for this card.";
808
+ return empty;
809
+ }
810
+
811
+ function createNoMatchingLabelsMessage() {
812
+ const empty = document.createElement("div");
813
+ empty.className = "empty-state";
814
+ empty.textContent = "No labels match that search.";
815
+ return empty;
816
+ }
817
+
818
+ function createLabelEditorRow(card, label) {
819
+ const row = document.createElement("div");
820
+ row.className = "label-editor-row";
821
+ row.append(
822
+ createLabelToggle(card, label),
823
+ createLabelNameInput(label),
824
+ createLabelColorSelect(label),
825
+ createLabelRemoveButton(label)
826
+ );
827
+ return row;
828
+ }
829
+
830
+ function createLabelToggle(card, label) {
831
+ const toggle = document.createElement("input");
832
+ toggle.className = "label-toggle";
833
+ toggle.type = "checkbox";
834
+ toggle.checked = card.idLabels.includes(label.id);
835
+ toggle.addEventListener("change", () => {
836
+ if (toggle.checked) {
837
+ if (!card.idLabels.includes(label.id)) card.idLabels.push(label.id);
838
+ } else {
839
+ card.idLabels = card.idLabels.filter((labelId) => labelId !== label.id);
840
+ }
841
+ touchCard(card);
842
+ queueSave("Labels updated");
843
+ renderCardDialog();
844
+ });
845
+ return toggle;
846
+ }
847
+
848
+ function createLabelNameInput(label) {
849
+ const nameInput = document.createElement("input");
850
+ nameInput.className = "label-name-input";
851
+ nameInput.type = "text";
852
+ nameInput.value = label.name || "";
853
+ nameInput.placeholder = "Label name";
854
+ nameInput.style.backgroundColor = colorForLabel(label.color);
855
+ let committedName = label.name || "";
856
+ nameInput.addEventListener("keydown", stopLabelEditorShortcut);
857
+ nameInput.addEventListener("input", () => {
858
+ label.name = nameInput.value;
859
+ });
860
+ nameInput.addEventListener("blur", () => {
861
+ const trimmedName = nameInput.value.trim();
862
+ if (label.name !== trimmedName) {
863
+ label.name = trimmedName;
864
+ }
865
+ if (committedName !== label.name) {
866
+ committedName = label.name;
867
+ queueSave("Label updated");
868
+ renderBoard();
869
+ }
870
+ });
871
+ return nameInput;
872
+ }
873
+
874
+ function createLabelColorSelect(label) {
875
+ const colorSelect = document.createElement("select");
876
+ colorSelect.className = "label-color-select";
877
+ for (const color of labelColorOptions()) {
878
+ const option = document.createElement("option");
879
+ option.value = color;
880
+ option.textContent = color.replaceAll("_", " ");
881
+ option.selected = color === label.color;
882
+ colorSelect.append(option);
883
+ }
884
+ colorSelect.addEventListener("keydown", stopLabelEditorShortcut);
885
+ colorSelect.addEventListener("change", () => {
886
+ label.color = colorSelect.value;
887
+ queueSave("Label updated");
888
+ renderCardDialog();
889
+ renderBoard();
890
+ });
891
+ return colorSelect;
892
+ }
846
893
 
847
- appendLabelEditorPanel(panel);
894
+ function createLabelRemoveButton(label) {
895
+ const removeButton = document.createElement("button");
896
+ removeButton.type = "button";
897
+ removeButton.className = "icon-button";
898
+ removeButton.append(createIcon("trash"));
899
+ removeButton.addEventListener("click", () => {
900
+ deleteLabel(label.id);
901
+ queueSave("Label removed");
902
+ render();
903
+ });
904
+ return removeButton;
848
905
  }
849
906
 
850
907
  function stopLabelEditorShortcut(event) {
@@ -1455,6 +1512,13 @@ function archiveCard(card, options = {}) {
1455
1512
  render();
1456
1513
  }
1457
1514
 
1515
+ function archiveSelectedCard() {
1516
+ const card = getSelectedCard();
1517
+ if (!card || isCardArchived(card)) return;
1518
+ archiveCard(card, { force: true });
1519
+ cardDialog.close();
1520
+ }
1521
+
1458
1522
  function restoreArchivedCard(cardId) {
1459
1523
  const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
1460
1524
  if (!card || !isCardArchived(card)) return;
@@ -1687,6 +1751,14 @@ function setKeyboardCard(cardId, shouldRender = true) {
1687
1751
  async function deleteSelectedCard() {
1688
1752
  const card = getSelectedCard();
1689
1753
  if (!card) return;
1754
+ if (await deleteBoardCard(card.id)) {
1755
+ cardDialog.close();
1756
+ }
1757
+ }
1758
+
1759
+ async function deleteBoardCard(cardId) {
1760
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
1761
+ if (!card) return false;
1690
1762
  const confirmed = await openConfirmDialog({
1691
1763
  label: "Delete card",
1692
1764
  title: "Delete card?",
@@ -1694,11 +1766,11 @@ async function deleteSelectedCard() {
1694
1766
  confirmLabel: "Delete",
1695
1767
  danger: true
1696
1768
  });
1697
- if (!confirmed) return;
1769
+ if (!confirmed) return false;
1698
1770
  removeCardCompletely(card.id);
1699
1771
  queueSave("Card deleted");
1700
- cardDialog.close();
1701
1772
  render();
1773
+ return true;
1702
1774
  }
1703
1775
 
1704
1776
  function addChecklistToSelectedCard() {
@@ -2824,17 +2896,34 @@ function hydrateIdentityFromGitConfig() {
2824
2896
  const gitUserName = storageSafeText(state.config?.gitUserName);
2825
2897
  const gitUserEmail = storageSafeText(state.config?.gitUserEmail);
2826
2898
 
2827
- if (!state.currentUserName.trim() && gitUserName) {
2899
+ if (gitUserName) {
2828
2900
  state.currentUserName = gitUserName;
2829
2901
  localStorage.setItem(USER_STORAGE_KEY, gitUserName);
2830
2902
  }
2831
2903
 
2832
- if (!state.currentUserEmail.trim() && gitUserEmail) {
2904
+ if (gitUserEmail) {
2833
2905
  state.currentUserEmail = gitUserEmail;
2834
2906
  localStorage.setItem(USER_EMAIL_STORAGE_KEY, gitUserEmail);
2835
2907
  }
2836
2908
  }
2837
2909
 
2910
+ function renderUserAvatar() {
2911
+ const userName = state.currentUserName.trim() || "Guest";
2912
+ userAvatarButton.dataset.tooltip = userName;
2913
+ userAvatarButton.setAttribute("aria-label", userName);
2914
+ userAvatarFallback.textContent = initialsFor(userName);
2915
+
2916
+ const avatarUrl = storageSafeText(state.config?.gravatarUrl);
2917
+ if (avatarUrl && userAvatarImage.src !== avatarUrl) {
2918
+ userAvatarImage.hidden = false;
2919
+ userAvatarFallback.hidden = true;
2920
+ userAvatarImage.src = avatarUrl;
2921
+ } else if (!avatarUrl) {
2922
+ userAvatarImage.hidden = true;
2923
+ userAvatarFallback.hidden = false;
2924
+ }
2925
+ }
2926
+
2838
2927
  function colorForLabel(color) {
2839
2928
  return labelColorMap[color] || labelColorMap.blue;
2840
2929
  }
package/public/index.html CHANGED
@@ -31,6 +31,10 @@
31
31
  <button id="syncButton" class="primary-button" type="button">Sync</button>
32
32
  <button id="archiveButton" class="ghost-button" type="button">Archive</button>
33
33
  <button id="settingsButton" class="ghost-button" type="button">Settings</button>
34
+ <div id="userAvatarButton" class="user-avatar-button" aria-label="Current user">
35
+ <img id="userAvatarImage" alt="" hidden />
36
+ <span id="userAvatarFallback">?</span>
37
+ </div>
34
38
  </div>
35
39
  </header>
36
40
 
@@ -42,9 +46,6 @@
42
46
  <input id="boardTitleInlineInput" class="inline-title-input board-title-inline-input" type="text" maxlength="200" hidden />
43
47
  </div>
44
48
  </div>
45
- <div class="board-meta">
46
- <span id="userBadge" class="meta-badge">Guest</span>
47
- </div>
48
49
  </section>
49
50
 
50
51
  <main id="boardScroller" class="board-scroller" aria-label="Kanban board"></main>
@@ -116,7 +117,10 @@
116
117
  <div id="activityList" class="activity-list"></div>
117
118
  </section>
118
119
 
119
- <button id="deleteCardButton" type="button" class="danger-button">Delete card</button>
120
+ <div class="card-detail-actions">
121
+ <button id="archiveCardButton" type="button" class="ghost-button">Archive card</button>
122
+ <button id="deleteCardButton" type="button" class="danger-button">Delete card</button>
123
+ </div>
120
124
  </aside>
121
125
  </div>
122
126
  </form>
@@ -133,12 +137,12 @@
133
137
  </header>
134
138
 
135
139
  <label class="field">
136
- <span>Your name</span>
140
+ <span>Your name (read-only, from Git config)</span>
137
141
  <input id="settingsUserName" type="text" maxlength="120" readonly />
138
142
  </label>
139
143
 
140
144
  <label class="field">
141
- <span>Your email</span>
145
+ <span>Your email (read-only, from Git config)</span>
142
146
  <input id="settingsUserEmail" type="email" maxlength="200" readonly />
143
147
  </label>
144
148
 
@@ -305,11 +309,18 @@
305
309
  <h3 class="card-title">Task</h3>
306
310
  <input class="inline-title-input card-title-inline-input" type="text" maxlength="200" placeholder="Task" hidden />
307
311
  </div>
308
- <button class="card-archive-button" type="button" aria-label="Archive card" data-tooltip="Archive">
309
- <svg viewBox="0 0 24 24" aria-hidden="true">
310
- <path d="M20.25 7.5v10.125c0 1.036-.84 1.875-1.875 1.875H5.625A1.875 1.875 0 0 1 3.75 17.625V7.5m16.5 0V5.625c0-1.036-.84-1.875-1.875-1.875H5.625A1.875 1.875 0 0 0 3.75 5.625V7.5m16.5 0h-16.5m8.25 4.5v3m0 0 1.5-1.5M12 15l-1.5-1.5" />
311
- </svg>
312
- </button>
312
+ <div class="card-hover-actions">
313
+ <button class="card-archive-button card-hover-action-button" type="button" aria-label="Archive card" data-tooltip="Archive">
314
+ <svg viewBox="0 0 24 24" aria-hidden="true">
315
+ <path d="M20.25 7.5v10.125c0 1.036-.84 1.875-1.875 1.875H5.625A1.875 1.875 0 0 1 3.75 17.625V7.5m16.5 0V5.625c0-1.036-.84-1.875-1.875-1.875H5.625A1.875 1.875 0 0 0 3.75 5.625V7.5m16.5 0h-16.5m8.25 4.5v3m0 0 1.5-1.5M12 15l-1.5-1.5" />
316
+ </svg>
317
+ </button>
318
+ <button class="card-delete-button card-hover-action-button" type="button" aria-label="Delete card" data-tooltip="Delete">
319
+ <svg viewBox="0 0 24 24" aria-hidden="true">
320
+ <path d="M3 6h18m-2 0-.9 13.15A2 2 0 0 1 16.1 21H7.9a2 2 0 0 1-2-1.85L5 6m3 0V4.5A1.5 1.5 0 0 1 9.5 3h5A1.5 1.5 0 0 1 16 4.5V6m-6 4v7m4-7v7" />
321
+ </svg>
322
+ </button>
323
+ </div>
313
324
  </div>
314
325
  <p class="card-description"></p>
315
326
  <footer class="card-footer"></footer>
package/public/styles.css CHANGED
@@ -212,7 +212,6 @@ button {
212
212
  }
213
213
 
214
214
  .topbar-actions,
215
- .board-meta,
216
215
  .lane-actions,
217
216
  .archive-header-actions,
218
217
  .modal-footer,
@@ -223,10 +222,82 @@ button {
223
222
  align-items: center;
224
223
  }
225
224
 
225
+ .user-avatar-button {
226
+ position: relative;
227
+ width: calc(34px * var(--ui-scale));
228
+ min-width: calc(34px * var(--ui-scale));
229
+ height: calc(34px * var(--ui-scale));
230
+ padding: 0;
231
+ border-radius: 999px;
232
+ border: 1px solid var(--border);
233
+ background: rgba(255, 255, 255, 0.08);
234
+ color: var(--text);
235
+ display: inline-grid;
236
+ place-items: center;
237
+ overflow: visible;
238
+ cursor: default;
239
+ }
240
+
241
+ .user-avatar-button img,
242
+ .user-avatar-button span {
243
+ width: 100%;
244
+ height: 100%;
245
+ border-radius: inherit;
246
+ }
247
+
248
+ .user-avatar-button img[hidden],
249
+ .user-avatar-button span[hidden] {
250
+ display: none !important;
251
+ }
252
+
253
+ .user-avatar-button img {
254
+ display: block;
255
+ object-fit: cover;
256
+ }
257
+
258
+ .user-avatar-button span {
259
+ display: inline-flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ font-size: 0.78rem;
263
+ font-weight: 800;
264
+ background: rgba(255, 255, 255, 0.1);
265
+ }
266
+
267
+ .user-avatar-button::after {
268
+ content: attr(data-tooltip);
269
+ position: absolute;
270
+ right: 0;
271
+ top: calc(100% + 0.55rem);
272
+ padding: 0.32rem 0.5rem;
273
+ border-radius: calc(10px * var(--ui-scale));
274
+ background: rgba(12, 15, 21, 0.96);
275
+ border: 1px solid rgba(255, 255, 255, 0.12);
276
+ color: var(--text);
277
+ font-size: 0.82rem;
278
+ font-weight: 600;
279
+ line-height: 1.2;
280
+ white-space: nowrap;
281
+ opacity: 0;
282
+ pointer-events: none;
283
+ z-index: 30;
284
+ box-shadow: 0 calc(10px * var(--ui-scale)) calc(24px * var(--ui-scale)) rgba(0, 0, 0, 0.32);
285
+ transition: opacity 90ms ease;
286
+ }
287
+
288
+ .user-avatar-button:hover::after {
289
+ opacity: 1;
290
+ }
291
+
226
292
  .archive-header-actions {
227
293
  flex: 0 0 auto;
228
294
  }
229
295
 
296
+ .card-detail-actions {
297
+ display: grid;
298
+ gap: 0.65rem;
299
+ }
300
+
230
301
  #promptDialog .modal-footer {
231
302
  margin-top: 1rem;
232
303
  }
@@ -451,11 +522,42 @@ button {
451
522
  }
452
523
 
453
524
  .card-label {
525
+ position: relative;
454
526
  width: calc(42px * var(--ui-scale));
455
527
  height: calc(8px * var(--ui-scale));
456
528
  border-radius: 999px;
457
529
  }
458
530
 
531
+ .card-label::after {
532
+ content: attr(data-tooltip);
533
+ position: absolute;
534
+ left: 0;
535
+ bottom: calc(100% + 0.5rem);
536
+ padding: 0.32rem 0.5rem;
537
+ border-radius: calc(10px * var(--ui-scale));
538
+ background: rgba(12, 15, 21, 0.96);
539
+ border: 1px solid rgba(255, 255, 255, 0.12);
540
+ color: var(--text);
541
+ font-size: 0.82rem;
542
+ font-weight: 600;
543
+ line-height: 1.2;
544
+ white-space: nowrap;
545
+ opacity: 0;
546
+ pointer-events: none;
547
+ z-index: 10;
548
+ box-shadow: 0 calc(10px * var(--ui-scale)) calc(24px * var(--ui-scale)) rgba(0, 0, 0, 0.32);
549
+ transition: opacity 90ms ease;
550
+ }
551
+
552
+ .card-label:hover::after {
553
+ opacity: 1;
554
+ }
555
+
556
+ .card-label.tooltip-below::after {
557
+ top: calc(100% + 0.5rem);
558
+ bottom: auto;
559
+ }
560
+
459
561
  .card-cover {
460
562
  display: none;
461
563
  margin: -0.9rem -0.9rem 0.85rem;
@@ -564,7 +666,14 @@ button {
564
666
  background: #22c55e;
565
667
  }
566
668
 
567
- .card-archive-button {
669
+ .card-hover-actions {
670
+ flex: 0 0 auto;
671
+ display: flex;
672
+ align-items: center;
673
+ gap: 0.1rem;
674
+ }
675
+
676
+ .card-hover-action-button {
568
677
  width: calc(30px * var(--ui-scale));
569
678
  min-width: calc(30px * var(--ui-scale));
570
679
  height: calc(30px * var(--ui-scale));
@@ -584,7 +693,7 @@ button {
584
693
  transition: opacity 140ms ease, color 140ms ease, transform 140ms ease;
585
694
  }
586
695
 
587
- .card-archive-button::after {
696
+ .card-hover-action-button::after {
588
697
  content: attr(data-tooltip);
589
698
  position: absolute;
590
699
  right: calc(100% + 0.5rem);
@@ -605,7 +714,7 @@ button {
605
714
  transition: opacity 90ms ease;
606
715
  }
607
716
 
608
- .card-archive-button svg {
717
+ .card-hover-action-button svg {
609
718
  width: calc(23px * var(--ui-scale));
610
719
  height: calc(23px * var(--ui-scale));
611
720
  stroke: currentColor;
@@ -615,18 +724,22 @@ button {
615
724
  stroke-linejoin: round;
616
725
  }
617
726
 
618
- .card.is-done:hover .card-archive-button {
727
+ .card:hover .card-hover-action-button {
619
728
  opacity: 1;
620
729
  pointer-events: auto;
621
730
  }
622
731
 
623
- .card-archive-button:hover {
732
+ .card-hover-action-button:hover {
624
733
  color: var(--text);
625
734
  transform: translateY(-1px);
626
735
  }
627
736
 
628
- .card-archive-button:hover::after,
629
- .card-archive-button:focus-visible::after {
737
+ .card-delete-button:hover {
738
+ color: #fecaca;
739
+ }
740
+
741
+ .card-hover-action-button:hover::after,
742
+ .card-hover-action-button:focus-visible::after {
630
743
  opacity: 1;
631
744
  }
632
745