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
8 changes: 6 additions & 2 deletions .github/workflows/release-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
VITE_MESH_HOMESERVER: ${{ vars.VITE_MESH_HOMESERVER }}
VITE_MESH_HOMESERVER: https://matrix.mesh.dhawal.org
VITE_MESH_SERVICE_NAME: ${{ vars.VITE_MESH_SERVICE_NAME }}
MESH_MANAGED_HOMESERVER: https://matrix.mesh.dhawal.org
MESH_MANAGED_SERVER_NAME: mesh.dhawal.org
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

Expand Down Expand Up @@ -87,8 +89,10 @@ jobs:
timeout-minutes: 90
environment: matrix-beta
env:
VITE_MESH_HOMESERVER: ${{ vars.VITE_MESH_HOMESERVER }}
VITE_MESH_HOMESERVER: https://matrix.mesh.dhawal.org
VITE_MESH_SERVICE_NAME: ${{ vars.VITE_MESH_SERVICE_NAME }}
MESH_MANAGED_HOMESERVER: https://matrix.mesh.dhawal.org
MESH_MANAGED_SERVER_NAME: mesh.dhawal.org
permissions:
contents: write
steps:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ e2e-results/
# Internal documents, prompts, chats, and design artifacts
*.md
*.mdx
!AGENTS.md
!mesh/infra/homeserver/README.md
*.doc
*.docx
*.pages
Expand Down Expand Up @@ -78,6 +80,7 @@ claudereview/

# Generated local integration-test state
**/infra/matrix-spike/runtime/
**/infra/homeserver/runtime/

# Local configuration and secrets
.env
Expand Down
56 changes: 56 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Mesh Product North Star

Mesh must feel like a regular consumer communication app, even though it is
built on decentralized infrastructure. A normal user should be able to:

1. install Mesh;
2. open an invitation;
3. create or sign in to an account; and
4. enter the community.

That path must not require the user to understand or configure Matrix,
homeservers, federation, DNS, TLS, ports, relays, TURN, Synapse, or storage
replication.

## Non-negotiable UX rules

- Ship safe, working defaults for the recommended Mesh service in release
builds.
- Invitation links must carry or resolve everything needed to reach the
service and community.
- Use plain product language such as "service", "community", and "voice";
reserve protocol terminology for diagnostics and advanced settings.
- Keep "sign in somewhere else" and custom homeserver support available as an
advanced path so decentralization does not become lock-in.
- Detect service capabilities and network conditions automatically. Do not ask
users to make infrastructure decisions that Mesh can make safely.
- Errors must explain what the user can do next without exposing raw protocol
failures as the primary message.
- Never silently consume a user's disk, bandwidth, or battery for shared
storage. Contribution must be explicit, bounded, encrypted, observable, and
reversible.
- Treat extra setup steps in the default onboarding path as product defects
unless they are required for account security or informed consent.

## Architecture guardrails

- The managed Mesh homeserver is the zero-configuration default, not the only
compatible service.
- Keep identity, membership, permissions, text history, encryption state, and
synchronization on the Matrix-compatible control plane.
- Treat peer-assisted storage as an optional encrypted data plane with durable
anchors, replication targets, integrity verification, repair, quotas, and
garbage collection.
- Preserve standard Matrix interoperability wherever possible so users and
communities can bring another compatible homeserver.
- Infrastructure choices must be migration-ready: stable identity domains,
backed-up signing keys, portable databases/media, and configuration separate
from secrets.

## Product references

