Skip to content

Commit ca78382

Browse files
committed
A11y: Polish the dynamic text size feature
- Tab bar: revert title-bar to 27 px, push tab-bar 3 px down via padding-top so the active tab's colored top sits flush below the title-bar at every scale (no negative margin tricks needed). - Settings window dimensions split into fixed chrome (220 px sidebar + 32 px padding) and scaled content area (`settingsMinWidth(scale)` / `settingsMaxWidth(scale)`). Right edge of content always snaps to right edge of window — no empty zone at large scales, no clipping at small ones. Removed the now-redundant `max-width` on `.settings-content`. - Slider min-width 120 → 60 so the unit label stays visible in narrow rows. - Capabilities: add `core:window:allow-set-min-size` and `allow-set-max-size` to `settings.json` so the live `setMinSize` / `setMaxSize` calls actually apply. - Surface Tauri capability failures as warn logs (`await` + `try`/`catch` instead of `void` fire-and-forget) so missing perms don't fail silently. - Document the recurring "Tauri feature needs a perm" gotcha in `AGENTS.md` § Critical rules and strengthen the existing note in `capabilities/CLAUDE.md`.
1 parent a326bca commit ca78382

8 files changed

Lines changed: 115 additions & 44 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ resilience, and common pitfalls.
176176
- When adding a new user-facing action, add it to `command-registry.ts` and `handleCommandExecute` in
177177
`routes/(main)/command-dispatch.ts`.
178178
- If you added a new Tauri command touching the filesystem, check `docs/architecture.md` § Platform constraints.
179+
-**Tauri APIs fail silently without permissions.** Whenever you call a new Tauri API from a window — `setMinSize`,
180+
`setTitle`, `show`, plugin commands, anything new — add the matching permission to that window's capability file in
181+
`src-tauri/capabilities/{default,settings,viewer}.json`. Without it, the call rejects with a generic "not allowed"
182+
error and your feature looks broken with no obvious cause. Surface failures by `await`-ing the call inside a
183+
`try/catch` and logging the error rather than `void`-ing the promise. See `src-tauri/capabilities/CLAUDE.md` for the
184+
per-window split and naming conventions.
179185
- We use [mise](https://mise.jdx.dev/) to manage tool versions (Go, Node, etc.), pinned in `.mise.toml`. Shims are on
180186
PATH via `~/.bashrc` and `~/.zshenv`, so `go` and `node` should just work. If `go` is "not found", check that
181187
`~/.local/share/mise/shims` is on `$PATH`.

apps/desktop/src-tauri/capabilities/CLAUDE.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,21 @@ for available permission identifiers.
2828
## Gotchas
2929

3030
**Gotcha**: Missing permissions fail silently at runtime.
31-
**Why**: Tauri doesn't crash or warn visibly when a webview calls an API it lacks permission for. The call just returns a generic "not allowed" error. If a new Tauri API call (e.g., `setFocus`, `setTitle`) is added to a window's frontend code, the corresponding permission must be added here or it will silently fail. Check the browser console for "not allowed" errors.
31+
**Why**: Tauri doesn't crash or warn visibly when a webview calls an API it lacks permission for. The call just rejects its promise with a generic
32+
`window.set_X not allowed. Permissions associated with this command: core:window:allow-set-X` error.
33+
If a new Tauri API call (e.g., `setFocus`, `setTitle`, `setMinSize`, `setMaxSize`, plugin commands) is added to a window's frontend code, the matching permission must be added to that window's capability file (`default.json` for the main window, `settings.json` for settings, `viewer.json` for viewer windows). Without it the call rejects with no UI feedback — the feature just looks broken.
34+
35+
**Mitigation pattern**: any Tauri call that affects the UI shape should be `await`-ed inside `try/catch` with a `log.warn` on failure, never `void`-ed. The instant failure log is what catches missing permissions during development before the user does. Pattern:
36+
37+
```typescript
38+
try {
39+
await win.setMaxSize(maxSize)
40+
} catch (e) {
41+
log.warn('setMaxSize failed: {error}', { error: String(e) })
42+
}
43+
```
44+
45+
This same pattern caught the missing `core:window:allow-set-min-size` / `allow-set-max-size` permissions when `routes/settings/+page.svelte` started using them for live text-size resizing — see `AGENTS.md` § Critical rules for the higher-level callout.
3246

3347
**Gotcha**: `opener:allow-open-path` needs explicit glob patterns for hidden files.
3448
**Why**: The default `opener:allow-open-path` permission doesn't match dotfiles. The `"**/*"` glob excludes files starting with `.`, so a separate `"**/.*"` pattern is required. Without it, opening hidden files from the file manager would silently fail.

apps/desktop/src-tauri/capabilities/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"permissions": [
99
"core:window:allow-close",
1010
"core:window:allow-set-focus",
11+
"core:window:allow-set-min-size",
12+
"core:window:allow-set-max-size",
1113
"core:event:default",
1214
"core:app:allow-set-app-theme",
1315
"core:webview:allow-internal-toggle-devtools",

apps/desktop/src/lib/file-explorer/tabs/TabBar.svelte

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,20 @@
143143
.tab-bar {
144144
display: flex;
145145
align-items: end;
146-
height: var(--spacing-tab-bar-height);
147-
min-height: var(--spacing-tab-bar-height);
148-
max-height: var(--spacing-tab-bar-height);
146+
/* Total height = scaled tab band + 3 px non-scaled top spacer.
147+
* The spacer is bg-secondary (same as the bar), so it visually reads
148+
* as a slim continuation of the (fixed-height) window title-bar above.
149+
* Tabs anchor to the bar's content-area bottom (align-items: end);
150+
* with `box-sizing: border-box`, the padding-top reduces the content
151+
* area to exactly `--spacing-tab-bar-height`, so the colored top edge
152+
* of the active tab sits 3 px below the title-bar at every scale. */
153+
height: calc(var(--spacing-tab-bar-height) + 3px);
154+
min-height: calc(var(--spacing-tab-bar-height) + 3px);
155+
max-height: calc(var(--spacing-tab-bar-height) + 3px);
149156
background-color: var(--color-bg-secondary);
150157
border-bottom: 1px solid var(--color-border);
151-
padding: 0 var(--spacing-xxs);
158+
/* stylelint-disable-next-line declaration-property-value-disallowed-list -- 3px is a deliberate non-scaling visual offset, no spacing token fits */
159+
padding: 3px var(--spacing-xxs) 0;
152160
overflow: hidden;
153161
}
154162
@@ -170,12 +178,11 @@
170178
min-width: 32px;
171179
max-width: 180px;
172180
flex: 1 1 0;
173-
/* Scales with --font-scale so the colored top of the active tab stays
174-
* flush below the (fixed-height) window title-bar at every scale.
175-
* Without this, the bar grew but the tab kept a fixed 24px height,
176-
* pushing the colored top down at large scales / behind the title-bar
177-
* at small scales. */
178-
height: calc(24px * var(--font-scale));
181+
/* Tabs fill the entire bar height. With `.tab-bar { align-items: end }`
182+
* and the tab matching the bar height, the colored top edge of the
183+
* active tab is always at the bar's top — flush below the (fixed)
184+
* window title-bar at every text scale. */
185+
height: var(--spacing-tab-bar-height);
179186
padding: 0 var(--spacing-sm);
180187
border: none;
181188
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
@@ -207,9 +214,9 @@
207214
background-color: color-mix(in oklch, var(--color-bg-primary), var(--color-accent) 4%);
208215
color: var(--color-text-primary);
209216
font-weight: 500;
210-
/* Scales with --font-scale; mirrors `.tab { height }` plus 1px to cover
211-
* the tab-bar bottom border. */
212-
height: calc(25px * var(--font-scale));
217+
/* Bar height + 1px so the active tab covers the tab-bar bottom border;
218+
* the extra px hangs below via `margin-bottom: -1px`. */
219+
height: calc(var(--spacing-tab-bar-height) + 1px);
213220
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
214221
margin-bottom: -1px;
215222
z-index: 1;

apps/desktop/src/lib/settings/components/SettingSlider.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,13 @@
129129
width: 100%;
130130
}
131131
132-
/* The root needs explicit sizing for Ark UI slider to work */
132+
/* The root needs explicit sizing for Ark UI slider to work.
133+
* `min-width: 60px` lets the track shrink in narrow rows so the unit
134+
* label after the number input still fits. The thumb stays interactive
135+
* down to that floor; below it Ark UI handles the cramped layout. */
133136
:global(.slider-root) {
134137
flex: 1;
135-
min-width: 120px;
138+
min-width: 60px;
136139
}
137140
138141
:global(.slider-control) {

apps/desktop/src/lib/settings/components/SettingsContent.svelte

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
}
7777
</script>
7878

79-
<div class="settings-content">
79+
<div>
8080
<!-- Summary pages for top-level sections -->
8181
{#if showSummary}
8282
<SectionSummary sectionName={selectedSection[0]} onNavigate={handleNavigate} />
@@ -185,9 +185,11 @@
185185
</div>
186186

187187
<style>
188-
.settings-content {
189-
max-width: 600px;
190-
}
188+
/* No content max-width: settings rows fill the wrapper, which is window
189+
* width minus the fixed sidebar (220 px) and content padding (32 px).
190+
* Window min/max in `lib/settings/settings-window.ts` make the available
191+
* content area scale proportionally with text size, so the right edge of
192+
* the content always snaps to the right edge of the window. */
191193
192194
section {
193195
margin-bottom: var(--spacing-lg);

apps/desktop/src/lib/settings/settings-window.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22
* Settings window management.
33
* Creates and manages the settings window as a separate Tauri window.
44
*
5-
* Dimensions scale with the user's effective text size: at 100% the values
6-
* below are the literal pixel dimensions; at 200% everything is doubled.
7-
* This keeps all settings rows visible and proportional. The settings page
8-
* itself updates `setMinSize`/`setMaxSize` live when the user moves the
9-
* slider — see `routes/settings/+page.svelte`.
5+
* **Sizing model.** Width has two parts:
6+
*
7+
* window_width = chrome (fixed) + content_area (scales with text size)
8+
*
9+
* The chrome covers the fixed-width sidebar (220 px) plus the content
10+
* wrapper's horizontal padding (16 px each side = 32 px). Whatever the text
11+
* scale, those values stay constant — only the readable content area scales,
12+
* so a row reads with the same proportions at every size. Height scales
13+
* fully (no fixed-height chrome inside).
14+
*
15+
* The settings page (`routes/settings/+page.svelte`) updates `setMinSize` /
16+
* `setMaxSize` live when the user moves the slider so the constraints track
17+
* the new scale. See that file for the live-update logic.
1018
*/
1119

1220
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
@@ -16,13 +24,29 @@ import { getEffectiveScale } from '$lib/text-size.svelte'
1624

1725
const log = getAppLogger('settings')
1826

19-
/** Base dimensions at scale = 1 (the historical hard-coded values). */
20-
export const SETTINGS_BASE_WIDTH = 800
27+
/**
28+
* Fixed-width chrome that does NOT scale with text size:
29+
* - Sidebar: 220 px (`.settings-sidebar { width: 220px }`)
30+
* - Content wrapper padding: `var(--spacing-lg)` × 2 = 32 px
31+
*
32+
* Keep in sync with `routes/settings/+page.svelte`'s `.settings-sidebar` and
33+
* `.settings-content-wrapper` rules.
34+
*/
35+
export const SETTINGS_CHROME_WIDTH = 252
36+
37+
/** Content-area width at scale = 1. Window total = chrome + content × scale. */
38+
export const SETTINGS_CONTENT_BASE_MIN_WIDTH = 348
39+
export const SETTINGS_CONTENT_BASE_MAX_WIDTH = 600
40+
41+
/** Height scales fully — no fixed-height chrome inside the settings layout. */
2142
export const SETTINGS_BASE_HEIGHT = 600
22-
export const SETTINGS_BASE_MAX_WIDTH = 852
23-
export const SETTINGS_BASE_MIN_WIDTH = 600
2443
export const SETTINGS_BASE_MIN_HEIGHT = 400
2544

45+
export const settingsMinWidth = (scale: number): number =>
46+
SETTINGS_CHROME_WIDTH + SETTINGS_CONTENT_BASE_MIN_WIDTH * scale
47+
export const settingsMaxWidth = (scale: number): number =>
48+
SETTINGS_CHROME_WIDTH + SETTINGS_CONTENT_BASE_MAX_WIDTH * scale
49+
2650
/**
2751
* Opens the settings window, or focuses it if already open. When `section` is provided,
2852
* the settings window listens for the `navigate-to-section` event and scrolls/highlights
@@ -49,11 +73,13 @@ export async function openSettingsWindow(section?: string[]): Promise<void> {
4973
new WebviewWindow('settings', {
5074
url: section ? `/settings?section=${encodeURIComponent(JSON.stringify(section))}` : '/settings',
5175
title: 'Settings',
52-
width: SETTINGS_BASE_WIDTH * scale,
76+
// Open at max width so the content-area starts at its scaled cap; user can
77+
// shrink down to `settingsMinWidth(scale)`.
78+
width: settingsMaxWidth(scale),
5379
height: SETTINGS_BASE_HEIGHT * scale,
54-
minWidth: SETTINGS_BASE_MIN_WIDTH * scale,
80+
minWidth: settingsMinWidth(scale),
5581
minHeight: SETTINGS_BASE_MIN_HEIGHT * scale,
56-
maxWidth: SETTINGS_BASE_MAX_WIDTH * scale,
82+
maxWidth: settingsMaxWidth(scale),
5783
center: true,
5884
resizable: true,
5985
decorations: true,

apps/desktop/src/routes/settings/+page.svelte

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@
88
import { initializeShortcuts, flushPendingSave as flushShortcutsSave } from '$lib/shortcuts'
99
import { initAccentColor, cleanupAccentColor } from '$lib/accent-color'
1010
import { initTextSize, cleanupTextSize, getEffectiveScale } from '$lib/text-size.svelte'
11-
import {
12-
SETTINGS_BASE_MAX_WIDTH,
13-
SETTINGS_BASE_MIN_HEIGHT,
14-
SETTINGS_BASE_MIN_WIDTH,
15-
} from '$lib/settings/settings-window'
11+
import { SETTINGS_BASE_MIN_HEIGHT, settingsMaxWidth, settingsMinWidth } from '$lib/settings/settings-window'
1612
import { getMatchingSections } from '$lib/settings/settings-search'
1713
import { loadLastSettingsSection, saveLastSettingsSection } from '$lib/app-status-store'
1814
import { getAppLogger } from '$lib/logging/logger'
@@ -45,20 +41,35 @@
4541
/**
4642
* Settings-window dimensions track the effective text scale: at 100% the
4743
* base values match the historical layout; at other scales the min/max
48-
* grow proportionally so all rows stay visible. Tauri has no
49-
* "no max height" knob — we set a very large value (50_000 logical px)
50-
* which is effectively unbounded for practical use.
44+
* grow proportionally so all rows stay visible. Tauri has no "no max
45+
* height" knob — we set a very large value (50_000 logical px) which is
46+
* effectively unbounded for practical use.
47+
*
48+
* Standard NSWindow clamping behavior: when the new constraints leave the
49+
* current frame out of bounds, macOS clamps it to fit. Otherwise the
50+
* frame stays where the user put it. The `appearance.textSize` slider
51+
* itself debounces re-measurement, so the window doesn't thrash.
5152
*
5253
* Reading `getEffectiveScale()` inside `$effect` makes this re-run on
5354
* every scale change (system Accessibility settle or user slider move).
5455
*/
5556
$effect(() => {
5657
const scale = getEffectiveScale()
5758
const win = getCurrentWindow()
58-
const minSize = new LogicalSize(SETTINGS_BASE_MIN_WIDTH * scale, SETTINGS_BASE_MIN_HEIGHT * scale)
59-
const maxSize = new LogicalSize(SETTINGS_BASE_MAX_WIDTH * scale, 50_000)
60-
void win.setMinSize(minSize)
61-
void win.setMaxSize(maxSize)
59+
const minSize = new LogicalSize(settingsMinWidth(scale), SETTINGS_BASE_MIN_HEIGHT * scale)
60+
const maxSize = new LogicalSize(settingsMaxWidth(scale), 50_000)
61+
// Awaited rather than fire-and-forget so a missing capability surfaces
62+
// as a warn log instead of silently swallowing the rejection. Tauri
63+
// rejects without these perms in `capabilities/settings.json`:
64+
// `core:window:allow-set-min-size`, `core:window:allow-set-max-size`.
65+
void (async () => {
66+
try {
67+
await win.setMinSize(minSize)
68+
await win.setMaxSize(maxSize)
69+
} catch (e) {
70+
log.warn('Settings window setMinSize/setMaxSize failed: {error}', { error: String(e) })
71+
}
72+
})()
6273
})
6374
6475
// Handle search input

0 commit comments

Comments
 (0)