diff --git a/docs/console-page-headers.md b/docs/console-page-headers.md new file mode 100644 index 0000000..8e6e74c --- /dev/null +++ b/docs/console-page-headers.md @@ -0,0 +1,10 @@ +# Console tab headers + +Runs, Repositories, Issue chat, Usage & costs, Settings, User guide, Getting started +and Account/Team use ConsolePageHeader. The component owns eyebrow/title/description +typography and spacing, with an optional action area. Headers have no panel +background. Repository cards keep their individual backgrounds without a second +outer panel. Mobile actions wrap below the same header typography. + +Normal image preparation preserves supplied icons and social-preview.jpg. Set +FORGELOOP_REGENERATE_BRANDING=true explicitly to regenerate them from source art. diff --git a/docs/desktop-preview-release-notes.md b/docs/desktop-preview-release-notes.md index fd674f9..4ec2106 100644 --- a/docs/desktop-preview-release-notes.md +++ b/docs/desktop-preview-release-notes.md @@ -5,6 +5,10 @@ RPM for Fedora/RHEL/openSUSE, portable `.tar.gz` archives, and native Arch ## What changed in this preview +- **Updated branding:** The runner window, navigation mark and native Windows, + macOS and Linux installer/launcher icons now derive from the approved green/cyan + ForgeLoop logo mark used by the website. Existing identity and keys are unchanged. + - **Docker startup:** Start runner can start a recognized, installed local Docker engine on Windows, macOS, or Linux, then wait for Linux-container readiness. Startup is bounded and cancellable; no model work starts before readiness. diff --git a/frontend/e2e/mobile-runs.spec.ts b/frontend/e2e/mobile-runs.spec.ts index 8bb59da..f453a35 100644 --- a/frontend/e2e/mobile-runs.spec.ts +++ b/frontend/e2e/mobile-runs.spec.ts @@ -83,7 +83,9 @@ for (const width of [320, 390, 430, 768, 1440]) { await expect(menuToggle).toHaveAttribute('aria-expanded', 'false'); await expect(page.locator('.console-sidebar')).toHaveCSS('visibility', 'hidden'); } - await expect(page.getByRole('heading', { name: 'Connected repositories', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Repositories', exact: true })).toBeVisible(); + await expect(page.locator('.console-page-header .eyebrow')).toHaveText('Repository access'); + expect(await page.locator('.connected-repositories').evaluate(element => getComputedStyle(element).backgroundImage)).toBe('none'); const repositoryCard = page.locator('.repository-card'); await expect(repositoryCard).toHaveCount(1); await expect(repositoryCard.getByRole('heading', { name: 'Repository issue scan', exact: true })).toBeVisible(); diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..d90fbb0 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/icons/icon-180.png b/frontend/public/icons/icon-180.png index 5219cdc..d49a216 100644 Binary files a/frontend/public/icons/icon-180.png and b/frontend/public/icons/icon-180.png differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png index 226a588..dd87ab1 100644 Binary files a/frontend/public/icons/icon-192.png and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-32.png b/frontend/public/icons/icon-32.png index 5d50984..2e3e91e 100644 Binary files a/frontend/public/icons/icon-32.png and b/frontend/public/icons/icon-32.png differ diff --git a/frontend/public/images/social-preview.jpg b/frontend/public/images/social-preview.jpg index f3b4f0b..ae1a300 100644 Binary files a/frontend/public/images/social-preview.jpg and b/frontend/public/images/social-preview.jpg differ diff --git a/frontend/scripts/prepare-images.mjs b/frontend/scripts/prepare-images.mjs index 3006220..0ea5902 100644 --- a/frontend/scripts/prepare-images.mjs +++ b/frontend/scripts/prepare-images.mjs @@ -1,5 +1,12 @@ import sharp from 'sharp'; -import { mkdir, copyFile, readdir } from 'node:fs/promises'; +import { mkdir, copyFile, readdir, access } from 'node:fs/promises'; +// Supplied branding is authoritative; regeneration must be an explicit choice. +async function generateBranding(path, generate) { + if (process.env.FORGELOOP_REGENERATE_BRANDING !== 'true') { + try { await access(path); return; } catch (error) { if (error.code !== 'ENOENT') throw error; } + } + await generate(); +} await mkdir('public/images',{recursive:true}); await mkdir('public/icons',{recursive:true}); await mkdir('public/fonts',{recursive:true}); @@ -9,13 +16,13 @@ const hero='src/assets/delivery-hero-generated.png'; // Encoding/size derivatives of the approved generated asset, not substitute artwork. await sharp(hero).resize({width:1774,withoutEnlargement:true}).webp({quality:82}).toFile('public/images/delivery-hero.webp'); await sharp(hero).resize({width:900,withoutEnlargement:true}).webp({quality:78}).toFile('public/images/delivery-hero-small.webp'); -await sharp(hero).resize(1200,630,{fit:'cover'}).jpeg({quality:86}).toFile('public/images/social-preview.jpg'); +await generateBranding('public/images/social-preview.jpg', () => sharp(hero).resize(1200,630,{fit:'cover'}).jpeg({quality:86}).toFile('public/images/social-preview.jpg')); // Versioned URLs avoid stale cached artwork; retain the prior source/derivatives. const refinedHero='src/assets/delivery-hero-v2.png'; await sharp(refinedHero).webp({quality:92,effort:6}).toFile('public/images/delivery-v2-hero.webp'); await sharp(refinedHero).resize({width:1000,withoutEnlargement:true}).webp({quality:88,effort:6}).toFile('public/images/delivery-v2-hero-small.webp'); await sharp(refinedHero).resize(1200,630,{fit:'cover'}).jpeg({quality:92}).toFile('public/images/delivery-v2-social-preview.jpg'); -for(const size of [32,180,192])await sharp('src/assets/favicon.png').resize(size,size).png().toFile(`public/icons/icon-${size}.png`); +for(const size of [32,180,192])await generateBranding(`public/icons/icon-${size}.png`, () => sharp('src/assets/favicon.png').resize(size,size).png().toFile(`public/icons/icon-${size}.png`)); for(const file of await readdir('src/assets/heroes')){ if(!file.endsWith('.png'))continue; const key=file.slice(0,-4),source=`src/assets/heroes/${file}`; diff --git a/frontend/src/ConsolePageHeader.tsx b/frontend/src/ConsolePageHeader.tsx new file mode 100644 index 0000000..1d455e1 --- /dev/null +++ b/frontend/src/ConsolePageHeader.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react'; +import './console-page-header.css'; + +/** One typography and spacing contract for every console tab. */ +export default function ConsolePageHeader({ eyebrow, title, description, actions }: { + eyebrow: string; title: string; description: string; actions?: ReactNode; +}) { + return

{eyebrow}

{title}

{description}

{actions &&
{actions}
}
; +} diff --git a/frontend/src/RunConsole.test.tsx b/frontend/src/RunConsole.test.tsx index 283f264..b67b91a 100644 --- a/frontend/src/RunConsole.test.tsx +++ b/frontend/src/RunConsole.test.tsx @@ -29,6 +29,7 @@ it('places Account in the sidebar with a decorative icon matching the navigation expect(account).toHaveClass('account-console-link'); expect(account.querySelector('.nav-icon')).toHaveAttribute('aria-hidden', 'true'); expect(account.querySelector('svg')).toHaveAttribute('width', '17'); + expect(screen.getByRole('link', { name: 'ForgeLoop' }).querySelector('img')).toHaveAttribute('src', '/icons/icon-192.png?v=20261012'); expect(screen.queryByRole('link', { name: 'Support' })).not.toBeInTheDocument(); }); @@ -194,7 +195,7 @@ it('provides an in-app operator guide as the fifth navigation item', async () => fireEvent.click(guide); expect(guide).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('button', { name: 'Quick start' })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Using ForgeLoop' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'User guide' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'What every setting does' })).toBeInTheDocument(); expect(screen.getByText('Auto-merge', { selector: 'dt' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'When something does not move' })).toBeInTheDocument(); diff --git a/frontend/src/RunnerPairingPage.tsx b/frontend/src/RunnerPairingPage.tsx index fd145d0..ab7975d 100644 --- a/frontend/src/RunnerPairingPage.tsx +++ b/frontend/src/RunnerPairingPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { LockKeyhole, ShieldCheck } from 'lucide-react'; -import favicon from './assets/favicon.png'; +import { brandIcon as favicon } from './brand'; import './runner-pairing.css'; import { approveRunnerPairing, loadOperator } from './api'; diff --git a/frontend/src/TeamPage.tsx b/frontend/src/TeamPage.tsx index e0924b7..4ad1621 100644 --- a/frontend/src/TeamPage.tsx +++ b/frontend/src/TeamPage.tsx @@ -1,3 +1,4 @@ +import ConsolePageHeader from './ConsolePageHeader'; import { useEffect, useState, type FormEvent } from 'react'; import { inviteGithubUser, loadOrganizationMembers, revokeOrganizationMember, type OperatorSession, type OrganizationMember } from './api'; import './team.css'; @@ -29,7 +30,7 @@ export default function TeamPage({operator}: {operator: OperatorSession}) { finally { setBusy(false); } } return
-
ORGANIZATION ACCESS

