Public: anyone can read this; the crew can change it.
Settings › Features speaks the home page's language #9700
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9682-settings-features-speaks-the-home-page-s → main 6 commits fork
2 files
changed +28
−32
docs/design/areas/features.md added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | # Features (Settings › Features) | |
| 2 | ||
| 3 | `src/web/pages/features.ts` adds no component class. The list and a feature's page are a heading over rows, as the | |
| 4 | home page is: a `<section class="settings-block">` with an `<h2>` (a `.bar` when a badge sits beside it) over a | |
| 5 | `.setting-list` of `row()`s. No card around the list or a feature's form, and no icon in a row's lead. Classes used are | |
| 6 | named in `docs/design/areas/settings.md`. |
src/web/pages/features.ts +22 −32
| @@ -23,7 +23,7 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | ||
| 23 | 23 | import { readForm, type Form } from "../form.ts"; |
| 24 | 24 | import { html, raw, when } from "../html.ts"; |
| 25 | 25 | import { flashTo, page } from "../layout.ts"; |
| 26 | import { badge, btn, card, empty, icon, row } from "../ui.ts"; | |
| 26 | import { badge, btn, empty, row } from "../ui.ts"; | |
| 27 | 27 | import { accountSections } from "./origins.ts"; |
| 28 | 28 | |
| 29 | 29 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -54,7 +54,6 @@ export function switchNote(f: Pick<FeatureView, "name" | "absorbed">): string { | ||
| 54 | 54 | function featureRow(f: FeatureView) { |
| 55 | 55 | const base = `/settings/features/${encodeURIComponent(f.slug)}`; |
| 56 | 56 | return row({ |
| 57 | lead: icon("rocket"), | |
| 58 | 57 | title: html`${f.name} <span class="muted small mono">${f.repo}</span> |
| 59 | 58 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : null} |
| 60 | 59 | ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`, |
| @@ -89,25 +88,19 @@ export async function featuresPage(ctx: Ctx): Promise<Response> { | ||
| 89 | 88 | const listed = await timePhase(ctx.request, "features", () => listFeatures(ctx.app, ctx.auth.user)); |
| 90 | 89 | if (!listed.ok) return notFoundPage(ctx); |
| 91 | 90 | const rendering = performance.now(); |
| 92 | const body = html`${card( | |
| 93 | listed.value.length === 0 | |
| 94 | ? html`<div class="card-body"> | |
| 95 | ${empty({ | |
| 91 | const body = html`<section class="settings-block"> | |
| 92 | <h2>Features</h2> | |
| 93 | ${listed.value.length === 0 | |
| 94 | ? empty({ | |
| 96 | 95 | icon: "rocket", |
| 97 | 96 | title: "No app declares a feature yet", |
| 98 | 97 | note: 'An app declares one with a "feature" block in its forge.json: a name, a page and typed settings. Absorbing it puts it in the forge\'s nav and keeps its settings here.', |
| 99 | 98 | action: html`<p><a href="${DOCS}">How an app declares a feature</a></p>`, |
| 100 | })} | |
| 101 | </div>` | |
| 102 | : html`<ul class="list setting-list"> | |
| 103 | ${listed.value.map(featureRow)} | |
| 104 | </ul>`, | |
| 105 | { | |
| 106 | head: html`${icon("rocket")} | |
| 107 | <h2>Features</h2>`, | |
| 108 | pad: false, | |
| 109 | }, | |
| 110 | )}`; | |
| 99 | }) | |
| 100 | : html`<ul class="list setting-list"> | |
| 101 | ${listed.value.map(featureRow)} | |
| 102 | </ul>`} | |
| 103 | </section>`; | |
| 111 | 104 | const response = shell(ctx, "Features", body); |
| 112 | 105 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 113 | 106 | return response; |
| @@ -177,31 +170,28 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response { | ||
| 177 | 170 | const bad = refused?.key === shown.key; |
| 178 | 171 | const s = refused !== undefined && shown.key in refused.typed ? { ...shown, value: typedValue(shown, refused.typed[shown.key]) } : shown; |
| 179 | 172 | return row({ |
| 180 | lead: icon("settings"), | |
| 181 | 173 | title: html`${s.label} <span class="muted small mono">${s.env}</span>`, |
| 182 | 174 | meta: html`${bad ? html`<span class="danger" role="alert">${refused.message}</span><br />` : null}${s.help ?? ""}${when(s.help !== null, () => html`<br />`)}Default |
| 183 | 175 | <span class="mono">${s.default === "" ? "empty" : s.default}</span>${s.set ? "" : " · not changed"}`, |
| 184 | 176 | trail: input(s, bad), |
| 185 | 177 | }); |
| 186 | 178 | }); |
| 187 | const body = html`${card( | |
| 188 | f.settings.length === 0 | |
| 189 | ? html`<div class="card-body"><p class="muted small">${f.problem ?? "This feature declares no settings."}</p></div>` | |
| 179 | const body = html`<section class="settings-block"> | |
| 180 | <div class="bar"> | |
| 181 | <h2>${f.name}</h2> | |
| 182 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : badge("not absorbed", { variant: "outline" })} | |
| 183 | </div> | |
| 184 | ${f.settings.length === 0 | |
| 185 | ? html`<p class="muted small">${f.problem ?? "This feature declares no settings."}</p>` | |
| 190 | 186 | : html`<form method="post" action="${base}"> |
| 191 | 187 | <ul class="list setting-list">${settings}</ul> |
| 192 | <div class="card-body"> | |
| 188 | <p> | |
| 193 | 189 | ${f.absorbed |
| 194 | 190 | ? btn("Save", { variant: "primary", label: `Save ${f.name}'s settings` }) |
| 195 | : html`<p class="muted small">Absorb ${f.name} to change its settings.</p>`} | |
| 196 | </div> | |
| 197 | </form>`, | |
| 198 | { | |
| 199 | head: html`${icon("rocket")} | |
| 200 | <h2>${f.name}</h2> | |
| 201 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : badge("not absorbed", { variant: "outline" })}`, | |
| 202 | pad: false, | |
| 203 | }, | |
| 204 | )} | |
| 191 | : html`<span class="muted small">Absorb ${f.name} to change its settings.</span>`} | |
| 192 | </p> | |
| 193 | </form>`} | |
| 194 | </section> | |
| 205 | 195 | <p class="muted small"> |
| 206 | 196 | Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right. |
| 207 | 197 | <a href="/settings/features">All features</a> |