diff --git a/apps/catalog/src/catalog.css b/apps/catalog/src/catalog.css index 335e22dd24..3d396552cd 100644 --- a/apps/catalog/src/catalog.css +++ b/apps/catalog/src/catalog.css @@ -255,6 +255,12 @@ body { list-style: none; } +/* The pointer's room over a line held to one line, as wide as the column. */ +.catalog-one-line { + display: block; + min-width: 0; +} + /* A column narrower than a phone, where long words have to wrap. */ .catalog-narrow { display: flex; diff --git a/apps/catalog/src/examples.ts b/apps/catalog/src/examples.ts index b9e0376098..c5b6242935 100644 --- a/apps/catalog/src/examples.ts +++ b/apps/catalog/src/examples.ts @@ -4,6 +4,7 @@ import { ATTACHMENT_CHIPS_EXAMPLE } from "./examples/attachment-chips"; import { AUTH_PAGE_EXAMPLE } from "./examples/auth-page"; import { AVATARS_EXAMPLE } from "./examples/avatars"; import { BADGES_EXAMPLE } from "./examples/badges"; +import { BANNERS_EXAMPLE } from "./examples/banners"; import { BREADCRUMBS_EXAMPLE } from "./examples/breadcrumbs"; import { BUTTONS_EXAMPLE } from "./examples/buttons"; import { CARDS_EXAMPLE } from "./examples/cards"; @@ -29,8 +30,10 @@ import { LAYOUT_EXAMPLE } from "./examples/layout"; import { LIST_ROW_EXAMPLE } from "./examples/list-row"; import { MENU_EXAMPLE } from "./examples/menu"; import { NAV_ITEMS_EXAMPLE } from "./examples/nav-items"; +import { ONE_LINE_EXAMPLE } from "./examples/one-line"; import { PAGINATION_EXAMPLE } from "./examples/pagination"; import { POPOVER_PANELS_EXAMPLE } from "./examples/popover-panels"; +import { PROGRESS_EXAMPLE } from "./examples/progress"; import { PROSE_EXAMPLE } from "./examples/prose"; import { PROVIDER_BUTTONS_EXAMPLE } from "./examples/provider-buttons"; import { PUBLIC_PAGE_EXAMPLE } from "./examples/public-page"; @@ -73,6 +76,7 @@ export const EXAMPLES: readonly Example[] = [ ATTACHMENT_CHIPS_EXAMPLE, AVATARS_EXAMPLE, BADGES_EXAMPLE, + BANNERS_EXAMPLE, BREADCRUMBS_EXAMPLE, BUTTONS_EXAMPLE, CARDS_EXAMPLE, @@ -102,8 +106,10 @@ export const EXAMPLES: readonly Example[] = [ LIST_ROW_EXAMPLE, MENU_EXAMPLE, NAV_ITEMS_EXAMPLE, + ONE_LINE_EXAMPLE, PAGINATION_EXAMPLE, POPOVER_PANELS_EXAMPLE, + PROGRESS_EXAMPLE, PROSE_EXAMPLE, PROVIDER_BUTTONS_EXAMPLE, PUBLIC_PAGE_EXAMPLE, diff --git a/apps/catalog/src/examples/attachment-chips.tsx b/apps/catalog/src/examples/attachment-chips.tsx index 1c1cbe9a02..09599b61bb 100644 --- a/apps/catalog/src/examples/attachment-chips.tsx +++ b/apps/catalog/src/examples/attachment-chips.tsx @@ -1,4 +1,4 @@ -import { AttachmentChip, ImageIcon } from "@luke/ui"; +import { ATTACHMENT_CHIP_STATE, AttachmentChip, ImageIcon } from "@luke/ui"; import { useState } from "react"; import type { Example } from "../examples"; import { CatalogSection } from "../section"; @@ -7,7 +7,8 @@ import { CatalogSection } from "../section"; * attachment-chips.tsx -- a row of files coming along with a message: a * picture's thumbnail, files drawn by name and size, one with nothing to * open, a long name cut short, and the way off each, unavailable while busy; - * then the same chips in a narrow column. + * then the same chips in a narrow column, and a call's pictures still read, + * gone, and not kept. */ /** A synthetic picture drawn in place, so the catalog renders offline. */ @@ -67,6 +68,23 @@ function AttachmentChips(): React.JSX.Element { + + +
diff --git a/apps/catalog/src/examples/badges.tsx b/apps/catalog/src/examples/badges.tsx index 59996e5427..12554f421c 100644 --- a/apps/catalog/src/examples/badges.tsx +++ b/apps/catalog/src/examples/badges.tsx @@ -3,8 +3,10 @@ import type { Example } from "../examples"; import { CatalogSection } from "../section"; /** - * badges.tsx -- every tone with its words, one with a glyph, and badges - * standing on a line of body words, and a long label in a narrow column. + * badges.tsx -- every tone with its words, one with a glyph, a provider's + * labels each wearing its colour as a dot, a dark one and one with no colour + * it can paint among them, badges standing on a line of body words, and a + * long label in a narrow column. */ const SAYS = { @@ -16,6 +18,14 @@ const SAYS = { [BADGE_TONE.DANGER]: "Failed", } as const; +/** GitHub labels as GitHub answers them: six hex digits, or a colour the badge cannot paint. */ +const LABELS = [ + { name: "bug", color: "d73a4a" }, + { name: "enhancement", color: "a2eeef" }, + { name: "needs design", color: "1d1d1f" }, + { name: "imported", color: "not-a-colour" }, +] as const; + function Badges(): React.JSX.Element { return ( <> @@ -31,6 +41,16 @@ function Badges(): React.JSX.Element { + + + {LABELS.map((label) => ( + + {label.name} + + ))} + +3 + +
diff --git a/apps/catalog/src/examples/banners.tsx b/apps/catalog/src/examples/banners.tsx new file mode 100644 index 0000000000..e01cf09554 --- /dev/null +++ b/apps/catalog/src/examples/banners.tsx @@ -0,0 +1,81 @@ +/** + * banners.tsx -- a notice in each look: a strip across a column in the + * attention tone, a card over a document in either tone, and a quiet row in + * its place's frame; then each in a narrow column, where the presses drop + * beneath the words. + */ + +import { + AlertIcon, + ArchiveIcon, + BANNER_LOOK, + BANNER_TONE, + Banner, + BUTTON_VARIANT, + Button, + GAP, + Stack, + TEXT_TONE, + TEXT_VARIANT, + Text, +} from "@luke/ui"; +import type { Example } from "../examples"; +import { CatalogSection } from "../section"; + +/** Every look, at the width the column gives them. */ +function Looks(): React.JSX.Element { + return ( + + } + actions={} + > + This plan's repository, acme/relay, belongs to the Acme workspace. + + } + actions={} + > + This plan is archived + + }> + Every new plan starts from this template + Edit it to change what a plan holds when it is made. + +
+ } + actions={ + <> + + + + } + > + Sam archived this agent: superseded by the new flow + +
+
+ ); +} + +function Banners(): React.JSX.Element { + return ( + <> + + + + ); +} + +export const BANNERS_EXAMPLE: Example = { id: "banners", title: "Banners", Render: Banners }; diff --git a/apps/catalog/src/examples/hover-cards.tsx b/apps/catalog/src/examples/hover-cards.tsx index f5299d84b2..0d4bbf9287 100644 --- a/apps/catalog/src/examples/hover-cards.tsx +++ b/apps/catalog/src/examples/hover-cards.tsx @@ -1,14 +1,20 @@ import { BUTTON_VARIANT, Button, + DocumentIcon, + FolderIcon, GAP, + HOVER_CARD_ROW_LOOK, HoverCard, HoverCardHead, + HoverCardList, HoverCardNote, + HoverCardRow, HoverCardRule, Inline, PinIcon, TooltipCard, + UserIcon, } from "@luke/ui"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import type { Example } from "../examples"; @@ -17,8 +23,9 @@ import { CatalogSection } from "../section"; /** * hover-cards.tsx -- the words of a card hung in a pill's place: a head with * its glyph, line, and corner; notes wrapped and on one line faded at its - * end; and a rule before the rows it lists, one of them a button the pointer - * crosses onto the card to press. Each card shows once the pointer rests on + * end; quieter rows under the name, one a press and one text; and a rule + * before the rows it lists, counting what it leaves out, each row a button + * the pointer crosses onto the card to press. Each card shows once the pointer rests on * its control or the keyboard reaches it, and stands while the pointer is on * the control, on the card, or between them: the catalog's own stand-in for * the linger an app gives its cards. @@ -149,11 +156,35 @@ function HoverCards(): React.JSX.Element { card={ + } + label="acme/relay" + onPress={() => undefined} + /> + } + label="Created by Sam Rivera · 3d ago" + /> - - - - + +
  • + } + label="Invite flow" + onPress={() => undefined} + /> +
  • +
  • + } + label="Expire every invitation a week after it was sent, and say so in the email" + end="+120 −14" + onPress={() => undefined} + /> +
  • +
    } /> diff --git a/apps/catalog/src/examples/one-line.tsx b/apps/catalog/src/examples/one-line.tsx new file mode 100644 index 0000000000..b5d9953cf8 --- /dev/null +++ b/apps/catalog/src/examples/one-line.tsx @@ -0,0 +1,78 @@ +/** + * one-line.tsx -- words held to one line in a narrow column, ending in an + * ellipsis and in a fade, and a short line that ends on its last letter; + * the pointer resting on a line cut short reads it whole in a pill, the + * catalog's own stand-in for the timing an app gives its hints. + */ + +import { + GAP, + ONE_LINE_ELEMENT, + ONE_LINE_END, + OneLine, + type OneLineHint, + Stack, + TooltipPill, +} from "@luke/ui"; +import { type ReactNode, useId, useState } from "react"; +import type { Example } from "../examples"; +import { CatalogSection } from "../section"; + +const ADDRESS = "sam.rivera+release-notifications@relay.example.com"; + +/** A pill reading `words` whole over a line, while the pointer rests on it and it is cut short. */ +function PillOver({ + words, + clipped, + children, +}: { + words: string; + clipped: boolean; + children: ReactNode; +}): React.JSX.Element { + const id = useId(); + const [anchor, setAnchor] = useState(undefined); + return ( + <> + setAnchor(event.currentTarget)} + onPointerLeave={() => setAnchor(undefined)} + > + {children} + + {anchor === undefined || !clipped ? null : ( + + )} + + ); +} + +/** The catalog's hint for a line saying `words`. */ +function pillHint(words: string): OneLineHint { + return (line, clipped) => ( + + {line} + + ); +} + +function OneLines(): React.JSX.Element { + return ( + +
    + + {ADDRESS} + + {ADDRESS} + + + sam@relay.dev + + +
    +
    + ); +} + +export const ONE_LINE_EXAMPLE: Example = { id: "one-line", title: "One line", Render: OneLines }; diff --git a/apps/catalog/src/examples/progress.tsx b/apps/catalog/src/examples/progress.tsx new file mode 100644 index 0000000000..8d4ebccae0 --- /dev/null +++ b/apps/catalog/src/examples/progress.tsx @@ -0,0 +1,38 @@ +/** + * progress.tsx -- a download's bar under the words that say it in numbers, + * at its start, part way, and done, and a bar of its own named by its label. + */ + +import { GAP, Progress, Stack, TEXT_TONE, TEXT_VARIANT, Text } from "@luke/ui"; +import type { Example } from "../examples"; +import { CatalogSection } from "../section"; + +const SHARES = [0, 42, 100] as const; + +function ProgressBars(): React.JSX.Element { + return ( + <> + + + {SHARES.map((share) => ( + + + {`Downloading Luke 4.2 · ${String(share)}%`} + + + + ))} + + + + + + + ); +} + +export const PROGRESS_EXAMPLE: Example = { + id: "progress", + title: "Progress", + Render: ProgressBars, +}; diff --git a/apps/catalog/src/examples/spinners.tsx b/apps/catalog/src/examples/spinners.tsx index 4cc1d7a70c..5f5d2283cf 100644 --- a/apps/catalog/src/examples/spinners.tsx +++ b/apps/catalog/src/examples/spinners.tsx @@ -10,6 +10,7 @@ import { SpinnerRing, Stack, TEXT_TONE, + TEXT_VARIANT, Text, } from "@luke/ui"; import type { Example } from "../examples"; @@ -17,7 +18,7 @@ import { CatalogSection } from "../section"; /** * spinners.tsx -- a wait beside words and alone in a region, the ring as a - * mark, and a control's words giving way to it beside the same words at + * mark and at a caption's size beside the words that say the wait, and a control's words giving way to it beside the same words at * rest, so the two stand the same width. */ @@ -34,6 +35,12 @@ function Spinners(): React.JSX.Element { + + + + Waiting for the browser + + diff --git a/apps/desktop/src/renderer/banner.tsx b/apps/desktop/src/renderer/banner.tsx deleted file mode 100644 index 73eb501f2d..0000000000 --- a/apps/desktop/src/renderer/banner.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { type ReactNode, useId } from "react"; - -/** - * banner.tsx -- the one strip every notice about a plan is drawn as: a glyph, its words, and the presses that answer it, which drop beneath the words where the strip is narrow. - * - * A plan's notices come in three looks: the slim line across the plan's - * column (a stranded repository's, `task-banner.tsx`), the card over a - * document (an archived plan's, the plan template's), and the row in an - * agent's composer's place (an archived plan's agent). They share one - * layout, so they give way alike: each strip is a named container - * (`banner` in `desktop.css`), and below its narrow width the presses leave - * the words' line for a line of their own, each as wide as the strip - * allows, so a word is never squeezed a letter to a line and a press never - * stands on the words. A strip measures itself rather than the window, - * because the same strip stands in a 280px column beside a wide side panel - * and in a full window alike. A strip is named by its own words, never an - * attribute, because the session recording masks text and nothing else. - */ - -/** How a strip looks; its layout is the same for all three. */ -export const BANNER_LOOK = { - /** One slim line on a faint attention tint, across the plan's column. */ - LINE: "line", - /** A card on the document's reading width. */ - CARD: "card", - /** A quiet row in an agent's composer's place. */ - COMPOSER: "composer", -} as const; - -type BannerLook = (typeof BANNER_LOOK)[keyof typeof BANNER_LOOK]; - -/** The class each look wears beside the shared `banner`. */ -const LOOK_CLASS = { - [BANNER_LOOK.LINE]: "task-banner", - [BANNER_LOOK.CARD]: "document-banner", - [BANNER_LOOK.COMPOSER]: "agent-composer archived-composer", -} as const satisfies Record; - -/** A strip: its look, its glyph, its words, which name it to a reader, and the presses that answer it, if any. */ -export function Banner({ - look, - glyph, - actions, - className, - anchor, - children, -}: { - look: BannerLook; - glyph: ReactNode; - /** The presses, in reading order; the last is the strip's primary one. */ - actions?: ReactNode; - /** A class of the strip's own place, beside its look's. */ - className?: string; - /** Attributes that make the strip somewhere a settings search lands. */ - anchor?: Readonly>; - children: ReactNode; -}): React.JSX.Element { - const wordsId = useId(); - const classes = ["banner", LOOK_CLASS[look], className].filter(Boolean).join(" "); - return ( -
    -
    - {glyph} -
    - {children} -
    - {actions === undefined ? null :
    {actions}
    } -
    -
    - ); -} diff --git a/apps/desktop/src/renderer/desktop/move-task-dialog.test.ts b/apps/desktop/src/renderer/desktop/move-task-dialog.test.ts index da52059ffa..2e7f28d2fc 100644 --- a/apps/desktop/src/renderer/desktop/move-task-dialog.test.ts +++ b/apps/desktop/src/renderer/desktop/move-task-dialog.test.ts @@ -278,9 +278,16 @@ function openTask(tasks: TasksControl): HTMLElement { return container; } -/** The task-wide banner over a stranded task, where one stands. */ +/** The task-wide banner over a stranded task, where one stands: the region its words name. */ function strandedBanner(within: HTMLElement): HTMLElement | null { - return within.querySelector(".task-banner"); + const regions = [...within.querySelectorAll("section[aria-labelledby]")]; + return ( + regions.find((region) => + document + .getElementById(region.getAttribute("aria-labelledby") ?? "") + ?.textContent?.startsWith("This task's repository belongs to"), + ) ?? null + ); } /** The words a reader hears in an element: its text but for a mark hidden from a screen reader, a workspace's tile's letter among them. */ diff --git a/apps/desktop/src/renderer/desktop/sidebar-pinned-tab.tsx b/apps/desktop/src/renderer/desktop/sidebar-pinned-tab.tsx index c22af51d10..1f446f7022 100644 --- a/apps/desktop/src/renderer/desktop/sidebar-pinned-tab.tsx +++ b/apps/desktop/src/renderer/desktop/sidebar-pinned-tab.tsx @@ -4,9 +4,12 @@ import { BUTTON_SIZE, CopyIcon, DocumentIcon, + HOVER_CARD_ROW_LOOK, HoverCard, HoverCardHead, + HoverCardList, HoverCardNote, + HoverCardRow, HoverCardRule, IconButton, UnpinIcon, @@ -27,7 +30,7 @@ import { Tooltip } from "../tooltip"; import { ActionMenu, type MenuAction } from "./action-menu"; import { agentTranscriptRead } from "./main-tabs"; import { type TranscriptCopier, useTranscriptCopier } from "./strip-tabs"; -import { CardDetail, PullRequestLine, RepositoryDetail } from "./task-card"; +import { PullRequestLine, RepositoryDetail } from "./task-card"; /** * sidebar-pinned-tab.tsx -- a tab of the task's columns pinned to the sidebar: its row under Pinned, its Unpin, and its menu. @@ -219,19 +222,22 @@ function PinnedTabCard({ return ( -
    - } label={task.name} onPress={onOpenTask} /> - {repository === null ? null : ( - - )} -
    + } + label={task.name} + onPress={onOpenTask} + /> + {repository === null ? null : ( + + )} {gone ? {GONE_NOTE} : null} {pullRequest === undefined ? null : ( <> -
      + -
    + )}
    diff --git a/apps/desktop/src/renderer/desktop/task-actions.test.ts b/apps/desktop/src/renderer/desktop/task-actions.test.ts index 093e160fa0..3b1be838c2 100644 --- a/apps/desktop/src/renderer/desktop/task-actions.test.ts +++ b/apps/desktop/src/renderer/desktop/task-actions.test.ts @@ -408,10 +408,10 @@ test("a sidebar plan whose repository belongs to another of the account's worksp assert.notEqual(mark(), null, "the row wears the attention mark"); assert.match(heard(container), /\(repository belongs to Stage\)/u); - assert.deepEqual([...card.querySelectorAll(".task-card-detail")].map(heard), [ - "acme-corp/relay", - "Repository belongs to Stage", - ]); + assert.deepEqual( + [...card.querySelectorAll('.luke-hover-card-row[data-look="detail"]')].map(heard), + ["acme-corp/relay", "Repository belongs to Stage"], + ); act(() => roots[0]?.render(sidebarTask(tasks, { ...STRANDED, workspaceId: STAGE.id }, asked))); assert.equal(mark(), null, "moved to Stage, the row wears it no longer"); @@ -492,8 +492,8 @@ test("a task's card says who created it where others share its workspace, the re children: taskCardOf(tasks, { ...task, workspaceId: workspace.id }, task.updatedAt), }), ); - const lines = [...card.querySelectorAll(".task-card-detail")].map((line) => - heard(line).replace(/ · .*$/u, ""), + const lines = [...card.querySelectorAll('.luke-hover-card-row[data-look="detail"]')].map( + (line) => heard(line).replace(/ · .*$/u, ""), ); unmountAll(); return lines; @@ -515,7 +515,7 @@ test("a task's card's creator line opens the creator's page in the window, and i const charles = { ...OTHER, createdAt: 60_000, ownerId: CHARLES.userId, createdBy: CHARLES }; const drawn = (inWindow: boolean) => { const card = taskCardOf(tasks, { ...charles, workspaceId: STAGE.id }, charles.updatedAt); - return mount( + const view = mount( createElement(MemberDirectory, { workspace: undefined, readerId: DEAN.userId, @@ -523,7 +523,10 @@ test("a task's card's creator line opens the creator's page in the window, and i ? createElement(OpenPersonProvider, { value: (id: string) => opened.push(id) }, card) : card, }), - ).querySelector(".task-card-creator"); + ); + return [...view.querySelectorAll('.luke-hover-card-row[data-look="detail"]')].find( + (each) => each.textContent?.includes("Created by"), + ); }; const line = drawn(true); @@ -555,8 +558,8 @@ test("a task's card says you of the reader's own task outside a member directory await act(async () => { for (let tick = 0; tick < 8; tick += 1) await Promise.resolve(); }); - const said = [...card.querySelectorAll(".task-card-detail")].map((line) => - heard(line).replace(/ · .*$/u, ""), + const said = [...card.querySelectorAll('.luke-hover-card-row[data-look="detail"]')].map( + (line) => heard(line).replace(/ · .*$/u, ""), ); unmountAll(); return said; diff --git a/apps/desktop/src/renderer/desktop/task-banner.tsx b/apps/desktop/src/renderer/desktop/task-banner.tsx index 6409dd65b4..d1350f4a8c 100644 --- a/apps/desktop/src/renderer/desktop/task-banner.tsx +++ b/apps/desktop/src/renderer/desktop/task-banner.tsx @@ -1,12 +1,11 @@ -import { BUTTON_VARIANT, Button } from "@luke/ui"; +import { BANNER_LOOK, BANNER_TONE, Banner, BUTTON_VARIANT, Button } from "@luke/ui"; import type { ReactNode } from "react"; import { AttentionMark } from "../attention-mark"; -import { BANNER_LOOK, Banner } from "../banner"; /** * task-banner.tsx -- a notice about the whole task: one slim line under the task's toolbar, over every tab of it, which only mending what it says takes away. * - * A banner over a document (`document-banner` in `desktop.css`) is about + * A banner over a document (`archived-task.tsx`) is about * that document, so it stands over the task's own tab and nowhere else; what * is wrong with the task itself, wherever the developer is reading it, is * said here instead. It spans the task's column under the toolbar, so it @@ -18,7 +17,7 @@ import { BANNER_LOOK, Banner } from "../banner"; * one press that mends it, an outlined button, on a faint attention tint. It has no close: it * goes once what it says no longer holds. Its words are text, never an * attribute, because the session recording masks text and nothing else. Where - * the column is narrow it gives way as every banner does (`banner.tsx`): + * the column is narrow it gives way as every banner does (`@luke/ui`'s `Banner`): * the words wrap and the press takes a line of its own beneath them. */ @@ -39,7 +38,8 @@ export function TaskBanner({ }): React.JSX.Element { return ( } actions={ } > -

    {children}

    + {children}
    ); } diff --git a/apps/desktop/src/renderer/desktop/task-card.tsx b/apps/desktop/src/renderer/desktop/task-card.tsx index 998ea74544..ed4343022c 100644 --- a/apps/desktop/src/renderer/desktop/task-card.tsx +++ b/apps/desktop/src/renderer/desktop/task-card.tsx @@ -3,7 +3,15 @@ import type { CatalogModel } from "@luke/features/models-wire"; import { moreText, TASK_ROW_MARK, taskRowMark } from "@luke/features/task-agents-model"; import type { TaskAgentLine, TaskPullRequestLine, TaskSummary } from "@luke/features/task-wire"; import type { Workspace } from "@luke/features/workspace-wire"; -import { HoverCard, HoverCardHead, HoverCardRule, ProviderMark } from "@luke/ui"; +import { + HOVER_CARD_ROW_LOOK, + HoverCard, + HoverCardHead, + HoverCardList, + HoverCardRow, + HoverCardRule, + ProviderMark, +} from "@luke/ui"; import { ACTOR_KIND, actorName, SOMEONE_REASON } from "@luke/ui/actor-model"; import { TASK_ORDER, taskTime } from "@luke/ui/task-list-model"; import { repositoryPageUrl } from "@luke/ui/tasks-model"; @@ -42,9 +50,10 @@ import { WorkspaceRef } from "./workspace-ref"; * repository and a pull request open their pages on GitHub, and an agent * opens the task on its tab. Each is also offered where the card is not: * the task's menu opens the repository, and the agent's tab its pull - * request. The rest are facts, left as text. Its rows are set as a menu's - * rows are, and a pinned tab's card (`sidebar-pinned-tab.tsx`) is drawn - * from the same rows (`CardDetail`, `RepositoryDetail`, `PullRequestLine`). + * request. The rest are facts, left as text. Its rows are `@luke/ui`'s + * `HoverCardRow`, set as a menu's rows are, and a pinned tab's card + * (`sidebar-pinned-tab.tsx`) is drawn from the same rows + * (`RepositoryDetail`, `PullRequestLine`). */ /** The most rows each of the card's lists draws before it counts the rest. */ @@ -86,56 +95,6 @@ export function TaskStatusMark({ } } -/** - * One row of a card's lists, set as a menu's row is: its glyph, its words, - * and what stands at its end. It is a button where a press does something, - * and text where nothing does, in the same place either way. - */ -function CardLine({ - glyph, - label, - end, - onPress, -}: { - glyph: React.ReactNode; - label: React.ReactNode; - end?: React.ReactNode; - onPress?: (() => void) | undefined; -}): React.JSX.Element { - const line = ( - <> - {glyph} - {label} - {end} - - ); - return onPress === undefined ? ( - {line} - ) : ( - - ); -} - -/** One of the quieter lines under a card's name, which a press opens: a task, or a repository on GitHub. */ -export function CardDetail({ - glyph, - label, - onPress, -}: { - glyph: React.ReactNode; - label: string; - onPress: () => void; -}): React.JSX.Element { - return ( - - ); -} - /** A repository's line under a card's name, its owner's picture before it, which opens it on GitHub. */ export function RepositoryDetail({ repository, @@ -148,7 +107,8 @@ export function RepositoryDetail({ onOpenGitHub: (url: string) => void; }): React.JSX.Element { return ( - - - - - - {`Created by ${actorName(creator, { sentence: true })}${left ? ", who left" : ""} · `} - - - - ); - return door === undefined ? ( - {words} - ) : ( - + return ( + } + label={ + <> + {`Created by ${actorName(creator, { sentence: true })}${left ? ", who left" : ""} · `} + + + } + onPress={door} + /> ); } @@ -212,7 +167,7 @@ function AgentLine({ }): React.JSX.Element { return (
  • - } label={agentTabLabel(agent, models)} end={agentEndMark(agent, false)} @@ -238,7 +193,7 @@ export function PullRequestLine({ const { url } = pullRequest; return (
  • - } label={ <> @@ -247,10 +202,10 @@ export function PullRequestLine({ } end={ - + <> +{pullRequest.additions}{" "} −{pullRequest.deletions} - + } onPress={url === undefined ? undefined : () => onOpenGitHub(url)} /> @@ -258,12 +213,6 @@ export function PullRequestLine({ ); } -/** A count of what a list leaves out, under its rows; nothing where it leaves nothing out. */ -function More({ total, shown }: { total: number; shown: number }): React.JSX.Element | null { - const words = moreText(total, shown); - return words === undefined ? null :
  • {words}
  • ; -} - /** * The card a sidebar task hangs: its name, wrapped to two lines, and how * long ago it was updated at the head's right; its repository under the @@ -328,28 +277,28 @@ export function TaskCard({ ) : null} {stranded === undefined ? null : ( - - - - - - Repository belongs to - - + } + label={ + <> + Repository belongs to + + } + /> )} {focus === undefined ? null : ( <> - - {focus.glyph} - {focus.words} - +
    + +
    )} {focus !== undefined || agents === undefined || agentRows.length === 0 ? null : ( <> -
      + {agentRows.map((agent) => ( onShowAgent(agent.id)} /> ))} - -
    + )} {focus !== undefined || agents === undefined || pullRequestRows.length === 0 ? null : ( <> -
      + {pullRequestRows.map((pullRequest) => ( ))} - -
    + )} diff --git a/apps/desktop/src/renderer/settings/account-section.tsx b/apps/desktop/src/renderer/settings/account-section.tsx index 7bf4c7aae6..ef0d3fc319 100644 --- a/apps/desktop/src/renderer/settings/account-section.tsx +++ b/apps/desktop/src/renderer/settings/account-section.tsx @@ -6,6 +6,7 @@ import { Button, ConfirmDialog, DIALOG_TONE, + OneLine, ProviderMark, SettingsRow, SettingsSection, @@ -14,7 +15,7 @@ import { import { accountActor } from "@luke/ui/actor-model"; import { ACTION_RESULT_STATUS, type ActionResult } from "@luke/wire"; import { ACTOR_SIZE, ActorMark } from "../actor-ref"; -import { OneLine } from "./one-line"; +import { wholeWordsHint } from "../tooltip"; /** * Who is signed in, and the two ways out of that, at the head of the Account @@ -55,7 +56,7 @@ export function AccountSection({ } - label={} + label={{account.email}} description={ Signed in with diff --git a/apps/desktop/src/renderer/settings/integrations-page.tsx b/apps/desktop/src/renderer/settings/integrations-page.tsx index 3e3e53baac..90c92270fe 100644 --- a/apps/desktop/src/renderer/settings/integrations-page.tsx +++ b/apps/desktop/src/renderer/settings/integrations-page.tsx @@ -253,11 +253,7 @@ function grantLabel(status: IntegrationStatus | undefined, signIn: SignIn | unde function RowLineText({ line }: { line: RowLine }): React.JSX.Element { return ( - {line.state === LINE_STATE.WAITING ? ( - - - - ) : null} + {line.state === LINE_STATE.WAITING ? : null} {line.text} ); diff --git a/apps/desktop/src/renderer/settings/one-line.tsx b/apps/desktop/src/renderer/settings/one-line.tsx deleted file mode 100644 index 4fd7bdc332..0000000000 --- a/apps/desktop/src/renderer/settings/one-line.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useClipped } from "@luke/ui/use-clipped"; -import { Tooltip } from "../tooltip"; - -/** - * one-line.tsx -- a row's words held to one line, an ellipsis where the row cuts them short, and the pointer resting on them reading them whole. - * - * An address has nowhere to wrap but its dots and hyphens, so in a narrow - * row it ran under the control beside it or out of its card. Held to one - * line it ends in an ellipsis instead (`.settings-one-line`), and only then - * does it raise the window's pill with its whole words: a line drawn whole - * says nothing twice. The words ride the pill rather than a `title`, which - * session replay would carry unmasked. - */ -export function OneLine({ - as: Element, - text, -}: { - /** The row's name (`strong`, or `span` in a shared row's label) or the detail under it (`small`). */ - as: "strong" | "span" | "small"; - text: string; -}): React.JSX.Element { - const [line, clipped] = useClipped(); - return ( - - - {text} - - - ); -} diff --git a/apps/desktop/src/renderer/settings/plan-template-page.tsx b/apps/desktop/src/renderer/settings/plan-template-page.tsx index fc17d0c4b4..f09a398271 100644 --- a/apps/desktop/src/renderer/settings/plan-template-page.tsx +++ b/apps/desktop/src/renderer/settings/plan-template-page.tsx @@ -9,6 +9,8 @@ import { TASK_CALL_FAILURE, type TasksEditAnswer } from "@luke/features/tasks-vi import { ACT_KIND } from "@luke/host/acts"; import { SETTINGS_SEARCH_ROW, searchAnchorProps } from "@luke/settings/settings-anchors"; import { + BANNER_LOOK, + Banner, BUTTON_VARIANT, Button, ConfirmDialog, @@ -28,7 +30,6 @@ import { ACTION_RESULT_STATUS, type ActionResult } from "@luke/wire"; import { HistoryIcon, InfoIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { type ActHandle, useAct } from "../act"; -import { BANNER_LOOK, Banner } from "../banner"; import type { TaskReader } from "../tasks/edit-ref"; import { PlanBody } from "../tasks/plan-body"; import type { PlanEditDoor } from "../tasks/use-plan-editing"; @@ -303,19 +304,12 @@ function useTemplate(signedIn: boolean, act: ActHandle["act"]): TemplateControl */ function TemplateNote(): React.JSX.Element { return ( - - } - > - {PAGE_LINE.NOTE} - {PAGE_LINE.NOTE_DETAIL} - +
    + }> + {PAGE_LINE.NOTE} + {PAGE_LINE.NOTE_DETAIL} + +
    ); } diff --git a/apps/desktop/src/renderer/settings/updates.tsx b/apps/desktop/src/renderer/settings/updates.tsx index ce4621edda..8a891fd4c2 100644 --- a/apps/desktop/src/renderer/settings/updates.tsx +++ b/apps/desktop/src/renderer/settings/updates.tsx @@ -6,6 +6,7 @@ import { Button, CheckIcon, ExternalIcon, + Progress, SettingsRow, SettingsSection, } from "@luke/ui"; @@ -95,11 +96,8 @@ export function UpdatesSection({ row.failed ? undefined : ( <> {row.detail} - {row.progress === undefined ? null : ( - ), - description: , + description: {member.email}, error: refusal, control: ( <> @@ -1012,7 +1013,7 @@ function Invitations({ ]} row={{ leading: , - label: , + label: {one.email}, description: `Invited as ${ROLE_WORDS[one.role]} · Expires ${dayAndTimeText(Date.parse(one.expiresAt))}`, // Luke sends no mail: the accept link is copied and sent by hand. control: ( diff --git a/apps/desktop/src/renderer/styles/desktop.css b/apps/desktop/src/renderer/styles/desktop.css index 329d6c06f9..f8bdb22861 100644 --- a/apps/desktop/src/renderer/styles/desktop.css +++ b/apps/desktop/src/renderer/styles/desktop.css @@ -2068,7 +2068,7 @@ ul.actor-stack { font-size: var(--luke-type-caption-size); } -.failure-note-remedy :is(svg, .luke-spinner-ring) { +.failure-note-remedy svg { width: 13px; height: 13px; } @@ -2389,138 +2389,24 @@ ul.actor-stack { height: 12px; } -/* A notice about a plan (`banner.tsx`): a glyph, its words, and the presses - that answer it on one row, in any of its three looks. Each strip is a - container of its own, so it answers its own width rather than the - window's: the same strip stands in a 280px column beside a wide side panel - and across a whole window. */ -.banner { - --banner-gap: var(--luke-icon-gap); - container: banner / inline-size; -} - -.banner-row { - display: flex; - align-items: center; - gap: var(--banner-gap); -} - -.banner-words { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: var(--luke-space-2); -} - -.banner-actions { - display: flex; - min-width: 0; - flex: 0 1 auto; - align-items: center; - gap: var(--luke-control-gap); -} - -/* Narrow, the presses leave the words' line for one of their own beneath - it, each as wide as the line allows, and the words wrap at their own - width rather than being squeezed past it or cut short. */ -@container banner (max-width: 479px) { - .banner-row { - flex-wrap: wrap; - } - - .banner-actions { - flex: 1 0 100%; - } - - .banner .banner-actions > * { - flex: 1 1 0%; - } - - .banner .task-banner-words { - white-space: normal; - } -} - -/* A notice about the whole plan (`task-banner.tsx`): one slim line across - the plan's column under its toolbar, or under the side panel's bar while - the panel fills the window, on a faint attention tint with a hairline of - it beneath. Its words are cut short rather than wrapped while the line is - wide enough to read them, so the line keeps its height, and its one press - stands at its end, an outlined button. */ -.task-banner { - flex: 0 0 auto; - padding: var(--luke-space-2) var(--column-inset) var(--luke-space-2) var(--content-inset); - border-bottom: 1px solid - color-mix(in srgb, var(--luke-color-attention) 30%, var(--luke-color-border)); - background: color-mix(in srgb, var(--luke-color-attention) 9%, var(--luke-color-ground)); -} - -.task-banner .banner-row { - min-height: var(--luke-control-height); -} - -.task-banner-words { - margin: 0; - overflow: hidden; - color: var(--luke-color-text-body); - font-size: var(--luke-type-body-size); - line-height: var(--luke-type-body-leading); - text-overflow: ellipsis; - white-space: nowrap; -} - +/* A notice about the whole plan (`task-banner.tsx`), `@luke/ui`'s strip: + the workspace it names reads at the line's own weight. */ .task-banner-words .workspace-ref { max-width: none; color: var(--luke-color-text); font-weight: var(--luke-weight-medium); } -/* The press gives way too while it shares the line, its words cut short at - the end rather than the press pushed out of the column, and never takes - more than half the line, which its presses' box holds it to; the whole of - them stays its text, which a reader hears. */ -.task-banner .banner-actions { - max-width: 50%; -} - -@container banner (max-width: 479px) { - .task-banner .banner-actions { - max-width: none; - } -} - /* The document ------------------------------------------------------------- */ -/* A banner over a document: a card on the document's own reading width, so - what it says is the first thing read. An archived plan's says so with the - one action the plan takes, the way Notion marks a page in the trash, and - the plan template's says what the template is for. */ +/* A banner over a document (`archived-task.tsx`), `@luke/ui`'s card, on the + document's own reading width, so what it says is the first thing read. An + archived plan's says so with the one action the plan takes, the way Notion + marks a page in the trash, and the plan template's says what the template + is for. */ .document-banner { - --banner-gap: var(--luke-space-12); - box-sizing: border-box; width: min(var(--luke-page-measure), calc(100% - 2 * var(--luke-page-inset))); margin: var(--luke-space-16) auto 0; - padding: var(--luke-space-12) var(--luke-space-12) var(--luke-space-12) var(--luke-space-16); - border: 1px solid var(--luke-color-border-strong); - border-radius: var(--luke-radius-14); - background: var(--luke-color-well); -} - -.document-banner-glyph { - display: grid; - width: 32px; - height: 32px; - flex: none; - place-items: center; - border-radius: var(--luke-radius-full); - background: var(--luke-color-fill-strong); - color: var(--luke-color-text-muted); -} - -.document-banner-glyph svg { - width: var(--luke-control-icon); - height: var(--luke-control-icon); } /* The document's column answers its own width (`@container plan-document` @@ -7090,11 +6976,9 @@ button.integration-tool-head:hover, flex: none; } -/* A pull request's labels (`tasks/pull-request-card-notes.tsx`), each its - name beside a dot of the colour GitHub gives it, a size under the notes, - wrapping where they run past the card's width. The dot carries the - colour rather than the words, since a label may be any colour, a dark one - the card's words could not be read in. */ +/* A pull request's labels (`tasks/pull-request-card-notes.tsx`), each + `@luke/ui`'s badge wearing the colour GitHub gives it as a dot, wrapping + where they run past the card's width. */ .card-labels { display: flex; flex-wrap: wrap; @@ -7104,28 +6988,6 @@ button.integration-tool-head:hover, list-style: none; } -.card-label { - display: inline-flex; - min-width: 0; - align-items: center; - gap: var(--luke-space-4); - padding: 0 var(--luke-space-6); - border: 1px solid var(--luke-color-border-strong); - border-radius: var(--luke-radius-full); - color: var(--luke-color-text-muted); - font-size: var(--luke-type-caption-size); - line-height: var(--luke-type-caption-leading); - overflow-wrap: anywhere; -} - -.card-label-dot { - width: 7px; - height: 7px; - flex: none; - border-radius: 50%; - background: var(--luke-color-text-subtle); -} - /* A server's card lists its tools as the MCP popover's page does (`planning/server-card.tsx`), each row's words on the card's own column. */ .luke-hover-card .integration-tool-list { @@ -8090,35 +7952,15 @@ body:has(.luke-dialog-backdrop) .toast[data-action="true"] { } } -/* The rows a plan's card lists under its head, which a pinned tab's card - (`sidebar-pinned-tab.tsx`) and a skill chip's (`skill-chip.tsx`) list - too: its repository, its agents, and its pull requests, each list under a - rule. Each row is set as a menu's row is (`.menu-item`), a menu row's - height and corner, with its glyph on the card's column, under the head's, - and the wash under the pointer off the card's edge by a menu's inset, as a - menu item's sits. A line a press does nothing on keeps a row's place, so - every word starts on the rows' column. */ -.task-card-rows { - display: flex; - flex-direction: column; - margin: 0; - padding: 0; - list-style: none; -} - -.task-card-corner, -.task-card-more { - color: var(--luke-color-text-subtle); - font-size: var(--luke-type-caption-size); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.task-card-corner { - flex: none; +/* A card's rows (`@luke/ui`'s HoverCardRow) draw an agent's mark at a menu + glyph's size, as its tab does. */ +.luke-hover-card-row-glyph { + --state-glyph-size: var(--luke-menu-glyph); } -.task-card-detail, +/* A server's line at a card's head (`server-card.tsx`), set as a card's + row is, a menu row's height and corner, its mark on the card's glyph + column and the row's words beside it. */ .task-card-line { display: flex; width: calc(100% + 2 * var(--row-inset)); @@ -8129,35 +7971,15 @@ body:has(.luke-dialog-backdrop) .toast[data-action="true"] { margin-inline: calc(-1 * var(--row-inset)); padding: 0 var(--row-inset); border-radius: var(--luke-menu-row-radius); - background: none; - text-align: left; - transition: background-color var(--duration-hover) ease; -} - -:is(button.task-card-detail, button.task-card-line):is(:hover, :focus-visible) { - outline: none; /* no ring: the row's fill marks it, as a menu row's does */ - background: var(--luke-color-fill-strong); -} - -.task-card-detail { - color: var(--luke-color-text-muted); - font-size: var(--luke-type-caption-size); -} - -.task-card-line { color: var(--luke-color-text); font-size: var(--luke-type-body-size); } -/* A glyph takes a menu glyph's footprint, the working mark, a harness's mark - wearing where it stands, and an owner's picture alike, so every row's - label starts on one edge. */ .task-card-glyph { display: inline-flex; width: var(--luke-menu-glyph); flex: none; justify-content: center; - --state-glyph-size: var(--luke-menu-glyph); } .task-card-glyph > svg { @@ -8165,31 +7987,11 @@ body:has(.luke-dialog-backdrop) .toast[data-action="true"] { height: var(--luke-menu-glyph); } -.task-card-detail .task-card-glyph > :is(.repository-picture, .provider-mark) { - width: 14px; - height: 14px; -} - -.task-card-label { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .task-card-number { color: var(--luke-color-text-subtle); font-variant-numeric: tabular-nums; } -.task-card-more { - display: flex; - height: 22px; - align-items: center; - padding-left: calc(var(--luke-menu-glyph) + var(--luke-icon-gap)); -} - /* A run's figures under its card's rule (`turn-card.tsx`), a turn's tokens or a call's metered time, each label muted at the start and its count at the end in tabular figures. */ diff --git a/apps/desktop/src/renderer/styles/settings.css b/apps/desktop/src/renderer/styles/settings.css index c18ddb1209..fc38a36bc6 100644 --- a/apps/desktop/src/renderer/styles/settings.css +++ b/apps/desktop/src/renderer/styles/settings.css @@ -71,16 +71,6 @@ overflow-wrap: break-word; } -/* Words a row holds to one line, an address or a name with no room to - wrap well, ending in an ellipsis where they are cut short, the pointer - resting on them reading them whole (`one-line.tsx`). */ -.settings-one-line { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - /* A row's words take what the row leaves once its mark and controls stand, so its controls end at the row's edge however many stand beside them. */ .settings-row > .settings-copy { @@ -281,24 +271,6 @@ color: var(--luke-color-danger-text); } -/* Settings › Updates: how far a download is, a thin bar under the row's - words, which say the same in numbers. */ -.update-progress { - display: block; - width: 160px; - height: 3px; - margin-top: var(--luke-space-4); - overflow: hidden; - border-radius: 2px; - background: var(--luke-color-fill-strong); -} - -.update-progress > span { - display: block; - height: 100%; - background: var(--luke-color-working); -} - /* Settings › Coding agents › API keys: a key being added or edited grows its row a field, Cancel, and Save under the row's words (the row's `below`), and a refused save says why under them. The field takes the line's room, @@ -517,12 +489,6 @@ width: fit-content; } -/* A sign-in waiting on the browser wears the window's ring, at the line's size. */ -.settings-state .luke-spinner { - width: 12px; - height: 12px; -} - /* Settings › Integrations: a connected row's tools opened in place under it, over a hairline. The chevron that opens a row, after its count, turns down while its tools show. The tools stand in a column of their own, diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css index 681393dd99..b40428661a 100644 --- a/apps/desktop/src/renderer/styles/tasks.css +++ b/apps/desktop/src/renderer/styles/tasks.css @@ -4034,7 +4034,7 @@ button.draft-fork-quote:hover { /* The files on a message: a row of chips (`@luke/ui`'s AttachmentChip) over the box's words, and over the developer's bubble in the transcript, at its right as the bubble is; and the pictures a call answered - (`agent-pictures.tsx`), whose chips are drawn by the rules below. */ + (`agent-pictures.tsx`), on the same chips. */ .attached-files { display: flex; flex-wrap: wrap; @@ -4052,86 +4052,6 @@ button.draft-fork-quote:hover { justify-content: flex-end; } -/* A call's picture is its thumbnail, or why it is not there, on a chip as - tall as the thumbnail. The chip's face is the press that opens the - picture, washed under the pointer as a row is. */ -.attached-file { - position: relative; - display: flex; - max-width: min(220px, 100%); - height: 48px; - border: 1px solid var(--luke-color-border); - border-radius: var(--luke-radius-8); - background: var(--luke-color-surface); - transition: border-color var(--duration-hover) ease; -} - -.attached-file-face { - display: flex; - min-width: 0; - flex: 1 1 auto; - align-items: center; - gap: var(--luke-space-8); - padding: 0 var(--luke-space-12); - border-radius: var(--luke-radius-8); - background: none; - color: var(--luke-color-text-muted); - font-size: var(--luke-type-caption-size); - text-align: left; - transition: background-color var(--duration-hover) ease; -} - -button.attached-file-face { - cursor: pointer; -} - -button.attached-file-face:hover { - background: var(--luke-color-fill-strong); -} - -.attached-file:has(button.attached-file-face:hover) { - border-color: var(--luke-color-border-strong); -} - -.attached-file:has(img) { - width: 64px; - overflow: visible; -} - -.attached-file-face:has(img) { - padding: 0; -} - -.attached-file img { - display: block; - width: 100%; - height: 100%; - border-radius: var(--luke-radius-8); - object-fit: cover; - transition: filter var(--duration-hover) ease; -} - -button.attached-file-face:hover img { - filter: brightness(1.12); -} - -.attached-file-face > svg { - width: 16px; - height: 16px; - flex: none; -} - -.attached-file-name { - overflow: hidden; - color: var(--luke-color-text); - text-overflow: ellipsis; - white-space: nowrap; -} - -.attached-file-size { - flex: none; -} - /* A file's type icon in its type's colour, as the file tree draws it: Pierre's own colour for each type of the complete set (`@pierre/trees`' stylesheet), in the palette `base.css` repeats from it. A type Pierre @@ -4403,25 +4323,18 @@ button.attached-file-face:hover img { } /* An archived plan's agent takes no message, so its composer's place says - why on one quiet row (`banner.tsx`), with Unarchive as the press that - opens it again. */ -/* The note a continued agent's box gives way to (`continue-agent.tsx`), - framed as the box was, on its card, edge and corner, so it stands where - the box stood rather than across the column. */ -.continued-composer > .banner-row { + why on one quiet row (`archived-task.tsx`, `@luke/ui`'s Banner), with + Unarchive as the press that opens it again. The note a continued agent's + box gives way to (`continue-agent.tsx`) is framed as the box was, on its + card, edge and corner, so it stands where the box stood rather than across + the column. */ +.continued-composer { padding: var(--luke-space-8) var(--luke-space-8) var(--luke-space-8) var(--luke-space-12); border: 1px solid var(--luke-color-border); border-radius: var(--luke-radius-14); background: var(--luke-color-sidebar); } -.archived-composer .banner-row > svg { - width: var(--luke-control-icon); - height: var(--luke-control-icon); - flex: none; - color: var(--luke-color-text-muted); -} - /* An agent's Terminal tab (`terminal-tab.tsx`): the File tab's frame, its bar naming the agent and where the connection stands in words, over the terminal's screen on the terminal's own ground. A state with an action @@ -5615,28 +5528,6 @@ button.picture-frame { margin: var(--luke-space-2) 0 var(--luke-space-6) var(--transcript-gutter); } -.call-picture[data-state] .attached-file-face { - display: grid; - width: 64px; - height: 48px; - place-items: center; - color: var(--luke-color-text-muted); -} - -.call-picture[data-state="too-large"] .attached-file-face, -.call-picture[data-state="limit"] .attached-file-face, -.call-picture[data-state="unsupported"] .attached-file-face { - display: flex; - width: auto; - gap: var(--luke-space-4); - padding: 0 var(--luke-space-8); -} - -.call-picture svg { - width: 14px; - height: 14px; -} - /* A version's change to a picture: written in on the green edge, struck out dimmed behind the red, as its words would be (`markdown-message.tsx`'s change marks); the mark lends it no wash of its own. */ diff --git a/apps/desktop/src/renderer/tasks/agent-pictures.test.ts b/apps/desktop/src/renderer/tasks/agent-pictures.test.ts index 2dea596ee0..f7f124c63c 100644 --- a/apps/desktop/src/renderer/tasks/agent-pictures.test.ts +++ b/apps/desktop/src/renderer/tasks/agent-pictures.test.ts @@ -182,7 +182,9 @@ test("an integration's call keeps its row and gains its pictures as chips, one l ]), ], }); - const chips = [...answered.container.querySelectorAll(".call-picture")]; + const chips = [ + ...answered.container.querySelectorAll('ul[aria-label="Pictures the call answered"] > li'), + ]; assert.equal(chips.length, 2); assert.equal(chips[0]?.querySelector("img")?.getAttribute("alt"), "extract_images-1"); assert.equal(chips[1]?.textContent, "Not kept6.4 MB"); diff --git a/apps/desktop/src/renderer/tasks/agent-pictures.tsx b/apps/desktop/src/renderer/tasks/agent-pictures.tsx index ea06439865..802e56bcc8 100644 --- a/apps/desktop/src/renderer/tasks/agent-pictures.tsx +++ b/apps/desktop/src/renderer/tasks/agent-pictures.tsx @@ -7,6 +7,7 @@ import { import type { FileChip } from "@luke/features/attachment-model"; import type { KeptPicture } from "@luke/features/conversation-picture-wire"; import { ACT_KIND } from "@luke/host/acts"; +import { ATTACHMENT_CHIP_STATE, AttachmentChip, type AttachmentChipState } from "@luke/ui"; import { PICTURE_STATE, type PictureAnswer, @@ -43,8 +44,10 @@ import { AttachmentViewer, type ViewerAction } from "./attachment-viewer"; * in one row, each picture at its own shape, uncropped, and the row * stretched to the column so they share a height; a set of more stands as * thumbnails of one height that wrap, six of them, the sixth saying how many - * more there are, the rest read all the same for the viewer. A call's pictures are the message box's chips under its - * row, open or closed. A press on any opens the window's file viewer + * more there are, the rest read all the same for the viewer. A call's + * pictures are the message box's chips (`@luke/ui`'s `AttachmentChip`) under + * its row, open or closed: a thumbnail, the spinner while read, or the + * glyph where gone. A press on any opens the window's file viewer * walking the set, or the call's pictures, from it, with Add to plan * beside Download where the transcript is drawn on a plan's page. Everything here is * left out of the screen recording, as the tab around it is. @@ -402,7 +405,14 @@ function sizeWords(bytes: number): string { /** What a chip says of a picture that was not kept. */ const NOT_KEPT = "Not kept"; -/** One chip under a call's row: its picture, its place while read, or why it is not there. */ +/** Where a kept picture's chip stands as its read does. */ +const CHIP_STATE = { + [PICTURE_STATE.READING]: ATTACHMENT_CHIP_STATE.LOADING, + [PICTURE_STATE.READY]: ATTACHMENT_CHIP_STATE.READY, + [PICTURE_STATE.MISSING]: ATTACHMENT_CHIP_STATE.FAILED, +} as const satisfies Record; + +/** One chip under a call's row: its picture, its place while read, or that it is gone. */ function CallChip({ door, called, @@ -413,30 +423,14 @@ function CallChip({ onOpen: () => void; }): ReactNode { const state = usePicture(door.owner, called.picture.id, door.read); - if (state.kind !== PICTURE_STATE.READY) { - return ( - -
  • - - - -
  • -
    - ); - } return ( - -
  • - -
  • -
    +
    ); } diff --git a/apps/desktop/src/renderer/tasks/chip-cards.test.ts b/apps/desktop/src/renderer/tasks/chip-cards.test.ts index 35ea4e812c..a95688b997 100644 --- a/apps/desktop/src/renderer/tasks/chip-cards.test.ts +++ b/apps/desktop/src/renderer/tasks/chip-cards.test.ts @@ -266,7 +266,9 @@ async function cardOf(chip: Element | undefined) { corner: card.querySelector(".luke-hover-card-corner")?.textContent, notes: [...card.querySelectorAll(".luke-hover-card-note")].map((note) => note.textContent), focus: card.querySelector("[data-task-card-focus]")?.textContent, - details: [...card.querySelectorAll(".task-card-detail")].map((detail) => detail.textContent), + details: [...card.querySelectorAll('.luke-hover-card-row[data-look="detail"]')].map( + (detail) => detail.textContent, + ), }; assert.equal(carriesWords(card, look.name ?? ""), false, "the card's words ride no attribute"); act(() => { diff --git a/apps/desktop/src/renderer/tasks/continue-agent.tsx b/apps/desktop/src/renderer/tasks/continue-agent.tsx index 27431552eb..60a4b1e64f 100644 --- a/apps/desktop/src/renderer/tasks/continue-agent.tsx +++ b/apps/desktop/src/renderer/tasks/continue-agent.tsx @@ -1,10 +1,9 @@ import type { CodingAgentAttachment, CodingAgentSummary } from "@luke/features/coding-agent-wire"; import type { Failure } from "@luke/features/failure-remedy"; -import { BUTTON_VARIANT, Button, TEXT_TONE, Text } from "@luke/ui"; +import { BANNER_LOOK, Banner, BUTTON_VARIANT, Button, TEXT_TONE, Text } from "@luke/ui"; import { ACTOR_KIND } from "@luke/ui/actor-model"; import { ArrowRightIcon } from "lucide-react"; import { useState } from "react"; -import { BANNER_LOOK, Banner } from "../banner"; import { DRAFT_PLACE, type Draft, EMPTY_DRAFT, useDraftWriter } from "../composer-drafts"; import { useAgentArchive } from "../desktop/agent-archive"; import { AgentActorRef, type AgentSubject, agentSubject, NOTHING_HELD } from "./agent-ref"; @@ -217,35 +216,38 @@ export function ContinuedComposerNote({ }; return ( <> - +
    +
    + +
    +
    {archive.dialog} ); diff --git a/apps/desktop/src/renderer/tasks/github-link-chips.test.ts b/apps/desktop/src/renderer/tasks/github-link-chips.test.ts index 5a35bc2bc3..03d2fe449a 100644 --- a/apps/desktop/src/renderer/tasks/github-link-chips.test.ts +++ b/apps/desktop/src/renderer/tasks/github-link-chips.test.ts @@ -389,9 +389,9 @@ function cardLabels(chip: Element | undefined): (string | null)[] { chip.dispatchEvent(new MouseEvent("pointerover", { bubbles: true })); }); const card = [...document.querySelectorAll(".luke-tooltip-card [data-mention-card]")].at(-1); - const labels = [...(card?.querySelectorAll(".card-label") ?? [])].map( - (label) => label.textContent, - ); + const labels = [ + ...(card?.querySelectorAll('ul[aria-label="Labels"] > li') ?? []), + ].map((label) => label.textContent); act(() => { chip.dispatchEvent(new MouseEvent("pointerout", { bubbles: true })); }); diff --git a/apps/desktop/src/renderer/tasks/pull-request-card-notes.tsx b/apps/desktop/src/renderer/tasks/pull-request-card-notes.tsx index 49530883d7..522871c6a8 100644 --- a/apps/desktop/src/renderer/tasks/pull-request-card-notes.tsx +++ b/apps/desktop/src/renderer/tasks/pull-request-card-notes.tsx @@ -1,5 +1,5 @@ import type { GitHubPerson } from "@luke/features/coding-agent-wire"; -import { HoverCardNote } from "@luke/ui"; +import { Badge, HoverCardNote } from "@luke/ui"; import { githubActor } from "@luke/ui/actor-model"; import { GitCommitHorizontalIcon, MessageSquareIcon } from "lucide-react"; import { ActorLine, ActorRef } from "../actor-ref"; @@ -24,11 +24,11 @@ import { * [face] octo-dev · 3d ago" while it is open, and "Opened by [face] * octo-dev · merged 2h ago" once it settled, so the age is the one that * matters. Its branches stand as inline code does, and its comment and - * commit counts each by its glyph. A label wears a dot of its own colour - * beside its name, rather than its colour on its words, because GitHub lets - * a label be any colour, a dark one among them, which the card's words could - * not be read in; and a card names its first six labels and counts the - * rest, so a pull request with many keeps a card short enough to stand in + * commit counts each by its glyph. A label is a badge wearing a dot of its + * own colour beside its name (`@luke/ui`'s `Badge` swatch), rather than its + * colour on its words, because GitHub lets a label be any colour, a dark one + * among them, which the card's words could not be read in; and a card names + * its first six labels and counts the rest, so a pull request with many keeps a card short enough to stand in * the window. */ @@ -45,9 +45,6 @@ const SEPARATOR = " · "; /** The most labels a card names, so one with many stays a card's height; the rest are counted in a last pill. */ const MAX_LABELS = 6; -/** GitHub's six hex digits for a label's colour, the one spelling a dot is painted in. */ -const LABEL_COLOUR = /^[0-9a-f]{6}$/iu; - /** A count and the noun it counts, the noun in the plural unless the count is one. */ function counted(count: number, one: string, many: string): string { return `${count} ${count === 1 ? one : many}`; @@ -182,19 +179,14 @@ export function PullRequestCardNotes({ {labels.length === 0 ? null : (
      {labels.slice(0, MAX_LABELS).map((label) => ( -
    • -
    • + {label.name}
    • ))} {labels.length > MAX_LABELS ? ( -
    • {`+${labels.length - MAX_LABELS}`}
    • +
    • + {`+${labels.length - MAX_LABELS}`} +
    • ) : null}
    )} diff --git a/apps/desktop/src/renderer/tasks/skill-chip.test.ts b/apps/desktop/src/renderer/tasks/skill-chip.test.ts index af039bca8c..6700a0c964 100644 --- a/apps/desktop/src/renderer/tasks/skill-chip.test.ts +++ b/apps/desktop/src/renderer/tasks/skill-chip.test.ts @@ -155,7 +155,10 @@ test("a sent chip's hover card is the plan card: the skill's mark and name, what ); assert.equal(card.querySelector(".luke-hover-card-name")?.textContent, "/release"); assert.equal(card.querySelector(".luke-hover-card-note")?.textContent, RELEASE.description); - assert.equal(card.querySelector(".task-card-detail")?.textContent, RELEASE.path); + assert.equal( + card.querySelector('.luke-hover-card-row[data-look="detail"]')?.textContent, + RELEASE.path, + ); assert.equal( card.querySelector("button")?.textContent, undefined, diff --git a/apps/desktop/src/renderer/tasks/skill-ref.tsx b/apps/desktop/src/renderer/tasks/skill-ref.tsx index 5e916ac047..d90bb58202 100644 --- a/apps/desktop/src/renderer/tasks/skill-ref.tsx +++ b/apps/desktop/src/renderer/tasks/skill-ref.tsx @@ -1,4 +1,11 @@ -import { HoverCard, HoverCardHead, HoverCardNote, INLINE_REF_LOOK } from "@luke/ui"; +import { + HOVER_CARD_ROW_LOOK, + HoverCard, + HoverCardHead, + HoverCardNote, + HoverCardRow, + INLINE_REF_LOOK, +} from "@luke/ui"; import { type SkillOption, skillWord } from "@luke/ui/skill-mention"; import { BookOpenIcon } from "lucide-react"; import { InlineRef } from "../inline-ref"; @@ -35,12 +42,11 @@ export function SkillCard({ subject }: { subject: SkillSubject }): React.JSX.Ele ); } diff --git a/apps/desktop/src/renderer/tooltip.tsx b/apps/desktop/src/renderer/tooltip.tsx index c1aed66784..1381544b0b 100644 --- a/apps/desktop/src/renderer/tooltip.tsx +++ b/apps/desktop/src/renderer/tooltip.tsx @@ -54,7 +54,13 @@ import { type ShortcutChord, shortcutAria, } from "@luke/settings/shortcuts"; -import { TOOLTIP_LEAVING, TooltipCard, type TooltipLeaving, TooltipPill } from "@luke/ui"; +import { + type OneLineHint, + TOOLTIP_LEAVING, + TooltipCard, + type TooltipLeaving, + TooltipPill, +} from "@luke/ui"; import { useDisclosure } from "@luke/ui/use-disclosure"; import { useStateWithRef } from "@luke/ui/use-state-with-ref"; import { Duration, Effect } from "effect"; @@ -472,3 +478,14 @@ export function Tooltip({ ); } + +/** + * The hint over words held to one line (`@luke/ui`'s `OneLine`): the pill + * reading them whole while the line cuts them short, and none while it + * draws them whole, since a line drawn whole says nothing twice. The words + * ride the pill rather than a `title`, which session replay would carry + * unmasked. + */ +export function wholeWordsHint(words: string): OneLineHint { + return (line, clipped) => {line}; +} diff --git a/packages/ui/src/components/attachment-chip/attachment-chip.css b/packages/ui/src/components/attachment-chip/attachment-chip.css index 2232bb8eaa..7a712df715 100644 --- a/packages/ui/src/components/attachment-chip/attachment-chip.css +++ b/packages/ui/src/components/attachment-chip/attachment-chip.css @@ -1,5 +1,6 @@ /* attachment-chip.css -- a file's chip, as tall as a thumbnail: a picture - fills it, any other file is its glyph, its name cut short, and its size. + fills it, any other file is its glyph, its name cut short, and its size; + a file still read or failed holds the spinner or its glyph. The face is the press that opens the file, washed under the pointer as a row is; the way off is a small disc over the chip's corner. */ @@ -41,7 +42,10 @@ transition: background-color var(--duration-hover) ease; } + /* A thumbnail's place holds a picture, or a wait or a failure's glyph at + its middle. */ .luke-attachment-chip[data-image="true"] .luke-attachment-chip-face { + justify-content: center; padding: 0; } diff --git a/packages/ui/src/components/attachment-chip/attachment-chip.test.ts b/packages/ui/src/components/attachment-chip/attachment-chip.test.ts index 4cd57b5b39..437847427c 100644 --- a/packages/ui/src/components/attachment-chip/attachment-chip.test.ts +++ b/packages/ui/src/components/attachment-chip/attachment-chip.test.ts @@ -4,13 +4,13 @@ import assert from "node:assert/strict"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, test } from "vitest"; -import { AttachmentChip } from "./attachment-chip.js"; +import { ATTACHMENT_CHIP_STATE, AttachmentChip } from "./attachment-chip.js"; /** * attachment-chip.test.ts -- a chip with an open opens its file on a press * or a key, one without presses nothing, its way off takes the file off by - * pointer or keyboard and refuses while unavailable, and a reader hears each - * control by the file's name. + * pointer or keyboard and refuses while unavailable, a reader hears each + * control by the file's name, and a file still read or failed opens nothing. */ const PICTURE = "data:image/png;base64,iVBORw0KGgo="; @@ -121,3 +121,29 @@ test("an unavailable way off refuses its press", () => { act(() => remove.click()); assert.equal(removed, 0); }); + +test("a file still read says so to a reader and opens nothing", () => { + const list = drawn({ + name: "shot.png", + state: ATTACHMENT_CHIP_STATE.LOADING, + onOpen: () => assert.fail("a chip still read opens nothing"), + }); + assert.deepEqual(buttonNames(list), []); + assert.equal(list.querySelector('[role="status"]')?.textContent, "Reading shot.png"); + assert.equal(list.querySelector("img"), null); +}); + +test("a file that failed to come says why and its size, and opens nothing", () => { + const list = drawn({ + name: "shot.png", + image: PICTURE, + state: ATTACHMENT_CHIP_STATE.FAILED, + failure: "Not kept", + detail: "2.4 MB", + onOpen: () => assert.fail("a failed chip opens nothing"), + }); + assert.deepEqual(buttonNames(list), []); + assert.equal(list.textContent, "Not kept2.4 MB"); + assert.equal(list.querySelector("img"), null, "no picture stands for a file that failed"); + assert.equal(list.querySelector("li")?.getAttribute("title"), "shot.png"); +}); diff --git a/packages/ui/src/components/attachment-chip/attachment-chip.tsx b/packages/ui/src/components/attachment-chip/attachment-chip.tsx index dd07eff041..4eb4475b5b 100644 --- a/packages/ui/src/components/attachment-chip/attachment-chip.tsx +++ b/packages/ui/src/components/attachment-chip/attachment-chip.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { RemoveIcon } from "../../face/glyphs.js"; +import { SPINNER_SIZE, Spinner } from "../spinner/spinner.js"; /** * attachment-chip.tsx -- a file coming along with a message or a note, as a chip: its thumbnail, or its type's glyph, its name, and its size. @@ -12,10 +13,58 @@ import { RemoveIcon } from "../../face/glyphs.js"; * chip carries its way off on its corner, a real button named * "Remove ", unavailable while `removeDisabled` holds. * + * A file whose bytes are still read stands as a thumbnail's place holding + * the spinner, said to a reader as reading the file. One that failed to + * come stands as its caller's glyph for the failure (a picture gone), with + * why in words where the caller says (`failure`, "Not kept") and what more + * it says of it (its size). Neither opens anything. + * * A chip is one item of a list its caller draws and names, so the caller * owns the row's layout; the chip takes no class or style. */ +/** Where a chip's file stands: here to open, still read, or failed to come. */ +export const ATTACHMENT_CHIP_STATE = { + READY: "ready", + LOADING: "loading", + FAILED: "failed", +} as const; + +export type AttachmentChipState = + (typeof ATTACHMENT_CHIP_STATE)[keyof typeof ATTACHMENT_CHIP_STATE]; + +/** What a chip's face holds for its file as it stands. */ +function ChipFace({ + state, + name, + image, + icon, + detail, + failure, +}: { + state: AttachmentChipState; + name: string; + image: string | undefined; + icon: ReactNode; + detail: string | undefined; + failure: string | undefined; +}): React.JSX.Element { + if (state === ATTACHMENT_CHIP_STATE.LOADING) { + return ; + } + if (state === ATTACHMENT_CHIP_STATE.READY && image !== undefined) { + return {name}; + } + const words = state === ATTACHMENT_CHIP_STATE.FAILED ? failure : name; + return ( + <> + {icon} + {words === undefined ? null : {words}} + {detail === undefined ? null : {detail}} + + ); +} + /** A file as a chip: a thumbnail or a glyph and name, with its open and its way off where given. */ export function AttachmentChip({ name, @@ -25,6 +74,8 @@ export function AttachmentChip({ onOpen, onRemove, removeDisabled, + state = ATTACHMENT_CHIP_STATE.READY, + failure, }: { /** The file's name, which names the chip, its open, and its way off. */ name: string; @@ -40,26 +91,35 @@ export function AttachmentChip({ onRemove?: (() => void) | undefined; /** Whether the way off stands unavailable, while what the file is for is busy. */ removeDisabled?: boolean | undefined; + /** Where the file stands; a chip still read or failed opens nothing. */ + state?: AttachmentChipState; + /** Why a failed file is not there, in words ("Not kept"); its glyph alone says so where none is given. */ + failure?: string | undefined; }): React.JSX.Element { - const face = - image === undefined ? ( - <> - {icon} - {name} - {detail === undefined ? null : ( - {detail} - )} - - ) : ( - {name} - ); + const ready = state === ATTACHMENT_CHIP_STATE.READY; + const face = ( + + ); + // A thumbnail's place: a picture, a wait, or a failure with no words. + const thumbnail = + (ready && image !== undefined) || + state === ATTACHMENT_CHIP_STATE.LOADING || + (state === ATTACHMENT_CHIP_STATE.FAILED && failure === undefined && detail === undefined); return (
  • - {onOpen === undefined ? ( + {onOpen === undefined || !ready ? ( {face} ) : ( + ); +} + +/** A list a card holds under a rule, named by `label`: its rows, each an `li` of the caller's, and a last line counting what it leaves out. */ +export function HoverCardList({ + label, + more, + children, +}: { + label: string; + /** How many it leaves out, in words ("2 more"); nothing where it leaves nothing out. */ + more?: string | undefined; + children: ReactNode; +}): React.JSX.Element { + return ( +
      + {children} + {more === undefined ? null :
    • {more}
    • } +
    + ); +} diff --git a/packages/ui/src/components/one-line/clipped-line.tsx b/packages/ui/src/components/one-line/clipped-line.tsx new file mode 100644 index 0000000000..520738d802 --- /dev/null +++ b/packages/ui/src/components/one-line/clipped-line.tsx @@ -0,0 +1,58 @@ +/** + * clipped-line.tsx -- a line of words held to one line, measured for whether its box cuts it short, which `OneLine` and a hover card's one-line name and note are drawn from. + */ + +import type { HTMLAttributes, ReactElement, ReactNode } from "react"; +import { useClipped } from "../../use-clipped.js"; + +/** How a line cut short ends: in an ellipsis, or fading out over its last few pixels as a browser's tab does. */ +export const ONE_LINE_END = { + ELLIPSIS: "ellipsis", + FADE: "fade", +} as const; + +export type OneLineEnd = (typeof ONE_LINE_END)[keyof typeof ONE_LINE_END]; + +/** The elements a line may be drawn as: words in a row, a name, a detail under it, or a paragraph. */ +export const ONE_LINE_ELEMENT = { + SPAN: "span", + STRONG: "strong", + SMALL: "small", + P: "p", +} as const; + +export type OneLineElement = (typeof ONE_LINE_ELEMENT)[keyof typeof ONE_LINE_ELEMENT]; + +/** The app's hint over a line: the line it hangs from, which takes the hint's handlers, and whether its words are cut short. */ +export type OneLineHint = ( + line: ReactElement>, + clipped: boolean, +) => ReactElement; + +/** A line held to one line, in `className` beside the shared one, its hint hung where one is given. */ +export function ClippedLine({ + className, + as: Element, + end, + hint, + children, +}: { + className?: string; + as: OneLineElement; + end: OneLineEnd; + hint?: OneLineHint | undefined; + children: ReactNode; +}): React.JSX.Element { + const [measured, clipped] = useClipped(); + const line = ( + + {children} + + ); + return hint === undefined ? line : hint(line, clipped); +} diff --git a/packages/ui/src/components/one-line/one-line.css b/packages/ui/src/components/one-line/one-line.css new file mode 100644 index 0000000000..56b1f5301e --- /dev/null +++ b/packages/ui/src/components/one-line/one-line.css @@ -0,0 +1,21 @@ +/* one-line.css -- words held to one line: an ellipsis where they are cut + short, or a fade over their last few pixels, as a browser's tab ends a + long title; only a line measured cut short fades, so a short one ends on + its last letter. */ + +@layer components { + .luke-one-line { + display: block; + min-width: 0; + overflow: hidden; + white-space: nowrap; + } + + .luke-one-line[data-end="ellipsis"] { + text-overflow: ellipsis; + } + + .luke-one-line[data-end="fade"][data-overflow="true"] { + mask-image: linear-gradient(to left, transparent, #000 20px); /* off-scale: a label's fade */ + } +} diff --git a/packages/ui/src/components/one-line/one-line.test.ts b/packages/ui/src/components/one-line/one-line.test.ts new file mode 100644 index 0000000000..0df669d198 --- /dev/null +++ b/packages/ui/src/components/one-line/one-line.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import assert from "node:assert/strict"; +import { act, createElement, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, test, vi } from "vitest"; +import { ONE_LINE_ELEMENT, OneLine } from "./one-line.js"; + +/** + * one-line.test.ts -- a line reads its words whole to a reader, and its + * hint says them whole only while its box cuts them short. jsdom lays + * nothing out, so the box's widths are stood in for, and the observer that + * measures it is fired by hand. + */ + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const roots: Root[] = []; + +/** The measurement each observed box was handed, fired as a resize. */ +const resizes: (() => void)[] = []; + +/** Fires every observed box's measurement, as a resize would. */ +function resize(): void { + act(() => { + for (const fire of resizes) fire(); + }); +} + +class StandInObserver { + readonly #fire: () => void; + constructor(fire: () => void) { + this.#fire = fire; + } + observe(): void { + resizes.push(this.#fire); + } + disconnect(): void {} +} + +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.replaceChildren(); + resizes.splice(0); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function render(element: ReactElement): HTMLElement { + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + act(() => root.render(element)); + return container; +} + +/** A hint that says the address whole while the line is cut short, as an app's tooltip would. */ +function hint(line: ReactElement, clipped: boolean): ReactElement { + return createElement("span", { "data-hint": clipped ? "sam@example.com" : "" }, line); +} + +test("a line drawn whole raises no hint, and one cut short says its words whole", () => { + vi.stubGlobal("ResizeObserver", StandInObserver); + const view = render( + createElement(OneLine, { as: ONE_LINE_ELEMENT.SMALL, hint, children: "sam@example.com" }), + ); + const line = view.querySelector("small") ?? assert.fail("the line"); + assert.equal(line.textContent, "sam@example.com"); + const said = () => view.querySelector("[data-hint]")?.getAttribute("data-hint"); + vi.spyOn(line, "scrollWidth", "get").mockReturnValue(120); + vi.spyOn(line, "clientWidth", "get").mockReturnValue(120); + resize(); + assert.equal(said(), ""); + vi.spyOn(line, "clientWidth", "get").mockReturnValue(80); + resize(); + assert.equal(said(), "sam@example.com"); +}); diff --git a/packages/ui/src/components/one-line/one-line.tsx b/packages/ui/src/components/one-line/one-line.tsx new file mode 100644 index 0000000000..3714a449f3 --- /dev/null +++ b/packages/ui/src/components/one-line/one-line.tsx @@ -0,0 +1,46 @@ +/** + * one-line.tsx -- words held to one line, ending in an ellipsis or a fade where their box cuts them short, and a hint reading them whole while it does. + * + * An address, a name, or a title has nowhere to wrap well, so in a narrow row + * it would run under the control beside it or out of its card. Held to one + * line it ends in an ellipsis instead, or fades out over its last few pixels + * as a browser's tab does, and only then does its hint say it whole: a line + * drawn whole says nothing twice. The hint is the app's, since when a hint + * shows is the app's (the desktop's `Tooltip`): it is handed the line and + * whether it is cut short, and hangs from the line it returns. The words + * ride the hint rather than a `title`, which a session recording would + * carry unmasked. + */ + +import type { ReactNode } from "react"; +import { + ClippedLine, + ONE_LINE_ELEMENT, + ONE_LINE_END, + type OneLineElement, + type OneLineEnd, + type OneLineHint, +} from "./clipped-line.js"; + +export { ONE_LINE_ELEMENT, ONE_LINE_END, type OneLineElement, type OneLineEnd, type OneLineHint }; + +/** Words on one line, cut short at `end` where their box ends, with `hint` hung over them while they are. */ +export function OneLine({ + as = ONE_LINE_ELEMENT.SPAN, + end = ONE_LINE_END.ELLIPSIS, + hint, + children, +}: { + /** The element the line is drawn as: a row's name (`strong`), its detail (`small`), or words in a row (`span`). */ + as?: OneLineElement; + end?: OneLineEnd; + /** The app's hint over the line, which says its words whole while they are cut short. */ + hint?: OneLineHint | undefined; + children: ReactNode; +}): React.JSX.Element { + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/progress/progress.css b/packages/ui/src/components/progress/progress.css new file mode 100644 index 0000000000..01e133bed6 --- /dev/null +++ b/packages/ui/src/components/progress/progress.css @@ -0,0 +1,20 @@ +/* progress.css -- a thin track in the strong fill, as long as a short line + of words, its done share filled in the working ink from its start. */ + +@layer components { + .luke-progress { + display: block; + width: 100%; + max-width: 160px; /* off-scale: a meter's length beside a line of words */ + height: 3px; /* off-scale: a hairline bar */ + overflow: hidden; + border-radius: var(--luke-radius-full); + background: var(--luke-color-fill-strong); + } + + .luke-progress-fill { + display: block; + height: 100%; + background: var(--luke-color-working); + } +} diff --git a/packages/ui/src/components/progress/progress.test.ts b/packages/ui/src/components/progress/progress.test.ts new file mode 100644 index 0000000000..7496c0cb21 --- /dev/null +++ b/packages/ui/src/components/progress/progress.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import assert from "node:assert/strict"; +import { act, createElement, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, test } from "vitest"; +import { Progress } from "./progress.js"; + +/** + * progress.test.ts -- a bar given a label is a progress bar a reader hears + * by it, at its share held to the whole; one beside words that say the same + * says nothing more. + */ + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.replaceChildren(); +}); + +function render(element: ReactElement): HTMLElement { + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + roots.push(root); + act(() => root.render(element)); + return container; +} + +test("a labelled bar is a progress bar a reader hears by its label and its share", () => { + const view = render(createElement(Progress, { value: 42.4, label: "Downloading the update" })); + const bar = view.querySelector('[role="progressbar"]') ?? assert.fail("a progress bar"); + assert.equal(bar.getAttribute("aria-label"), "Downloading the update"); + assert.equal(bar.getAttribute("aria-valuenow"), "42"); + assert.equal(bar.getAttribute("aria-valuemax"), "100"); +}); + +test("a share past either end is held to the whole, and one that is not a number to none", () => { + const over = render(createElement(Progress, { value: 140, label: "Uploading" })); + assert.equal(over.querySelector('[role="progressbar"]')?.getAttribute("aria-valuenow"), "100"); + const broken = render(createElement(Progress, { value: Number.NaN, label: "Uploading" })); + assert.equal(broken.querySelector('[role="progressbar"]')?.getAttribute("aria-valuenow"), "0"); +}); + +test("a bar beside words that say the same is hidden from a reader", () => { + const view = render( + createElement("p", null, "12 of 40 MB", createElement(Progress, { value: 30 })), + ); + assert.equal(view.querySelector('[role="progressbar"]'), null); + assert.equal(view.querySelector('[aria-hidden="true"]')?.textContent, ""); + assert.equal(view.textContent, "12 of 40 MB"); +}); diff --git a/packages/ui/src/components/progress/progress.tsx b/packages/ui/src/components/progress/progress.tsx new file mode 100644 index 0000000000..b30fe43f36 --- /dev/null +++ b/packages/ui/src/components/progress/progress.tsx @@ -0,0 +1,44 @@ +/** + * progress.tsx -- how far a known amount of work has come, as a thin bar filling in the working ink: a download, an upload, a count against its whole. + * + * The bar is the one meter every wait with a known end is drawn as, so a + * download in Settings and any other reads as one object. Where words beside + * it already say the same in numbers ("12 of 40 MB"), the bar is their + * picture and says nothing to a reader; given a `label`, it is a progress bar + * of its own, named by it and read as a percentage. A wait with no known end + * is a spinner's, never an empty bar. + */ + +/** The share of the work done, as a percentage held to 0–100, a value that is not a number read as none. */ +function percentOf(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} + +/** A bar filled to `value` percent, read by `label` where given, else the picture of words beside it. */ +export function Progress({ + value, + label, +}: { + /** How far the work has come, in percent. */ + value: number; + /** What the bar measures, where no words beside it say it ("Downloading the update"). */ + label?: string | undefined; +}): React.JSX.Element { + const percent = percentOf(value); + const read = + label === undefined + ? ({ "aria-hidden": true } as const) + : ({ + role: "progressbar", + "aria-label": label, + "aria-valuemin": 0, + "aria-valuemax": 100, + "aria-valuenow": Math.round(percent), + } as const); + return ( + + + + ); +} diff --git a/packages/ui/src/components/spinner/spinner.css b/packages/ui/src/components/spinner/spinner.css index fec6544080..7cfa738889 100644 --- a/packages/ui/src/components/spinner/spinner.css +++ b/packages/ui/src/components/spinner/spinner.css @@ -1,4 +1,4 @@ -/* spinner.css -- the ring, its two sizes, a region holding one at its +/* spinner.css -- the ring, its three sizes, a region holding one at its centre, and a control's words sharing one cell with it, so the ring stands over the words' own room rather than taking a width of its own. */ @@ -24,8 +24,8 @@ } } - /* A wait: 16px beside words or inside a control, 22px alone in a region, - in the muted ink a wait is said in. */ + /* A wait: 12px on a caption's line, 16px beside words or inside a + control, 22px alone in a region, in the muted ink a wait is said in. */ .luke-spinner { display: inline-flex; flex: 0 0 auto; @@ -34,6 +34,11 @@ color: var(--luke-color-text-muted); } + .luke-spinner[data-size="small"] { + width: 12px; + height: 12px; + } + .luke-spinner[data-size="region"] { --luke-spinner-stroke: 2px; width: 22px; diff --git a/packages/ui/src/components/spinner/spinner.test.ts b/packages/ui/src/components/spinner/spinner.test.ts index b57f44b604..166cadc635 100644 --- a/packages/ui/src/components/spinner/spinner.test.ts +++ b/packages/ui/src/components/spinner/spinner.test.ts @@ -4,12 +4,13 @@ import assert from "node:assert/strict"; import { act, createElement, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, test } from "vitest"; -import { BusyLabel, Spinner, SpinnerRegion } from "./spinner.js"; +import { BusyLabel, SPINNER_SIZE, Spinner, SpinnerRegion, SpinnerRing } from "./spinner.js"; /** * spinner.test.ts -- a wait is said to a reader from a status line that * stands before it shows, and a busy control says what it is doing beside - * the words it keeps. + * the words it keeps, and a ring beside words that already say the wait + * says nothing more. */ declare global { @@ -58,3 +59,18 @@ test("a busy control keeps its words and says what it is doing; at rest it says ); assert.equal(idle.textContent, "Check for updates"); }); + +test("a ring drawn at a size beside words that say the wait adds nothing a reader hears", () => { + const line = render( + createElement( + "span", + null, + createElement(SpinnerRing, { size: SPINNER_SIZE.SMALL }), + "Waiting", + ), + ); + assert.equal(line.querySelector('[role="status"]'), null); + const ring = line.firstElementChild?.firstElementChild ?? assert.fail("the ring"); + assert.equal(ring.getAttribute("aria-hidden"), "true"); + assert.equal(line.textContent, "Waiting"); +}); diff --git a/packages/ui/src/components/spinner/spinner.tsx b/packages/ui/src/components/spinner/spinner.tsx index 4bccdc19ab..71ecbc8f5e 100644 --- a/packages/ui/src/components/spinner/spinner.tsx +++ b/packages/ui/src/components/spinner/spinner.tsx @@ -17,17 +17,28 @@ import type { ReactNode } from "react"; * which is true unless it says otherwise. */ -/** How large the ring is drawn: beside words or inside a control, or alone in a region. */ +/** How large the ring is drawn: on a caption's line, beside words or inside a control, or alone in a region. */ export const SPINNER_SIZE = { + SMALL: "small", INLINE: "inline", REGION: "region", } as const; export type SpinnerSize = (typeof SPINNER_SIZE)[keyof typeof SPINNER_SIZE]; -/** The ring alone, decorative: a mark that is itself the wait, such as a sign-in waiting on the browser. */ -export function SpinnerRing(): React.JSX.Element { - return