Public: anyone can read this; the crew can change it.

A connected origin's row and its mirror forms draw §5's row, .bar, field and btn, and .origin-row, .origin-name, .origin-mark, .mirror-form leave the markup #4174

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/4142-a-connected-origin-s-row-and-its-mirror → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/4142-a-connected-origin-s-row-and-its-mirror was merged into main as 5caf714.

Review

Sign in to review this change.
4 files changed +74 −30
docs/DESIGN.md +1
@@ -391,6 +391,7 @@ be a component — and the check fails a name kept here after its last rule is
391391 gone, so this list only shrinks.
392392
393393 - **`style/base.ts`** — the page shell (topbar, main, footer) and the layout and text utilities every page leans on: `.bar` `.bare` `.foot` `.grow` `.hide-lg` `.hide-sm` `.js-only` `.live-pill` `.mark` `.mb` `.mt` `.muted` `.narrow` `.narrow-wide` `.num` `.reduced` `.shell` `.shell-wide` `.skip` `.small` `.spacer` `.sr-only` `.stack` `.stack-tight` `.topbar` `.topbar-tools` `.truncate` `.wordmark`
394 A `.field` directly inside a `.bar` drops its bottom margin and the bar aligns to the end, so a labelled input and its button share a baseline
394395 - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them: `.cat` `.clone` `.clone-text` `.comment` `.comment-body` `.comment-form` `.dash` `.dialog-body` `.dialog-foot` `.dialog-head` `.empty-title` `.error-note` `.field-row` `.form-actions` `.hex` `.ico` `.ico-loader` `.list` `.live-status` `.menu-item` `.menu-panel` `.menu-sep` `.menu-title` `.palette-empty` `.palette-foot` `.palette-group` `.palette-heading` `.palette-input` `.palette-item` `.palette-list` `.passkey` `.passkey-lead` `.passkey-note` `.passkey-off` `.passkey-on` `.preview` `.sep` `.split` `.sprite` `.swatch` `.table-scroll` `.tell-here` `.theme-apply` `.theme-option` `.tick` `.timeline` `.tl-dot` `.toast-body` `.topbar-search`
395396 - **`style/components.ts`, settings rows** — a list whose rows are settings (limits, capacity): the `<ul class="list setting-list">` lets title and meta wrap instead of ellipsizing, and a row's controls take their own line under 640px. `.row-fields` is the form in that row's trail (input, Save, Default): always visible, never hover-gated, wrapping under the text before it would clip. Use both for any page whose rows show a name, a current value, and a control to change it; use `.row-actions` only for secondary actions (delete, wake, pin, revoke). Flags, telemetry, cloud, people, crew roles and subscription headroom/budget use `.row-fields`. Classes: `.row-fields` `.setting-list`
396397 - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the merge box, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, following, lineage: `.about` `.act` `.app-shelf` `.app-tile` `.app-tile-body` `.app-tile-desc` `.app-tile-look` `.app-tile-name` `.backups-drill` `.backups-error` `.backups-now` `.backups-restore` `.bform` `.bgrid` `.big` `.bmanage` `.boards-result` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.chart` `.chart-box` `.commit-body` `.commit-head` `.connect-more` `.connect-path` `.domain-records` `.explore-filters` `.explore-line` `.explore-main` `.explore-name` `.explore-row` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.following` `.following-actions` `.following-eyebrow` `.following-foot` `.following-head` `.following-sentence` `.fork-bar` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.front-pin-any` `.front-weights` `.frozen` `.frozen-form` `.frozen-sentence` `.handoff` `.handoff-links` `.headroom` `.hero` `.hero-terminal` `.issue-head` `.issue-meta` `.lineage` `.lineage-children` `.lineage-here` `.merge-box` `.merge-form` `.merge-headline` `.merge-sub` `.mirror-form` `.number` `.origin-link` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.terminal-line` `.tok` `.tree-row` `.tree-total` `.wall-cells` `.where-sep`
src/web/pages/origins.ts +26 −30
@@ -313,31 +313,26 @@ function reauthNote(origin: Origin, mirrors: number): Html {
313313 function originCard(origin: Origin, mirrors: number): Html {
314314 const base = `/settings/origins/${origin.id}`;
315315 return card(
316 html`<div class="origin-row">
317 <span class="origin-mark">${icon(originIcon(origin.kind))}</span>
318 <span class="grow">
319 <span class="origin-name"
320 >${origin.login}<span class="muted"> @ </span>${hostLabel(
316 html`<ul class="list setting-list">
outdated

Use <ul class="list setting-list"> here. The meta line is long and .row-meta is nowrap with an ellipsis, so on a phone the sync time and "trusted internal host" will be cut.

317 ${row({
318 lead: icon(originIcon(origin.kind)),
319 title: html`${origin.login}<span class="muted"> @ </span>${hostLabel(
321320 origin.webUrl,
322 )}</span
323 >
324 <span class="row-meta muted small"
325 >${origin.authKind === "oauth" ? "OAuth" : "personal access token"}
321 )}`,
322 meta: html`${origin.authKind === "oauth"
323 ? "OAuth"
324 : "personal access token"}
326325 ·
327326 ${origin.lastSyncAt === null
328327 ? html`never synced`
329 : html`synced ${time(origin.lastSyncAt)}`}</span
330 >
331 ${when(
332 isTrustedHost(URL.parse(origin.webUrl)?.hostname ?? ""),
333 () =>
334 html`<span class="row-meta muted small"
335 >trusted internal host</span
336 >`,
337 )}
338 </span>
339 ${healthBadge(origin)}
340 </div>
328 : html`synced ${time(origin.lastSyncAt)}`}
329 ${when(
330 isTrustedHost(URL.parse(origin.webUrl)?.hostname ?? ""),
331 () => html` · trusted internal host`,
332 )}`,
333 trail: healthBadge(origin),
334 })}
335 </ul>
341336 ${when(
342337 origin.lastError !== "",
343338 () => html`<p class="error-note mt">${origin.lastError}</p>`,
@@ -1454,7 +1449,7 @@ export async function originReposPage(ctx: Ctx): Promise<Response> {
14541449 forkable,
14551450 () =>
14561451 html`<form
1457 class="mirror-form"
1452 class="bar"
14581453 method="post"
14591454 action="${browseBase(origin.id, remote.fullName)}/fork"
14601455 >
@@ -1476,7 +1471,7 @@ export async function originReposPage(ctx: Ctx): Promise<Response> {
14761471 })}`
14771472 : html`${counted}${forkForm}
14781473 <form
1479 class="mirror-form"
1474 class="bar"
14801475 method="post"
14811476 action="/settings/origins/${origin.id}/mirror"
14821477 >
@@ -1488,13 +1483,14 @@ export async function originReposPage(ctx: Ctx): Promise<Response> {
14881483 ${when(
14891484 collides,
14901485 () =>
1491 html`<input
1492 class="input"
1493 name="name"
1494 placeholder="local name"
1495 aria-label="Local name for ${remote.fullName}"
1496 autocomplete="off"
1497 />`,
1486 uiField({

This field() sits in a .bar (align-items:center). Its label sits above the input and it carries a 16px bottom margin, so the Mirror button below will not line up with the input. Use .edit-row, or zero the margin and align the row to the end, and check it at desktop and 360px.

1487 label: "Local name",
1488 name: "name",
1489 id: `mirror-name-${remote.fullName}`,
1490 placeholder: "local name",
1491 autocomplete: "off",
1492 spellcheck: false,
1493 }),
14981494 )}
14991495 ${btn("Mirror", {
15001496 size: "sm",
src/web/style/base.ts +2
@@ -169,6 +169,8 @@ export const utilities = /* css */ `
169169 .stack { display: flex; flex-direction: column; gap: 12px; }
170170 .stack-tight { gap: 4px; }
171171 .bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
172 .bar:has(> .field) { align-items: flex-end; }

This :has rule applies to every .bar that directly holds a .field, not just the mirror form. Check the other bars at 360px, and consider adding the rule to the §5 .bar row so it is documented where people look.

173 .bar > .field { margin: 0; }
172174 .mt { margin-top: 12px; }
173175 .mb { margin-bottom: 12px; }
174176 .narrow { max-width: 400px; margin: 8dvh auto 0; }
test/web-origins.test.ts +45
@@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
1616 import { join } from "node:path";
1717 import { createApp, type App } from "../src/app.ts";
1818 import { serve, type Servers } from "../src/http/server.ts";
19 import { createRepo } from "../src/actions/repos.ts";
1920 import { enableApp } from "../src/actions/apps.ts";
2021 import { createToken } from "../src/actions/users.ts";
2122 import { listOrigins } from "../src/core/origins.ts";
@@ -293,6 +294,50 @@ describe("origins on the web", () => {
293294 expect(html).not.toContain("Mirror all");
294295 });
295296
297 test("the origin row and the mirror forms use the shared row, bar and btn, not the page-local names", async () => {
298 const id = listOrigins(app.db, getUserByName(app.db, "alice")!.id)[0]!.id;
299 const old = /origin-row|origin-name|origin-mark|mirror-form/;
300 const list = await (await asUser(alice, "/settings/origins")).text();
301 expect(list).not.toMatch(old);
302 expect(list).toContain(`class="list setting-list"`);
303 expect(list).toContain(`class="row"`);
304 expect(list).toContain(`action="/settings/origins/${id}/sync"`);
305 expect(list).toContain(`href="/settings/origins/${id}/repos"`);
306 expect(list).toContain("Sync now");
307 expect(list).toContain("Repositories");
308 expect(list).toContain("Disconnect");
309 expect(list).toContain("personal access token");
310 // A remote whose name is already taken locally gets the labelled name box.
311 const alicia = getUserByName(app.db, "alice")!;
312 const taken = await createRepo(app, alicia, {
313 name: "gadgets",
314 visibility: "private",
315 });
316 expect(taken.ok).toBe(true);
317 forgejo.state.repos.push({
318 id: 42,
319 fullName: "octo/gadgets",
320 description: "",
321 cloneUrl: `${originServers.url}/octo/gadgets.git`,
322 htmlUrl: `${originServers.url}/octo/gadgets`,
323 defaultBranch: "main",
324 });
325 const picker = await (
326 await asUser(alice, `/settings/origins/${id}/repos?refresh=1`)
327 ).text();
328 forgejo.state.repos.pop();
329 // Refresh again so the cached repository list forgets the fixture.
outdated

Result discarded and it runs after the fixture is popped. Delete it, or comment why it is needed.

330 await asUser(alice, `/settings/origins/${id}/repos?refresh=1`);
outdated

Mis-indented by 4 spaces. Run the formatter. The new RegExp(...) line below is also over-long.

331 expect(picker).not.toMatch(old);
332 const mirrorForm = `<form[^>]*class="bar"[^>]*action="/settings/origins/${id}/mirror"`;
333 expect(picker).toMatch(new RegExp(mirrorForm));
334 expect(picker).toContain('name="fullName"');
335 expect(picker).toMatch(
336 /<label for="mirror-name-[^"]+">Local name<\/label>/,
337 );
338 expect(picker).toContain("Mirror");
339 });
340
296341 test("Mirror starts a job, shows its stages, and lands on the mirror", async () => {
297342 const id = listOrigins(app.db, getUserByName(app.db, "alice")!.id)[0]!.id;
298343 const mirrored = await asUser(alice, `/settings/origins/${id}/mirror`, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs