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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3636-design-the-code-browser-s-explorer-colum → main 3 commits fork
5 files
changed +59
−46
docs/DESIGN.md −1
| @@ -390,7 +390,6 @@ gone, so this list only shrinks. | ||
| 390 | 390 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 391 | 391 | - **`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` |
| 392 | 392 | - **`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` | |
| 394 | 393 | - **`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` |
| 395 | 394 | - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals` |
| 396 | 395 | - **`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 */ ` | ||
| 724 | 724 | if (!text) return; |
| 725 | 725 | var holder = doc.createElement('div'); |
| 726 | 726 | holder.innerHTML = text; |
| 727 | var list = holder.querySelector('ul.ex-list'); | |
| 727 | var list = holder.querySelector('ul'); | |
| 728 | 728 | if (list && stub.parentNode === d) d.replaceChild(list, stub); |
| 729 | 729 | }, function () { stub.setAttribute('data-stub', ''); }); |
| 730 | 730 | }, true); |
src/web/pages/explorer.ts +10 −13
| @@ -119,13 +119,11 @@ function levelList(data: ExplorerData, base: string, at: number): Html { | ||
| 119 | 119 | ${open ? raw("open") : null} |
| 120 | 120 | > |
| 121 | 121 | <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> | |
| 125 | 123 | </summary> |
| 126 | 124 | ${open |
| 127 | 125 | ? levelList(data, base, at + 1) |
| 128 | : html`<ul class="ex-list" data-stub> | |
| 126 | : html`<ul data-stub> | |
| 129 | 127 | <li> |
| 130 | 128 | <a class="ex-more" href="${`${base}/tree/${encoded}/${path}`}" |
| 131 | 129 | >${icon("folder")}Open ${entry.name}/</a |
| @@ -145,13 +143,13 @@ function levelList(data: ExplorerData, base: string, at: number): Html { | ||
| 145 | 143 | href="${`${base}/blob/${encoded}/${path}`}" |
| 146 | 144 | ${current ? raw('aria-current="page"') : null} |
| 147 | 145 | title="${path}" |
| 148 | >${icon("file")}<span class="ex-name">${entry.name}</span></a | |
| 146 | >${icon("file")}<span class="truncate">${entry.name}</span></a | |
| 149 | 147 | > |
| 150 | 148 | </li>`; |
| 151 | 149 | }); |
| 152 | return html`<ul class="ex-list"> | |
| 150 | return html`<ul> | |
| 153 | 151 | ${rows.length === 0 |
| 154 | ? html`<li><span class="ex-empty">empty</span></li>` | |
| 152 | ? html`<li><span class="muted small">empty</span></li>` | |
| 155 | 153 | : rows} |
| 156 | 154 | </ul>`; |
| 157 | 155 | } |
| @@ -203,12 +201,12 @@ export function explorerColumn( | ||
| 203 | 201 | ): Html { |
| 204 | 202 | const base = scope.base; |
| 205 | 203 | return html`<details class="explorer-panel"> |
| 206 | <summary class="explorer-toggle"> | |
| 204 | <summary> | |
| 207 | 205 | ${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> | |
| 209 | 207 | </summary> |
| 210 | 208 | <nav class="explorer" aria-label="Files" data-current="${data.current}"> |
| 211 | <div class="ex-head"> | |
| 209 | <div> | |
| 212 | 210 | ${when(o.finder === true, () => finderButton())}${o.actions ?? null} |
| 213 | 211 | </div> |
| 214 | 212 | ${levelList(data, base, 0)} |
| @@ -239,7 +237,7 @@ export function codeMount( | ||
| 239 | 237 | /** The control that opens the finder. It needs a script, so it says so. */ |
| 240 | 238 | export function finderButton(): Html { |
| 241 | 239 | return html`<button |
| 242 | class="btn js-only ex-find" | |
| 240 | class="btn js-only" | |
| 243 | 241 | data-size="sm" |
| 244 | 242 | data-variant="outline" |
| 245 | 243 | type="button" |
| @@ -297,7 +295,6 @@ export function codeLayout(explorer: Html, content: Html): Html { | ||
| 297 | 295 | return html`<div class="code-layout"> |
| 298 | 296 | ${explorer} |
| 299 | 297 | <div |
| 300 | class="code-split" | |
| 301 | 298 | role="separator" |
| 302 | 299 | aria-orientation="vertical" |
| 303 | 300 | aria-label="Resize the file tree" |
| @@ -307,6 +304,6 @@ export function codeLayout(explorer: Html, content: Html): Html { | ||
| 307 | 304 | title="Drag to resize · double-click to reset" |
| 308 | 305 | data-split |
| 309 | 306 | ></div> |
| 310 | <div class="code-main" id="code-main">${content}</div> | |
| 307 | <div id="code-main">${content}</div> | |
| 311 | 308 | </div>`; |
| 312 | 309 | } |
src/web/style/content.ts +26 −29
| @@ -896,23 +896,23 @@ form.inline { display: inline; margin-left: auto; } | ||
| 896 | 896 | tree's width is one custom property, \`--ex-w\`, so the divider's script |
| 897 | 897 | has exactly one thing to set and the stylesheet keeps the fallback. */ |
| 898 | 898 | .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); } | |
| 901 | 901 | /* The divider is nothing until a script can drag it, and nothing on one |
| 902 | 902 | column either. */ |
| 903 | .code-split { display: none; } | |
| 903 | .code-layout > [data-split] { display: none; } | |
| 904 | 904 | |
| 905 | 905 | .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 { | |
| 908 | 908 | display: flex; align-items: center; gap: 8px; cursor: pointer; |
| 909 | 909 | padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); |
| 910 | 910 | background: var(--card); font-weight: 550; list-style: none; |
| 911 | 911 | } |
| 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; } | |
| 916 | 916 | |
| 917 | 917 | .explorer { |
| 918 | 918 | border: 1px solid var(--border); border-top: none; |
| @@ -922,12 +922,11 @@ form.inline { display: inline; margin-left: auto; } | ||
| 922 | 922 | scrollbar-width: thin; |
| 923 | 923 | padding: 4px 0 8px; |
| 924 | 924 | } |
| 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; } | |
| 928 | 927 | |
| 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; } | |
| 931 | 930 | .ex-file, .ex-dir > summary, .ex-more { |
| 932 | 931 | display: flex; align-items: center; gap: 6px; |
| 933 | 932 | padding: 3px 10px; min-height: 26px; |
| @@ -936,11 +935,9 @@ form.inline { display: inline; margin-left: auto; } | ||
| 936 | 935 | } |
| 937 | 936 | .ex-dir > summary::-webkit-details-marker { display: none; } |
| 938 | 937 | @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); } | |
| 944 | 941 | .ex-dir > summary { font-weight: 500; color: var(--fg); } |
| 945 | 942 | .ex-file:hover, .ex-dir > summary:hover, .ex-more:hover { background: var(--muted); color: var(--fg); } |
| 946 | 943 | .ex-file[aria-current="page"] { |
| @@ -950,7 +947,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 950 | 947 | .ex-file:focus-visible { outline-offset: -2px; } |
| 951 | 948 | .ex-dir > summary[data-current] { color: var(--accent); } |
| 952 | 949 | .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); } | |
| 954 | 951 | |
| 955 | 952 | @media (min-width: 900px) { |
| 956 | 953 | .code-layout { |
| @@ -978,39 +975,39 @@ form.inline { display: inline; margin-left: auto; } | ||
| 978 | 975 | same band as the tree so there is always something to grab. The line |
| 979 | 976 | thickens and takes the accent under the pointer, on focus and while |
| 980 | 977 | dragging; the lane itself stays transparent. */ |
| 981 | :root.js .code-split { | |
| 978 | :root.js .code-layout > [data-split] { | |
| 982 | 979 | display: block; position: sticky; align-self: stretch; |
| 983 | 980 | top: calc(var(--topbar-h) + 12px); |
| 984 | 981 | max-height: calc(100dvh - var(--topbar-h) - 32px); |
| 985 | 982 | width: 12px; cursor: col-resize; touch-action: none; |
| 986 | 983 | border-radius: var(--radius-sm); |
| 987 | 984 | } |
| 988 | .code-split::before { | |
| 985 | .code-layout > [data-split]::before { | |
| 989 | 986 | content: ""; position: absolute; inset: 0 -4px; |
| 990 | 987 | } |
| 991 | .code-split::after { | |
| 988 | .code-layout > [data-split]::after { | |
| 992 | 989 | content: ""; position: absolute; top: 0; bottom: 0; left: 50%; |
| 993 | 990 | width: 1px; margin-left: -0.5px; background: var(--border); |
| 994 | 991 | transition: background-color 120ms var(--ease), width 120ms var(--ease), margin-left 120ms var(--ease); |
| 995 | 992 | } |
| 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 { | |
| 997 | 994 | width: 3px; margin-left: -1.5px; background: var(--accent); |
| 998 | 995 | } |
| 999 | .code-split:focus-visible { outline-offset: 0; } | |
| 996 | .code-layout > [data-split]:focus-visible { outline-offset: 0; } | |
| 1000 | 997 | /* While a drag is on, nothing beneath the pointer may select or steal |
| 1001 | 998 | the cursor. */ |
| 1002 | 999 | .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; } | |
| 1004 | 1001 | } |
| 1005 | 1002 | @supports selector(::details-content) { |
| 1006 | 1003 | @media (min-width: 900px) { |
| 1007 | .explorer-panel > .explorer-toggle { display: none; } | |
| 1004 | .explorer-panel > summary { display: none; } | |
| 1008 | 1005 | } |
| 1009 | 1006 | } |
| 1010 | 1007 | @media (min-width: 900px) and (pointer: coarse) { |
| 1011 | 1008 | /* A finger needs 44px: the lane keeps its width and the hit area grows |
| 1012 | 1009 | under it, into the padding on either side. */ |
| 1013 | .code-split::before { inset: 0 -16px; } | |
| 1010 | .code-layout > [data-split]::before { inset: 0 -16px; } | |
| 1014 | 1011 | } |
| 1015 | 1012 | @media (min-width: 1200px) { |
| 1016 | 1013 | .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; } | ||
| 1245 | 1242 | .front-live, .run-log .ln-pulse i { animation: none; } |
| 1246 | 1243 | .run-log .ln-pulse i { opacity: .6; } |
| 1247 | 1244 | .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; } | |
| 1249 | 1246 | } |
| 1250 | 1247 | `; |
test/web-code.test.ts +22 −2
| @@ -226,12 +226,32 @@ describe("the code browser on the web", () => { | ||
| 226 | 226 | const res = await get("/alice/beacon/tree/main/docs?fragment=explorer"); |
| 227 | 227 | expect(res.status).toBe(200); |
| 228 | 228 | const html = await res.text(); |
| 229 | expect(html).toContain('class="ex-list"'); | |
| 229 | expect(html).toMatch(/^\s*<ul[\s>]/); | |
| 230 | 230 | expect(html).toContain("keeping.md"); |
| 231 | 231 | expect(html).not.toContain("<!doctype html>"); |
| 232 | 232 | expect(res.headers.get("content-security-policy")).toContain("sandbox"); |
| 233 | 233 | }); |
| 234 | 234 | |
| 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 | ||
| 235 | 255 | test("the blob fragment is the same pane a full load renders", async () => { |
| 236 | 256 | const res = await get("/alice/beacon/blob/main/src/weather.ts?fragment=1"); |
| 237 | 257 | expect(res.status).toBe(200); |
| @@ -301,7 +321,7 @@ describe("the code browser on the web", () => { | ||
| 301 | 321 | test("the finder is offered as an enhancement with the explorer behind it", async () => { |
| 302 | 322 | const html = await text("/alice/beacon/blob/main/src/lamp.ts"); |
| 303 | 323 | // 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"'); | |
| 305 | 325 | expect(html).toContain('data-shortcut="t"'); |
| 306 | 326 | expect(html).toContain('id="finder"'); |
| 307 | 327 | // The fallback is a real, populated tree of links in the same page. |
new above
offline