From e1f4cb3b2d246af1e05bfe00d29a74742024d536 Mon Sep 17 00:00:00 2001
From: oliver <54379826+ok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:21:20 +0200
Subject: [PATCH 1/4] [test] Give the fields harness its keyboard provider
ListControlsRow registers its Cmd+F command since the filter shortcut
landed, so the fields layout case threw before measuring anything.
---
test/frontend-layout/harness-fields-entry.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/test/frontend-layout/harness-fields-entry.tsx b/test/frontend-layout/harness-fields-entry.tsx
index 46cecc91..52dbccd5 100644
--- a/test/frontend-layout/harness-fields-entry.tsx
+++ b/test/frontend-layout/harness-fields-entry.tsx
@@ -88,7 +88,9 @@ createRoot(document.getElementById('root') as HTMLElement).render(
-
+
+
+
From fcfb24f3bfe2f3e0d442dd744c5c3ce80968b878 Mon Sep 17 00:00:00 2001
From: oliver <54379826+ok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:21:25 +0200
Subject: [PATCH 2/4] [fix] Raise the copy icon to 3:1 in dark mode
The icon was painted in the outline token, 2.1:1 on a field and 2.5:1
on a card in dark mode, below the 3:1 a graphic needs. It now uses the
muted text token, which clears it in both themes.
---
src/renderer/components/primitives/CopyButton.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/components/primitives/CopyButton.tsx b/src/renderer/components/primitives/CopyButton.tsx
index 45946664..9dd242d5 100644
--- a/src/renderer/components/primitives/CopyButton.tsx
+++ b/src/renderer/components/primitives/CopyButton.tsx
@@ -36,7 +36,7 @@ export default function CopyButton({ value, className = '', showLabel = false }:
title={label}
className={`shrink-0 inline-flex items-center justify-center rounded-sm focus-ring ${className}`}
>
-
+
)}
{copied ? t('actions.copied') : ''}
From fd4fcada3e0ef7da7a9b5a4a07d6536ceebc2769 Mon Sep 17 00:00:00 2001
From: oliver <54379826+ok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:21:25 +0200
Subject: [PATCH 3/4] [docs] Note that set-value leaves focus where it was
A key pressed after an AX set-value lands on whatever held focus, so
a scenario clicks a field before typing into it.
---
.claude/lessons.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.claude/lessons.md b/.claude/lessons.md
index 0c4f2708..2410f69e 100644
--- a/.claude/lessons.md
+++ b/.claude/lessons.md
@@ -342,7 +342,8 @@ app is frontmost; `press` needs an AX-focused element the inactive app does not
cannot tell dev instances apart (all are "Electron"). Keys go through the main-process channel
(`sendInputEvent`, menu accelerators via their menu item). `sendInputEvent` only queues, so wait a
rendered frame before the next step, and give Return/Space their `char` event — a focused button
-activates on keypress, not keyDown.
+activates on keypress, not keyDown. `set-value` does not move DOM focus either: a key pressed after
+it lands on whatever held focus (a dialog's panel), so `click` the field before typing into it.
**An inactive Electron window still activates the app on macOS 27 unless AppKit's state restorer
is off.** The restorer runs at every launch even with no saved state and, when it finishes after
From 82bc7e83e71d5989fddd5c4800620c023423b8dc Mon Sep 17 00:00:00 2001
From: oliver <54379826+ok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:21:29 +0200
Subject: [PATCH 4/4] [feat] Paste, type and copy folder paths
Folder fields showed the path as text, so a path could neither be
pasted in nor copied out. Each is now an input: a typed path is taken
on Enter or blur and checked as a picked one is; a fixed path is
read-only with Copy.
---
.claude/design.md | 49 +-
.claude/mockups/path-field-input.html | 595 ++++++++++++++++++
CHANGELOG.md | 3 +
eslint-rules/invariants.mjs | 10 +-
src/main/backup-settings.js | 23 +-
src/preload/preload.js | 1 +
.../components/layout/SectionHeading.tsx | 6 +-
.../components/modals/AddFolderShareModal.tsx | 8 +-
.../components/modals/BackupSetupModal.tsx | 34 +-
.../components/modals/EditFolderModal.tsx | 22 +-
.../components/modals/EditSpaceModal.tsx | 13 +-
.../components/modals/MirrorFolderModal.tsx | 3 +-
.../components/modals/RestoreBackupModal.tsx | 28 +-
.../components/path/MountPathField.tsx | 9 +-
src/renderer/components/path/PathRow.tsx | 139 +++-
src/renderer/locales/de/common.json | 2 +-
src/renderer/locales/en/common.json | 2 +-
src/renderer/locales/es/common.json | 2 +-
src/renderer/locales/fr/common.json | 2 +-
src/renderer/locales/it/common.json | 2 +-
src/renderer/platform/global.d.ts | 11 +-
.../screens/settings/BackupSettings.tsx | 34 +-
.../screens/settings/StorageSettings.tsx | 32 +-
src/renderer/styles/tailwind.css | 9 +-
test/frontend-layout/README.md | 4 +-
test/frontend-layout/harness-fields-entry.tsx | 14 +-
.../harness-truncation-entry.tsx | 52 +-
test/frontend-layout/run-truncation.mjs | 2 +-
test/frontend/scenarios/s160-backup-setup.mjs | 18 +
.../s194-managed-backup-required.mjs | 8 +-
test/invariants/path-field-harmonized.test.js | 36 +-
test/unit/backup-settings.test.js | 40 +-
32 files changed, 1043 insertions(+), 170 deletions(-)
create mode 100644 .claude/mockups/path-field-input.html
diff --git a/.claude/design.md b/.claude/design.md
index 6a2b9725..99100de7 100644
--- a/.claude/design.md
+++ b/.claude/design.md
@@ -35,7 +35,8 @@ them to `var()`-backed utility colors, and a `.dark` class on `` swaps the
- **Soft, heavy rounding.** The UI reads as stacked rounded cards; corner radii climb with element
size (§5).
- **App, not webpage.** Text is not user-selectable; copy is offered through an explicit
- `` (`src/renderer/components/primitives/CopyButton.tsx`).
+ `` (`src/renderer/components/primitives/CopyButton.tsx`), or by a read-only field
+ where the value is one (a fixed path).
- **Accessibility is a gate, not a finish.** Every interactive control carries a name/role/state;
focus is always visible; motion respects the OS setting. See `.claude/testing.md`.
- **Desktop-first, fixed-width.** Almost no responsive breakpoints — this is a windowed Electron
@@ -665,8 +666,8 @@ places at the right end (the filter's count and clear button).
### Field label — `primitives/FieldLabel.tsx`
`font-headline text-sm font-bold text-accent px-1`, and the element is the point: `htmlFor` renders
-a `
- {/* Same row as Add Folder and Mirror to Disk — a path is a path, whichever door you
- came through. Display-only when the location is fixed: the field stays, the button
- is what says whether you can re-point it. */}
-
+ {canRelocate ? (
+
+ ) : (
+
+ )}
{pathError && {pathError}}
diff --git a/src/renderer/components/modals/EditSpaceModal.tsx b/src/renderer/components/modals/EditSpaceModal.tsx
index 47027c24..4a9c77c3 100644
--- a/src/renderer/components/modals/EditSpaceModal.tsx
+++ b/src/renderer/components/modals/EditSpaceModal.tsx
@@ -50,6 +50,11 @@ export default function EditSpaceModal({ space, onSave, onClose }: EditSpaceModa
if (picked) setFolderEdit(picked)
}, [folder])
+ function handleTyped(typed: string) {
+ setFolderError(null)
+ setFolderEdit(typed)
+ }
+
async function handleSave() {
if (!isValid || !hasChanges || saving) return
setSaving(true)
@@ -97,16 +102,16 @@ export default function EditSpaceModal({ space, onSave, onClose }: EditSpaceModa
- {/* The same row Add Folder, Mirror to Disk, Edit Folder and Storage settings show — a
- path is a path, whichever door you came through. */}
{folderError && (
{folderError}
diff --git a/src/renderer/components/modals/MirrorFolderModal.tsx b/src/renderer/components/modals/MirrorFolderModal.tsx
index 735b3d98..32a19df9 100644
--- a/src/renderer/components/modals/MirrorFolderModal.tsx
+++ b/src/renderer/components/modals/MirrorFolderModal.tsx
@@ -136,11 +136,12 @@ export default function MirrorFolderModal({
)}
{ void wizard.browse() }}
+ onCommit={wizard.setMountPath}
/>
diff --git a/src/renderer/components/modals/RestoreBackupModal.tsx b/src/renderer/components/modals/RestoreBackupModal.tsx
index 86814372..97644f8c 100644
--- a/src/renderer/components/modals/RestoreBackupModal.tsx
+++ b/src/renderer/components/modals/RestoreBackupModal.tsx
@@ -68,14 +68,15 @@ export default function RestoreBackupModal({ isOpen, onClose, onRestored, locked
onClose()
}
- async function chooseFolder() {
+ // Picked or typed, the peek says whether the folder holds a backup.
+ async function openFolder(pick: () => Promise) {
setError(null)
try {
- const picked = await window.bridge.browseBackupFolder()
- if (!picked) return
- setFolder(picked.folder)
+ const chosen = await pick()
+ if (!chosen) return
+ setFolder(chosen)
setPeek(null)
- setPeek(await request('backup:peek', { folder: picked.folder }))
+ setPeek(await request('backup:peek', { folder: chosen }))
} catch (err) {
setError(errorText(err))
}
@@ -168,7 +169,7 @@ export default function RestoreBackupModal({ isOpen, onClose, onRestored, locked
passphrase={passphrase}
fieldError={fieldError}
busy={busy}
- onChooseFolder={() => void chooseFolder()}
+ onOpenFolder={(pick) => void openFolder(pick)}
onPassphrase={(v) => { setPassphrase(v); setFieldError(null) }}
/>
) : (
@@ -242,27 +243,34 @@ function SnapshotChoice({ snapshots, chosen, onChoose }: SnapshotChoiceProps) {
)
}
+async function pickBackupFolder() {
+ return (await window.bridge.browseBackupFolder())?.folder ?? null
+}
+
interface SourceFieldsProps {
folder: string | null
peek: BackupPeek | null
passphrase: string
fieldError: string | null
busy: boolean
- onChooseFolder: () => void
+ /** Opens the folder `pick` answers: the picker's choice, or a typed path. */
+ onOpenFolder: (pick: () => Promise) => void
onPassphrase: (value: string) => void
}
// Where the backup is and what opens it. Nothing here can change while the dialog works on it.
-function SourceFields({ folder, peek, passphrase, fieldError, busy, onChooseFolder, onPassphrase }: SourceFieldsProps) {
+function SourceFields({ folder, peek, passphrase, fieldError, busy, onOpenFolder, onPassphrase }: SourceFieldsProps) {
const { t } = useTranslation()
const keyInFolder = !!peek?.keyCreatedAt
return (
<>
- {t('restoreBackup.folderLabel')}
+ {t('restoreBackup.folderLabel')} onOpenFolder(pickBackupFolder)}
+ onCommit={(typed) => onOpenFolder(async () => typed)}
subject={t('backup.folderSubject')}
actionDisabled={busy}
/>
diff --git a/src/renderer/components/path/MountPathField.tsx b/src/renderer/components/path/MountPathField.tsx
index c28866dd..baddba20 100644
--- a/src/renderer/components/path/MountPathField.tsx
+++ b/src/renderer/components/path/MountPathField.tsx
@@ -8,15 +8,14 @@ interface MountPathFieldProps {
path: string
error: string | null
onBrowse: () => void
+ onCommit: (path: string) => void
}
-// The label is a carrying an id rather than a : PathRow's action is a button, not a
-// form control, so the association goes through aria-describedby.
-export default function MountPathField({ id, label, path, error, onBrowse }: MountPathFieldProps) {
+export default function MountPathField({ id, label, path, error, onBrowse, onCommit }: MountPathFieldProps) {
return (
- {label}
-
+ {label}
+
{error && {error}}
)
diff --git a/src/renderer/components/path/PathRow.tsx b/src/renderer/components/path/PathRow.tsx
index 7741c077..ef339241 100644
--- a/src/renderer/components/path/PathRow.tsx
+++ b/src/renderer/components/path/PathRow.tsx
@@ -1,9 +1,12 @@
-import type { Ref } from 'react'
+import { useLayoutEffect, useRef, useState, type KeyboardEvent, type Ref } from 'react'
import { useTranslation } from 'react-i18next'
-import FilePath from './FilePath.js'
+import CopyButton from '../primitives/CopyButton.js'
import { FIELD_FILL, useFieldFill } from '../primitives/FieldGround.js'
+import { FIELD_PLACEHOLDER } from '../primitives/TextField.js'
-interface PathRowProps {
+interface PathRowBase {
+ /** The input's id: the field's `` points here. */
+ id: string
path: string | null
/**
* A path exists but its text has not arrived yet. Distinct from having none: the field says so
@@ -11,34 +14,132 @@ interface PathRowProps {
* not flip once the read lands.
*/
loading?: boolean
- /** Omit for a display-only row — the field keeps its shape, the button is absent. */
- onAction?: () => void
+ /** For a field named by a heading rather than a ``. */
+ ariaLabelledBy?: string
ariaDescribedBy?: string
+}
+
+interface EditablePath {
+ /** Opens the folder picker. */
+ onAction: () => void
+ /** Takes a typed or pasted path; the caller checks it exactly as it checks a picked one. */
+ onCommit: (path: string) => void
/** What the path is, when one page shows several rows: the button's name says it after its label. */
subject?: string
- /** The button stays focusable and announced, but does nothing while the owner is busy. */
+ /** The field is read-only and the button does nothing while the owner is busy; both stay focusable. */
actionDisabled?: boolean
/** Forwarded to the button, for callers that must hand focus back to it. */
actionRef?: Ref
}
-// One filesystem path, presented the same way everywhere: the path in a filled field, with an
-// optional button beside it that re-picks it. Display-only keeps the field — a path is a value
-// either way; the button's presence says whether you can change it. The label follows the state,
-// not the caller: nothing picked is "Browse…", a path (or one still loading) is "Change". The
-// placeholder is muted TEXT, not `outline`: that token fails AA against every fill in both themes.
-export default function PathRow({ path, loading = false, onAction, ariaDescribedBy, subject, actionDisabled = false, actionRef }: PathRowProps) {
+interface FixedPath {
+ onAction?: never
+ onCommit?: never
+ subject?: never
+ actionDisabled?: never
+ actionRef?: never
+}
+
+type PathRowProps = PathRowBase & (EditablePath | FixedPath)
+
+// One filesystem path, presented the same way everywhere: a filled text field. An editable path
+// has a button beside it that re-picks it, and takes a typed or pasted path on Enter or blur — never
+// per keystroke, since every take runs a filesystem check or applies a setting. While an edit is
+// pending, Enter takes it and Escape reverts it instead of confirming or dismissing the dialog. A
+// fixed path is a read-only field with Copy at its end; the missing button is what says it cannot
+// change. The button label follows the state, not the caller: nothing picked is "Browse…", a path
+// (or one still loading) is "Change". A field without focus shows the end of its path, so the
+// folder's own name stays visible.
+export default function PathRow(props: PathRowProps) {
+ const { id, path, loading = false, ariaLabelledBy, ariaDescribedBy, onAction, onCommit, subject, actionDisabled = false, actionRef } = props
const { t } = useTranslation()
const fill = useFieldFill()
+ const inputRef = useRef(null)
+ const shown = path ?? ''
+ const [draft, setDraft] = useState(shown)
+ const [synced, setSynced] = useState(shown)
+ // The last path handed to onCommit, so the blur after an Enter does not take it again. Enter itself
+ // always takes: pressing it again is how a refused path is retried.
+ const taken = useRef(null)
+ if (synced !== shown) {
+ setSynced(shown)
+ setDraft(shown)
+ taken.current = null
+ }
+ const editable = onCommit !== undefined
+ const readOnly = !editable || actionDisabled || loading
+
+ // Shown from its end whenever the text or the field's width changes, or the font lands — each moves
+ // where the end is. A focused field is the user's to scroll.
+ useLayoutEffect(() => {
+ const input = inputRef.current
+ if (!input) return
+ const showEnd = () => {
+ if (document.activeElement !== input) input.scrollLeft = input.scrollWidth
+ }
+ showEnd()
+ const observer = new ResizeObserver(showEnd)
+ observer.observe(input)
+ document.fonts.addEventListener('loadingdone', showEnd)
+ return () => {
+ observer.disconnect()
+ document.fonts.removeEventListener('loadingdone', showEnd)
+ }
+ }, [shown])
+
+ function take(again: boolean) {
+ const next = draft.trim()
+ if (!onCommit || readOnly) return
+ if (!next || next === shown) {
+ setDraft(shown)
+ return
+ }
+ if (!again && next === taken.current) return
+ taken.current = next
+ onCommit(next)
+ }
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (draft === shown) return
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ e.stopPropagation()
+ take(true)
+ } else if (e.key === 'Escape') {
+ e.stopPropagation()
+ setDraft(shown)
+ taken.current = null
+ }
+ }
+
+ function handleBlur() {
+ take(false)
+ const input = inputRef.current
+ if (input) input.scrollLeft = input.scrollWidth
+ }
+
return (
-
+ {locked ? (
+
+ ) : (
+
+ )}
{locked && managed.org && }
{offline ? (
diff --git a/src/renderer/screens/settings/StorageSettings.tsx b/src/renderer/screens/settings/StorageSettings.tsx
index f663be58..dae39199 100644
--- a/src/renderer/screens/settings/StorageSettings.tsx
+++ b/src/renderer/screens/settings/StorageSettings.tsx
@@ -57,21 +57,30 @@ export default function StorageSettings({ onBack, onOpenActivityLogSettings, onO
const downloadFolder = settings?.downloads.folder ?? (folderReadError ? '' : null)
const shownError = folderError ?? (folderReadError ? errorText(folderReadError) : null)
+ // A picked and a typed folder take the same path: the worker validates and persists in one step,
+ // since only it can see the owned/mirrored folders a download folder must not overlap, and its
+ // reply pokes every window's settings query.
+ const applyFolder = useCallback(async (folder: string) => {
+ await request('settings:set-download-folder', { folder })
+ // The worker only accepts a folder that validated, so the warning below is stale the moment
+ // this resolves — re-probe rather than leaving it up until the next 60s tick.
+ await refreshRootStatus()
+ }, [refreshRootStatus])
+
const handleBrowseFolder = useCallback(async () => {
setFolderError(null)
try {
const picked = await window.bridge.browseDownloadFolder(downloadFolder || undefined)
- if (!picked) return
- // The worker validates and persists in one step: only it can see the owned/mirrored folders
- // a download folder must not overlap, and its reply pokes every window's settings query.
- await request('settings:set-download-folder', { folder: picked })
- // The picker only accepts a folder that validated, so the warning below is stale the
- // moment this resolves — re-probe rather than leaving it up until the next 60s tick.
- await refreshRootStatus()
+ if (picked) await applyFolder(picked)
} catch (err) {
setFolderError(errorText(err))
}
- }, [refreshRootStatus, errorText, downloadFolder])
+ }, [applyFolder, errorText, downloadFolder])
+
+ const handleTypedFolder = useCallback((typed: string) => {
+ setFolderError(null)
+ applyFolder(typed).catch((err: unknown) => setFolderError(errorText(err)))
+ }, [applyFolder, errorText])
// This screen shows the folder new spaces start in; `unavailableRoots` also carries every space's
// folder and the recent ones, so match rather than test for a non-empty list. The folder and the unavailable roots are both
@@ -101,15 +110,16 @@ export default function StorageSettings({ onBack, onOpenActivityLogSettings, onO
) : info && (
- {/* The same row Add Folder, Mirror to Disk, Edit Folder and Edit Space show — a path
- is a path, whichever screen you are on. */}
{/* A rejected pick leaves BOTH true — the old folder is still unavailable and the
diff --git a/src/renderer/styles/tailwind.css b/src/renderer/styles/tailwind.css
index 2373c4c0..dc053daf 100644
--- a/src/renderer/styles/tailwind.css
+++ b/src/renderer/styles/tailwind.css
@@ -197,11 +197,12 @@ body {
box-shadow: inset 1px 0 0 color-mix(in srgb, var(--color-on-primary) 28%, transparent);
}
-/* Mirall is an app, not a webpage — text in labels, headings, paths, names,
- * and metadata is not user-selectable. Where copy is genuinely useful (e.g.
- * the storage path or the version string for support), the UI surfaces an
+/* Mirall is an app, not a webpage — text in labels, headings, names and
+ * metadata is not user-selectable. Where copy is genuinely useful (e.g. the
+ * storage path or the version string for support), the UI surfaces an
* explicit copy affordance via instead. Inputs and textareas
- * keep selection so users can edit what they typed. */
+ * keep selection, which is why a folder path is a field (read-only where it
+ * is fixed) rather than text. */
html,
body,
#root {
diff --git a/test/frontend-layout/README.md b/test/frontend-layout/README.md
index ac71b25b..98958250 100644
--- a/test/frontend-layout/README.md
+++ b/test/frontend-layout/README.md
@@ -13,7 +13,7 @@ components run unmodified. `harness-bootstrap.ts` gives every harness the query
| `test:layout:case -- denyapproved` | `run-denyapproved.mjs` | `` with a pending request a co-member already approved | Deny disables in flight; the already-approved outcome is a sticky polite status toast, never an alert; a deny with nothing open is a polite notice |
| `test:layout:case -- darkshadow` | `run-darkshadow.mjs` | `` + ``, light + dark | the ambient mauve lift is there in light mode and gone in dark mode |
| `test:layout:case -- callouts` | `run-callouts.mjs` | `` in both tones beside a ``, light + dark | a note sits on the field gray, a warning on the amber container, never on the `-high` plate |
-| `test:layout:case -- fields` | `run-fields.mjs` | `` on a modal panel and a card, ``, ``, ``, ``, `` waiting for a holder, ``, `` off and on, light + dark | placeholders and error text clear 4.5:1, a field stands off its card, a focused field's ring clears 3:1, a switch clears 3:1 in either state. Focus emulation on; the modal probes sit on a `glass-modal` panel in `FieldGround fill="low"`, because a real ``'s focus trap pulls focus out of every other field |
+| `test:layout:case -- fields` | `run-fields.mjs` | `` on a modal panel and a card, ``, read-only ``s on a card and a modal panel, ``, ``, `` waiting for a holder, ``, `` off and on, light + dark | placeholders and error text clear 4.5:1, a field stands off its card, a focused field's ring clears 3:1, a switch clears 3:1 in either state, a path field's Copy icon clears 3:1 on the field. Focus emulation on; the modal probes sit on a `glass-modal` panel in `FieldGround fill="low"`, because a real ``'s focus trap pulls focus out of every other field |
| `test:layout:case -- infostrip` | `run-infostrip.mjs` | `` and `` (determinate mirroring, indeterminate indexing) on the page, light + dark | each strip is painted `info-container` and clears 1.2:1 off the page; its sentence, lane percentage and button label clear 4.5:1; the lane's fill and sweep clear 3:1 on its track, and the track 1.2:1 on the strip |
| `test:layout:case -- dropoverlay` | `run-dropoverlay.mjs` | the full-bleed Drop-to-Share overlay | covers the pane edge to edge at the shipped inset |
| `test:layout:case -- actionmenu` | `run-actionmenu.mjs` | `` as the Download-to menu beside a More and a filter menu | the folders are radio items, one checked, and Choose Folder… is a plain menu item; the heading never takes focus; each item's second line is its description; ArrowDown skips a disabled item; Esc returns focus to the trigger; the existing menus render as before |
@@ -37,7 +37,7 @@ components run unmodified. `harness-bootstrap.ts` gives every harness the query
| `test:layout:case -- lanefocus` | `run-lanefocus.mjs` | a folder `` with the real `useDownloadTo`, its status changed under a keyboard user | a pick that starts the download leaves focus on Pause, a discard on Download to…, a busy row keeps it in the lane; focus moved elsewhere or let go of is never taken back |
| `test:layout:case -- focusring` | `run-focusring.mjs` | `` | every focusable control's ring is unclipped |
| `test:layout:case -- rowfocus` | `run-rowfocus.mjs` | ``, ``, ``, an `ActionRow` group | a focused grouped row is tinted, its ring is inset and follows the group's corners where it meets the edge |
-| `test:layout:case -- truncation` | `run-truncation.mjs` | ``, loose ``s (one downloading), a `` folder row with a file row inside, and a `` in narrow fields, read once fonts load | nothing overflows; a path keeps its last segment; a name keeps both ends and more than its extension (dotted folder/share names split evenly), a short one renders whole, the full name is the accessible name; a hit on the card's icon resolves to a title carrying it, but the progress bar and Browse Files keep theirs; a name's natural width is the whole name's |
+| `test:layout:case -- truncation` | `run-truncation.mjs` | ``, loose ``s (one downloading), a `` folder row with a file row inside, and a `` in narrow fields, read once fonts load | nothing overflows; a long path field without focus is scrolled to its last segment; a name keeps both ends and more than its extension (dotted folder/share names split evenly), a short one renders whole, the full name is the accessible name; a hit on the card's icon resolves to a title carrying it, but the progress bar and Browse Files keep theirs; a name's natural width is the whole name's |
| `test:layout:case -- waiting` | `run-waiting.mjs` | a loose `` mid-hash with members waiting, three row widths | the waiting cluster yields before the hash bar; nothing overflows; only a narrow row sheds the stack |
| `test:layout:case -- recipients` | `run-recipients.mjs` | a loose and a folder `` with two of three members holding the file, four row widths | the cluster sheds before the name: no overflow, a one-line toggle, faces only when wide, "2/3" when narrow |
| `test:layout:case -- segments` | `run-segments.mjs` | `` in its three shapes | the track and every segment keep their size across selections |
diff --git a/test/frontend-layout/harness-fields-entry.tsx b/test/frontend-layout/harness-fields-entry.tsx
index 52dbccd5..f3c22cd1 100644
--- a/test/frontend-layout/harness-fields-entry.tsx
+++ b/test/frontend-layout/harness-fields-entry.tsx
@@ -2,7 +2,7 @@
// placeholder clears 4.5:1 on its field, error text 4.5:1 on its ground, a field stands apart from
// the card it sits on, and a switch reads at 3:1 whether it is on or off.
//
-// Mounts the REAL on a modal panel and on a card, , , ,
+// Mounts the REAL on a modal panel and on a card, , read-only s, ,
// , , and on the grounds they ship on, and measures painted colour: every computed colour
// is composited onto a canvas over the backgrounds behind it, so an alpha utility is judged as it is
// seen. The two name fields also have to resolve their description to their help and count, which
@@ -73,6 +73,9 @@ createRoot(document.getElementById('root') as HTMLElement).render(
+
+
+
@@ -82,7 +85,7 @@ createRoot(document.getElementById('root') as HTMLElement).render(
-
+
@@ -183,7 +186,7 @@ function ringColor(el: Element): string | null {
}
async function focusRings(theme: 'light' | 'dark', failures: string[], ratios: Record): Promise {
- for (const id of ['modal-field', 'card-field', 'account-display-name', 'display-name']) {
+ for (const id of ['modal-field', 'card-field', 'account-display-name', 'display-name', 'card-path', 'modal-path']) {
const el = q(`#${id}`) as HTMLInputElement
el.focus()
await sleep(250)
@@ -226,6 +229,11 @@ function measure(theme: 'light' | 'dark', failures: string[]): ThemeResult {
need('modal field vs its panel', contrast(bg(field('modal-field')), bg(q('[data-probe="panel"]'))), 1.1)
need('card search vs its card', contrast(bg(field('card-search')), bg(q('[data-probe="card-search"]'))), 1.1)
need('card path vs its card', contrast(bg(q('[data-probe="card-path"] .rounded-xl')), bg(q('[data-probe="card-path"]'))), 1.1)
+ // The Copy icon of a read-only path is a graphic on the field's fill, not on the card behind it.
+ for (const probe of ['card-path', 'modal-path']) {
+ const input = q(`[data-probe="${probe}"] input`)
+ need(`${probe} copy icon`, contrast(over(getComputedStyle(q(`[data-probe="${probe}"] button svg`)).color, input), bg(input)), 3)
+ }
need('account name field vs its card', contrast(bg(q('#account-display-name')), bg(q('#account-display-name').parentElement as Element)), 1.1)
need('off switch outline', contrast(over(getComputedStyle(track('toggle-off')).borderTopColor, track('toggle-off')), bg(track('toggle-off'))), 3)
need('off switch knob', contrast(bg(knob('toggle-off')), bg(track('toggle-off'))), 3)
diff --git a/test/frontend-layout/harness-truncation-entry.tsx b/test/frontend-layout/harness-truncation-entry.tsx
index a66d4622..f9c704a7 100644
--- a/test/frontend-layout/harness-truncation-entry.tsx
+++ b/test/frontend-layout/harness-truncation-entry.tsx
@@ -2,7 +2,7 @@
// agent-desktop frontend suite). Mounts the REAL , loose file rows, a folder row with a file row
// inside it and a share card in deliberately narrow fields and asserts the text never escapes them.
//
-// A path keeps its final segment; a name is middle-truncated so both its beginning and the part
+// A path field shows its final segment; a name is middle-truncated so both its beginning and the part
// right before the extension stay visible, its full form is the accessible name, and hovering
// anywhere on its card (not only the name) resolves to a title carrying it. Geometry is the only honest test of fit: the text either sits inside its box or
// it does not.
@@ -36,8 +36,8 @@ interface HarnessResults {
pass: boolean
error: string | null
pathOverflow: number
- pathEndsWith: string
- pathEllipsised: boolean
+ pathScrolls: boolean
+ pathShowsEnd: boolean
file: NameResult
short: NameResult
folder: NameResult
@@ -86,7 +86,7 @@ const share: ShareWithRole = {
createRoot(document.getElementById('root') as HTMLElement).render(
-
+
@@ -123,26 +123,6 @@ createRoot(document.getElementById('root') as HTMLElement).render(
,
)
-// How far the widest rendered text run escapes its field's content box, in px. <= 0 is the
-// invariant; a positive number is text sitting on top of whatever comes next.
-function overflowOf(field: HTMLElement): number {
- const limit = field.getBoundingClientRect().right - parseFloat(getComputedStyle(field).paddingRight)
- let worst = -Infinity
- for (const span of Array.from(field.querySelectorAll('span'))) {
- // sr-only nodes are clipped to 1px and carry the full string by design.
- if (span.className.includes('sr-only')) continue
- worst = Math.max(worst, span.getBoundingClientRect().right - limit)
- }
- return Math.round(worst * 10) / 10
-}
-
-// The last visible run — proof the ending stayed pinned rather than being ellipsised away.
-function lastRun(field: HTMLElement): string {
- const spans = Array.from(field.querySelectorAll('span'))
- .filter((s) => !s.className.includes('sr-only') && s.textContent)
- return spans.at(-1)?.textContent ?? ''
-}
-
// The card's icon tile: a spot on the card that is not the name, so a title found there is the
// card's, not the name's.
function iconOf(card: Element): HTMLElement {
@@ -199,18 +179,16 @@ function laneIntrinsicShare(): number {
const fits = (r: NameResult, full: string) => r.overflow <= 0 && r.cardTitle === full && r.accessible === full
function collect(): HarnessResults {
- const pathField = document.querySelector('[data-test="path"] .bg-surface-container-low') as HTMLElement
+ const pathRow = document.querySelector('[data-test="path"]') as HTMLElement
+ const pathInput = pathRow.querySelector('input') as HTMLInputElement
const results: HarnessResults = {
error: null,
- pathOverflow: overflowOf(pathField),
- pathEndsWith: lastRun(pathField),
- // An ellipsis is actually being painted: the flexible run's content is wider than the box
- // it is allowed to occupy. (innerText is no good here — the sr-only node carries the whole
- // path by design, so it never gets shorter.)
- pathEllipsised: (() => {
- const run = pathField.querySelector('span span') as HTMLElement
- return run.scrollWidth > run.clientWidth + 1
- })(),
+ // The row, button included, stays inside its column however long the path.
+ pathOverflow: Math.max(...Array.from(pathRow.querySelectorAll('input, button')).map((el) => el.getBoundingClientRect().right)) - pathRow.getBoundingClientRect().right,
+ // The path is longer than its field, and the field without focus is scrolled to its end, so the
+ // final segment is the part on screen.
+ pathScrolls: pathInput.scrollWidth > pathInput.clientWidth + 1,
+ pathShowsEnd: pathInput.scrollLeft + pathInput.clientWidth >= pathInput.scrollWidth - 1,
file: nameIn('file', LONG_FILE, '.group'),
short: nameIn('short', SHORT_FILE, '.group'),
folder: nameIn('folder', LONG_FOLDER, 'button'),
@@ -224,8 +202,8 @@ function collect(): HarnessResults {
}
results.pass =
results.pathOverflow <= 0 &&
- results.pathEllipsised &&
- results.pathEndsWith.endsWith('(DJ Mix)') &&
+ results.pathScrolls &&
+ results.pathShowsEnd &&
fits(results.file, LONG_FILE) && middleTruncated(results.file, LONG_FILE) &&
fits(results.short, SHORT_FILE) && results.short.shown === SHORT_FILE &&
fits(results.folder, LONG_FOLDER) && middleTruncated(results.folder, LONG_FOLDER) &&
@@ -246,7 +224,7 @@ document.fonts.ready
.then((results) => { window.__results = results })
.catch((e: unknown) => {
window.__results = {
- pass: false, error: String(e), pathOverflow: 0, pathEndsWith: '', pathEllipsised: false,
+ pass: false, error: String(e), pathOverflow: 0, pathScrolls: false, pathShowsEnd: false,
file: emptyName, short: emptyName, folder: emptyName, nested: emptyName, share: emptyName,
laneTitle: '', shareActionTitle: '', laneIntrinsicShare: 0,
}
diff --git a/test/frontend-layout/run-truncation.mjs b/test/frontend-layout/run-truncation.mjs
index 58c9083a..9a1c6559 100644
--- a/test/frontend-layout/run-truncation.mjs
+++ b/test/frontend-layout/run-truncation.mjs
@@ -10,7 +10,7 @@ import { runHarness } from './run-harness.mjs'
const out = await runHarness({ html: 'harness-truncation.html', width: 600, height: 1100 })
console.log('\n──────── Text truncation harness ────────')
-console.log(`path : overflow ${out.pathOverflow}px (<= 0) · ellipsised ${out.pathEllipsised} · ends "${out.pathEndsWith}"`)
+console.log(`path : overflow ${out.pathOverflow}px (<= 0) · longer than its field ${out.pathScrolls} · scrolled to its end ${out.pathShowsEnd}`)
for (const key of ['file', 'short', 'folder', 'nested', 'share']) {
const r = out[key]
if (r) console.log(`${key.padEnd(6)}: overflow ${r.overflow}px (<= 0) · shown "${r.shown}" · card title "${r.cardTitle}"`)
diff --git a/test/frontend/scenarios/s160-backup-setup.mjs b/test/frontend/scenarios/s160-backup-setup.mjs
index 04bfd17c..f4f71850 100644
--- a/test/frontend/scenarios/s160-backup-setup.mjs
+++ b/test/frontend/scenarios/s160-backup-setup.mjs
@@ -37,6 +37,24 @@ export default async function s160({ runDir, bootstrap }) {
await A.shot('s160-other-files', runDir)
await A.click({ role: 'button', name: 'Cancel' })
})
+ await r.ok('a typed folder gets the checks a picked one does', async () => {
+ const field = { role: 'textfield', name: 'Where should the backup live?' }
+ await A.click({ role: 'button', name: 'Set up backup' })
+ await A.waitText('Step 1 of 2', 8000)
+ // Setting a value through the AX tree does not move focus; a click puts Enter in the field.
+ await A.click(field)
+ await A.type(field, path.join(runDir, 's160-missing'))
+ await A.press('return')
+ await A.waitText('There is no folder at this path.', 8000)
+ await A.type(field, clutteredDir)
+ // Enter takes the typed path; it does not also press Next.
+ await A.press('return')
+ await A.waitText('This folder already holds other files', 8000)
+ await A.waitText('Step 1 of 2', 2000)
+ if (!(await A.isDisabled({ role: 'button', name: 'Next' }))) throw new Error('Next is available for a typed folder of other files')
+ await A.shot('s160-typed-folder', runDir)
+ await A.click({ role: 'button', name: 'Cancel' })
+ })
await r.ok('setup takes a folder and a passphrase in two steps', async () => {
await A.click({ role: 'button', name: 'Set up backup' })
await setUpBackup(A, backupDir, PASS)
diff --git a/test/frontend/scenarios/s194-managed-backup-required.mjs b/test/frontend/scenarios/s194-managed-backup-required.mjs
index dfc0e0b6..c1e877fb 100644
--- a/test/frontend/scenarios/s194-managed-backup-required.mjs
+++ b/test/frontend/scenarios/s194-managed-backup-required.mjs
@@ -8,7 +8,7 @@ const PASS = 'a long enough passphrase'
// An organization that sets the backup folder and requires a backup: the Spaces card cannot be put
// off, setup shows the organization's folder instead of a picker, and Settings → Backup locks the
-// switch and the folder while the passphrase stays the person's.
+// switch and shows the folder read-only, with Copy, while the passphrase stays the person's.
export default async function s194({ runDir, bootstrap }) {
mkdirSync(runDir, { recursive: true })
const share = path.join(runDir, 's194-share')
@@ -37,6 +37,7 @@ export default async function s194({ runDir, bootstrap }) {
await A.waitText('Step 1 of 2', 8000)
await A.waitText('Set by Acme Test GmbH', 8000)
assert(!(await A.has({ role: 'button', name: 'Browse… (backup folder)' })), 'no folder picker')
+ assert(await A.has({ role: 'button', name: 'Copy' }), 'the organization\'s folder can be copied')
await A.shot('s194-setup-folder', runDir)
await A.click({ role: 'button', name: 'Next' })
await A.waitText('Step 2 of 2', 8000)
@@ -50,7 +51,10 @@ export default async function s194({ runDir, bootstrap }) {
await A.gotoSettings('Backup')
await A.waitText('Backup Folder', 15000)
assert(await A.isDisabled(automatic), 'the switch reads unavailable while required')
- assert(await A.isDisabled({ role: 'button', name: 'Change (backup folder)' }), 'Change reads unavailable')
+ assert(!(await A.has({ role: 'button', name: 'Change (backup folder)' })), 'the folder offers no Change')
+ const folder = String(await A.nodeValue({ role: 'textfield', name: 'Backup Folder' }))
+ assert(folder.startsWith(share), `the field holds the organization's folder (${folder})`)
+ assert(await A.has({ role: 'button', name: 'Copy' }), 'the folder can be copied')
assert(!(await A.isDisabled({ role: 'button', name: 'Check passphrase' })), 'the passphrase is the person\'s')
await A.shot('s194-settings-locked', runDir)
})
diff --git a/test/invariants/path-field-harmonized.test.js b/test/invariants/path-field-harmonized.test.js
index cd4acb1b..8086266a 100644
--- a/test/invariants/path-field-harmonized.test.js
+++ b/test/invariants/path-field-harmonized.test.js
@@ -32,7 +32,6 @@ const pathRow = read(path.join(RENDERER, 'components/path/PathRow.tsx'))
// consolidation removed, so its every use is listed here with the reason it is not a path field.
// A new name in this list is the review question: should this be a PathRow?
const FILE_PATH_CALLERS = new Set([
- 'components/path/PathRow.tsx', // the path field itself
'components/modals/ScanPreviewModal.tsx', // rows of a file list, not a path the user re-picks
'components/storage/AppStorageCard.tsx', // the data folder's location: display-only, with Copy
])
@@ -91,9 +90,38 @@ test('the path button label is derived, never passed', (t) => {
// REGRESSION (FIX-PATHFIELD-1: the field rendered its only content in `outline`, a border token
// that fails AA against every fill in both themes — 2.63:1 light and 1.90:1 dark at 70% opacity).
test('the path field placeholder is muted TEXT, not a border colour', (t) => {
- const placeholder = pathRow.slice(pathRow.indexOf(' {
+ const editable = pathRow.slice(pathRow.indexOf('interface EditablePath'), pathRow.indexOf('interface FixedPath'))
+ t.ok(/\bonAction: \(\) => void/.test(editable) && /\bonCommit: \(path: string\) => void/.test(editable),
+ 'onAction and onCommit are both required on an editable row')
+ const mount = read(path.join(RENDERER, 'components/path/MountPathField.tsx'))
+ t.ok(/onCommit: \(path: string\) => void/.test(mount), 'the wizards\' field requires it as well')
+})
+
+// While an edit is pending, Enter takes it and Escape reverts it — they must not also reach the
+// dialog, which would confirm with the old path or close and drop the edit.
+test('a pending edit keeps Enter and Escape from the dialog', (t) => {
+ const keys = pathRow.slice(pathRow.indexOf('function handleKeyDown'), pathRow.indexOf('function handleBlur'))
+ t.ok(/if \(draft === shown\) return/.test(keys), 'with nothing pending the keys reach the dialog')
+ t.is((keys.match(/e\.stopPropagation\(\)/g) ?? []).length, 2, 'Enter and Escape both stop there otherwise')
})
// A path that is still loading is not a path the user has yet to pick. Conflating them showed the
diff --git a/test/unit/backup-settings.test.js b/test/unit/backup-settings.test.js
index 5bef8d2c..6887ddac 100644
--- a/test/unit/backup-settings.test.js
+++ b/test/unit/backup-settings.test.js
@@ -2,7 +2,7 @@ import test from 'brittle'
import fs from 'fs'
import os from 'os'
import path from 'path'
-import { applyRestoredSettings } from '../../src/main/backup-settings.js'
+import { applyRestoredSettings, registerBackupIpc } from '../../src/main/backup-settings.js'
import { ConfigStore } from '../../src/main/config-store.js'
function freshStore(t) {
@@ -54,3 +54,41 @@ test('anything that is not a settings object changes nothing', (t) => {
t.is(store.get('appearance.theme'), 'system', json)
}
})
+
+function tmpDir(t) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mirall-backup-folder-'))
+ t.teardown(() => fs.rmSync(dir, { recursive: true, force: true }))
+ return dir
+}
+
+// The two handlers, registered as main registers them, with the picker answering `picked`.
+function backupIpc(dataDir, picked = null) {
+ const handlers = new Map()
+ const ipcMain = { handle: (channel, fn) => handlers.set(channel, fn) }
+ registerBackupIpc({ ipcMain, config: () => null, pickDirectory: async () => picked, dataDir: () => dataDir })
+ return {
+ browse: () => handlers.get('backup:browse')({}, undefined),
+ inspect: (folder) => handlers.get('backup:inspect')({}, folder),
+ }
+}
+
+test('a typed backup folder gets the checks a picked one does', async (t) => {
+ const dataDir = tmpDir(t)
+ const empty = tmpDir(t)
+ const cluttered = tmpDir(t)
+ fs.writeFileSync(path.join(cluttered, 'notes.txt'), 'x')
+ const ipc = backupIpc(dataDir, empty)
+ t.alike(await ipc.inspect(empty), { folder: empty, sameDisk: true, otherFiles: false })
+ t.alike(await ipc.inspect(empty), await ipc.browse(), 'typed and picked describe one folder alike')
+ t.is((await ipc.inspect(cluttered)).otherFiles, true)
+})
+
+test('a typed path that is not an existing folder is no backup folder', async (t) => {
+ const dir = tmpDir(t)
+ const file = path.join(dir, 'file.txt')
+ fs.writeFileSync(file, 'x')
+ const ipc = backupIpc(dir)
+ for (const typed of [path.join(dir, 'missing'), file, 'relative/folder', '', 42, undefined]) {
+ t.is(await ipc.inspect(typed), null, String(typed))
+ }
+})