Spaces:
Running
Running
Document style switch: Google Docs' Arial, or a serif reading setting
#3
by thomwolf HF Staff - opened
- client/app.css +120 -2
- client/doc.html +16 -0
- client/fonts/libre-baskerville-latin-ext-italic.woff2 +0 -0
- client/fonts/libre-baskerville-latin-ext.woff2 +0 -0
- client/fonts/libre-baskerville-latin-italic.woff2 +0 -0
- client/fonts/libre-baskerville-latin.woff2 +0 -0
- client/src/common.js +46 -0
- client/src/doc.js +3 -2
- test/browser.js +54 -0
client/app.css
CHANGED
|
@@ -19,6 +19,53 @@
|
|
| 19 |
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
* { box-sizing: border-box; }
|
| 23 |
:root {
|
| 24 |
color-scheme: light;
|
|
@@ -391,7 +438,7 @@ textarea { resize: vertical; }
|
|
| 391 |
|
| 392 |
/* editing / suggesting switch — the header's mode control, like Docs' pencil */
|
| 393 |
/* the theme toggle is icon-only in the header; the label is for the ⋯ sheet */
|
| 394 |
-
#theme-btn .lbl { display: none; }
|
| 395 |
|
| 396 |
#mode-switch { display: inline-flex; gap: 2px; padding: 2px; border-radius: 999px; background: var(--tint); flex-shrink: 0; }
|
| 397 |
#mode-switch button {
|
|
@@ -518,6 +565,73 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 518 |
--code-block-border: #3a3a40;
|
| 519 |
}
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
/* --- home --- */
|
| 522 |
.home { max-width: 680px; margin: 0 auto; padding: 48px 24px 120px; }
|
| 523 |
.home-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
|
@@ -1000,6 +1114,9 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 1000 |
.proposal-bar { padding: 12px 14px; }
|
| 1001 |
/* 11pt is uncomfortably small on a handset — nudged up, still Arial/1.15 */
|
| 1002 |
.tiptap { font-size: calc(16px * var(--doc-zoom, 1)); }
|
|
|
|
|
|
|
|
|
|
| 1003 |
.tiptap pre { font-size: 13px; padding: 10px; }
|
| 1004 |
/* wide content scrolls inside itself rather than widening the page */
|
| 1005 |
.tiptap table, .sugg-ins-block table { display: block; overflow-x: auto; white-space: nowrap; }
|
|
@@ -1031,7 +1148,7 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 1031 |
|
| 1032 |
/* theme + mode controls ride the ⋯ sheet on a phone, where the header is full */
|
| 1033 |
#more-pop #theme-btn { width: 100%; justify-content: flex-start; gap: 10px; padding: 11px 12px; height: auto; font-size: 14px; }
|
| 1034 |
-
#more-pop #theme-btn .lbl { display: inline; }
|
| 1035 |
/* the mode switch rides the ⋯ sheet on a phone, where the header is full */
|
| 1036 |
#more-pop #mode-switch { margin: 4px 8px 6px; justify-content: center; }
|
| 1037 |
#more-pop #mode-switch button { flex: 1; padding: 9px 12px; font-size: 13.5px; }
|
|
@@ -1056,3 +1173,4 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 1056 |
@media (hover: none) {
|
| 1057 |
[data-tip]:hover::after { display: none; }
|
| 1058 |
}
|
|
|
|
|
|
| 19 |
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
| 20 |
}
|
| 21 |
|
| 22 |
+
/* Libre Baskerville, for the "Reading" document style. Four files, 132KB,
|
| 23 |
+
each well inside the ~100KB the Hub allows a non-LFS binary — and each a
|
| 24 |
+
variable font carrying wght 400-700, so bold and bold italic are interpolated
|
| 25 |
+
from the real design rather than synthesised by slanting or smearing a roman.
|
| 26 |
+
(Google serves one file per style for both weights; the pair of weights in
|
| 27 |
+
the descriptor below is what tells the browser to use the axis.)
|
| 28 |
+
|
| 29 |
+
Chosen by eye from a tuner over fourteen serifs, not by distance from the
|
| 30 |
+
magazine's own Ubique Text — that face was cut for the Council on Foreign
|
| 31 |
+
Relations and cannot be licensed, so the comparison had a floor. What the
|
| 32 |
+
measurements did settle is the sizing below.
|
| 33 |
+
|
| 34 |
+
Nothing references them until data-doc-style="reading" is stamped, and an
|
| 35 |
+
unused @font-face is never fetched — Docs-style readers pay nothing. */
|
| 36 |
+
@font-face {
|
| 37 |
+
font-family: 'Libre Baskerville';
|
| 38 |
+
font-style: normal;
|
| 39 |
+
font-weight: 400 700;
|
| 40 |
+
font-display: swap;
|
| 41 |
+
src: url('/fonts/libre-baskerville-latin-ext.woff2') format('woff2');
|
| 42 |
+
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
| 43 |
+
}
|
| 44 |
+
@font-face {
|
| 45 |
+
font-family: 'Libre Baskerville';
|
| 46 |
+
font-style: italic;
|
| 47 |
+
font-weight: 400 700;
|
| 48 |
+
font-display: swap;
|
| 49 |
+
src: url('/fonts/libre-baskerville-latin-ext-italic.woff2') format('woff2');
|
| 50 |
+
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
| 51 |
+
}
|
| 52 |
+
@font-face {
|
| 53 |
+
font-family: 'Libre Baskerville';
|
| 54 |
+
font-style: normal;
|
| 55 |
+
font-weight: 400 700;
|
| 56 |
+
font-display: swap;
|
| 57 |
+
src: url('/fonts/libre-baskerville-latin.woff2') format('woff2');
|
| 58 |
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
| 59 |
+
}
|
| 60 |
+
@font-face {
|
| 61 |
+
font-family: 'Libre Baskerville';
|
| 62 |
+
font-style: italic;
|
| 63 |
+
font-weight: 400 700;
|
| 64 |
+
font-display: swap;
|
| 65 |
+
src: url('/fonts/libre-baskerville-latin-italic.woff2') format('woff2');
|
| 66 |
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
* { box-sizing: border-box; }
|
| 70 |
:root {
|
| 71 |
color-scheme: light;
|
|
|
|
| 438 |
|
| 439 |
/* editing / suggesting switch — the header's mode control, like Docs' pencil */
|
| 440 |
/* the theme toggle is icon-only in the header; the label is for the ⋯ sheet */
|
| 441 |
+
#theme-btn .lbl, #docstyle-btn .lbl { display: none; }
|
| 442 |
|
| 443 |
#mode-switch { display: inline-flex; gap: 2px; padding: 2px; border-radius: 999px; background: var(--tint); flex-shrink: 0; }
|
| 444 |
#mode-switch button {
|
|
|
|
| 565 |
--code-block-border: #3a3a40;
|
| 566 |
}
|
| 567 |
|
| 568 |
+
/* --- document style: Docs vs Reading ---------------------------------------
|
| 569 |
+
A second axis, independent of light/dark: what the *document* is set in.
|
| 570 |
+
The default ("docs") is Google Docs' Arial 11pt/1.15, set in :root above.
|
| 571 |
+
"reading" is the long-form magazine setting measured off a Foreign Affairs
|
| 572 |
+
article: a serif at 22px/1.45, space between paragraphs instead of Docs'
|
| 573 |
+
none, and small-caps section heads.
|
| 574 |
+
|
| 575 |
+
The sheet is untouched — and it happens to need no touching: Docs' 8.5in page
|
| 576 |
+
with 1in margins leaves a 624px column, within a few pixels of the 630px the
|
| 577 |
+
article is set to, so the measure is already right at this size.
|
| 578 |
+
|
| 579 |
+
The page geometry is the one thing Reading leaves alone. No literal colour
|
| 580 |
+
appears below either — every rule inherits --doc-text and friends, so Reading
|
| 581 |
+
works in dark mode without a second block. A head script stamps
|
| 582 |
+
data-doc-style before first paint, like the theme. */
|
| 583 |
+
:root[data-doc-style="reading"] {
|
| 584 |
+
--doc: "Libre Baskerville", Charter, "Iowan Old Style", Georgia, "Times New Roman", serif;
|
| 585 |
+
/* Chosen at the page rather than derived, after sliding the numbers around
|
| 586 |
+
against real text. Measured over 37 paragraphs in this 624px column it sets
|
| 587 |
+
10.69 words a line on 28.8px of leading, which puts it between Docs (13.54
|
| 588 |
+
words on 16.9px) and the magazine (9.88 on 31.9px) and nearer the magazine.
|
| 589 |
+
Switching styles therefore does reflow the document — Baskerville is a wide
|
| 590 |
+
face, and no size of it both matches Docs' line and stays this open. */
|
| 591 |
+
--doc-size: 16px;
|
| 592 |
+
--doc-line: 1.8; /* = 28.8px */
|
| 593 |
+
--para-gap: 1.1em; /* = 17.6px: paragraphs are separated by space… */
|
| 594 |
+
--doc-link: var(--doc-text); /* the article underlines links in the text ink */
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
/* …not by indentation, so the "no space between paragraphs" Docs rule lifts */
|
| 598 |
+
[data-doc-style="reading"] .tiptap p { margin: 0 0 var(--para-gap); }
|
| 599 |
+
[data-doc-style="reading"] .tiptap p:last-child { margin-bottom: 0; }
|
| 600 |
+
[data-doc-style="reading"] .tiptap li > p { margin: 0; }
|
| 601 |
+
|
| 602 |
+
/* Section heads are small caps at text weight — the magazine's own hierarchy,
|
| 603 |
+
which leans on letterspacing and space above rather than on size and bold.
|
| 604 |
+
font-variant is synthesised from the roman here; Libre Baskerville ships no
|
| 605 |
+
true small-cap glyphs (the article's own face does). */
|
| 606 |
+
[data-doc-style="reading"] .tiptap h2,
|
| 607 |
+
[data-doc-style="reading"] .tiptap h3 {
|
| 608 |
+
/* 25.6px small caps, with the article's 0.075em tracking and its 2em of air
|
| 609 |
+
above — the proportions that make a section head read as one */
|
| 610 |
+
font-size: 1.6em; font-variant: all-small-caps; letter-spacing: 0.075em;
|
| 611 |
+
line-height: 1.45; margin: 2em 0 0.333em; color: var(--doc-text);
|
| 612 |
+
}
|
| 613 |
+
/* holds the article's 64-to-30 relationship between headline and section head */
|
| 614 |
+
[data-doc-style="reading"] .tiptap h1 { font-size: 3.4133em; line-height: 1.1; margin: 1.25em 0 0.208em; }
|
| 615 |
+
[data-doc-style="reading"] .tiptap h4 { font-size: 1em; font-weight: 700; line-height: 1.3; margin: 2em 0 0.455em; color: var(--doc-text); }
|
| 616 |
+
[data-doc-style="reading"] .tiptap h5 { font-size: 1em; font-style: italic; line-height: 1.3; margin: 2em 0 0.455em; color: var(--doc-text); }
|
| 617 |
+
[data-doc-style="reading"] .tiptap h6 {
|
| 618 |
+
font-size: 0.818em; font-variant: all-small-caps; letter-spacing: 0.075em;
|
| 619 |
+
font-style: normal; line-height: 1.3; margin: 2.44em 0 0.556em; color: var(--doc-text-2);
|
| 620 |
+
}
|
| 621 |
+
/* nothing above the first block, whatever it is */
|
| 622 |
+
[data-doc-style="reading"] .tiptap > :first-child { margin-top: 0; }
|
| 623 |
+
/* the opening heading is the article title: the display cut, set large */
|
| 624 |
+
[data-doc-style="reading"] .tiptap > h1:first-child { font-size: 2.909em; line-height: 1.1; margin-bottom: 0.234em; }
|
| 625 |
+
|
| 626 |
+
/* an underline in the text's own ink, rather than Docs' blue */
|
| 627 |
+
[data-doc-style="reading"] .tiptap a { text-decoration: underline; text-decoration-thickness: 0.05em; text-underline-offset: 0.15em; }
|
| 628 |
+
[data-doc-style="reading"] .tiptap hr { border-top-color: var(--doc-text); margin: 1.818em 0; }
|
| 629 |
+
[data-doc-style="reading"] .tiptap blockquote { padding-left: 1.2em; font-style: italic; }
|
| 630 |
+
/* lists sit close to the text, not at Docs' half-inch tab stop */
|
| 631 |
+
[data-doc-style="reading"] .tiptap ul,
|
| 632 |
+
[data-doc-style="reading"] .tiptap ol { padding-left: 1.4em; margin: 0 0 var(--para-gap); }
|
| 633 |
+
[data-doc-style="reading"] .tiptap li { padding: 0.227em 0; }
|
| 634 |
+
|
| 635 |
/* --- home --- */
|
| 636 |
.home { max-width: 680px; margin: 0 auto; padding: 48px 24px 120px; }
|
| 637 |
.home-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
|
|
|
| 1114 |
.proposal-bar { padding: 12px 14px; }
|
| 1115 |
/* 11pt is uncomfortably small on a handset — nudged up, still Arial/1.15 */
|
| 1116 |
.tiptap { font-size: calc(16px * var(--doc-zoom, 1)); }
|
| 1117 |
+
/* Reading steps down too: 22px is a desktop column measure, and a phone has
|
| 1118 |
+
no room for it. The article itself drops to 18px on small screens. */
|
| 1119 |
+
[data-doc-style="reading"] .tiptap { font-size: calc(18px * var(--doc-zoom, 1)); }
|
| 1120 |
.tiptap pre { font-size: 13px; padding: 10px; }
|
| 1121 |
/* wide content scrolls inside itself rather than widening the page */
|
| 1122 |
.tiptap table, .sugg-ins-block table { display: block; overflow-x: auto; white-space: nowrap; }
|
|
|
|
| 1148 |
|
| 1149 |
/* theme + mode controls ride the ⋯ sheet on a phone, where the header is full */
|
| 1150 |
#more-pop #theme-btn { width: 100%; justify-content: flex-start; gap: 10px; padding: 11px 12px; height: auto; font-size: 14px; }
|
| 1151 |
+
#more-pop #theme-btn .lbl, #more-pop #docstyle-btn .lbl { display: inline; }
|
| 1152 |
/* the mode switch rides the ⋯ sheet on a phone, where the header is full */
|
| 1153 |
#more-pop #mode-switch { margin: 4px 8px 6px; justify-content: center; }
|
| 1154 |
#more-pop #mode-switch button { flex: 1; padding: 9px 12px; font-size: 13.5px; }
|
|
|
|
| 1173 |
@media (hover: none) {
|
| 1174 |
[data-tip]:hover::after { display: none; }
|
| 1175 |
}
|
| 1176 |
+
|
client/doc.html
CHANGED
|
@@ -13,6 +13,21 @@
|
|
| 13 |
} catch (e) {
|
| 14 |
document.documentElement.dataset.theme = 'light'
|
| 15 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
</script>
|
| 17 |
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
|
| 18 |
<!--boot-->
|
|
@@ -44,6 +59,7 @@
|
|
| 44 |
<button class="iconbtn phone-only" id="m-fmt" title="Formatting">Aa</button>
|
| 45 |
<button class="iconbtn phone-only" id="m-comments" title="Comments"></button>
|
| 46 |
<button class="iconbtn phone-only" id="m-more" title="More">⋯</button>
|
|
|
|
| 47 |
<button class="iconbtn" id="theme-btn" title="Switch theme"></button>
|
| 48 |
<button class="btn ghost small" id="share-btn">Share</button>
|
| 49 |
<button class="btn ghost small" id="agents-btn">Agents</button>
|
|
|
|
| 13 |
} catch (e) {
|
| 14 |
document.documentElement.dataset.theme = 'light'
|
| 15 |
}
|
| 16 |
+
// …and the document style, so the sheet is never set in the wrong face for
|
| 17 |
+
// a frame. Reading also needs its serif early: the stylesheet only asks for
|
| 18 |
+
// it once text is laid out, which is a whole round trip too late.
|
| 19 |
+
try {
|
| 20 |
+
var s = localStorage.getItem('cw-doc-style')
|
| 21 |
+
document.documentElement.dataset.docStyle = s === 'reading' ? 'reading' : 'docs'
|
| 22 |
+
if (s === 'reading') {
|
| 23 |
+
var l = document.createElement('link')
|
| 24 |
+
l.rel = 'preload'; l.as = 'font'; l.type = 'font/woff2'; l.crossOrigin = 'anonymous'
|
| 25 |
+
l.id = 'serif-preload'; l.href = '/fonts/libre-baskerville-latin.woff2'
|
| 26 |
+
document.head.appendChild(l)
|
| 27 |
+
}
|
| 28 |
+
} catch (e) {
|
| 29 |
+
document.documentElement.dataset.docStyle = 'docs'
|
| 30 |
+
}
|
| 31 |
</script>
|
| 32 |
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
|
| 33 |
<!--boot-->
|
|
|
|
| 59 |
<button class="iconbtn phone-only" id="m-fmt" title="Formatting">Aa</button>
|
| 60 |
<button class="iconbtn phone-only" id="m-comments" title="Comments"></button>
|
| 61 |
<button class="iconbtn phone-only" id="m-more" title="More">⋯</button>
|
| 62 |
+
<button class="iconbtn" id="docstyle-btn" title="Switch document style"></button>
|
| 63 |
<button class="iconbtn" id="theme-btn" title="Switch theme"></button>
|
| 64 |
<button class="btn ghost small" id="share-btn">Share</button>
|
| 65 |
<button class="btn ghost small" id="agents-btn">Agents</button>
|
client/fonts/libre-baskerville-latin-ext-italic.woff2
ADDED
|
Binary file (35.2 kB). View file
|
|
|
client/fonts/libre-baskerville-latin-ext.woff2
ADDED
|
Binary file (35.5 kB). View file
|
|
|
client/fonts/libre-baskerville-latin-italic.woff2
ADDED
|
Binary file (31.2 kB). View file
|
|
|
client/fonts/libre-baskerville-latin.woff2
ADDED
|
Binary file (33.9 kB). View file
|
|
|
client/src/common.js
CHANGED
|
@@ -10,6 +10,8 @@ const ICONS = {
|
|
| 10 |
back: '<path d="M15 4l-7 7 7 7"/>',
|
| 11 |
sun: '<circle cx="11" cy="11" r="4"/><path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17"/>',
|
| 12 |
moon: '<path d="M17 13.2A7 7 0 0 1 8.8 5a7.2 7.2 0 1 0 8.2 8.2z"/>',
|
|
|
|
|
|
|
| 13 |
comment: '<path d="M19 12.5a6.5 6.5 0 0 1-6.5 6.5H5l-1.8 1.8V12.5a7.3 7.3 0 0 1 7.3-7.3h1.5A6.5 6.5 0 0 1 19 12.5z"/>',
|
| 14 |
edit: '<path d="M14.5 4.5l3 3L8 17l-4 1 1-4 9.5-9.5z"/>',
|
| 15 |
image: '<rect x="3.5" y="4.5" width="15" height="13" rx="2"/><circle cx="8" cy="9" r="1.4"/><path d="M18 14l-4-4-7 7"/>',
|
|
@@ -139,6 +141,50 @@ export function wireTheme(btn) {
|
|
| 139 |
})
|
| 140 |
}
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
export function colorFor(name) {
|
| 143 |
let h = 0
|
| 144 |
for (const c of String(name)) h = (h * 31 + c.charCodeAt(0)) % 360
|
|
|
|
| 10 |
back: '<path d="M15 4l-7 7 7 7"/>',
|
| 11 |
sun: '<circle cx="11" cy="11" r="4"/><path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17"/>',
|
| 12 |
moon: '<path d="M17 13.2A7 7 0 0 1 8.8 5a7.2 7.2 0 1 0 8.2 8.2z"/>',
|
| 13 |
+
serif: '<path d="M4.5 6V4.5h13V6M11 4.5v13M8 17.5h6"/>',
|
| 14 |
+
docpage: '<rect x="4.5" y="3.5" width="13" height="15" rx="1.5"/><path d="M7.5 8h7M7.5 11.5h7M7.5 15h4"/>',
|
| 15 |
comment: '<path d="M19 12.5a6.5 6.5 0 0 1-6.5 6.5H5l-1.8 1.8V12.5a7.3 7.3 0 0 1 7.3-7.3h1.5A6.5 6.5 0 0 1 19 12.5z"/>',
|
| 16 |
edit: '<path d="M14.5 4.5l3 3L8 17l-4 1 1-4 9.5-9.5z"/>',
|
| 17 |
image: '<rect x="3.5" y="4.5" width="15" height="13" rx="2"/><circle cx="8" cy="9" r="1.4"/><path d="M18 14l-4-4-7 7"/>',
|
|
|
|
| 141 |
})
|
| 142 |
}
|
| 143 |
|
| 144 |
+
// --- document style ---------------------------------------------------------
|
| 145 |
+
// What the document itself is set in: 'docs' (Arial 11pt on a letter sheet) or
|
| 146 |
+
// 'reading' (a serif article column). Unlike the theme this has no OS signal to
|
| 147 |
+
// follow, so the default is simply 'docs'. The head script has already stamped
|
| 148 |
+
// the stored choice; this wires the button that changes it.
|
| 149 |
+
export const DOC_STYLES = ['docs', 'reading']
|
| 150 |
+
|
| 151 |
+
export function wireDocStyle(btn) {
|
| 152 |
+
const paint = () => {
|
| 153 |
+
const reading = document.documentElement.dataset.docStyle === 'reading'
|
| 154 |
+
if (!btn) return
|
| 155 |
+
btn.replaceChildren(icon(reading ? 'docpage' : 'serif'), el('span', { class: 'lbl' }, reading ? 'Docs' : 'Reading'))
|
| 156 |
+
btn.title = reading ? 'Set the document in Arial, like Google Docs' : 'Set the document in a serif reading column'
|
| 157 |
+
btn.setAttribute('aria-label', btn.title)
|
| 158 |
+
}
|
| 159 |
+
const apply = style => {
|
| 160 |
+
document.documentElement.dataset.docStyle = style
|
| 161 |
+
// the serif is only fetched once something on the page asks for it, so the
|
| 162 |
+
// first switch is also the first request — warm it alongside the repaint
|
| 163 |
+
if (style === 'reading') preloadSerif()
|
| 164 |
+
paint()
|
| 165 |
+
}
|
| 166 |
+
paint()
|
| 167 |
+
btn?.addEventListener('click', () => {
|
| 168 |
+
const next = document.documentElement.dataset.docStyle === 'reading' ? 'docs' : 'reading'
|
| 169 |
+
try {
|
| 170 |
+
localStorage.setItem('cw-doc-style', next)
|
| 171 |
+
} catch {}
|
| 172 |
+
apply(next)
|
| 173 |
+
})
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
function preloadSerif() {
|
| 177 |
+
if (document.getElementById('serif-preload')) return
|
| 178 |
+
const l = document.createElement('link')
|
| 179 |
+
l.id = 'serif-preload'
|
| 180 |
+
l.rel = 'preload'
|
| 181 |
+
l.as = 'font'
|
| 182 |
+
l.type = 'font/woff2'
|
| 183 |
+
l.crossOrigin = 'anonymous'
|
| 184 |
+
l.href = '/fonts/libre-baskerville-latin.woff2'
|
| 185 |
+
document.head.appendChild(l)
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
export function colorFor(name) {
|
| 189 |
let h = 0
|
| 190 |
for (const c of String(name)) h = (h * 31 + c.charCodeAt(0)) % 360
|
client/src/doc.js
CHANGED
|
@@ -198,7 +198,7 @@ import { ySyncPluginKey, yUndoPluginKey, absolutePositionToRelativePosition, rel
|
|
| 198 |
import { markdownToBlocks } from '../../server/md.js'
|
| 199 |
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
| 200 |
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
| 201 |
-
import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox, wireTheme } from './common.js'
|
| 202 |
import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js'
|
| 203 |
|
| 204 |
const docId = location.pathname.split('/').filter(Boolean)[1]
|
|
@@ -244,6 +244,7 @@ async function main() {
|
|
| 244 |
// --- one-time shell wiring (survives page switches) ---
|
| 245 |
document.getElementById('back-link').appendChild(icon('back'))
|
| 246 |
wireTheme(document.getElementById('theme-btn'))
|
|
|
|
| 247 |
wireSelectionMenu()
|
| 248 |
wireMobileChrome()
|
| 249 |
wireMarginToggle()
|
|
@@ -870,7 +871,7 @@ function syncHeaderHeight() {
|
|
| 870 |
// Move the crowded-out header controls in and out of the ⋯ sheet.
|
| 871 |
function applyBreakpoint() {
|
| 872 |
const pop = document.getElementById('more-pop')
|
| 873 |
-
const movable = () => ['mode-switch', 'theme-btn', 'share-btn', 'agents-btn', 'reset-btn', 'whoami'].map(id => document.getElementById(id)).filter(Boolean)
|
| 874 |
if (isPhone()) {
|
| 875 |
for (const node of movable()) {
|
| 876 |
if (pop.contains(node)) continue
|
|
|
|
| 198 |
import { markdownToBlocks } from '../../server/md.js'
|
| 199 |
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
| 200 |
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
| 201 |
+
import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox, wireTheme, wireDocStyle } from './common.js'
|
| 202 |
import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js'
|
| 203 |
|
| 204 |
const docId = location.pathname.split('/').filter(Boolean)[1]
|
|
|
|
| 244 |
// --- one-time shell wiring (survives page switches) ---
|
| 245 |
document.getElementById('back-link').appendChild(icon('back'))
|
| 246 |
wireTheme(document.getElementById('theme-btn'))
|
| 247 |
+
wireDocStyle(document.getElementById('docstyle-btn'))
|
| 248 |
wireSelectionMenu()
|
| 249 |
wireMobileChrome()
|
| 250 |
wireMarginToggle()
|
|
|
|
| 871 |
// Move the crowded-out header controls in and out of the ⋯ sheet.
|
| 872 |
function applyBreakpoint() {
|
| 873 |
const pop = document.getElementById('more-pop')
|
| 874 |
+
const movable = () => ['mode-switch', 'docstyle-btn', 'theme-btn', 'share-btn', 'agents-btn', 'reset-btn', 'whoami'].map(id => document.getElementById(id)).filter(Boolean)
|
| 875 |
if (isPhone()) {
|
| 876 |
for (const node of movable()) {
|
| 877 |
if (pop.contains(node)) continue
|
test/browser.js
CHANGED
|
@@ -738,6 +738,60 @@ async function main() {
|
|
| 738 |
await page.click('#zoom-out')
|
| 739 |
console.log('✓ zoom controls')
|
| 740 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
// --- undo / redo ---
|
| 742 |
await focusDocEnd(page)
|
| 743 |
await page.waitForTimeout(600)
|
|
|
|
| 738 |
await page.click('#zoom-out')
|
| 739 |
console.log('✓ zoom controls')
|
| 740 |
|
| 741 |
+
// --- document style: Docs (Arial 11pt/1.15) <-> Reading (serif 22px/1.45) ---
|
| 742 |
+
const typeset = () =>
|
| 743 |
+
page.evaluate(() => {
|
| 744 |
+
const t = getComputedStyle(document.querySelector('.tiptap'))
|
| 745 |
+
const p = [...document.querySelectorAll('.tiptap p')].find(e => e.textContent.trim().length > 40)
|
| 746 |
+
return {
|
| 747 |
+
style: document.documentElement.dataset.docStyle,
|
| 748 |
+
family: t.fontFamily.split(',')[0].replace(/"/g, ''),
|
| 749 |
+
size: parseFloat(t.fontSize),
|
| 750 |
+
line: parseFloat(t.lineHeight),
|
| 751 |
+
paraGap: p ? parseFloat(getComputedStyle(p).marginBottom) : null,
|
| 752 |
+
measure: Math.round(document.querySelector('.tiptap').getBoundingClientRect().width),
|
| 753 |
+
}
|
| 754 |
+
})
|
| 755 |
+
|
| 756 |
+
const docsSet = await typeset()
|
| 757 |
+
assert.strictEqual(docsSet.style, 'docs', 'default document style is docs')
|
| 758 |
+
assert.strictEqual(docsSet.family, 'Arial', 'docs style sets the document in Arial')
|
| 759 |
+
assert.strictEqual(docsSet.paraGap, 0, 'docs style has no space between paragraphs')
|
| 760 |
+
|
| 761 |
+
await page.click('#docstyle-btn')
|
| 762 |
+
await waitFor(async () => (await typeset()).family === 'Libre Baskerville', 'reading style swaps in the serif')
|
| 763 |
+
const readingSet = await typeset()
|
| 764 |
+
// The settled numbers, pinned: 16px on 28.8px with 17.6px between paragraphs.
|
| 765 |
+
// Chosen by eye against real text rather than derived, which is exactly why
|
| 766 |
+
// they are worth pinning — nothing else records why these and not others.
|
| 767 |
+
assert.strictEqual(readingSet.size, 16, `body is set at 16px (got ${readingSet.size})`)
|
| 768 |
+
assert.strictEqual(Math.round(readingSet.line * 10) / 10, 28.8, `leading is 28.8px (got ${readingSet.line})`)
|
| 769 |
+
assert.strictEqual(Math.round(readingSet.paraGap * 10) / 10, 17.6, `paragraph gap is 17.6px (got ${readingSet.paraGap})`)
|
| 770 |
+
const head = await page.evaluate(() => {
|
| 771 |
+
const h = document.querySelector('.tiptap h2')
|
| 772 |
+
if (!h) return null
|
| 773 |
+
const s = getComputedStyle(h)
|
| 774 |
+
return { size: parseFloat(s.fontSize), line: parseFloat(s.lineHeight), caps: s.fontVariantCaps,
|
| 775 |
+
ls: parseFloat(s.letterSpacing), mt: parseFloat(s.marginTop) }
|
| 776 |
+
})
|
| 777 |
+
if (head) {
|
| 778 |
+
assert.strictEqual(Math.round(head.size * 10) / 10, 25.6, `section heads are 25.6px (got ${head.size})`)
|
| 779 |
+
assert.strictEqual(head.caps, 'all-small-caps', 'section heads are small caps')
|
| 780 |
+
assert.ok(Math.abs(head.ls / head.size - 0.075) < 0.005, `section heads keep the article's 0.075em tracking (got ${(head.ls / head.size).toFixed(4)}em)`)
|
| 781 |
+
assert.ok(Math.abs(head.mt / head.size - 2) < 0.02, `section heads take 2em of air above (got ${(head.mt / head.size).toFixed(2)}em)`)
|
| 782 |
+
}
|
| 783 |
+
// the switch is typography only — the sheet keeps Docs' letter geometry
|
| 784 |
+
assert.strictEqual(readingSet.measure, docsSet.measure, 'the text column is untouched by the document style')
|
| 785 |
+
// (no words-per-line guard against Docs: this face at this size fits 10.69
|
| 786 |
+
// to Docs' 13.54, so the two deliberately differ and switching reflows)
|
| 787 |
+
// and it survives a reload, like the theme
|
| 788 |
+
await page.reload({ waitUntil: 'domcontentloaded' })
|
| 789 |
+
await waitFor(async () => !!(await page.$('.tiptap p')), 'doc reloads')
|
| 790 |
+
assert.strictEqual((await typeset()).family, 'Libre Baskerville', 'document style persists across a reload')
|
| 791 |
+
await page.click('#docstyle-btn')
|
| 792 |
+
await waitFor(async () => (await typeset()).family === 'Arial', 'switches back to docs')
|
| 793 |
+
console.log('✓ document style switch (Docs <-> Reading), persisted, page width untouched')
|
| 794 |
+
|
| 795 |
// --- undo / redo ---
|
| 796 |
await focusDocEnd(page)
|
| 797 |
await page.waitForTimeout(600)
|