Public: anyone can read this; the crew can change it.
The home page's apps rail draws §5's card, row and btn, and .home-rail, .home-apps, .home-app, .home-apps-verbs, .home-pick leave the markup #4284
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4269-the-home-page-s-apps-rail-draws-5-s-card → main 4 commits fork
4 files
changed +43
−42
docs/DESIGN.md +1 −2
| @@ -402,8 +402,7 @@ gone, so this list only shrinks. | ||
| 402 | 402 | - **`style/content.ts`, search results** — search results, the inbox's ask cards: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.live-toggle` `.needs-you` |
| 403 | 403 | - **`pages/board.ts`** — the board itself is §5's **board**; the spend tables `spend.ts` draws wear §5's `.table` and `.stack`, no class of their own |
| 404 | 404 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
| 405 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` | |
| 406 | ||
| 405 | - **`studio/feed_style.ts`** — the home feed's cards and its composer (the apps rail is §5's card, row and btn; an admin's tick boxes and Archive are always shown, deliberately, with no Select toggle): `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.home-tabs` | |
| 407 | 406 | --- |
| 408 | 407 | |
| 409 | 408 | ## 6. Keyboard |
src/web/studio/feed_style.ts +4 −7
| @@ -21,17 +21,14 @@ export const feedCss = /* css */ ` | ||
| 21 | 21 | .composer-bar select { min-width: 0; max-width: 60%; } |
| 22 | 22 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 23 | 23 | .home-tabs { display: none; margin-bottom: 12px; } |
| 24 | .home-app { gap: 8px; } | |
| 25 | .home-pick { display: none; } | |
| 26 | #apps-select:checked ~ * .home-pick { display: inline-flex; } | |
| 27 | #apps-select:focus-visible ~ * .home-apps-verbs label { outline: 2px solid var(--accent, currentColor); } | |
| 28 | .home-apps-verbs { flex-wrap: wrap; padding: 10px 14px; } | |
| 24 | /* An admin's tick boxes are always shown (no Select toggle); 24 px to tap. */ | |
| 25 | #live-apps input[type="checkbox"] { width: 24px; height: 24px; } | |
| 29 | 26 | @media (max-width: 899px) { |
| 30 | 27 | .home-tabs { display: inline-flex; } |
| 31 | 28 | /* "What you asked for" is a tab on a phone: the first post comes next. |
| 32 | 29 | !important, as [hidden] is: .bar sits in a later cascade layer. */ |
| 33 | 30 | .feed-head { display: none !important; } |
| 34 | .home-rail .home-apps { display: none; } | |
| 31 | #live-apps { display: none; } | |
| 35 | 32 | } |
| 36 | 33 | .fcard-form { display: grid; gap: 8px; margin-top: 6px; } |
| 37 | 34 | .fcard-form textarea { width: 100%; min-height: 56px; resize: vertical; } |
| @@ -40,6 +37,6 @@ export const feedCss = /* css */ ` | ||
| 40 | 37 | .feed .disclosure > summary { min-height: 44px; display: flex; align-items: center; } |
| 41 | 38 | } |
| 42 | 39 | @media (forced-colors: active) { |
| 43 | .feed > .card:focus-visible, #apps-select:focus-visible ~ * .home-apps-verbs label { outline: 2px solid Highlight; } | |
| 40 | .feed > .card:focus-visible { outline: 2px solid Highlight; } | |
| 44 | 41 | } |
| 45 | 42 | `; |
src/web/studio/home.ts +19 −33
| @@ -90,29 +90,27 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 90 | 90 | record === undefined || runtime === undefined |
| 91 | 91 | ? undefined |
| 92 | 92 | : runtime.status(record).find((env) => env.env === "production")?.state; |
| 93 | return html`<li class="row home-app"> | |
| 94 | ${when( | |
| 95 | user.isAdmin, | |
| 96 | () => | |
| 97 | html`<input | |
| 98 | class="home-pick" | |
| 99 | type="checkbox" | |
| 100 | name="repo" | |
| 101 | value="${repo.id}" | |
| 102 | aria-label="Select ${repo.name}" | |
| 103 | />`, | |
| 104 | )} | |
| 105 | <a class="truncate" href="${repoBase(repo)}">${repo.ownerName}/${repo.name}</a> | |
| 106 | <span class="spacer"></span> | |
| 107 | ${state === undefined ? null : envStateBadge(state)} | |
| 108 | </li>`; | |
| 93 | return row({ | |
| 94 | ...(user.isAdmin | |
| 95 | ? { | |
| 96 | lead: html`<input | |
| 97 | type="checkbox" | |
| 98 | name="repo" | |
| 99 | value="${repo.id}" | |
| 100 | aria-label="Select ${repo.name}" | |
| 101 | />`, | |
| 102 | } | |
| 103 | : {}), | |
| 104 | title: html`<a class="truncate break" href="${repoBase(repo)}">${repo.ownerName}/${repo.name}</a>`, | |
| 105 | ...(state === undefined ? {} : { trail: envStateBadge(state) }), | |
| 106 | }); | |
| 109 | 107 | }); |
| 110 | 108 | const list = html`${rows.length === 0 |
| 111 | 109 | ? html`<p class="muted small">No apps yet.</p>` |
| 112 | 110 | : html`<ul class="list"> |
| 113 | 111 | ${rows} |
| 114 | 112 | </ul>`} |
| 115 | <div class="bar home-apps-verbs"> | |
| 113 | <div class="bar"> | |
| 116 | 114 | ${btn(`All apps${repos.length > RAIL_APPS ? ` (${repos.length})` : ""}`, { href: "/apps", size: "sm" })} |
| 117 | 115 | ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })} |
| 118 | 116 | ${btn("Repositories", { |
| @@ -126,29 +124,17 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 126 | 124 | ${when(user.isAdmin, () => |
| 127 | 125 | btn("Front page", { href: "/front", size: "sm", icon: "globe" }), |
| 128 | 126 | )} |
| 129 | ${when( | |
| 130 | user.isAdmin && rows.length > 0, | |
| 131 | () => html`<label class="btn" data-size="sm" for="apps-select">Select</label> | |
| 132 | <span class="home-pick">${btn("Archive", { size: "sm" })}</span>`, | |
| 133 | )} | |
| 127 | ${when(user.isAdmin && rows.length > 0, () => btn("Archive", { size: "sm" }))} | |
| 134 | 128 | </div>`; |
| 135 | 129 | return card( |
| 136 | // An admin's list is a form: ticks cost no space until Select shows them. | |
| 130 | // An admin's list is a form: tick apps, then Archive. | |
| 137 | 131 | user.isAdmin && rows.length > 0 |
| 138 | ? html`<form method="post" action="/home/archive"> | |
| 139 | <input | |
| 140 | class="sr-only" | |
| 141 | type="checkbox" | |
| 142 | id="apps-select" | |
| 143 | aria-label="Select apps" | |
| 144 | />${list} | |
| 145 | </form>` | |
| 132 | ? html`<form method="post" action="/home/archive">${list}</form>` | |
| 146 | 133 | : list, |
| 147 | 134 | { |
| 148 | 135 | head: html`${icon("rocket")} |
| 149 | 136 | <h2>Your apps</h2>`, |
| 150 | 137 | pad: false, |
| 151 | extra: "home-apps", | |
| 152 | 138 | }, |
| 153 | 139 | ); |
| 154 | 140 | } |
| @@ -285,7 +271,7 @@ export async function studioHome( | ||
| 285 | 271 | ${feedNote()} |
| 286 | 272 | ${timePhase(ctx.request, "feed", () => feedRegion(user, ctx), () => feedCandidateCache(ctx.app.db))} |
| 287 | 273 | </div> |
| 288 | <aside class="dash-rail stack home-rail"> | |
| 274 | <aside class="dash-rail stack"> | |
| 289 | 275 | ${ |
| 290 | 276 | // The crew's missing key is the owner's to fix, not the first |
| 291 | 277 | // thing above every post: beside the timeline on a desk, after it |
test/home-archive.test.ts +19
| @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | import { feedCss } from "../src/web/studio/feed_style.ts"; | |
| 9 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| 11 | 12 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| @@ -126,6 +127,24 @@ describe("home archive", () => { | ||
| 126 | 127 | expect(await home(rootCookie)).toContain("keeper/gamma"); |
| 127 | 128 | }); |
| 128 | 129 | |
| 130 | test("the apps rail is drawn with the design system's classes alone", async () => { | |
| 131 | const page = await full(rootCookie); | |
| 132 | for (const old of ["home-rail", "home-apps", "home-app", "home-apps-verbs", "home-pick"]) | |
| 133 | expect(page).not.toMatch(new RegExp(`class="[^"]*\\b${old}\\b`)); | |
| 134 | expect(page).toContain("Your apps"); | |
| 135 | expect(page).toContain('href="/apps/new"'); | |
| 136 | expect(page).toContain('href="/map"'); | |
| 137 | expect(page).toContain("keeper/gamma"); | |
| 138 | expect(page).toMatch(/<form method="post" action="\/home\/archive">[\s\S]*name="repo"[\s\S]*Archive/); | |
| 139 | expect(page).toContain('class="truncate break"'); | |
| 140 | }); | |
| 141 | ||
| 142 | test("the stylesheet no longer names the removed classes", () => { | |
| 143 | for (const old of ["home-rail", "home-apps", "home-app", "home-pick", "apps-select"]) | |
| 144 | expect(feedCss).not.toContain(old); | |
| 145 | expect(feedCss).toContain("#live-apps"); | |
| 146 | }); | |
| 147 | ||
| 129 | 148 | test("a non-admin sees no checkboxes and cannot post", async () => { |
| 130 | 149 | const page = await full(bobCookie); |
| 131 | 150 | expect(page).not.toContain('name="repo"'); |
new above
offline