Team

Invite GitHub users to {operator.organizationId}. Only administrators can manage access.

+

Invite a person

Enter their GitHub username. ForgeLoop resolves it to GitHub's permanent account ID before granting access. No email is sent; share your ForgeLoop URL with them.

void invite(event)} className="team-invite-form">
diff --git a/frontend/src/UserGuidePage.tsx b/frontend/src/UserGuidePage.tsx index 8108ab8..e785dfc 100644 --- a/frontend/src/UserGuidePage.tsx +++ b/frontend/src/UserGuidePage.tsx @@ -1,3 +1,4 @@ +import ConsolePageHeader from './ConsolePageHeader'; import MarkdownContent from "./MarkdownContent"; import runnerSetup from "./docs/runner-setup.md?raw"; @@ -21,7 +22,7 @@ const settings = [ export default function UserGuidePage() { return (
-

Operator handbook

Using ForgeLoop

From GitHub issue to verified pull request: setup, operating procedures, safety boundaries, and every configurable setting.

+

Start here

How delivery works

{lifecycle.map(([number,title,body])=>
{number}

{title}

{body}

)}
diff --git a/frontend/src/account/AccountPage.tsx b/frontend/src/account/AccountPage.tsx index ea1ee6b..fe0a7d0 100644 --- a/frontend/src/account/AccountPage.tsx +++ b/frontend/src/account/AccountPage.tsx @@ -1,3 +1,4 @@ +import ConsolePageHeader from '../ConsolePageHeader'; import { ArrowUpRight, BarChart3, Settings2, ShieldCheck } from 'lucide-react'; import type { OrganizationMember, OperatorSession } from '../api'; import SupportApp from '../support/SupportApp'; @@ -15,7 +16,7 @@ export default function AccountPage({ operator, workspaces, onUsage, onWorkspace const activeWorkspace = workspaces.find(item => item.organizationId === operator.organizationId); return
-

Profile & help

Account

Review your signed-in identity, workspace access, and support requests.

Sign out
+ Sign out}/>
Signed-in GitHub identity{operator.subject}Your identity is managed by GitHub.
diff --git a/frontend/src/assets/favicon.png b/frontend/src/assets/favicon.png index fe0bf6e..d90fbb0 100644 Binary files a/frontend/src/assets/favicon.png and b/frontend/src/assets/favicon.png differ diff --git a/frontend/src/brand.ts b/frontend/src/brand.ts new file mode 100644 index 0000000..9641a62 --- /dev/null +++ b/frontend/src/brand.ts @@ -0,0 +1,2 @@ +// One approved icon for dashboard, pairing and public chrome; version avoids stale caches. +export const brandIcon = '/icons/icon-192.png?v=20261012'; diff --git a/frontend/src/console-page-header.css b/frontend/src/console-page-header.css new file mode 100644 index 0000000..8925ecd --- /dev/null +++ b/frontend/src/console-page-header.css @@ -0,0 +1,11 @@ +.console-app .console-page-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin: 0 0 24px; padding: 0; border: 0; background: none; box-shadow: none; } +.console-app .console-page-header > div:first-child { min-width: 0; } +.console-app .console-page-header .eyebrow { display: block; margin: 0 0 8px; color: var(--blue); font-size: .68rem; font-weight: 700; letter-spacing: .14em; line-height: 1.4; text-transform: uppercase; } +.console-app .console-page-header h1 { margin: 0; color: var(--text); font-size: 1.8rem; line-height: 1.15; letter-spacing: -.035em; } +.console-app .console-page-header .console-page-description { margin: 8px 0 0; color: var(--muted); font-size: .85rem; line-height: 1.55; } +.console-page-actions { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; } +.console-app .connected-repositories { padding: 0; border: 0; background: none; box-shadow: none; } +@media (max-width: 760px) { + .console-app .console-page-header { flex-direction: column; align-items: stretch; gap: 14px; } + .console-page-actions { flex-wrap: wrap; } +} diff --git a/frontend/src/issue-chat/IssueChatPage.tsx b/frontend/src/issue-chat/IssueChatPage.tsx index 8198f91..bfacaa2 100644 --- a/frontend/src/issue-chat/IssueChatPage.tsx +++ b/frontend/src/issue-chat/IssueChatPage.tsx @@ -1,3 +1,4 @@ +import ConsolePageHeader from '../ConsolePageHeader'; import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import { Trash2 } from 'lucide-react'; import { @@ -170,7 +171,7 @@ export default function IssueChatPage({ repositories, operator, onRepositories } finally { setBusy(''); } } - if (!enabledRepositories.length) return

Runner-assisted intake

Issue chat

Connect a repository before drafting an issue.

; + if (!enabledRepositories.length) return Open repositories}/>; const canCreate = operator.role === 'ADMIN' && conversation?.status === 'READY' && draft.title.trim() && draft.body.trim() && draft.criteria.split(/\r?\n/).some(item => item.trim()); @@ -178,12 +179,9 @@ export default function IssueChatPage({ repositories, operator, onRepositories } const publication = operations?.publication; return <> -
-

Runner-assisted intake

Issue chat

Describe the change, refine a reviewable specification, then choose whether to create the issue.

- -
+ }/>

Messages and drafts are saved in this organization workspace; do not include secrets. Each reply may incur provider charges, shown under Usage & costs.

{error &&

{error}

}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0f40c07..6e54e42 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,3 +1,4 @@ +import ConsolePageHeader from './ConsolePageHeader'; import { FormEvent, useEffect, @@ -44,7 +45,7 @@ import { type RunAnalytics, type PlatformConfiguration, } from "./api"; -import favicon from "./assets/favicon.png"; +import { brandIcon as favicon } from './brand'; import MarkdownContent from "./MarkdownContent"; import UserGuidePage from "./UserGuidePage"; import RunnerSetup from "./RunnerSetup"; @@ -84,18 +85,7 @@ function RepositoryPage({ items, operator, onSettings }: { items: RepositoryConn return ( <>
-
-
-

Connected repositories

-

- Issue intake and verification are bound to the persisted policy - revision. -

-
- - Install GitHub App - -
+ Install GitHub App}/> {items.length ? ( items.map((item) => ( @@ -751,17 +741,7 @@ function RunsPage({ ); return ( <> -
-
-

Runs

-

Turn specifications into working software.

-
- {operator.role !== "VIEWER" && ( - - )} -
+ setCreating(true)}>+ New Run}/>

{refreshError?`Updates interrupted: ${refreshError}. Retrying automatically.`:`Live updates every 2 seconds${lastUpdated?` · Updated ${lastUpdated}`:''}`}

@@ -821,7 +801,7 @@ function ConfigurationPage({operator, repositories, onSaved}:{operator:OperatorS async function perform(label:string,work:()=>Promise){setBusy(label);setError("");try{await work();setConfiguration(await loadPlatformConfiguration());}catch(reason){setError(reason instanceof Error?reason.message:"Configuration update failed");}finally{setBusy("");}} if(!configuration)return

Loading execution policy…

; return <> -

Execution governance

Settings

Manage spending limits, delivery, repository intake and runner configuration in one place.

Policy revision {configuration.policy.revision}
+ Policy revision {configuration.policy.revision}}/> {error&&

{error}

}

Organization boundary

Execution policy

Hard limits applied before work enters the runner queue.

{event.preventDefault();const form=new FormData(event.currentTarget);void perform("policy",()=>configureOrganizationPolicy({maxRunBudgetUsd:Number(form.get("budget")),maxParallelTasks:Number(form.get("parallel")),allowedProviders:csv(String(form.get("providers"))),requireHumanApproval:form.get("approval")==="on",autoMergeEnabled:form.get("autoMerge")==="on"}));}}>{admin&&}
diff --git a/frontend/src/onboarding/OnboardingPage.tsx b/frontend/src/onboarding/OnboardingPage.tsx index 5b09262..65b37dd 100644 --- a/frontend/src/onboarding/OnboardingPage.tsx +++ b/frontend/src/onboarding/OnboardingPage.tsx @@ -1,3 +1,4 @@ +import ConsolePageHeader from '../ConsolePageHeader'; import { useEffect, useRef, useState, type FormEvent } from 'react'; import { checkIssueIntake, loadPlatformConfiguration, loadRepositoryConnections, loadRunners, loadRuns, type FeatureRun, type IssueIntakeCheck, type OperatorSession, type PlatformConfiguration, type RepositoryConnection, type Runner } from '../api'; @@ -40,8 +41,7 @@ export default function OnboardingPage({ operator, navigate }: Props) { const issueUrl = repository && newIssueUrl(repository.repository, repository.issueLabel, repository.requiredAssignee, repository.requireAssignee); const admin = operator.role === 'ADMIN'; return
-

Getting started

-

Connect a repository to your own runner, then follow one issue through verified delivery.

+

These checks do not call a model or start work. Provider keys stay on your runner. Starting a runner with eligible queued work can spend API credits.

{error &&

{error}

} diff --git a/frontend/src/public/PublicLayout.tsx b/frontend/src/public/PublicLayout.tsx index 67a513d..4e58686 100644 --- a/frontend/src/public/PublicLayout.tsx +++ b/frontend/src/public/PublicLayout.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -const favicon='/icons/icon-192.png'; +import { brandIcon as favicon } from '../brand'; import { REPOSITORY } from './content'; import { heroStyle } from './heroes'; import { BookOpen, CircleHelp, Download, FileText, Info, Layers, LifeBuoy, Menu, Shield, X } from 'lucide-react'; diff --git a/frontend/src/usage/UsagePage.tsx b/frontend/src/usage/UsagePage.tsx index 7b5fa5f..b37e13c 100644 --- a/frontend/src/usage/UsagePage.tsx +++ b/frontend/src/usage/UsagePage.tsx @@ -1,3 +1,4 @@ +import ConsolePageHeader from '../ConsolePageHeader'; import { useEffect, useState } from 'react'; import { loadProviderActivities, loadRunAnalytics, loadRuns, type FeatureRun, type ProviderActivity, type RunAnalytics } from '../api'; import { usageSummary, usd, type UsageBucket } from './usage'; @@ -28,7 +29,7 @@ export default function UsagePage({ initialAnalytics, onSettings }: { initialAna const usage = runs && usageSummary(runs, days, repository, updated, activities); const count = usage ? usage.total.priced + usage.total.unpriced : 0; return
-

Organization usage

Usage & costs

Review estimated provider spend across runs and AI-assisted workspace activity.

+ Budget & execution settings}/>

Costs are estimates from recorded provider usage, not provider invoices. The report includes delivery runs, repository scans, and any metered issue-spec or chat activity. Unpriced requests are shown as N/A, never counted as free. Archived runs remain included.

diff --git a/runner/src/main/resources/desktop/favicon.png b/runner/src/main/resources/desktop/favicon.png index fe0bf6e..d90fbb0 100644 Binary files a/runner/src/main/resources/desktop/favicon.png and b/runner/src/main/resources/desktop/favicon.png differ diff --git a/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java b/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java index d329dd0..bfe7be2 100644 --- a/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java +++ b/runner/src/test/java/io/forgeloop/runner/DesktopIconsTest.java @@ -11,6 +11,14 @@ import org.junit.jupiter.api.io.TempDir; class DesktopIconsTest { + @Test void bundlesTheApprovedCurrentBrandMark() throws Exception { + // Pin the reviewed web/runner source asset so old branding cannot ship silently. + try (var source = DesktopIcons.class.getResourceAsStream("/desktop/favicon.png")) { + assertNotNull(source); + String digest = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(source.readAllBytes())); + assertEquals("e20001b3d232659174f15f68b0c4ccfc96c67ceb2119d53eb1afb43e8f0f008d", digest); + } + } @TempDir Path directory; @Test void encodesNativeIconsFromBundledFavicon() throws Exception { DesktopIcons.write(directory);