From 09d3f1cb291d96372d03fd5023f0e0b7fbe0a169 Mon Sep 17 00:00:00 2001
From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:43:00 +0000
Subject: [PATCH] fix(desktop): draw the Issues board and an issue's page in
blocks while they read
The board stood nothing under its toolbar until the list answered, and an
issue opened from a link stood only its breadcrumbs until its read did.
The board now stands a column of blocks for every status, in the board's
own geometry, and an issue's page its title, byline, chips, and a few
description lines as blocks in its own document, each said as reading.
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
---
.../src/renderer/desktop/issue-board.tsx | 69 ++++++++++++++++++-
.../src/renderer/desktop/issue-detail.tsx | 51 ++++++++++++++
.../src/renderer/desktop/issues-page.test.ts | 24 ++++++-
.../src/renderer/desktop/issues-page.tsx | 29 +++++---
apps/desktop/src/renderer/styles/issues.css | 35 ++++++++++
5 files changed, 194 insertions(+), 14 deletions(-)
diff --git a/apps/desktop/src/renderer/desktop/issue-board.tsx b/apps/desktop/src/renderer/desktop/issue-board.tsx
index 521b89fba7..89239e4eff 100644
--- a/apps/desktop/src/renderer/desktop/issue-board.tsx
+++ b/apps/desktop/src/renderer/desktop/issue-board.tsx
@@ -5,7 +5,17 @@ import {
issueIdentifier,
} from "@luke/features/issue-list-model";
import { ISSUE_STATUS_ORDER, type Issue, type IssueStatus } from "@luke/features/issue-wire";
-import { IconButton, TEXT_ELEMENT, TEXT_TONE, TEXT_VARIANT, Text, useMenu } from "@luke/ui";
+import {
+ IconButton,
+ SKELETON_SHAPE,
+ Skeleton,
+ SkeletonRegion,
+ TEXT_ELEMENT,
+ TEXT_TONE,
+ TEXT_VARIANT,
+ Text,
+ useMenu,
+} from "@luke/ui";
import { PlusIcon } from "lucide-react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { RelativeTime } from "../tasks/relative-time";
@@ -41,6 +51,19 @@ import { useRowDrag } from "./use-row-drag";
/** What a column with no issue says in its own place. */
const NO_ISSUES = "No issues";
+/** How wide each placeholder column's heading stands, as a share of what its glyph leaves, and how wide each of its cards' titles, so the columns read as columns of their own. */
+const SKELETON_COLUMNS = [
+ { heading: "56%", cards: ["82%", "64%", "74%"] },
+ { heading: "36%", cards: ["70%", "88%"] },
+ { heading: "48%", cards: ["76%", "58%", "84%"] },
+ { heading: "34%", cards: ["66%"] },
+ { heading: "52%", cards: ["72%"] },
+] as const;
+
+/** How wide a placeholder card's identifier and its time stand, as wide as "ACME-12" and a short "2h". */
+const SKELETON_IDENTIFIER = "48px";
+const SKELETON_TIME = "20px";
+
/** What a column's + is called, by the column it files into. */
function newIssueIn(status: IssueStatus): string {
return `New issue in ${ISSUE_STATUS_WORDS[status]}`;
@@ -341,3 +364,47 @@ export function IssueBoard({
);
}
+
+/**
+ * The board while its issues are first read: a column for every status in
+ * the board's own box, its head a block where the glyph and the name will
+ * stand, over a few cards each a block for its identifier and time over one
+ * for its title, so the cards land where the blocks stood rather than on a
+ * blank page. Nothing stands for the counts and the +, and the region says
+ * what is being read.
+ */
+export function IssueBoardSkeleton({ label }: { label: string }): React.JSX.Element {
+ return (
+
+
+
+ {ISSUE_STATUS_ORDER.map((status, at) => {
+ const shape = SKELETON_COLUMNS[at % SKELETON_COLUMNS.length] ?? SKELETON_COLUMNS[0];
+ return (
+
+
+
+
+
+
+
+
+ {shape.cards.map((title, card) => (
+ // oxlint-disable-next-line react/no-array-index-key -- a placeholder's cards have nothing but their place.
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/desktop/issue-detail.tsx b/apps/desktop/src/renderer/desktop/issue-detail.tsx
index e6ae089aee..e12acde4a2 100644
--- a/apps/desktop/src/renderer/desktop/issue-detail.tsx
+++ b/apps/desktop/src/renderer/desktop/issue-detail.tsx
@@ -8,6 +8,9 @@ import {
ConfirmDialog,
EllipsisIcon,
IconButton,
+ SKELETON_SHAPE,
+ Skeleton,
+ SkeletonRegion,
useConfirmDialog,
useMenu,
} from "@luke/ui";
@@ -42,6 +45,16 @@ import type { IssueChange } from "./use-issues";
* which no longer holds the issue.
*/
+/** How wide the placeholder title and the line under it stand, as shares of their lines. */
+const SKELETON_TITLE = "55%";
+const SKELETON_BYLINE = "40%";
+
+/** How wide the placeholder Status and Priority chips stand, as wide as a chip naming one. */
+const SKELETON_CHIPS = ["112px", "96px"] as const;
+
+/** How wide each placeholder line of the description stands, as a share of its line. */
+const SKELETON_DESCRIPTION = ["92%", "86%", "64%"] as const;
+
/**
* The toolbar's path, as the automation editor's: the Issues list a press
* away, and the issue's identifier where one stands.
@@ -259,3 +272,41 @@ export function IssueDetail({
);
}
+
+/**
+ * One issue's page while its issue is first read, opened from a link or a
+ * notification before the list holds it: the page's own document, a block
+ * where the title will stand in its heading's type, one for the line under
+ * it, the two chips' boxes, and a few lines of description, so the issue
+ * lands where the blocks stood. The region says what is being read.
+ */
+export function IssueDetailSkeleton({ label }: { label: string }): React.JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+ {SKELETON_CHIPS.map((width) => (
+
+ ))}
+
+
+ {SKELETON_DESCRIPTION.map((width) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/desktop/issues-page.test.ts b/apps/desktop/src/renderer/desktop/issues-page.test.ts
index b3932c46bc..b0c46eb6b4 100644
--- a/apps/desktop/src/renderer/desktop/issues-page.test.ts
+++ b/apps/desktop/src/renderer/desktop/issues-page.test.ts
@@ -839,6 +839,21 @@ test("an issue's page opened on an issue the service does not hand the reader, a
assert.match(page.textContent ?? "", /This issue is gone, or not in this workspace\./u);
});
+test("an issue's page opened before its issue is read, as a link opens it, stands the issue's page in blocks, said as reading, until the issue lands in their place", async () => {
+ standing = [issue(1, { title: "Fix login" })];
+ const page = mount({ kind: ISSUES_PAGE.ISSUE, issueId: issue(1).id });
+ const reading = page.querySelector('[role="status"]');
+ const readingWords = reading?.textContent;
+ const readingTitle = titleOf(page);
+ await settle();
+
+ assert.equal(readingWords, "Reading the issue");
+ assert.ok(reading?.closest(".desktop-document"), "the blocks stand in the issue's own document");
+ assert.equal(readingTitle, undefined);
+ assert.equal(page.querySelector('[role="status"]'), null);
+ assert.equal(titleOf(page), "Fix login");
+});
+
test("two changes at once draw the newest's answer, though the older one is answered after it", async () => {
standing = [issue(1)];
const page = await openIssue(issue(1).id);
@@ -1365,7 +1380,7 @@ test("View options lays the page out as the list and back as the board, and an i
assert.equal(page.querySelector(".home-section"), null);
});
-test("while the issues are read the list stands rows of blocks, said as reading, and the board stands nothing until its columns can", async () => {
+test("while the issues are read the list stands rows of blocks and the board a column of blocks for every status, each said as reading", async () => {
standing = [issue(1)];
window.localStorage.setItem("luke.issuesLayout", JSON.stringify("list"));
const list = mount();
@@ -1378,12 +1393,17 @@ test("while the issues are read the list stands rows of blocks, said as reading,
window.localStorage.setItem("luke.issuesLayout", JSON.stringify("board"));
const board = mount();
const boardReading = board.querySelector('[role="status"]');
+ const readingColumns = boardReading?.querySelectorAll(".issue-column").length;
+ const readingCards = boardReading?.querySelectorAll("[data-issue-id]").length;
await settle();
assert.equal(reading, "Reading issues");
assert.equal(read, null);
assert.deepEqual(listedGroups, [["Backlog1", ["ACME-1 Issue 1"]]]);
- assert.equal(boardReading, null);
+ assert.equal(boardReading?.textContent, "Reading issues");
+ assert.equal(readingColumns, 5, "a column for every status stands while the board is read");
+ assert.equal(readingCards, 0, "no issue's card stands before the issues are read");
+ assert.equal(board.querySelector('[role="status"]'), null);
assert.deepEqual(columns(board)[0], ["Backlog1", ["ACME-1"]]);
});
diff --git a/apps/desktop/src/renderer/desktop/issues-page.tsx b/apps/desktop/src/renderer/desktop/issues-page.tsx
index 3e4dc103c6..569229bab5 100644
--- a/apps/desktop/src/renderer/desktop/issues-page.tsx
+++ b/apps/desktop/src/renderer/desktop/issues-page.tsx
@@ -39,8 +39,8 @@ import { TOAST_HOLD, TOAST_TONE, useToast } from "../toast";
import { commandKeyshortcuts, Tooltip } from "../tooltip";
import { ActionMenu, type MenuAction } from "./action-menu";
import { readKept, writeKept } from "./folded-groups";
-import { IssueBoard, useBoardKeys } from "./issue-board";
-import { IssueCrumbs, IssueDetail } from "./issue-detail";
+import { IssueBoard, IssueBoardSkeleton, useBoardKeys } from "./issue-board";
+import { IssueCrumbs, IssueDetail, IssueDetailSkeleton } from "./issue-detail";
import { IssueList, listRowIssue } from "./issue-list";
import { type IssueDraft, NewIssueDialog } from "./new-issue-dialog";
import { type IssueChange, useIssue, useIssues } from "./use-issues";
@@ -89,8 +89,9 @@ const storedLayout = Schema.fromJsonString(Schema.Literals(Object.values(ISSUES_
/** What the toolbar's layout menu is called. */
const VIEW_OPTIONS = "View options";
-/** What the list's page is read as while its issues are. */
+/** What the list's page is read as while its issues are, and one issue's page while its issue is. */
const READING_LINE = "Reading issues";
+const READING_ONE_LINE = "Reading the issue";
/** What the list says where it could not be read. */
const UNREAD_LINE = "The issues could not be read.";
@@ -142,8 +143,9 @@ function keyboardLeftWith(issueId: string): boolean {
/**
* What the list's page stands, by its read and its layout. Note that an
* empty board still stands its columns, each saying it holds none, so only
- * the list says there are none yet; and that a board being read stands
- * nothing, since the page's loading rows are a list's shape, not a board's.
+ * the list says there are none yet; and that a board being read stands as
+ * ready, drawing its own columns of blocks (`IssueBoardSkeleton`), since
+ * the page's loading rows are a list's shape, not a board's.
*/
function listState(read: ReturnType["read"], onBoard: boolean): CollectionState {
if (read === undefined) return onBoard ? COLLECTION_STATE.READY : COLLECTION_STATE.LOADING;
@@ -236,7 +238,10 @@ function ListPage({
}
>
- {listed === undefined ? null : onBoard ? (
+ {/* Note that the children stand only once ready, which a page still being read is only on the board. */}
+ {listed === undefined ? (
+
+ ) : onBoard ? (
-
- {failure === undefined ? null : (
+ {failure === undefined ? (
+
+ ) : (
+
{failure === ISSUE_CALL_FAILURE.NOT_FOUND ? GONE_LINE : UNREAD_ONE_LINE}
- )}
-
+
+ )}
);
}
diff --git a/apps/desktop/src/renderer/styles/issues.css b/apps/desktop/src/renderer/styles/issues.css
index 86baa3481f..b5b8847dec 100644
--- a/apps/desktop/src/renderer/styles/issues.css
+++ b/apps/desktop/src/renderer/styles/issues.css
@@ -288,3 +288,38 @@
.issue-board .sidebar-task-time {
margin-left: auto;
}
+
+/* The board while its issues are read (`IssueBoardSkeleton`): the reading
+ region and the box around it hold to the page's height as the board
+ does, so its columns stand as tall as the board's will. */
+.issue-board-reading,
+.issue-board-reading > [role="status"] {
+ display: flex;
+ min-width: min-content;
+ min-height: 0;
+ flex: 1 1 0;
+ flex-direction: column;
+}
+
+/* A placeholder column's name takes what its glyph's block leaves, as the
+ name does. */
+.issue-column-head > .luke-skeleton + .luke-text {
+ flex: 1;
+}
+
+/* A placeholder card stands a card's room: its identifier and time on a
+ caption's line over its title on the body's, at the card's own inset. */
+.issue-card-skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: var(--luke-space-2);
+ padding: var(--luke-space-8) var(--row-inset);
+ font-size: var(--luke-type-body-size);
+}
+
+.issue-card-skeleton-line {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--luke-space-8);
+ font-size: var(--luke-type-caption-size);
+}