Help: one click opens the manual, two quick clicks the note; the manual styled

One click on Help shows the explanations of every command in one
window, the names in a sans-serif and the explanations in a serif, with
a 2em margin; Copy copies them as plain text.  Two quick clicks show
the note about the documentation site.  The closed window is not
displayed (a layout rule had shown it empty on the start screen).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-15 17:17:45 +02:00
1 parent 24a6a2595b
commit 5d63a0f758
5 files changed
+156 -38

No files matched your search

+3 -3
View File
@@ -133,8 +133,8 @@
</div>
<button id="session_button" type="button"
data-help="Who is here, and the invitation to send to others.">Session</button>
<button id="help" type="button" data-help="Where to read about Kokkoja. With Ctrl (Command on a Mac) held, the explanations of every command in one text.">Help</button>
<div id="help_popup" class="popup help_popup" hidden>Help is coming!</div>
<button id="help" type="button" data-help="One click shows the explanations of every command, as one text in a window. Two quick clicks show where the documentation is.">Help</button>
<div id="help_popup" class="popup help_popup" hidden>Documentation website coming soon!</div>
</div>
</header>
<div id="notice" class="notice" hidden></div>
@@ -184,7 +184,7 @@
data-help="Copy this text.">Copy</button>
<button id="manual_close" type="button" aria-label="Close">×</button>
</header>
<pre id="manual_text" class="manual_text" tabindex="0" autofocus></pre>
<div id="manual_text" class="manual_text" tabindex="0" autofocus></div>
</dialog>
<dialog id="notebook_chooser" class="chooser">
+33 -11
View File
@@ -40,7 +40,7 @@ import { TextEditor } from './text_editor.js';
import { enableTooltips } from './tooltip.js';
import { guardSelection, inField } from './selection_guard.js';
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
import { manualText } from './manual.js';
import { manualList, manualText } from './manual.js';
/**
* The colours in the toolbar: black, red, green, blue, white (Andy's
@@ -1461,29 +1461,50 @@ async function enterSession(invitationText, name, notebook) {
// The host's Fit and Normal scale ("A4" until 2026-10-14) reach everyone (Andy, 2026-10-08); a guest's
// are the guest's own.
// The page and view commands live in the Page menu (Andy, 2026-10-14:
// seldom used, and the one row is precious); Help is a note for now.
// seldom used, and the one row is precious).
attachPopup(element('page_menu_button'), element('page_menu'));
// Ctrl-click (Command on a Mac) on Help: the tooltips of every command,
// gathered as one text (Andy, 2026-10-14). Registered before the
// popup's handler, which it then stops.
// Help (Andy, 2026-10-15): one click shows the manual, the tooltips of
// every command gathered into one window (Ctrl-click until then); two
// clicks within DOUBLE_TAP_MS show the note about the documentation
// site. Since one click opens a modal window, it must wait the
// interval out to be told from the first of two; the shape button's
// double click needs no such wait, its single click having chosen the
// tool already. Two clicks are counted here rather than by the
// browser's dblclick, which a pen or a touch screen does not always
// send.
const manualDialog = /** @type {HTMLDialogElement} */ (element('manual_dialog'));
element('help').addEventListener('click', (event) => {
if (!(event.ctrlKey || event.metaKey)) return;
event.stopImmediatePropagation();
element('manual_text').textContent = manualText(document);
const helpNote = attachPopup(element('help'), element('help_popup'), undefined, false);
const showManual = () => {
helpNote.close();
const body = element('manual_text');
body.replaceChildren(manualList(document));
manualDialog.showModal();
body.focus();
};
/** @type {ReturnType<typeof setTimeout> | null} */
let helpClick = null;
element('help').addEventListener('click', () => {
if (helpClick !== null) {
clearTimeout(helpClick);
helpClick = null;
helpNote.open();
} else {
helpClick = setTimeout(() => {
helpClick = null;
showManual();
}, DOUBLE_TAP_MS);
}
});
element('manual_close').addEventListener('click', () => manualDialog.close());
element('copy_manual').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(element('manual_text').textContent ?? '');
await navigator.clipboard.writeText(manualText(document));
element('copy_manual').textContent = 'Copied';
setTimeout(() => { element('copy_manual').textContent = 'Copy'; }, 1500);
} catch (error) {
log(`The manual could not be copied: ${error}`);
}
});
attachPopup(element('help'), element('help_popup'));
element('zoom_fit').addEventListener('click', () => {
renderer.fit();
session.sendViewCommand('fit');
@@ -1693,6 +1714,7 @@ async function enterSession(invitationText, name, notebook) {
// For looking at the session from the browser's console during development.
Object.assign(window, { kokkoja: {
session, renderer, writer, loadImage, textEditor, logText,
manualText: () => manualText(document),
notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage },
} });
}
+30 -3
View File
@@ -4,9 +4,12 @@
* (`data-help`), gathered from the page and written as plain text under
* the title "User interface actions": the controls in alphabetical
* order, each one's name on a line, its explanation on the next lines,
* wrapped at WIDTH columns, and a blank line after (Andy, 2026-10-14:
* Ctrl-click on Help shows it; a first form as Klammertext was replaced
* by this the same day).
* wrapped at WIDTH columns, and a blank line after (Andy, 2026-10-14; a
* first form as Klammertext was replaced by this the same day); and the
* same entries as elements for the Help window (`manualList`), which
* the stylesheet sets, the names in a sans-serif and the explanations in
* a serif (Andy, 2026-10-15: one click on Help shows it, Ctrl-click
* until then). The Copy button copies the text.
*/
export const TITLE = 'User interface actions';
@@ -137,3 +140,27 @@ export function manualText(doc) {
const blocks = manualEntries(doc).map(([name, help]) => `${name}\n${wrap(help, WIDTH)}\n`);
return `${TITLE}\n\n${blocks.join('\n')}`;
}
/**
* The manual as elements: the title as a heading, then a description
* list, each control's name a term and its explanation the description.
* @param {Document | Element} doc
* @returns {DocumentFragment}
*/
export function manualList(doc) {
const owner = doc instanceof Document ? doc : doc.ownerDocument;
const fragment = owner.createDocumentFragment();
const title = owner.createElement('h3');
title.textContent = TITLE;
fragment.append(title);
const list = owner.createElement('dl');
for (const [name, help] of manualEntries(doc)) {
const term = owner.createElement('dt');
term.textContent = name;
const description = owner.createElement('dd');
description.textContent = help;
list.append(term, description);
}
fragment.append(list);
return fragment;
}
+31 -9
View File
@@ -257,21 +257,43 @@ h2 {
.help_popup { padding: 0.8rem 1rem; }
/* The generated manual (Ctrl-click on Help): the tooltips as Klammertext.
Its Copy button has a button's ordinary shape, not the close mark's
2rem; the text takes the focus when the window opens, so that the
keyboard scrolls it and no button's tooltip opens by itself. */
/* The manual (one click on Help): every control's tooltip, the names in
the interface's sans-serif and the explanations in a serif, a 2em
margin on all sides of the window (Andy, 2026-10-15); as tall as its
contents up to most of the window, the list then scrolling. Its Copy
button has a button's ordinary shape, not the close mark's 2rem; the
list takes the focus when the window opens, so that the keyboard
scrolls it and no button's tooltip opens by itself. (`.chooser`'s own
box comes later in this file, hence the two classes.) The flex layout
is set ONLY WHILE OPEN: on the dialog itself it would override the
browser's `display: none` for a closed dialog, and the empty window
stood at the end of the page (Andy saw it on the start screen,
2026-10-15; in a session it lay below the desk, out of view). */
.chooser.manual[open] { display: flex; }
.chooser.manual {
flex-direction: column;
width: min(46rem, 90vw);
height: fit-content;
max-height: 85vh;
padding: 2em;
}
.manual header h2 { flex: 1; min-width: 0; }
.chooser header button.action { width: auto; padding: 0.35rem 0.8rem; margin-right: 0.5rem; font-size: inherit; line-height: normal; }
.manual_text:focus { outline: 2px solid var(--line); outline-offset: 2px; }
/* The focus ring only when the keyboard put the focus there (Enter on
Help); after a click it would frame the list for no reason. */
.manual_text:focus-visible { outline: 2px solid var(--line); outline-offset: 2px; }
.manual_text {
margin: 0;
max-height: 70vh;
flex: 1;
min-height: 0;
margin-top: 1em;
overflow: auto;
white-space: pre-wrap;
font-size: 0.85rem;
line-height: 1.4;
}
.manual_text h3 { margin: 0 0 1em; font-size: 1.1rem; }
.manual_text dl { margin: 0; }
.manual_text dt { font-family: system-ui, sans-serif; font-weight: 600; }
.manual_text dd { margin: 0.15em 0 1em; font-family: serif; font-size: 1.05rem; }
.manual_text dd:last-child { margin-bottom: 0; }
.palette .swatch { width: 1.5rem; height: 1.5rem; }
/* A colour already in the toolbar leaves its place in the grid empty. */
+59 -12
View File
@@ -2107,12 +2107,19 @@ class EndToEndTest(unittest.TestCase):
def test_page_menu_and_help(self):
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
a popup of Kokkoja's own under the button, closed by a press on an
item, elsewhere, or Escape; Help shows a note for now; one popup
at a time."""
item, elsewhere, or Escape; one popup at a time. Help (Andy,
2026-10-15): one click shows the manual, two quick clicks the note
about the documentation site."""
firefox = self.firefox
firefox.go(self.address("Late"))
# The closed manual window is not displayed, on the start screen
# or in the session (a layout rule on the dialog itself had
# overridden the browser's display: none, 2026-10-15).
displayed = lambda: firefox.run("return getComputedStyle(document.getElementById('manual_dialog')).display;")
self.assertEqual(displayed(), "none")
self.enter_name("Menuist", "start_session")
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
self.assertEqual(displayed(), "none")
hidden = lambda id: firefox.run(f"return document.getElementById('{id}').hidden;")
click = lambda id: firefox.run(f"document.getElementById('{id}').click();")
box = lambda id: firefox.run(f"const b = document.getElementById('{id}').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
@@ -2157,32 +2164,54 @@ class EndToEndTest(unittest.TestCase):
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));")
self.assertTrue(hidden("page_menu"))
# Help: a note for now; opening it closes the menu, and the reverse.
# Help, two quick clicks: the note about the documentation site;
# opening it closes the menu, and the reverse.
click("page_menu_button")
click("help")
double_click = lambda id: firefox.run(f"const b = document.getElementById('{id}'); b.click(); b.click();")
double_click("help")
self.assertFalse(hidden("help_popup"))
self.assertTrue(hidden("page_menu"))
# At the far right, the note's right edge aligns with the button's, inside the window.
help_button, note = box("help"), box("help_popup")
self.assertAlmostEqual(note[2], help_button[2], delta=1)
self.assertLess(note[2], firefox.run("return window.innerWidth;"))
self.assertEqual(firefox.run("return document.getElementById('help_popup').textContent.trim();"), "Help is coming!")
self.assertEqual(firefox.run("return document.getElementById('help_popup').textContent.trim();"), "Documentation website coming soon!")
time.sleep(0.6)
self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"), "two clicks do not open the manual")
click("page_menu_button")
self.assertTrue(hidden("help_popup"))
self.assertFalse(hidden("page_menu"))
firefox.chord("\ue00c")
# Ctrl-click on Help: the tooltips of every command as one text, in
# a window: the title, then each control in alphabetical order,
# its name on a line, its explanation wrapped at 80 columns, and
# a blank line after.
firefox.run("document.getElementById('help').dispatchEvent(new MouseEvent('click', {ctrlKey: true, bubbles: true}));")
self.assertTrue(firefox.run("return document.getElementById('manual_dialog').open;"))
# One click on Help: the manual, the tooltips of every command in a
# window, once the interval for a second click has passed. The
# title, then each control in alphabetical order, its name in the
# interface's sans-serif and its explanation in a serif below; the
# window's contents 2em from its edges.
click("help")
self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"), "not before the interval")
self.wait_until_true(lambda: firefox.run("return document.getElementById('manual_dialog').open;"), bool, "the manual", seconds=3)
self.assertEqual(displayed(), "flex")
self.assertTrue(hidden("help_popup"), "the note did not open as well")
manual = firefox.run("return document.getElementById('manual_text').textContent;")
self.assertEqual(firefox.run("return document.getElementById('manual_text').querySelector('h3').textContent;"), "User interface actions")
shown = firefox.run("""
const list = document.querySelector('#manual_text dl');
return [...list.children].map((e) => [e.tagName, e.textContent, getComputedStyle(e).fontFamily]);
""")
self.assertEqual([tag for tag, _, _ in shown[::2]], ["DT"] * (len(shown) // 2))
self.assertEqual([tag for tag, _, _ in shown[1::2]], ["DD"] * (len(shown) // 2))
self.assertTrue(all("sans-serif" in font for _, _, font in shown[::2]), "the names in a sans-serif")
self.assertTrue(all(font == "serif" for _, _, font in shown[1::2]), "the explanations in a serif")
self.assertEqual(firefox.run("const s = getComputedStyle(document.getElementById('manual_dialog')); return [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft];"), ["30px"] * 4, "2em at 15px")
self.assertEqual(firefox.run("return document.activeElement.id;"), "manual_text")
# The text the Copy button copies: the title, then each control in
# alphabetical order, its name on a line, its explanation wrapped
# at 80 columns, and a blank line after; the same entries as shown.
manual = firefox.run("return window.kokkoja.manualText();")
self.assertTrue(manual.startswith("User interface actions\n\n"))
blocks = manual.split("\n\n")[1:]
names = [block.split("\n")[0] for block in blocks if block]
self.assertEqual(names, [text for _, text, _ in shown[::2]], "the text and the window agree")
self.assertEqual(names, sorted(names, key=str.casefold), "alphabetical")
self.assertIn("Pen", names)
self.assertIn("Color", names) # the swatches as one entry
@@ -2204,8 +2233,26 @@ class EndToEndTest(unittest.TestCase):
unwrapped = re.sub(r"\d+(?:\.\d+)?", "#", manual.replace("\n", " "))
for help in set(helps):
self.assertIn(re.sub(r"\d+(?:\.\d+)?", "#", help), unwrapped, help[:40])
self.assertIn("Two quick clicks show where the documentation is.", unwrapped)
firefox.run("document.getElementById('manual_close').click();")
self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"))
self.assertEqual(displayed(), "none")
# The same with a real mouse: two clicks within the interval show
# the note, and the manual does not open; after a click the list
# has the focus without a focus ring (the ring is for keyboard
# focus).
x, y = firefox.run("const b = document.getElementById('help').getBoundingClientRect(); return [Math.round(b.left + b.width / 2), Math.round(b.top + b.height / 2)];")
press = [{"type": "pointerDown", "button": 0}, {"type": "pointerUp", "button": 0}]
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": x, "y": y},
*press, {"type": "pause", "duration": 120}, *press])
self.assertFalse(hidden("help_popup"))
time.sleep(0.6)
self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"))
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));")
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": x, "y": y}, *press])
self.wait_until_true(lambda: firefox.run("return document.getElementById('manual_dialog').open;"), bool, "the manual", seconds=3)
self.assertEqual(firefox.run("const e = document.getElementById('manual_text'); return [document.activeElement.id, getComputedStyle(e).outlineStyle];"), ["manual_text", "none"])
firefox.run("document.getElementById('manual_close').click();")
def test_shrink_ignores_the_invisible(self):
"""Andy, 2026-10-14: what cannot be seen does not count for Shrink: