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

Design: the editor uses the design system, and its editor-* classes leave §5d #3775

Merged opened this

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

Merged

scout/forge:scout/3635-design-the-editor-uses-the-design-system → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/3635-design-the-editor-uses-the-design-system was merged into main as 018aac7.

Review

Sign in to review this change.
5 files changed +44 −34
docs/DESIGN.md +1 −2
@@ -391,7 +391,6 @@ gone, so this list only shrinks.
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-body` `.rc-form` `.rc-head` `.rc-outdated` `.rc-resolved-line` `.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`
393393 - **`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`
395394 - **`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`
396395 - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals`
397396 - **`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
528527 the disabled radio rather than letting somebody find out after typing.
529528
530529 **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
532531 textarea on top of it has transparent text and a visible caret. They share one grid cell and one set
533532 of metrics — font, size, line height, padding, tab size — because a single
534533 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 */ `
950950 * commits, and the Preview tab asks the server to render the Markdown with
951951 * the same renderer that will render it once it lands.
952952 *
953 * The highlighting is the classic overlay: a `<pre class="editor-mirror">`
953 * The highlighting is the classic overlay: a `<pre id="editor-mirror">`
954954 * holding the syntax-coloured text, and the real textarea on top of it with
955955 * transparent text and a visible caret. The two must agree on every metric —
956956 * font, size, line height, padding, tab size — which is why they share a
@@ -970,7 +970,7 @@ export const editorJs = /* js */ `
970970 var gutter = doc.getElementById('editor-gutter');
971971 var wrap = doc.getElementById('editor-write');
972972 var mirror = doc.getElementById('editor-mirror');
973 var pathInput = doc.querySelector('.editor-path');
973 var pathInput = doc.querySelector('.editor-head input[name=path]');
974974 var form = area.form;
975975 var MAX_MIRROR = 200000;
976976 var MAX_LINES = 10000;
@@ -1003,7 +1003,7 @@ export const editorJs = /* js */ `
10031003
10041004 function build(html, from, to) {
10051005 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>';
10071007 return out;
10081008 }
10091009
@@ -1061,9 +1061,9 @@ export const editorJs = /* js */ `
10611061 var at = area.value.slice(0, area.selectionStart).split('\\n').length - 1;
10621062 if (at === current) return;
10631063 var was = mirror.children[current];
1064 if (was) was.classList.remove('cur');
1064 if (was) was.removeAttribute('aria-current');
10651065 var now = mirror.children[at];

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.

1066 if (now) now.classList.add('cur');
1066 if (now) now.setAttribute('aria-current', 'true');
10671067 current = at;
10681068 }
10691069
@@ -1077,7 +1077,7 @@ export const editorJs = /* js */ `
10771077
10781078 function stopMirror() {
10791079 model = null;
1080 if (wrap) wrap.classList.remove('mirrored');
1080 if (wrap) wrap.removeAttribute('data-mirrored');
10811081 if (mirror) mirror.innerHTML = '';
10821082 }
10831083
@@ -1088,7 +1088,7 @@ export const editorJs = /* js */ `
10881088 if (area.value.length > MAX_MIRROR || lineCount() > MAX_LINES) return;
10891089 lang = wrap.getAttribute('data-lang') || 'plain';
10901090 model = new hl.Mirror(lang);
1091 wrap.classList.add('mirrored');
1091 wrap.setAttribute('data-mirrored', '1');
10921092 paint(true);
10931093 syncScroll();
10941094 }
@@ -1217,7 +1217,9 @@ export const editorJs = /* js */ `
12171217 }
12181218 function show(which) {
12191219 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');
12211223 });
12221224 if (wrap) wrap.hidden = which !== 'write';
12231225 if (pane) pane.hidden = which !== 'preview';
src/web/pages/edit.ts +8 −8
@@ -177,7 +177,7 @@ function editorForm(
177177 draft.creating,
178178 () =>
179179 html`<input
180 class="input mono editor-path"
180 class="input mono"
181181 name="path"
182182 value="${draft.path}"
183183 placeholder="path/to/file.ts"
@@ -208,9 +208,9 @@ function editorForm(
208208 isMarkdownPath(draft.path) || draft.creating,
209209 () =>
210210 html`<button
211 class="btn editor-tab"
211 class="btn"
212212 data-size="sm"
213 data-variant="ghost"
213 data-variant="secondary"
214214 type="button"
215215 data-editor-tab="write"
216216 aria-selected="true"
@@ -219,7 +219,7 @@ function editorForm(
219219 Write
220220 </button>
221221 <button
222 class="btn editor-tab"
222 class="btn"
223223 data-size="sm"
224224 data-variant="ghost"
225225 type="button"
@@ -239,10 +239,10 @@ function editorForm(
239239 ${mirror ? raw('data-mirror="1"') : null}
240240 >
241241 <div class="editor-gutter" id="editor-gutter" aria-hidden="true"></div>
242 <div class="editor-stack">
242 <div>
243243 ${when(mirror, () =>
244244 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(
246246 draft.content,
247247 lang,
248248 )}</pre>`,
@@ -261,7 +261,7 @@ ${draft.content}</textarea
261261 </div>
262262 </div>
263263 <div
264 class="prose editor-preview"
264 class="prose"
265265 id="editor-preview"
266266 data-preview="${base}/preview"
267267 data-path="${draft.path}"
@@ -511,7 +511,7 @@ export async function editorPage(
511511 function mirrorHtml(content: string, lang: string): string {
512512 return highlightToHtml(content, lang as Parameters<typeof highlightToHtml>[1])
513513 .split("\n")
514 .map((line) => `<div class="ml">${line}</div>`)
514 .map((line) => `<div>${line}</div>`)
515515 .join("");
516516 }
517517
src/web/style/content.ts +13 −13
@@ -1052,8 +1052,8 @@ form.inline { display: inline; margin-left: auto; }
10521052 background: var(--card); border: 1px solid var(--border);
10531053 border-radius: var(--radius-sm) var(--radius-sm) 0 0;
10541054 }
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; }
10571057
10581058 .editor-wrap {
10591059 display: flex; align-items: stretch;
@@ -1081,10 +1081,10 @@ form.inline { display: inline; margin-left: auto; }
10811081 line height or tab size would put the caret in the wrong place. Neither
10821082 wraps: the editor scrolls horizontally instead of reflowing, which is what
10831083 a code editor does. */
1084 .editor-stack {
1084 .editor-wrap > div:last-child {
10851085 position: relative; display: grid; flex: 1; min-width: 0; min-height: 0;

.editor-wrap > div:last-child depends on DOM order. A single named hook, or display:grid on a child with an id, would survive a new sibling being added.

10861086 }
1087 .editor-mirror,
1087 #editor-mirror,
10881088 textarea.editor {
10891089 grid-area: 1 / 1;
10901090 margin: 0; border: none; background: none;
@@ -1093,29 +1093,29 @@ textarea.editor {
10931093 tab-size: 2; white-space: pre;
10941094 overflow: auto;
10951095 }
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; }
10981098 textarea.editor { resize: vertical; field-sizing: content; }
10991099 textarea.editor:focus-visible { outline: none; }
1100 .editor-mirror {
1100 #editor-mirror {
11011101 overflow: hidden; pointer-events: none; user-select: none;
11021102 color: var(--fg);
11031103 }
11041104 /* Only when the mirror is live does the textarea's own text step aside — with
11051105 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 {
11081108 color: transparent;
11091109 caret-color: var(--fg);
11101110 -webkit-text-fill-color: transparent;
11111111 }
1112 .editor-wrap.mirrored textarea.editor::selection {
1112 .editor-wrap[data-mirrored] textarea.editor::selection {
11131113 background: color-mix(in srgb, var(--accent) 30%, transparent);
11141114 }
1115 .editor-mirror .ml {
1115 #editor-mirror > div {
11161116 display: block; min-height: 1.55em; min-width: 100%; width: max-content;
11171117 }
1118 .editor-mirror .ml.cur {
1118 #editor-mirror > [aria-current] {
11191119 background: color-mix(in srgb, var(--muted) 75%, transparent);
11201120 }
11211121 .editor-wrap { --editor-fs: 12.5px; }
@@ -1124,7 +1124,7 @@ textarea.editor:focus-visible { outline: none; }
11241124 @media (pointer: coarse) { .editor-wrap { --editor-fs: 16px; } }
11251125 /* The ring above is a shadow, which forced colours drop; the textarea's own outline is off, so draw one. */
11261126 @media (forced-colors: active) { .editor-wrap:focus-within { outline: 2px solid Highlight; outline-offset: 2px; } }
1127 .editor-preview {
1127 #editor-preview {
11281128 border: 1px solid var(--border); border-top: none;
11291129 border-radius: 0 0 var(--radius) var(--radius);
11301130 background: var(--card); min-height: 12rem;
test/web-editor.test.ts +12 −3
@@ -184,6 +184,15 @@ describe("editing files on the web", () => {
184184 expect(theirs).not.toContain("/alice/beacon/edit/main/src/lamp.ts");
185185 });
186186
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
187196 test("a stranger cannot even open the editor", async () => {
188197 const res = await get("/alice/beacon/edit/main/src/lamp.ts", kim);
189198 expect(res.status).toBe(404);
@@ -617,13 +626,13 @@ describe("editing files on the web", () => {
617626 files: [{ path: "src/mirror.ts", content: "export const lit = true\n" }],
618627 });
619628 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"');
621630 expect(html).toContain('data-mirror="1"');
622631 expect(html).toContain('data-lang="ts"');
623632 expect(html).toContain("<textarea");
624633 // The first paint is already highlighted: no flash of unstyled code.
625634 expect(html).toContain(
626 '<div class="ml"><span class="tok-keyword">export</span>',
635 '<div><span class="tok-keyword">export</span>',
627636 );
628637 // …and the text is still in the textarea, which is what actually posts.
629638 expect(html).toContain("export const lit = true");
@@ -759,7 +768,7 @@ describe("editing files on the web", () => {
759768 const html = await text("/alice/beacon/edit/main/src/generated.ts", alice);
760769 expect(html).toContain("<textarea");
761770 // 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"');
763772 expect(html).not.toContain('data-mirror="1"');
764773 expect(html).not.toContain('<script type="module"');
765774 const res = await get("/alice/beacon/edit/main/src/generated.ts", alice);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs