Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/desktop-design-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Desktop design review

Local-only review of the runner's navy-and-teal desktop shell: compact sidebar,
page toolbar, bounded forms, consistent sections, thin scrollbars, and a
strongly rounded launcher silhouette. Sidebar branding supplies 1x–4x rasters
for sharp Windows scaling; the approved source mark is unchanged.

On Windows, run `scripts/Start-DesktopPreview.ps1` with `-JavaHome` pointing to
JDK 21 and `-MavenCommand` pointing to `mvn.cmd` if it is not on PATH.
This compiles classes only, not a release or installer. The preview opens on
Runner and allows navigation through Connection, Provider, and Activity.
It uses temporary sample settings, does not read the installed runner's keys
or identity, disables operational actions, and does not start paid work.
Close the preview normally when finished.
Minimize, maximize/restore, and close remain available; only actions inside the
app's content area are disabled.

Verification: desktop layout/window tests cover all pages at narrow/wide sizes
and large fonts. Icon tests check transparent rounded corners, deterministic
native output, and device-resolution variants. Installer/release builds and
publishing remain pending explicit design approval.
2 changes: 1 addition & 1 deletion docs/first-run-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Completed scope

- Five-step setup checklist, available from Runs and the sidebar after User guide.
- Five-step setup checklist, available from **Open setup checklist** on Runs.
- Repository selection and ten-second, non-overlapping metadata polling with
explicit stale/error states and manual refresh.
- Saved harness, intake label, assignee, budget, approval, auto-merge, and gate visibility.
Expand Down
3 changes: 2 additions & 1 deletion docs/settings-consolidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ their captured limits; no stored settings or enforcement were removed.
The getting-started banner's dismissal is persisted in localStorage per GitHub
identity and workspace. It survives reloads in that browser; clearing storage or
using another browser resets it. Blocked storage still permits session dismissal.
Getting started remains accessible in the sidebar. No secrets are stored.
The checklist remains accessible from **Open setup checklist** on Runs, not as
a separate sidebar tab. No secrets are stored.
Label and assignment gates are independent. Label gating defaults on; administrators
can bypass it. Issue Chat can opt into adding the configured label on publication.
Both paths warn about potential immediate provider spending.
1 change: 1 addition & 0 deletions frontend/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ for (const width of [390, 1440]) {
await expect(page.getByRole('heading', { name: 'Getting started', exact: true })).toBeVisible();
await expect(page.getByText('No enabled runner is enrolled.', { exact: true })).toBeVisible();
const navigation = page.getByRole('navigation', { name: 'ForgeLoop navigation' });
await expect(navigation.getByRole('button', { name: 'Getting started', exact: true })).toHaveCount(0);
if (width === 390) await page.getByRole('button', { name: 'Open navigation' }).click();
await expect(navigation.getByRole('button', { name: 'Issue chat', exact: true })).toBeVisible();
await expect(navigation.getByRole('button', { name: 'User guide', exact: true })).toBeVisible();
Expand Down
51 changes: 20 additions & 31 deletions frontend/src/docs/runner-setup.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
## Desktop downloads and updates

Open [Download runner](/app/runner-downloads) for the latest published desktop
preview. Downloads come from GitHub Releases and do not require signing in.
Each operating system has one card: Linux, Windows, then macOS. Use its package
dropdown to choose a format and architecture; the download and verification
details always match that selection.
Choose Windows x64/ARM64, macOS Apple Silicon (arm64) or Intel (x64), or a Linux
x64/arm64 package: DEB for Debian/Ubuntu, RPM for Fedora/RHEL/openSUSE, a portable
`.tar.gz` archive, or a native Arch `.pkg.tar.zst` package for x64 and ARM64.
Arch and Omarchy users can install the matching package with `sudo pacman -U`
followed by the downloaded `.pkg.tar.zst` filename.
The portable archive works without AppImage or FUSE. Java is bundled; Git and
Docker are required.

Linux credential storage requires `secret-tool` and an unlocked desktop Secret
Service keyring; install the libsecret command-line tools for your distribution.

Preview installers are unsigned and macOS previews are not notarized. Windows
and macOS may warn or block installation; use previews only if your device policy
permits them. Signing remains a separate release milestone.

Expand **Verify download** to copy SHA-256 and see the command for your platform.
Compare the entire hash. A mismatch means the file must not be installed.
Checksums confirm file integrity, not the publisher's identity.

For updates, pause the runner, wait for current work to finish, close it, install
the newer package and reopen it. Your identity, provider keys and settings are
retained outside the installation folder. The website discovers new releases
automatically within about five minutes; the app does not install updates itself.
1. **Download.** Open [Download runner](/app/runner-downloads). Choose your OS card,
then a package and architecture: x64 or ARM64 (macOS: Intel or Apple Silicon).
2. **Verify.** Expand **Verify download**, run the SHA-256 command, and compare
the full hash. Do not install a mismatch. Checksums verify integrity, not identity.
3. **Install.** Open the installer or extract the archive. Java is bundled;
install Git and Docker separately.
4. **Update.** Pause, wait for current work to finish, close the runner, install
the newer package, and reopen. Saved keys and settings remain. Updates are manual.

**Linux notes**

- Debian/Ubuntu/Mint: DEB. Fedora/RHEL/openSUSE: RPM.
- Arch/Omarchy: `sudo pacman -U <downloaded-file.pkg.tar.zst>`.
- Portable `.tar.gz`: extract and run; no AppImage or FUSE needed.
- Credentials: install libsecret's `secret-tool` and unlock your desktop Secret Service keyring.

**Preview warning:** Installers are unsigned; macOS builds are not notarized.
Your OS may block installation. Proceed only if your device policy permits it.

## Navigation and usage tracking

Expand All @@ -38,7 +28,7 @@ the confirmation checkbox to enable approval. Approval alone never starts paid w
On mobile, open the hamburger button in the header for the navigation dropdown.
It includes **Support** and **Sign out**, closes when you choose a page, and
supports Escape to close. Desktop keeps the sidebar and a Sign out button in the
header. Expand **User guide**, the fifth sidebar item, to choose Quick start,
header. Expand **User guide** to choose Quick start,
Runner setup & API keys, Repositories, Runs, Settings, Delivery, or Troubleshooting.
Choosing a section opens the guide at that section and closes the mobile menu;
expanding the submenu itself keeps the mobile menu open.
Expand Down Expand Up @@ -69,8 +59,7 @@ refresh every ten seconds; failures show a stale-data warning and a retry button

## Start with the setup checklist

Open **Getting started** in the dashboard sidebar, or **Open setup checklist**
on Runs. User guide remains the fifth sidebar item. The checklist refreshes
Select **Open setup checklist** on Runs. The checklist refreshes
organization-scoped repository, runner, policy, and run metadata every 10 seconds:

1. **Connect GitHub.** An administrator installs the ForgeLoop GitHub App and
Expand Down
1 change: 0 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,6 @@ function App() {
</button>
<button className={page === "Configuration" ? "active" : ""} onClick={() => navigate("Configuration")}><span className="nav-icon" aria-hidden="true"><SlidersHorizontal size={17} strokeWidth={1.9} /></span> Settings</button>
<GuideNavigation active={page === 'Guide'} onSelect={id => { setGuideTarget({ id }); navigate('Guide'); }}/>
<button className={page === "GettingStarted" ? "active" : ""} onClick={() => navigate("GettingStarted")}><span className="nav-icon" aria-hidden="true"><ListChecks size={17} strokeWidth={1.9} /></span> Getting started</button>
{operator?.role === 'ADMIN' && <button className={page === 'Team' ? 'active' : ''} onClick={() => navigate('Team')}><span className="nav-icon" aria-hidden="true"><Users size={17} strokeWidth={1.9}/></span> Team</button>}
<button className={`account-console-link ${page === 'Account' ? 'active' : ''}`} onClick={() => navigate('Account')}><span className="nav-icon" aria-hidden="true"><UserRound size={17} strokeWidth={1.9}/></span> Account</button>
<a className="logout mobile-signout" href="/logout">Sign out</a>
Expand Down
27 changes: 24 additions & 3 deletions runner/src/main/java/io/forgeloop/runner/DesktopIcons.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io.forgeloop.runner;

import java.awt.RenderingHints;
import java.awt.AlphaComposite;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.BaseMultiResolutionImage;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
Expand All @@ -12,6 +15,17 @@
/** Native launcher formats derived from the same favicon used by the desktop window. */
public final class DesktopIcons {
private DesktopIcons() { }
/** Swing selects a device-resolution raster instead of enlarging the 1x sidebar icon. */
static javax.swing.ImageIcon displayIcon(int logicalSize) {
return new javax.swing.ImageIcon(new BaseMultiResolutionImage(windowImage(logicalSize),windowImage(logicalSize*2),windowImage(logicalSize*3),windowImage(logicalSize*4)));
}
/** The launcher, taskbar, and in-app brand share the same strongly rounded silhouette. */
static BufferedImage windowImage(int size) {
try(var resource=DesktopIcons.class.getResourceAsStream("/desktop/favicon.png")) {
if(resource==null)throw new IllegalStateException("ForgeLoop favicon is missing");
return rounded(ImageIO.read(resource),size);
}catch(java.io.IOException error){throw new IllegalStateException("Cannot load ForgeLoop icon",error);}
}
public static void main(String[] args) throws Exception {
if (args.length != 1) throw new IllegalArgumentException("An output directory is required");
write(Path.of(args[0]));
Expand Down Expand Up @@ -53,14 +67,21 @@ static void write(Path directory) throws Exception {
Files.write(directory.resolve("forgeloop.icns"), icns.array());
}
private static byte[] png(BufferedImage source, int size) throws Exception {
var scaled=rounded(source,size);
var bytes = new ByteArrayOutputStream();
if (!ImageIO.write(scaled, "png", bytes)) throw new IllegalStateException("PNG encoder unavailable");
return bytes.toByteArray();
}
private static BufferedImage rounded(BufferedImage source,int size) {
var scaled = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
var graphics = scaled.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
graphics.fill(new RoundRectangle2D.Double(0,0,size,size,size*.44,size*.44));
graphics.setComposite(AlphaComposite.SrcIn);
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.drawImage(source, 0, 0, size, size, null);
} finally { graphics.dispose(); }
var bytes = new ByteArrayOutputStream();
if (!ImageIO.write(scaled, "png", bytes)) throw new IllegalStateException("PNG encoder unavailable");
return bytes.toByteArray();
return scaled;
}
}
34 changes: 28 additions & 6 deletions runner/src/main/java/io/forgeloop/runner/DesktopLayout.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ static JPanel field(String label, JComponent control, String help) {
}

static JPanel section(String title, String subtitle, JComponent body) {
JPanel card = stack(14);
JPanel card = stack(12);
card.setBackground(DesktopTheme.SURFACE);
card.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(DesktopTheme.BORDER),
BorderFactory.createEmptyBorder(20, 20, 20, 20)));
new com.formdev.flatlaf.ui.FlatLineBorder(new Insets(0,0,0,0),DesktopTheme.BORDER,1,12),
BorderFactory.createEmptyBorder(18, 20, 18, 20)));
JTextArea heading = text(title);
heading.setFont(heading.getFont().deriveFont(Font.BOLD, heading.getFont().getSize2D() + 2));
card.add(heading);
Expand All @@ -70,7 +70,7 @@ private static void transparentChildren(Component component) {
}

static JPanel page(String title, String description, JComponent... sections) {
JPanel page = stack(20);
JPanel page = stack(18);
JPanel heading = stack(7);
heading.add(DesktopTheme.heading(title));
heading.add(muted(description));
Expand All @@ -89,7 +89,7 @@ static JPanel actions(JComponent... controls) {

static JScrollPane scroll(JComponent content) {
JPanel page = new ScrollablePage();
page.setBorder(BorderFactory.createEmptyBorder(24, 24, 24, 24));
page.setBorder(BorderFactory.createEmptyBorder(28, 32, 28, 32));
page.add(content);
JScrollPane scroll = new JScrollPane(page, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
Expand All @@ -100,14 +100,36 @@ static JScrollPane scroll(JComponent content) {
}

private static final class ScrollablePage extends JPanel implements Scrollable {
ScrollablePage() { super(new StackLayout(0)); }
ScrollablePage() { super(new ContentLayout()); }
@Override public Dimension getPreferredScrollableViewportSize() { return new Dimension(820, 510); }
@Override public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) { return 20; }
@Override public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) { return Math.max(20, visible.height - 20); }
@Override public boolean getScrollableTracksViewportWidth() { return true; }
@Override public boolean getScrollableTracksViewportHeight() { return false; }
}

/** Keep forms readable on large monitors, while tracking narrow viewports without clipping. */
private static final class ContentLayout extends BorderLayout {
@Override public Dimension preferredLayoutSize(Container parent) {
Insets insets=parent.getInsets();
Component content=parent.getComponentCount()==0?null:parent.getComponent(0);
if(content==null)return new Dimension(1,1);
int width=Math.min(820,Math.max(1,parent.getWidth()-insets.left-insets.right));
content.setSize(width,content.getHeight());
return new Dimension(width+insets.left+insets.right,content.getPreferredSize().height+insets.top+insets.bottom);
}
@Override public void layoutContainer(Container parent) {
if(parent.getComponentCount()==0)return;
Insets insets=parent.getInsets();
int available=Math.max(1,parent.getWidth()-insets.left-insets.right);
int width=Math.min(820,available);
Component content=parent.getComponent(0);
content.setSize(width,content.getHeight());
content.setBounds(insets.left+(available-width)/2,insets.top,width,content.getPreferredSize().height);
if(content instanceof Container container)container.doLayout();
}
}

/** Measure children at the actual viewport width before assigning their natural height. */
private static final class StackLayout implements LayoutManager {
private final int gap;
Expand Down
Loading
Loading