Skip to content

Navigation Menu

Sign in
Sign up

feat(devhud): redesign RealQA capture surface #883

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
labor0-bot wants to merge 28 commits into main
base: main
Choose a base branch
Loading
from codex/realqa-capture-foundation
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7f3ad58
feat(devhud): redesign RealQA capture surface
labor0-bot[bot] Sep 6, 2026
ddca821
fix(devhud): preserve RealQA modal controls
labor0-bot[bot] Sep 6, 2026
56a534f
fix(devhud): address RealQA capture review feedback
labor0-bot[bot] Sep 6, 2026
d48c81f
fix(devhud): address RealQA capture review feedback
labor0-bot[bot] Sep 6, 2026
84b905f
fix(devhud): scope editor capture cancellation
labor0-bot[bot] Sep 6, 2026
a768ca5
fix(devhud): preserve capture editor feedback
labor0-bot[bot] Sep 6, 2026
612ced2
fix(devhud): address RealQA preview review feedback
labor0-bot[bot] Sep 6, 2026
6f5fe66
fix(devhud): retain editor focus after preview dismissal
labor0-bot[bot] Sep 6, 2026
333c6fa
fix(devhud): clarify encrypted draft save failures
labor0-bot[bot] Sep 6, 2026
aba79f0
fix(devhud): expose RealQA capture failures
labor0-bot[bot] Sep 6, 2026
3771d13
fix(devhud): clarify capture progress feedback
labor0-bot[bot] Sep 6, 2026
e5a2d0e
fix(devhud): address RealQA review feedback
labor0-bot[bot] Sep 6, 2026
462e36a
fix(devhud): retain RealQA editor focus
labor0-bot[bot] Sep 6, 2026
8fe487d
fix(devhud): retain focus after layer removal
labor0-bot[bot] Sep 6, 2026
3150b6f
fix(devhud): show capture dialog progress
labor0-bot[bot] Sep 6, 2026
b0b7a30
fix(devhud): restore editor focus after palette
labor0-bot[bot] Sep 6, 2026
c23584d
fix(devhud): restore RealQA focus and previews
labor0-bot[bot] Sep 6, 2026
91216c4
fix(devhud): restore preview replacement focus
labor0-bot[bot] Sep 6, 2026
0607f50
fix(devhud): restore off-surface preview focus
labor0-bot[bot] Sep 6, 2026
5cecd07
fix(devhud): restore palette capture focus
labor0-bot[bot] Sep 6, 2026
221a40a
fix(devhud): preserve capture picker state
labor0-bot[bot] Sep 6, 2026
6503813
fix(devhud): preserve same-draft preview opener
labor0-bot[bot] Sep 6, 2026
aa4f430
fix(devhud): distinguish danger button hover
labor0-bot[bot] Sep 6, 2026
85788eb
fix(devhud): preserve capture focus during async refresh
labor0-bot[bot] Sep 6, 2026
737ba5b
fix(devhud): address RealQA capture review feedback
labor0-bot[bot] Sep 6, 2026
6a0dff1
fix(devhud): address RealQA review feedback
labor0-bot[bot] Sep 6, 2026
673b583
fix(devhud): retain focus during editor capture
labor0-bot[bot] Sep 6, 2026
206a3da
fix(devhud): restore focus after RealQA remount
labor0-bot[bot] Sep 6, 2026
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
2 changes: 2 additions & 0 deletions apps/AGENTS.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

### DevHud Rules

- Desktop RealQA presents capture actions, fixed encrypted-draft policy/status, then encrypted drafts in that order. Its localized policy names logout alongside explicit deletion and expiry, its success/recovery feedback stays visible inside an active editor sheet, and capture-picker focus returns to the trigger recorded before topology refresh.

