diff --git a/docs/desktop-design-preview.md b/docs/desktop-design-preview.md new file mode 100644 index 0000000..4234615 --- /dev/null +++ b/docs/desktop-design-preview.md @@ -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. diff --git a/docs/first-run-onboarding.md b/docs/first-run-onboarding.md index dce9dbe..0f1cc74 100644 --- a/docs/first-run-onboarding.md +++ b/docs/first-run-onboarding.md @@ -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. diff --git a/docs/settings-consolidation.md b/docs/settings-consolidation.md index 6bcada2..66cb543 100644 --- a/docs/settings-consolidation.md +++ b/docs/settings-consolidation.md @@ -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. diff --git a/frontend/e2e/onboarding.spec.ts b/frontend/e2e/onboarding.spec.ts index b0999a4..f271009 100644 --- a/frontend/e2e/onboarding.spec.ts +++ b/frontend/e2e/onboarding.spec.ts @@ -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(); diff --git a/frontend/src/docs/runner-setup.md b/frontend/src/docs/runner-setup.md index 1765816..dcb4333 100644 --- a/frontend/src/docs/runner-setup.md +++ b/frontend/src/docs/runner-setup.md @@ -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 `. +- 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 @@ -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. @@ -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 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a1f6aa2..0bd385c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -926,7 +926,6 @@ function App() { { setGuideTarget({ id }); navigate('Guide'); }}/> - {operator?.role === 'ADMIN' && } Sign out diff --git a/runner/src/main/java/io/forgeloop/runner/DesktopIcons.java b/runner/src/main/java/io/forgeloop/runner/DesktopIcons.java index ed205f4..fa2761c 100644 --- a/runner/src/main/java/io/forgeloop/runner/DesktopIcons.java +++ b/runner/src/main/java/io/forgeloop/runner/DesktopIcons.java @@ -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; @@ -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])); @@ -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; } } diff --git a/runner/src/main/java/io/forgeloop/runner/DesktopLayout.java b/runner/src/main/java/io/forgeloop/runner/DesktopLayout.java index 5859e9f..6987d84 100644 --- a/runner/src/main/java/io/forgeloop/runner/DesktopLayout.java +++ b/runner/src/main/java/io/forgeloop/runner/DesktopLayout.java @@ -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); @@ -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)); @@ -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); @@ -100,7 +100,7 @@ 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); } @@ -108,6 +108,28 @@ private static final class ScrollablePage extends JPanel implements Scrollable { @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; diff --git a/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java b/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java index d180796..fed8081 100644 --- a/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java +++ b/runner/src/main/java/io/forgeloop/runner/DesktopRunner.java @@ -55,7 +55,7 @@ public final class DesktopRunner { private final Timer statusTimer; DesktopRunner(Path directory,boolean autoStart)throws Exception{ this.directory=directory;worker=new DesktopWorker(directory); - var icon=DesktopRunner.class.getResource("/desktop/favicon.png");if(icon!=null)frame.setIconImage(new ImageIcon(icon).getImage()); + frame.setIconImage(DesktopIcons.windowImage(256)); model.setEditable(true); if(Files.exists(directory.resolve("config.json"))){configuration=JSON.readValue(directory.resolve("config.json").toFile(),DesktopConfiguration.class);endpoint.setText(configuration.endpoint());provider.setSelectedItem(configuration.provider());model.setSelectedItem(configuration.model());input.setText(configuration.inputUsdPerMillion()==null?"":configuration.inputUsdPerMillion().toPlainString());output.setText(configuration.outputUsdPerMillion()==null?"":configuration.outputUsdPerMillion().toPlainString());manualPrices="manual".equals(configuration.priceSource())||(configuration.priceSource()==null&&configuration.inputUsdPerMillion()!=null);} if(Files.exists(directory.resolve("endpoint")))endpoint.setText(Files.readString(directory.resolve("endpoint"))); @@ -111,16 +111,17 @@ public final class DesktopRunner { key.setToolTipText("Leave blank to keep the saved key for this provider. Enter a key only to replace it."); JButton checkKey=new JButton("Check saved key locally");providerForm.add(DesktopLayout.actions(checkKey));providerForm.add(DesktopLayout.text("Checks protected storage, not API credit or key validity.")); checkKey.addActionListener(e->{String selected=(String)provider.getSelectedItem();background(()->{new DesktopSecretStore(directory,selected).load();log("Saved key is readable from protected storage. No API request was made.");SwingUtilities.invokeLater(()->{if(selected.equals(provider.getSelectedItem()))keyStatus.setText("Saved key readable - no API request made");});});}); + providerForm.add(DesktopLayout.actions(save)); JPanel prices=DesktopLayout.stack(16);prices.add(DesktopLayout.field("Input USD / million tokens",input,null));prices.add(DesktopLayout.field("Output USD / million tokens",output,null));prices.setVisible(manualPrices); JButton advanced=new JButton(manualPrices?"Use automatic prices":"Use manual prices");advanced.setToolTipText("Override public rates for custom models or account-specific pricing.");advanced.addActionListener(e->{manualPrices=!manualPrices;selectedQuote=null;priceLookupVersion.incrementAndGet();lookingUpPrice=false;prices.setVisible(manualPrices);advanced.setText(manualPrices?"Use automatic prices":"Use manual prices");if(!manualPrices)refreshPricing();else priceStatus.setText("Manual override: enter both prices, or leave both blank for N/A.");}); JPanel pricing=DesktopLayout.stack(16);pricing.add(priceStatus);pricing.add(DesktopLayout.actions(advanced));pricing.add(prices); - JPanel consent=DesktopLayout.stack(12);consent.add(DesktopLayout.actions(login));consent.add(DesktopLayout.text("Optional: automatically process eligible work when you sign in. Model API calls can incur charges."));consent.add(DesktopLayout.actions(save)); + JPanel consent=DesktopLayout.stack(12);consent.add(DesktopLayout.actions(login));consent.add(DesktopLayout.muted("Optional: automatically process eligible work when you sign in. Model API calls can incur charges. Save provider settings to apply this choice.")); JPanel providerStep=DesktopLayout.page("Model & provider","Choose the model that will do the work. Credentials stay on this computer.", DesktopLayout.section("Provider settings",null,providerForm), DesktopLayout.section("Token pricing","Public base rates are looked up automatically.",pricing), - DesktopLayout.section("Save your settings",null,consent)); + DesktopLayout.section("Startup behavior",null,consent)); JPanel controls=DesktopLayout.stack(16); - runnerState.setFont(runnerState.getFont().deriveFont(Font.BOLD,24f)); + runnerState.setFont(runnerState.getFont().deriveFont(Font.PLAIN,30f)); controls.add(runnerState);controls.add(runnerDetails);controls.add(DesktopLayout.actions(start,pause)); controls.add(DesktopLayout.muted("Starting processes eligible issues and can use API credits. Docker starts automatically if needed.")); controls.add(DesktopLayout.actions(cancelDockerStartup));cancelDockerStartup.setVisible(false); @@ -195,7 +196,7 @@ public final class DesktopRunner { GraphicsConfiguration display=frame.getGraphicsConfiguration();Rectangle screen=display.getBounds();Insets systemInsets=Toolkit.getDefaultToolkit().getScreenInsets(display); int usableWidth=screen.width-systemInsets.left-systemInsets.right,usableHeight=screen.height-systemInsets.top-systemInsets.bottom; frame.setMinimumSize(new Dimension(Math.min(640,usableWidth),Math.min(560,usableHeight))); - frame.setSize(Math.min(1040,usableWidth),Math.min(780,usableHeight));frame.setLocationByPlatform(true);frame.setVisible(true); + frame.setSize(Math.min(1120,usableWidth),Math.min(780,usableHeight));frame.setLocationByPlatform(true);frame.setVisible(true); if(autoStart&&configuration!=null&&configuration.startAtLogin())background(()->{startWorker();log("Started using your saved sign-in consent.");}); } private JButton guideButton(String label,URI guide){ @@ -246,6 +247,19 @@ else if((workActive&&selected==0)||(!workActive&&selected==1)){ } /** UI verification may dispose an idle window without terminating its test JVM. */ void disposeIdle(){if(worker.running()||busy.get())throw new IllegalStateException("Cannot dispose active runner");statusTimer.stop();frame.dispose();} + /** Visual previews cannot connect, write credentials, or accidentally start paid work. */ + void designPreview() { + statusTimer.stop(); + status.setText("DESIGN PREVIEW · Isolated sample settings · No connection or paid work"); + // FlatLaf's caption buttons live outside the content pane: preserve window controls. + disablePreviewActions(frame.getContentPane()); + } + private static void disablePreviewActions(Container parent) { + for(Component child:parent.getComponents()) { + if(child instanceof AbstractButton button)button.setEnabled(button.getName()!=null&&button.getName().startsWith("navigation-")); + if(child instanceof Container container)disablePreviewActions(container); + } + } JFrame window(){return frame;} private RunnerClient client(URI uri){return new RunnerClient(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build(),uri);} /** Never decrypt credentials just to render the UI; distinguish configured from verified. */ diff --git a/runner/src/main/java/io/forgeloop/runner/DesktopShell.java b/runner/src/main/java/io/forgeloop/runner/DesktopShell.java index 9c7b53a..9f79b70 100644 --- a/runner/src/main/java/io/forgeloop/runner/DesktopShell.java +++ b/runner/src/main/java/io/forgeloop/runner/DesktopShell.java @@ -14,19 +14,19 @@ final class DesktopShell extends JPanel { private final JLabel brand = new JLabel("ForgeLoop"); private final JLabel version = new JLabel("Runner " + DesktopVersion.current()); private final String[] titles = {"Connection", "Provider", "Runner", "Activity"}; + private final JLabel location = new JLabel("Runner overview"); private Boolean compactMode; DesktopShell(DesktopPages pages, JComponent status) { super(new BorderLayout()); - sidebar.setBackground(new Color(0x0F1620)); + sidebar.setBackground(DesktopTheme.SIDEBAR); sidebar.setBorder(BorderFactory.createCompoundBorder( BorderFactory.createMatteBorder(0, 0, 0, 1, DesktopTheme.BORDER), - BorderFactory.createEmptyBorder(24, 12, 18, 12))); + BorderFactory.createEmptyBorder(20, 12, 18, 12))); JPanel top = DesktopLayout.stack(26); top.setOpaque(false); brand.setFont(brand.getFont().deriveFont(Font.BOLD, 18f)); - var logo = DesktopShell.class.getResource("/desktop/favicon.png"); - if (logo != null) brand.setIcon(new ImageIcon(new ImageIcon(logo).getImage().getScaledInstance(30, 30, Image.SCALE_SMOOTH))); + brand.setIcon(DesktopIcons.displayIcon(30)); brand.setIconTextGap(9); top.add(brand); JPanel links = DesktopLayout.stack(7); @@ -52,6 +52,14 @@ final class DesktopShell extends JPanel { version.setFont(version.getFont().deriveFont(11f)); sidebar.add(version, BorderLayout.SOUTH); JPanel main = new JPanel(new BorderLayout()); + JPanel toolbar=new JPanel(new BorderLayout()); + toolbar.setBackground(DesktopTheme.SIDEBAR); + toolbar.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createMatteBorder(0,0,1,0,DesktopTheme.BORDER),BorderFactory.createEmptyBorder(14,24,14,24))); + location.setFont(location.getFont().deriveFont(Font.BOLD)); + toolbar.add(location,BorderLayout.WEST); + JLabel local=new JLabel("LOCAL WORKSPACE");local.setForeground(DesktopTheme.MUTED);local.setFont(local.getFont().deriveFont(10f)); + toolbar.add(local,BorderLayout.EAST); + main.add(toolbar,BorderLayout.NORTH); main.add(pages, BorderLayout.CENTER); JPanel footer = new JPanel(new BorderLayout()); footer.setBorder(BorderFactory.createCompoundBorder( @@ -70,9 +78,10 @@ final class DesktopShell extends JPanel { } private void refreshSelection(int selected) { + location.setText(titles[selected]+(selected==2?" overview":" settings")); for (JButton link : navigation) { boolean active = link.getName().equals("navigation-" + selected); - link.setBackground(active ? new Color(0x183A34) : sidebar.getBackground()); + link.setBackground(active ? DesktopTheme.SELECTED : sidebar.getBackground()); link.setForeground(active ? DesktopTheme.ACCENT : DesktopTheme.MUTED); link.putClientProperty("JButton.buttonType", active ? null : "borderless"); } @@ -83,7 +92,7 @@ private void resizeNavigation() { boolean compact = getWidth() > 0 && getWidth() < 820; if (compactMode != null && compactMode == compact) return; compactMode = compact; - sidebar.setPreferredSize(new Dimension(compact ? 68 : 186, 0)); + sidebar.setPreferredSize(new Dimension(compact ? 68 : 210, 0)); brand.setText(compact ? "" : "ForgeLoop"); version.setText(compact ? "" : "Runner " + DesktopVersion.current()); for (JButton link : navigation) { diff --git a/runner/src/main/java/io/forgeloop/runner/DesktopTheme.java b/runner/src/main/java/io/forgeloop/runner/DesktopTheme.java index c3bbf07..babb70d 100644 --- a/runner/src/main/java/io/forgeloop/runner/DesktopTheme.java +++ b/runner/src/main/java/io/forgeloop/runner/DesktopTheme.java @@ -8,13 +8,15 @@ final class DesktopTheme { static final Color BACKGROUND = new Color(0x0B1017); static final Color SURFACE = new Color(0x111923); + static final Color SIDEBAR = new Color(0x0F1620); + static final Color SELECTED = new Color(0x183A34); static final Color BORDER = new Color(0x273342); static final Color MUTED = new Color(0x94A3B5); static final Color ACCENT = new Color(0x37D9AF); private DesktopTheme() {} static void install() { FlatDarkLaf.setup(); - UIManager.put("defaultFont", new Font(Font.SANS_SERIF, Font.PLAIN, 14)); + UIManager.put("defaultFont", new Font("Segoe UI", Font.PLAIN, 13)); UIManager.put("Panel.background", BACKGROUND); UIManager.put("Label.foreground", new Color(0xE8EDF4)); for(String component:new String[]{"TextField","PasswordField","ComboBox","TabbedPane"})UIManager.put(component+".background",SURFACE); @@ -28,14 +30,16 @@ static void install() { UIManager.put("Button.arc", 8); UIManager.put("TextComponent.arc", 8); UIManager.put("Component.focusColor", ACCENT); - UIManager.put("ScrollBar.width", 10); - UIManager.put("Button.margin", new Insets(8, 14, 8, 14)); - UIManager.put("TextField.margin", new Insets(8, 10, 8, 10)); - UIManager.put("PasswordField.margin", new Insets(8, 10, 8, 10)); + UIManager.put("ScrollBar.width", 8); + UIManager.put("ScrollBar.thumbArc", 999); + UIManager.put("ScrollBar.showButtons", false); + UIManager.put("Button.margin", new Insets(6, 12, 6, 12)); + UIManager.put("TextField.margin", new Insets(7, 10, 7, 10)); + UIManager.put("PasswordField.margin", new Insets(7, 10, 7, 10)); } static JLabel heading(String text) { JLabel label = new JLabel(text); - label.setFont(label.getFont().deriveFont(Font.BOLD, 26f)); + label.setFont(label.getFont().deriveFont(Font.BOLD, 24f)); return label; } static void primary(JButton button) { diff --git a/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java b/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java index bfe7be2..f7f9ac9 100644 --- a/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java +++ b/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java @@ -20,9 +20,21 @@ class DesktopIconsTest { } } @TempDir Path directory; + @Test void providesSharpDeviceResolutionVariantsForSidebarBrand() { + var icon=DesktopIcons.displayIcon(30); + assertEquals(30,icon.getIconWidth()); + var image=(java.awt.image.MultiResolutionImage)icon.getImage(); + assertEquals(4,image.getResolutionVariants().size()); + assertEquals(90,image.getResolutionVariant(90,90).getWidth(null)); + assertEquals(120,image.getResolutionVariant(120,120).getWidth(null)); + } @Test void encodesNativeIconsFromBundledFavicon() throws Exception { DesktopIcons.write(directory); assertEquals(512, ImageIO.read(directory.resolve("forgeloop.png").toFile()).getWidth()); + var rounded=ImageIO.read(directory.resolve("forgeloop.png").toFile()); + assertEquals(0,rounded.getRGB(0,0)>>>24,"Launcher corners must be transparent"); + assertEquals(0,rounded.getRGB(20,20)>>>24,"Corners must be heavily rounded"); + assertTrue((rounded.getRGB(256,256)>>>24)>0,"Brand mark remains visible"); byte[] bytes = Files.readAllBytes(directory.resolve("forgeloop.ico")); var ico = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); assertEquals(0, ico.getShort()); assertEquals(1, ico.getShort()); assertEquals(6, ico.getShort()); diff --git a/runner/src/test/java/io/forgeloop/runner/DesktopPagesTest.java b/runner/src/test/java/io/forgeloop/runner/DesktopPagesTest.java index c96cb51..ad7f438 100644 --- a/runner/src/test/java/io/forgeloop/runner/DesktopPagesTest.java +++ b/runner/src/test/java/io/forgeloop/runner/DesktopPagesTest.java @@ -27,6 +27,8 @@ class DesktopPagesTest { int expected = Integer.parseInt(link.getName().substring("navigation-".length())); assertEquals(expected,pages.getSelectedIndex()); assertTrue(pages.getSelectedComponent().isVisible()); + assertEquals(DesktopTheme.SELECTED,link.getBackground()); + assertEquals(DesktopTheme.ACCENT,link.getForeground()); } shell.setSize(1040,760);shell.doLayout(); for (JButton link : links) assertEquals(link.getAccessibleContext().getAccessibleName(),link.getText()); diff --git a/runner/src/test/java/io/forgeloop/runner/DesktopPreview.java b/runner/src/test/java/io/forgeloop/runner/DesktopPreview.java new file mode 100644 index 0000000..cec7990 --- /dev/null +++ b/runner/src/test/java/io/forgeloop/runner/DesktopPreview.java @@ -0,0 +1,31 @@ +package io.forgeloop.runner; + +import java.nio.file.Files; +import java.math.BigDecimal; +import javax.swing.SwingUtilities; + +/** Review-only launcher: isolated data, no saved identity/key, and no pricing or worker calls. */ +public final class DesktopPreview { + public static void main(String[] args)throws Exception { + var directory=Files.createTempDirectory("forgeloop-ui-preview-"); + DesktopFiles.protect(directory); + var config=new DesktopConfiguration("https://forgeloop.codefrontlabs.com","anthropic","claude-sonnet-5",new BigDecimal("3"),new BigDecimal("15")); + new com.fasterxml.jackson.databind.ObjectMapper().writeValue(directory.resolve("config.json").toFile(),config); + DesktopTheme.install(); + SwingUtilities.invokeLater(()->{ + try { + var app=new DesktopRunner(directory,false); + app.window().setTitle("ForgeLoop Runner — Local design preview"); + findPages(app.window()).setSelectedIndex(2); + app.designPreview(); + }catch(Exception error){throw new IllegalStateException(error);} + }); + } + private static DesktopPages findPages(java.awt.Container parent) { + for(var component:parent.getComponents()) { + if(component instanceof DesktopPages pages)return pages; + if(component instanceof java.awt.Container nested){var pages=findPages(nested);if(pages!=null)return pages;} + } + return null; + } +} diff --git a/runner/src/test/java/io/forgeloop/runner/DesktopWindowTest.java b/runner/src/test/java/io/forgeloop/runner/DesktopWindowTest.java index c725776..f8278c0 100644 --- a/runner/src/test/java/io/forgeloop/runner/DesktopWindowTest.java +++ b/runner/src/test/java/io/forgeloop/runner/DesktopWindowTest.java @@ -11,6 +11,34 @@ class DesktopWindowTest { @TempDir Path directory; + @Test void designPreviewDisablesOperationalActionsButKeepsNavigation()throws Exception { + org.junit.jupiter.api.Assumptions.assumeFalse(GraphicsEnvironment.isHeadless()); + DesktopFiles.protect(directory);writeManualConfiguration(); + SwingUtilities.invokeAndWait(()->{ + DesktopTheme.install();DesktopRunner app=null; + try { + app=new DesktopRunner(directory,false); + var windowControls=new java.util.ArrayList(); + findWindowControls(app.window().getRootPane(),app.window().getContentPane(),windowControls); + var enabledControls=windowControls.stream().filter(AbstractButton::isEnabled).toList(); + app.designPreview(); + for(var button:enabledControls)assertTrue(button.isEnabled(),"Preview must preserve title-bar controls"); + assertFalse(find(app.window(),"Connect in browser").isEnabled()); + assertFalse(find(app.window(),"Save provider settings").isEnabled()); + assertFalse(find(app.window(),"Start runner").isEnabled()); + assertTrue(hasText(app.window(),"DESIGN PREVIEW · Isolated sample settings · No connection or paid work")); + var pages=findTabs(app.window());pages.setSelectedIndex(1);assertEquals(1,pages.getSelectedIndex()); + assertFalse(Files.exists(directory.resolve("identity"))); + }catch(Exception error){throw new AssertionError(error);}finally{if(app!=null)app.disposeIdle();} + }); + } + private static void findWindowControls(Container parent,Container content,java.util.List buttons) { + for(var child:parent.getComponents()) { + if(child==content)continue; + if(child instanceof AbstractButton button)buttons.add(button); + if(child instanceof Container nested)findWindowControls(nested,content,buttons); + } + } @Test void opensSetupWithoutNetworkOrPaidWorkAndCapturesLayout()throws Exception{ org.junit.jupiter.api.Assumptions.assumeFalse(GraphicsEnvironment.isHeadless()); DesktopFiles.protect(directory); diff --git a/scripts/Start-DesktopPreview.ps1 b/scripts/Start-DesktopPreview.ps1 new file mode 100644 index 0000000..57565d7 --- /dev/null +++ b/scripts/Start-DesktopPreview.ps1 @@ -0,0 +1,17 @@ +param( + [string]$JavaHome=$env:JAVA_HOME, + [string]$MavenCommand='mvn.cmd' +) +$ErrorActionPreference='Stop' +$previewRoot=Split-Path $PSScriptRoot -Parent +$runnerPath=Join-Path $previewRoot 'runner' +if (!$JavaHome) { throw 'Supply -JavaHome pointing to a JDK 21 installation.' } +$env:JAVA_HOME=$JavaHome +Push-Location $runnerPath +try { + # Compile only; never invoke jpackage, install, release, or an authenticated runner. + & $MavenCommand -q test-compile dependency:build-classpath '-Dmdep.outputFile=target/preview-classpath.txt' + if ($LASTEXITCODE -ne 0) { throw 'Preview compilation failed.' } + $previewClasspath=(Join-Path $runnerPath 'target/test-classes')+';'+(Join-Path $runnerPath 'target/classes')+';'+(Get-Content 'target/preview-classpath.txt' -Raw).Trim() + Start-Process -FilePath (Join-Path $JavaHome 'bin/javaw.exe') -ArgumentList @('-cp',('"'+$previewClasspath+'"'),'io.forgeloop.runner.DesktopPreview') -WindowStyle Normal +} finally { Pop-Location }