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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4142-a-connected-origin-s-row-and-its-mirror → main 3 commits fork 5 threads · 5 unresolved
docs/DESIGN.md +1
| @@ -391,6 +391,7 @@ be a component — and the check fails a name kept here after its last rule is | ||
| 391 | 391 | gone, so this list only shrinks. |
| 392 | 392 | |
| 393 | 393 | - **`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 | |
| 394 | 395 | - **`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` |
| 395 | 396 | - **`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` |
| 396 | 397 | - **`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 { | ||
| 313 | 313 | function originCard(origin: Origin, mirrors: number): Html { |
| 314 | 314 | const base = `/settings/origins/${origin.id}`; |
| 315 | 315 | 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"> | |
| 317 | ${row({ | |
| 318 | lead: icon(originIcon(origin.kind)), | |
| 319 | title: html`${origin.login}<span class="muted"> @ </span>${hostLabel( | |
| 321 | 320 | 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"} | |
| 326 | 325 | · |
| 327 | 326 | ${origin.lastSyncAt === null |
| 328 | 327 | ? 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> | |
| 341 | 336 | ${when( |
| 342 | 337 | origin.lastError !== "", |
| 343 | 338 | () => html`<p class="error-note mt">${origin.lastError}</p>`, |
| @@ -1454,7 +1449,7 @@ export async function originReposPage(ctx: Ctx): Promise<Response> { | ||
| 1454 | 1449 | forkable, |
| 1455 | 1450 | () => |
| 1456 | 1451 | html`<form |
| 1457 | class="mirror-form" | |
| 1452 | class="bar" | |
| 1458 | 1453 | method="post" |
| 1459 | 1454 | action="${browseBase(origin.id, remote.fullName)}/fork" |
| 1460 | 1455 | > |
| @@ -1476,7 +1471,7 @@ export async function originReposPage(ctx: Ctx): Promise<Response> { | ||
| 1476 | 1471 | })}` |
| 1477 | 1472 | : html`${counted}${forkForm} |
| 1478 | 1473 | <form |
| 1479 | class="mirror-form" | |
| 1474 | class="bar" | |
| 1480 | 1475 | method="post" |
| 1481 | 1476 | action="/settings/origins/${origin.id}/mirror" |
| 1482 | 1477 | > |
| @@ -1488,13 +1483,14 @@ export async function originReposPage(ctx: Ctx): Promise<Response> { | ||
| 1488 | 1483 | ${when( |
| 1489 | 1484 | collides, |
| 1490 | 1485 | () => |
| 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 |
||
| 1487 | label: "Local name", | |
| 1488 | name: "name", | |
| 1489 | id: `mirror-name-${remote.fullName}`, | |
| 1490 | placeholder: "local name", | |
| 1491 | autocomplete: "off", | |
| 1492 | spellcheck: false, | |
| 1493 | }), | |
| 1498 | 1494 | )} |
| 1499 | 1495 | ${btn("Mirror", { |
| 1500 | 1496 | size: "sm", |
src/web/style/base.ts +2
| @@ -169,6 +169,8 @@ export const utilities = /* css */ ` | ||
| 169 | 169 | .stack { display: flex; flex-direction: column; gap: 12px; } |
| 170 | 170 | .stack-tight { gap: 4px; } |
| 171 | 171 | .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; } | |
| 172 | 174 | .mt { margin-top: 12px; } |
| 173 | 175 | .mb { margin-bottom: 12px; } |
| 174 | 176 | .narrow { max-width: 400px; margin: 8dvh auto 0; } |
test/web-origins.test.ts +45
| @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; | ||
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import { createApp, type App } from "../src/app.ts"; |
| 18 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 19 | import { createRepo } from "../src/actions/repos.ts"; | |
| 19 | 20 | import { enableApp } from "../src/actions/apps.ts"; |
| 20 | 21 | import { createToken } from "../src/actions/users.ts"; |
| 21 | 22 | import { listOrigins } from "../src/core/origins.ts"; |
| @@ -293,6 +294,50 @@ describe("origins on the web", () => { | ||
| 293 | 294 | expect(html).not.toContain("Mirror all"); |
| 294 | 295 | }); |
| 295 | 296 | |
| 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 |
||
| 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 | ||
| 296 | 341 | test("Mirror starts a job, shows its stages, and lands on the mirror", async () => { |
| 297 | 342 | const id = listOrigins(app.db, getUserByName(app.db, "alice")!.id)[0]!.id; |
| 298 | 343 | const mirrored = await asUser(alice, `/settings/origins/${id}/mirror`, { |
Use
<ul class="list setting-list">here. The meta line is long and.row-metais nowrap with an ellipsis, so on a phone the sync time and "trusted internal host" will be cut.