From 72d27e1769a3d934c961f48a89519dc2f69fdc6b Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Fri, 9 Oct 2026 21:57:17 +0530 Subject: [PATCH 1/5] A #anchor of the page is no longer taken for a share link Any hash made the page a viewer of a session that did not exist, so the home page's own links (#languages, #repl, #workspace, #request) broke the terminal ("master terminal is not available") and gave the chat a wrong id. A hash is a share id only if it has the shape of a Firebase push key (a - and 12 to 30 of A-Za-z0-9_-; the SDK's keys are 20 long). The page script, the terminal client and the Genie widget read it once, when they load, so clicking an anchor later changes neither a master nor a viewer; a different share link pasted into an open page loads that session. The widget keeps its own copy of the pattern; a test compares the two. Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 2 +- docs/lld/06-frontend.md | 2 +- src/js/src/firetty.ts | 9 +- src/js/src/gotty.ts | 11 +- src/js/src/page/01-session.js | 26 +++-- src/js/src/share-id.ts | 24 ++++ src/js/test/share-id.test.mjs | 105 ++++++++++++++++++ src/resources/chat-widget/src/index.ts | 7 ++ .../chat-widget/test/share-id.test.ts | 29 +++++ 9 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 src/js/src/share-id.ts create mode 100644 src/js/test/share-id.test.mjs create mode 100644 src/resources/chat-widget/test/share-id.test.ts diff --git a/docs/README.md b/docs/README.md index 4fc68cf0..11975b29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,7 +51,7 @@ Go code uses a GOPATH layout: the repo root is `GOPATH`, packages live under `sr - **REPL**: an interactive interpreter (Read-Eval-Print Loop), for example `cling` or `python`. - **Master / slave (webtty)**: the *master* is the browser WebSocket; the *slave* is the PTY-backed process. -- **Master / slave (sharing)**: the *master* browser owns the real WebSocket session; a *slave* browser opened `…/#` and mirrors it through Firebase. +- **Master / slave (sharing)**: the *master* browser owns the real WebSocket session; a *slave* browser opened `…/#` (a Firebase push key; any other `#anchor` is just an anchor) and mirrors it through Firebase. - **jid**: an encoded PID of a running REPL (`encoder.EncodePID`). Used by **Fork REPL** and by extra terminal tabs to join that REPL's namespaces. - **optionchange / optionrun / optiondebug**: DOM events on a terminal element. They mean, respectively: switch language (start a fresh REPL), run the editor content, and run it in debug mode. - **Demo**: an entry in `demos.xml` describing one REPL (animation, usage, docs, starter code, Compiler script). diff --git a/docs/lld/06-frontend.md b/docs/lld/06-frontend.md index df44e300..04b960a0 100644 --- a/docs/lld/06-frontend.md +++ b/docs/lld/06-frontend.md @@ -101,7 +101,7 @@ flowchart LR ## 4. Live sharing (`FireTTY`) -A page is the **master** when the URL has no `#hash`. It is a **slave** (viewer) when it was opened as `…/#`. `dbpath` is the page's Firebase push key (`getExampleRef()`), and the *Share REPL* box shows `origin + path + search + "#" + dbpath`. +A page is the **master** unless it was opened with a share link. It is a **slave** (viewer) when it was opened as `…/#`. `dbpath` is the page's Firebase push key (`getExampleRef()`), and the *Share REPL* box shows `origin + path + search + "#" + dbpath`. Only a hash that has the shape of a push key counts (`js/src/share-id.ts`: `-` then 12 to 30 of `A-Za-z0-9_-`; the SDK's keys are 20 long and start with `-` until 2109). Any other hash is an anchor of the page (`#languages`, `#workspace`, `#request`), and the page stays a master: before this rule every hash made a viewer, so using the home page's own links broke the terminal ("master terminal is not available"). The id is read once, when each script loads (the page script, the terminal client and the Genie widget each have it), so clicking an anchor later changes neither a master nor a viewer; a different share link pasted into the address bar of an open page loads the page again. The widget keeps its own copy of the pattern, because it is built apart; `test/share-id.test.ts` compares the two. Tests: `src/js/test/share-id.test.mjs` (the rule, and the page script run with a pretend page). ```mermaid sequenceDiagram diff --git a/src/js/src/firetty.ts b/src/js/src/firetty.ts index ea8632be..028bdbbf 100644 --- a/src/js/src/firetty.ts +++ b/src/js/src/firetty.ts @@ -2,6 +2,7 @@ // instead of bundling a second copy (Firebase 3.9) into gotty-bundle.js (T16). declare var firebase: any; import { Terminal, eventHandler, eventhandlertype, CloserArgs} from "./webtty"; +import { shareIdFrom } from "./share-id"; var dbpath = ""; @@ -26,10 +27,10 @@ export const getExampleRef = () => { return window['dbpath']; } var ref = getrepl_firebasedbref(); - var hash = window.location.hash.replace(/#/g, ''); - if (hash) { - window['dbpath'] = hash; - return hash; + var shared = shareIdFrom(window.location.hash); + if (shared) { + window['dbpath'] = shared; + return shared; } ref = ref.push(); if (ref.key) { diff --git a/src/js/src/gotty.ts b/src/js/src/gotty.ts index a918affc..19c375a3 100644 --- a/src/js/src/gotty.ts +++ b/src/js/src/gotty.ts @@ -2,6 +2,7 @@ import { Xterm } from "./xterm"; import { Terminal, WebTTY, protocols, jidHandler, Icallback, WebTTYFactory, IdeLangKey, IdeContentKey, IdeFileNameKey, CompilerOptionKey, CompilerFlagsKey, EnvFlagsKey, CloserArgs} from "./webtty"; import { ConnectionFactory } from "./websocket"; import { FireTTY, DisableShareBtn } from "./firetty"; +import { shareIdFrom } from "./share-id"; const option2args = { "c":"arg=-xc&arg=-noruntime", @@ -19,13 +20,11 @@ export interface CustomHTMLElement extends HTMLElement { isprimary: boolean; // if this corressponds to primary terminal tab element } +// the share id this page was opened with, read once (share-id.ts): an anchor clicked later is not a new session +const shareId = shareIdFrom(window.location.hash); + function isMaster() : boolean { - var hash = window.location.hash.replace(/#/g, ''); - if (!hash) { - return true; - } else { - return false; - } + return shareId === ""; } function handleTerminalOptions(elem, option, event="optionchange") { diff --git a/src/js/src/page/01-session.js b/src/js/src/page/01-session.js index 60ac13ae..ea2b1185 100644 --- a/src/js/src/page/01-session.js +++ b/src/js/src/page/01-session.js @@ -4,6 +4,8 @@ // into js/scribbler.js. Top-level names are page globals (window.*), as they // were in the single file: index.html, palette.js and the other parts use them. +import { shareIdFrom } from "../share-id"; + window.handleClickOutside = handleClickOutside; window.ismob = ismob; window.isMaster = isMaster; @@ -13,15 +15,24 @@ window.preprocessurl = preprocessurl; window.homedir = ""; // home directory +// The share id of this page, if it was opened as /# (src/share-id.ts). It is +// read once: an anchor such as #languages that is clicked later changes the +// hash, not the session. A different share link pasted into the address bar of +// an open page is a different session, so the page loads again. +const SHARE_ID = shareIdFrom(window.location.hash); +window.addEventListener("hashchange", function () { + const now = shareIdFrom(window.location.hash); + if (now && now !== SHARE_ID) window.location.reload(); +}); + window.getExampleRef = () => { if(window.dbpath) { return window.dbpath; } var ref = firebase.database().ref("openrepl"); - var hash = window.location.hash.replace(/#/g, ''); - if (hash) { - window.dbpath = hash; - return hash; + if (SHARE_ID) { + window.dbpath = SHARE_ID; + return SHARE_ID; } ref = ref.push(); if (ref.key) { @@ -71,12 +82,7 @@ function ismob() { } function isMaster() { - var hash = window.location.hash.replace(/#/g, ''); - if (!hash) { - return true; - } else { - return false; - } + return SHARE_ID === ""; // a viewer was given a share link; an anchor is not one } window.option2cmdMap = { diff --git a/src/js/src/share-id.ts b/src/js/src/share-id.ts new file mode 100644 index 00000000..10af9d35 --- /dev/null +++ b/src/js/src/share-id.ts @@ -0,0 +1,24 @@ +// What in the address of a page is a share id. +// +// A shared session is opened as https://openrepl.com/#, where is the +// Firebase push key of the owner's page: 20 characters, the first of them "-" +// (8 of time and 12 random from the alphabet - 0-9 A-Z _ a-z; the SDK checks +// the length, and the first one stays "-" until the year 2109). The accepted +// length is a little wider than 20, so that a change of the SDK's key could not +// stop sharing. +// +// Anything else after the # is only an anchor of the page: #languages, +// #workspace, #request. It must not turn the visitor into a viewer of a session +// that does not exist, which is what "any hash is a share id" did. +// +// The same rule is in chat-widget/src/index.ts (its own bundle); a test there +// compares the two patterns. Callers read the id once, when their script loads, +// and keep it: clicking an anchor later changes the hash, not the session. + +export const SHARE_ID_PATTERN = /^-[A-Za-z0-9_-]{12,30}$/; + +// The share id in a location.hash ("#-OJVdrF-2UPpWUafqBoU"), or "" if there is none. +export function shareIdFrom(hash: string): string { + const id = String(hash || "").replace(/^#/, ""); + return SHARE_ID_PATTERN.test(id) ? id : ""; +} diff --git a/src/js/test/share-id.test.mjs b/src/js/test/share-id.test.mjs new file mode 100644 index 00000000..3104141e --- /dev/null +++ b/src/js/test/share-id.test.mjs @@ -0,0 +1,105 @@ +// Which part of the address is a share id (src/share-id.ts), and what the page +// script does with it (src/page/01-session.js). The rule is TypeScript, so it is +// compiled here with the compiler the bundle is built with. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const js = (file) => + ts.transpileModule(fs.readFileSync(path.join(here, "..", "src", file), "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2020, target: ts.ScriptTarget.ES2017 } }).outputText; +const { shareIdFrom, SHARE_ID_PATTERN } = await import("data:text/javascript;base64," + Buffer.from(js("share-id.ts")).toString("base64")); + +// keys of the Firebase SDK's own generator (firebase-database.js), run with the clock of 2014, 2017, 2025 and now +const REAL = ["-JOJiRV-a8RYmm_zDNlx", "-KeGSp--p2fz1h5yyPnM", "-OJVdrF-2UPpWUafqBoU", "-P3WCLWG1hm_ZMkP2Al3"]; + +test("a share link is the Firebase push key after the #", () => { + for (const id of REAL) { + assert.equal(shareIdFrom("#" + id), id); + assert.equal(shareIdFrom(id), id, "location.hash always has the #, but the rule does not need it"); + } +}); + +test("the anchors of the page are not share links", () => { + for (const anchor of ["#languages", "#repl", "#workspace", "#request", "#faq", "#", "", "#-", "#-Nx3kQ7"]) { + assert.equal(shareIdFrom(anchor), "", anchor); + } +}); + +test("anything that is not the shape of a key is left out", () => { + for (const bad of ["#-OJVdrF-2UPpWUafqBoU?x=1", "#-OJVdrF-2UPpWUafqBoU/", "# -OJVdrF-2UPpWUafqBoU", "#--" + "a".repeat(40), "#OJVdrF-2UPpWUafqBoU", "#-ab cd"]) { + assert.equal(shareIdFrom(bad), "", bad); + } + assert.equal(shareIdFrom(null), ""); + assert.equal(shareIdFrom(undefined), ""); + assert.ok(SHARE_ID_PATTERN.test("-" + "x".repeat(12)) && !SHARE_ID_PATTERN.test("-" + "x".repeat(11)) && !SHARE_ID_PATTERN.test("-" + "x".repeat(31))); +}); + +// ---- the page script, run with a pretend page ---------------------------------- + +function page(hash) { + const listeners = {}; + const location = { hash, reloads: 0, reload() { this.reloads++; } }; + const window = { location, innerWidth: 1200, innerHeight: 800, addEventListener: (t, f) => (listeners[t] = f) }; + const source = fs + .readFileSync(path.join(here, "..", "src", "page", "01-session.js"), "utf8") + .replace(/^import \{ shareIdFrom \} from "..\/share-id";$/m, "") + .replace(/^export \{\};.*$/m, ""); + const ctx = { + window, + shareIdFrom, + firebase: { database: () => ({ ref: () => ({ push: () => ({ key: "-NEWKEYFROMPUSH12345" }) }) }) }, + document: {}, + $: () => ({ on() {} }), + console, + }; + vm.createContext(ctx); + vm.runInContext(source, ctx); + return { window, location, change: (h) => { location.hash = h; listeners.hashchange && listeners.hashchange(); } }; +} + +test("a page with no hash, or with an anchor, is the master and makes its own session", () => { + for (const hash of ["", "#languages", "#workspace", "#request", "#repl"]) { + const p = page(hash); + assert.equal(p.window.isMaster(), true, "hash " + JSON.stringify(hash)); + assert.equal(p.window.getExampleRef(), "-NEWKEYFROMPUSH12345", "it must start a session of its own, not read the anchor"); + } +}); + +test("a page opened with a share link is a viewer of that session", () => { + const p = page("#" + REAL[2]); + assert.equal(p.window.isMaster(), false); + assert.equal(p.window.getExampleRef(), REAL[2]); +}); + +test("clicking an anchor does not change what the page is", () => { + const master = page(""); + master.change("#languages"); + assert.equal(master.window.isMaster(), true); + assert.equal(master.location.reloads, 0); + + const viewer = page("#" + REAL[0]); + viewer.change("#languages"); + assert.equal(viewer.window.isMaster(), false, "the viewer's session must not turn into a master's"); + assert.equal(viewer.window.getExampleRef(), REAL[0]); + assert.equal(viewer.location.reloads, 0); +}); + +test("another share link pasted into the address bar loads that session", () => { + const viewer = page("#" + REAL[0]); + viewer.change("#" + REAL[1]); + assert.equal(viewer.location.reloads, 1); + const master = page(""); + master.change("#" + REAL[3]); + assert.equal(master.location.reloads, 1); + // the same link again is not news + const same = page("#" + REAL[0]); + same.change("#" + REAL[0]); + assert.equal(same.location.reloads, 0); +}); diff --git a/src/resources/chat-widget/src/index.ts b/src/resources/chat-widget/src/index.ts index f05190d3..d0f39fbf 100644 --- a/src/resources/chat-widget/src/index.ts +++ b/src/resources/chat-widget/src/index.ts @@ -301,6 +301,13 @@ let peerchatSwitchlistener = (e: Event) => { console.log('PeerChat switch is OFF'); peerchatmode=false; } +// A page is a viewer only when it was opened with a share link, /#. +// An anchor of the page (#languages, #workspace) is not one. The same pattern is in +// js/src/share-id.ts, which has the reasons; test/share-id.test.ts compares the two. +// Read once: clicking an anchor later changes the hash, not the session. +const SHARE_ID_PATTERN = /^-[A-Za-z0-9_-]{12,30}$/; +const sharedSession = SHARE_ID_PATTERN.test(window.location.hash.replace(/^#/, "")); + refreshChip(); if (chatfirebasedbref) { // push event to firebase db diff --git a/src/resources/chat-widget/test/share-id.test.ts b/src/resources/chat-widget/test/share-id.test.ts new file mode 100644 index 00000000..6a46bdc4 --- /dev/null +++ b/src/resources/chat-widget/test/share-id.test.ts @@ -0,0 +1,29 @@ +// node --test test/ (from src/resources/chat-widget; Node 22.18 or later runs TypeScript as it is) +// +// The widget keeps its own copy of the rule that tells a share link (/#) from an anchor of the page (/#languages), because it is built apart +// from the page script (js/src/share-id.ts). They must not drift. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const patternIn = (file: string) => { + const m = fs.readFileSync(file, "utf8").match(/SHARE_ID_PATTERN\s*=\s*(\/\^[^\n]*?\$\/)/); + assert.ok(m, "no SHARE_ID_PATTERN in " + file); + return m![1]; +}; + +test("the widget and the page script use the same rule for a share id", () => { + const widget = patternIn(path.join(here, "..", "src", "index.ts")); + const page = patternIn(path.join(here, "..", "..", "..", "js", "src", "share-id.ts")); + assert.equal(widget, page); +}); + +test("the widget's rule takes a share link and leaves the anchors of the page", () => { + const rule = new RegExp(patternIn(path.join(here, "..", "src", "index.ts")).slice(1, -1)); + for (const id of ["-JOJiRV-a8RYmm_zDNlx", "-KeGSp--p2fz1h5yyPnM", "-OJVdrF-2UPpWUafqBoU", "-P3WCLWG1hm_ZMkP2Al3"]) assert.ok(rule.test(id), id); + for (const anchor of ["languages", "repl", "workspace", "request", "faq", "", "-Nx3kQ7", "-" + "a".repeat(40)]) assert.ok(!rule.test(anchor), anchor); +}); From aa4e81db5d30e9669e2b94e3a49e406c23000fae Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Fri, 9 Oct 2026 21:57:53 +0530 Subject: [PATCH 2/5] Peer chat: a name for each person, and a note when someone joins or leaves Everybody in a peer chat is user-xxxxx, kept per browser (localStorage), so a reload or another tab is the same person; each tab has its own id so that two tabs of one browser still show each other's messages. What is on screen depends on who looks: your own messages are the light bubble on the right ("You . user-xxxxx"), everybody else's are on the left under their name. Before, the owner's messages were plain text and viewers' were bubbles, so with three people nobody could tell who said what. Small notes say who joined, left or turned peer chat on. A viewer writes a join event when it opens a share link and a leave event on pagehide (best effort); the owner writes none, so a page nobody shares leaves nothing in the database. The messages Genie mirrors to viewers are shown as before. Co-Authored-By: Claude Opus 5.5 --- docs/lld/07-ai-features.md | 1 + src/resources/chat-widget/src/index.ts | 123 ++++++++++++++++++++--- src/resources/chat-widget/src/widget.css | 25 +++++ src/resources/knowledge/genie.md | 2 +- 4 files changed, 137 insertions(+), 14 deletions(-) diff --git a/docs/lld/07-ai-features.md b/docs/lld/07-ai-features.md index 66bba8fa..d3cb27a5 100644 --- a/docs/lld/07-ai-features.md +++ b/docs/lld/07-ai-features.md @@ -95,6 +95,7 @@ Genie can work on a task in steps in the editor. Full design and decisions: `doc - **A closed panel** (`index.ts`, `renderActivity`): closing the panel hides it and stops nothing. A chat request or a task goes on (the message list and the task card are kept outside the panel's markup, which is built again on each open), and the two Ask Genie buttons (`[data-chat-widget-button]`, `#genie-button`) get `data-genie-state`: `working` (the icon turns; the floating one reads "Genie is working… 3/8" or "Genie is thinking…"), `needs` (amber dot: a permission question or a diff is open, and the task waits for it), `done` or `failed` (green or red dot: a result came while the panel was closed, cleared when it is opened). While the panel is closed the tab title carries the same words ("(1) Task done · …") and a hidden `role=status` element says them to a screen reader. The state is worked out each time from four facts (`needsYou`, `agentRunner.running`, `chatInFlight`, `unread`), which the runner reports through three optional host calls: `attention(on)`, `progress(n, max)` and `ended("done" | "failed" | "stopped")`. On open, the send button is set from the same facts. Only Stop, a reload or leaving the page ends a task; the server's limits (steps, 15 minutes, tasks an hour) are unchanged. - **The REPL guide** (`server/repl_guide.go`): every step names the REPL of the language in use (`repl`, the name the page asks `/demo?q=` for), and `agentBody` puts a second system message after the protocol: the REPL's prompt, its commands, an example session and, where there are any, notes on its habits. All of it comes from the REPL's entry in `resources/meta/demos.xml` (`Codes`, `Usage`, and `Notes`, which is for Genie only and is not in the `/demo` answer), so a REPL added there has a guide, and the text is never the page's: a name without a demo adds nothing, and `repl` is not sent on. gointerpreter has notes (a block runs only with `:r`, `:r` runs the session and not the editor, `:c` after an error), written after a model was seen restarting the terminal and typing `:r` where a line of Go was wanted. A guide is at most 3,600 characters. Chat mode does not get it; the note `knowledge/go-repl.md` answers a user's question there. - **What is left** (`server/usage.go`, `index.ts` `renderUsage`): `GET /chat/usage` and the `X-OpenREPL-Usage` header of every answer of `/chat/completions` carry the same JSON: `left`, `cap`, `perMinute`, `unlimited` (an admin), `signedIn`, and for a signed-in user where agent mode is on `agent: {used, perHour, nextFreeSeconds, maxSteps}`. Asking saves no cookie and charges nothing (`cookie.OpenApiRequestBalance` works the recharge out without saving it; `agentLedger.started` counts the hour's tasks). The panel asks when it opens and reads the header after each answer and each agent step; between them it counts the recharge itself, once a second. It shows one line under the composer ("52 of 60 requests left"; in Agent mode "17 of 20 tasks left this hour"; amber when few are left; "No requests left · next in 40 s" in red at zero; "No limit (admin)"), with the details behind a click, and it is hidden in peer chat. At zero the send button is dimmed and a submit is not sent (the line flashes and the text stays in the box), so the visitor does not meet the proxy's 429. The count shown is the balance rounded up, because the proxy takes a request while the balance is above 0. +- **Peer chat** (`index.ts`: `peerName`, `peerNote`, `PeerSpeaker`, `setupFBListener`). With the Peer chat switch on, the people in a shared session talk to each other through Firebase `chat-list/` instead of asking Genie. Each person is `user-<5 hex>` (`peerId()`, kept in `localStorage` `genie-peer-id`, so the name is the same in every tab and visit of a browser; the same id prefixes their questions to Genie), and each tab also has its own `TAB` id so that two tabs of one browser still show each other's messages (a message is skipped only when `tab` is this tab's; an older page that sends no `tab` is skipped by `uid`). What is on screen depends on who looks: your own messages are the light bubble on the right, labelled "You · user-xxxxx", everybody else's are on the left under their name. A message typed in a peer chat is stored with `peer: true` and `name`; the rest of the mirrored conversation (the owner's Genie questions and its answers) has no `peer` flag and is shown as it always was. Small notes (`.chat-widget__peer-note`) say "user-xxxxx joined the chat", "... left the chat", "... turned peer chat on" (and, to the person who turned it on, "Peer chat is on. You are user-xxxxx."). A viewer who opens a share link writes a `join` event once the listener is up (and tells itself "You joined the chat as ..."), and a `leave` event on `pagehide`/`unload` (best effort: the page may be gone before it is sent; a page restored from the back/forward cache joins again). The owner writes no join event, so a page nobody shares leaves nothing in the database, and says who it is with its first message; the owner's `unload` still removes the whole `chat-list/` entry. Late joiners get the history replayed, notes included. - **Admin:** the Genie settings (`agentDisabled`, `agentOnPractice`, `agentTasksPerHour`, `agentMaxSteps`) are in the Agent mode card of `/admin` (LLD 13) and in `settings.js` (`agentEnabled`, true unless an admin switched it off and only while Genie itself is on, `agentOnPractice`, `agentMaxSteps`). - **Tests:** `agent_test.go` (the ledger: steps, tokens, expiry, the hourly count, giving a step back; the proxy: guest, switched off, practice, charging only the first step, the model failing, admins, the settings and the audit log; and a test that compares the action list with `agent-protocol.ts`); `test/agent-protocol.test.ts` in the chat widget (`npm test` there; Node 22.18 or later), for what the page accepts from the model. diff --git a/src/resources/chat-widget/src/index.ts b/src/resources/chat-widget/src/index.ts index d0f39fbf..7c28b710 100644 --- a/src/resources/chat-widget/src/index.ts +++ b/src/resources/chat-widget/src/index.ts @@ -51,8 +51,34 @@ function fetchEditorContent(): string { return ""; } +// Who said a message in a peer chat: the name the others see, and whether it is this person. +type PeerSpeaker = { name: string; mine: boolean }; + let chatfirebasedbref: firebase.database.Reference | null = null; -const UID = generateFiveCharUUID(); +// The id of this person in peer chat (and in the questions they send Genie): five +// characters, one for each browser: every tab and every visit of it is the same +// person, so the name the others see does not change. Where storage is blocked +// it is new on each load. +function peerId(): string { + try { + const kept = localStorage.getItem("genie-peer-id"); + if (kept && /^[0-9a-f]{5}$/.test(kept)) return kept; + } catch (e) { + // a new one below + } + const id = generateFiveCharUUID(); + try { + localStorage.setItem("genie-peer-id", id); + } catch (e) { + // not kept + } + return id; +} +const UID = peerId(); +// This tab. Two tabs of one browser are one person, but each has to be told what the other one said. +const TAB = generateFiveCharUUID(); +// what the others in a peer chat call this person +const peerName = (uid: string) => "user-" + uid; let peerchatmode: boolean = false; export type WidgetConfig = { @@ -275,18 +301,42 @@ function addMessageToHistory(role: string, content: string, uid: string=UID): vo } } +// A page is a viewer only when it was opened with a share link, /#. +// An anchor of the page (#languages, #workspace) is not one. The same pattern is in +// js/src/share-id.ts, which has the reasons; test/share-id.test.ts compares the two. +// Read once: clicking an anchor later changes the hash, not the session. +const SHARE_ID_PATTERN = /^-[A-Za-z0-9_-]{12,30}$/; +const sharedSession = SHARE_ID_PATTERN.test(window.location.hash.replace(/^#/, "")); + function isMaster() : boolean { - var hash = window.location.hash.replace(/#/g, ''); - if (!hash) { - return true; - } else { - return false; - } + return !sharedSession; +} + +// A viewer going away says so. The page may be gone before it is sent, so it is +// tried on pagehide (which browsers still run) as well as on unload, once; a page +// that comes back from the back/forward cache joins again (pageshow). +let toldLeft = false; +function sayLeft() { + if (toldLeft || !sharedSession || !chatfirebasedbref) return; + toldLeft = true; + try { + chatfirebasedbref.push({ eventT: "leave", uid: UID, tab: TAB, name: peerName(UID), timestamp: Date.now() }); + } catch (e) { + // nobody to tell + } } +window.addEventListener("pagehide", sayLeft); +window.addEventListener("pageshow", (e: PageTransitionEvent) => { + if (!e.persisted || !toldLeft || !sharedSession || !chatfirebasedbref) return; + toldLeft = false; + chatfirebasedbref.push({ eventT: "join", uid: UID, tab: TAB, name: peerName(UID), timestamp: Date.now() }); +}); let cleanup = () => { if (isMaster() && chatfirebasedbref) { chatfirebasedbref.remove(); + } else { + sayLeft(); } console.log("cleanup done."); }; @@ -315,8 +365,11 @@ const sharedSession = SHARE_ID_PATTERN.test(window.location.hash.replace(/^#/, " eventT: "peerchatmode", val: peerchatmode, uid: UID, + tab: TAB, + name: peerName(UID), }); } + if (peerchatmode) peerNote(`Peer chat is on. You are ${peerName(UID)}.`); } else { console.error("peerchatSwitchlistener: an unexpected error occurred: null element."); } @@ -337,24 +390,42 @@ const setupFBListener = () => { return; } let d = data.val(); - if (d.uid==UID) { + // what this very tab sent is already on its screen (an older page sends no tab: then by person) + if (d.tab ? d.tab === TAB : d.uid == UID) { console.log("chat event triggered by me only, skipping.."); return; } + const me = d.uid == UID; // another tab of this browser: the same person + if (d.eventT === "join" || d.eventT === "leave") { + if (me) return; // not news + peerNote(`${d.name || peerName(d.uid)} ${d.eventT === "join" ? "joined" : "left"} the chat`, d.timestamp); + return; + } if (d.eventT && d.eventT=="peerchatmode") { console.log("received an peerchatmode event: ", d); // its a event message peerchatmode=d.val; + if (!me) peerNote(`${d.name || peerName(d.uid)} turned peer chat ${d.val ? "on" : "off"}`, d.timestamp); refreshChip(); const peerchatSwitchElem = document.getElementById("peerchat-switch") as HTMLInputElement; if (peerchatSwitchElem) { peerchatSwitchElem.checked=peerchatmode; } } else { - createNewMessageEntry(d.message, d.timestamp, d.from, true); + // a message typed in a peer chat has the sender's name; the rest (what the owner asked Genie, and its answers) is shown as it was + const speaker: PeerSpeaker | null = d.peer ? { name: d.name || peerName(d.uid), mine: me } : null; + createNewMessageEntry(d.message, d.timestamp, d.from, true, "", null, speaker); addMessageToHistory(d.from, d.message, d.uid); // remote uid needed here as its not my chat } }); + // A viewer who opened a share link joins the chat: the others are told, and + // the viewer is told who they are. The owner is in it from the start, and + // says so with the first message. (Nothing is written for a page that + // nobody shares: that would leave an entry behind for every visit.) + if (sharedSession) { + peerNote(`You joined the chat as ${peerName(UID)}.`); + chatfirebasedbref.push({ eventT: "join", uid: UID, tab: TAB, name: peerName(UID), timestamp: Date.now() }); + } } catch(error) { console.error("Error setup firebase handle: ", error); } @@ -1756,13 +1827,17 @@ async function createNewMessageEntry( from: "system" | "user", skipdbpush: boolean = false, meta: string = "", - used: ContextNote[] | null = null + used: ContextNote[] | null = null, + peer: PeerSpeaker | null = null ) { message = message.trim(); //console.log("message: ", message) if (!skipdbpush && chatfirebasedbref) { // push to firebase db first chatfirebasedbref.push ({ + // a message typed in a peer chat says so, and who from, so that the others can show the name + ...(peer && peer.mine ? { peer: true, name: peerName(UID) } : {}), + tab: TAB, message: message, timestamp: timestamp, from: from, @@ -1771,10 +1846,21 @@ async function createNewMessageEntry( } + // In a peer chat what is on screen depends on who is looking, not on who owns + // the session: your own messages are the bubble on the right, everybody + // else's are on the left under their name. + const shown: "system" | "user" = peer ? (peer.mine ? "user" : "system") : from; const messageElement = document.createElement("div"); messageElement.classList.add("chat-widget__message"); - messageElement.classList.add(`chat-widget__message--${from}`); - messageElement.id = `chat-widget__message--${from}--${timestamp}`; + messageElement.classList.add(`chat-widget__message--${shown}`); + messageElement.id = `chat-widget__message--${shown}--${timestamp}`; + if (peer) { + messageElement.classList.add("chat-widget__message--peer"); + const who = document.createElement("p"); + who.classList.add("chat-widget__peer-name"); + who.textContent = peer.mine ? `You · ${peer.name}` : peer.name; + messageElement.appendChild(who); + } const messageText = document.createElement("div"); messageText.classList.add("chat-widget__message-text"); @@ -1805,6 +1891,17 @@ async function createNewMessageEntry( messagesHistory.prepend(messageElement); } +// A small line in the conversation that is not a message: someone joined or left +// the peer chat, turned it on or off. +function peerNote(text: string, timestamp: number = Date.now()) { + const note = document.createElement("div"); + note.classList.add("chat-widget__peer-note"); + note.setAttribute("role", "status"); + note.id = `chat-widget__peer-note--${timestamp}-${messagesHistory.children.length}`; + note.textContent = text; + messagesHistory.prepend(note); +} + const handleErrorResponse = async (errData: any) => { chatFailed = true; console.error("Chat Widget: Server error: ", errData); @@ -1994,7 +2091,7 @@ async function submit(e: Event) { } addMessageToHistory(myrole, msg); - await createNewMessageEntry(msg, Date.now(), myrole); + await createNewMessageEntry(msg, Date.now(), myrole, false, "", null, peerchatmode ? { name: peerName(UID), mine: true } : null); target.reset(); autoGrow((target.elements as any).message as HTMLTextAreaElement); if (peerchatmode) { diff --git a/src/resources/chat-widget/src/widget.css b/src/resources/chat-widget/src/widget.css index 14a7310a..775dae7c 100644 --- a/src/resources/chat-widget/src/widget.css +++ b/src/resources/chat-widget/src/widget.css @@ -397,6 +397,31 @@ color: var(--cw-on-accent); background: var(--cw-accent); } +/* Peer chat: who said it, and the small notes (someone joined, left, turned it on) */ +.chat-widget__peer-name { + margin: 0; + font-size: 11.5px; + font-weight: 500; + color: var(--cw-muted); +} +.chat-widget__message--peer.chat-widget__message--system .chat-widget__message-text { + color: var(--cw-text); + background: var(--cw-chip-bg); + border-radius: 12px 12px 12px 4px; + padding: 8px 12px; +} +.chat-widget__peer-note { + align-self: center; + max-width: 90%; + padding: 3px 10px; + font-size: 11.5px; + line-height: 1.4; + text-align: center; + color: var(--cw-muted); + background: var(--cw-chip-bg); + border-radius: 999px; + animation: cw-fade 0.25s ease-out; +} .chat-widget__message-meta { margin: 4px 0 0; font-size: 11.5px; diff --git a/src/resources/knowledge/genie.md b/src/resources/knowledge/genie.md index 0e741c9a..93cdbddd 100644 --- a/src/resources/knowledge/genie.md +++ b/src/resources/knowledge/genie.md @@ -3,4 +3,4 @@ title: Genie, the AI helper keywords: genie, ai, assistant, chat, help, ask, explain, fix, suggest, how to use genie, use genie, what can genie do, getting started with genie, model, models, effort, thinking, luna, gpt, gemma, openai, openrouter, ctrl k, requests, requests left, pin, dock, side bar, usage, limit, quota, close the panel, background, notification, answer ready link: /privacy.html --- -Genie is OpenREPL's AI helper. Open it with the Ask Genie button or Ctrl+K and type a question. It reads your editor code and the latest terminal output, so you can ask why something fails without pasting anything. Choose the model with the chip beside Send: GPT-6 Luna, which thinks first and has an effort setting, GPT-4o mini, which is quick, or Gemma 4 31B through OpenRouter. Other models, some of them free, can appear in the list when the site owner adds them. Each message uses one request; signed-in users get more. A line under the message box shows how many requests you have left and when they refill. Snippets in answers have Insert and Replace buttons. Right-click selected text to explain it, fix it, comment it or write tests. Signed-in users can switch from Chat to Agent mode. You can close the panel while Genie answers or works on an Agent task: it carries on, and the Ask Genie button shows when it is done or needs you. The pin in Genie's header docks it beside the editor. Genie is optional. Do not paste passwords or keys into it. +Genie is OpenREPL's AI helper. Open it with the Ask Genie button or Ctrl+K and type a question. It reads your editor code and the latest terminal output, so you can ask why something fails without pasting anything. Choose the model with the chip beside Send: GPT-6 Luna, which thinks first and has an effort setting, GPT-4o mini, which is quick, or Gemma 4 31B through OpenRouter. Other models, some of them free, can appear in the list when the site owner adds them. Each message uses one request; signed-in users get more. A line under the message box shows how many requests you have left and when they refill. Snippets in answers have Insert and Replace buttons. Right-click selected text to explain it, fix it, comment it or write tests. Signed-in users can switch from Chat to Agent mode. You can close the panel while Genie answers or works on an Agent task: it carries on, and the Ask Genie button shows when it is done or needs you. The pin in Genie's header docks it beside the editor. In a shared session, Peer chat lets the people in it talk to each other; each one is shown as user-xxxxx. Genie is optional. Do not paste passwords or keys into it. From 9ca52cc37992ac7b59447eee11f9e587b79c2776 Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Fri, 9 Oct 2026 21:59:54 +0530 Subject: [PATCH 3/5] Practice DSA: the starter questions are loaded, the list is paged, and the home page says what it is The 312 titles of js/dsa.json (301 distinct) are put in a visitor's list the first time a practice page loads: ordinary questions with the id s-, sorted after what the visitor generated, not counted in the 100 generated questions a visitor keeps, not sent to the account until opened (their done marks and deletions are), and not brought back once deleted. The editor waits for them, so a link to a starter works on a first visit. The list shows 10, 20, 40 or 100 rows to a page, with the range and page buttons under it; a search, a filter, the sort or the size goes back to page 1. The menu link is Practice DSA, and the home card says what the page is: 300+ problems and a coach that gives hints and never just the answer. Co-Authored-By: Claude Opus 5.5 --- docs/lld/06-frontend.md | 4 + src/js/test/practice-seed.test.mjs | 162 ++++++++++++++++++++++++++++ src/resources/about.html | 2 +- src/resources/css/ui-refresh.css | 46 ++++++++ src/resources/index.html | 9 +- src/resources/js/practice-store.js | 122 +++++++++++++++++++-- src/resources/js/scribbler-misc.js | 84 +++++++++++++-- src/resources/knowledge/practice.md | 2 +- src/resources/practice.html | 21 +++- src/server/knowledge_test.go | 1 + 10 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 src/js/test/practice-seed.test.mjs diff --git a/docs/lld/06-frontend.md b/docs/lld/06-frontend.md index 04b960a0..558ff7a1 100644 --- a/docs/lld/06-frontend.md +++ b/docs/lld/06-frontend.md @@ -168,6 +168,10 @@ It uses no jQuery, DataTables, select2 or Font Awesome. **Store** (`js/practice-store.js`, `window.PracticeStore`), used by the list and the editor: `all()`, `find(id or slug)`, `isDone`, `setDone`, `add` (what `saveNewQuestions` now calls), `update` (sets `updated`), `remove` (leaves a tombstone), `init()` (starts account sync), `status()`, `onChange(fn)`. Storage keys and sync: LLD 05. Another tab's changes arrive through the `storage` event. +**Starter questions** (`js/dsa.json`, `seed()` in `practice-store.js`). The file lists about 300 DSA problems as `{title, topic, difficulty}`. When either practice page loads, `seed()` fetches it and adds to the visitor's `questions` list every problem that is not there yet: id `s-`, `starter: true`, a readable name ("Best Time to Buy and Sell Stock II"), no description or code, and `added` counting down from 1 Jan 2025 so that they sort after anything generated, in the file's order. They are ordinary entries, so the pages that read `localStorage.questions` directly (the editor, `getCodeTemplate`) find them; a description and code are generated when one is first opened, as for any question. Starters are not counted in the 100 generated questions a visitor keeps (`trimOwn`), and one nobody has opened is not sent to the account (`untouched`); its done mark and a deletion are, in `practiceState`, which is also what keeps a deleted starter from coming back. A problem added to the file later reaches existing visitors on their next visit. The editor waits for `PracticeStore.ready()` before it looks for its question, so a link to a starter works on a first visit. Tests: `src/js/test/practice-seed.test.mjs`. + +**Pages of the list** (`scribbler-misc.js`: `render`, `renderPager`, `pageNumbers`; `#q-pager` in `practice.html`). The list shows one page of rows at a time: 10, 20 (the default), 40 or 100 (`#f-size`, kept with the filters in `practiceFilters`). Under the table are the size, the range ("21–40 of 301", or "301 of 301") and Prev, page numbers and Next, with the first and last page always shown, a window around the current one and `…` for the gaps; the current page has `aria-current="page"`. A search, a filter, the sort or the size takes the list back to page 1; a tick or a delete keeps the page (and moves back to the last one if a delete left fewer). After a page button is pressed the keyboard stays on Prev, Next or the current page, which are drawn again, and the top of the list scrolls into view. With 10 rows or fewer the pager is hidden. On a phone the page buttons wrap under the size. + **New question dialog** (`#modal`, same markup on both pages). A topic text field with suggestions from `topics` (a ``, so any topic can be typed), level, creativity (the LLM temperature) and extra instructions. `practice-store.js` moves focus into it, keeps Tab inside, closes it on Esc and returns focus, and shows "Generating…" on the button while `common.js` works. **In the editor** (`/practice?&name=`), `index.html` adds: diff --git a/src/js/test/practice-seed.test.mjs b/src/js/test/practice-seed.test.mjs new file mode 100644 index 00000000..3c64b10c --- /dev/null +++ b/src/js/test/practice-seed.test.mjs @@ -0,0 +1,162 @@ +// The starter questions of the practice page (resources/js/practice-store.js): +// they are put in the visitor's list once, they are not sent to the account +// until opened, they do not count in the 100 generated questions a visitor +// keeps, and a deleted one stays deleted. The store is run as a browser would +// run it, with a pretend page, storage and network. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const resources = path.join(here, "..", "..", "resources", "js"); +const source = fs.readFileSync(path.join(resources, "practice-store.js"), "utf8"); +const catalog = JSON.parse(fs.readFileSync(path.join(resources, "dsa.json"), "utf8")); + +// a page with its own storage; `network` is what /js/dsa.json and /practice/progress answer +function page({ items = catalog, storage = {}, account = null } = {}) { + const puts = []; + const store = Object.assign({}, storage); + const window = { + addEventListener() {}, + fetch: null, + }; + window.fetch = (url, opts = {}) => { + if (String(url).includes("dsa.json")) return Promise.resolve({ ok: items !== null, status: items === null ? 404 : 200, json: async () => items }); + if (opts.method === "PUT") { + const body = JSON.parse(opts.body); + puts.push(body); + return Promise.resolve({ ok: true, status: 200, json: async () => ({ signedIn: true, questions: body.questions, state: body.state }) }); + } + return Promise.resolve({ ok: true, status: 200, json: async () => (account ? Object.assign({ signedIn: true }, account) : { signedIn: false }) }); + }; + const ctx = { + window, + fetch: window.fetch, + document: { readyState: "complete", getElementById: () => null, addEventListener() {} }, + localStorage: { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => void (store[k] = String(v)), + }, + console: { warn() {}, error() {}, log() {} }, + setTimeout, + clearTimeout, + JSON, + }; + vm.createContext(ctx); + vm.runInContext(source, ctx); + return { PS: window.PracticeStore, store, puts, window }; +} + +const question = (n, extra = {}) => ({ id: "q" + n, name: "Q" + n, nameHyphenated: "q" + n, topic: "Arrays", difficulty: "Easy", added: 1800000000000 + n, ...extra }); + +test("the first visit has every starter question, once", async () => { + const p = page(); + assert.equal(await p.PS.ready(), true); + const all = p.PS.all(); + const slugs = new Set(catalog.map((c) => c.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""))); + assert.equal(all.length, slugs.size); + assert.ok(all.length > 290, "only " + all.length); + const two = p.PS.find("two-sum"); + assert.equal(two.id, "s-two-sum"); + assert.equal(two.name, "Two Sum"); + assert.equal(two.topic, "Arrays"); + assert.equal(two.difficulty, "Easy"); + assert.equal(two.description, null); + assert.equal(two.starter, true); + // the pages that read the list directly find it too + const raw = JSON.parse(p.store.questions); + assert.ok(raw.find((q) => q.nameHyphenated === "best-time-to-buy-and-sell-stock-ii")); +}); + +test("names read like names, and a second call adds nothing", async () => { + const p = page(); + await p.PS.ready(); + assert.equal(p.PS.find("best-time-to-buy-and-sell-stock-ii").name, "Best Time to Buy and Sell Stock II"); + assert.equal(p.PS.find("lru-cache").name, "LRU Cache"); + const before = p.store.questions; + assert.equal(await p.PS.seed(), true); // the same promise: it ran once + assert.equal(p.store.questions, before); + // a second page load finds them all there + const again = page({ storage: p.store }); + assert.equal(await again.PS.ready(), false); + assert.equal(again.PS.all().length, p.PS.all().length); +}); + +test("the starters come after what the visitor generated, in the order of the file", async () => { + const p = page({ storage: { questions: JSON.stringify([question(1)]) } }); + await p.PS.ready(); + const byNewest = p.PS.all().sort((a, b) => b.added - a.added); + assert.equal(byNewest[0].id, "q1"); + assert.equal(byNewest[1].nameHyphenated, "two-sum"); + assert.equal(byNewest[2].nameHyphenated, "best-time-to-buy-and-sell-stock"); +}); + +test("a question the visitor already has is not added again", async () => { + const mine = question(1, { name: "Two Sum", nameHyphenated: "two-sum", id: "old-random-id", description: "text" }); + const p = page({ storage: { questions: JSON.stringify([mine]) } }); + await p.PS.ready(); + assert.equal(p.PS.all().filter((q) => q.nameHyphenated === "two-sum").length, 1); + assert.equal(p.PS.find("two-sum").id, "old-random-id"); +}); + +test("a starter that was deleted is not put back", async () => { + const first = page(); + await first.PS.ready(); + first.PS.remove("s-two-sum"); + const again = page({ storage: first.store }); + await again.PS.ready(); + assert.equal(again.PS.find("two-sum"), null); + assert.equal(again.PS.all().length, first.PS.all().length); +}); + +test("a starter nobody opened is not sent to the account, and one that was opened is", async () => { + const p = page({ account: { questions: {}, state: {} } }); + await p.PS.ready(); + p.PS.setDone("s-two-sum", true); + await p.PS.sync(); + let sent = p.puts[p.puts.length - 1]; + assert.deepEqual(Object.keys(sent.questions), [], "the starters were sent"); + assert.equal(sent.state["s-two-sum"].done, true, "its done mark is sent"); + // opened: its description was written + const two = p.PS.find("two-sum"); + two.description = "Given an array..."; + p.PS.update(two); + await p.PS.sync(); + sent = p.puts[p.puts.length - 1]; + assert.deepEqual(Object.keys(sent.questions), ["s-two-sum"]); +}); + +test("starters do not count in the 100 generated questions, and a full list keeps them", async () => { + const mine = []; + for (let i = 0; i < 100; i++) mine.push(question(i)); + const p = page({ storage: { questions: JSON.stringify(mine) } }); + await p.PS.ready(); + const starters = p.PS.all().filter((q) => q.starter).length; + const added = p.PS.add(question(500)); + assert.equal(added.error, null); + const all = p.PS.all(); + assert.equal(all.filter((q) => !q.starter).length, 100); + assert.equal(all.filter((q) => q.starter).length, starters, "a starter was dropped to make room"); + assert.equal(p.PS.find("q0"), null, "the oldest generated question stays"); + assert.ok(p.PS.find("q500")); +}); + +test("an unreachable or broken list leaves the practice page as it was", async () => { + for (const items of [null, { not: "a list" }, []]) { + const p = page({ items, storage: { questions: JSON.stringify([question(1)]) } }); + assert.equal(await p.PS.ready(), false); + assert.equal(p.PS.all().length, 1); + } +}); + +test("a visitor's own questions and the account's copy of a starter are kept when signed in", async () => { + const opened = { id: "s-two-sum", name: "Two Sum", nameHyphenated: "two-sum", topic: "Arrays", difficulty: "Easy", description: "from another device", code_templates: {}, added: 1735689600000, updated: 1800000000000, starter: true }; + const p = page({ account: { questions: { "s-two-sum": opened }, state: {} } }); + await p.PS.ready(); + await p.PS.init(); + assert.equal(p.PS.find("two-sum").description, "from another device"); + assert.equal(p.PS.all().filter((q) => q.nameHyphenated === "two-sum").length, 1); +}); diff --git a/src/resources/about.html b/src/resources/about.html index 6907a753..2b8ce265 100644 --- a/src/resources/about.html +++ b/src/resources/about.html @@ -68,7 +68,7 @@