Cinny is a useful reference for its calm interface, familiar
community/channel organization, and configuration-driven homeserver defaults.
Mesh should learn from that simplicity while hiding more infrastructure from
the recommended user path. Do not copy Cinny branding or code without an
explicit compatibility and licensing review.
124 changes: 119 additions & 5 deletions mesh/e2e/authenticated-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,14 @@ async function installAuthenticatedMatrixMock(page: Page): Promise<void> {
}
case 'matrix_typing_users':
return []
case 'matrix_room_pins':
return {
roomId: String(args.roomId),
eventIds: [],
messages: [],
unavailableEventIds: [],
canManage: true,
}
case 'matrix_mark_read':
case 'matrix_set_typing':
case 'matrix_save_composer_draft':
Expand Down Expand Up @@ -423,14 +431,38 @@ test.describe('authenticated desktop shell', () => {

const context = page.getByRole('complementary', { name: 'Room context for general' })
await expect(context).toBeVisible()
await expect(context.getByRole('tab', { name: 'Ledger' })).toHaveAttribute('aria-selected', 'true')
const peopleTab = context.getByRole('tab', { name: 'People' })
const ledgerTab = context.getByRole('tab', { name: 'Ledger' })
const filesTab = context.getByRole('tab', { name: 'Files' })
const pinsTab = context.getByRole('tab', { name: 'Pins' })
await expect(ledgerTab).toHaveAttribute('aria-selected', 'true')
await expect(ledgerTab).toHaveAttribute('tabindex', '0')
await expect(peopleTab).toHaveAttribute('tabindex', '-1')
await expect(filesTab).toHaveAttribute('tabindex', '-1')
await expect(pinsTab).toHaveAttribute('tabindex', '-1')
await expect(context.getByText('Protected end to end')).toBeVisible()
await expect(context.getByText('1 need review')).toBeVisible()
await expect(context.getByText('Ready', { exact: true })).toBeVisible()

await context.getByRole('tab', { name: 'Pins' }).click()
await ledgerTab.focus()
await page.keyboard.press('ArrowRight')
await expect(filesTab).toBeFocused()
await expect(filesTab).toHaveAttribute('aria-selected', 'true')
await expect(context.getByRole('tabpanel')).toHaveAttribute(
'aria-labelledby',
await filesTab.getAttribute('id') ?? '',
)
await page.keyboard.press('End')
await expect(pinsTab).toBeFocused()
await expect(pinsTab).toHaveAttribute('aria-selected', 'true')
await expect(context.getByText('Nothing pinned yet')).toBeVisible()
await context.getByRole('tab', { name: 'Files' }).click()
await page.keyboard.press('Home')
await expect(peopleTab).toBeFocused()
await expect(peopleTab).toHaveAttribute('aria-selected', 'true')
await page.keyboard.press('ArrowLeft')
await expect(pinsTab).toBeFocused()
await page.keyboard.press('ArrowLeft')
await expect(filesTab).toBeFocused()
await expect(context.getByText('No files shared yet')).toBeVisible()

await context.getByRole('button', { name: 'Close room context' }).click()
Expand Down Expand Up @@ -473,6 +505,27 @@ test.describe('authenticated desktop shell', () => {
})
})

test('hands Security focus back to the persistent User Settings trigger', async ({ page }) => {
await openAuthenticatedShell(page)

const settingsButton = page.getByRole('button', { name: 'User settings' })
await settingsButton.click()
const settingsDialog = page.getByRole('dialog', { name: 'User Settings' })
await expect(settingsDialog).toBeVisible()

await settingsDialog.getByRole('button', { name: 'Open your devices' }).click()
const securityDialog = page.getByRole('dialog', { name: 'Your devices' })
await expect(settingsDialog).toHaveCount(0)
await expect(securityDialog).toBeVisible()
await expect(page.getByRole('dialog')).toHaveCount(1)

await page.keyboard.press('Escape')

await expect(securityDialog).toHaveCount(0)
await expect(settingsButton).toBeFocused()
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false)
})

test('opens community management as an accessible sheet and restores focus', async ({ page }) => {
await openAuthenticatedShell(page)

Expand Down Expand Up @@ -567,16 +620,42 @@ test.describe('authenticated desktop shell', () => {
})

test.describe('authenticated narrow shell', () => {
test.use({ viewport: { width: 390, height: 844 } })
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true })

test('@a11y has no automated WCAG A/AA violations with navigation open', async ({ page }) => {
await openAuthenticatedShell(page)
await page.getByRole('button', { name: 'Open room navigation' }).click()
await expect(page.getByRole('button', { name: 'Close room navigation' })).toBeVisible()
const navigationDrawer = page.locator('#mesh-context-sidebar')
await expect(navigationDrawer).toHaveAttribute('aria-modal', 'true')
expect(await navigationDrawer.evaluate((drawer) => drawer.contains(document.activeElement))).toBe(true)
for (let index = 0; index < 10; index += 1) await page.keyboard.press('Tab')
expect(await navigationDrawer.evaluate((drawer) => drawer.contains(document.activeElement))).toBe(true)

await expectNoWcagViolations(page, 'Authenticated narrow shell with room navigation')
})

test('closes room navigation with Escape and restores its trigger', async ({ page }) => {
await openAuthenticatedShell(page)
const drawerButton = page.getByRole('button', { name: 'Open room navigation' })
await drawerButton.click()
await expect(page.locator('#mesh-context-sidebar')).toHaveAttribute('data-open', 'true')

await page.keyboard.press('Escape')

await expect(page.locator('#mesh-context-sidebar')).toHaveAttribute('data-open', 'false')
await expect(drawerButton).toBeFocused()
})

test('keeps message actions reachable on touch-only layouts', async ({ page }) => {
await openAuthenticatedShell(page)
const actions = page.locator('.mesh-message-actions').first()

await expect(actions).toHaveCSS('opacity', '1')
await expect(actions).toHaveCSS('pointer-events', 'auto')
await expect(actions.getByRole('button', { name: /React to message/ })).toBeVisible()
})

test('opens the room drawer, changes rooms, and sends a message without overflow', async ({ page }) => {
await openAuthenticatedShell(page)

Expand Down Expand Up @@ -617,7 +696,20 @@ test.describe('authenticated narrow shell', () => {
const context = page.getByRole('complementary', { name: 'Room context for general' })
await expect(context).toBeVisible()
await expect(page.locator('.mesh-room-context-backdrop')).toBeVisible()
await context.getByRole('tab', { name: 'Ledger' }).click()
expect(await context.evaluate((panel) => panel.contains(document.activeElement))).toBe(true)
for (let index = 0; index < 8; index += 1) await page.keyboard.press('Tab')
expect(await context.evaluate((panel) => panel.contains(document.activeElement))).toBe(true)
const ledgerTab = context.getByRole('tab', { name: 'Ledger' })
const peopleTab = context.getByRole('tab', { name: 'People' })
const pinsTab = context.getByRole('tab', { name: 'Pins' })
await ledgerTab.focus()
await page.keyboard.press('ArrowLeft')
await expect(peopleTab).toBeFocused()
await page.keyboard.press('ArrowLeft')
await expect(pinsTab).toBeFocused()
await page.keyboard.press('Home')
await expect(peopleTab).toBeFocused()
await ledgerTab.click()
await expect(context.getByText('Protected end to end')).toBeVisible()
await expectNoWcagViolations(page, 'Authenticated narrow room context drawer')

Expand All @@ -643,6 +735,28 @@ test.describe('authenticated narrow shell', () => {
await expect(dialog).toHaveCount(0)
})

test('hands narrow Security focus back to the persistent User Settings trigger', async ({ page }) => {
await openAuthenticatedShell(page)

await page.getByRole('button', { name: 'Open room navigation' }).click()
const settingsButton = page.getByRole('button', { name: 'User settings' })
await settingsButton.click()
const settingsDialog = page.getByRole('dialog', { name: 'User Settings' })
await expect(settingsDialog).toBeVisible()

await settingsDialog.getByRole('button', { name: 'Open your devices' }).click()
const securityDialog = page.getByRole('dialog', { name: 'Your devices' })
await expect(settingsDialog).toHaveCount(0)
await expect(securityDialog).toBeVisible()
await expect(page.getByRole('dialog')).toHaveCount(1)

await page.keyboard.press('Escape')

await expect(securityDialog).toHaveCount(0)
await expect(settingsButton).toBeFocused()
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false)
})

test('fits community management to the viewport and restores its drawer trigger', async ({ page }) => {
await openAuthenticatedShell(page)

Expand Down
57 changes: 57 additions & 0 deletions mesh/e2e/design-system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import { expectNoWcagViolations } from './helpers/accessibility'

const runtimeErrors = new WeakMap<Page, string[]>()

function relativeLuminance(hex: string): number {
const channels = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16) / 255)
const [red, green, blue] = channels.map((channel) =>
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
)
return (0.2126 * red) + (0.7152 * green) + (0.0722 * blue)
}

function contrastRatio(foreground: string, background: string): number {
const foregroundLuminance = relativeLuminance(foreground)
const backgroundLuminance = relativeLuminance(background)
return (
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05)
/ (Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
)
}

async function sampleAmbientMotion(page: Page): Promise<string[]> {
const probe = page.locator('[data-ambient-motion-probe]')
await expect(probe).toBeAttached()
Expand Down Expand Up @@ -56,6 +73,46 @@ test('renders every supported theme and exposes keyboard-operable primitives', a
await expect(page.getByRole('tabpanel').getByText('Detail content')).toBeVisible()
})

test('keeps semantic foregrounds and focus indicators contrast-safe in light theme', async ({ page }) => {
const lightTheme = page.locator('section[data-theme="light"]')
const tokens = await lightTheme.evaluate((element) => {
const styles = getComputedStyle(element)
const value = (name: string) => styles.getPropertyValue(name).trim()
return {
canvas: value('--surface-canvas'),
accent: value('--content-accent'),
link: value('--content-link'),
focus: value('--border-focus'),
success: value('--status-success'),
warning: value('--status-warning'),
avatar: value('--avatar-sand'),
onAvatar: value('--content-on-avatar'),
}
})

expect(contrastRatio(tokens.accent, tokens.canvas)).toBeGreaterThanOrEqual(4.5)
expect(contrastRatio(tokens.link, tokens.canvas)).toBeGreaterThanOrEqual(4.5)
expect(contrastRatio(tokens.success, tokens.canvas)).toBeGreaterThanOrEqual(4.5)
expect(contrastRatio(tokens.warning, tokens.canvas)).toBeGreaterThanOrEqual(4.5)
expect(contrastRatio(tokens.focus, tokens.canvas)).toBeGreaterThanOrEqual(3)
expect(contrastRatio(tokens.onAvatar, tokens.avatar)).toBeGreaterThanOrEqual(4.5)
})

test('renders the command palette as one compact surface', async ({ page }) => {
await page.getByRole('button', { name: /Commands/ }).click()

const dialog = page.getByRole('dialog', { name: 'Command palette' })
const input = dialog.getByRole('combobox', { name: 'Command palette' })
const listbox = dialog.getByRole('listbox')
await expect(dialog).toBeVisible()
await expect(input).toBeFocused()
await expect(listbox).toBeVisible()
await expect(dialog).toHaveCSS('padding', '0px')
await expect(input).toHaveCSS('border-top-width', '0px')
await expect(listbox).toHaveCSS('position', 'static')
await expect(listbox).toHaveCSS('box-shadow', 'none')
})

test('runs ambient motion when the operating system allows it', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'no-preference' })
await page.reload()
Expand Down
9 changes: 8 additions & 1 deletion mesh/e2e/matrix-messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,15 @@ test.describe('Matrix direct messaging and encrypted attachments', () => {
await expect(page.getByText('Message backup')).toBeVisible()

await page.getByRole('button', { name: 'Review devices and backup' }).click()
await expect(page.getByRole('dialog', { name: 'Your devices' })).toBeVisible()
const securityDialog = page.getByRole('dialog', { name: 'Your devices' })
await expect(securityDialog).toBeVisible()
await expect(page.getByRole('dialog')).toHaveCount(1)
await expect(page.getByText('Message backup is ready')).toBeVisible()

await page.keyboard.press('Escape')
await expect(securityDialog).toHaveCount(0)
await expect(trustSummary).toBeFocused()
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false)
})

test('sends DM text through the dedicated Matrix direct-message command', async ({ page }) => {
Expand Down
8 changes: 8 additions & 0 deletions mesh/e2e/message-keyboard-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ async function installKeyboardActionsMock(page: Page): Promise<void> {
}
case 'matrix_typing_users':
return []
case 'matrix_room_pins':
return {
roomId: String(args.roomId),
eventIds: [],
messages: [],
unavailableEventIds: [],
canManage: true,
}
case 'matrix_mark_read':
case 'matrix_set_typing':
case 'matrix_save_composer_draft':
Expand Down
Loading
Loading