- Native widget search items must provide exact string `node_id`, string `title`, string `repository_url`, and integer `number` fields; malformed fields retain the last successful snapshot under the general error state. Android widget enablement cleans stale Deck state before reporting an absent selected PAT or scope marker as `not-configured`, and an unreadable authoritative PAT must durably remove or block the copied widget credential before returning; cleanup, persistence, Keystore, and ciphertext failures remain storage failures.
- Require every concrete Chrome origin to be covered by its URL mapping scheme, host, and normalized port before settings acceptance or extension publication.
- Implemented DevHud foundations are the `apps/devhud` desktop/mobile/widget hosts and diagnostics boundary, `apps/devhud-chrome-extension`, `apps/devhud-admin`, `crates/devhud-native-messaging-host`, the `servers/devhud-api` Bootstrap/Settings/Upload/Account/Admin/Diagnostics API and account/retention sweeper, `protos/devhud/v1`, and `packages/devhud-api-client`. Desktop RealQA direct GitHub issue submission, direct official/BYO R2 image uploads, and native widgets are implemented; remaining product workflows stay documentation-first until their project/domain contracts are updated. Logto uses native callback `devhud://auth/callback`, keeps pending callbacks queued through issuer-policy reloads until the identity session is ready, platform client keys `desktop`/`ios`/`android`, an `admin` client key with the documented exact browser redirect, and Native Messaging host `io.delino.devhud.native_messaging` with one fixed release-configured extension ID. The desktop/mobile shell and administrator SPA retain the complete configured Logto issuer for validation but remove exactly one terminal `/oidc` segment when deriving the Logto SDK endpoint, because the SDK owns that discovery-path segment. The Native Messaging host uses the documented authenticated v1 user-scoped IPC contract owned by the app, retries Unix accept failures with capped backoff, and shares one absolute five-second deadline across host connection establishment and authentication.
Expand Down
23 changes: 23 additions & 0 deletions apps/devhud/scripts/accessibility.test.mjs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const styles = readFileSync(join(appRoot, "src/styles.css"), "utf8");
const app = readFileSync(join(appRoot, "src/App.tsx"), "utf8");
const identityUi = readFileSync(join(appRoot, "src/identity-ui.tsx"), "utf8");
const foundation = readFileSync(join(appRoot, "src/ui-foundation.tsx"), "utf8");
const realqa = readFileSync(join(appRoot, "src/realqa-ui.tsx"), "utf8");
const icons = readFileSync(join(appRoot, "src/ui-icons.tsx"), "utf8");
const main = readFileSync(join(appRoot, "src/main.tsx"), "utf8");
const nativeHost = readFileSync(join(appRoot, "src-tauri/src/main.rs"), "utf8");
Expand Down Expand Up @@ -82,6 +83,16 @@ test("RealQA annotation text uses CSP-compatible static font styling", () => {
assert.match(styles, /\.annotation-text\{font-family:"DevHud RealQA Noto Sans KR";font-kerning:none\}/u);
});

test("RealQA uses the shared state primitives and remains readable at narrow and zoomed layouts", () => {
assert.match(realqa, /import \{ Button, Card, Dialog, PageHeader, Sheet, StatePanel, StatusBadge/u);
for (const primitive of ["<PageHeader", "<Card", "<Button", "<StatusBadge", "<StatePanel", "<Dialog", "<Sheet"]) assert(realqa.includes(primitive), `RealQA must use ${primitive}`);
assert.match(realqa, /realqaPolicyQuota/u);
assert.doesNotMatch(realqa, /(?:used bytes|quota meter|quota percentage|<progress)/iu);
assert.match(styles, /\.ui-dialog,\.ui-sheet\s*\{[^}]*max-height:calc\(100vh - var\(--ui-overlay-top\) - var\(--ui-overlay-bottom\)\)/u);
assert.match(styles, /@media\(max-width:700px\)\{[^}]*\.capture-actions \.ui-button\{width:100%;min-width:0/u);
assert.match(styles, /\.draft-list\{[^}]*minmax\(15rem,1fr\)/u);
});

test("form-control boundaries meet non-text contrast in light and dark themes", () => {
for (const block of themeBlocks) {
const line = customColor(block, "--line");
Expand All @@ -93,6 +104,18 @@ test("form-control boundaries meet non-text contrast in light and dark themes",
}
});

test("action Button variants remain legible and distinguishable on hover", () => {
assert.match(styles, /\.actions button:not\(\.ui-button\)\s*\{/u);
assert.match(styles, /\.ui-button-danger:hover\s*\{[^}]*background:var\(--error\);[^}]*box-shadow:0 0 0 2px var\(--line\)/u);
assert.match(styles, /\.ui-button-danger:active\s*\{\s*box-shadow:inset 0 0 0 2px var\(--line\)/u);
assert.match(styles, /html\[data-theme="dark"\] \.ui-button-danger\s*\{\s*color:var\(--devhud-background\)/u);
for (const block of themeBlocks) {
assert(contrastRatio(customColor(block, "--text"), customColor(block, "--surface-muted")) >= 4.5);
}
assert(contrastRatio("#ffffff", customColor(themeBlocks[0], "--error")) >= 4.5);
assert(contrastRatio(customColor(themeBlocks[1], "--devhud-background"), customColor(themeBlocks[1], "--error")) >= 4.5);
});

test("command palette uses buttons and has a localized empty state", () => {
assert.doesNotMatch(app, /role="listbox"|role="option"/u);
assert.match(app, /actions\.length === 0/u);
Expand Down
10 changes: 10 additions & 0 deletions apps/devhud/scripts/localization.test.mjs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ test("localizes recoverable encrypted-draft deletion failures", () => {
assert.match(messages.ko.realqaDeleteFailed, /초안 삭제/u);
});

test("localizes the fixed RealQA encryption policy and typed recovery states without usage metrics", () => {
for (const language of ["en", "ko"]) {
const copy = messages[language];
for (const key of ["realqaCapture", "realqaPolicyTitle", "realqaPolicySummary", "realqaPolicyQuota", "realqaEmptyTitle", "realqaUnreadableTitle", "realqaQuotaTitle", "realqaPermissionTitle", "realqaProtectedTitle", "realqaTopologyTitle", "realqaSaveTitle", "realqaOpenTitle", "realqaDeleteTitle"]) assert.ok(copy[key]);
assert.match(copy.realqaPolicySummary, language === "en" ? /encrypted.*device.*30-day/iu : /이 기기.*암호화.*30일/u);
assert.match(copy.realqaPolicyQuota, /10\s*GiB/u);
assert.doesNotMatch(copy.realqaPolicyQuota, /(?:used|%|사용량)/iu);
}
});

test("uses the first supported platform language", () => {
assert.equal(selectSupportedLanguage(["fr-FR", "ko-KR", "en-US"]), "ko");
assert.equal(selectSupportedLanguage(["fr-FR", "en-US", "ko-KR"]), "en");
Expand Down
2 changes: 2 additions & 0 deletions apps/devhud/scripts/mobile-policy.mjs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ export function assertMobileContracts({ platforms, tauri, ios, android, cargo, a
for (const operation of ["runtime.snapshot", "lifecycle.open-external", "auth.peek-pending-callback", "auth.take-pending-callback", "secure.read", "secure.write", "notifications.request-permission", "updates.status", "widgets.replace-deck-snapshot"]) assert(nativeBridge.includes(`\"${operation}\"`), `typed bridge operation missing: ${operation}`);
assert(nativeBridge.includes("readonly widgets: boolean"), "runtime widget capability must be platform-reported");
assert(app.includes("mobile &&") && app.includes("copy.realqaMobileTitle"), "mobile RealQA unavailable state is missing");
assert(/surface === SurfaceId\.Realqa && mobile && <><PageHeader[\s\S]*realqaMobileSummary[\s\S]*<\/Card><\/>/u.test(app), "mobile RealQA must retain only its localized desktop-only notice");
assert(/!mobile && runtimeCapabilities\.available\.has\(PlatformCapability\.Capture\) && <RealqaSurface/u.test(app), "capture, editor, and issue-submission UI must remain outside the mobile closure");
assert(app.includes("!mobile") && app.includes("ExternalLinkTarget.Issue"), "issue creation is not explicitly desktop-only");
assert(workflow.includes("devhud-mobile-contracts") && workflow.includes("devhud-android-emulator"), "mobile CI validation jobs are incomplete");
assertMobileCi(workflow);
Expand Down
139 changes: 139 additions & 0 deletions apps/devhud/src/App.test.tsx
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,125 @@ describe("native App state", () => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: messages.en.captureNow }));
});

it.each([
["captureDisplay"],
["captureWindow"],
["captureAll"],
] as const)("returns focus to Capture after a palette-started %s capture closes its editor", async (actionLabel) => {
const runtime: RuntimeSnapshot = { ...desktopRuntime, capabilities: { ...desktopRuntime.capabilities, capture: true } };
const draft = {
id: "019b0000-0000-7000-8000-000000000051",
revision: 1,
createdAt: 1_700_000_000,
updatedAt: 1_700_000_000,
expiresAt: 1_702_592_000,
hasBrowserContext: false,
imageCount: 1,
images: [{ id: "019b0000-0000-7000-8000-000000000052", width: 800, height: 600, previewUrl: "realqa://asset/draft/image/captured/1", crop: null, layers: [] }],
canUndo: false,
canRedo: false,
};
const request = vi.fn(async (value: NativeBridgeRequestV1): Promise<NativeBridgeResponseV1> => {
if (value.operation === "capture.status") return { kind: "capture-status", available: true, platform: "windows", shadowRemovalSupported: false, topology: [] };
if (value.operation === "capture.list-drafts") return { kind: "capture-drafts", drafts: [], unreadableDraftIds: [] };
if (value.operation === "capture.start") return { kind: "capture-draft", draft };
throw new Error(`unexpected operation ${value.operation}`);
});
render(<App bridge={bridgeWith(request)} initialRuntime={runtime} />);

fireEvent.click(screen.getByRole("button", { name: messages.en.openPalette }));
const paletteAction = within(screen.getByRole("dialog", { name: messages.en.commandPalette })).getByRole("button", { name: messages.en[actionLabel] });
paletteAction.focus();
fireEvent.click(paletteAction);
await screen.findByRole("dialog", { name: messages.en.editorTitle });
fireEvent.click(screen.getByRole("button", { name: messages.en.close }));

await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("button", { name: messages.en.captureDisplay })));
});

it.each([
["captureSelection"],
["captureToolbar"],
] as const)("returns focus to an open editor after canceling a palette-started %s picker", async (dialogLabel) => {
const runtime: RuntimeSnapshot = { ...desktopRuntime, capabilities: { ...desktopRuntime.capabilities, capture: true } };
const draft = {
id: "019b0000-0000-7000-8000-000000000041",
revision: 3,
createdAt: 1_700_000_000,
updatedAt: 1_700_000_000,
expiresAt: 1_702_592_000,
hasBrowserContext: false,
imageCount: 1,
images: [{ id: "019b0000-0000-7000-8000-000000000042", width: 800, height: 600, previewUrl: "realqa://asset/draft/image/source/3", crop: null, layers: [] }],
canUndo: false,
canRedo: false,
};
const request = vi.fn(async (value: NativeBridgeRequestV1): Promise<NativeBridgeResponseV1> => {
if (value.operation === "capture.status") return { kind: "capture-status", available: true, platform: "windows", shadowRemovalSupported: false, topology: [] };
if (value.operation === "capture.list-drafts") return { kind: "capture-drafts", drafts: [draft], unreadableDraftIds: [] };
if (value.operation === "capture.open-draft") return { kind: "capture-draft", draft };
throw new Error(`unexpected operation ${value.operation}`);
});
render(<App bridge={bridgeWith(request)} initialRuntime={runtime} />);
fireEvent.click(screen.getByRole("button", { name: messages.en.realqa }));
fireEvent.click(await screen.findByRole("button", { name: messages.en.realqaOpenEditor }));
await screen.findByRole("dialog", { name: messages.en.editorTitle });

fireEvent.click(screen.getByRole("button", { name: messages.en.openPalette }));
fireEvent.click(within(screen.getByRole("dialog", { name: messages.en.commandPalette })).getByRole("button", { name: messages.en[dialogLabel] }));
await screen.findByRole("dialog", { name: messages.en[dialogLabel] });
fireEvent.click(screen.getByRole("button", { name: messages.en.captureCancel }));

await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("button", { name: `${messages.en.editorImage} 1` })));
});

it.each([
["captureSelection"],
["captureToolbar"],
] as const)("returns focus to Capture after canceling a palette-started %s picker without an open editor", async (dialogLabel) => {
const runtime: RuntimeSnapshot = { ...desktopRuntime, capabilities: { ...desktopRuntime.capabilities, capture: true } };
const request = vi.fn(async (value: NativeBridgeRequestV1): Promise<NativeBridgeResponseV1> => {
if (value.operation === "capture.status") return { kind: "capture-status", available: true, platform: "windows", shadowRemovalSupported: false, topology: [] };
if (value.operation === "capture.list-drafts") return { kind: "capture-drafts", drafts: [], unreadableDraftIds: [] };
throw new Error(`unexpected operation ${value.operation}`);
});
render(<App bridge={bridgeWith(request)} initialRuntime={runtime} />);

fireEvent.click(screen.getByRole("button", { name: messages.en.openPalette }));
fireEvent.click(within(screen.getByRole("dialog", { name: messages.en.commandPalette })).getByRole("button", { name: messages.en[dialogLabel] }));
await screen.findByRole("dialog", { name: messages.en[dialogLabel] });
fireEvent.click(screen.getByRole("button", { name: messages.en.captureCancel }));

await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("button", { name: messages.en.captureDisplay })));
});

it("returns palette focus to a picker that was already open", async () => {
const runtime: RuntimeSnapshot = { ...desktopRuntime, capabilities: { ...desktopRuntime.capabilities, capture: true } };
let receive: ((event: NativeBridgeEventV1) => void) | undefined;
const request = vi.fn(async (value: NativeBridgeRequestV1): Promise<NativeBridgeResponseV1> => {
if (value.operation === "capture.status") return { kind: "capture-status", available: true, platform: "windows", shadowRemovalSupported: false, topology: [] };
if (value.operation === "capture.list-drafts") return { kind: "capture-drafts", drafts: [], unreadableDraftIds: [] };
throw new Error(`unexpected operation ${value.operation}`);
});
const bridge: NativeBridgeV1 = {
request,
async listen(listener) { receive = listener; return () => {}; },
};
render(<App bridge={bridge} initialRuntime={runtime} />);
await waitFor(() => expect(receive).toBeTypeOf("function"));
fireEvent.click(screen.getByRole("button", { name: messages.en.realqa }));
fireEvent.click(await screen.findByRole("button", { name: messages.en.captureSelection }));
const picker = await screen.findByRole("dialog", { name: messages.en.captureSelection });

await act(async () => receive?.({ version: 1, kind: "shortcut-triggered", action: ShortcutActionId.CommandPalette }));
fireEvent.click(await within(screen.getByRole("dialog", { name: messages.en.commandPalette })).findByRole("button", { name: messages.en.close }));

const captureNow = within(picker).getByRole("button", { name: messages.en.captureNow });
await waitFor(() => expect(document.activeElement).toBe(captureNow));
fireEvent.keyDown(captureNow, { key: "Tab" });
expect(picker.contains(document.activeElement)).toBe(true);
});

it("suppresses global shortcuts while an updater approval is open", async () => {
const updaterStatus: DesktopUpdaterStatus = {
kind: "available",
Expand Down Expand Up @@ -1162,6 +1281,10 @@ describe("responsive application shell", () => {
await waitFor(() => expect(document.activeElement).toBe(realqa));
fireEvent.click(realqa);
expect(screen.getByRole("heading", { name: messages.en.realqaMobileTitle })).toBeTruthy();
for (const label of [messages.en.captureDisplay, messages.en.captureWindow, messages.en.captureAll, messages.en.captureSelection, messages.en.captureToolbar, messages.en.realqaOpenEditor, messages.en.issueSubmit]) {
expect(screen.queryByRole("button", { name: label })).toBeNull();
}
expect(document.querySelector(".capture-editor, .issue-dialog, .capture-actions")).toBeNull();
expect(more.getAttribute("aria-current")).toBe("page");

fireEvent.click(more);
Expand All @@ -1175,6 +1298,22 @@ describe("responsive application shell", () => {
expect(more.getAttribute("aria-current")).toBe("page");
});

it.each([RuntimePlatform.Ios, RuntimePlatform.Android])("renders only the localized desktop-only RealQA notice on %s", async (platform) => {
Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: 390 });
vi.stubGlobal("fetch", vi.fn(async () => new Response("unavailable", { status: 503 })));
render(<App bridge={unavailableBridge()} initialRuntime={{ ...mobileRuntime, platform, operatingSystem: platform === RuntimePlatform.Ios ? "ios" : "android" }} />);

fireEvent.click(within(screen.getByRole("navigation", { name: messages.en.mobileNavigation })).getByRole("button", { name: messages.en.more }));
fireEvent.click(within(screen.getByRole("dialog", { name: messages.en.more })).getByRole("button", { name: /RealQA/u }));

expect(screen.getByRole("heading", { name: messages.en.realqaMobileTitle })).toBeTruthy();
expect(screen.getByText(messages.en.unavailable)).toBeTruthy();
for (const label of [messages.en.captureDisplay, messages.en.captureWindow, messages.en.captureAll, messages.en.captureSelection, messages.en.captureToolbar, messages.en.realqaOpenEditor, messages.en.issueSubmit]) {
expect(screen.queryByRole("button", { name: label })).toBeNull();
}
expect(document.querySelector(".capture-editor, .issue-dialog, .capture-actions")).toBeNull();
});

it("moves localized skip-link focus to the main content", () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("unavailable", { status: 503 })));
render(<App bridge={unavailableBridge()} initialRuntime={desktopRuntime} />);
Expand Down
Loading

AltStyle によって変換されたページ (->オリジナル) /