Repository navigation
Redesign the convt desktop app UI on one visual system - #68
leoisadev1 wants to merge 52 commits into
Conversation
A shared theme (spacing, radii, type steps, palette, buttons, badges, callouts, grouped rows, the brand mark) and every window rebuilt on it: Activity with a branded sidebar, icons and a real empty state; first run with the step above the art and a larger window; Settings as grouped sections; Quick convert with a format grid and grouped options; and the pack, account, update and popover surfaces restyled to match. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…design-6808 Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
| let notifications = theme::switch("notifications", settings.notifications, false, p) | ||
| .on_click({ | ||
| let app = app.clone(); | ||
| let on = settings.notifications; | ||
| move |_, _, cx| { | ||
| app.update(cx, |s, cx| s.update_settings(|s| s.notifications = !on, cx)) | ||
| } | ||
| }); | ||
| let reveal = theme::switch("reveal", settings.reveal_when_done, false, p).on_click({ |
There was a problem hiding this comment.
🟡 Notification and reveal switches become indistinguishable
Replacing the checkboxes with theme::switch gives both controls only “On” or “Off” as accessible names. Their setting titles remain separate siblings, so assistive-technology users cannot identify which preference each switch changes.
Learn more
The General tab previously rendered these preferences as checkboxes whose accessible labels named their actions. The shared switch supplies only On or Off as its accessible label. The row places the visible preference title in a separate child without associating it with the switch. Both newly converted preferences therefore expose the same name when they have the same value.
Example: With notifications and Reveal both enabled, an assistive-technology user encounters two controls named “On” and cannot tell which one reveals files.
Recommended fix: Give each switch a descriptive accessible name, including its setting title, either through an optional label parameter on theme::switch or by associating the switch with its theme::row title. Preserve the current toggled state.
Was this helpful? React with 👍 or 👎 to provide feedback.
| let items = choices.into_iter().map(|choice| { | ||
| let on_pick = on_pick.clone(); | ||
| let pick = choice.id.clone(); | ||
| let on = choice.label == current; |
There was a problem hiding this comment.
🟡 Automatic background shows wrong checked option
When Quick convert infers a background, select checks the inferred color instead of “Automatic.” The background remains unset, so the checked option misrepresents what conversion will use for other routes.
Learn more
The shared dropdown currently chooses its checkmark by comparing option labels with the displayed text. Quick convert's background selector displays the inferred default color even when self.background is None, which means Automatic. Since that displayed color matches a concrete option, the dropdown marks the concrete option instead of Automatic. The choice users see as selected is therefore not the stored conversion setting.
Example: For a JPEG target, the control displays White while self.background is None. Opening the dropdown marks White, although Automatic is selected and a different route can infer a different background.
Recommended fix: Pass the actual selected choice id separately from the display value to theme::select, and compare choice ids rather than labels. Pass automatic for an unset background even when displaying the inferred default.
Was this helpful? React with 👍 or 👎 to provide feedback.
| return div() | ||
| .id("activity") | ||
| .flex() | ||
| .flex_col() | ||
| .flex_1() | ||
| .min_h_0() | ||
| .children(notices) | ||
| .child(self.empty_state(finder_off, p, cx)) |
There was a problem hiding this comment.
🟡 Empty Activity can hide its file picker
When Activity has no conversions and shows a long error, activity renders its notices without a scroll container. Resizing the window short can push “Choose files…” below the viewport with no way to reach it.
Learn more
The nonempty Activity path uses vertical scrolling, but the empty path does not. notices can contain a multi-line Add files error and, on macOS, the Finder recovery card. They are placed ahead of the flexible empty state in the unscrollable branch, so shrinking the window or displaying enough error lines moves the picker action outside the visible area.
Example: On a small main window, a long unreadable-folder error above the empty state takes the remaining vertical space. The Choose files button falls below the bottom edge and scrolling does nothing.
Recommended fix: Keep the empty Activity branch scrollable as well, with sufficient minimum content height for its empty-state action and notices. Cover a resized window with both notices and the empty-state picker in a layout test.
Was this helpful? React with 👍 or 👎 to provide feedback.
| .h(px(52.)) | ||
| // The traffic lights sit at the left of a transparent title bar. | ||
| .pl(px(if theme::transparent_titlebar() { | ||
| 84. | ||
| } else { | ||
| 28. | ||
| })) | ||
| .pr(px(28.)) | ||
| .child(theme::lockup(13., &p)) | ||
| .child(steps), |
There was a problem hiding this comment.
|
| return div() | ||
| .id("activity") | ||
| .flex() | ||
| .flex_col() | ||
| .flex_1() | ||
| .min_h_0() | ||
| .children(notices) | ||
| .child(self.empty_state(finder_off, p, cx)) |
There was a problem hiding this comment.
The empty activity branch no longer scrolls. Dropping several unreadable folders can produce more error lines than the window can show: add() joins every folder error into one message, but users cannot reach the lower lines. Keep this branch scrollable, as the populated branch still is.
Finding confidence: 5/5.
| let items = choices.into_iter().map(|choice| { | ||
| let on_pick = on_pick.clone(); | ||
| let pick = choice.id.clone(); | ||
| let on = choice.label == current; |
There was a problem hiding this comment.
Background checks the wrong choice
The new menu check matches the displayed label rather than the selected choice. In Background, an unset value shows its default, such as White for JPEG. Selecting Automatic therefore leaves the check beside White, even though those choices behave differently when the target changes. Pass the selected choice ID separately from the displayed value.
Finding confidence: 4/5.
| .bg(p.green) | ||
| .child(icon(IconName::Check, 11., c(0xFFFFFF))) |
There was a problem hiding this comment.
Dark-mode checks lack contrast
Checked controls now use the brighter dark-mode p.green behind a white check. White against #3FCB84 has about 2.1:1 contrast, below the 3:1 target for control marks. The new selected-format check in Quick convert uses the same pair. Use a darker control fill or a dark check in dark mode.
Finding confidence: 5/5.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Both palettes now take their colors from apps/web/src/styles.css (light :root and .dark), with chip tokens for neutral badges, the web's button ring and shadows, and a darker primary fill so white labels reach AA. A test checks every text color against every background for 4.5:1. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Automatic checks (on by default) now run at every launch and every 5 hours while the app runs; Check now runs whether or not they are on. The last answered check is saved as update_checked_at. Settings, the network disclosure, the privacy page and the docs say the same. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
The card shows this version, its build date and when convt last checked, with Check now (a spinner and "Checking…" while it runs). A newer build gets a callout with its version, Release notes and Download (or Renew), and the switch for automatic checks sits below. Buttons gain a loading state. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
convt, File, Edit, Window and Help menus with the usual shortcuts, set only on macOS; the actions are registered everywhere. Check for Updates… checks now and shows Settings at the Updates card. Help links to the docs, the changelog and the contact page. Handlers run deferred so an action dispatched through the key window can bring it forward or close it. Adds a changeset. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
| chip: c(0xF5F7F6), // --chip | ||
| chip_border: c(0xE0E3E1), // --chip-line | ||
| text: c(0x0A0A0A), // --ink | ||
| secondary: c(0x6B6F6D), // --ink-2 |
There was a problem hiding this comment.
Unselected choices lack text contrast
In light mode, unselected Settings tabs and Quick convert quality choices now draw p.secondary (#6B6F6D) on p.track (#E6E8E7). This gives 4.14:1 contrast, below the stated 4.5:1 text target, making these choices harder to read. The new text_meets_aa test misses this because it excludes p.track.
Darken this text or lighten its background, and test this pair. Finding confidence: 5/5.
| "Release notes", | ||
| release_notes_url(VERSION), | ||
| )) | ||
| .child(link("about-source", "Source code", SOURCE_URL.into())), |
There was a problem hiding this comment.
The new About window’s “Source code” button opens the repository’s current default branch, not the source for the installed release. Someone looking for their release’s source gets a newer, changing code tree instead.
docs/plan.md requires About to link to matching source, and releases already carry a version-specific source archive. Use a source link tied to this build instead of SOURCE_URL. Finding confidence: 5/5.
| .into_any_element(), | ||
| Update::UpToDate => note( | ||
| lead(IconName::CircleCheck, p.green_text), | ||
| format!("You're up to date. convt {VERSION} is the newest version."), |
There was a problem hiding this comment.
Update result overstates the answer
The new Update::UpToDate message says VERSION “is the newest version,” but this state only means no newer build passed the date and package checks. A later release can exist without a package for this install and still produce this message, incorrectly calling the user’s older version the newest.
Say that no newer compatible build is available instead. Finding confidence: 5/5.
| format!("You're up to date. convt {VERSION} is the newest version."), | |
| "No newer compatible build is available.", |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…design-6808 # Conflicts: # crates/convt-app/src/ui/tests.rs Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
| pub(super) fn release_notes_url(version: &str) -> String { | ||
| format!("https://convt.app/changelog#v{version}") |
There was a problem hiding this comment.
release_notes_url links About to #v0.2.0, and the upcoming release to #v0.2.1. The website’s changelog only contains 0.1.0, so users reach old notes rather than the version shown by the app. Publish the matching sections or link to the matching published release.
Finding confidence: 5/5.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| track: c(0x232726), // --line | ||
| toggle_off: c(0x2E3331), // --separator | ||
| // Derived: a step past --line-strong, so an empty box keeps its edge. | ||
| mark_off: c(0x5A605D), |
There was a problem hiding this comment.
mark_off is now #5A605D on surface (#111312) in dark mode. That gives about 2.9:1 contrast, below the 3:1 target for control edges. Empty checkboxes and radio buttons both use this pair, making them harder to see. Lighten the outline and test this pair alongside the text colors.
Finding confidence: 5/5.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…design-6808 # Conflicts: # .agents/skills/test-convt-desktop/SKILL.md # crates/convt-app/src/ui/main_window.rs # crates/convt-app/src/ui/popover.rs Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
| text(size, 16., color) | ||
| .font_weight(FontWeight::MEDIUM) | ||
| .whitespace_nowrap() | ||
| .hover(|s| s.opacity(0.75)) |
There was a problem hiding this comment.
The new hover effect makes small links harder to read. text_button lowers text opacity to 75%; in light mode, p.secondary on white becomes about #909391, with only about 3.1:1 contrast. This affects Remove license and other shared links. Keep their text opaque and use another hover cue. The contrast tests should cover hover states too.
Finding confidence: 5/5.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
The General tab is taller on macOS (Finder and menu bar rows), so Release notes sat below the fold there. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Ctrl+Q and Ctrl+W did nothing on Linux and Windows, and Cmd+Q and Cmd+W nothing on macOS before the menu bar: gpui-kit leaves them to the app and nothing bound them. Bind secondary-q and secondary-w everywhere; the other Mac shortcuts and the menu bar stay macOS only. A test types them in each window, including with a text field focused, and quits from the menu with no window open. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…design-6808 Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
The SQLx CLI cache held only ~/.cargo/bin/sqlx, and the install step skipped when sqlx was there, so a cache hit left `cargo sqlx` missing. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…y on macOS Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…can't add one Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…design-6808 Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…f in its toggled state Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
…design-6808 # Conflicts: # crates/convt-app/src/ui/tests.rs
POST /api/device/cloud authenticates the device token like the other /api/device routes, checks paid Pro with cloudAllowance, and returns the API address and a five-minute cvt_web_ credential. It answers 403 not_pro, 503 not_configured, 401 signed_out and 429 rate_limited.
Registry::run uses it, and the desktop app's cloud jobs will publish their downloads through it so they follow the same naming rules.
…hidden defaults The main window's defaults bar is gone. Add files and files dropped on the window open Quick convert, so the user picks the format. The popover's drop bar still uses the defaults in settings.toml. The sidebar's "Files stay on this computer" line and the empty state's format list go too.
The footer's "Runs on this computer" note becomes a This computer / Cloud choice. Cloud is dimmed with the reason on hover until the account allows it. The first time Cloud is picked, Quick convert asks inline that the files upload to convt's servers and are deleted after; agreeing sets cloud_consent. Convert with Cloud calls AppState::convert_in_cloud and shows its error like any other. Settings now says files leave the computer only through Cloud.
Settings: the two dropdowns share one width and font, and the notification and reveal switches get their own "When a file is done" group instead of repeating it under each. The preset form lists targets by kind. Quick convert says what each image format is for instead of "Image". The Automations note sits under the rules at a readable width.
A card above the footer looked like it covered the scrolled options, and its Cancel sat right above the window's. The question now sits on the footer itself, with Not now and Agree.
cloud_access() decides offline from the stored sign-in and license: Ready needs a session and a current Pro key (a source build only the session). convert_in_cloud() queues cloud jobs through the same Runner, Queue and history as local ones. Each job asks convt.app for a credential with the device token, then creates, uploads, starts, polls and downloads against convt-server, and publishes through convt_core::Destination so results land and are named like local ones. Stop cancels the job on the server; Retry reruns it in the cloud; refusals read in plain words. Tests script both APIs; nothing touches the network.
With CONVT_API_URL and CONVT_WEB_TOKEN_SECRET both set, dev-web.sh adds them to apps/web/.dev.vars, so the dashboard converter and the desktop app's cloud jobs can reach a local convt-server.
Quick convert's Cloud choice now runs real cloud jobs. The cloud API takes no conversion options, so with Cloud picked Quick convert says it uses the standard settings and converts with none. Refs CNV-61, CNV-62
A job the app gave up on, for example after the server stayed busy while polling, stayed queued on the server and held storage until it expired. Refs CNV-62
The heading said Converting above a saved file while the footer said Done. It now reads Converting, then Converted when every job succeeded, or Finished when one failed or was cancelled.
A cloud job looked like a local one. Its row now shows a small cloud icon and "Cloud" after the target format, for running jobs and for history, which already kept the flag in Setup. The icon isn't in gpui-kit's default set and the whole Lucide catalog is megabytes, so the app's asset source serves Lucide's cloud.svg on top of gpui-kit's. Lucide's ISC notice already ships with gpui-kit-assets.
At 600x560 the Save card was cut off, even in Cloud mode. The window now opens at 600x680, plus the 24px title bar macOS draws inside it, and sections and option rows are a little tighter, so an image's three options and Save fit without scrolling. Video options still scroll.
A machine with a valid key, or a sign-in that fetched one, was offered "Start 7-day trial" next to "Pro until". Its plan step now says "You have Pro" or "You have a license", shows the plan selected, and Continue moves on without a trial or a key.
GPUI's tooltip follows the pointer and, near the window's bottom edge, flips up onto the control, so it covered the Cloud label it explained. A disabled choice now keeps its own hover state and draws the note in the overlay pass, 8px above its top left corner.
The repo is public and keeps email addresses out of its sources, so the sample sign-ins are joined with concat! instead of written out.
| pub(super) fn set_cloud(&mut self, cloud: bool, cx: &mut Context<Self>) { | ||
| self.cloud = cloud && self.cloud_access(cx).ready(); | ||
| self.error = None; | ||
| cx.notify(); | ||
| } |
There was a problem hiding this comment.
Cloud still needs local engines
Picking Cloud does not rebuild targets or the files returned by supported(). A Pro user opening a DOCX without LibreOffice still sees the document-pack offer and a disabled Convert button, even though the cloud supports DOCX → PDF. Build the choices and file list from the cloud’s formats when Cloud is selected.
Finding confidence: 5/5.
| Path::new(name) | ||
| .file_stem() | ||
| .and_then(|s| s.to_str()?.parse::<usize>().ok()) | ||
| .map_or(n, |k| k.saturating_sub(1)) |
There was a problem hiding this comment.
Downloaded pages get wrong names
pages_of() expects names such as 1.png, but the worker publishes input.png, input-2.png, and so on. It sorts these names before returning them, so a two-page PDF returns input-2.png before input.png. Both names fail the number parse, and the app saves page 2 as report.png and page 1 as report-2.png. Read the page number from the worker’s published names rather than using their list position.
Finding confidence: 5/5.
| let outputs = self.call(|api, c| api.outputs(c, id)).map_err(err)?; | ||
| if outputs.is_empty() { | ||
| return Err(fail("cloud_failed", "convt's cloud produced no file.")); | ||
| } | ||
| let mut files = Vec::new(); | ||
| for (i, output) in outputs.iter().enumerate() { | ||
| if cancel.is_cancelled() { | ||
| return Err(cancelled()); | ||
| } | ||
| // Staged under an index, never the server's name. | ||
| let path = staging.join(format!("{i}.part")); | ||
| api.download(&output.url, &path, cancel).map_err(err)?; |
There was a problem hiding this comment.
follow() gets every output link once, then downloads the files one at a time. The server signs those links for at most five minutes. If the first large output takes longer than that to download, the next request uses an expired link and the whole job fails, discarding the staged results. Refresh the output links before they expire or before each later download.
Finding confidence: 5/5.
| let transfers = Agent::config_builder() | ||
| .max_redirects(0) | ||
| .http_status_as_error(false) | ||
| .timeout_connect(Some(Duration::from_secs(30))) | ||
| .timeout_recv_response(Some(Duration::from_secs(300))) | ||
| .build() | ||
| .new_agent(); |
There was a problem hiding this comment.
Stop cannot interrupt stalled transfers
The transfer client never receives Cancel. Upload checks it between file reads, and download checks it between blocking network reads. If storage stalls inside send(), call(), or body.read(), pressing Stop only sets a flag; it cannot end that wait. The job keeps its cloud slot and does not send the server’s cancel request until the wait returns. Use network waits that can be interrupted or that regularly check cancellation.
Finding confidence: 4/5.
| Ok(files) => { | ||
| let pages = pages_of(&files); |
There was a problem hiding this comment.
Stopped results still get saved
There is no cancellation check between the last download and destination.publish(). If the user presses Stop while the last file is being synced to disk, download() returns success and the app publishes the files as a completed job. Check Cancel again before publishing, as Registry::run() does for local conversions.
Finding confidence: 5/5.
| Ok(files) => { | |
| let pages = pages_of(&files); | |
| Ok(files) => { | |
| if cancel.is_cancelled() { | |
| return Err(cancelled()); | |
| } | |
| let pages = pages_of(&files); |
| ApiError::SignedOut => fail( | ||
| "cloud_signed_out", | ||
| "This computer was signed out of convt.app. Sign in again to convert in the cloud.", | ||
| ), |
There was a problem hiding this comment.
Revoked accounts still look signed-in
When a cloud credential request returns SignedOut, the app records a job error but keeps account.session. Cloud therefore stays enabled, while Settings still shows the account as signed in and offers no Sign in button. After a dashboard revocation, the user is told to sign in again but must first discover that they need to sign out locally. Pass this result back to AppState and clear the matching revoked session, as license renewal already does.
Finding confidence: 5/5.
| fn consent(&self, p: &Palette, cx: &mut Context<Self>) -> Option<AnyElement> { | ||
| if !self.jobs.is_empty() || !self.in_cloud(cx) || self.app.read(cx).settings.cloud_consent { | ||
| return None; |
There was a problem hiding this comment.
Once cloud_consent is saved, later cloud conversions skip the question. The privacy page still says the app “asks you each time” and that the app never uploads a file. Shipping this upload path makes those statements false. Update the desktop and cloud sections to describe uploads and the saved consent, or keep the promised per-conversion question.
Finding confidence: 5/5.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Redesigns the GPUI desktop app (
crates/convt-app) from the theme up. Every window now uses one shared visual system instead of per-window colors and sizes. The redesign itself keeps behavior unchanged: the same ids, labels, flows, license and trial logic, Finder recovery card (CNV-41/42), sign-in and document pack. On top of it, for 0.2.1, this PR adds a macOS menu bar, checks for updates at every launch and every 5 hours, and restyles the Updates card. It also fixes Quit and Close Window from the keyboard: ⌘Q and ⌘W on macOS, Ctrl+Q and Ctrl+W on Linux and Windows. All of this is under "Menus, update checks and the Updates card". The menus, the Finder screens and the popover were then checked on a real macOS desktop in light and dark mode ("macOS: menus, Finder screens and popover"). That check, together with a Linux run of the same screens, found two popover layout bugs, both fixed here. Apart from the privacy page's update-check sentence, the website, /download and the API docs are untouched.The visual system (
src/ui/theme.rs)space(4 to 32),radius(5, 7, 10, 14) andsizetype steps (title, display, body, small, caption) in Geist and Geist Mono.apps/web/src/styles.css); see "Brand colors" below.Button: primary, secondary and ghost looks, an optional Lucide icon, small size, disabled, and a loading state with a spinner.badge,icon_tile,callout,card, andgroup/rowfor grouped settings.selectwith a check on the current choice,segmented,switch,checkbox,radio, and framed thumbnails.markandlockupare drawn fromdesign-assets/brand(the two overlapping squares, with the overlap shade), in both themes.Brand colors
Both palettes now come from the web app's tokens (light
:rootand.darkinapps/web/src/styles.css). Each line inPalette::light()andPalette::dark()names its web token.window--page#FFFFFF#0A0B0Bchrome(sidebar, toolbar)--sunken/--raised#F7F8F7#111312surface(cards, controls)--raised#FFFFFF#111312recessed(footers, wells)--sunken#F7F8F7#161918border,hairline,selected,track--line#E6E8E7#232726control_border--line-strong#D5D9D7#2E3331row_divider--divider#EEF0EF#232726chip/chip_border(neutral badges)--chip/--chip-line#F5F7F6/#E0E3E1#161918/#2E3331hover--hover#F3F4F3#1C201Etext/secondary/tertiary--ink/--ink-2/--ink-3#0A0A0A/#6B6F6D/#6C716E#EDEFEE/#A1A6A3/#868B88green,green_text--green#127A47#3FCB84green_tint/green_border--green-tint/--green-line#EEF7F2/#CFE6D9#12261B/#3FCB84at 20%error/error_border--error/--error-line#B3261E/#F0D4D1#F2786D/#F2786Dat 20%toggle_off--separator#D0D3D1#2E3331overlay(menus)--raised/--hover#FFFFFF#1C201E--shadow-button,--shadow-float,--shadow-note--mark-green-top,--mark-green-bottomand--mark-overlap. Radii are unchanged.error_tint(#FCF3F2light,#261716dark, made like--green-tint) andmark_off(the empty checkbox and radio edge, one step stronger than--line-strong)..btn-primaryring (#157F4A), inset highlight and shadows. The web fill (#22A867to#1B9259) gives white text about 3:1, so the desktop fill runs from--green#127A47to#0F6B3E, which is at least 5.3:1. The old desktop fill's top (#168A51) was 4.39:1, short of AA.ui::theme::contrast) checks every text color against every background in both palettes for 4.5:1, plus white on the primary fill. It caught one miss before commit (tertiary text on the light error tint), which is fixed.#2F6FE4). It now uses the brand green. File-type badge colors (audio purple, PDF red and so on) and the macOS window dots in the Finder preview are unchanged, because they stand for file types and the OS.theme: "dark"), so its light and dark captures are the same. /download follows the OS, so it is the light-mode reference.Activity light Activity dark
Quick convert light Quick convert dark Trial card light
Settings General light Settings General dark First run plan light First run plan dark
First run done light First run done dark
convt.app for comparison: convt.app landing (dark only) convt.app/download light convt.app/download dark
The Before/After tables below are from before this palette change.
Menus, update checks and the Updates card
macOS menu bar (
src/ui/menus.rs)os_actionitems bound to the text fields' own actions, so ⌘C and the rest work in the license and preset fields.cx.set_menusand the Mac-only shortcuts are installed only on macOS. The actions are registered on every platform, and Quit and Close Window are bound everywhere (see below).Quit and Close Window shortcuts (bug fix, all platforms)
menus::key_bindings()now binds Quit and Close Window assecondary-qandsecondary-w, which GPUI reads as ⌘ on macOS and Ctrl elsewhere.menus::initbinds them on every platform. The other Mac shortcuts (mac_key_bindings(): ⌘, ⌘H ⌥⌘H ⌘O ⌘M) and the menu bar stay macOS only.cx.quit(). Close Window closes the active window, the one you typed in. Text fields don't bind either key, so both shortcuts work with a field focused. On macOS the menu items show ⌘Q and ⌘W from these bindings, and they stay enabled with no window open (menu bar icon mode), since their handlers are global.Update checks (
src/update.rs)CHECK_INTERVAL). Before, it was once a UTC day at launch.SCHEDULE_TICK) instead of waiting out one long timer, so a check that came due while the computer slept runs soon after it wakes.update_checked_at(Unix seconds) records the last check that got an answer. It is saved together with the accepted manifest sequence, so the rollback guard is unchanged. The oldupdate_checkedday key is ignored.docs/plan.md,docs/update-integration.mdand the desktop test skill describe the new schedule.Updates card (Settings, General)
Button::loading).changelog#v<version>). A build the license doesn't cover gets the same callout with Renew. A failure is a quiet note.These are from a real run under Xvfb, against a local server with a manifest signed by a throwaway key (
CONVT_UPDATE_URLandCONVT_UPDATE_PUBKEY). The launch check found 0.2.1; I re-signed the manifest for the up-to-date shot, held the server's answer for the checking shot, and removed the manifest for the failure (HTTP 404). The sidebar card from the same launch: Main window with the update cardLinux has no way to open About, so its screenshots come from a scratch copy of the branch patched to open About at launch; that patch isn't committed. The real macOS menu bar is in the next section.
macOS: menus, Finder screens and popover
These come from a real macOS 15 desktop: GitHub's
macos-15runner, on a throwaway branch that isn't part of this PR.convt.appusingpackaging/macos/Info.plist, and drove it with System Events (the same approach ascnv-54-spanish-macos.ymlon main).screencapturetook the pictures.What the run checked, besides the pictures:
Bugs this found, now fixed:
b03bc83).fe15828).windows_fit_their_content_at_their_opening_sizesnow opens the popover with a long rule line and checks that the switch fits; without the fix it fails.Screens
Activity
main-activity-dark.png main-empty-dark.png
Trial and Pro surfaces
main-trial-ended-light.png quick-blocked-light.png
Settings
settings-general-2-light.png settings-license-dark.png
Quick convert
quick-picked-light.png quick-picked-dark.png
First run
FIRST_RUN_SIZE).finder-preview, "Preview", "This picture isn't a switch."), now drawn as a mock System Settings window.first-run-plan-dark.png
Also restyled:
Automations, light Automations, dark
Small visible changes besides the look
update-version,update-last-checkedandupdate-notes.Tests
cargo fmt --all --checkpasses.cargo clippy --all-targets -- -D warningsandcargo clippy -p convt-app --all-targets -- -D warningspass.cargo test -p convt-apppasses (135 tests, including the two contrast tests and main's automation tests), and so doescargo teston the default members.bun run rs:checkpasses, and so dobun run check,bun run check-typesandbun run buildfor the privacy page change.mainis merged in, including #81 (9.x versions in the update tests), #83 (the macOS menu bar app stays alive after its last window closes; the menus and Quit work with it), #90 (automations run) and #80 (TypeScript and database CI jobs). The latest merge (99ac631) adds #91 (Homebrew install), #93 (photo orientation), #88 ($0 Desktop orders on Billing) and #96 (no console window on Windows); the only conflict wasci.yml, below. Main's Automations page and popover content moved into the redesigned layout; the old "Rules don't run yet" callout is gone. CI runs ten checks on the head commit: Rust core and desktop on Linux, macOS and Windows, Web, Release scripts, Database integration tests, and Greptile. All pass..github/workflows/ci.yml: this branch had fixed ci: run TypeScript and database-backed tests #80's database job cache, which held onlysqlxand notcargo-sqlx, socargo sqlx prepare --checkfailed on a cache hit. Main has since fixed it the same way, so the merge takes main's file and this PR no longer changesci.yml.renamed_binary_uses_convt_in_help_and_errors(convt-cli) hit "Text file busy" running a just-copied binary. The database fixture test "pro has its sample data" ran at 00:00 UTC against data seeded before midnight, so its 30-day window lost a day.fb6a3acis an empty commit that only re-triggered CI.accountkey in the settings test reads"fixture-account".reveal_updates), because the General tab is taller on macOS and Release notes sat below the fold there.a_running_app_checks_again_every_few_hours: a launch check, then no fetch beforeCHECK_INTERVAL, a fetch once it has passed, and none with automatic checks off. It drives the timer withadvance_clock.update_checks_off_ask_only_when_the_user_does: off makes no launch request, while Check now still checks.check_now_shows_that_it_is_checkingandupdate_dates_read_as_words. The covered-update test also checks the version and last-checked rows, Release notes and Download, and that a second launch checks again.the_menu_bar_has_the_menus_mac_apps_have:cx.set_menus(menus()), read back withcx.get_menus(), item by item.the_menu_shortcuts_are_the_usual_ones: ⌘, opens Settings, and the Close Window shortcut closes the key window.quit_and_close_window_shortcuts_work_on_every_platform: types the platform's literal keys (cmd-q/cmd-won macOS,ctrl-q/ctrl-won Linux and Windows). They reach Quit from the main window and from Settings with the license field focused. Close Window closes only the window typed in, then the main window. Quit is enabled and quits from the menu with no window open. Each key has exactly one binding. GPUI's test platform makesquit()a no-op, so a second Quit listener counts the action and passes it on to the app's handler. With the all-platform binding removed, the test fails on Ctrl+Q, so it catches the shipped bug. Linux CI runs it with Ctrl, macOS CI with ⌘, and Windows CI with Ctrl.check_for_updates_checks_now_and_shows_the_result: with automatic checks off, it covers available (Download in view), up to date and offline, all in one Settings window.about_and_help_open_what_they_say: About's version and links, plus the docs, changelog and contact URLs.the_edit_menu_reaches_the_text_fields: the Edit menu's own Select All, Copy, Cut and Undo items, dispatched to a focused license field.the_icons_the_windows_draw_are_bundlednow lists every icon the windows draw.FIRST_RUN_SIZE.empty-add-filesbutton.windows_fit_their_content_at_their_opening_sizeschecks that the button fits, and now About too, plus the popover's switch next to a long rule line.test-convt-desktoprecipe), in both themes. The licensed shots use a build withCONVT_LICENSE_ENFORCE=1and a throwaway test key..agents/skills/test-convt-desktop/SKILL.mdnow describes the shared theme, the new first-run size and its window position, the update schedule, the Updates card's ids, and how to test the menus and the Quit and Close Window shortcuts.Release
.changeset/desktop-ui-redesign.mdbumps@convt/desktopby a patch with one user-facing line, so the redesign ships in 0.2.1..changeset/desktop-menus-and-update-checks.mdadds a second patch line for the menus, the Quit and Close Window shortcuts on every platform, the update schedule and the Updates card.bunx --bun @changesets/cli versionandsync-version.py) in a scratch worktree, next to the existing Linux changeset. All three versioned packages go to 0.2.1, each gets a 0.2.1 changelog, and the desktop changelog carries the new line.Not verified here
integrations/macos("Convert with convt" in Finder) is unchanged and needs Xcode, so it was not built. The macOS run used a bundle without it and set the extension state through the scratch variable, so the Finder screens are real renders of each state, but nobody switched a real extension on.packaging/macos/bundle.sh's notarized app.To show artifacts inline, enable in settings.