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

Design: the code browser's explorer column uses the design system, and its ex-* classes leave §5d #3814

Merged opened this

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

Merged

mason/forge:mason/3636-design-the-code-browser-s-explorer-colum → main 3 commits fork

  • Merged. mason/3636-design-the-code-browser-s-explorer-colum was merged into main as f06062e.

Review

Sign in to review this change.
5 files changed +59 −46
docs/DESIGN.md −1
@@ -390,7 +390,6 @@ gone, so this list only shrinks.
390390 - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r`
391391 - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd`
392392 - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**): `.app-url` `.check-progress` `.check-steps` `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.log-tail-row` `.pinned-failures` `.pinned-slot` `.run-actions` `.run-jump` `.run-live` `.run-steer` `.run-summary` `.step-state` `.tests-bar` `.tests-words` `.txt` `.who`
393 - **`style/content.ts`, the code browser: explorer beside content** — the explorer column's own parts: `.code-main` `.code-split` `.ex-empty` `.ex-find` `.ex-head` `.ex-list` `.ex-name` `.ex-ref` `.ex-twist` `.explorer-toggle`
394393 - **`style/content.ts`, search results** — search results, the inbox's ask cards, the client list and the claim link: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.claim-link-box` `.forges-sent` `.live-toggle` `.needs-you` `.no-hits`
395394 - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals`
396395 - **`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`
src/web/client.ts +1 −1
@@ -724,7 +724,7 @@ export const codeJs = /* js */ `
724724 if (!text) return;
725725 var holder = doc.createElement('div');
726726 holder.innerHTML = text;
727 var list = holder.querySelector('ul.ex-list');
727 var list = holder.querySelector('ul');
728728 if (list && stub.parentNode === d) d.replaceChild(list, stub);
729729 }, function () { stub.setAttribute('data-stub', ''); });
730730 }, true);
src/web/pages/explorer.ts +10 −13
@@ -119,13 +119,11 @@ function levelList(data: ExplorerData, base: string, at: number): Html {
119119 ${open ? raw("open") : null}
120120 >
121121 <summary ${current ? raw('data-current=""') : null}>
122 <span class="ex-twist" aria-hidden="true"
123 >${icon("chevronRight")}</span
124 ><span class="ex-name">${entry.name}</span>
122 ${icon("chevronRight")}<span class="truncate">${entry.name}</span>
125123 </summary>
126124 ${open
127125 ? levelList(data, base, at + 1)
128 : html`<ul class="ex-list" data-stub>
126 : html`<ul data-stub>
129127 <li>
130128 <a class="ex-more" href="${`${base}/tree/${encoded}/${path}`}"
131129 >${icon("folder")}Open ${entry.name}/</a
@@ -145,13 +143,13 @@ function levelList(data: ExplorerData, base: string, at: number): Html {
145143 href="${`${base}/blob/${encoded}/${path}`}"
146144 ${current ? raw('aria-current="page"') : null}
147145 title="${path}"
148 >${icon("file")}<span class="ex-name">${entry.name}</span></a
146 >${icon("file")}<span class="truncate">${entry.name}</span></a
149147 >
150148 </li>`;
151149 });
152 return html`<ul class="ex-list">
150 return html`<ul>
153151 ${rows.length === 0
154 ? html`<li><span class="ex-empty">empty</span></li>`
152 ? html`<li><span class="muted small">empty</span></li>`
155153 : rows}
156154 </ul>`;
157155 }
@@ -203,12 +201,12 @@ export function explorerColumn(
203201 ): Html {
204202 const base = scope.base;
205203 return html`<details class="explorer-panel">
206 <summary class="explorer-toggle">
204 <summary>
207205 ${icon("folder")}<span class="grow">Files</span>
208 <span class="ex-ref mono">${data.ref}</span>
206 <span class="mono muted small">${data.ref}</span>
209207 </summary>
210208 <nav class="explorer" aria-label="Files" data-current="${data.current}">
211 <div class="ex-head">
209 <div>
212210 ${when(o.finder === true, () => finderButton())}${o.actions ?? null}
213211 </div>
214212 ${levelList(data, base, 0)}
@@ -239,7 +237,7 @@ export function codeMount(
239237 /** The control that opens the finder. It needs a script, so it says so. */
240238 export function finderButton(): Html {
241239 return html`<button
242 class="btn js-only ex-find"
240 class="btn js-only"
243241 data-size="sm"
244242 data-variant="outline"
245243 type="button"
@@ -297,7 +295,6 @@ export function codeLayout(explorer: Html, content: Html): Html {
297295 return html`<div class="code-layout">
298296 ${explorer}
299297 <div
300 class="code-split"
301298 role="separator"
302299 aria-orientation="vertical"
303300 aria-label="Resize the file tree"
@@ -307,6 +304,6 @@ export function codeLayout(explorer: Html, content: Html): Html {
307304 title="Drag to resize · double-click to reset"
308305 data-split
309306 ></div>
310 <div class="code-main" id="code-main">${content}</div>
307 <div id="code-main">${content}</div>
311308 </div>`;
312309 }
src/web/style/content.ts +26 −29
@@ -896,23 +896,23 @@ form.inline { display: inline; margin-left: auto; }
896896 tree's width is one custom property, \`--ex-w\`, so the divider's script
897897 has exactly one thing to set and the stylesheet keeps the fallback. */
898898 .code-layout { display: grid; gap: 12px; margin-top: 12px; align-items: start; }
899 .code-main { min-width: 0; }
900 .code-main[aria-busy="true"] { opacity: .55; transition: opacity 120ms var(--ease); }
899 .code-layout > #code-main { min-width: 0; }
900 .code-layout > #code-main[aria-busy="true"] { opacity: .55; transition: opacity 120ms var(--ease); }
901901 /* The divider is nothing until a script can drag it, and nothing on one
902902 column either. */
903 .code-split { display: none; }
903 .code-layout > [data-split] { display: none; }
904904
905905 .explorer-panel { min-width: 0; }
906 .explorer-panel:not([open]) > .explorer-toggle { border-radius: var(--radius-sm); }
907 .explorer-toggle {
906 .explorer-panel:not([open]) > summary { border-radius: var(--radius-sm); }
907 .explorer-panel > summary {
908908 display: flex; align-items: center; gap: 8px; cursor: pointer;
909909 padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
910910 background: var(--card); font-weight: 550; list-style: none;
911911 }
912 .explorer-toggle::-webkit-details-marker { display: none; }
913 .explorer-toggle .ico { color: var(--faint); }
914 .explorer-toggle .ex-ref { color: var(--muted-fg); font-size: 12.5px; }
915 .explorer-panel[open] > .explorer-toggle { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
912 .explorer-panel > summary::-webkit-details-marker { display: none; }
913 .explorer-panel > summary .ico { color: var(--faint); }
914 .explorer-panel > summary .mono { font-size: 12.5px; }
915 .explorer-panel[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
916916
917917 .explorer {
918918 border: 1px solid var(--border); border-top: none;
@@ -922,12 +922,11 @@ form.inline { display: inline; margin-left: auto; }
922922 scrollbar-width: thin;
923923 padding: 4px 0 8px;
924924 }
925 .ex-head { display: flex; gap: 6px; padding: 6px 8px; flex-wrap: wrap; }
926 .ex-head .btn { flex: 1; justify-content: flex-start; }
927 .ex-find kbd { margin-left: auto; }
925 .explorer > div { display: flex; gap: 6px; padding: 6px 8px; flex-wrap: wrap; }
926 .explorer > div .btn { flex: 1; justify-content: flex-start; }
928927
929 .ex-list { margin: 0; padding: 0; list-style: none; }
930 .ex-list .ex-list { padding-left: 12px; border-left: 1px solid var(--border); margin-left: 10px; }
928 .explorer ul { margin: 0; padding: 0; list-style: none; }
929 .explorer ul ul { padding-left: 12px; border-left: 1px solid var(--border); margin-left: 10px; }
931930 .ex-file, .ex-dir > summary, .ex-more {
932931 display: flex; align-items: center; gap: 6px;
933932 padding: 3px 10px; min-height: 26px;
@@ -936,11 +935,9 @@ form.inline { display: inline; margin-left: auto; }
936935 }
937936 .ex-dir > summary::-webkit-details-marker { display: none; }
938937 @media (pointer: coarse) { .ex-file, .ex-dir > summary, .ex-more { min-height: 44px; } }
939 .ex-file .ico, .ex-more .ico { color: var(--faint); flex: none; width: 14px; height: 14px; }
940 .ex-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
941 .ex-twist { display: inline-flex; flex: none; color: var(--faint); transition: transform 120ms var(--ease); }
942 .ex-twist .ico { width: 14px; height: 14px; }
943 .ex-dir[open] > summary > .ex-twist { transform: rotate(90deg); }
938 .explorer ul .ico { color: var(--faint); flex: none; width: 14px; height: 14px; }
939 .ex-dir > summary > .ico { transition: transform 120ms var(--ease); }
940 .ex-dir[open] > summary > .ico { transform: rotate(90deg); }
944941 .ex-dir > summary { font-weight: 500; color: var(--fg); }
945942 .ex-file:hover, .ex-dir > summary:hover, .ex-more:hover { background: var(--muted); color: var(--fg); }
946943 .ex-file[aria-current="page"] {
@@ -950,7 +947,7 @@ form.inline { display: inline; margin-left: auto; }
950947 .ex-file:focus-visible { outline-offset: -2px; }
951948 .ex-dir > summary[data-current] { color: var(--accent); }
952949 .ex-more { color: var(--accent); font-size: 12.5px; }
953 .ex-empty { display: block; padding: 3px 10px 3px 16px; font-size: 12.5px; color: var(--faint); }
950 .explorer li > .muted { display: block; padding: 3px 10px 3px 16px; font-size: 12.5px; color: var(--faint); }
954951
955952 @media (min-width: 900px) {
956953 .code-layout {
@@ -978,39 +975,39 @@ form.inline { display: inline; margin-left: auto; }
978975 same band as the tree so there is always something to grab. The line
979976 thickens and takes the accent under the pointer, on focus and while
980977 dragging; the lane itself stays transparent. */
981 :root.js .code-split {
978 :root.js .code-layout > [data-split] {
982979 display: block; position: sticky; align-self: stretch;
983980 top: calc(var(--topbar-h) + 12px);
984981 max-height: calc(100dvh - var(--topbar-h) - 32px);
985982 width: 12px; cursor: col-resize; touch-action: none;
986983 border-radius: var(--radius-sm);
987984 }
988 .code-split::before {
985 .code-layout > [data-split]::before {
989986 content: ""; position: absolute; inset: 0 -4px;
990987 }
991 .code-split::after {
988 .code-layout > [data-split]::after {
992989 content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
993990 width: 1px; margin-left: -0.5px; background: var(--border);
994991 transition: background-color 120ms var(--ease), width 120ms var(--ease), margin-left 120ms var(--ease);
995992 }
996 .code-split:hover::after, .code-split:focus-visible::after, .code-layout[data-dragging] .code-split::after {
993 .code-layout > [data-split]:hover::after, .code-layout > [data-split]:focus-visible::after, .code-layout[data-dragging] > [data-split]::after {
997994 width: 3px; margin-left: -1.5px; background: var(--accent);
998995 }
999 .code-split:focus-visible { outline-offset: 0; }
996 .code-layout > [data-split]:focus-visible { outline-offset: 0; }
1000997 /* While a drag is on, nothing beneath the pointer may select or steal
1001998 the cursor. */
1002999 .code-layout[data-dragging] { cursor: col-resize; user-select: none; -webkit-user-select: none; }
1003 .code-layout[data-dragging] .code-main, .code-layout[data-dragging] .explorer-panel { pointer-events: none; }
1000 .code-layout[data-dragging] > #code-main, .code-layout[data-dragging] .explorer-panel { pointer-events: none; }
10041001 }
10051002 @supports selector(::details-content) {
10061003 @media (min-width: 900px) {
1007 .explorer-panel > .explorer-toggle { display: none; }
1004 .explorer-panel > summary { display: none; }
10081005 }
10091006 }
10101007 @media (min-width: 900px) and (pointer: coarse) {
10111008 /* A finger needs 44px: the lane keeps its width and the hit area grows
10121009 under it, into the padding on either side. */
1013 .code-split::before { inset: 0 -16px; }
1010 .code-layout > [data-split]::before { inset: 0 -16px; }
10141011 }
10151012 @media (min-width: 1200px) {
10161013 .code-layout { grid-template-columns: minmax(0, var(--ex-w, 300px)) auto minmax(0, 1fr); }
@@ -1245,6 +1242,6 @@ textarea.editor:focus-visible { outline: none; }
12451242 .front-live, .run-log .ln-pulse i { animation: none; }
12461243 .run-log .ln-pulse i { opacity: .6; }
12471244 .explore-filters { animation: none; }
1248 .ex-twist, .code-main[aria-busy="true"], .code-split::after { transition: none; }
1245 .ex-dir > summary > .ico, .code-layout > #code-main[aria-busy="true"], .code-layout > [data-split]::after { transition: none; }
12491246 }
12501247 `;
test/web-code.test.ts +22 −2
@@ -226,12 +226,32 @@ describe("the code browser on the web", () => {
226226 const res = await get("/alice/beacon/tree/main/docs?fragment=explorer");
227227 expect(res.status).toBe(200);
228228 const html = await res.text();
229 expect(html).toContain('class="ex-list"');
229 expect(html).toMatch(/^\s*<ul[\s>]/);
230230 expect(html).toContain("keeping.md");
231231 expect(html).not.toContain("<!doctype html>");
232232 expect(res.headers.get("content-security-policy")).toContain("sandbox");
233233 });
234234
235 test("the explorer column is drawn from §5's components, with none of its old classes", async () => {
236 const page = await text("/alice/beacon/blob/main/src/lamp.ts");
237 const fragment = await text("/alice/beacon/tree/main/docs?fragment=explorer");
238 for (const html of [page, fragment]) {
239 for (const old of [
240 "code-main", "code-split", "ex-empty", "ex-find", "ex-head",
241 "ex-list", "ex-name", "ex-ref", "ex-twist", "explorer-toggle",
242 ]) {
243 expect(html).not.toMatch(new RegExp(`class="[^"]*\\b${old}\\b`));
244 }
245 }
246 // What a person reads and the hooks the script and keyboard need stay.
247 expect(page).toContain('<span class="grow">Files</span>');
248 expect(page).toContain('<span class="mono muted small">main</span>');
249 expect(page).toContain('<span class="truncate">lamp.ts</span>');
250 expect(page).toContain('id="code-main"');
251 expect(page).toContain('role="separator"');
252 expect(page).toContain("data-split");
253 });
254
235255 test("the blob fragment is the same pane a full load renders", async () => {
236256 const res = await get("/alice/beacon/blob/main/src/weather.ts?fragment=1");
237257 expect(res.status).toBe(200);
@@ -301,7 +321,7 @@ describe("the code browser on the web", () => {
301321 test("the finder is offered as an enhancement with the explorer behind it", async () => {
302322 const html = await text("/alice/beacon/blob/main/src/lamp.ts");
303323 // The button that needs a script says so, and carries its shortcut.
304 expect(html).toContain('class="btn js-only ex-find"');
324 expect(html).toContain('class="btn js-only"');
305325 expect(html).toContain('data-shortcut="t"');
306326 expect(html).toContain('id="finder"');
307327 // The fallback is a real, populated tree of links in the same page.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs