diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md
index 34086dff..0bf68137 100644
--- a/.design-sync/NOTES.md
+++ b/.design-sync/NOTES.md
@@ -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
diff --git a/.design-sync/config.json b/.design-sync/config.json
index 5c4ae20b..6cecc7aa 100644
--- a/.design-sync/config.json
+++ b/.design-sync/config.json
@@ -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",
@@ -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,
@@ -113,6 +121,7 @@
},
"overrides": {
"Accordion": { "cardMode": "column" },
+ "Empty": { "cardMode": "column" },
"InputGroup": { "cardMode": "column" },
"Tabs": { "cardMode": "column" },
"Dialog": { "cardMode": "single", "primaryStory": "InviteTeammate", "viewport": "760x520" },
diff --git a/.design-sync/conventions.md b/.design-sync/conventions.md
index a2b757ef..32cac708 100644
--- a/.design-sync/conventions.md
+++ b/.design-sync/conventions.md
@@ -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
diff --git a/.design-sync/previews/Empty.tsx b/.design-sync/previews/Empty.tsx
new file mode 100644
index 00000000..3c44f615
--- /dev/null
+++ b/.design-sync/previews/Empty.tsx
@@ -0,0 +1,51 @@
+import "./canvas.module.css";
+import {
+ Button,
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@uploads/ui";
+
+export function Default() {
+ return (
+
+
+ ⌁
+ No screenshots yet
+
+ Capture one with uploads screenshot <url> and it shows up here.
+
+
+
+
+ Read the docs
+
+
+
+ );
+}
+
+export function TitleOnly() {
+ return (
+
+
+ No galleries yet
+ Create one to collect related uploads.
+
+
+ );
+}
+
+export function Inline() {
+ return (
+
+
+ No tokens yet
+ Create one above to start.
+
+
+ );
+}
diff --git a/.design-sync/previews/Kbd.tsx b/.design-sync/previews/Kbd.tsx
new file mode 100644
index 00000000..4fe4bb2b
--- /dev/null
+++ b/.design-sync/previews/Kbd.tsx
@@ -0,0 +1,30 @@
+import "./canvas.module.css";
+import { Kbd, KbdGroup } from "@uploads/ui";
+
+export function Default() {
+ return (
+
+ ⌘
+ K
+
+ );
+}
+
+export function SingleKey() {
+ return / ;
+}
+
+export function InlineHint() {
+ return (
+
+ Filter path
+
+ ⌘
+ K
+
+
+ );
+}
diff --git a/apps/web/src/components/ScreenshotsByPath.tsx b/apps/web/src/components/ScreenshotsByPath.tsx
index 178af93c..502a0e34 100644
--- a/apps/web/src/components/ScreenshotsByPath.tsx
+++ b/apps/web/src/components/ScreenshotsByPath.tsx
@@ -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,
@@ -90,15 +92,41 @@ function EmptyShotsCta({ title }: { title: string }) {
}
};
return (
-
-
{title}
-
- {EMPTY_CTA_CMD}
- void copy()}>
- {copied ? "copied ✓" : "copy"}
-
-
-
+
+
+ {title}
+
+
+
+
+ {EMPTY_CTA_CMD}
+
+ void copy()}
+ >
+ {copied ? "copied ✓" : "copy"}
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {title}
+
+
);
}
@@ -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);
@@ -479,7 +514,7 @@ function FilterBar({
+ {value === "" && (
+ {modKbd}
+ )}
{open && (
{drillEmptyMessage}}
+ {drillItems.length === 0 && }
{drillItems.length > 0 && drill.truncated && (
{view.project
@@ -1159,13 +1197,15 @@ function ScreenshotsByPathInner({
!view.merged && overview.latest.length === 0 && overview.catalog.length === 0 ? (
) : (
-
- {overview.latest.length === 0
- ? view.merged
- ? "No merged uploads to show."
- : "No recent uploads to show."
- : "No recent uploads match this filter."}
-
+
)
) : (
<>
@@ -1243,7 +1283,7 @@ function ScreenshotsByPathInner({
{isEmptyWorkspace ? (
) : isEmptyFilter ? (
- {emptyFilterMessage}
+
) : (
<>
{sectionLabels.map((label, index) => {
diff --git a/apps/web/src/components/WorkspaceFileTable.tsx b/apps/web/src/components/WorkspaceFileTable.tsx
index 84ea8c1c..318b2c9f 100644
--- a/apps/web/src/components/WorkspaceFileTable.tsx
+++ b/apps/web/src/components/WorkspaceFileTable.tsx
@@ -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";
@@ -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;
@@ -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 —
@@ -1190,8 +1228,9 @@ function WorkspaceFileTableInner({
addFilter();
}}
>
-
+
{
// Cancel a still-armed blur-close from a quick refocus — otherwise
@@ -1269,6 +1310,9 @@ function WorkspaceFileTableInner({
}
}}
/>
+ {draft === "" && (
+ {modKbd}
+ )}
add
@@ -1608,7 +1652,20 @@ function WorkspaceFileTableInner({
)}
- {endLabel}
+ {isEmptyState ? (
+
+
+ {endLabel}
+
+ {filtered
+ ? "Try removing a filter or searching a different name."
+ : "Files uploaded to this workspace will show up here."}
+
+
+
+ ) : (
+ {endLabel}
+ )}
{state.status === "ok" && state.cursor && (
+
+
+
+ ⌁
+ No screenshots yet
+
+ Capture one with uploads screenshot <url> and it shows up here.
+
+
+
+
+ Read the docs
+
+
+
+
+
+
+
+
+ Filter
+
+ ⌘
+ K
+
+
+
+ Search
+ /
+
+
+
+
diff --git a/apps/web/src/lib/developers-ui.ts b/apps/web/src/lib/developers-ui.ts
index e130cc2e..1afd9858 100644
--- a/apps/web/src/lib/developers-ui.ts
+++ b/apps/web/src/lib/developers-ui.ts
@@ -8,7 +8,7 @@ import {
type IssuedWorkspaceToken,
type MintableWorkspace,
} from "./api-client";
-import { escapeHtml } from "./workspace-ui";
+import { escapeHtml, renderEmptyStateHtml } from "./workspace-ui";
export async function loadDevelopersPageData(
apiOrigin: string,
@@ -39,7 +39,11 @@ export function tokenAccessLabel(scopes: string[]): string {
export function renderIssuedTokenListHtml(tokens: IssuedWorkspaceToken[]): string {
if (tokens.length === 0) {
- return `No tokens yet
Create one above to start.
`;
+ return `${renderEmptyStateHtml({
+ title: "No tokens yet",
+ description: "Create one above to start.",
+ variant: "inline",
+ })} `;
}
return tokens
.map((token) => {
diff --git a/apps/web/src/lib/workspace-ui.test.ts b/apps/web/src/lib/workspace-ui.test.ts
index 143366cc..d45ca16a 100644
--- a/apps/web/src/lib/workspace-ui.test.ts
+++ b/apps/web/src/lib/workspace-ui.test.ts
@@ -6,6 +6,7 @@ import {
formatMarketedBytes,
orderOrgsOldestFirst,
renderDetailListPlaceholderHtml,
+ renderEmptyStateHtml,
renderFilesPlaceholderHtml,
renderGalleriesEmptyHtml,
renderGalleriesGridHtml,
@@ -470,19 +471,69 @@ describe("skeleton placeholders", () => {
});
});
+describe("renderEmptyStateHtml", () => {
+ it("mirrors the shadcn Empty component's data-slot structure", () => {
+ const html = renderEmptyStateHtml({ title: "No items", description: "Do a thing." });
+ expect(html).toContain('data-slot="empty"');
+ expect(html).toContain('data-slot="empty-header"');
+ expect(html).toContain('data-slot="empty-title"');
+ expect(html).toContain('data-slot="empty-description"');
+ expect(html).toContain(">No items<");
+ expect(html).toContain(">Do a thing.<");
+ });
+
+ it("escapes the title but leaves description/content as caller-trusted HTML", () => {
+ const html = renderEmptyStateHtml({
+ title: "",
+ description: 'Create a thing .',
+ });
+ expect(html).not.toContain("