Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions apps/mobile/__tests__/liveEditorMount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,4 +821,126 @@ describe("every --lp-* the editor's styles read is one this half declares", () =
for (const property of readIn(completion)) expect([...declared]).toContain(property);
m.unmount();
});

/**
* The reading measure is a `--lp-*` like any other, so it is subject to the
* sweep above — but it is the first one that is not a colour or a face, and
* a missing colour is at least visible. A missing *measure* is a note that
* still looks fine and reads at 150 characters a line, which is the failure
* this whole change exists to stop, so it is named here too.
*/
test("including the reading measure, which the iOS host also has to send", () => {
const m = mount({ value: "# note\n\nprose\n", editable: true });
const declared = declaredIn(
document.getElementById("context-live-preview-styles")?.textContent ?? "",
);
expect([...declared]).toContain("--lp-measure");
m.unmount();
});
});

/* -------------------------------------------------------------------------- */

/**
* THE NOTE IS A COLUMN, NOT THE WIDTH OF THE WINDOW.
*
* Measured in Chromium at 1440x900 before this existed: the element holding
* the first sentence of the console's own demo note was 1160px wide, with
* `max-width: none` on every one of its first eight ancestors — about 150
* characters to a line, twice a comfortable measure. It survived because the
* fixture note was hard-wrapped in `placeholderData.ts`, so every screenshot
* showed a tidy column that the layout had nothing to do with.
*
* jsdom does not lay anything out, so these assert the *rules* and
* `e2e/webkit/readingMeasure.spec.ts` asserts the rendered result in a real
* engine at both viewports. Both are needed: a rule that is present and does
* not bind is exactly what was there before.
*/
describe("the rendered note has a reading measure", () => {
/**
* One rule's declarations, by its selector, out of the mounted stylesheet.
*
* Comments are stripped rather than left in: these rules carry long ones,
* and a test asserting a property is *absent* would otherwise be satisfied
* or defeated by prose about it.
*/
function block(selector: string): string {
const css = (
document.getElementById("context-live-preview-styles")?.textContent ?? ""
).replace(/\/\*[\s\S]*?\*\//g, "");
const at = css.indexOf(`${selector} {`);
if (at === -1) return "";
return css.slice(at, css.indexOf("}", at));
}

test("the column is measured and centred, and it is the column rather than the line", () => {
const m = mount({ value: "# note\n\nprose\n", editable: true });

const content = block(".cm-lp-root .cm-content");
expect(content).toContain(
"padding-inline: max(0px, calc((100% - var(--lp-measure) * 1em) / 2))",
);

/*
The unit is added HERE and not where the property is declared. A
font-relative length inside a custom property may be resolved at the
declaring element or at the using one, and engines differ — and the
wrapper this is declared on is Times New Roman at 16px while the note is
a sans at 14.5px, so the two answers are different lengths. Shipped as
`62ch`, that read 75 characters a line in Chromium and 91 in WebKit on
the same runner.
*/
expect(block(".cm-lp-root")).toMatch(/--lp-measure:\s*\d+;/);

/*
Padding rather than `max-width: var(--lp-measure); margin-inline: auto`,
which draws the identical column. Measured in Chromium: the max-width
recipe leaves `.cm-content` 572px wide inside a 1192px pane, and a click
in the 310px either side lands on `.cm-scroller` and does not focus the
editor — half the note's apparent area stops being the editing surface.
With padding the element stays full width, so CodeMirror still maps a
click in the margin to the nearest position.
*/
expect(content).not.toContain("max-width");

/*
On `.cm-content` rather than on `.cm-line`: a table, a form and a
rendered diagram are block children of the same element, and measuring
the lines alone would leave each of those starting at a different left
edge from the paragraph above it. If a future edit moves the constraint
down to the line, this is the test that should have to be deleted
deliberately.
*/
expect(block(".cm-lp-root .cm-line")).not.toContain("max-width");

m.unmount();
});

/**
* WHAT IS ALLOWED TO BE WIDER THAN THE PROSE: nothing.
*
* A table is the case with a real argument on the other side — twelve
* columns in 68 characters is cramped — and it still loses, because a block
* wider than the text it sits between has to start left of that text, and a
* document with two left edges reads as broken layout rather than as a wide
* table. What a wide table gets instead is its own horizontal scroller, so
* it stays inside the column and the note never scrolls sideways as a whole.
*/
test("a table wider than the measure scrolls inside the column rather than widening it", () => {
const m = mount({
value: "# note\n\n| a | b | c |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n",
editable: false,
});

// The rendered grid really is on screen: without this the rules below are
// about a selector nothing matches.
expect(document.querySelectorAll(".cm-lp-grid").length).toBeGreaterThan(0);

expect(block(".cm-lp-grid")).toContain("overflow-x: auto");
// And the table inside it is sized by its content up to the column's own
// width — never past it, which is what would drag the column open.
expect(block(".cm-lp-grid-table")).toContain("max-width: 100%");

m.unmount();
});
});
125 changes: 124 additions & 1 deletion apps/mobile/__tests__/webviewBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
import { runCommand } from "../features/console/files/editorSetup";
import { splitNote } from "../features/console/files/frontmatter";
import { editorReducer, emptyEditor } from "../features/console/files/editor";
import { darkColors, lightColors } from "../features/design/tokens";
import { darkColors, layout, lightColors } from "../features/design/tokens";

/**
* Every command on the accessory bar that touches the document.
Expand Down Expand Up @@ -990,6 +990,129 @@ describe("the palette", () => {
expect(themeVars(darkColors, "Menlo", false)["--lp-content"]).toBe(darkColors.text2);
});

/**
* THE OTHER HALF OF THE GUARD `liveEditorMount.test.ts` HOLDS.
*
* That one proves the web console declares every `--lp-*` its stylesheets
* read. This is the same relationship on the host where the values arrive
* over a bridge — and the consequence of a gap is worse here, because an
* undeclared custom property does not fall back, it invalidates the whole
* declaration that names it. The note keeps rendering, without whatever that
* declaration was doing.
*
* Two questions, because there are two suppliers: the `:root` block in
* `styles.ts` is what a guest whose first theme message never arrives sees,
* and `themeVars` is what every real one gets.
*/
test("every --lp-* the guest stylesheet reads is one its own :root declares", () => {
const css = guestStyles();
const open = css.indexOf(":root {");
expect(open).toBeGreaterThan(-1);
const root = css.slice(open, css.indexOf("}", open));

const declared = new Set([...root.matchAll(/(--lp-[a-z0-9-]+)\s*:/g)].map((m) => m[1]));
const read = new Set([...css.matchAll(/var\(\s*(--lp-[a-z0-9-]+)/g)].map((m) => m[1]));

expect([...read].filter((property) => !declared.has(property))).toEqual([]);
// Named so a regression says which one went: the measure is the only
// property here that is not a colour or a face, and losing it is invisible
// in a screenshot of a short note.
expect([...declared]).toContain("--lp-measure");
});

test("and every one the host is responsible for is in themeVars", () => {
const css = guestStyles();
const read = [...css.matchAll(/var\(\s*(--lp-[a-z0-9-]+)/g)].map((m) => m[1]);
const sent = themeVars(darkColors, "Menlo", true);

/*
The one exception, and it is a real one rather than an excuse:
`--lp-inset-bottom` is how much of the editor the keyboard is covering.
It is measured on the device and written by the `inset` message (see
`guest.ts`), so it changes many times per second while a keyboard is
animating and has no business in a theme.
*/
const missing = [...new Set(read)].filter(
(property) => property !== "--lp-inset-bottom" && !(property in sent),
);
expect(missing).toEqual([]);
});

test("the note is drawn as a measured, centred column on this host too", () => {
// Comments stripped: this rule carries a long one, and the assertions
// below are about which properties are and are not set rather than about
// what the prose beside them mentions.
const css = guestStyles().replace(/\/\*[\s\S]*?\*\//g, "");
const at = css.indexOf("#root .cm-content {");
expect(at).toBeGreaterThan(-1);
const rule = css.slice(at, css.indexOf("}", at));

expect(rule).toContain("padding-inline: max(0px, calc((100% - var(--lp-measure) * 1em) / 2))");
// The same rule the web console has, on the column rather than the line,
// and by padding rather than by width — see `liveEditorMount.test.ts` for
// both halves of why.
expect(rule).not.toContain("max-width");
expect(css.slice(css.indexOf("#root .cm-line {"))).not.toMatch(
/^#root \.cm-line \{[^}]*(max-width|padding-inline)/,
);
});

test("the reading measure is a bare multiple, so it is one value for both densities", () => {
const compact = themeVars(darkColors, "Menlo", true)["--lp-measure"];
const pointer = themeVars(darkColors, "Menlo", false)["--lp-measure"];

/*
Unlike every other line in `themeVars`, this one does not branch on
`compact` — and that is the argument rather than an oversight. The type
scale differs between the two (16px and 14.5px); a measure stated as a
multiple of the type is the same line at both. A pixel measure would have
had to be two numbers kept in step by hand.
*/
expect(compact).toBe(pointer);

/*
And it carries NO UNIT. A font-relative length inside a custom property
may be resolved where the property is declared or where it is used, and
engines differ; the wrapper is Times New Roman at 16px and the note is a
sans at 14.5px, so those are two different lengths. `styles.ts`
multiplies by 1em against the text itself. A unit sneaking back in here
is that ambiguity returning, silently, on one engine only.
*/
expect(compact).toMatch(/^\d+$/);

/*
The band the number has to stay inside, which is the design decision
rather than the value. Prose in a system sans averages 0.45-0.55em a
character, so 36em is roughly 65-80 characters and the comfortable range
is 60-75. Anything outside this changes how the note reads and should
have to edit a test that says so.
`e2e/webkit/readingMeasure.spec.ts` checks the rendered result.
*/
expect(layout.readingMeasureEm).toBeGreaterThanOrEqual(30);
expect(layout.readingMeasureEm).toBeLessThanOrEqual(40);
});

test("the phone's own width is what governs there — the measure cannot bind", () => {
const compact = themeVars(darkColors, "Menlo", true);
const ems = Number(compact["--lp-measure"]);
const size = Number(compact["--lp-size"]?.replace("px", ""));
const pad = Number(compact["--lp-pad-x"]?.replace("px", ""));

/*
The measure in points is exactly the multiple times the type size — no
font metric involved, which is the other half of why the unit is em: this
arithmetic is the layout's, not a guess about a face. Against the widest
phone this app runs on (a 430pt iPhone Pro Max, wider than the 390 the
WebKit suite uses) the text column is still far narrower, so the padding
that insets the column computes to zero and `--lp-pad-x` decides the line
length. If that ever stops being true the phone quietly gains a centred
column with slack either side, which is not what a note on a phone should
look like.
*/
const widestPhone = 430 - 2 * pad;
expect(ems * size).toBeGreaterThan(widestPhone);
});

test("only our own custom properties are written", () => {
const target = document.createElement("div");
applyTheme(target, {
Expand Down
Loading
Loading