l-env-feature-matrix
Look up how zudo-text detects its runtime environment (Tauri vs browser, macOS vs iOS vs Android, touch vs no-touch, local vs cloud) and gates features by platform and capability. Covers the six shipp...
Env Feature Matrix
Source of truth for the shipped gating decisions: the two-comment audit on issue #4594 (method + full 83-field verdict table). This skill summarizes that audit AND verifies it against the code that actually landed (#4592, #4598, #4599) — where the two disagree, this document follows the code.
The six environments
| Code | Environment | Adapter | Key capability shape |
|---|---|---|---|
| T-mac | Tauri on macOS (ROOT + every LEAF) | TauriAdapter | localFiles:true, dialogs:true, fs:"local", terminal:true, webShortcutProfile:false |
| T-iOS | Tauri on iOS | TauriAdapter (TAURI_MOBILE_CAPABILITIES) | localFiles:false, dialogs:false, fs:"local", terminal:false, webShortcutProfile:false |
| T-Android | Tauri on Android (epic #6757) | TauriAdapter (TAURI_MOBILE_CAPABILITIES) | same shape as T-iOS — Android and iOS share one mobile capability set; see Android developer docs |
| B-desk | Browser, cloud workspace, desktop viewport | RestAdapter | localFiles:false, dialogs:false, fs:"cloud", terminal:false, webShortcutProfile:true |
| B-iOS | Browser, cloud workspace, iPhone/iPad Safari | RestAdapter | same as B-desk, plus iOS input constraints |
| Mock | pnpm dev:mock / the Cloudflare Pages preview | MockAdapter | localFiles:true, dialogs:true, fs:"local", terminal:true, webShortcutProfile:false (mirrors T-mac) |
ai is true on all six adapters — AI features are never platform-gated, only capability-gated (inline-ai-command section visibility is capabilities.ai === true, currently always true).
There is no B-Android row: a browser on Android is plain isBrowser() with no Android-specific input handling anywhere in the codebase today (unlike iOS, which gets isIOSBrowser()/isAnyIOS() for Safari/PWA quirks) — Android web traffic is untested and unclaimed by any predicate. T-Android is Tauri-only, exactly like T-iOS.
Env-label detection — packages/backend-bridge/src/env.ts
getEnv() returns a Set<EnvLabel> built from independent axes: runtime (tauri/browser), OS (ios/macos/windows/linux/android/chromeos), device (iphone/ipad/android-phone/android-tablet), device class (phone/tablet/desktop), input (touch/no-touch), PWA (standalone-pwa). inEnv(label) is sugar over getEnv().has(label).
iPadOS-desktop-mode heuristic. iPadOS 13+ Safari reports a desktop Macintosh user-agent by default — the only distinguishing signal is navigator.maxTouchPoints. detectOS() treats a Macintosh UA with no explicit iPad/iPhone token AND maxTouchPoints > 1 as iPadDesktopMode, which getEnv() then classifies as ios/ipad, not macos/desktop.
Platform predicates — packages/backend-bridge/src/platform.ts
| Predicate | Meaning |
|---|---|
isTauriMacOS() | Tauri runtime AND macOS. Excludes Windows/Linux Tauri dev builds. |
isTauriDesktop() | Tauri runtime AND NOT mobile (!isTauriMobile()). Broader than isTauriMacOS() on purpose — desktop-gated boot paths should behave the same on Windows/Linux dev hosts. |
isTauriIOS() | Tauri runtime AND iOS. |
isTauriAndroid() | Tauri runtime AND Android (added by epic #6757's ts-predicates topic). |
isTauriMobile() | isTauriIOS() || isTauriAndroid() — the union predicate for "any mobile Tauri build." Prefer this over spelling out both checks whenever the gate is really "mobile," not "iOS specifically"; see the Android docs' "Platform-gating vocabulary" section for the call-site convention. |
isBrowser() | Browser context (Mock, REST, or no Tauri global). |
isIOSBrowser() | Browser runtime AND iOS (Safari, home-screen PWA). No Android equivalent exists — see the six-environments note above. |
isAnyIOS() | iOS regardless of runtime — union of isTauriIOS() and isIOSBrowser(). |
isTouchDevice() | pointer: coarse or maxTouchPoints > 0 — an INPUT axis, true on Android/Windows-touch too, not just iOS. Don't conflate "touch" with "iOS." |
isDocCloudAvailable() | One gate for every Doc Cloud registration/settings/pin/command surface: if (isTauriMobile()) return false (iOS AND Android, unchanged posture — the packaged transport is unproven on either); on Tauri desktop requires a valid injected VITE_DOC_CLOUD_ORIGIN; true in browser and Mock. |
Adapter capabilities — packages/backend-bridge/src/capabilities.ts
Four exported constants (TAURI_CAPABILITIES, TAURI_MOBILE_CAPABILITIES, MOCK_CAPABILITIES, REST_CAPABILITIES), each shaped { ai, dialogs, fs: "local"|"cloud", localFiles, terminal } (the historical shell / pins / localWorkspacePicker fields were deleted post-pivot; terminal came later with the native-PTY restore, unrelated to mobile). TAURI_IOS_CAPABILITIES was renamed to TAURI_MOBILE_CAPABILITIES by epic #6757's ts-predicates topic — same shape (localFiles:false, dialogs:false, terminal:false), now shared by iOS and Android rather than iOS-only. These are static — baked in when the adapter is constructed (the TauriAdapter picks TAURI_MOBILE_CAPABILITIES vs { ...TAURI_CAPABILITIES, terminal:
isTauriMacOS() } once, at construction, based on isTauriMobile() — so a Tauri Windows/Linux dev build gets desktop's localFiles/dialogs but not terminal).
Contrast with dynamic isSupported() probes that exist on specific bridge namespaces, not in the static Capabilities shape. Their contracts are NOT uniform — check the actual signature before writing a consumer:
Async (
Promise<boolean>):bridge.fileSearch.isSupported()(Spotlight),bridge.noteHistory.isSupported(), andbridge.spell.isSupported()(OS-dictionary spell checking). Spotlight's three adapters disagree for three different reasons (Tauri asks Rust; REST hardcodesfalse; Mock disambiguates real macOS from iPadOS-in-Mock viamaxTouchPoints, see Pitfalls below), so it cannot be a static boolean.bridge.spellfollows the same shape — Tauri asks Rust (spell_is_supported), REST hardcodesfalse, and Mock copies the samemaxTouchPointssniff, overridable viaMockSpellOptions.supported. A consumer gating on one of these must hold the resolved value in React state — seegeneral-settings.tsx'sspotlightSupportedstate.Synchronous (
() => boolean):bridge.workspaceFiles.isSupported()andbridge.similarDocs.isSupported()— call and use the result directly, no.then()/state needed.
filterByCapabilities(items, caps) — the command-palette filter — reads item.requiresCapability and keeps the item unless the corresponding capability is boolean false (see "boolean-only" rule below).
webShortcutProfile is a separate static capability: REST sets it to true, while Tauri desktop, Tauri mobile, and Mock set it to false. Use this capability to select the web shortcut profile; do not infer it from isBrowser(), because Mock also runs in a browser context. See @takazudo/app-defaults shortcut profiles for the remap, provenance, edit-translation, and browser-reserved-combo rules.
The shipped feature-availability matrix
Verdict vocabulary (from the #4594 audit, unchanged in the shipped code):
A — available. Works as labeled in this environment.
I — inert. Does nothing here; writing it changes nothing anywhere.
H — harmful-if-shown. Inert here and the persisted value reaches another device through the workspace-synced settings document (epic #4204 D3) — e.g. Window Opacity on an iPhone silently reconfiguring the user's Mac. This is why some inert fields are hidden and others (Vim, Sync, Notifications, Move Buttons, Raw Settings) are deliberately left visible everywhere: an inert-but-not-harmful field configuring a different device the user owns is a legitimate workflow, not a bug.
The default bias across all 83 audited static fields is keep visible. The table below covers what the #4594/#4598/#4599 gating work actually touched — settings fields/sections, the EFE-adjacent palette commands, and the web shortcut profile selector. It is not a full inventory of every requiresCapability in the app: four AI commands (open-ai-assistant, inline-ai-command.insert, inline-ai-command.open-panel, inline-ai-command.toggle-enabled) also declare requiresCapability: "ai", and open-directory declares requiresCapability: "dialogs" — these predate #4592 and are omitted here because ai is true on all six shipped environments (a no-op gate today) and dialogs is a pre-existing, unrelated gate, not one of these issues' outputs. Grep requiresCapability in app-commands.tsx for the full, current list.
| Surface | Field / id | Predicate | Where |
|---|---|---|---|
| Settings row | general.window-opacity | isTauriMacOS() | general-settings.tsx showWindowOpacity |
| Settings row | general.spotlight-search | await bridge.fileSearch.isSupported() (state) | general-settings.tsx showSpotlightSearch |
| Settings row | general.external-editor-tab-position | caps.localFiles | general-settings.tsx showEfeTabPosition |
| Runtime profile | web shortcuts | caps.webShortcutProfile | applyShortcutProfile() from @takazudo/app-defaults |
| Settings row | inline-ai-command.skills-folder | isTauriDesktop() | inline-ai-command-section.tsx showSkillsFolder |
| Settings row | sync.realtime | !isBrowser() (browser shows a static "always on" note instead, #4756) | sync-settings.tsx render branch + renderer settings-search-metadata.ts isVisible |
| Search catalog entry (orphan) | sync.publish-server-url | deleted outright | settings-search-metadata.ts (renderer) |
| Shortcut rows (3) | toggleEfeDirViewSidebar, cycleExternalFileEditorTabPosition, toggleExternalFileEditorPreview | requiresCapability: "localFiles" | shortcut-groups.ts |
| Settings section | slides | slidesEnabled | settings-section-registry.ts |
| Settings section | inline-ai-command | capabilities.ai === true | settings-section-registry.ts |
| Settings section | frontmatter | !isTauriMobile (section-level only — hidden on T-iOS AND T-Android, no further field gate; renamed from !isTauriIOS by epic #6757's ts-predicates topic) | settings-section-registry.ts |
| Settings sections | mobile, mobile-device | isTauriMobile (renamed from ios/ios-device + isTauriIOS by epic #6757's mobile-settings topic, #6762) | settings-section-registry.ts |
| View provider | External File Editor (core.external-file-editor, #4599) | hasUsableLocalFiles(caps) → caps.localFiles | see "View providers" below |
| Settings section + view provider + pin/picker surfaces | Doc Cloud (core.doc-cloud) | isDocCloudAvailable() | one shared platform predicate; never reconstruct from isBrowser() or capabilities |
| Palette command | open-provider:doc-cloud | isDocCloudAvailable() | command is absent where the provider is unavailable |
| Palette command | doc-cloud:publish | Doc Cloud available and active frame is core.doc-cloud | visible but disabled with a reason when no Doc Cloud frame is active |
| Palette commands (3) | toggle-efe-dir-view-sidebar, cycle-efe-tab-position, toggle-efe-preview | requiresCapability: "localFiles" | app-commands.tsx |
| Palette command | inline-ai-command.reveal-skills | requiresCapability: "localFiles" | app-commands.tsx (see Pitfalls — this is an approximation of isTauriDesktop()) |
Everything else — Vim, Sync, Notifications, Move Buttons, Aliases, Quick Actions, API Tokens, Raw Settings, the menu-bar and other shortcuts rows, Native Context Menu, Font Family — stays visible in all six environments by design. Frontmatter is the one exception to call out explicitly: its section IS hidden on T-iOS AND T-Android (row above) — it is not in this "visible everywhere" list, and its single field (frontmatter.schema) has no gate beyond that section-level one. Two notable non-gates among the "stays visible" set, because the reasoning generalizes:
Raw Settings must stay visible everywhere. It is the one surface where a user on an iPhone can still see and correct a field the gated UI no longer renders — load-bearing for the "hidden ≠ deleted" contract.
Menu Bar icons are not visibility-gated at all — they render with an explicit "Unavailable now" annotation instead of being hidden (
resolveMenuBarPreviewCapabilities()). Don't add hide-based gating on top of this; it would regress a deliberately more informative design.
Doc Cloud availability and credential matrix
Doc Cloud is a platform predicate rather than an adapter capability because availability depends on the runtime plus a validated build-time origin, not on localFiles/dialogs/fs:
| Environment | Surface | Transport | Calling-token storage |
|---|---|---|---|
| T-mac | Available only with valid VITE_DOC_CLOUD_ORIGIN | native Rust HTTP/SSE allowlist | macOS Keychain |
| T-iOS | Hidden/unregistered | unavailable until packaged transport is proven | none |
| T-Android | Hidden/unregistered — same posture as T-iOS (isTauriMobile() gate) | unavailable until packaged transport is proven | none |
| B-desk | Available | fetch-based Doc Cloud bridge | memory-only by default |
| B-iOS | Available | same browser bridge | memory-only by default |
| Mock | Available | deterministic in-memory service | memory-only |
Non-macOS persistence is never automatic. DocCloudAdapterConfig.persistTokenInLocalStorage is accepted only as an explicit DEV opt-in (dev: true); production defaults to memory. A token is never an AppSettings or device-override field. Only docCloud.defaultProjectSlug is device-overridable. An inaccessible effective slug falls back to Projects.
Use the same isDocCloudAvailable() result for the provider registries, settings registry/search, add/edit-pin flows, and commands. Mock is a browser, so an isBrowser() test would accidentally conflate the test environment with production availability rules. The e2e:mock-capabilities seam does not affect Doc Cloud; this is not a capability-gated surface.
How to gate a new feature
There are four distinct mechanisms — pick the one matching the surface, don't invent a fifth.
1. A settings FIELD (row) — section stays visible, one row doesn't
Add the axis to SettingsFieldVisibilityContext in packages/ if it isn't already there (isTauriMacOS, isTauriDesktop, isBrowser, spotlightSupported, localFiles today) — deliberately plain booleans, no React/backend import, so this module stays mountable without hooks.
Declare the predicate once (see GENERAL_FIELD_VISIBILITY in the same file) and reuse it in exactly two places:
the search-catalog entry's
isVisible(passed todefineSettingsSearchEntries), andthe rendering section component's own gate (e.g.
general-settings.tsxbuilds onefieldVisibilityobject and callsGENERAL_FIELD_VISIBILITY.xxx(fieldVisibility)).
This is what keeps the rendered DOM and the settings-search results in agreement by construction — re-deriving the condition independently at each site is exactly the kind of thing that silently drifts.
2. A settings SECTION (whole section shown/hidden)
Add isVisible: (context: SettingsSectionVisibilityContext) => boolean to the section's entry in SETTINGS_SECTION_REGISTRY (tauri-). That context (slidesEnabled, docCloudAvailable, isTauriMobile, isIOSBrowser, isTauriDesktop, isCloud, capabilities — isTauriMobile renamed from isTauriIOS and broadened to cover Android by epic #6757's mobile-settings topic, #6762) is built once, in settings-dialog.tsx (literally commented there as "the single visibility context threaded through all consumers (#4598)"), and fed to buildSettingsSidebarNodes (sidebar tree — the mobile tree inherits automatically since it renders the same nodes), buildSettingsSearchCatalog (search groups), and buildDeviceSubtree (per-device override subtree). Don't construct a second context object for a new consumer — thread the existing one through.
3. A view provider or other non-settings surface — the EFE pattern
Write one predicate function taking Capabilities and returning boolean, exported from the provider's own registration module — the canonical example is hasUsableLocalFiles(caps) in tauri- (return caps.localFiles). Then apply that same imported function at every construction/surfacing site — for EFE that's: view- (×2, registration), lib/ (caps threaded into registerAllProviders), pages/, pages/, components/, and components/ (the pin flows filter by the shared LOCAL_FILES_ONLY_PROVIDER_IDS set from the same module, #4756). Missing even one site leaves a path where the surface can still be constructed/pinned in an environment where it's unusable.
Explicitly do not gate this class of surface on isBrowser() — it's true for Mock exactly as much as for REST (neither has a Tauri global), so it cannot distinguish "Mock, which should show EFE" from "cloud browser, which shouldn't" — or on caps.fs === "local" (also true on Tauri iOS, where local files are still unusable — sandboxed, no dialogs/pins). caps.localFiles is the one capability that means "can bridge.files actually read/write/watch absolute host paths," which is the real question; REST declares it false (fs: "cloud") regardless of whether the browser session is pnpm dev:rest or production cloud.
If a future provider needs the same treatment, import hasUsableLocalFiles rather than declaring a second predicate, and add its id to LOCAL_FILES_ONLY_PROVIDER_IDS so the pin flows pick it up automatically.
4. A command palette entry
Add requiresCapability: "<key>" to the command definition. filterByCapabilities() in capabilities.ts enforces it.
Boolean-only rule. filterByCapabilities does typeof value ===
"boolean" ? value : Boolean(value) — so it only actually filters when the named capability key holds a boolean. Point requiresCapability at one of ai / dialogs / localFiles. Pointing it at fs (a "local"|"cloud" string) would always resolve Boolean("cloud") → true — a silent no-op gate, not an error.
requiresCapability also cannot express an arbitrary predicate like isTauriDesktop() — it can only key into the adapter's static Capabilities object. See "Reveal Skills Folder" in Pitfalls below for what happens when a command needs a non-capability predicate and settles for the nearest boolean proxy instead.
Known pitfalls
MOCK declares all capabilities true — and the e2e:mock-capabilities seam only escapes that if you use it. MOCK_CAPABILITIES mirrors TAURI_CAPABILITIES exactly, so pnpm dev:mock / the Pages preview exercises the full desktop feature surface by default and cannot, on its own, demonstrate REST/iOS-only gating. To exercise capability-gated UI without a real Tauri iOS runtime, seed localStorage["e2e:mock-capabilities"] with a JSON partial Capabilities object via page.addInitScript before navigation — main-mock.tsx's readE2ESeededCapabilities() merges it over MOCK_CAPABILITIES. As of this writing this seam exists (added with #4592) but no e2e spec actually uses it yet — it's infrastructure waiting for its first consumer, not an established pattern to copy from an existing spec.
A command can be visible-but-dead in Mock even without touching the seam. The "Inline AI: reveal skills folder" palette command (inline-ai-command.reveal-skills, app-commands.tsx) wants the same gate as its settings-row twin ("Skills folder" in inline-ai-command-section.tsx, gated on isTauriDesktop()) — but isTauriDesktop() isn't a capability, so it can't be a requiresCapability value. The command settles for requiresCapability: "localFiles" as the nearest boolean proxy (with a code comment acknowledging the approximation). Net effect: in Mock (localFiles: true, but no real Finder to reveal) the palette command shows and does nothing when invoked, while the settings row for the identical feature correctly disappears (isTauriDesktop() is false in Mock, a browser). This is the boolean-only rule's real consequence, not a bug to "fix" by making the two sides match — they can't, without a way to express non-capability predicates in requiresCapability.
The iPadOS UA heuristic is implemented twice, independently, for different reasons. env.ts's detectOS() (see above) answers "is this device an iPad" for the whole app's env-label system. The Mock adapter's bridge.fileSearch.isSupported() (mock-adapter.ts, around the Spotlight implementation) re-derives the same Macintosh UA + maxTouchPoints > 1 signal separately, because it needs to tell real-macOS-in-Mock (Spotlight should appear) from iPadOS-in-Mock (Spotlight should not) on a different code path with different call timing. Don't assume fixing one heuristic fixes the other, and don't assume a new consumer can skip writing its own check just because getEnv() exists — some consumers (like this one) need the answer before/without going through the env.ts label set.
isTauriIOS vs isTauriAndroid vs isTauriMobile vs isIOSBrowser vs isAnyIOS/isTouchDevice are five different axes — picking the wrong one compiles fine and mostly works.
isTauriIOS()— gates surfaces that are genuinely iOS-only, not just "mobile":isAnyIOS()'s Tauri half, and code that has a real Android counterpart but a different implementation (e.g.asset-upload-tab.tsxbranchesisTauriIOS()vs Android's own path — see the Android developer docs' "Assets" section).isTauriAndroid()— the Android-specific symmetric counterpart, added by epic #6757. PreferisTauriMobile()unless the code path genuinely differs between iOS and Android.isTauriMobile()— gates Tauri-plugin-only surfaces with no web-API equivalent, where iOS and Android are treated identically: themobile/mobile-devicesettings sections (local notifications, haptics, diagnostics export, sign-out, reset-local-data),isDocCloudAvailable(), andTAURI_MOBILE_CAPABILITIESselection. This is the union predicate most new mobile-gated code should reach for — see it broaden a formerly iOS-only gate below.isIOSBrowser()— gates browser-specific copy/behavior on iOS Safari/PWA (e.g. the auto-zoom hint under Display Scale ingeneral-settings.tsx). There is no Android-browser counterpart (see the six-environments note above) — Android web traffic falls through to the plainisBrowser()path with no Android-specific handling.isAnyIOS()— the union ofisTauriIOS()andisIOSBrowser(); drives theenv-iosCSS root class so styling can target "any iOS, any runtime" without a per-component JS branch. There is noenv-androidcounterpart.isTouchDevice()— a separate INPUT axis (pointer: coarse/maxTouchPoints > 0), true on Android and Windows-touch too. Not an iOS or mobile-runtime check at all.
The failure mode for using the wrong one is narrow and easy to miss in review: #4604 (filed from the #4594 audit) is the editor context-menu handler checking viewport width instead of isAnyIOS()/isTouchDevice(), so an iPad in Safari landscape — wide viewport, not Tauri — still gets the custom React context menu it shouldn't. The same mistake in the other direction is why epic #6757 exists as three separate predicates rather than one flag: frontmatter's section gate and the ios/ios-device settings sections were narrowed to isTauriIOS() when only iOS existed, and had to be widened to isTauriMobile() (not just search-and-replaced to isTauriAndroid()) once Android needed the same treatment.
The two settings-search-metadata catalogs are disjoint halves, not duplicates. packages/ (SHARED_SETTINGS_SEARCH_ENTRIES — general/styles/editor/preview/slides; also consumed by the ROOT's generate-child settings step) and tauri- (RENDERER_SETTINGS_SEARCH_ENTRIES — vim/aliases/move-buttons/quick-actions/ inline-ai-command/sync/api-tokens/notifications/frontmatter/mobile/mobile-device/ deviceOverride/raw/menu-bar/shortcuts — section keys renamed from ios/ios-device by epic #6757's mobile-settings topic) have no overlapping section keys. buildSettingsSearchCatalog() simply concatenates both arrays. Both import the same SettingsSearchEntry / SettingsFieldVisibilityContext types and the same defineSettingsSearchEntries() helper from @takazudo/ — so a new field in either half uses the identical gating mechanism (§1 above). The split is about which sections the ROOT's generate-child wizard reuses (only the shared half), not about two competing gating systems.
env-* root classes and the 640px viewport breakpoint answer different questions — don't conflate them. env-ios / env-touch / env-tauri / env-browser (tauri-, stamped on document.documentElement before first paint by all four renderer bootstrap paths) answer "what kind of device/runtime is this," independent of window size — .env-ios styling still applies to an iPad in landscape at 1194px wide. MOBILE_BREAKPOINT_PX (640, packages/) and useIsMobileViewport() / the max-mobile: Tailwind variant answer "is the window narrow right now," independent of device — true for a narrowed desktop browser window, false for an iPad in landscape. The env-* classes exist specifically because a width-only check misses iPad landscape (wide, but still touch-only iOS) — see the module doc comment in apply-env-classes.ts. A new feature needing "iOS/touch behavior" wants the env-* classes or the platform predicates; one needing "cramped-screen layout" wants the viewport breakpoint. Some need both, independently — picking the wrong one is the same class of bug as #4604 above.