What you can do

  • Fork a terminal to run a server and a client side by side.
  • Keep files in your workspace, and sign in to keep them between visits.
  • Ask Genie, the AI helper, about your code and your terminal output, review its suggestions as a diff, let it carry out a task in Agent mode when you are signed in (it can also work in your terminal, open terminal tabs and manage your files), or right-click selected code to explain, fix, comment or test it. Genie keeps working when you close its panel, and the Ask Genie button tells you when it is done or needs you. Pin it to dock it beside the editor.
  • -
  • Practice with generated interview questions, with a coach for hints, a solution review and complexity.
  • +
  • Practice DSA with 300+ ready-made interview problems (and your own generated ones), with a coach for hints, a solution review and complexity.

  • diff --git a/src/resources/css/ui-refresh.css b/src/resources/css/ui-refresh.css index f6bced1d..ac6aabb7 100644 --- a/src/resources/css/ui-refresh.css +++ b/src/resources/css/ui-refresh.css @@ -2971,6 +2971,52 @@ body.palette-open { overflow: hidden; } .practice-filter--sort { margin-left: auto; } .practice-count { margin: 18px 0 10px; font-size: 14px; color: var(--text-2); } +/* One page of the list at a time: rows per page, the range, and the pages. */ +.q-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 20px; + margin-top: 14px; + font-size: 14px; + color: var(--text-2); +} +.q-pager[hidden] { display: none; } +.q-pager__size { display: flex; align-items: center; gap: 8px; } +.practice-page .q-pager__size select { + min-width: 0; + width: auto; + padding-right: 28px; +} +.q-pager__range { margin: 0; } +.q-pager__pages { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-left: auto; +} +.q-page { + min-width: 36px; + height: 36px; + padding: 0 10px; + font: inherit; + font-size: 14px; + color: var(--text); + background: transparent; + border: 1px solid var(--line); + border-radius: 8px; + cursor: pointer; +} +.q-page:hover:not(:disabled) { border-color: var(--text-2); } +.q-page:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; } +.q-page.is-current { color: var(--on-accent-color, #15151C); background: var(--accent-color); border-color: var(--accent-color); font-weight: 600; } +.q-page:disabled { opacity: 0.4; cursor: default; } +.q-page-gap { padding: 0 4px; color: var(--text-2); } +@media screen and (max-width: 700px) { + .q-pager__pages { margin-left: 0; width: 100%; } +} + .q-table { width: 100%; margin: 0; diff --git a/src/resources/index.html b/src/resources/index.html index 288538ac..d9b06d51 100644 --- a/src/resources/index.html +++ b/src/resources/index.html @@ -166,7 +166,7 @@