Character codes in typed text; the site and relay at app.kokkoja.com

With the Text tool, Klammertext's codes give letters a keyboard lacks:
^o" is ö, ^s^ ß, ^a1 ā, ^130ED^ a code point of 4 to 6 digits,
^9053 53EF^ a sequence, ^^ a caret, replaced as each code is completed.
The public site and relay moved to app.kokkoja.com; the old name
redirects.  The Markdown documentation is withdrawn from the release
while it is converted to Klammertext; the README's links to it are
stale until then.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-13 20:40:09 +02:00
1 parent 34ca33d365
commit 89b4a41b2a
15 files changed
+364 -1273

No files matched your search

+2 -2
View File
@@ -28,12 +28,12 @@ may be used.
A guest needs nothing but a web browser: open the invitation link, enter A guest needs nothing but a web browser: open the invitation link, enter
a name, join. A host can start a session from the same web site, a name, join. A host can start a session from the same web site,
`https://kokkoja.andykopra.com/`, with the notebooks kept in the `https://app.kokkoja.com/`, with the notebooks kept in the
browser. browser.
A host who wants notebooks as files needs Python 3 and this directory: A host who wants notebooks as files needs Python 3 and this directory:
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay python3 kokkoja.py --relay wss://app.kokkoja.com/relay
opens Kokkoja in the browser; *Start a session* gives an invitation to opens Kokkoja in the browser; *Start a session* gives an invitation to
send to the guests. Notebooks are directories in `Kokkoja` in the send to the guests. Notebooks are directories in `Kokkoja` in the
+147
View File
@@ -0,0 +1,147 @@
// @ts-check
/**
* Klammertext's character codes, for typing letters a keyboard lacks:
* `^o"` is ö, `^s^` is ß, `^a1` is ā, `^00E9^` is é, `^130ED^` an
* Egyptian hieroglyph, `^9053 53EF^` two characters from one pair of
* carets, and `^^` is a caret. The text field replaces a code as soon
* as the keystroke that completes it is typed (text_editor.js), like a
* compose key.
*
* The tables and the rules are those of Klammertext's K/mac/character.h,
* character.cpp and katom_process.cpp (the live tree; K/src is
* deprecated), copied 2026-10-13; `kdesc -c` lists the tables. Left
* out, since a text block is not a document: the `^'...'^` literal span
* (a code is replaced only at the caret here, so nothing needs
* protecting), and the one-hex-digit form `^a^`, which makes a control
* character. A single character standing for itself inside a sequence
* (`^9053 a^` is 道a) applies only when at least one member is a code
* point (Andy, 2026-10-13), so that a typed `x^2 + y^2` stays. An
* invalid code point, which is an error in a document, stays as typed.
* The result is normalized to its precomposed form, as Klammertext's
* `compose` does, so that ö is one character in the page file and to
* Backspace.
*/
/** The diacritic each mark letter stands for, as a combining character. */
export const DIACRITICS = {
'`': '̀', // grave accent
"'": '́', // acute accent
'h': '̂', // circumflex (hat)
'~': '̃', // tilde
'-': '̄', // macron
'"': '̈', // dieresis
'b': '̆', // breve
'd': '̇', // dot above
'r': '̊', // ring above
'w': '̌', // wedge (caron)
'c': '̧', // cedilla
'a': '̋', // double acute accent
};
/** Letters outside the Roman alphabet and ligatures, `^code^`. */
export const EXTENDED_LATIN = {
's': 'ß', // Eszett
'i': 'ı', // dotless i
'I': 'İ', // capital dotted i
't': 'þ', // thorn
'T': 'Þ', // thorn capital
'e': 'ð', // eth
'E': 'Ð', // eth capital
'o': 'ø', // o stroke
'O': 'Ø', // o stroke capital
'd': 'đ', // d stroke
'D': 'Đ', // d stroke capital
'ae': 'æ', // ae ligature
'AE': 'Æ', // ae ligature capital
'oe': 'œ', // oe ligature
'OE': 'Œ', // oe ligature capital
};
/** Mandarin pinyin tones on a vowel, `^a1` to `^a4`. */
export const PINYIN_TONES = {
'1': '̄', // high
'2': '́', // rising
'3': '̌', // falling-rising
'4': '̀', // falling
};
/** A code point in hex: 4 to 6 digits (2 and 3 would collide with the mnemonics). */
const HEX = '[0-9A-Fa-f]{4,6}';
/**
* Whether the number is a code point a character can be made of
* (Klammertext's `valid_codepoint`): in range, not a surrogate, not a
* noncharacter.
* @param {number} cp
* @returns {boolean}
*/
export function validCodePoint(cp) {
if (cp < 0 || cp > 0x10FFFF) return false;
if (0xD800 <= cp && cp <= 0xDFFF) return false;
if (0xFDD0 <= cp && cp <= 0xFDEF) return false;
if ((cp & 0xFFFE) === 0xFFFE) return false;
return true;
}
/**
* What a sequence's members stand for: a hex member its code point, any
* other, a single character, itself; or null when no member is a code
* point (the run is not a sequence) or one is not a valid code point.
* @param {string[]} members
* @returns {string | null}
*/
function charactersOf(members) {
const hexRe = new RegExp(`^${HEX}$`, 'u');
if (!members.some((m) => hexRe.test(m))) return null;
let result = '';
for (const member of members) {
if (hexRe.test(member)) {
const cp = parseInt(member, 16);
if (!validCodePoint(cp)) return null;
result += String.fromCodePoint(cp);
} else {
result += member;
}
}
return result;
}
/**
* The code that the text ends with, if it ends with a complete one.
* Checked in Klammertext's order: an extended Latin letter, a code point
* or a sequence, the caret itself, a diacritic, a pinyin tone.
* @param {string} text
* @returns {{length: number, character: string} | null}
* How many characters of the text the code takes, and what it means.
*/
export function codeAtEnd(text) {
const extended = /\^(ae|AE|oe|OE|[siItTeEoOdD])\^$/u.exec(text);
if (extended) {
const name = /** @type {keyof typeof EXTENDED_LATIN} */ (extended[1]);
return { length: name.length + 2, character: EXTENDED_LATIN[name] };
}
// One pair of carets around members separated by spaces, on one line,
// no space inside the carets: code points of 4 to 6 hex digits and,
// once one of those is present, single characters standing for
// themselves (a caret cannot be one: it would close the sequence).
const member = `(?:${HEX}|[^\\s^])`;
const sequence = new RegExp(`\\^(${member}(?: +${member})*)\\^$`, 'u').exec(text);
if (sequence) {
const character = charactersOf(sequence[1].split(/ +/));
if (character !== null) return { length: sequence[0].length, character };
}
if (text.endsWith('^^')) return { length: 2, character: '^' };
// The base is a letter of any alphabet: not a space, a digit or ASCII
// punctuation (Klammertext's rule).
const diacritic = /\^([^\s0-9!-/:-@[-`{-~])([-'`h~"cbrdwa])$/u.exec(text);
if (diacritic) {
const mark = /** @type {keyof typeof DIACRITICS} */ (diacritic[2]);
return { length: diacritic[0].length, character: (diacritic[1] + DIACRITICS[mark]).normalize('NFC') };
}
const tone = /\^([aeiou])([1-4])$/u.exec(text);
if (tone) {
const number = /** @type {keyof typeof PINYIN_TONES} */ (tone[2]);
return { length: 3, character: (tone[1] + PINYIN_TONES[number]).normalize('NFC') };
}
return null;
}
+32 -1
View File
@@ -9,6 +9,7 @@
* shows it; everyone else sees it drawn, updated at every keystroke. * shows it; everyone else sees it drawn, updated at every keystroke.
*/ */
import { codeAtEnd } from './character_codes.js';
import { randomId } from './crypto.js'; import { randomId } from './crypto.js';
import { textBounds } from './page_model.js'; import { textBounds } from './page_model.js';
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js'; import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
@@ -51,7 +52,13 @@ export class TextEditor {
this.field.className = 'text_field'; this.field.className = 'text_field';
this.field.spellcheck = false; this.field.spellcheck = false;
this.field.hidden = true; this.field.hidden = true;
this.field.addEventListener('input', () => this.changed()); /** True while a character code is being replaced in the field. */
this.replacing = false;
this.field.addEventListener('input', () => {
if (this.replacing) return;
this.replaceCode();
this.changed();
});
this.watchForHold(); this.watchForHold();
this.field.addEventListener('keydown', (event) => { this.field.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -231,6 +238,30 @@ export class TextEditor {
return block; return block;
} }
/**
* The text before the caret ends with one of Klammertext's character
* codes (character_codes.js): replace it by the character it stands
* for. Done through the browser's own editing command so that its
* undo history survives (assigning the field's value would discard
* it); the command fires an input event of its own, ignored here.
*/
replaceCode() {
const field = this.field;
const caret = field.selectionStart;
if (caret !== field.selectionEnd) return;
const code = codeAtEnd(field.value.slice(0, caret));
if (!code) return;
field.setSelectionRange(caret - code.length, caret);
this.replacing = true;
try {
if (!document.execCommand('insertText', false, code.character)) {
field.setRangeText(code.character, caret - code.length, caret, 'end');
}
} finally {
this.replacing = false;
}
}
/** The text, position or width changed: lay it out and tell the page. */ /** The text, position or width changed: lay it out and tell the page. */
changed() { changed() {
const block = this.block; const block = this.block;
+13 -10
View File
@@ -4,7 +4,8 @@ A Kokkoja *server* is one machine that does two things for a group of
users: it runs the relay, which passes the participants' encrypted users: it runs the relay, which passes the participants' encrypted
messages, and it serves the client as a web site, so that a guest can messages, and it serves the client as a web site, so that a guest can
join by opening a link with nothing installed. Neither stores anything. join by opening a link with nothing installed. Neither stores anything.
The first one is Andy's VPS at `kokkoja.andykopra.com`. The first one is Andy's VPS, reached at `app.kokkoja.com` (the older
name `kokkoja.andykopra.com` still works, redirected there).
| On the server | What it is | | On the server | What it is |
|----------------------------|-------------------------------------------------| |----------------------------|-------------------------------------------------|
@@ -15,10 +16,10 @@ The first one is Andy's VPS at `kokkoja.andykopra.com`.
| nginx site `kokkoja` | `https://<site>/` serves the client; `wss://<site>/relay` reaches the relay | | nginx site `kokkoja` | `https://<site>/` serves the client; `wss://<site>/relay` reaches the relay |
The client's `config.json` on the site says The client's `config.json` on the site says
`{"relay": "wss://kokkoja.andykopra.com/relay", "storage": "browser", `{"relay": "wss://app.kokkoja.com/relay", "storage": "browser",
"site": "https://kokkoja.andykopra.com/"}`: the notebooks of anyone using "site": "https://app.kokkoja.com/"}`: the notebooks of anyone using
the site are kept in their browser. A host with the local program uses the site are kept in their browser. A host with the local program uses
the server with `python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay`; the server with `python3 kokkoja.py --relay wss://app.kokkoja.com/relay`;
the invitation is then a link to the site. the invitation is then a link to the site.
## Setting up or updating ## Setting up or updating
@@ -38,14 +39,16 @@ denied (publickey)".
Steps a person does, once: Steps a person does, once:
1. DNS: an A record `kokkoja.andykopra.com` pointing at the VPS 1. DNS: an A record `app.kokkoja.com` pointing at the VPS
(82.221.141.89), in cPanel's Zone Editor, since andykopra.com is on (82.221.141.89), wherever kokkoja.com's DNS is managed (its registrar,
the orangewebsite.com nameservers. Check with or the orangewebsite.com nameservers if delegated there). The older
`host kokkoja.andykopra.com buffy.ns.orangewebsite.com`. name `kokkoja.andykopra.com` keeps its A record in cPanel's Zone
Editor and is redirected to app.kokkoja.com by nginx. Check with
`host app.kokkoja.com`.
2. TLS, after the name resolves and `deploy.sh` has run once: 2. TLS, after the name resolves and `deploy.sh` has run once:
ssh root@vps313847.iceservers.net certbot --nginx -d kokkoja.andykopra.com ssh root@vps313847.iceservers.net certbot --nginx -d app.kokkoja.com -d kokkoja.andykopra.com
certbot adds the TLS listener to the nginx site and turns the plain certbot adds the TLS listener to the nginx site and turns the plain
HTTP block into a redirect. Afterwards `setup_vps.sh` leaves the HTTP block into a redirect. Afterwards `setup_vps.sh` leaves the
@@ -57,7 +60,7 @@ Steps a person does, once:
vps-sentinel --accept vps-sentinel --accept
Checks: `https://kokkoja.andykopra.com/config.json` shows the Checks: `https://app.kokkoja.com/config.json` shows the
configuration above; `systemctl status kokkoja-relay` is active; configuration above; `systemctl status kokkoja-relay` is active;
`journalctl -u kokkoja-relay -n 20` shows it listening. `journalctl -u kokkoja-relay -n 20` shows it listening.
+2 -2
View File
@@ -12,8 +12,8 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net} server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net}
site=https://kokkoja.andykopra.com/ site=https://app.kokkoja.com/
relay=wss://kokkoja.andykopra.com/relay relay=wss://app.kokkoja.com/relay
staging=$(mktemp -d) staging=$(mktemp -d)
trap 'rm -rf "$staging"' EXIT trap 'rm -rf "$staging"' EXIT
+21 -5
View File
@@ -1,13 +1,14 @@
# nginx server block for a Kokkoja web site: the client's files at # nginx server block for a Kokkoja web site: the client's files at
# https://kokkoja.andykopra.com/ and the relay at wss://kokkoja.andykopra.com/relay. # https://app.kokkoja.com/ and the relay at wss://app.kokkoja.com/relay.
# Installed by deploy/setup_vps.sh as /etc/nginx/sites-available/kokkoja. # Installed by deploy/setup_vps.sh as /etc/nginx/sites-available/kokkoja.
# #
# This is the plain HTTP form. certbot --nginx -d kokkoja.andykopra.com # This is the plain HTTP form. certbot --nginx -d app.kokkoja.com -d
# adds the TLS listener and turns this block into a redirect. # kokkoja.andykopra.com adds the TLS listeners and turns the HTTP blocks
# into redirects.
server { server {
listen 80; listen 80;
server_name kokkoja.andykopra.com; server_name app.kokkoja.com;
root /var/www/kokkoja; root /var/www/kokkoja;
index index.html; index index.html;
@@ -33,7 +34,22 @@ server {
# browsers ask whether they have changed rather than keeping old ones. # browsers ask whether they have changed rather than keeping old ones.
add_header Cache-Control "no-cache" always; add_header Cache-Control "no-cache" always;
# The client loads nothing from anywhere else, and may not be framed. # The client loads nothing from anywhere else, and may not be framed.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://kokkoja.andykopra.com; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" always; add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://app.kokkoja.com; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always; add_header Referrer-Policy "no-referrer" always;
} }
# The old name, kept so that bookmarks and links to the SITE still reach
# it: everything is redirected to app.kokkoja.com. The browser carries a
# link's #join fragment across the redirect, so the join page opens; but
# an invitation made before the move names the old relay inside that
# fragment, and a WebSocket does not follow the 301 (and the new CSP
# forbids the old host), so a pre-move invitation opens but cannot
# connect. Invitations made from app.kokkoja.com name the new relay and
# are fine; live invitations are per-session, so none is affected in
# practice.
server {
listen 80;
server_name kokkoja.andykopra.com;
return 301 https://app.kokkoja.com$request_uri;
}
+1 -1
View File
@@ -12,7 +12,7 @@
# #
# TLS is the one step left to a person, once the name resolves to this # TLS is the one step left to a person, once the name resolves to this
# machine (see deploy/README.md): # machine (see deploy/README.md):
# certbot --nginx -d kokkoja.andykopra.com # certbot --nginx -d app.kokkoja.com -d kokkoja.andykopra.com
set -euo pipefail set -euo pipefail
incoming=/opt/kokkoja/incoming incoming=/opt/kokkoja/incoming
-336
View File
@@ -1,336 +0,0 @@
# A guest's Kokkoja
This part describes Kokkoja as a guest sees it: someone invited to a
session by its host. In a lesson, the guest is the student. The host's
screen is the same with a few more buttons, described in
[A host's Kokkoja](host.md).
## The invitation
The host sends you an invitation by whatever means you both use: email, a
chat message, a text message. It is a link of this shape:
https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23uQ5c__CGB4FvN64Qiv3jGA.aS_1O51Umk1YHXwgqBZANw
The part after `join=` is the invitation itself, which the host's Kokkoja
may also give you on its own, without the link around it. Both forms
work wherever an invitation is asked for. A host in the same room may
instead show you the link as a QR code on their screen: point your
phone's or tablet's camera at it and open the link it offers. The invitation carries the key
that protects the session, so pass it on only to people who belong in the
session, and treat it as you would a meeting link. An invitation is good
for one session: the host's next session has a new one.
## Joining
Open the link in a web browser. Firefox, Brave and Chrome on a
computer, and Safari on an iPad or an iPhone, are known to work; other
browsers built on the same engines very probably do. You see
Kokkoja's start screen, headed "Join a session", with the invitation
already filled in. Enter the name the others should see and press
**Join**. That is all: nothing is installed, and there is no account to
make. (Opened without an invitation, the same screen also offers to
start a session of your own; an invitation is a request to join one
session, so with one it offers joining alone.)
Your name is remembered by the browser for next time. So is a second
choice, **Notebook for the pages of this session**, which you can leave
as it is for now; what it does is explained under "Your notebook" below.
If you have the Kokkoja program installed on your computer (see
[A host's Kokkoja](host.md) for what that is and why you might want it),
the start screen also offers to open the invitation in it instead. If
you have not, ignore that line.
If the host has not started yet, or has left, you are still in the
session. The page is shared among whoever is present, and the host's
place in it waits for the host to return.
## The screen
Across the top is the toolbar, one row holding everything in three
groups: on the left the tools, their settings and **Undo**; in the
middle the page and the view; on the right the host's page buttons,
**Notebooks**, your notebook, and **Session**, which opens a window with
the participants and the invitation. Below it the page has the whole width of the window. Every button
explains itself: rest the pointer on it for a second and its
explanation appears beneath it.
### The page
The page is A4 in size, lying on its side, shown so that it fits the
window. Write
anywhere on it. When your writing comes near an edge, the page grows in
that direction, in small steps, so there is always room, and the view
keeps its scale and extends beyond the window; scroll to reach the rest.
An image the host puts on the page lies at the top, or below the images
already there.
The page as a whole is shared: everyone sees the same marks in the same
places. Your view of it is your own: how far it is zoomed and where it
is scrolled to concern nobody else.
### Pen
**Pen** is chosen when you arrive. Draw with a pen on a tablet, a mouse,
or a finger on a touch screen. With a pen, pressing harder makes the
line wider. The colours are to the right of the tools: black, red,
green, blue and white, and **+** opens a palette of more, in which the
colours already in the row leave their places empty. A colour chosen
from the palette joins the row and stays there in this browser. White
is for painting over. The four lines to the right of the colours are
the line widths: 0.4, 0.7, 1.2 and 2.4 mm at ordinary pressure.
If a pen is in use, a hand resting on a touch screen is ignored. A
click on the page that only brings the browser window to the front, when
you come back to it from another window, makes no mark.
### Text
**Text** types on the page. Tap where the text should begin and type; a
text field appears there and the text is laid out on the page as you
type. Enter begins a new line. Press Escape, or tap somewhere else, to
finish; a block with nothing in it disappears. You need not finish to
move a block: rest the pen on it while typing, and it follows the pen,
field and all; lift, and go on typing.
A block made by a tap does not wrap: its lines are as long as you type
them, and the page grows to hold them; a red corner mark of 5 mm at its
top left shows where it begins. To make a *text rectangle*
instead, drag it open where you want it: a red box, white inside, in
which the text wraps at the box's width, and which grows downward by
lines when you type more than it holds. The box is part of the page,
seen by everyone, and where it lies over an image it covers the image
and nothing else; a teacher uses one to say "write your answer here".
Tap your own text to change it, anywhere in a box, and the cursor is at
the end of the text. To move a block of yours, rest the pen
on it, without moving, for a little over half a second: a green outline
appears, and it follows the pen. To resize a text rectangle, rest the
pen near one of its corners and drag the corner; the text wraps anew at
the new width. You cannot change anyone else's text.
With the Text tool, the row of colours stays and the line widths give way
to the font, one of **Sans**, **Serif** and **Mono**, and the size in
points, 14 unless you change it. Both apply to the block you are typing
and to the next ones.
### Eraser
**Eraser** removes your own writing. Move it across a stroke and the
whole stroke goes; across a text block and the block goes. It removes
nothing of anyone else's, and nobody else's eraser can touch yours. An
erased stroke is gone from the page for everyone, until you undo the
erasure.
### Undo
**Undo**, the last button of the drawing tools, takes back your latest mark: a
stroke, what one movement of the eraser removed, a text block as you
last typed or moved it, a Hide rectangle made, moved or removed. Press
it again for the mark before that, and so on back to the beginning of
the page. Ctrl+Z (Command+Z on a Mac) does the same, and so do two
quick taps of the Eraser on empty paper, away from anything the eraser
would remove; a tap that erases something is never half of such a
double tap. Undo acts only on your own marks, since nobody can change
anyone else's, and only on marks made on this page since you opened it:
after a reload of the tab, or on a page loaded from a notebook, there is
nothing to undo although the marks are there. The button is grey when
there is nothing to undo. An image cannot be undone; put it back with
**Image** if a tap of the eraser removed it by mistake.
### The view
A page is shown whole when you first see it. Afterwards the view keeps
its scale: when the page grows, or the host adds an image, the page
extends beyond the window and you scroll to the rest. Three buttons and
two gestures change the view, and none of them changes the page:
* **Fit** shows the whole page in the window.
* **A4** shows the page at the scale a new page has, a full A4 page
filling the window, so that a page shrunk to a small exercise is seen
at its own size rather than enlarged to fill the window.
* When the host presses **Fit** or **A4**, every participant's view
follows, each in their own window. Your own Fit and A4 change only
your screen.
* **Expand**, which only the host and co-hosts have, makes the page
itself fill the window, for everyone, at the present zoom, in both
directions. The page grows equally on every side, so nothing on it
moves and nothing is rescaled; there is simply more room around it.
* **Shrink**, also for the host and co-hosts, makes the page, for everyone, just large enough for what is on it,
with the usual margin around. Nothing on it moves either.
* **Extend** is a box, ticked to begin with. Ticked, the page grows
when your writing comes near an edge, so there is always room.
Unticked, your writing stops short of the edge; a stroke that leaves
the page resumes when the pen comes back. Images always get the room
they need.
* Hold **Ctrl** (Command on a Mac) and turn the scroll wheel to zoom in
and out about the pointer.
* Hold **Ctrl** (Command on a Mac) and draw a stroke with the pen: up
zooms in, down zooms out, by twice for every 100 pixels of movement,
about the point where the pen touched down. Nothing is drawn while
the key is held.
If you reload the browser tab, it comes back to the page as you last saw
it: same zoom, same place.
### Session
**Session** in the toolbar opens a window with the people and the
invitation. **Participants** lists everyone in the session, with *you*
and *host* marked, and *away* beside anyone who has left or lost their
connection; an absent participant's writing stays on the page. The box
in front of each name shows or hides that participant's writing on your
screen alone. Hiding your teacher's corrections to try an exercise
again, or hiding your own answers to see the exercise as it was,
changes nothing for anyone else.
**Invitation** shows the invitation of this session, with a button to
copy it and, below, as a QR code for a camera, in case someone else
should be invited. **Log** is a record of what happened in this
session, pages and connection and every press and release of the pen,
with a button to copy it: if something goes wrong, copy it and send it
to whoever looks after Kokkoja. Above it the window says which page
you are on, counted from the start of the session, how you stand with
the relay, and when the page was last saved; below it, where your
notebooks are kept.
### Notices
While all is well Kokkoja says nothing. When there is something to
know, a line appears between the toolbar and the page: in red while
a problem lasts, in grey for a few seconds after something was done.
*Not connected. Reconnecting…* means the relay cannot be reached;
Kokkoja reconnects by itself, and what you wrote meanwhile is sent when
it returns. *The page could not be saved* means the notebook is out of
reach; the page is still in memory and the saving is tried again.
*Waiting for the host's page* means the host has not started a page
yet, or your connection is down. The grey notices say that a page was
kept or exported, and where.
For finding faults, the browser's console (F12) keeps a short log of the
session's events: pages, images, the connection, and what your pen or
mouse delivers.
## Your notebook
Every page of the session is saved for you, as it changes, without your
doing anything. Where it is saved depends on how you joined.
**Notebooks** in the toolbar opens a chooser that shows your notebooks
and small pictures of the pages kept in them, wherever they are kept.
Rest the pointer on a page for a second and its facts appear: the first
text on it, when it was last changed, who wrote on it, what is on it
and the size of the page; on a notebook, how many pages it keeps and
from when, when it was last written in, and where it is.
There you can delete pages and notebooks you no longer want: mark them
with the × at their left, then press **Delete** twice. In the browser
a deleted page is gone for good; with the Kokkoja program it goes to the
Trash. Starting a page for everyone from a kept page is the host's
business, and a co-host's.
### In the browser
When you joined by opening the link, your notebook is kept inside the
browser, on the computer you are using. It stays there between sessions,
but it belongs to that browser: another browser, or another computer,
has its own. The page you are on is saved there continuously. When the
session moves to another page, the page you are leaving is kept under a
dated name, such as `2026_10_06_01.svg`, the date being the day it was
last changed and the number counting the pages kept that day.
Two buttons bring pages out of the browser:
* **Keep** keeps a dated copy of the page as it is now, and
downloads it, so you find it among your browser's downloads. Pressing
it again before anything has changed makes no second copy and no
second download. Any mark counts as a change, even a dot from a
stray touch of the pen.
* **Export** downloads a picture of the page as you see it, as a PNG
file; writing you have hidden is left out. Its name is the date and
the page number.
A page file is an SVG file. Any web browser displays it, and vector
drawing programs such as Inkscape open it. Kokkoja itself can load one
again, writing and all, when the host or a co-host chooses it (see "When the host gives you more" below, and the host's
part).
The notebook list on the start screen names the notebook within the
browser's storage. Giving each course or each colleague a notebook of
their own keeps their pages apart; until you make one, everything goes
into the notebook *General*. The last entry of the list, **New
notebook…**, asks for a name and makes a notebook of that name; it
exists from the moment a page is written into it. **Current notebook**
in the toolbar is the same list, and lets you change notebook, or make
one, during the session.
### On your computer
If you would rather have your pages as files in a directory on your own
computer, where you can open, copy and back them up as you do any file,
install the Kokkoja program. It is small and needs only Python, which
Linux and macOS have or can quickly get; [A host's Kokkoja](host.md)
tells how. Then join from the program's own
start screen, pasting the invitation, or follow the start screen's offer
to open the invitation in the installed program.
With the program, a notebook is a directory in `Kokkoja` in your
Documents directory. The page you are on is `Current.svg` there, and
kept pages lie beside it under their dated names. **Keep** makes
the dated copy; **Export** opens Kokkoja's own file chooser, in which you
pick a directory and a name for the PNG file.
The program also keeps your identity, so that you are the same
participant in every session and from any browser on that computer, and
writing from an earlier lesson is still yours when its page is loaded
again. In the browser, your identity belongs to that browser.
## Leaving and returning
Close the tab and you have left. Nothing needs saving. If you come back
to the same session by opening the invitation again, your writing is
still on the page, from the others' copies and from your notebook, and
it is still yours to erase or change. A tab that was in a session and is
reloaded goes straight back in.
## When the host makes you a co-host
Normally only the host puts images on the page, starts new pages, or
loads pages from a notebook. The host can make you a *co-host*, which
gives you the same powers, for instance in a discussion among
colleagues where everyone brings material. The Session window then
says *co-host* beside your name, and three more buttons appear at the
right of your toolbar:
* **New** starts a new blank page for everyone. The page being left is
kept in each person's notebook.
* **Image** puts an image file on the page for everyone, below any
images already there. Your browser's file chooser asks for the file
(with the Kokkoja program, Kokkoja's own chooser does, beginning where
the notebooks are). A PNG or JPEG file keeps its exact pixels unless
it is very large; other formats and very large images are converted
and reduced before they are shared. An image is placed at its natural
size, centred, 5 mm below the image above or at the top of the page;
one wider than the page widens it. To put an image on a page of its
own, press **New** first.
* **Notebooks**, which everyone has, now also starts a new page for
everyone from a page kept in one of your notebooks, with everything
that was written on it: choose the page in the chooser. The kept page
itself is not changed: the session works on a copy.
* **Hide**, among the tools, hides part of the page for everyone: drag
open a rectangle and whatever lies under it, writing included, is
covered in white, marked by blue corners so that nobody writes there
unknowingly. Rest the pen on it to move it, near a corner to resize
it; a tap of the eraser removes it and uncovers what was there. A Hide
stays even for someone who hides your writing on their own screen.
As a co-host you can also move and scale images, as the host can: rest the pen on an image for a little over half a second
and it follows the pen; rest it near a corner and the corner follows,
the shape kept. Writing stays where it is, so images are arranged
first and written on afterwards. A new image always arrives below the
others, centred, at its own size, to be arranged by hand. A tap of the
eraser on an image removes it; writing on it stays. An image you added
yourself you may always remove, even after you have stopped being a
co-host.
You stay a co-host until the host decides otherwise or the session ends.
-341
View File
@@ -1,341 +0,0 @@
# A host's Kokkoja
The host starts a session, invites the guests and drives the session:
puts images on the page, starts new pages, loads pages from a notebook,
and decides which guests may do the same. In a lesson, the teacher is
the host. Everything in [A guest's Kokkoja](guest.md) applies to the host
too; this part describes what is added.
## Hosting from the web site, or with the Kokkoja program
A host, like a guest, can use Kokkoja with nothing installed: open
`https://kokkoja.andykopra.com/` in a browser, enter a name, press
**Start a session**, and the invitation is ready in the Session window.
Your notebooks are then kept inside that browser, as a guest's are (see
"Your notebook" in the guest's part): they belong to that browser on
that computer, and a page leaves it by **Keep** or **Export**, which
download it. "Image" asks for the file with the browser's own chooser.
If you would rather have your notebooks as ordinary directories of
files, where you can open, copy, back up and send them, run the Kokkoja
program on your own computer. It is a small Python program with two
jobs: it shows Kokkoja in your web browser, and it reads and writes
your notebooks as directories and files. It listens only to your own
computer and talks to nothing else but the relay you name. Your
identity, too, then belongs to your computer rather than to one
browser, so your writing from an earlier lesson is yours from any
browser there. A teacher who keeps notebooks of exercises and a
notebook per student will want the program; for a first session the
web site is enough.
## Installing the Kokkoja program
You need:
* **Linux or macOS.** Windows has not been tried yet.
* **Python 3.** Linux has it. On macOS, open Terminal and type
`python3 --version`; if Python is not installed, macOS offers to
install the command line developer tools, which include it. Any Python
from 3.9 on will do.
* **A web browser.** Firefox, Brave or Chrome, or Safari on a Mac;
other browsers built on the same engines very probably work.
* **A pen tablet**, if you want to write by hand. A mouse works for
pointing and for rough marks.
Get Kokkoja from its repository: download
https://git.andykopra.com/ack/kokkoja-rel/archive/main.zip
and unpack it, or, if you have git,
git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja
Nothing else is installed and nothing is changed on your system. To
update later, download again or run `git pull` in that directory.
Kokkoja is free software under the GNU General Public License; the
`LICENSE.md` file in the directory is the license. To
remove Kokkoja, delete the directory; your notebooks are elsewhere and
stay.
### On a Mac
The same steps, with four things to know:
* Terminal is in Applications → Utilities. The first `python3` or
`git` command may make macOS offer to install the command line
developer tools; accept, and run the command again when it has
finished.
* The first time the program writes a notebook, macOS asks whether
Terminal may reach your Documents folder. Allow it, once; the
notebooks are there. The answer can be changed later under System
Settings → Privacy & Security → Files and Folders.
* The program opens Kokkoja in your default browser, Safari included.
To use another, start the program with `--no-browser` and open the
address it prints in the browser you prefer.
* Wherever this documentation says Ctrl for zooming, use Command on a
Mac. Both work, so a hand used to Ctrl is not punished.
## Starting Kokkoja
Open a terminal in the Kokkoja directory and run
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
The program prints an address beginning `http://localhost:8760/` and
opens your browser there. Leave the terminal open: the program must run
as long as you are in a session. Ctrl-C stops it.
The address the program prints contains a secret token. Your browser
remembers it, so afterwards `http://localhost:8760/` alone brings Kokkoja
back; but if the browser ever says that the notebooks are locked, open
the full address the program printed. The token is what stops any other
web page you might have open from reading your notebooks.
The relay address names the *Kokkoja server* you use. A server does two
things: it passes the participants' messages, encrypted so that it
cannot read them, and it serves the web site that your guests join from.
The address above is Andy's server; whoever runs a server for you gives
you its address. The program remembers it, so from the second time on
python3 kokkoja.py
is enough, and the program says which relay it is using. Give the
option again only to change servers. Started for the first time with no
relay, it stops and asks for one.
Other options, all optional:
* `--notebooks DIRECTORY` keeps the notebooks somewhere other than
`Kokkoja` in your Documents directory.
* `--port NUMBER` uses another port than 8760, if that one is taken.
* `--no-browser` does not open a browser; open the printed address
yourself.
* `--site ADDRESS` names the web site that invitations link to. You
need it only if your server's web site is not at the relay's host.
* `--trash DIRECTORY` is where deleted notebooks and pages are moved,
instead of your system's Trash; `--trash none` deletes them outright.
## Starting a session
On the start screen, enter your name and the notebook for this session,
and press **Start a session**. You are on page 1, blank, and
**Session** in the toolbar shows the invitation.
### The notebook
A notebook is a directory under `Kokkoja` in your Documents directory.
The start screen lists the notebooks you have; until you make one, there
is *General*, the notebook for whatever has no notebook of its own. The
last entry of the list, **New notebook…**, asks for a name and makes a
notebook of that name, which exists from the moment a page is written
into it. For teaching, a notebook per student works well: everything a
student is given, with everything they and you wrote on it, gathers in
that student's notebook, and the next lesson with that student starts
by choosing it again. Your library of exercises can live in notebooks
of its own, such as *Exercises A1*, filled by keeping pages in a session
of your own with nobody else present.
The notebook can be changed during a session, in the notebook list in
the toolbar. The page you are on then continues in the notebook chosen.
### The invitation
**Session** in the toolbar opens a window; **Invitation** there shows a
link. **Copy** copies it; send it to
your guests by email, in a chat message, or however you reach them. A
guest opens it, enters a name and is in. Below the link the same
invitation stands as a QR code: a guest with a phone or a tablet points
its camera at your screen, and the link opens there. A guest with the Kokkoja
program installed may paste it into their own program instead. The link
carries the secret that protects the session, so send it only to those
who should be there; anyone with the link can join and read the page.
Each session you start has a new invitation.
An invitation is good as long as you keep the session open. A guest can
join at any time, see everything written so far, and leave and return.
## Driving the session
Three buttons at the right of the toolbar are the host's.
**New** starts a new blank page for everyone. Pages are numbered from 1
through the session. The page being left is kept in every participant's
notebook, by itself, under a dated name; nobody has to remember to save.
**Image** puts an image file on the page for everyone. Kokkoja's own
file chooser opens, beginning where your notebooks are and remembering
where you last chose from; a directory path can also be typed into it.
It shows small pictures of the image files it finds. Choose one and it
is placed on the page in one way always: 5 mm below the lowest image
there, or at the top of the page if there is none, centred across the
page's width, at its natural size, in front of the images already
there. Natural size is the size the file records: a scan arrives at
the size of the paper it came from. A photo from a phone or camera
records no useful size and arrives at a tenth of a millimetre per
pixel. An image wider than the page widens it on both sides, and one
taller than the page lengthens it downward, so the whole image has room.
From there you move and scale it by hand, as described next;
there is no other setting to choose. PNG and JPEG files are shared as
they are unless very large; other formats, and images over 2400 pixels
on a side or 1.5 MB, are converted to JPEG and reduced first, which
does not change the size they arrive at. A page holds up to 12 images.
To move an image, rest the pen on it, without moving, for a little over
half a second: a green outline appears, and the image follows the pen
until you lift it. It comes to the front of the other images, which a
hold without moving also does. To scale an image, rest the pen near one
of its corners: a green square marks the corner, and dragging it scales
the image along its diagonal, the opposite corner staying put and the
shape kept. This works with every tool: a dot the pen may have drawn
while resting is removed, and the eraser erases nothing while it rests.
Writing is not attached to images: an image moved from under writing
leaves the writing where it was, so compose the images first and write
afterwards. Only you and co-hosts can move images.
For a page that begins with an image, press **New** and then **Image**.
For a series of exercises, keep pressing **Image** on the same page: the
page grows downward and everyone scrolls through them. A new image does
not change anyone's zoom; it appears below, to be scrolled to.
A tap of the eraser on an image removes it, whoever added it and
whenever. Writing on it stays where it was, so remove an image before
anyone writes on it unless the writing is meant to stay. The page keeps
the size the image gave it; **Shrink** trims it. A guest who added an
image may remove it, even after they have stopped being a co-host.
**Fit** and **A4**, pressed by you, act on every participant's screen,
each in their own window: the way to bring everyone back to the whole
page. A guest's Fit and A4 change only that guest's screen.
**Hide**, the tool beside the Eraser, covers part of the page for
everyone: drag open a rectangle, and whatever lies under it, writing of
anyone included, is covered in white, marked by blue corners that tell
the students something is there. Use it to cover a solution until the
student has tried, or a part of the exercise for later. Rest the pen on
it to move it, near a corner to resize it; a tap of the eraser removes
it and shows what was covered, and **Undo** takes back any of these as
it does your writing. Nothing under a Hide is lost, and a
student cannot uncover it by hiding your writing on their screen: a Hide
belongs to the page, not to your writing. A Hide is white in an
exported picture and in the page file as other programs see it.
With the Text tool, a rectangle dragged open is a *text rectangle*: a
red box with a white ground, which covers the image beneath it and no
writing, in which text wraps and which grows downward as it fills. It
is the "write your answer here" box of a worksheet: draw it on the scan
where the answer belongs, and the student types into their own box or
beside yours. Only its owner can type in a box, as with any text.
**Expand**, among the view buttons, makes the page fill your window for
everyone, at your present zoom, in both directions: the quickest way to
more room for writing around an exercise. The page grows equally on
every side, so nothing on it moves. The page keeps the new size
in everyone's notebook. **Shrink** is its opposite: the page becomes
just large enough for everything on it, images and writing, with the
margin of 5 mm around, which trims a page down to its exercise before you keep
or export it. A blank page cannot shrink.
**Notebooks** starts a new page for everyone from a page kept in one of
your notebooks, with everything that was written on it. A chooser shows
your notebooks on the left and small pictures of the chosen notebook's
kept pages on the right. The loaded page is a copy: the kept page is
not changed by anything that happens next. Each layer on it still
belongs to the participant who wrote it, so a student's answers from an
earlier lesson are still theirs, and your corrections still yours.
This is also how you return to an earlier page of the session: the page
was kept when you left it, and **Notebooks** brings it back as a new page.
A lesson might go: **New**, **Image** for the exercise, work, **New**
for a point of grammar, work, **Notebooks** to return to the exercise.
The same chooser deletes. Every page card and every notebook in the
list carries a small × at its left; a tap marks it with a tick, a second tap unmarks it,
and a marked notebook goes with every page in it. As soon as something
is marked, a **Delete** button in the chooser's header says how much:
"Delete 3 pages?", "Delete 1 notebook and 3 pages?". The first press
says what will happen, "Press again to move 3 pages to the Trash";
the second does it. With the Kokkoja program, deleted notebooks and
pages are moved to your system's Trash, where the
file manager can put them back; the program's `--trash` option
names another directory, or `none` to delete outright. In the browser
there is no Trash, and the button says "for good". The notebook
you are writing in cannot be deleted while you are: choose another
notebook in the toolbar first.
## Co-hosts
In the **Participants** list of the Session window, each guest's name
has a box **Co-host** beside it. Ticking it makes that guest a
co-host: they may then start new pages, put images on the page and
arrange them, load pages from their notebook, expand, shrink and hide,
exactly as you can, and the buttons for it appear on their toolbar.
Unticking it takes it back; otherwise it lasts until the session
ends. What stays yours alone: making co-hosts, and bringing everyone's
view to the page with Fit or A4. Guests, co-hosts or not, can never
erase or change anyone's writing but their own; that needs no setting.
## Hiding writing
The box in front of each participant's name shows or hides that
participant's writing on your screen only. Hide a student's answers to
see the exercise clean, hide your own corrections to see what the
student sees, show them again; the others notice nothing. **Export**
leaves hidden writing out of the picture it makes, which is how to make a
picture of the exercise with the corrections alone.
## Keeping and exporting
The page you are on is written continuously to `Current.svg` in the
current notebook, and copied to a dated file whenever the session moves
on. **Keep** makes such a copy now, named for the day and a serial
number, such as `2026_10_06_02.svg`; pressing it again before anything
has changed makes no second copy, and neither does moving on from a
page that is already kept, such as one loaded from the notebook and left
as it was. Any mark counts as a change, even a
dot from a stray touch of the pen. Use it to mark a state worth keeping
on its own, or to add a finished page to a notebook of exercises.
**Export** saves a picture of the page as you see it, as a PNG file at
200 dots per inch, in a directory and under a name you choose in
Kokkoja's file chooser. Replacing an existing file takes a second press
of the button, so a slip does not overwrite anything.
The notebooks are ordinary directories, and a page is an ordinary SVG
file. Open one in a browser or in Inkscape, attach it to an email, keep
it with your other documents. Only Kokkoja can load it as a live page
again, with each person's writing still theirs.
## Ending a session
Close the browser tab, or stop the program with Ctrl-C in its terminal.
There is nothing to save: the page is already in your notebook, and in
each guest's. Guests who are still connected can go on writing on the
page among themselves, but no new page can be started without you or a
co-host, and a guest who arrives after everyone
has left finds an empty session. The next lesson is a new session with a
new invitation; load the pages you need from the student's notebook.
## When something goes wrong
* **The browser says the notebooks are locked.** Open Kokkoja from the
full address the program printed when it started, the one with the
token.
* **"Not connected" above the page.** Kokkoja reconnects by itself as
soon as the relay can be reached, and sends what was written meanwhile.
If it does not come back, check your connection; the page is safe in
your notebook either way.
* **A guest sees "Waiting for the host's page".** You have not started
a page yet, or your connection is down. The first page is started when
you start the session, so this usually means the latter.
* **Pressure does not work.** The browser's console (F12) logs what
the browser receives from your pen at your first stroke. If it says
*mouse*, the browser is not seeing the pen as a pen; try the other
browser.
* **"Address already in use" when starting the program.** A copy of
the program is already running, usually in another terminal window.
Only one can run at a time; use it, or stop it there with Ctrl-C and
start again.
* **Guests cannot connect.** Look at the line `Relay: ...` the program
printed when it started: it names the relay the session goes through.
If it is not your server's address, stop the program and start it
again with `--relay` and the right address.
-310
View File
@@ -1,310 +0,0 @@
# How Kokkoja works
This part is for the curious, for anyone who wants to run a Kokkoja
server, and for anyone who wants to read or change the code. It
describes the parts, what travels between them, what is protected and
what is not, and how pages are stored. The code is the final word; it
is short and commented.
## The parts
Kokkoja is three programs and a few static files.
* **The client** (`client/`) runs in the web browser and does everything
the user sees: the pen, the page, the text, the encryption, the
saving. It is plain JavaScript modules, run by the browser as written,
with no build step and no third-party packages. Its types are written
in JSDoc comments and checked by the TypeScript compiler during
development.
* **The local program** (`kokkoja.py`) runs on the user's computer. It
serves the client to the browser at `http://localhost:8760/`, keeps the
user's identity, and reads and writes the notebook directories, which
a web page cannot do on its own. It uses nothing but Python's standard
library.
* **The relay** (`relay/kokkoja_relay.py`) runs on a server that every
participant can reach. It passes messages between the members of a
session and nothing more: it cannot read them and it stores nothing.
It is Python with one library, `websockets`.
* **The web site** is the `client/` directory served over HTTPS, with a
`config.json` beside it, so that a guest can join, and a host can
start a session, without the local program. `deploy/` has the server
setup.
Every participant's computer holds the whole page. Only changes travel:
a stroke as it is drawn, a text block as it is typed, an erasure, a new
page, an image once. There is no central copy.
## Why the relay exists
Two computers behind home routers generally cannot connect to each other
directly, so something in between has to pass the messages. The relay is
the smallest such thing: a WebSocket server that passes messages among
the members of a session. What a user calls a session, the relay calls a
*room*: the word appears in the relay's code and log and in the address a
client connects to, `wss://<server>/relay/room/<room id>`, and nowhere
else. The relay gives each connection a number, tells the others that a
peer has arrived, and from then on sends every message it receives from
one member to all the others, stamped with the sender's number. When
the last member leaves, nothing of the session remains at the relay. It
holds at most 32 members per session and 500 sessions, and refuses
messages over 1 MB; clients split anything larger.
## Sessions and encryption
The invitation is
<relay address>#<secret>.<host id>
The *secret* is 16 random bytes, made by the host's browser. From it
every client derives two values with HKDF-SHA-256: the *room id*, which
names the session at the relay, and the *message key*, a 256-bit AES-GCM
key with which every message is encrypted before it is sent and
decrypted on arrival. The relay sees the room id, the members' network
addresses, and the size and timing of encrypted messages. It cannot
compute the key from the room id, so it cannot read anything. Neither
can anyone watching the network between the participants and the relay,
which is TLS-protected in any case.
A link invitation wraps the same text:
https://<site>/#join=<invitation>
Browsers never send the part of an address after `#` to the site, so the
secret stays between the host and the guests. The Session window also
shows the link as a QR code, so that a phone or a tablet in the room can
join by camera; the encoder is Kokkoja's own (`qr_code.js`: byte mode,
error correction level M, any version), checked by the tests against
Nayuki's reference encoder.
The client writes its singular events (pages, images, the connection,
the co-host right, the first input report) to the browser's console
and keeps them, with a trace of every press, release and cancellation
of the pointer (an eraser's release says how many strokes and text
blocks it removed), every change of tool and the touch events, in
memory (`log.js`, the last 400 entries). The Session window shows the record with a Copy button,
so that a guest on a tablet, where there is no console, can send it
when the pen misbehaves. The record stays in the browser tab; nothing
of it is sent anywhere by Kokkoja.
## Identity and layers
Each participant has a signing key pair (ECDSA on P-256), made in the
browser the first time and then kept, by the local program in its
configuration directory or by the browser in its own storage. The
*participant id* is the first 16 bytes of the SHA-256 hash of the public
key. When a client connects to a session it announces itself with a signed
`hello` carrying its name and public key; the signature covers the room
id and the peer number the relay stamped on the message, so nobody can
replay it elsewhere or claim another peer's connection. Until a peer has
sent a valid `hello`, everything else from it is ignored.
A page is a stack of layers, one per participant, keyed by participant
id. A client accepts a change to a layer only from the participant whose
id it is, so the rule "nobody can change anyone else's writing" is not a
permission that could be misconfigured but a consequence of how the data
is organised. Because the id is derived from the key, and the key is
kept, a person is the same participant in every session from the same
computer, and their writing on a page loaded weeks later is still theirs.
The host is whoever holds the key whose id is in the invitation. Pages,
images and the list of co-hosts are accepted only
from the host (or from an allowed guest, for pages and images).
## Pages
Page coordinates are in units of 0.1 mm. A blank page is A4
in landscape, 2970 by 2100 units. "Expand" makes the page fill the
window in both directions, and
"Shrink" makes it the bounding box of everything on the page plus the
greater of the two margins, exactly, on every side; both are changes to the page like adding
an image, carried in the page descriptor (its `left` and `top` edges and
its size) with the images' revision raised. Writing within 5 mm of an edge grows the page by
5 mm on that side, counted from the page's edge. An image is placed at its natural size, taken from
the resolution recorded in the file, or 0.1 mm per pixel when there is
none (the nominal 72 and 96 dpi that cameras and screenshots write count
as none), centred across the page, 5 mm from the top or from the image
above, the page widened equally on both sides if the image is wider
than it and lengthened downward if the image is taller; only one wider
than a page can be, 2 m, is scaled down. A page holds at most 12 images and 24 MB of image data.
An image file over 2400 pixels on a side or 1.5 MB, or not PNG or JPEG,
is redrawn at most 2400 pixels and stored as JPEG before it is shared;
smaller PNG and JPEG files are shared byte for byte.
A text block is free text, or a *text rectangle* with a width and a
height, drawn with a red frame on a white ground; the ground is drawn
beneath every layer, so that it hides an image and never anyone's
writing, and the frame within the owner's layer. A *Hide* rectangle is
a page object like an image, in the page descriptor and the page file,
drawn white above every layer with blue corners on screen; made, moved
and removed by the host and co-hosts. Both are moved by a hold of
the pen and resized from their corners, like images but with their
shape free; an image keeps its shape.
A stroke is a list of points with pressure, and the line width is a
function of pressure: nominal at a pressure of 0.4, about 2.6 times that
at 0.75. Points are sent while the stroke is drawn, so the others see it
grow. A text block is sent whole at every keystroke, with its line
breaks already laid out by the writer's browser, so that everyone shows
the same lines.
Each page has a number, counted from 1 in the session, and a random id.
A client that joins late asks the longest-connected member for the
layers of absent participants, and receives each present participant's
layer from that participant. A client that reconnects after a dropped
connection sends its whole layer again, so that nothing drawn meanwhile
is lost.
Undo is personal, since nobody can change another's layer, and is a
stack of one's own gestures kept in the browser tab for the page being
written: a stroke; the strokes and text blocks one movement of the
eraser removed, kept whole; a text block as it was before an editing or
a move; a Hide rectangle as it was before it was made, moved or removed.
Undoing sends the ordinary messages, except that erased strokes come
back by a `stroke_restore` message carrying them whole. Images are not
in the stack, because their data is not kept. The stack is cleared when
the page changes and is not in the page file, so a reloaded tab starts
with nothing to undo.
## The page file
A notebook is a directory. The page being written is `Current.svg` in
it, rewritten a moment after every change (at most every 400 ms while
writing continues, at least every 2 s). A *kept* page is a copy of
`Current.svg` named `YYYY_MM_DD_NN.svg`, the day being when the page was
last changed and NN a serial number within that day. A copy is made when
the user asks (**Keep**), and by the program whenever `Current.svg`
is about to be given to another page: when the session moves to a new
page, when the notebook is changed, and when a session begins and finds
a page of another session in the notebook. Nothing is copied if the
page is blank or the same page is already kept in that notebook; a
page loaded from a notebook and left unchanged is therefore not kept
again, although the file names it as a new page. A kept page or a
whole notebook can be deleted from the Notebooks chooser, whose
tooltips describe each page (from the page file itself, which the
chooser fetches for the picture) and each notebook (from the listing,
which names the pages and says when each was last written); the local
program moves it to the system's Trash (the freedesktop Trash on
Linux, with the info file the file manager wants for putting it back;
`~/.Trash` on macOS) unless `--trash` says otherwise, and the
browser's notebook, having none, deletes for good.
The file is an SVG document that any viewer displays: the images as
embedded `<image>` elements, each layer as a group with the participant's
name and id, each stroke as a path and each text block as text.
Beside the shapes, the same elements carry Kokkoja's own data in
attributes, the exact points and pressures, the text and its breaks, so
that Kokkoja reads the page back without loss, with each layer still
belonging to its participant. The session id and host id are recorded
too, which is how a reloaded tab knows that `Current.svg` is its own page
to continue from.
With the local program, the notebooks are in `Kokkoja` in the Documents
directory (`--notebooks` chooses another place), the identity and the
browser's token in the configuration directory (`~/.config/kokkoja` on
Linux, `~/Library/Application Support/kokkoja` on macOS). In the
browser, the notebooks are in the site's IndexedDB under the same names
and rules, and the identity in its localStorage; a kept page and an
exported picture are offered as downloads, since that is the only way a
web page can hand a file to its user.
## The local program and its token
The local program listens on 127.0.0.1 only. Any web page open in the
browser could nevertheless send requests to `localhost:8760`, so every
request that touches files must carry a secret token, which the program
makes once, keeps in its configuration directory, and passes to the
browser in the address it opens (`#token=...`, which the browser keeps
in its local storage and never sends to any server). Requests without
the token are refused. The program also checks that requests come from
its own origin. Outside the notebooks it reads only image files and
writes only PNG files, each where the file chooser was pointed.
## The web site, and whom you trust
A client served by a web site is a web application like any other: the
site operator's code runs in your browser. The end-to-end encryption
protects the session from the relay and the network, but a dishonest
site could serve a client that gives the key away. Participants who run
the local program load the client from their own disk and are not
exposed to that; they trust the code they downloaded once. The site
sends a Content Security Policy that lets the client load nothing from
anywhere else, which protects against the ordinary web attacks but not
against the operator. For a lesson this is the same trust one places in
any video call service, and considerably less than in one that sees the
content; it is stated here so that nobody is surprised.
## Running a server
A server is a machine with a public name, TLS and nginx (or any reverse
proxy that passes WebSockets). `deploy/README.md` describes the one
Kokkoja comes with: the relay as a systemd service bound to localhost,
nginx terminating TLS, serving the client's files at `https://<name>/`
and forwarding `wss://<name>/relay/` to the relay, and a `config.json`
telling the client which relay to use and that notebooks are kept in the
browser. A host uses the server with
python3 kokkoja.py --relay wss://<name>/relay
and invitations become links to `https://<name>/`. One server serves
any number of hosts and sessions at once; the relay's work per session
is passing a few kilobytes per second. Since the relay cannot read what
it passes, a server run by someone else is a matter of availability, not
of privacy; the web site is the part that requires trust, as described
above. A teacher who wants no dependence on anyone's server needs a
machine of their own that is reachable from the Internet, which a
computer behind a home router is not without arrangement.
## What is not protected yet
* Only `hello` is signed. A participant working together with a
dishonest relay could forge another participant's strokes. Signing
every message, or page descriptors with the host's key, would close
this.
* A participant can announce a page with a higher number than the
host's, and the others move to it.
* The relay sees who talks to whom, when, and how much.
## Pen input
The client uses the browser's pointer events, which deliver every sample
a pen produces, with pressure, in Firefox and in Chromium-based browsers
on Linux and macOS. The browser's console shows what arrives. When a
pen has been used, touches are ignored, so a hand resting on a touch
screen does not draw; this rule has not yet been tested with a device
that has both a pen and a touch sensor. Kokkoja opens none of the
browser's system dialogs (the colour palette and, with the local
program, the file chooser are its own), because one such dialog crashed
Firefox on Linux.
## Adjustable choices
Some choices of the interface are named constants at the top of the
file that uses them, so that they can be changed in one place, or offered
as preferences later: the line widths and the five colours (`main.js`),
the zoom step of the wheel and the zoom per 100 pixels of a pen drag
(`renderer.js`), the dead zone of that drag (`pen_input.js`), the margin
by which the page grows (`page_model.js`), the tooltip delay (1 s,
`DELAY_MS` in `tooltip.js`) and how long after one tooltip the next
button's comes at once (1 s, `WARM_MS` there), the time within which a press after the window comes to the front is taken for the click that raised it and draws nothing (250 ms, `FOCUS_SETTLE_MS` in `pen_input.js`), the hold that moves an image (600 ms, with 6 pixels of
slack, `pen_input.js`), the reach of an image's corner (24 pixels,
`renderer.js`), the smallest an image can be scaled to (1 cm,
`page_model.js`), how often a dragged image's place is sent (40 ms,
`session.js`) and the points of a stroke (25 ms, `session.js`), the
interval of the eraser's double tap (400 ms, `DOUBLE_TAP_MS` in
`main.js`) and how many gestures can be undone (100, `UNDO_LIMIT` in
`session.js`), the colour and width of a text rectangle's frame
(`page_file.js`) and whether the frame is shown whole or as corners on
screen (`TEXT_FRAME_STYLE` in `renderer.js`; the file and the export
always hold the whole frame).
## Development
The code is Python and JavaScript only. The tests run with
.venv/bin/python -m unittest discover tests -v
where `tests/test_end_to_end.py` drives a headless Firefox through
geckodriver with several participants, with and without the local
program, and `npm run check` type-checks the client (the TypeScript
compiler is the only development dependency). The relay's `--host` and
`--port` and the local program's options are listed by `--help`.
-157
View File
@@ -1,157 +0,0 @@
# Kokkoja: an introduction
Kokkoja lets people at different computers write together on a shared
*page*. Everyone in a session sees the same page. Anyone can write on it
by hand or type on it, and what one person writes appears on the others'
screens as it is written. A page can begin blank, or it can begin with an
image, such as a scanned document or a photograph, which everyone then writes
on. Every page is saved automatically to a *notebook* on each person's
own computer, so that after the session each participant keeps a complete
copy of everything that was written.
Kokkoja runs in a web browser, beside whatever video call you are already
using. It replaces the screen sharing and annotation of a video call
application with something made for writing together, and it leaves the
voice and video to the call.
## What Kokkoja does
**A shared page, not a shared screen.** In a video call one person's
screen is transmitted as a picture, and annotations float on top of that
picture. In Kokkoja every participant's computer holds the page itself:
the image, if there is one, and every mark made on it. Only the changes
travel over the network. The marks belong to the page, are saved with it,
and can be shown or hidden later.
**Handwriting and typing.** Kokkoja is made for a pen on a drawing
tablet, with pressure giving the line its thickness, but a mouse or a
finger will do. Typed text is placed anywhere on the page and wraps at a
width you choose. Both come in any colour.
**Everyone writes on their own layer.** Each participant's writing is a
layer of its own. You can erase your own writing and nothing else; nobody
else can erase yours. Any layer can be hidden on your own screen without
affecting anyone else's view.
**A page that grows.** A page is A4 in size, lying on its side. When writing approaches an
edge, the page grows in that direction, so you are never short of room.
An image is placed at its natural size; one wider than the page is
scaled down to fit, and the page grows to hold an image taller than it
or further images below.
**Saved without a save command.** The page you are on is written to your
notebook continuously, as it changes. Nothing is lost if a connection
drops or a computer crashes. When the session moves to another page, the
page being left is kept as a dated file. You can also keep a dated copy
of a page at any moment, and export a picture of it as a PNG file.
**Notebooks are yours.** A notebook is a directory on your own computer
and a page is a single file in it, an ordinary SVG file that any
image viewer or vector drawing program can open. Nothing is stored on any
server, and there are no accounts: you choose the name the others will
see, and that is all.
**Private.** Everything that travels between participants is encrypted
on their own computers. The relay server that passes the messages cannot
read them. The invitation to a session carries the key, so only those
who were invited can read the page.
**Nothing to install.** A guest joins by opening a link in a web
browser, and a host can start a session from the web site in the same
way. Someone who wants a notebook of real files on their own computer,
a teacher with a notebook per student for instance, installs the small
Kokkoja program instead.
**Free software.** Kokkoja is free and open-source. Anyone can use it
without paying, and nobody depends on a company's service. It runs in
Firefox, in browsers built on the Chromium engine, such as Brave or
Chrome, and in Safari, on Linux and macOS; a guest can also join from
Safari on an iPad or an iPhone, writing with a pencil or a finger.
## Who uses it
Kokkoja is a general tool for working on a page together at a distance:
going through a document with a colleague, sketching a diagram while
talking it over, correcting a draft, teaching anything that is done on
paper. Wherever two people would otherwise lean over one sheet of
paper, Kokkoja puts that page between their computers.
Its ideal use, and the one it was made for first, is teaching a foreign
language over a video call. The teacher puts a scanned exercise on the
page. The student fills it in by hand or by typing, during the lesson,
while the teacher watches, corrects mistakes as they are made, and adds
notes in the margin. Each correction is in the teacher's own hand and
colour, on the teacher's own layer, where the student cannot change it.
When the lesson is over, the student's notebook holds every exercise of
the lesson, with the student's answers and the teacher's corrections
together on one page, and the teacher's notebook holds the same pages as
a record of what the student was given. The teacher's notebooks of
exercises become a library: loading a page from one of them puts a fresh
copy in front of a new student, and the original is never changed.
## Hosts and guests
A session has one *host*, the person who starts it, and any number of
*guests*. In a lesson the teacher is the host and the students are the
guests. The host sends the guests an invitation, by email, in a chat
message, or by any other means. The host *drives*: only the host puts
images on the page, starts new pages or loads pages from a notebook, and
everyone follows. The host can make any guest a *co-host*, with the
same powers, for a discussion among colleagues.
## What runs where
Three kinds of machine take part, and it helps to have the picture in
mind before reading on.
```
Host's computer Server Guests
--------------- ------ ------
Kokkoja program kokkoja.andykopra.com Guest A: a browser
serves the client -+ | opens the link
to the browser at | +----+-----+ the server serves
localhost:8760 | | relay | <-------------> the client to it;
keeps the notebook | | (passes | notebook in the
as files | | messages,| browser, downloads
| | | reads |
browser <-----------+ | nothing) | <-------------> Guest B: has the
| +----------+ Kokkoja program too;
+---- encrypted ----------^ | joins from its own
messages | start screen; note-
web site: book as files
the client's files
```
* **The host** in the picture runs the small Kokkoja program on their
own computer. It shows Kokkoja in the host's browser, at the address
`http://localhost:8760/`, and it keeps the host's notebooks as
directories of files. It must be running for as long as the host is
in a session; the browser alone cannot reach the notebooks. A host
can also do without it and start the session from the web site, like
guest A, with the notebooks in the browser.
* **The server** does two things for everyone. Its *relay* passes the
participants' messages, which are encrypted before they leave each
computer, so the relay reads nothing. Its *web site* serves the
Kokkoja client to guests who join by link. The host's program is
told the server's address once, when it is started.
* **A guest** needs only a browser: opening the invitation link fetches
the client from the server's web site, and the guest's notebook lives
in that browser, with kept pages and exports offered as downloads. A
guest who wants a notebook of real files runs the Kokkoja program as
the host does, and joins from the program's own start screen.
Every participant's computer holds the whole page. Only changes travel,
through the relay, to everyone else. Nothing is stored on the server.
The rest of this documentation describes Kokkoja from the guest's point of
view first, since the host sees the same screen and has a few more
buttons, then from the host's, and finally explains how it works
underneath, for those who want to run their own server or read the code.
* [Kokkoja in five minutes](quickstart.md), the shortest path for
someone at home in a terminal
* [A guest's Kokkoja](guest.md)
* [A host's Kokkoja](host.md)
* [How Kokkoja works](implementation.md)
-107
View File
@@ -1,107 +0,0 @@
# Kokkoja in five minutes
The shortest path from nothing to a shared page, for someone who is at
home in a terminal. Everything here is said at more length in [A host's
Kokkoja](host.md), [A guest's Kokkoja](guest.md) and [How Kokkoja
works](implementation.md).
## What you are installing
Kokkoja has three parts. The **client** is plain JavaScript that runs
in a browser. The **local program**, `kokkoja.py`, serves the client to
your own browser and keeps your notebooks as directories of SVG files;
without it the notebooks live in the browser's storage. The **relay**
passes the participants' messages, encrypted end to end so that it
cannot read them, and serves the client as a web site, from which anyone
can host or join with nothing installed; Andy's relay at
`kokkoja.andykopra.com` is there for you, and you can run your own.
## Requirements
* Linux or macOS. Windows is untried.
* Python 3. The local program uses the standard library only; nothing
is installed with pip.
* Firefox, Brave, Chrome or Safari.
* A pen tablet, or the pencil of a tablet, for handwriting. A mouse
does for pointing and rough marks.
## Install
git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja
cd kokkoja
or download and unpack
`https://git.andykopra.com/ack/kokkoja-rel/archive/main.zip`. Nothing
else touches your system. Updating is `git pull`; removal is deleting
the directory.
## Host a session
With nothing installed: open `https://kokkoja.andykopra.com/`, enter a
name, press **Start a session**; the notebooks are then in the browser.
With the program, for notebooks as files:
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
The program prints `http://localhost:8760/#token=…` and opens your
browser there. Enter a name, choose a notebook, press **Start a
session**. The relay is remembered, so from now on `python3 kokkoja.py`
suffices. Keep the terminal open while the session lasts; Ctrl-C ends
the program.
**Session** in the toolbar shows the invitation: a link such as
https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23<secret>.<host id>
Send it by any means, or let the other person scan the QR code under it.
The part after `#` is never sent to the web site; it carries the
session's secret, so treat the link as you would a meeting link.
## Join a session
Open the invitation link in a browser. Enter a name and press **Join**.
That is all; the page is in front of you. A guest with the local
program installed can instead paste the link into their own program's
start screen, and then their notebooks are files on their computer
rather than in the browser's storage.
## The screen in one paragraph
The toolbar holds the pen, text, eraser and hide tools, colours and
line widths, the view commands (Fit, A4, Expand, Shrink, Extend), the
host's page buttons (New, Image, Notebooks), your notebook, Keep, Export
and Session. Rest the pointer on any button for a second for its
explanation. Each participant writes on a layer of their own that
nobody else can change. The page is saved continuously to the current
notebook, `Current.svg`; Keep makes a dated copy. Rest the pen on an
image to move it, near a corner to scale it. Ctrl (Command on a Mac)
with the wheel, or with a pen drag, zooms.
## Where things are
* Notebooks: `~/Documents/Kokkoja/<notebook>/`, one SVG file per page,
which any browser displays.
* The program's settings, including your identity key and the remembered
relay: `~/.config/kokkoja/` on Linux, `~/Library/Application
Support/kokkoja/` on macOS.
* Options: `--notebooks DIR`, `--port N` (default 8760), `--no-browser`,
`--site URL`, `--config DIR`.
## Your own relay
python3 -m venv .venv && .venv/bin/pip install -r relay/requirements.txt
.venv/bin/python relay/kokkoja_relay.py --host 0.0.0.0 --port 8770
Then hosts start with `--relay ws://<host>:8770`. For a public server
with TLS, the web site and a systemd service, `deploy/` has the nginx
and systemd files and `deploy/README.md` the steps. The relay stores
nothing and sees only ciphertext.
## Tests and types
.venv/bin/pip install -r tests/requirements.txt
.venv/bin/python -m unittest discover tests
npm install && npm run check
The end-to-end test needs Firefox and geckodriver; the type check is
TypeScript over JSDoc comments and is the only use of npm.
+100
View File
@@ -0,0 +1,100 @@
"""The client's reading of Klammertext's character codes (character_codes.js)
against the tables `kdesc -c` prints and the rules of the live Klammertext
tree, K/mac (character.cpp, katom_process.cpp; K/src is deprecated), as
`ktext -s` showed them on 2026-10-13. Needs node (the type checker's).
"""
import json
import os
import subprocess
import unittest
CLIENT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "js")
HARNESS = """
import { codeAtEnd } from %s;
const texts = JSON.parse(await new Promise((resolve) => {
let data = '';
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
}));
console.log(JSON.stringify(texts.map((text) => codeAtEnd(text))));
"""
# `kdesc -c`, 2026-10-13: Displayed, Written.
DIACRITICS = [("é", "^e'"), ("à", "^a`"), ("ô", "^oh"), ("ñ", "^n~"), ("ü", '^u"'), ("ç", "^cc"),
("ō", "^o-"), ("ğ", "^gb"), ("å", "^ar"), ("ė", "^ed"), ("š", "^sw"), ("ő", "^oa")]
EXTENDED_LATIN = [("ß", "^s^"), ("ı", "^i^"), ("İ", "^I^"), ("þ", "^t^"), ("Þ", "^T^"), ("ð", "^e^"),
("Ð", "^E^"), ("ø", "^o^"), ("Ø", "^O^"), ("đ", "^d^"), ("Đ", "^D^"), ("æ", "^ae^"),
("Æ", "^AE^"), ("œ", "^oe^"), ("Œ", "^OE^")]
PINYIN = [("ā", "^a1"), ("á", "^a2"), ("ǎ", "^a3"), ("à", "^a4")]
def codes_at_end(texts):
harness = HARNESS % json.dumps(os.path.join(CLIENT, "character_codes.js"))
result = subprocess.run(["node", "--input-type=module", "-e", harness],
input=json.dumps(texts), capture_output=True, text=True, check=True)
return json.loads(result.stdout)
class CharacterCodesTest(unittest.TestCase):
def check(self, cases):
"""[(text, expected)]: expected is the character, or None."""
results = codes_at_end([text for text, _ in cases])
for (text, expected), result in zip(cases, results):
with self.subTest(text=text):
if expected is None:
self.assertIsNone(result)
else:
self.assertEqual(result, {"length": len(text) - len("Vor "), "character": expected})
def test_the_tables(self):
cases = [("Vor " + written, shown) for shown, written in DIACRITICS + EXTENDED_LATIN + PINYIN]
self.check(cases)
# Precomposed, one code point each, as the tables display them.
for shown, _ in DIACRITICS + EXTENDED_LATIN + PINYIN:
self.assertEqual(len(shown), 1)
def test_other_bases_and_capitals(self):
"""The base of a diacritic is a letter of any alphabet, not a
digit, a space or ASCII punctuation."""
self.check([("Vor ^A'", "Á"), ("Vor ^U\"", "Ü"), ("Vor ^Ss", None), ("Vor ^Sw", "Š"),
("Vor ^c'", "ć"), ("Vor ^Ccedilla", None), ("Vor ^e1", "ē"), ("Vor ^u3", "ǔ"),
("Vor ^x1", None), ("Vor ^q~", "q̃"), ("Vor ^1~", None), ("Vor ^.~", None),
("Vor ^ö-", "\u022B"), ("Vor ^α'", "ά")])
def test_code_points_and_the_caret(self):
self.check([("Vor ^00E9^", "é"), ("Vor ^00e9^", "é"), ("Vor ^20AC^", "€"), ("Vor ^^", "^"),
("Vor ^00AE^", "®"), # not ^E^ at its end
("Vor ^130ED^", "𓃭"), ("Vor ^1F600^", "😀"), ("Vor ^10FFFD^", "\U0010FFFD"),
# 1 to 3 digits are not a code point: a control
# character, or a collision with the mnemonics.
("Vor ^a^", None), ("Vor ^7^", None), ("Vor ^ae^", "æ"), ("Vor ^0E9^", None),
# Not a character: beyond the range, a surrogate, a
# noncharacter. Stays as typed (an error in a document).
("Vor ^110000^", None), ("Vor ^D800^", None), ("Vor ^FFFE^", None), ("Vor ^FDD0^", None)])
def test_sequences(self):
"""Code points separated by spaces between one pair of carets."""
self.check([("Vor ^9053 53EF 9053 975E 5A38 9053^", "道可道非娸道"), ("Vor ^9053 53EF^", "道可"),
("Vor ^0041 1F600^", "A😀"),
("Vor ^ 9053^", None), ("Vor ^9053 ^", None), # no space inside the carets
("Vor ^9053 D800^", None), # one bad member spoils it
# A single character stands for itself, as in
# Klammertext, once a code point is among the members
# (Andy, 2026-10-13); without one, x^2 + y^2 stays.
("Vor ^9053 a 53EF^", "道a可"), ("Vor ^a 1F600^", "a😀"), ("Vor ^9053 @^", "道@"),
("Vor ^2 + y^", None), ("Vor ^a b^", None), ("Vor ^ab 9053^", None),
("Vor ^a^", None)])
def test_an_unfinished_or_no_code(self):
"""A keystroke at a time: nothing until the code is complete, and
a caret with something else after it stays."""
self.check([("Vor ^", None), ("Vor ^o", None), ("Vor ^ae", None), ("Vor ^00E", None),
("Vor ^00E9", None), ("Vor ^ a", None), ("Vor ^o\"a", None), ("Vor 2^3", None),
("Vor ^@'", None), ("Vor ", None), ("", None)])
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -1346,6 +1346,51 @@ class EndToEndTest(unittest.TestCase):
firefox.switch_to(host_tab) firefox.switch_to(host_tab)
firefox.go("about:blank") firefox.go("about:blank")
def test_character_codes_in_text(self):
"""Andy, 2026-10-13: Klammertext's character codes in a text
block, replaced as each is completed; the field's own undo
survives a replacement."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Typist", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Onlooker", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the onlooker")
firefox.switch_to(host_tab)
field_text = lambda: firefox.run("return document.querySelector('.text_field').value;")
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3)
firefox.type_into(".text_field", 'Gr^u"^s^e, ^a` 2^^3, ^OE^uvre, ^00E9^, m^a1, ^130ED^ ^9053 53EF^')
self.assertEqual(field_text(), "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
self.wait_until(lambda s: s["texts"]["Typist"] and s["texts"]["Typist"][0]["text"] == "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可",
"the text with its codes replaced")
# The caret counts UTF-16 units, so the hieroglyph counts twice.
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"),
len("Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可".encode("utf-16-le")) // 2)
# In the middle of the text too, and only at the caret: the ^
# left earlier stays.
firefox.run("const f = document.querySelector('.text_field'); f.setSelectionRange(2, 2);")
firefox.type_into(".text_field", "^o-")
self.assertEqual(field_text(), "Grōüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), 3)
# The browser's own undo in the field is not lost by a replacement:
# one step gives back the code as typed.
firefox.chord("\ue009", "z")
self.assertEqual(field_text(), "Gr^o-üße, à 2^3, Œuvre, é, mā, 𓃭 道可")
firefox.run("document.querySelector('[data-tool=pen]').click();")
shown = self.wait_until(lambda s: s["texts"]["Typist"] and "üße" in s["texts"]["Typist"][0]["text"],
"the finished block")["texts"]["Typist"][0]["text"]
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["texts"].get("Typist") and s["texts"]["Typist"][0]["text"] == shown,
"the onlooker to see the same text")
def test_text_being_typed_stays_on_the_page_left(self): def test_text_being_typed_stays_on_the_page_left(self):
"""Andy, 2026-10-06: a guest was typing when the host pressed New, """Andy, 2026-10-06: a guest was typing when the host pressed New,
and the text reappeared on the new page.""" and the text reappeared on the new page."""
+1 -1
View File
@@ -64,7 +64,7 @@ class QrCodeTest(unittest.TestCase):
self.assertEqual(matrix, expected) self.assertEqual(matrix, expected)
def test_invitation_link(self): def test_invitation_link(self):
link = ("https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23" link = ("https://app.kokkoja.com/#join=wss://app.kokkoja.com/relay%23"
"AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") "AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"), self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
("Übung 1: äöü", "M")]) ("Übung 1: äöü", "M")])