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
10 changes: 10 additions & 0 deletions docs/console-page-headers.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/desktop-preview-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion frontend/e2e/mobile-runs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Binary file added frontend/public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/public/icons/icon-180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/public/icons/icon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/public/images/social-preview.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions frontend/scripts/prepare-images.mjs
Original file line number Diff line number Diff line change
@@ -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});
Expand All @@ -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}`;
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/ConsolePageHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 <section className="console-page-header"><div><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p className="console-page-description">{description}</p></div>{actions && <div className="console-page-actions">{actions}</div>}</section>;
}
3 changes: 2 additions & 1 deletion frontend/src/RunConsole.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/RunnerPairingPage.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/TeamPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,7 +30,7 @@ export default function TeamPage({operator}: {operator: OperatorSession}) {
finally { setBusy(false); }
}
return <section className="team-page">
<div className="page-heading"><div><span className="eyebrow">ORGANIZATION ACCESS</span><h1>Team</h1><p>Invite GitHub users to {operator.organizationId}. Only administrators can manage access.</p></div></div>
<ConsolePageHeader eyebrow="Organization access" title="Team" description="Invite GitHub users and manage workspace access."/>
<div className="team-card"><h2>Invite a person</h2><p>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.</p>
<form onSubmit={event => void invite(event)} className="team-invite-form"><label>GitHub username<input value={login} onChange={event => setLogin(event.target.value)} required autoComplete="off" placeholder="octocat" /></label><label>Role<select value={role} onChange={event => setRole(event.target.value as OperatorSession['role'])}><option value="VIEWER">Viewer — read only</option><option value="OPERATOR">Operator — manage runs</option><option value="ADMIN">Administrator — manage organization</option></select></label><button disabled={busy || !login.trim()} type="submit">Invite GitHub user</button></form>
</div>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/UserGuidePage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ConsolePageHeader from './ConsolePageHeader';
import MarkdownContent from "./MarkdownContent";
import runnerSetup from "./docs/runner-setup.md?raw";

Expand All @@ -21,7 +22,7 @@ const settings = [
export default function UserGuidePage() {
return (
<article className="guide">
<section className="hero guide-hero"><p className="eyebrow">Operator handbook</p><h1>Using ForgeLoop</h1><p>From GitHub issue to verified pull request: setup, operating procedures, safety boundaries, and every configurable setting.</p></section>
<ConsolePageHeader eyebrow="Operator handbook" title="User guide" description="Learn how to set up, operate, and verify your delivery workflow."/>
<section className="panel guide-section" id="quick-start">
<p className="eyebrow">Start here</p><h2>How delivery works</h2>
<div className="guide-steps">{lifecycle.map(([number,title,body])=><div key={number}><span>{number}</span><div><h3>{title}</h3><p>{body}</p></div></div>)}</div>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/account/AccountPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,7 +16,7 @@ export default function AccountPage({ operator, workspaces, onUsage, onWorkspace
const activeWorkspace = workspaces.find(item => item.organizationId === operator.organizationId);

return <section className="account-page">
<div className="page-title"><div><p className="eyebrow">Profile &amp; help</p><h1>Account</h1><p>Review your signed-in identity, workspace access, and support requests.</p></div><a className="logout account-signout" href="/logout">Sign out</a></div>
<ConsolePageHeader eyebrow="Profile & help" title="Account" description="Review your identity, workspace access, and support requests." actions={<a className="logout account-signout" href="/logout">Sign out</a>}/>

<div className="account-overview" aria-label="Account overview">
<article><span>Signed-in GitHub identity</span><strong>{operator.subject}</strong><small>Your identity is managed by GitHub.</small></article>
Expand Down
Binary file modified frontend/src/assets/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/brand.ts
Original file line number Diff line number Diff line change
@@ -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';
11 changes: 11 additions & 0 deletions frontend/src/console-page-header.css
Original file line number Diff line number Diff line change
@@ -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; }
}
10 changes: 4 additions & 6 deletions frontend/src/issue-chat/IssueChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ConsolePageHeader from '../ConsolePageHeader';
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { Trash2 } from 'lucide-react';
import {
Expand Down Expand Up @@ -170,20 +171,17 @@ export default function IssueChatPage({ repositories, operator, onRepositories }
finally { setBusy(''); }
}

if (!enabledRepositories.length) return <section className="page-title"><div><p className="eyebrow">Runner-assisted intake</p><h1>Issue chat</h1><p>Connect a repository before drafting an issue.</p><button className="primary" onClick={onRepositories}>Open repositories</button></div></section>;
if (!enabledRepositories.length) return <ConsolePageHeader eyebrow="Runner-assisted intake" title="Issue chat" description="Connect a repository before drafting an issue." actions={<button className="primary" onClick={onRepositories}>Open repositories</button>}/>;

const canCreate = operator.role === 'ADMIN' && conversation?.status === 'READY' && draft.title.trim() && draft.body.trim()
&& draft.criteria.split(/\r?\n/).some(item => item.trim());
const waitingForIssue = conversation?.issueNumber && !run;
const publication = operations?.publication;

return <>
<section className="page-title issue-chat-title">
<div><p className="eyebrow">Runner-assisted intake</p><h1>Issue chat</h1><p>Describe the change, refine a reviewable specification, then choose whether to create the issue.</p></div>
<label className="issue-chat-repository">Repository<select aria-label="Repository" value={repository} onChange={event => { setRepository(event.target.value); select(''); }}>
<ConsolePageHeader eyebrow="Runner-assisted intake" title="Issue chat" description="Describe a change and refine a reviewable issue specification." actions={<label className="issue-chat-repository">Repository<select aria-label="Repository" value={repository} onChange={event => { setRepository(event.target.value); select(''); }}>
{enabledRepositories.map(item => <option key={item.id} value={item.repository}>{item.repository}</option>)}
</select></label>
</section>
</select></label>}/>
<p className="issue-chat-cost-note">Messages and drafts are saved in this organization workspace; do not include secrets. Each reply may incur provider charges, shown under Usage &amp; costs.</p>
{error && <p role="alert">{error}</p>}
<div className="issue-chat-layout">
Expand Down
30 changes: 5 additions & 25 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ConsolePageHeader from './ConsolePageHeader';
import {
FormEvent,
useEffect,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -84,18 +85,7 @@ function RepositoryPage({ items, operator, onSettings }: { items: RepositoryConn
return (
<>
<section className="panel connected-repositories">
<div className="section-heading">
<div>
<h2>Connected repositories</h2>
<p>
Issue intake and verification are bound to the persisted policy
revision.
</p>
</div>
<a className="primary" href="/api/github/app/install">
Install GitHub App
</a>
</div>
<ConsolePageHeader eyebrow="Repository access" title="Repositories" description="Manage connected repositories and discover work to propose." actions={<a className="primary" href="/api/github/app/install">Install GitHub App</a>}/>
{items.length ? (
items.map((item) => (
<RepositoryDisclosure key={`${operator.subject}:${operator.organizationId}:${item.id}`} storageKey={`forgeloop:repository-expanded:${encodeURIComponent(operator.subject)}:${encodeURIComponent(operator.organizationId)}:${encodeURIComponent(item.repository)}`} heading={<>
Expand Down Expand Up @@ -751,17 +741,7 @@ function RunsPage({
);
return (
<>
<section className="page-title runs-title">
<div>
<h1>Runs</h1>
<p>Turn specifications into working software.</p>
</div>
{operator.role !== "VIEWER" && (
<button className="primary" onClick={() => setCreating(true)}>
+ New Run
</button>
)}
</section>
<ConsolePageHeader eyebrow="Software delivery" title="Runs" description="Track issues through verified delivery." actions={operator.role !== "VIEWER" && <button className="primary" onClick={() => setCreating(true)}>+ New Run</button>}/>
<section className="panel runs-dashboard">
<p role="status">{refreshError?`Updates interrupted: ${refreshError}. Retrying automatically.`:`Live updates every 2 seconds${lastUpdated?` · Updated ${lastUpdated}`:''}`}</p>
<label className="check"><input type="checkbox" checked={archived} onChange={event=>{setArchived(event.target.checked);setSelected(undefined);}}/> Show archived runs</label>
Expand Down Expand Up @@ -821,7 +801,7 @@ function ConfigurationPage({operator, repositories, onSaved}:{operator:OperatorS
async function perform(label:string,work:()=>Promise<unknown>){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 <p className="loading">Loading execution policy…</p>;
return <>
<section className="page-title"><div><p className="eyebrow">Execution governance</p><h1>Settings</h1><p>Manage spending limits, delivery, repository intake and runner configuration in one place.</p></div><span className="revision">Policy revision {configuration.policy.revision}</span></section>
<ConsolePageHeader eyebrow="Execution governance" title="Settings" description="Manage spending limits, delivery rules, and repository configuration." actions={<span className="revision">Policy revision {configuration.policy.revision}</span>}/>
{error&&<p role="alert">{error}</p>}
<RunnerSetup operator={operator}/>
<section className="panel policy-card"><div><p className="eyebrow">Organization boundary</p><h2>Execution policy</h2><p>Hard limits applied before work enters the runner queue.</p></div><form onSubmit={event=>{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"}));}}><label>Organization per-run ceiling (USD)<input name="budget" type="number" min="1" step="1" defaultValue={configuration.policy.maxRunBudgetUsd} disabled={!admin}/></label><label>Parallel task ceiling<input name="parallel" type="number" min="1" max="16" defaultValue={configuration.policy.maxParallelTasks} disabled={!admin}/></label><label>Allowed providers<input name="providers" defaultValue={configuration.policy.allowedProviders.join(", ")} disabled={!admin}/></label><label className="check"><input name="approval" type="checkbox" defaultChecked={configuration.policy.requireHumanApproval} disabled={!admin}/> Require human approval before delivery</label><label className="check"><input name="autoMerge" type="checkbox" defaultChecked={configuration.policy.autoMergeEnabled} disabled={!admin}/> Auto-merge the verified commit after all GitHub checks pass</label>{admin&&<button className="primary compact" disabled={!!busy}>Save policy</button>}</form></section>
Expand Down
Loading
Loading