diff --git a/README.md b/README.md
index 7ac434b..4bf2c7e 100644
--- a/README.md
+++ b/README.md
@@ -13,24 +13,24 @@ may be used.
## Documentation
-The user documentation (an introduction, Kokkoja in five minutes, a
-guest's Kokkoja, a host's Kokkoja, and how Kokkoja works) is being moved
+The user documentation (an introduction, Kokkoja in five minutes,
+using Kokkoja, and how Kokkoja works) is being moved
to a new format and is not in this repository at the moment. It will
return with a later release.
## In short
-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,
+Joining needs nothing but a web browser: open the invitation link, enter
+a name, join. Anyone can start a session from the same web site,
`https://app.kokkoja.com/`, with the notebooks kept in the
-browser.
+browser; everyone in a session has the same buttons.
-A host who wants notebooks as files needs Python 3 and this directory:
+Whoever wants notebooks as files needs Python 3 and this directory:
python3 kokkoja.py --relay wss://app.kokkoja.com/relay
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 others. Notebooks are directories in `Kokkoja` in the
Documents directory.
## What is here
diff --git a/client/index.html b/client/index.html
index 3df62de..2d8235f 100644
--- a/client/index.html
+++ b/client/index.html
@@ -82,12 +82,12 @@
- Hide
Arrange
Eraser
+ data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it for everyone. Two quick taps on empty paper, away from anything the eraser would remove, undo your latest mark; again, and the one before.">Eraser
Undo
@@ -99,14 +99,14 @@
{
- if (!this.mayLoad) {
- this.say('Only the host and co-hosts can start a page for everyone from a kept page.');
- return;
- }
const text = await readPage(notebook, pageName);
if (text === null) return;
this.dialog.close();
diff --git a/client/js/crypto.js b/client/js/crypto.js
index 5681579..4a95da7 100644
--- a/client/js/crypto.js
+++ b/client/js/crypto.js
@@ -83,7 +83,10 @@ export function randomId(byteCount = 9) {
* @typedef {object} Invitation
* @property {string} relay Address of the relay, ws:// or wss://.
* @property {string} secret The session secret.
- * @property {string} hostId Participant id of the host.
+ * @property {string} hostId Participant id of whoever started the
+ * session (named the host until 2026-10-18; the field keeps its name
+ * so that invitations and page files are unchanged). It gives no
+ * right.
*/
/**
diff --git a/client/js/main.js b/client/js/main.js
index 06afbdf..955fb10 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -230,7 +230,7 @@ const CONNECTION_GRACE_MS = 1500;
* The notice line between the toolbar and the page. It says nothing
* while all is well. A STANDING notice, kept by its source until that
* source clears it, is a state: not connected, the page not being
- * saved, no page from the host yet. A PASSING notice is a report of
+ * saved, no page from the others yet. A PASSING notice is a report of
* one event, kept for a few seconds: a page kept or exported, a file
* that was not an image. Standing notices come before a passing one.
*/
@@ -470,7 +470,6 @@ async function enterSession(invitationText, name, notebook) {
input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
showInvitationCode(config.site ? input('invitation').value : '');
- const isHost = identity.id === invitation.hostId;
let tool = /** @type {Tool} */ ('pen');
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
@@ -501,7 +500,7 @@ async function enterSession(invitationText, name, notebook) {
function showPageNumber() {
const waiting = session.page.number === 0;
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
- standingNotice('page', waiting ? 'Waiting for the host’s page' : '', false);
+ standingNotice('page', waiting ? 'Waiting for the others’ page; New starts one' : '', false);
}
/** @type {ReturnType | undefined} */
let connectionNoticeTimer;
@@ -568,18 +567,6 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
writer.changed();
},
- onViewCommand: (command) => {
- log(`The host's view: ${command === 'fit' ? 'Fit' : 'Normal scale'}`);
- if (command === 'fit') renderer.fit();
- else renderer.sheetScale();
- },
- onPermissionsChanged: () => {
- log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
- (session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
- showHostTools();
- showParticipants(session, renderer);
- refreshArrangeCues();
- },
onParticipantsChanged: () => {
syncLayers();
showParticipants(session, renderer);
@@ -846,12 +833,11 @@ async function enterSession(invitationText, name, notebook) {
/**
* With the Arrange tool chosen, mark everything a press can move or
* resize with a blue box and corners (Andy, 2026-10-14): the images
- * and Hides for the host and co-hosts, one's own text blocks and
- * shapes; free text, which has no width to resize, gets the box without
- * the corners. The one being held is left out, the green grip standing
- * in its place until the pointer lifts. Called whenever the images,
- * the Hides, one's own text or shapes, the right, or the hold may have
- * changed.
+ * and Hides, one's own text blocks and shapes; free text, which has
+ * no width to resize, gets the box without the corners. The one being
+ * held is left out, the green grip standing in its place until the
+ * pointer lifts. Called whenever the images, the Hides, one's own
+ * text or shapes, or the hold may have changed.
*/
function refreshArrangeCues() {
if (tool !== 'arrange') {
@@ -861,16 +847,14 @@ async function enterSession(invitationText, name, notebook) {
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
const cues = [];
const page = session.page;
- if (session.mayMakePages()) {
- page.images.forEach((im, index) => {
- if (held?.kind === 'image' && held.index === index) return;
- cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
- });
- page.hides.forEach((h, index) => {
- if (held?.kind === 'hide' && held.index === index) return;
- cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
- });
- }
+ page.images.forEach((im, index) => {
+ if (held?.kind === 'image' && held.index === index) return;
+ cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
+ });
+ page.hides.forEach((h, index) => {
+ if (held?.kind === 'hide' && held.index === index) return;
+ cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
+ });
for (const block of session.ownLayer.texts.values()) {
if (held?.kind === 'text' && held.id === block.id) continue;
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
@@ -1110,13 +1094,13 @@ async function enterSession(invitationText, name, notebook) {
if (rects.length) return rects;
const page = session.page;
const hide = page.hideAt(x, y, 0);
- if (hide && session.mayMakePages()) {
+ if (hide) {
const h = page.hides[hide.index];
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
}
const found = page.imageAt(x, y, 0);
const image = found && page.images[found.index];
- if (image && (session.mayMakePages() || image.by === session.identity.id)) {
+ if (image) {
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
}
return [];
@@ -1143,15 +1127,15 @@ async function enterSession(invitationText, name, notebook) {
zoomTo: (pixelsUp) => renderer.zoomDragTo(pixelsUp),
endZoom: () => renderer.endZoomDrag(),
// A hold moves what is under the pen, or resizes it from a corner: a
- // Hide rectangle or an image (for the host and co-hosts), or a
- // text block of our own. The dot drawn meanwhile goes.
+ // Hide rectangle, an image, or a text block of our own. The dot
+ // drawn meanwhile goes.
beginHold: (clientX, clientY) => {
const [x, y] = renderer.pageFromClient(clientX, clientY);
const reach = renderer.cornerReach();
const page = session.page;
/** @type {Held | null} */
let found = null;
- const hide = session.mayMakePages() ? page.hideAt(x, y, reach) : null;
+ const hide = page.hideAt(x, y, reach);
if (hide) {
const h = page.hides[hide.index];
found = hold('hide', hide.index, h.id, { left: h.x, top: h.y, width: h.width, height: h.height }, hide.corner, x, y);
@@ -1190,7 +1174,7 @@ async function enterSession(invitationText, name, notebook) {
const stroke = shapes[shapeAt.index];
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke));
if (session.beginShapeEdit(stroke.id)) found = hold('shape', shapeAt.index, stroke.id, box, shapeAt.corner, x, y);
- } else if (session.mayMakePages()) {
+ } else {
const image = page.imageAt(x, y, reach);
if (image) {
const index = session.raiseImage(image.index);
@@ -1277,8 +1261,8 @@ async function enterSession(invitationText, name, notebook) {
lastEmptyTap = 0;
return;
}
- // A tap of the eraser on a Hide rectangle or an image removes it
- // (the right to make pages, or having added the image).
+ // A tap of the eraser on a Hide rectangle or an image removes it,
+ // for everyone (by anyone since 2026-10-18).
let took = erased.strokes + erased.texts > 0;
if (!took) {
const hide = session.page.hideAt(x, y, 0);
@@ -1458,8 +1442,8 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
viewChanged();
};
- // 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.
+ // Fit and Normal scale ("A4" until 2026-10-14) are each person's own
+ // (2026-10-18; the host's reached everyone from 2026-10-08 until then).
// The page and view commands live in the Page menu (Andy, 2026-10-14:
// seldom used, and the one row is precious).
attachPopup(element('page_menu_button'), element('page_menu'));
@@ -1505,14 +1489,8 @@ async function enterSession(invitationText, name, notebook) {
log(`The manual could not be copied: ${error}`);
}
});
- element('zoom_fit').addEventListener('click', () => {
- renderer.fit();
- session.sendViewCommand('fit');
- });
- element('zoom_sheet').addEventListener('click', () => {
- renderer.sheetScale();
- session.sendViewCommand('sheet');
- });
+ element('zoom_fit').addEventListener('click', () => renderer.fit());
+ element('zoom_sheet').addEventListener('click', () => renderer.sheetScale());
// "Expand" makes the sheet itself fill the window at the present zoom,
// in both directions; nothing on it moves (Andy, 2026-10-08 for the
// width, both ways 2026-10-11).
@@ -1541,21 +1519,9 @@ async function enterSession(invitationText, name, notebook) {
element('desk').addEventListener('scroll', viewChanged, { passive: true });
window.addEventListener('pagehide', saveView);
- // The host drives: the host starts new pages, and may let a guest do so.
- function showHostTools() {
- // New and Image are the host's; Notebooks, in the same group so that
- // nothing moves for the host, is everyone's (the chooser reserves
- // loading).
- for (const id of ['new_page', 'load_image']) element(id).hidden = !session.mayMakePages();
- // The Hide tool is for the host and co-hosts; losing the right
- // while it is chosen puts the pen back in hand.
- const hideTool = /** @type {HTMLElement} */ (document.querySelector('[data-tool=hide]'));
- hideTool.hidden = !session.mayMakePages();
- if (hideTool.hidden && tool === 'hide') /** @type {HTMLElement} */ (document.querySelector('[data-tool=pen]')).click();
- element('zoom_expand').hidden = !session.mayMakePages();
- element('zoom_shrink').hidden = !session.mayMakePages();
- }
- showHostTools();
+ // Everyone has every button (Andy and his teacher, 2026-10-18; until
+ // then New, Image, Hide, Expand and Shrink were the host's and the
+ // co-hosts').
element('new_page').addEventListener('click', () => {
log('New page asked for');
session.newPage();
@@ -1567,7 +1533,7 @@ async function enterSession(invitationText, name, notebook) {
* @param {Blob} file
*/
async function loadImage(file) {
- if (!session.mayMakePages() || session.page.number === 0) return;
+ if (session.page.number === 0) return;
try {
if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.');
@@ -1588,7 +1554,7 @@ async function enterSession(invitationText, name, notebook) {
// A deleted notebook leaves the toolbar's list.
showNotebooks(sessionPicker).catch(() => {});
});
- element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook, session.mayMakePages()); });
+ element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
// Image files are chosen, and pictures of the page exported, through a
// chooser of our own, which begins where the notebooks are.
@@ -2056,20 +2022,6 @@ function showParticipants(session, renderer) {
const present = participant.self || participant.peer !== null;
item.classList.toggle('absent', !present);
- if (session.isHost && !participant.self) {
- // A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
- const allowLine = document.createElement('label');
- allowLine.className = 'allow_line';
- allowLine.setAttribute('data-help', 'Make this participant a co-host: they may then start pages, put images on the page and arrange them, load notebook pages, expand, shrink and hide, as you can.');
- const allow = document.createElement('input');
- allow.type = 'checkbox';
- allow.className = 'allow';
- allow.checked = session.permitted.has(participant.id);
- allow.addEventListener('change', () => session.setPermitted(participant.id, allow.checked));
- allowLine.append('Co-host ', allow);
- item.append(allowLine);
- }
-
const label = document.createElement('label');
const visible = document.createElement('input');
visible.type = 'checkbox';
@@ -2082,8 +2034,6 @@ function showParticipants(session, renderer) {
const roles = [];
if (participant.self) roles.push('you');
- if (participant.host) roles.push('host');
- else if (!session.isHost && session.permitted.has(participant.id)) roles.push('co-host');
if (!present) roles.push('away');
if (roles.length) {
const role = document.createElement('span');
diff --git a/client/js/page_file.js b/client/js/page_file.js
index c792c15..82352d0 100644
--- a/client/js/page_file.js
+++ b/client/js/page_file.js
@@ -165,7 +165,7 @@ function strokeShapes(stroke) {
* @param {object} options
* @param {string} options.session The session the page belongs to (its
* room id), so that returning to the session continues the page.
- * @param {string} options.hostId
+ * @param {string} options.hostId Who started the session (`data-host`).
* @param {Set} [options.hidden] Layers to leave out: for a picture
* of the page as someone shows it, never for the page file itself.
* @returns {string} The page file.
diff --git a/client/js/page_model.js b/client/js/page_model.js
index dbcaf4a..88922cd 100644
--- a/client/js/page_model.js
+++ b/client/js/page_model.js
@@ -338,8 +338,8 @@ function withoutData(image) {
}
/**
- * What identifies a page and gives its size. The host sends it to the
- * others when the page changes.
+ * What identifies a page and gives its size. Whoever starts or changes
+ * a page sends it to the others.
* @typedef {object} PageDescriptor
* @property {string} id
* @property {number} number Counts the pages of a session from 1.
@@ -504,8 +504,8 @@ export class Page {
/**
* @param {number} [width]
* @param {number} [height]
- * @param {string} [id] Empty for the placeholder a guest shows
- * before the host's page arrives.
+ * @param {string} [id] Empty for the placeholder someone joining
+ * shows before a page arrives.
* @param {number} [number] 0 for the placeholder.
*/
constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) {
@@ -781,9 +781,10 @@ export class Page {
}
/**
- * Layer ids from bottom to top. The host's layer is on top so that
- * corrections lie over the writing they correct.
- * @param {string} hostId
+ * Layer ids from bottom to top. The layer of whoever started the
+ * session is on top, so that a teacher's corrections lie over the
+ * writing they correct (a drawing order, not a right).
+ * @param {string} hostId Who started the session.
* @returns {string[]}
*/
layerOrder(hostId) {
diff --git a/client/js/session.js b/client/js/session.js
index ec28d58..d7c4853 100644
--- a/client/js/session.js
+++ b/client/js/session.js
@@ -46,7 +46,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} name
* @property {number | null} peer Relay peer number; null when not connected.
* @property {boolean} self
- * @property {boolean} host
*/
/**
@@ -64,11 +63,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {(byImages: boolean) => void} onPageResized
* The page grew (or shrank) around its contents; `byImages` if an
* image was added or taken back, not if writing reached an edge.
- * @property {() => void} onPermissionsChanged
- * Who may start pages changed.
- * @property {(command: 'fit' | 'sheet') => void} onViewCommand
- * The host pressed "Fit" or "Normal scale": everyone's view follows (Andy,
- * 2026-10-08), each in their own window.
* @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
* @property {() => void} [onUndoChanged]
@@ -81,7 +75,9 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} roomId
* @property {CryptoKey} key
* @property {Identity} identity
- * @property {string} hostId
+ * @property {string} hostId Who started the session, the id in the
+ * invitation: the page file records it and that layer is drawn on
+ * top; it gives no right (everyone has the same, Andy, 2026-10-18).
* @property {string} name Our display name.
* @property {Page} [page] The page as last saved, when returning to
* the session.
@@ -146,13 +142,11 @@ export class Session {
this.hideEditing = null;
this.events = events;
- this.isHost = this.identity.id === this.hostId;
- /** @type {Set} Guests the host allows to start pages. */
- this.permitted = new Set();
- // The host begins a session with page 1. A guest shows a placeholder
- // until the host's page arrives.
+ // Whoever starts a session begins on page 1. Someone joining shows
+ // a placeholder until a page arrives from the others (or starts one
+ // with New, if nobody is there).
this.page = options.page ??
- (this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page());
+ (this.identity.id === this.hostId ? new Page(undefined, undefined, randomId(), 1) : new Page());
this.ownLayer = this.page.layer(this.identity.id, this.name);
this.page.recomputeStrokeBounds();
this.page.updateExtent();
@@ -161,13 +155,11 @@ export class Session {
this.participants = new Map();
this.participants.set(this.identity.id, {
id: this.identity.id, name: this.name, peer: null, self: true,
- host: this.identity.id === this.hostId,
});
for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id) continue;
this.participants.set(layer.id, {
id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false,
- host: layer.id === this.hostId,
});
}
@@ -241,34 +233,10 @@ export class Session {
return this.page.layerOrder(this.hostId);
}
- // Pages.
-
- /** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */
- mayMakePages() {
- return this.isHost || this.permitted.has(this.identity.id);
- }
-
- /**
- * The host's "Fit" or "Normal scale" goes to everyone. Only the host's does.
- * @param {'fit' | 'sheet'} command
- */
- sendViewCommand(command) {
- if (!this.isHost) return;
- this.send(0, { type: 'view', command });
- }
-
- /**
- * Allow a guest to start pages, or stop allowing it. Only the host may.
- * @param {string} id
- * @param {boolean} allowed
- */
- setPermitted(id, allowed) {
- if (!this.isHost || id === this.identity.id) return;
- if (allowed) this.permitted.add(id);
- else this.permitted.delete(id);
- this.send(0, { type: 'permissions', ids: [...this.permitted] });
- this.events.onPermissionsChanged();
- }
+ // Pages. Everyone may start, load and change them (Andy and his
+ // teacher, 2026-10-18: the host's rights and the co-host went; what
+ // an accident costs is said in the documentation, and the page left
+ // is kept in every notebook).
/**
* The page's rectangle may have changed with its contents.
@@ -279,11 +247,10 @@ export class Session {
}
/**
- * Start a new blank page for everyone. The host may; so may a guest the
- * host allows.
+ * Start a new blank page for everyone. Someone with no page yet (the
+ * others gone) starts page 1.
*/
newPage() {
- if (!this.mayMakePages()) return;
const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
page.updateExtent();
this.replacePage(page);
@@ -292,7 +259,6 @@ export class Session {
/**
* Add an image to the page for everyone, below the images already on it.
- * The host may; so may a guest the host allows.
* @param {object} prepared
* @param {PageImage['type']} prepared.type
* @param {Uint8Array} prepared.data
@@ -303,7 +269,7 @@ export class Session {
*/
addImage(prepared) {
const page = this.page;
- if (!this.mayMakePages() || page.number === 0) return false;
+ if (page.number === 0) return false;
const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */
@@ -333,7 +299,7 @@ export class Session {
*/
addHide(rect) {
const page = this.page;
- if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
+ if (page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
const hide = {
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height),
@@ -377,7 +343,7 @@ export class Session {
placeHideByHand(index, rect) {
const page = this.page;
const hide = page.hides[index];
- if (!this.mayMakePages() || !hide) return;
+ if (!hide) return;
hide.x = Math.round(rect.left);
hide.y = Math.round(rect.top);
hide.width = Math.round(rect.width);
@@ -394,7 +360,7 @@ export class Session {
removeHide(index) {
const page = this.page;
const hide = page.hides[index];
- if (!this.mayMakePages() || !hide) return false;
+ if (!hide) return false;
page.hides.splice(index, 1);
page.imagesRevision += 1;
this.sendPageNow();
@@ -424,7 +390,7 @@ export class Session {
*/
raiseImage(index) {
const page = this.page;
- if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
+ if (index < 0 || index >= page.images.length) return index;
if (index !== page.images.length - 1) {
const [image] = page.images.splice(index, 1);
page.images.push(image);
@@ -443,7 +409,7 @@ export class Session {
*/
placeImageByHand(index, rect) {
const page = this.page;
- if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
+ if (index < 0 || index >= page.images.length) return index;
index = this.raiseImage(index);
const image = page.images[index];
image.x = Math.round(rect.left);
@@ -496,7 +462,7 @@ export class Session {
*/
expandPage(width, height) {
const page = this.page;
- if (!this.mayMakePages() || page.number === 0) return false;
+ if (page.number === 0) return false;
// Equally on both sides, and top and bottom, of the PAGE as it is,
// writing included (the sheet may lie inside a page that writing has
// grown; measured from the sheet, the page came out short, Andy,
@@ -530,7 +496,7 @@ export class Session {
*/
shrinkPage() {
const page = this.page;
- if (!this.mayMakePages() || page.number === 0) return false;
+ if (page.number === 0) return false;
// The blank lines at the end of one's own text blocks go first (Andy,
// 2026-10-14); another's blocks are theirs, trimmed when they finish
// typing. Each trim is a gesture of its own to undo; a block being
@@ -564,7 +530,7 @@ export class Session {
removeImage(index) {
const page = this.page;
const image = page.images[index];
- if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
+ if (!image) return false;
page.images.splice(index, 1);
page.imagesRevision += 1;
this.imageTransfers.delete(image.hash);
@@ -581,7 +547,6 @@ export class Session {
* @param {Page} kept As read from the page file.
*/
loadPage(kept) {
- if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.baseLeft = kept.baseLeft;
page.baseTop = kept.baseTop;
@@ -595,9 +560,7 @@ export class Session {
}
for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() });
if (!this.participants.has(id)) {
- this.participants.set(id, {
- id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId,
- });
+ this.participants.set(id, { id, name: layer.name || 'Unnamed', peer: null, self: false });
}
}
page.recomputeStrokeBounds();
@@ -744,15 +707,18 @@ export class Session {
/**
* Move to a page that another participant is on, if it is newer than
- * ours. Pages are numbered by the host, so the highest number is the
- * session's current page; between two pages of the same number, the
- * host's is taken. If it is the page we are on, it may have gained
+ * ours. Whoever presses New numbers the page one past their own, so
+ * the highest number is the session's current page; between two
+ * different pages of the same number (two people pressed New at the
+ * same moment, or apart while disconnected) the one with the greater
+ * id is taken, the same choice on every machine, and the loser's page
+ * is kept in its notebook like any page left (2026-10-18; the host's
+ * won until then). If it is the page we are on, it may have gained
* images.
* @param {unknown} received A page descriptor.
- * @param {boolean} fromHost
* @param {number} peer Who told us.
*/
- considerPage(received, fromHost, peer) {
+ considerPage(received, peer) {
const descriptor = cleanPageDescriptor(received);
if (!descriptor) return;
if (descriptor.id === this.page.id) {
@@ -760,44 +726,12 @@ export class Session {
return;
}
const newer = descriptor.number > this.page.number ||
- (descriptor.number === this.page.number && fromHost);
+ (descriptor.number === this.page.number && descriptor.id > this.page.id);
if (!newer) return;
this.replacePage(Page.from(descriptor));
this.pageSourcePeer = peer;
}
- /**
- * Whether a page descriptor from a participant without the right to
- * make pages is one they may still send: our page, the sheet as it is,
- * and the images ours with only images they added left out (Andy,
- * 2026-10-08: whoever added an image may remove it, right or no right).
- * @param {any} received
- * @param {string} id The sender.
- * @returns {boolean}
- */
- removesOnlyOwnImages(received, id) {
- const descriptor = cleanPageDescriptor(received);
- const page = this.page;
- if (!descriptor || descriptor.id !== page.id || descriptor.left !== page.baseLeft ||
- descriptor.top !== page.baseTop || descriptor.width !== page.baseWidth ||
- descriptor.height !== page.baseHeight ||
- JSON.stringify(descriptor.hides) !== JSON.stringify(page.hides)) return false;
- /** @param {Omit} image */
- const key = (image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height} ${image.by}`;
- let at = 0;
- let removed = 0;
- for (const ours of page.images) {
- if (at < descriptor.images.length && key(descriptor.images[at]) === key(ours)) {
- at += 1;
- } else if (ours.by === id) {
- removed += 1;
- } else {
- return false;
- }
- }
- return at === descriptor.images.length && removed > 0;
- }
-
/**
* Another participant's list of the images on the page we are on. The
* list with the higher revision is the later one. Of two different
@@ -1116,7 +1050,6 @@ export class Session {
return 'a move of a shape';
case 'hide': {
const page = this.page;
- if (!this.mayMakePages()) return null;
const index = page.hides.findIndex((one) => one.id === entry.id);
if (entry.before === null) {
if (index < 0) return null;
@@ -1197,7 +1130,6 @@ export class Session {
type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey,
signature: await signClaim(this.identity, this.roomId, peer),
page: this.page.number > 0 ? this.page.descriptor() : null,
- permitted: this.isHost ? [...this.permitted] : undefined,
}));
}
@@ -1305,9 +1237,8 @@ export class Session {
const previous = this.participants.get(id);
if (previous && previous.peer !== null) this.peerIds.delete(previous.peer);
this.peerIds.set(peer, id);
- this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId });
- if (id === this.hostId) this.receivedPermissions(message.permitted);
- this.considerPage(message.page, id === this.hostId, peer);
+ this.participants.set(id, { id, name, peer, self: false });
+ this.considerPage(message.page, peer);
// This peer is on our page: it can give us the images we lack.
if (message.page?.id === this.page.id) this.requestImages(peer);
this.page.layer(id, name);
@@ -1329,20 +1260,12 @@ export class Session {
const id = this.peerIds.get(peer);
if (!id) return;
if (message.type === 'page') {
- if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
- else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
- return;
- }
- if (message.type === 'permissions') {
- if (id === this.hostId) this.receivedPermissions(message.ids);
- return;
- }
- if (message.type === 'view') {
- if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
- this.events.onViewCommand(message.command);
- }
+ this.considerPage(message.page, peer);
return;
}
+ // 'permissions' and 'view' came from a host, until 2026-10-18; an
+ // older client's are ignored.
+ if (message.type === 'permissions' || message.type === 'view') return;
if (message.type === 'image') {
this.receivedImagePiece(message);
return;
@@ -1482,19 +1405,6 @@ export class Session {
}
}
- /**
- * The host said who may start pages.
- * @param {unknown} ids
- */
- receivedPermissions(ids) {
- if (!Array.isArray(ids)) return;
- const permitted = new Set(ids.filter(validId));
- const same = permitted.size === this.permitted.size && [...permitted].every((id) => this.permitted.has(id));
- if (same) return;
- this.permitted = permitted;
- this.events.onPermissionsChanged();
- }
-
/**
* Whether to accept a snapshot of a layer from a peer. A participant may
* always send their own layer. The peer that moved us to this page may
@@ -1543,7 +1453,6 @@ export class Session {
if (!this.participants.has(layerId)) {
this.participants.set(layerId, {
id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false,
- host: layerId === this.hostId,
});
this.events.onParticipantsChanged();
}
diff --git a/client/kokkoja.css b/client/kokkoja.css
index 1d93ce2..8697093 100644
--- a/client/kokkoja.css
+++ b/client/kokkoja.css
@@ -529,8 +529,6 @@ h2 {
.session_body li.absent { color: var(--muted); }
.session_body li label { flex: 1; }
.session_body li .role { font-size: 0.8rem; color: var(--muted); }
-.session_body li .allow_line { order: 1; display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
-.session_body li .allow_line input { margin: 0; }
.invitation_line { display: flex; gap: 0.5rem; align-items: center; }
.invitation_line input { flex: 1; min-width: 0; }
/* The invitation as a QR code, for the camera of a phone or a tablet. */
diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py
index d959d10..42909e7 100644
--- a/tests/test_end_to_end.py
+++ b/tests/test_end_to_end.py
@@ -136,7 +136,7 @@ PAGE_STATE = """
hash: one.hash, x: one.x, y: one.y, width: one.width, height: one.height,
ready: one.data !== null, shown: i < shown.length && !shown[i].hidden})),
participants: session.participantList().map(
- (p) => ({name: p.name, host: p.host, present: p.self || p.peer !== null})),
+ (p) => ({name: p.name, present: p.self || p.peer !== null})),
layers,
texts,
};
@@ -265,14 +265,6 @@ PEN_WAVE = """
page.setPointerCapture = capture;
"""
-# Clicks the host's "Co-host" box beside the named participant.
-TOGGLE_PERMISSION = """
- const find = () => [...document.querySelectorAll('#participants li')]
- .find((li) => li.textContent.includes(arguments[0])).querySelector('.allow');
- find().click();
- return String(find().checked); // The list is rebuilt by the click.
-"""
-
# Makes an image of the given size in pixels and puts it on the page, as
# the "Image" button does with a file. Arguments: width, height, kind
# ('png' or 'jpeg'), a resolution in dots per inch to record in the file,
@@ -799,7 +791,10 @@ class EndToEndTest(unittest.TestCase):
def test_browser_guest(self):
"""A guest with nothing installed: the host's invitation is a link to
- the Kokkoja web site, and the guest's notebook is in the browser."""
+ the Kokkoja web site, and the guest's notebook is in the browser.
+ Everyone has every button (2026-10-18): the guest puts an image on
+ the page, goes on without the host, and a newcomer to an empty
+ room starts page 1."""
firefox = self.firefox
today = time.strftime("%Y_%m_%d")
@@ -959,14 +954,11 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(picture.startswith(b"\x89PNG"))
self.assertIn("downloads", firefox.run("return document.getElementById('notice').textContent;"))
- # Allowed by the host, the guest puts an image on the page (the
- # file would come from the browser's own chooser).
- self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
- firefox.switch_to(host_tab)
- self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true")
- firefox.switch_to(guest_tab)
- self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
- "the guest to be allowed to start pages")
+ # The guest puts an image on the page (the file would come from the
+ # browser's own chooser); nobody's leave is needed (2026-10-18).
+ self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
+ self.assertFalse(firefox.run("return document.getElementById('load_image').hidden;"))
+ self.assertIsNone(firefox.run("return document.querySelector('#participants .allow');"), "no Co-host box")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
guest = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the guest's image")
firefox.switch_to(host_tab)
@@ -985,9 +977,34 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"])
self.assertEqual(len(reloaded["participants"]), 2)
- # The host leaves the session; the tabs are closed for the next test.
+ # The host leaves; the guest goes on, and starts a new page without
+ # anyone's leave. A newcomer to the room with nobody left in it
+ # has no page, and New gives page 1; between two different pages
+ # of one number the greater id wins, on every machine alike.
+ firefox.switch_to(host_tab)
+ firefox.go("about:blank")
+ firefox.switch_to(guest_tab)
+ self.wait_until(lambda s: [p["present"] for p in s["participants"]] == [True, False], "the host to be away")
+ firefox.run("document.getElementById('new_page').click();")
+ self.wait_until(lambda s: s["page"]["number"] == 2, "the guest's new page")
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
+ firefox.go(self.address("Late", join=urllib.parse.unquote(link.split("#join=")[1])))
+ self.enter_name("Latecomer", "join_session")
+ self.wait_until(lambda s: s["status"] == "Connected", "the latecomer to connect")
+ time.sleep(0.5)
+ self.assertEqual(self.state()["page"]["number"], 0)
+ self.assertIn("New starts one", firefox.run("return document.getElementById('notice').textContent;"))
+ firefox.run("document.getElementById('new_page').click();")
+ self.wait_until(lambda s: s["page"]["number"] == 1, "the latecomer's page 1")
+ tie = lambda id: firefox.run("""
+ const session = window.kokkoja.session;
+ session.considerPage({...session.page.descriptor(), id: arguments[0]}, 0);
+ return [session.page.number, session.page.id];
+ """, id)
+ own = firefox.run("return window.kokkoja.session.page.id;")
+ self.assertEqual(tie("-" * 22), [1, own], "a lesser id loses")
+ self.assertEqual(tie("z" * 22), [1, "z" * 22], "a greater id wins")
firefox.go("about:blank")
def test_tooltips(self):
@@ -1086,7 +1103,8 @@ class EndToEndTest(unittest.TestCase):
def test_hide_rectangles(self):
"""The teacher's requests of 2026-10-08: a Hide rectangle covers part
- of the page in white for everyone, marked by blue corners."""
+ of the page in white for everyone, marked by blue corners. Everyone
+ has the tool and may move or remove a Hide (2026-10-18)."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
@@ -1096,7 +1114,7 @@ class EndToEndTest(unittest.TestCase):
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
- self.assertTrue(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
+ self.assertFalse(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
firefox.switch_to(host_tab)
self.assertFalse(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
@@ -1121,8 +1139,9 @@ class EndToEndTest(unittest.TestCase):
self.assertFalse(white_at(hide[0] + hide[2] + 100, hide[1] + hide[3] + 100))
# The watcher sees it; hiding the host's layer on the watcher's
- # screen does not uncover it; the watcher can neither move nor
- # remove it.
+ # screen does not uncover it. The watcher moves it by a hold, as
+ # anyone may (2026-10-18), and takes the move back with Undo; the
+ # host sees both.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == made["hides"], "the Hide to reach the watcher")
firefox.run("""
@@ -1134,9 +1153,16 @@ class EndToEndTest(unittest.TestCase):
origin = self.state()["origin"]
firefox.run_until_done(PEN_HOLD, (hide[0] + 50 - origin[0]) * vscale, (hide[1] + 50 - origin[1]) * vscale,
100 * vscale, 0, 800)
- firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
- time.sleep(0.5)
- self.assertEqual(self.state()["hides"], made["hides"])
+ moved_by_watcher = [hide[0] + 100, *hide[1:]]
+ self.wait_until(lambda s: s["hides"] == [moved_by_watcher], "the Hide moved by the watcher")
+ firefox.switch_to(host_tab)
+ self.wait_until(lambda s: s["hides"] == [moved_by_watcher], "the watcher's move to reach the host")
+ firefox.switch_to(guest_tab)
+ firefox.run("document.getElementById('undo').click();")
+ self.wait_until(lambda s: s["hides"] == made["hides"], "the watcher's Undo")
+ firefox.switch_to(host_tab)
+ self.wait_until(lambda s: s["hides"] == made["hides"], "the Undo to reach the host")
+ firefox.switch_to(guest_tab)
# The host moves it by a hold, resizes it freely from a corner,
# and the file carries it above every layer.
@@ -1409,43 +1435,36 @@ class EndToEndTest(unittest.TestCase):
firefox.run("document.querySelector('[data-tool=pen]').click();")
scaled = erased_not
- # A guest given the right adds an image, keeps the power to remove it
- # after the right is withdrawn, and cannot remove the host's.
- self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "true")
+ # The guest adds an image and removes it with a tap of the eraser,
+ # nobody's leave being needed (2026-10-18).
firefox.switch_to(guest_tab)
- self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
- "the viewer to be allowed")
+ self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.run_until_done(LOAD_IMAGE, 200, 100, "png", 0, False)
added = self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image")
firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image to reach the host")
- self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "false")
firefox.switch_to(guest_tab)
- self.wait_until(lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
- "the right to be withdrawn")
own = added["images"][2]
firefox.run(ERASER_AT, own["x"] + 100, own["y"] + 50, 5)
self.wait_until(lambda s: len(s["images"]) == 2, "the viewer's own image removed")
- hosts = self.state()["images"][1]
- firefox.run(ERASER_AT, hosts["x"] + 50, hosts["y"] + 50, 5)
- time.sleep(0.5)
- self.assertEqual(len(self.state()["images"]), 2)
firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 2, "the removal to reach the host")
firefox.switch_to(guest_tab)
- # The viewer sees the same, and cannot move an image: a hold there
- # draws a dot instead.
- firefox.switch_to(guest_tab)
+ # The viewer sees the same, and moves the host's image by a hold,
+ # which draws no dot; the host sees the move.
viewer = self.wait_until(lambda s: s["images"] and rect(s, 1) == rect(scaled, 1), "the viewer's copy")
self.assertEqual(viewer["images"][1]["hash"], scaled["images"][1]["hash"])
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
+ viewer_strokes = len(viewer["layers"].get("Viewer", []))
firefox.run_until_done(PEN_HOLD, (viewer["images"][1]["x"] + 50 - viewer["origin"][0]) * vscale,
(viewer["images"][1]["y"] + 50 - viewer["origin"][1]) * vscale, 60 * vscale, 0, 800)
- time.sleep(0.5)
- after = self.state()
- self.assertEqual(rect(after, 1), rect(scaled, 1))
- self.assertEqual(len(after["layers"].get("Viewer", [])), 1)
+ after = self.wait_until(lambda s: s["images"][1]["x"] == scaled["images"][1]["x"] + 60, "the image moved by the viewer")
+ self.assertEqual(rect(after, 1)[1:], rect(scaled, 1)[1:])
+ self.assertEqual(len(after["layers"].get("Viewer", [])), viewer_strokes)
+ firefox.switch_to(host_tab)
+ self.wait_until(lambda s: s["images"][1]["x"] == scaled["images"][1]["x"] + 60, "the viewer's move to reach the host")
+ firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
@@ -1501,8 +1520,9 @@ class EndToEndTest(unittest.TestCase):
image (and, until 2026-10-14, every Hide and own text rectangle,
which carry blue corners at all times since), a drag inside one
moves it and a drag on a corner resizes it, at once, no hold; a
- press elsewhere does nothing. A guest without the right sees no
- cues, their own text rectangle's corners being drawn anyway."""
+ press elsewhere does nothing. A guest sees the same cues on the
+ image and the Hide (2026-10-18; none before), and one on their
+ own text."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
@@ -1640,17 +1660,17 @@ class EndToEndTest(unittest.TestCase):
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"])
- # The watcher, without the right, is shown no cue on the image or
- # the Hide, only on their own text.
+ # The watcher is shown the cues on the image and the Hide too, and
+ # on their own text.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange")
- self.assertEqual(cues(), [])
+ self.assertEqual(cues(), ["image", "hide"])
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine")
choose("arrange")
- self.assertEqual(cues(), ["text"])
+ self.assertEqual(cues(), ["image", "hide", "text"])
mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box")
mine = mine["texts"]["Watcher"][0]
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect")
@@ -2224,7 +2244,7 @@ class EndToEndTest(unittest.TestCase):
self.assertNotIn("Copy", names)
self.assertEqual(len(names), len(set(names)), "no two entries of one name")
self.assertIn("\nPen\nWrite on the page. Only your own writing can be erased.\n", manual)
- self.assertIn("\nFit to the window\nShow the whole page in the window.", manual)
+ self.assertIn("\nFit to the window\nShow the whole page in the window, on your screen only.", manual)
self.assertTrue(all(len(line) <= 80 for line in manual.split("\n")), "wrapped at 80")
self.assertTrue(any(len(line) > 60 for line in manual.split("\n")), "filled, not one word a line")
# Every tooltip is in it; those that differ only by a number (the
@@ -2460,7 +2480,6 @@ class EndToEndTest(unittest.TestCase):
"the student to receive the teacher's layer")
self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"])
teacher_first_page = {"Teacher": teacher["layers"]["Teacher"]}
- self.assertEqual([p["host"] for p in student["participants"]], [False, True])
# The student writes with a pen; the teacher sees the same strokes.
for fx in (0.15, 0.4, 0.65):
@@ -2545,12 +2564,11 @@ class EndToEndTest(unittest.TestCase):
self.assertIn("Already kept", firefox.run(
"return document.getElementById('notice').textContent;"))
- # Only the host can start a new page.
- self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
- firefox.switch_to(teacher_tab)
+ # Everyone has New (2026-10-18; the host alone until then).
self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
+ firefox.switch_to(teacher_tab)
- # The host starts a new page. Everyone moves to it, and everyone's
+ # The teacher starts a new page. Everyone moves to it, and everyone's
# old page is kept without anyone asking: the teacher's now, the
# student's already.
firefox.run("document.getElementById('new_page').click();")
@@ -2624,8 +2642,10 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(doubled["strokes"], before["strokes"])
firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104)
self.assertAlmostEqual(zoom()["width"], before["width"], delta=2)
- # The host's Fit and Normal scale reach the guest, who then fits the page to
- # the guest's own window; the guest's own Fit stays the guest's.
+ # Fit and Normal scale are each person's own (2026-10-18; the
+ # host's reached everyone from 2026-10-08 until then): the
+ # teacher's Fit leaves the student's zoom alone, and the student's
+ # Normal scale the teacher's.
firefox.switch_to(student_tab)
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
@@ -2634,12 +2654,13 @@ class EndToEndTest(unittest.TestCase):
guest_zoomed = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.switch_to(teacher_tab)
firefox.run("document.getElementById('zoom_fit').click();")
- firefox.switch_to(student_tab)
- self.wait_until_true(lambda: firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"),
- lambda v: v < guest_zoomed, "the guest's view to follow the host's Fit")
- firefox.run("document.getElementById('zoom_sheet').click();")
- firefox.switch_to(teacher_tab)
host_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
+ firefox.switch_to(student_tab)
+ time.sleep(0.5)
+ self.assertEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), guest_zoomed)
+ firefox.run("document.getElementById('zoom_sheet').click();")
+ self.assertNotEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), guest_zoomed)
+ firefox.switch_to(teacher_tab)
time.sleep(0.5)
self.assertEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), host_scale)
@@ -2679,7 +2700,7 @@ class EndToEndTest(unittest.TestCase):
self.assertGreater(wide_page["height"], teacher["page"]["height"])
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == wide_page, "the expanded page to reach the student")
- self.assertTrue(firefox.run("return document.getElementById('zoom_expand').hidden;"))
+ self.assertFalse(firefox.run("return document.getElementById('zoom_expand').hidden;"))
self.assertEqual(firefox.run("return window.kokkoja.session.page.baseLeft;"),
-((wide_page["width"] - teacher["page"]["width"]) // 2))
# The page file carries the sheet's edges and reads back with them.
@@ -3007,8 +3028,8 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(alone["page"], teacher["page"])
self.assertEqual(alone["image"], teacher["image"])
self.assertEqual(alone["participants"], [
- {"name": "Teacher", "host": True, "present": True},
- {"name": "Student", "host": False, "present": False}])
+ {"name": "Teacher", "present": True},
+ {"name": "Student", "present": False}])
firefox.run("document.getElementById('zoom_fit').click();") # The view came back scrolled.
firefox.drag("#page", 100, 500, 300, 520)
self.current_page("Teacher notebook", {"Teacher": 1, "Student": 1}, number=3)
@@ -3082,22 +3103,14 @@ class EndToEndTest(unittest.TestCase):
return [shown.naturalWidth, shown.naturalHeight];
"""), [2400, 1600])
- # The student joins the new session. The host lets the student
- # start pages, the student loads an image, and the host withdraws
- # the permission.
+ # The student joins the new session and starts a page with an
+ # image on it, as anyone may (2026-10-18).
invitation = firefox.run("return document.getElementById('invitation').value;")
firefox.switch_to(student_tab)
firefox.go(self.address("Student", join=invitation))
self.enter_name("Student", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 2 and s["image"] and s["image"]["ready"],
"the student to reach the large image page")
- self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
- firefox.switch_to(teacher_tab)
- self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "true")
- firefox.switch_to(student_tab)
- self.wait_until(
- lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
- "the student to be allowed to start pages")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True)
student = self.wait_until(lambda s: s["page"]["number"] == 3, "the student's image page")
self.assertEqual(student["page"], {"number": 3, "width": 2970, "height": 2100})
@@ -3106,14 +3119,7 @@ class EndToEndTest(unittest.TestCase):
lambda s: s["page"]["number"] == 3 and s["image"] and s["image"]["ready"],
"the student's image to reach the teacher")
self.assertEqual(teacher["image"], student["image"])
- self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "false")
firefox.switch_to(student_tab)
- self.wait_until(
- lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
- "the student's permission to be withdrawn")
- firefox.run("window.kokkoja.session.newPage();")
- time.sleep(1)
- self.assertEqual(self.state()["page"]["number"], 3)
# Writing past the edge of the page makes the page grow, for
# everyone, in steps of 5 mm.
@@ -3370,19 +3376,6 @@ class EndToEndTest(unittest.TestCase):
"""), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook")
firefox.run("document.getElementById('chooser_close').click();")
- # A guest has the chooser too, to look at and clear their own
- # notebook; a card tells them that loading is the host's.
- firefox.switch_to(student_tab)
- number = self.state()["page"]["number"]
- firefox.run("document.getElementById('load_notebook_page').click();")
- self.wait_until_true(lambda: firefox.run("""
- return document.querySelectorAll('#chooser_pages .page_card').length;
- """), lambda n: n >= 1, "the student's chooser to list kept pages")
- firefox.run("document.querySelector('#chooser_pages .page_card').click();")
- self.assertIn("Only the host and co-hosts", firefox.run("return document.getElementById('chooser_message').textContent;"))
- self.assertTrue(firefox.run("return document.getElementById('notebook_chooser').open;"))
- self.assertEqual(self.state()["page"]["number"], number)
- firefox.run("document.getElementById('chooser_close').click();")
firefox.switch_to(teacher_tab)
# A page that its images have made longer than a sheet is first
# shown at the scale at which one sheet fits the window.
@@ -3394,7 +3387,20 @@ class EndToEndTest(unittest.TestCase):
"the loaded page of three images to reach the student")
self.assertEqual(student["images"], stacked["images"])
self.assertEqual(firefox.run(VIEW), long_view)
+
+ # The student's chooser loads a kept page for everyone too
+ # (2026-10-18; a card refused until then): a new page, the
+ # teacher following.
+ number = student["page"]["number"]
+ firefox.run("document.getElementById('load_notebook_page').click();")
+ self.wait_until_true(lambda: firefox.run("""
+ return document.querySelectorAll('#chooser_pages .page_card').length;
+ """), lambda n: n >= 1, "the student's chooser to list kept pages")
+ firefox.run("document.querySelector('#chooser_pages .page_card').click();")
+ self.wait_until(lambda s: s["page"]["number"] == number + 1, "the page the student loaded")
+ self.assertFalse(firefox.run("return document.getElementById('notebook_chooser').open;"))
firefox.switch_to(teacher_tab)
+ self.wait_until(lambda s: s["page"]["number"] == number + 1, "the student's loaded page to reach the teacher")
# Five colours and four widths in the toolbar. "+" among the
# colours opens a palette of our own, in which the colours already
@@ -3557,9 +3563,10 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours)
firefox.run("document.querySelector('#participants input[type=checkbox]').click();")
- # A tap of the eraser on an image removes it, for whoever may make
- # pages or added the image; a sweep across it does not. Writing on
- # it stays; the sheet keeps its size.
+ # A tap of the eraser on an image removes it, by anyone (2026-10-18;
+ # the host, a co-host or whoever added it until then); a sweep
+ # across it does not. Writing on it stays; the sheet keeps its
+ # size.
firefox.run("document.getElementById('new_page').click();")
number = self.wait_until(lambda s: not s["images"], "another new page")["page"]["number"]
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
@@ -3571,18 +3578,19 @@ class EndToEndTest(unittest.TestCase):
firefox.switch_to(student_tab)
self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]),
"the two images to reach the student")
- # Not the student, who has no right and did not add it.
- firefox.run(ERASER_AT, 1050, 1500, 5)
+ # Not by moving the eraser across it: the student's sweep leaves it.
+ firefox.run(ERASER_AT, 1050, 1500, 100)
firefox.run(PEN_WAVE, 0.3, 0.5)
firefox.switch_to(teacher_tab)
self.wait_until(lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"],
"the student's writing on the image")
- # Not by moving the eraser across it.
- firefox.run(ERASER_AT, 1050, 1500, 100)
- time.sleep(0.3)
self.assertEqual(rectangles(self.state()), rectangles(two))
- # A tap by the teacher removes it, written on or not.
+ # A tap by the student, who did not add it, removes it, written on
+ # or not; the teacher sees it go.
+ firefox.switch_to(student_tab)
firefox.run(ERASER_AT, 1050, 1500, 5)
+ self.wait_until(lambda s: len(s["images"]) == 1, "the image removed by the student")
+ firefox.switch_to(teacher_tab)
one = self.wait_until(lambda s: len(s["images"]) == 1, "the image to be removed")
self.assertEqual(one["page"], {"number": number, "width": 2970, "height": 3250})
self.assertEqual(rectangles(one), [[1335, 50, 300, 400]])