Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .design-sync/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,20 @@ web-app shims but are deliberately EXCLUDED from the design project).
- **@parcel/watcher** (optional dep of @tailwindcss/cli): its postinstall is
denied in pnpm-workspace.yaml `allowBuilds` — not needed for one-shot builds.

### 2026-08-23 second pass — Empty + Kbd (PR #799)

Kit grew to 20 components. `Empty` and `Kbd` added to `src/design-sync.ts`,
`cfg.componentSrcMap` (subparts nulled), previews authored, conventions
enumeration updated. `Empty` needed `cardMode: "column"` (same `[GRID_OVERFLOW]`
remedy as Accordion/InputGroup/Tabs). Incremental upload: the two component
dirs + `_preview/{Empty,Kbd}.*` + shared closure
(`_ds_bundle.js/.css`, `styles.css`, `README.md`, `_ds_sync.json`,
`_ds_needs_recompile`). Gotcha: running `resync.mjs` WITHOUT `--entry` cleans
`ds-bundle/` then fails resolving the package (expects a self-link in
node_modules) — leaving an empty bundle. Always drive the individual
`package-build/capture/validate` scripts with the explicit
`--entry ./packages/ui/dist/design-sync.js`.

### Known render warns (2026-08-23 kit sync)
- `[TOKENS_MISSING] --accordion-panel-height, --radius, --secondary, --foreground, --tw`
— runtime/JS-set vars (accordion height) plus shadcn-generated arbitrary
Expand Down
9 changes: 9 additions & 0 deletions .design-sync/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
"Combobox": "src/components/ui/combobox.tsx",
"Dialog": "src/components/ui/dialog.tsx",
"DropdownMenu": "src/components/ui/dropdown-menu.tsx",
"Empty": "src/components/ui/empty.tsx",
"Input": "src/components/ui/input.tsx",
"InputGroup": "src/components/ui/input-group.tsx",
"Kbd": "src/components/ui/kbd.tsx",
"Label": "src/components/ui/label.tsx",
"Popover": "src/components/ui/popover.tsx",
"Select": "src/components/ui/select.tsx",
Expand Down Expand Up @@ -64,6 +66,12 @@
"DialogPortal": null,
"DialogTitle": null,
"DialogTrigger": null,
"EmptyHeader": null,
"EmptyTitle": null,
"EmptyDescription": null,
"EmptyContent": null,
"EmptyMedia": null,
"KbdGroup": null,
"DropdownMenuPortal": null,
"DropdownMenuTrigger": null,
"DropdownMenuContent": null,
Expand Down Expand Up @@ -113,6 +121,7 @@
},
"overrides": {
"Accordion": { "cardMode": "column" },
"Empty": { "cardMode": "column" },
"InputGroup": { "cardMode": "column" },
"Tabs": { "cardMode": "column" },
"Dialog": { "cardMode": "single", "primaryStory": "InviteTeammate", "viewport": "760x520" },
Expand Down
9 changes: 6 additions & 3 deletions .design-sync/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,14 @@ Cap prose at `--measure` (68ch).
## Components

`Accordion` · `AlertDialog` · `Badge` · `Button` · `Checkbox` · `Combobox`
· `Dialog` · `DropdownMenu` · `Input` · `InputGroup` · `Label` · `Popover`
· `Select` · `Switch` · `Table` · `Tabs` · `Textarea` · `Tooltip`
· `Dialog` · `DropdownMenu` · `Empty` · `Input` · `InputGroup` · `Kbd`
· `Label` · `Popover` · `Select` · `Switch` · `Table` · `Tabs` · `Textarea`
· `Tooltip`

Compound parts (`DialogTrigger`, `SelectItem`, `TableRow`,
`DropdownMenuItem`, `InputGroupAddon`, …) are all exported flat from the
`DropdownMenuItem`, `InputGroupAddon`, `EmptyHeader`/`EmptyTitle`/
`EmptyDescription`/`EmptyContent`/`EmptyMedia`, `KbdGroup`, …) are all
exported flat from the
same module. Key variant axes: `Button` `variant`
(`default | outline | secondary | ghost | destructive | link`) and `size`
(`xs | sm | default | lg | icon*`); `Badge` `variant` (same six). The
Expand Down
51 changes: 51 additions & 0 deletions .design-sync/previews/Empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import "./canvas.module.css";
import {
Button,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@uploads/ui";

export function Default() {
return (
<Empty style={{ width: 420 }} className="border">
<EmptyHeader>
<EmptyMedia variant="icon">⌁</EmptyMedia>
<EmptyTitle>No screenshots yet</EmptyTitle>
<EmptyDescription>
Capture one with <code>uploads screenshot &lt;url&gt;</code> and it shows up here.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" size="sm">
Read the docs
</Button>
</EmptyContent>
</Empty>
);
}

export function TitleOnly() {
return (
<Empty style={{ width: 420 }} className="border">
<EmptyHeader>
<EmptyTitle>No galleries yet</EmptyTitle>
<EmptyDescription>Create one to collect related uploads.</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

export function Inline() {
return (
<Empty style={{ width: 420 }} className="items-start border-0 p-2 text-left">
<EmptyHeader className="items-start text-left">
<EmptyTitle>No tokens yet</EmptyTitle>
<EmptyDescription>Create one above to start.</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
30 changes: 30 additions & 0 deletions .design-sync/previews/Kbd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import "./canvas.module.css";
import { Kbd, KbdGroup } from "@uploads/ui";

export function Default() {
return (
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
);
}

export function SingleKey() {
return <Kbd>/</Kbd>;
}

export function InlineHint() {
return (
<span
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
className="text-sm text-muted-foreground"
>
Filter path
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</span>
);
}
78 changes: 59 additions & 19 deletions apps/web/src/components/ScreenshotsByPath.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
*/
import { Callout, Input, Select } from "@uploads/ui";
import "@uploads/ui/styles.css";
import { Empty, EmptyContent, EmptyHeader, EmptyTitle } from "@uploads/ui/components/ui/empty";
import { Kbd } from "@uploads/ui/components/ui/kbd";
import {
useEffect,
useLayoutEffect,
Expand Down Expand Up @@ -90,15 +92,41 @@ function EmptyShotsCta({ title }: { title: string }) {
}
};
return (
<div className="ws-empty-state ws-empty-state--cta">
<p className="ws-empty-state__title">{title}</p>
<div className="command ws-empty__command">
<code>{EMPTY_CTA_CMD}</code>
<button type="button" aria-live="polite" onClick={() => void copy()}>
{copied ? "copied ✓" : "copy"}
</button>
</div>
</div>
<Empty>
<EmptyHeader>
<EmptyTitle>{title}</EmptyTitle>
</EmptyHeader>
<EmptyContent>
<div className="ws-empty__command flex w-full min-w-0 items-center gap-2 rounded-[6px] border border-line bg-panel px-3 py-2">
<code className="min-w-0 flex-1 overflow-x-auto font-[var(--mono)] text-[13px] text-fg [overflow-wrap:anywhere]">
{EMPTY_CTA_CMD}
</code>
<button
type="button"
aria-live="polite"
className="text-btn text-btn--boxed flex-none"
onClick={() => void copy()}
>
{copied ? "copied ✓" : "copy"}
</button>
</div>
</EmptyContent>
</Empty>
);
}

/**
* Compact Empty for inline filter/drill "nothing found" messages — smaller
* than the CTA-style empty state above, since these sit inside already-
* scoped list/grid contexts rather than replacing the whole page.
*/
function InlineEmpty({ title }: { title: ReactNode }) {
return (
<Empty className="gap-2 p-3">
<EmptyHeader className="gap-1">
<EmptyTitle>{title}</EmptyTitle>
</EmptyHeader>
</Empty>
);
}

Expand Down Expand Up @@ -429,6 +457,13 @@ function FilterBar({
// useful to offer until the user edits it back into a query.
const suggestions = path ? [] : pathSuggestions(catalog, { project, q });
const open = suggestOpen && suggestions.length > 0;
// "Ctrl K" is the deterministic default so the server's render and the
// client's first render agree bit-for-bit (no `navigator` at SSR time) —
// corrected to "⌘K" after mount, once hydration has already reconciled.
const [modKbd, setModKbd] = useState("Ctrl K");
useEffect(() => {
if (/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)) setModKbd("⌘K");
}, []);

const pick = (picked: string) => {
setSuggestOpen(false);
Expand Down Expand Up @@ -479,7 +514,7 @@ function FilterBar({
<Input
id="wsp-path-filter"
type="search"
className="wsp-filter__q flex-1 min-w-0 min-h-9 px-3 py-1.5 text-base sm:text-[13px] rounded-[6px] box-border"
className={`wsp-filter__q flex-1 min-w-0 min-h-9 px-3 py-1.5 text-base sm:text-[13px] rounded-[6px] box-border${value === "" ? " pr-14" : ""}`}
aria-label="Filter by path"
aria-keyshortcuts="Meta+K Control+K Slash"
aria-expanded={open}
Expand All @@ -504,6 +539,9 @@ function FilterBar({
}}
onKeyDown={onKeyDown}
/>
{value === "" && (
<Kbd className="absolute top-1/2 right-2.5 -translate-y-1/2">{modKbd}</Kbd>
)}
{open && (
<ul
className="wsp-suggest absolute top-[calc(100%+4px)] left-0 right-0 z-30 m-0 max-h-[280px] list-none overflow-y-auto rounded-[6px] border border-line bg-panel p-1 shadow-[0_8px_24px_rgb(0_0_0_/_0.25)]"
Expand Down Expand Up @@ -1129,7 +1167,7 @@ function ScreenshotsByPathInner({
as a metadata filter — spec keeps URL-prefix search out of
scope), so a truncated response may hide project matches: say
so rather than claiming an empty/complete result. */}
{drillItems.length === 0 && <p className="wft-end">{drillEmptyMessage}</p>}
{drillItems.length === 0 && <InlineEmpty title={drillEmptyMessage} />}
{drillItems.length > 0 && drill.truncated && (
<p className="wft-end">
{view.project
Expand Down Expand Up @@ -1159,13 +1197,15 @@ function ScreenshotsByPathInner({
!view.merged && overview.latest.length === 0 && overview.catalog.length === 0 ? (
<EmptyShotsCta title="No screenshots yet" />
) : (
<p className="wft-end">
{overview.latest.length === 0
? view.merged
? "No merged uploads to show."
: "No recent uploads to show."
: "No recent uploads match this filter."}
</p>
<InlineEmpty
title={
overview.latest.length === 0
? view.merged
? "No merged uploads to show."
: "No recent uploads to show."
: "No recent uploads match this filter."
}
/>
)
) : (
<>
Expand Down Expand Up @@ -1243,7 +1283,7 @@ function ScreenshotsByPathInner({
{isEmptyWorkspace ? (
<EmptyShotsCta title="No screenshots yet" />
) : isEmptyFilter ? (
<p className="wft-end">{emptyFilterMessage}</p>
<InlineEmpty title={emptyFilterMessage} />
) : (
<>
{sectionLabels.map((label, index) => {
Expand Down
61 changes: 59 additions & 2 deletions apps/web/src/components/WorkspaceFileTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
*/
import { Callout } from "@uploads/ui";
import "@uploads/ui/styles.css";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@uploads/ui/components/ui/empty";
import { Kbd } from "@uploads/ui/components/ui/kbd";
import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { IslandErrorBoundary } from "./IslandErrorBoundary";
import type { ConnectedWorkSetter } from "../lib/workspace-rail";
Expand Down Expand Up @@ -800,6 +802,39 @@ function WorkspaceFileTableInner({
};
}, []);

// "Ctrl K" is the deterministic default so the server's render and the
// client's first render agree bit-for-bit (no `navigator` at SSR time) —
// corrected to "⌘K" after mount, once hydration has already reconciled.
const [modKbd, setModKbd] = useState("Ctrl K");
useEffect(() => {
if (/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)) setModKbd("⌘K");
}, []);

// "/" or ⌘K/Ctrl+K focuses the filter input from anywhere on the page,
// mirroring ScreenshotsByPath's path-filter shortcut.
useEffect(() => {
const typingInField = (event: KeyboardEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return false;
return Boolean(target.closest("input, textarea, select") || target.isContentEditable);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.isComposing) return;
if ((event.key === "k" || event.key === "K") && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
document.getElementById("wft-filter-input")?.focus();
return;
}
if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
if (typingInField(event)) return;
event.preventDefault();
document.getElementById("wft-filter-input")?.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);

// Close the open `⋯` menu on outside click / Escape.
useEffect(() => {
if (!openMenuKey) return;
Expand Down Expand Up @@ -1020,6 +1055,9 @@ function WorkspaceFileTableInner({
parts.push(pluralCount(count, "file"));
return parts.join(" · ");
})();
// True "nothing here" states — as opposed to loading/error/truncated/
// non-empty end-of-list markers, which stay plain `.wft-end` text.
const isEmptyState = state.status === "ok" && count === 0 && (filtered || folderCount === 0);

const parsedDraft = parseDraft(draft);
// Only treat the parsed key as "selected" once it's a valid (lowercase) key —
Expand Down Expand Up @@ -1190,8 +1228,9 @@ function WorkspaceFileTableInner({
addFilter();
}}
>
<span className="input-group__field">
<span className="input-group__field relative">
<input
id="wft-filter-input"
role="combobox"
aria-expanded={menuOpen}
aria-controls="wft-suggest"
Expand All @@ -1202,7 +1241,9 @@ function WorkspaceFileTableInner({
: undefined
}
aria-label="Filter files"
aria-keyshortcuts="Meta+K Control+K Slash"
placeholder="Filter by name, or key=value…"
style={draft === "" ? { paddingRight: 56 } : undefined}
value={draft}
onFocus={() => {
// Cancel a still-armed blur-close from a quick refocus — otherwise
Expand Down Expand Up @@ -1269,6 +1310,9 @@ function WorkspaceFileTableInner({
}
}}
/>
{draft === "" && (
<Kbd className="absolute top-1/2 right-2.5 -translate-y-1/2">{modKbd}</Kbd>
)}
</span>
<button type="submit" className="input-group__action">
add
Expand Down Expand Up @@ -1608,7 +1652,20 @@ function WorkspaceFileTableInner({
</div>
)}

<div className="wft-end">{endLabel}</div>
{isEmptyState ? (
<Empty className="wft-empty">
<EmptyHeader>
<EmptyTitle>{endLabel}</EmptyTitle>
<EmptyDescription>
{filtered
? "Try removing a filter or searching a different name."
: "Files uploaded to this workspace will show up here."}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="wft-end">{endLabel}</div>
)}

{state.status === "ok" && state.cursor && (
<button
Expand Down
Loading
Loading