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
13 changes: 13 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,16 @@ body::before {
.drawer-fade {
animation: drawer-fade-in 0.15s ease-out;
}

/* Visually hidden, still read by screen readers. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
12 changes: 10 additions & 2 deletions components/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ToolCall } from "@/components/tool-call";
import { Send, Square, Slash, Sparkles, Pin as PinIcon } from "lucide-react";
import { localMember } from "@/lib/team";
import { useIdentityVersion } from "@/lib/use-identity-version";
import { chatLogProps, composerProps, statusRegionProps } from "@/lib/a11y";

const LOCAL = localMember();

Expand Down Expand Up @@ -308,7 +309,7 @@ export function ChannelChat({
description={description}
/>
) : (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" {...chatLogProps}>
{items.map((it, i) => (
<ChatMessage
key={it.id}
Expand Down Expand Up @@ -521,7 +522,10 @@ function ChatMessage({
<Dots />
)}
{item.kind === "assistant" && item.streaming && item.text && (
<span className="inline-block w-1.5 h-4 ml-0.5 bg-amber-400/70 animate-pulse align-middle rounded-sm" />
<span
className="inline-block w-1.5 h-4 ml-0.5 bg-amber-400/70 animate-pulse align-middle rounded-sm"
{...statusRegionProps}
/>
)}
</div>
<button
Expand Down Expand Up @@ -629,7 +633,11 @@ function Composer({
placeholder={`Message #${channelName}`}
rows={1}
className="flex-1 bg-transparent text-sm resize-none focus:outline-none placeholder:text-neutral-600 py-1.5 max-h-40"
{...composerProps(channelName)}
/>
<span id="chat-composer-hint" className="sr-only">
Enter to send, Shift+Enter for a new line
</span>
{streaming ? (
<button
onClick={onStop}
Expand Down
2 changes: 2 additions & 0 deletions components/channel-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { Hash, Lock, FolderOpen, X, Folder } from "lucide-react";
import { FolderPicker } from "@/components/folder-picker";
import { modalProps } from "@/lib/a11y";

export type ChannelDraft = {
name: string;
Expand Down Expand Up @@ -70,6 +71,7 @@ export function ChannelDialog({ mode, initial, groupLabel, serverName, onSave, o
<div
ref={ref}
onClick={(e) => e.stopPropagation()}
{...modalProps(mode === "create" ? "Create channel" : "Edit channel")}
className="relative bg-[#0d0d0f] border-l border-neutral-800 w-full max-w-md h-full shadow-2xl flex flex-col drawer-slide"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800">
Expand Down
3 changes: 3 additions & 0 deletions components/channel-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { ChannelDialog, type ChannelDraft } from "./channel-dialog";
import { DashboardWidgets } from "./dashboard-widgets";
import type { ChannelGroup, Channel } from "@/lib/channels";
import { useServers } from "@/lib/server-context";
import { ariaExpanded } from "@/lib/a11y";

const KIND_ICONS = {
home: Home,
Expand Down Expand Up @@ -288,6 +289,7 @@ export function ChannelList() {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search channels"
aria-label="Search channels"
className="w-full bg-neutral-900 border border-neutral-800 rounded-md text-xs pl-7 pr-2 py-1.5 focus:outline-none focus:border-neutral-700 placeholder:text-neutral-600"
/>
</div>
Expand All @@ -306,6 +308,7 @@ export function ChannelList() {
<div className="flex items-center gap-1 px-3 group">
<button
onClick={() => setCollapsed({ ...collapsed, [g.label]: !isCol })}
{...ariaExpanded(!isCol, g.label)}
className="flex items-center gap-1 py-1 text-xs uppercase tracking-wider font-semibold text-neutral-500 hover:text-neutral-300 flex-1"
>
{isCol ? (
Expand Down
2 changes: 2 additions & 0 deletions components/invite-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Sparkles,
ExternalLink,
} from "lucide-react";
import { modalProps } from "@/lib/a11y";

type OnboardingShape = {
settings?: Record<string, string | null>;
Expand Down Expand Up @@ -72,6 +73,7 @@ export function InviteModal({ onClose }: { onClose: () => void }) {
>
<div
onClick={(e) => e.stopPropagation()}
{...modalProps("Invite teammates")}
className="bg-[#0d0d0f] border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800">
Expand Down
8 changes: 6 additions & 2 deletions components/onboarding-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
KeyRound,
} from "lucide-react";
import { FolderPicker } from "@/components/folder-picker";
import { modalProps } from "@/lib/a11y";

type Identity = "primary" | "teammate";

Expand Down Expand Up @@ -143,10 +144,13 @@ export function OnboardingWizard() {

return (
<div className="fixed inset-0 z-[100] bg-black/70 backdrop-blur-sm flex items-center justify-center p-6">
<div className="bg-[#0d0d0f] border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden">
<div
{...modalProps("War Room setup")}
className="bg-[#0d0d0f] border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-amber-400" />
<Sparkles className="w-5 h-5 text-amber-400" aria-hidden="true" />
<h2 className="text-lg font-semibold">War Room setup</h2>
</div>
<button onClick={skip} title="Skip, finish later" className="text-neutral-500 hover:text-neutral-300 p-1">
Expand Down
9 changes: 7 additions & 2 deletions components/rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useServers, serverLandingPath, type ServerRow } from "@/lib/server-context";
import { UploadButton } from "@/components/upload-button";
import { ariaCurrent } from "@/lib/a11y";

const COLOR_MAP: Record<string, string> = {
amber: "from-amber-500/30 to-amber-700/20 border-amber-500/40 text-amber-300",
Expand Down Expand Up @@ -56,7 +57,10 @@ export function Rail() {
};

return (
<div className="w-[72px] shrink-0 bg-neutral-950 border-r border-neutral-900 flex flex-col items-center py-3 gap-2 overflow-y-auto">
<nav
aria-label="Servers"
className="w-[72px] shrink-0 bg-neutral-950 border-r border-neutral-900 flex flex-col items-center py-3 gap-2 overflow-y-auto"
>
{servers.map((s) => (
<ServerIcon
key={s.id}
Expand Down Expand Up @@ -145,7 +149,7 @@ export function Rail() {
}}
/>
)}
</div>
</nav>
);
}

Expand Down Expand Up @@ -175,6 +179,7 @@ function ServerIcon({
}}
title={`${server.name} · right-click to edit`}
aria-label={`Open ${server.name} server`}
{...ariaCurrent(active)}
className="relative group"
>
{active && (
Expand Down
2 changes: 2 additions & 0 deletions components/settings-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { UploadButton } from "@/components/upload-button";
import { HostingPanel } from "@/components/settings-hosting-panel";
import { JoinForm } from "@/components/settings-hosting-join-form";
import { modalProps } from "@/lib/a11y";

export type SettingsTab = "general" | "agent" | "sidebar" | "boardroom" | "sync" | "about";

Expand Down Expand Up @@ -52,6 +53,7 @@ export function SettingsModal({
<div className="fixed inset-0 z-[90] bg-black/70 backdrop-blur-sm flex items-center justify-center p-6" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
{...modalProps("Settings")}
className="bg-[#0d0d0f] border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-5xl flex flex-col max-h-[90vh] overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800">
Expand Down
14 changes: 13 additions & 1 deletion electron/after-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,19 @@ const path = require("path");
exports.default = async function afterPack(context) {
const projectRoot = context.packager.info.projectDir;
const appOutDir = context.appOutDir;
const dest = path.join(appOutDir, "resources", "app", ".next", "standalone");
const productName = context.packager.appInfo.productName;

// On macOS, electron-builder places the .app bundle at
// appOutDir/<ProductName>.app/Contents/Resources/, NOT at
// appOutDir/resources/. The old path left the standalone server outside the
// .app bundle, so process.resourcesPath could not find it and the embedded
// Next.js server never started.
const resourcesDir =
process.platform === "darwin"
? path.join(appOutDir, `${productName}.app`, "Contents", "Resources")
: path.join(appOutDir, "resources");

const dest = path.join(resourcesDir, "app", ".next", "standalone");

// Wipe whatever electron-builder copied first (it may have partial contents).
if (fs.existsSync(dest)) {
Expand Down
5 changes: 5 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,11 @@ if (!hasSingleInstanceLock) {
});

app.whenReady().then(async () => {
// Must run after ready. Without it, Chromium only builds the accessibility
// tree when a screen reader is already running at launch, so a reader
// started after the app (e.g. VoiceOver) would see an empty tree.
app.setAccessibilitySupportEnabled(true);

await createMainWindow();
await createMiniWindow();

Expand Down
80 changes: 80 additions & 0 deletions lib/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Accessibility helpers for War Room.
*
* Small prop factories so screen-reader semantics (dialog roles, live regions,
* expand/collapse state) stay consistent across components instead of being
* hand-rolled at each call site.
*/

/**
* Props for an accessible modal dialog.
* Usage: <div {...modalProps("Create server")} ref={containerRef}>
*/
export function modalProps(title: string): {
role: "dialog";
"aria-modal": true;
"aria-label": string;
} {
return { role: "dialog", "aria-modal": true, "aria-label": title };
}

/**
* Props for a collapsible section toggle button.
* Usage: <button {...ariaExpanded(isOpen, "Channels")}>
*/
export function ariaExpanded(
expanded: boolean,
label: string,
): { "aria-expanded": boolean; "aria-label": string } {
return {
"aria-expanded": expanded,
"aria-label": `${label}, ${expanded ? "collapse" : "expand"}`,
};
}

/**
* Props marking the active item in a navigation list.
* Usage: <button {...ariaCurrent(isActive)}>
*/
export function ariaCurrent(active: boolean): {
"aria-current": "page" | undefined;
} {
return { "aria-current": active ? "page" : undefined };
}

/**
* Props for the chat message container. "polite" announces new messages once
* the user stops interacting, without interrupting.
*/
export const chatLogProps = {
role: "log" as const,
"aria-live": "polite" as const,
"aria-label": "Channel messages",
"aria-relevant": "additions" as const,
};

/**
* Props for a transient status region (e.g. the streaming-in-progress cursor).
* Content is announced automatically when it changes.
*/
export const statusRegionProps = {
role: "status" as const,
"aria-live": "polite" as const,
"aria-atomic": true,
};

/**
* Props for the message composer textarea.
* Pair with an element whose id is "chat-composer-hint".
*/
export function composerProps(channelName: string): {
id: string;
"aria-label": string;
"aria-describedby": string;
} {
return {
id: "chat-composer",
"aria-label": `Message #${channelName}`,
"aria-describedby": "chat-composer-hint",
};
}
5 changes: 5 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ if (process.env.WAR_ROOM_ENV_FILE) loadEnvFile(process.env.WAR_ROOM_ENV_FILE);
loadEnvFile(path.join(os.homedir(), ".war-room", ".env"));

const nextConfig: NextConfig = {
// Pin the file-tracing root to this directory. Without it, Next.js detects a
// lockfile in the home directory and treats ~/ as the monorepo root, so the
// standalone server.js lands at standalone/<nested>/server.js instead of
// standalone/server.js and the Electron main.js path lookup breaks.
outputFileTracingRoot: __dirname,
// Standalone output bundles a self-contained server.js + a minimal node_modules
// tree. This is what the packaged Electron app launches as the API server.
output: "standalone",
Expand Down
Loading