Public: anyone can read this; the crew can change it.
Design: the editor uses the design system, and its editor-* classes leave §5d #3775
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3635-design-the-editor-uses-the-design-system → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +44
−34
docs/DESIGN.md +1 −2
| @@ -391,7 +391,6 @@ gone, so this list only shrinks. | ||
| 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-body` `.rc-form` `.rc-head` `.rc-outdated` `.rc-resolved-line` `.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 | 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 | - **`style/content.ts`, the editor** — the editor's parts beyond the §5 row: `.editor-mirror` `.editor-path` `.editor-preview` `.editor-stack` `.editor-tab` `.mirrored` `.ml` | |
| 395 | 394 | - **`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` |
| 396 | 395 | - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals` |
| 397 | 396 | - **`pages/flow-panel.ts`** — the pipeline's outflow sparklines, on /flow and /boards: `.flow-bar` `.flow-out` |
| @@ -528,7 +527,7 @@ all — a protected default, or a ref that is not a branch — the page says so | ||
| 528 | 527 | the disabled radio rather than letting somebody find out after typing. |
| 529 | 528 | |
| 530 | 529 | **The highlighted mirror.** Under the textarea sits a |
| 531 | `<pre class="editor-mirror">` holding the same text, syntax-coloured; the | |
| 530 | `<pre id="editor-mirror">` holding the same text, syntax-coloured; the | |
| 532 | 531 | textarea on top of it has transparent text and a visible caret. They share one grid cell and one set |
| 533 | 532 | of metrics — font, size, line height, padding, tab size — because a single |
| 534 | 533 | divergence puts the caret in the wrong place, and the _height ceiling lives on |
src/web/client.ts +10 −8
| @@ -950,7 +950,7 @@ export const codeJs = /* js */ ` | ||
| 950 | 950 | * commits, and the Preview tab asks the server to render the Markdown with |
| 951 | 951 | * the same renderer that will render it once it lands. |
| 952 | 952 | * |
| 953 | * The highlighting is the classic overlay: a `<pre class="editor-mirror">` | |
| 953 | * The highlighting is the classic overlay: a `<pre id="editor-mirror">` | |
| 954 | 954 | * holding the syntax-coloured text, and the real textarea on top of it with |
| 955 | 955 | * transparent text and a visible caret. The two must agree on every metric — |
| 956 | 956 | * font, size, line height, padding, tab size — which is why they share a |
| @@ -970,7 +970,7 @@ export const editorJs = /* js */ ` | ||
| 970 | 970 | var gutter = doc.getElementById('editor-gutter'); |
| 971 | 971 | var wrap = doc.getElementById('editor-write'); |
| 972 | 972 | var mirror = doc.getElementById('editor-mirror'); |
| 973 | var pathInput = doc.querySelector('.editor-path'); | |
| 973 | var pathInput = doc.querySelector('.editor-head input[name=path]'); | |
| 974 | 974 | var form = area.form; |
| 975 | 975 | var MAX_MIRROR = 200000; |
| 976 | 976 | var MAX_LINES = 10000; |
| @@ -1003,7 +1003,7 @@ export const editorJs = /* js */ ` | ||
| 1003 | 1003 | |
| 1004 | 1004 | function build(html, from, to) { |
| 1005 | 1005 | var out = ''; |
| 1006 | for (var i = from; i < to; i++) out += '<div class="ml">' + html[i] + '</div>'; | |
| 1006 | for (var i = from; i < to; i++) out += '<div>' + html[i] + '</div>'; | |
| 1007 | 1007 | return out; |
| 1008 | 1008 | } |
| 1009 | 1009 | |
| @@ -1061,9 +1061,9 @@ export const editorJs = /* js */ ` | ||
| 1061 | 1061 | var at = area.value.slice(0, area.selectionStart).split('\\n').length - 1; |
| 1062 | 1062 | if (at === current) return; |
| 1063 | 1063 | var was = mirror.children[current]; |
| 1064 | if (was) was.classList.remove('cur'); | |
| 1064 | if (was) was.removeAttribute('aria-current'); | |
| 1065 | 1065 | var now = mirror.children[at]; |
| 1066 | if (now) now.classList.add('cur'); | |
| 1066 | if (now) now.setAttribute('aria-current', 'true'); | |
| 1067 | 1067 | current = at; |
| 1068 | 1068 | } |
| 1069 | 1069 | |
| @@ -1077,7 +1077,7 @@ export const editorJs = /* js */ ` | ||
| 1077 | 1077 | |
| 1078 | 1078 | function stopMirror() { |
| 1079 | 1079 | model = null; |
| 1080 | if (wrap) wrap.classList.remove('mirrored'); | |
| 1080 | if (wrap) wrap.removeAttribute('data-mirrored'); | |
| 1081 | 1081 | if (mirror) mirror.innerHTML = ''; |
| 1082 | 1082 | } |
| 1083 | 1083 | |
| @@ -1088,7 +1088,7 @@ export const editorJs = /* js */ ` | ||
| 1088 | 1088 | if (area.value.length > MAX_MIRROR || lineCount() > MAX_LINES) return; |
| 1089 | 1089 | lang = wrap.getAttribute('data-lang') || 'plain'; |
| 1090 | 1090 | model = new hl.Mirror(lang); |
| 1091 | wrap.classList.add('mirrored'); | |
| 1091 | wrap.setAttribute('data-mirrored', '1'); | |
| 1092 | 1092 | paint(true); |
| 1093 | 1093 | syncScroll(); |
| 1094 | 1094 | } |
| @@ -1217,7 +1217,9 @@ export const editorJs = /* js */ ` | ||
| 1217 | 1217 | } |
| 1218 | 1218 | function show(which) { |
| 1219 | 1219 | tabs.forEach(function (t) { |
| 1220 | t.setAttribute('aria-selected', t.getAttribute('data-editor-tab') === which ? 'true' : 'false'); | |
| 1220 | var on = t.getAttribute('data-editor-tab') === which; | |
| 1221 | t.setAttribute('aria-selected', on ? 'true' : 'false'); | |
| 1222 | t.setAttribute('data-variant', on ? 'secondary' : 'ghost'); | |
| 1221 | 1223 | }); |
| 1222 | 1224 | if (wrap) wrap.hidden = which !== 'write'; |
| 1223 | 1225 | if (pane) pane.hidden = which !== 'preview'; |
src/web/pages/edit.ts +8 −8
| @@ -177,7 +177,7 @@ function editorForm( | ||
| 177 | 177 | draft.creating, |
| 178 | 178 | () => |
| 179 | 179 | html`<input |
| 180 | class="input mono editor-path" | |
| 180 | class="input mono" | |
| 181 | 181 | name="path" |
| 182 | 182 | value="${draft.path}" |
| 183 | 183 | placeholder="path/to/file.ts" |
| @@ -208,9 +208,9 @@ function editorForm( | ||
| 208 | 208 | isMarkdownPath(draft.path) || draft.creating, |
| 209 | 209 | () => |
| 210 | 210 | html`<button |
| 211 | class="btn editor-tab" | |
| 211 | class="btn" | |
| 212 | 212 | data-size="sm" |
| 213 | data-variant="ghost" | |
| 213 | data-variant="secondary" | |
| 214 | 214 | type="button" |
| 215 | 215 | data-editor-tab="write" |
| 216 | 216 | aria-selected="true" |
| @@ -219,7 +219,7 @@ function editorForm( | ||
| 219 | 219 | Write |
| 220 | 220 | </button> |
| 221 | 221 | <button |
| 222 | class="btn editor-tab" | |
| 222 | class="btn" | |
| 223 | 223 | data-size="sm" |
| 224 | 224 | data-variant="ghost" |
| 225 | 225 | type="button" |
| @@ -239,10 +239,10 @@ function editorForm( | ||
| 239 | 239 | ${mirror ? raw('data-mirror="1"') : null} |
| 240 | 240 | > |
| 241 | 241 | <div class="editor-gutter" id="editor-gutter" aria-hidden="true"></div> |
| 242 | <div class="editor-stack"> | |
| 242 | <div> | |
| 243 | 243 | ${when(mirror, () => |
| 244 | 244 | raw( |
| 245 | `<pre class="editor-mirror mono" id="editor-mirror" aria-hidden="true">${mirrorHtml( | |
| 245 | `<pre class="mono" id="editor-mirror" aria-hidden="true">${mirrorHtml( | |
| 246 | 246 | draft.content, |
| 247 | 247 | lang, |
| 248 | 248 | )}</pre>`, |
| @@ -261,7 +261,7 @@ ${draft.content}</textarea | ||
| 261 | 261 | </div> |
| 262 | 262 | </div> |
| 263 | 263 | <div |
| 264 | class="prose editor-preview" | |
| 264 | class="prose" | |
| 265 | 265 | id="editor-preview" |
| 266 | 266 | data-preview="${base}/preview" |
| 267 | 267 | data-path="${draft.path}" |
| @@ -511,7 +511,7 @@ export async function editorPage( | ||
| 511 | 511 | function mirrorHtml(content: string, lang: string): string { |
| 512 | 512 | return highlightToHtml(content, lang as Parameters<typeof highlightToHtml>[1]) |
| 513 | 513 | .split("\n") |
| 514 | .map((line) => `<div class="ml">${line}</div>`) | |
| 514 | .map((line) => `<div>${line}</div>`) | |
| 515 | 515 | .join(""); |
| 516 | 516 | } |
| 517 | 517 |
src/web/style/content.ts +13 −13
| @@ -1052,8 +1052,8 @@ form.inline { display: inline; margin-left: auto; } | ||
| 1052 | 1052 | background: var(--card); border: 1px solid var(--border); |
| 1053 | 1053 | border-radius: var(--radius-sm) var(--radius-sm) 0 0; |
| 1054 | 1054 | } |
| 1055 | .editor-head .editor-path { flex: 1 1 16rem; min-width: 0; } | |
| 1056 | .editor-tab[aria-selected="true"] { background: var(--muted); color: var(--fg); font-weight: 550; } | |
| 1055 | .editor-head input[name="path"] { flex: 1 1 16rem; min-width: 0; } | |
| 1056 | .editor-head .btn[aria-selected="true"] { color: var(--fg); font-weight: 550; } | |
| 1057 | 1057 | |
| 1058 | 1058 | .editor-wrap { |
| 1059 | 1059 | display: flex; align-items: stretch; |
| @@ -1081,10 +1081,10 @@ form.inline { display: inline; margin-left: auto; } | ||
| 1081 | 1081 | line height or tab size would put the caret in the wrong place. Neither |
| 1082 | 1082 | wraps: the editor scrolls horizontally instead of reflowing, which is what |
| 1083 | 1083 | a code editor does. */ |
| 1084 | .editor-stack { | |
| 1084 | .editor-wrap > div:last-child { | |
| 1085 | 1085 | position: relative; display: grid; flex: 1; min-width: 0; min-height: 0; |
|
||
| 1086 | 1086 | } |
| 1087 | .editor-mirror, | |
| 1087 | #editor-mirror, | |
| 1088 | 1088 | textarea.editor { |
| 1089 | 1089 | grid-area: 1 / 1; |
| 1090 | 1090 | margin: 0; border: none; background: none; |
| @@ -1093,29 +1093,29 @@ textarea.editor { | ||
| 1093 | 1093 | tab-size: 2; white-space: pre; |
| 1094 | 1094 | overflow: auto; |
| 1095 | 1095 | } |
| 1096 | .editor-wrap.mirrored .editor-mirror, | |
| 1097 | .editor-wrap.mirrored textarea.editor { min-height: 0; } | |
| 1096 | .editor-wrap[data-mirrored] #editor-mirror, | |
| 1097 | .editor-wrap[data-mirrored] textarea.editor { min-height: 0; } | |
| 1098 | 1098 | textarea.editor { resize: vertical; field-sizing: content; } |
| 1099 | 1099 | textarea.editor:focus-visible { outline: none; } |
| 1100 | .editor-mirror { | |
| 1100 | #editor-mirror { | |
| 1101 | 1101 | overflow: hidden; pointer-events: none; user-select: none; |
| 1102 | 1102 | color: var(--fg); |
| 1103 | 1103 | } |
| 1104 | 1104 | /* Only when the mirror is live does the textarea's own text step aside — with |
| 1105 | 1105 | no script (or no bundle) the plain textarea is what you see. */ |
| 1106 | .editor-wrap:not(.mirrored) .editor-mirror { display: none; } | |
| 1107 | .editor-wrap.mirrored textarea.editor { | |
| 1106 | .editor-wrap:not([data-mirrored]) #editor-mirror { display: none; } | |
| 1107 | .editor-wrap[data-mirrored] textarea.editor { | |
| 1108 | 1108 | color: transparent; |
| 1109 | 1109 | caret-color: var(--fg); |
| 1110 | 1110 | -webkit-text-fill-color: transparent; |
| 1111 | 1111 | } |
| 1112 | .editor-wrap.mirrored textarea.editor::selection { | |
| 1112 | .editor-wrap[data-mirrored] textarea.editor::selection { | |
| 1113 | 1113 | background: color-mix(in srgb, var(--accent) 30%, transparent); |
| 1114 | 1114 | } |
| 1115 | .editor-mirror .ml { | |
| 1115 | #editor-mirror > div { | |
| 1116 | 1116 | display: block; min-height: 1.55em; min-width: 100%; width: max-content; |
| 1117 | 1117 | } |
| 1118 | .editor-mirror .ml.cur { | |
| 1118 | #editor-mirror > [aria-current] { | |
| 1119 | 1119 | background: color-mix(in srgb, var(--muted) 75%, transparent); |
| 1120 | 1120 | } |
| 1121 | 1121 | .editor-wrap { --editor-fs: 12.5px; } |
| @@ -1124,7 +1124,7 @@ textarea.editor:focus-visible { outline: none; } | ||
| 1124 | 1124 | @media (pointer: coarse) { .editor-wrap { --editor-fs: 16px; } } |
| 1125 | 1125 | /* The ring above is a shadow, which forced colours drop; the textarea's own outline is off, so draw one. */ |
| 1126 | 1126 | @media (forced-colors: active) { .editor-wrap:focus-within { outline: 2px solid Highlight; outline-offset: 2px; } } |
| 1127 | .editor-preview { | |
| 1127 | #editor-preview { | |
| 1128 | 1128 | border: 1px solid var(--border); border-top: none; |
| 1129 | 1129 | border-radius: 0 0 var(--radius) var(--radius); |
| 1130 | 1130 | background: var(--card); min-height: 12rem; |
test/web-editor.test.ts +12 −3
| @@ -184,6 +184,15 @@ describe("editing files on the web", () => { | ||
| 184 | 184 | expect(theirs).not.toContain("/alice/beacon/edit/main/src/lamp.ts"); |
| 185 | 185 | }); |
| 186 | 186 | |
| 187 | test("the editor wears no class of its own beyond the design system's", async () => { | |
| 188 | for (const path of ["src/lamp.ts", "README.md"]) { | |
| 189 | const html = await text(`/alice/beacon/edit/main/${path}`, alice); | |
| 190 | for (const name of ["editor-mirror", "editor-path", "editor-preview", "editor-stack", "editor-tab", "mirrored"]) | |
| 191 | expect(html).not.toMatch(new RegExp(`class="[^"]*\\b${name}\\b`)); | |
| 192 | expect(html).not.toContain('class="ml"'); | |
| 193 | } | |
| 194 | }); | |
| 195 | ||
| 187 | 196 | test("a stranger cannot even open the editor", async () => { |
| 188 | 197 | const res = await get("/alice/beacon/edit/main/src/lamp.ts", kim); |
| 189 | 198 | expect(res.status).toBe(404); |
| @@ -617,13 +626,13 @@ describe("editing files on the web", () => { | ||
| 617 | 626 | files: [{ path: "src/mirror.ts", content: "export const lit = true\n" }], |
| 618 | 627 | }); |
| 619 | 628 | const html = await text("/alice/beacon/edit/main/src/mirror.ts", alice); |
| 620 | expect(html).toContain('class="editor-mirror mono"'); | |
| 629 | expect(html).toContain('id="editor-mirror"'); | |
| 621 | 630 | expect(html).toContain('data-mirror="1"'); |
| 622 | 631 | expect(html).toContain('data-lang="ts"'); |
| 623 | 632 | expect(html).toContain("<textarea"); |
| 624 | 633 | // The first paint is already highlighted: no flash of unstyled code. |
| 625 | 634 | expect(html).toContain( |
| 626 | '<div class="ml"><span class="tok-keyword">export</span>', | |
| 635 | '<div><span class="tok-keyword">export</span>', | |
| 627 | 636 | ); |
| 628 | 637 | // …and the text is still in the textarea, which is what actually posts. |
| 629 | 638 | expect(html).toContain("export const lit = true"); |
| @@ -759,7 +768,7 @@ describe("editing files on the web", () => { | ||
| 759 | 768 | const html = await text("/alice/beacon/edit/main/src/generated.ts", alice); |
| 760 | 769 | expect(html).toContain("<textarea"); |
| 761 | 770 | // No mirror element, no bundle, and the strict script policy back. |
| 762 | expect(html).not.toContain('class="editor-mirror'); | |
| 771 | expect(html).not.toContain('id="editor-mirror"'); | |
| 763 | 772 | expect(html).not.toContain('data-mirror="1"'); |
| 764 | 773 | expect(html).not.toContain('<script type="module"'); |
| 765 | 774 | const res = await get("/alice/beacon/edit/main/src/generated.ts", alice); |
new above
offline
aria-current on a line inside an aria-hidden mirror conveys nothing and misuses the attribute. Use a data attribute such as data-cur and select on that in content.ts.