Document style switch: Google Docs' Arial, or a serif reading setting

#3
by thomwolf HF Staff - opened
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)