The web interface¶
The interface is a set of native ES modules under cartolex/app/static/,
written with Preact, its hooks, signals and
htm templates. There is no build step, no
npm and no bundler: the files in the repository are the files the browser
loads, and an installation stays Python-only.
cartolex/app/static/
index.html the shell document (no inline script, no inline style)
core/ shell, router, registries, i18n, stores, API client
main.js entry point: boot() into #cx-app
app.js the start sequence
shell.js header, navigation, outlet, drawers, toasts
router.js history routes and the page lifecycle
page.js definePage(), usePage(), usePageTitle()
registries.js pages, slots, facets, layers
extension-api.js what an extension's register(api) receives
i18n.js catalogues, messages (ICU subset), Intl formatting
api.js the JSON client (CSRF, AbortSignal, ETag, errors)
errors.js the ErrorCard model and the diagnostic
states.js the build's six states
stores/ project state, jobs (one poller), preferences
dom.js ids, focus trap, outside press, Escape, floating
runtime.js what components need from the running app
preact.js the one door to the vendored libraries
boot-theme.js a classic script: the stored theme before first paint
components/ Button, Card, StatusDot, Table, Dialog… (index.js)
pages/ the gallery, the overview, placeholders, not found
i18n/ en.json, fr.json, pt-BR.json
css/ tokens.css, base.css, components.css, gallery.css
brand/ logo and icon
vendor/ Preact, hooks, htm, signals (see VENDOR.md)
The shell¶
core/main.js calls boot(), which starts the app in this order:
GET /api/app/manifest(formatcartolex-manifest/1: app, branding, locales and their catalogue files, navigation entries, extension modules, capabilities, the open project, the CSRF header’s name);the catalogues of the interface language;
import()of every extension module, then itsregister(api);the shell (header, navigation, outlet);
the route: the page of the current address is mounted.
Then the project state is refreshed (its status dots already showed the cached state) and the jobs poller starts. If the manifest cannot be read, the shell shows an ErrorCard, in the browser’s language, and nothing else.
Routes and the page lifecycle¶
Routes are real paths (/keywords, /gallery), read with the History API.
The server answers every path that is not under /api/ or /static/ with
the shell document. Real paths keep the fragment free for anchors inside a
page, give addresses people can share, and let a hosted service apply its
access rules per path.
Each navigation gets a token and an AbortController. A page module
exports a page object whose mount(ctx) renders into ctx.root and returns
its teardown (or a promise of it). definePage(Component, {styles}) makes one
from a Preact component:
import { html } from '../core/preact.js';
import { definePage, usePage, usePageTitle } from '../core/page.js';
function Keywords() {
const ctx = usePage();
usePageTitle(t('nav.keywords'));
useEffect(() => {
ctx.api.get('/api/keywords').then((result) => { /* … */ });
}, []);
return html`<div class="cx-page"><h1 class="cx-page__title">…</h1></div>`;
}
export const page = definePage(Keywords);
ctx holds api (the API client bound to the navigation’s signal), signal,
params (from /people/:id), query, guard(), onLeave(), keep(),
isCurrent(), setTitle(), navigate(), deferReady() and ready().
Leaving a page aborts its signal, runs its teardowns in reverse order and
empties the outlet. Calls made through ctx.api (or wrapped in ctx.keep())
return promises that never settle once the page is left: no callback of a
torn-down page runs, whatever arrives late. The router marks the page
ready when mount returns (or when the page calls the function
ctx.deferReady() gave it), records the time, moves the focus to the page’s
h1 and announces its title.
Guards. A page with unsaved edits registers
ctx.guard({dirty: () => boolean}); the guard goes away with the page.
Leaving while dirty() is true — a link, navigate(), the browser’s back
button — asks « Leave without saving? » first (the back button is undone if
the person stays); closing the tab asks the browser’s own question. A guard
may bring its own confirm(to).
Registries and extensions¶
An extension is an ES module the manifest lists in modules; the host
application serves it (for example under /static/ext/<id>/). The shell
imports it before the first render and calls its register(api):
export function register(api) {
const { html, definePage } = api.ui;
api.i18n.add('en', { 'ext.demo.title': 'Demo' });
api.pages.add({ id: 'demo', route: '/demo', label: 'ext.demo.title', order: 90,
page: definePage(() => html`<h1>…</h1>`) });
api.slots.add('overview.cards', { id: 'demo', order: 10,
component: () => html`<${api.components.Card} title="…">…<//>` });
}
|
what it holds |
|---|---|
|
a routed page |
|
a contribution to a named insertion point; known names: |
|
list filters and map layers (used by later screens) |
|
messages merged over the catalogues, now and at every language switch |
|
Preact, hooks, signals, |
|
the component library |
|
the API client, the stores, navigation and toasts |
Every add returns a function that removes what it added. A contribution
that throws while rendering shows a compact ErrorCard in its place; the rest
of the page keeps working. An extension module that fails to load is reported
by an error toast. An extension’s override catalogues are listed in the
manifest after the base catalogue of each locale and merged over it.
Stores¶
Stores are signals: a component that reads store.x.value while rendering
re-renders when it changes.
Project state (
core/stores/project.js): the six-state validity of every stage (never built,up to date,needs update,running,failed,skipped), grouped by area, fromGET /api/project/state. The last state read is kept in the browser per project, so the status dots render at once, then refresh.Jobs (
core/stores/jobs.js):GET /api/jobs, read by one poller for the whole app: every second while a job is queued or running or the Activity drawer is open, every twenty seconds otherwise, paused while the tab is hidden. When a job ends the project state refreshes and a toast says so. A failed job stays listed until dismissed; the header stops showing it once a later job of the same kind succeeded. Every entry of the Activity drawer says when it reached its state (asked for, started, finished, failed, waiting, paused, stopped), date and time in the interface language.Preferences (
core/stores/prefs.js): theme, interface language, the jobs dismissed from the Activity list and a few settings by key (get(key),set(key, value): the atlas’s layout, the colour scheme), kept by the app (/api/me/preferences, read beside the manifest at start and saved at every change, so they outlive the browser’s storage); the browser’s local storage keeps a copy, whichcore/boot-theme.jsreads to apply the theme before the first paint.
The API client¶
core/api.js sends JSON with credentials: 'same-origin', the interface
language as Accept-Language, and on every POST, PUT, PATCH and DELETE the
CSRF header the manifest names, with the session’s token (the manifest’s
security.csrf_token, else the cartolex_csrf cookie). A call never throws
for an HTTP or network failure; it resolves to a result:
result |
when |
|---|---|
|
2xx; |
|
a write sent with |
|
any other 4xx or 5xx |
|
no answer |
|
the signal was aborted |
error is the ErrorCard model built from the server’s
{"error": {"code", "message", "next": {"label", "action"}}}. An ErrorCard
shows plain words (the catalogue’s error.<code>.* when it knows the code,
else the server’s words), a « What to do » button, the technical details
folded, and « Copy a diagnostic »: app and version, page, language, time,
error code, HTTP status, method, the request path without its query and with
every identifier-like segment replaced by « … », the request id. Never
project data.
The button runs the error’s next action. The interface runs retry (the
page’s onRetry, else a reload), reload (the page’s onReload: read again
and merge; else onRetry), settings, open-project and build (their
pages; build opens the pre-flight sheet, /build), wait (the Activity drawer), report (unfolds the details and copies
the diagnostic), and an address (open:<path> or a path, for extensions).
confirm and fix-input get a button only when the page passes onAction;
sign-in and none are told in words, without a button. In a
language other than English the catalogue’s words for the action
(error.action.<action>) replace the server’s label.
Interface language¶
The interface language is the person’s preference, apart from a project’s
keyword languages. The interface speaks English, French and Portuguese
(Brazil); Spanish is not an interface language. Catalogues are flat JSON files
("$format": "cartolex-i18n/1") of dotted keys; messages use the ICU subset
of core/i18n.js: {name}, {n, number}, {n, number, percent},
{d, date, short}, {items, list}, {n, plural, =0 {…} one {# row} other {# rows}},
{n, selectordinal, …}, {kind, select, … other {…}}. Numbers, dates,
percentages, durations and lists are formatted with Intl for the interface
language. Stage names come from stage.<id> when the catalogue has them, else
from the API.
Design¶
Black and greys on white, or the reverse; one accent (--cx-accent) for the
active navigation item and links, used rarely. The theme follows the system
setting unless the person chose one. State is coded by shape: a full dot
(up to date), a half dot (needs update), an empty dot (never built), a ring
with a gap (running), a cross (failed), a dash (skipped). Only errors and
warnings carry a hue, and always with an icon and a word. The system font
stack only (no web font); tabular figures in tables and counts.
A host application may set its own accent, per theme, in the manifest’s
branding.accent ({"light": "#rrggbb", "dark": "#rrggbb"}); the shell
passes it to the style sheet as --cx-brand-accent-light and
--cx-brand-accent-dark, which replace the accent and the focus ring.
Data, not state, has hues of its own: the twelve families --cx-hue-1 to
--cx-hue-12 colour the top-level themes in the treemap and on the map (3:1
on the page, checked with the other tokens); the accent marks the selection
only.
Every colour, size and duration is a token (--cx-*) of css/tokens.css.
The contrast check computes the WCAG ratio of every text token on every
background token in both themes (at least 4.5:1, muted text included) and of
every control boundary and the focus ring (at least 3:1); --cx-rule is a
decorative separator and exempt. Motion respects prefers-reduced-motion.
Components¶
Every component is keyboard-operable, has a visible focus, the ARIA roles of its pattern and every state (default, hover, focus, disabled, loading, error, empty, long text); the gallery shows them all.
component |
notes |
|---|---|
Button, IconButton |
variants primary, secondary, ghost, danger; loading keeps its place and focus; an IconButton’s label is its name and tooltip |
Card |
titled region, actions, footer, loading placeholder |
StatusDot, StatusPill, StageTracker |
the six states by shape; the tracker shows reasons, the last failed attempt and progress (« phase 4 of 7 ») |
Stepper |
done steps are buttons back; |
Tabs |
one tab stop; arrows, Home, End; automatic or manual activation |
Table |
virtualised (10⁵ rows), sortable headers, arrows, PageUp/Down, Home/End, Shift for ranges, Space, Ctrl+A, Enter, Escape, context menu; rows keyed, so updates keep the scroll, the focus and the selection |
Menu, MenuButton, ContextMenuArea |
arrows, Home/End, type-ahead, radio and checkbox items, groups; Escape gives the focus back; right click, Shift+F10 or the Menu key |
Dialog, Drawer, ConfirmDialog |
native |
Toast |
two live regions (polite, assertive); errors stay until dismissed |
FormField, Input, Textarea, Select, Checkbox |
label, help, error in words, « required » in words; a Select’s option may be disabled |
ParamField, ParamControl |
a build parameter edited with the control of its shape ( |
EmptyState |
always names the next action |
ErrorCard |
see the API client above |
ProgressBar |
never goes back; indeterminate without a value |
Tooltip, Help |
hover and focus, dismissible with Escape; Help opens an explanation |
ActivityIndicator, ActivityDrawer |
« Building · keywords 45 % »; jobs with progress, Stop, results, errors |
TreeView |
a virtualised tree (thousands of rows): the tree pattern’s keyboard, ranges, type-ahead, context menu, drag and drop, a caller’s own keys |
Treemap |
squarified nested rectangles by weight, one hue family per top-level node, labels that fit or are cut, zoom, drop targets |
MapFrame, MapSymbol |
a scene (layers of points, one shape per kind, ranks the zoom reveals; convex regions; lines; labels on the selection and with the zoom) drawn by WebGL, else Canvas 2D, behind a renderer interface: pan, zoom, fit, centre, hit testing, highlight, a hover card, a permanent legend, resize, theme changes; 10⁵ points pan at 60 frames a second on an integrated GPU. MapSymbol draws a kind’s shape in legends and lists |
Rules¶
No inline script, no inline handler, no
eval. The app’s Content-Security-Policy isdefault-src 'self'with nothing inline and nothing evaluated. Styles are classes and CSS custom properties set through the CSSOM (style=${{'--cx-x': …}}), never astyleattribute from a string. NoinnerHTMLwith anything but a constant, nodangerouslySetInnerHTML.No literal text. User-visible text in templates comes from the catalogues. The lint allows only text inside
<code>(identifiers, token names) and text without a letter (punctuation, digits, symbols).Teardown. A page releases everything it took: listeners, timers, subscriptions, requests. Preact effects’ cleanups run at teardown; anything else goes through
ctx.onLeave(). Calls go throughctx.apiorctx.keep(), so their late answers are dropped.No bare import outside
vendor/, and no import cycle incore/. Modules import the libraries throughcore/preact.jsonly.Vendored files change only through
tools/vendor_ui.py, which checks each npm archive’s integrity, rewrites bare specifiers to relative paths, removes the source-map comment and writesVENDOR.mdwith the SHA-256 of every file.
Running it¶
The gallery (/gallery) shows every component in every state, with switchers
for the theme and the language. Without the app’s server:
python tools/ui_fixture_server.py # prints http://127.0.0.1:<port>/gallery
The fixture server stands in for the app: it serves static/, the test
extension at /static/ext/demo/, GET /api/app/manifest
(tests/fixtures/ui/manifest.json: the shape of the contract’s copy
tests/fixtures/manifest.example.json, with the core pages and the test
extension), GET /api/project/state, GET /api/jobs and
POST /api/jobs/<id>/cancel (tests/fixtures/ui/), the shell for every other
path, and the app’s Content-Security-Policy on every answer.
Every core page is built;
the overview (pages/overview.js: overview/page.js, cards.js,
checklist.js, preview.js, cloud.js) and the build (pages/build.js, a page placed hidden:
build/page.js, preflight.js, run.js, words.js) are built. The overview
reads the project state and GET /api/overview: the project’s name and
state, the one next step, « Your first map » (a Stepper of the steps from the
people to sharing, the current one with its button and a link to the guide,
Your first map, with the demo project; hidden per project in the person’s preferences), the stage
tracker (a failure a later job came after says « failed on cloud.js, the Lexicon tab’s image for the page’s look and colour scheme, leading to it;
nothing before), the health panel, a small
preview of the map (a MapFrame) and the recent shared builds. The build page
(/build?scope=map) shows the pre-flight sheet from the dry run (what runs
and why, the time, the memory against the machine’s, the AI calls, the
consent a stage asks for, a refusal when a stage is too large, with « run
anyway »), then follows the job it starts with the StageTracker and Stop,
and ends in one sentence (« nothing changed », « finished before the stop »)
with an ErrorCard naming the cause and the next action when a stage failed;
pages placed in settings are listed in the header’s settings menu. The
themes screen (pages/themes.js, The theme editor) is built, and so are
the settings (pages/settings.js: one module per section under
pages/settings/, the section in the address, /settings?section=build; each
section reads what it shows when it opens), the start screen
(pages/start.js and pages/start/: recent projects, each with « Remove… »,
remove.js: out of the list, or its folder deleted after a last question; the demo
project, a new project; /start, and /start?new=1 for the form) and the About page
(pages/about.js and pages/about/: the logo (brand/mark.svg, or the host’s own),
what cartolex is for, the pipeline as one
SVG figure drawn with the theme’s tokens, the scientific background as
bibliographic records, the authors, citation, licence and build from
GET /api/app/about; the logo leads to it, docs/about.md holds the same
text). The header’s project menu (core/project-menu.js, local only) names
the open project and lists the recent ones (GET /api/projects, read when it
opens), « All projects… » (/start) and « New project… » (/start?new=1);
opening a project, with the warning before opening a held one anyway and the
word on a busy app, is shared with the start screen
(components/project-open.js). The settings menu ends with « About » and the
version and build; the browser tab’s title names the page, the project and the
app. A screen
is split into modules of a few hundred lines each, under a folder named after
it, with a small entry module: pages/themes.js loads pages/themes/editor.js,
which puts together the outline (outline.js, rows.js, review.js), the
treemap and map panels (treemap.js, map.js, centre.js), the side panel
(panel.js), the actions and operations (actions.js, operations.js,
dialogs.js), the draft store (store.js), the versions (versions.js) and
the AI copilot (copilot.js, its review in proposal.js), who uses a keyword and the links to the map
and the keywords (users.js; the address opens a node or a keyword, ?node=, ?keyword=; the centre
goes full screen), the playground (playground/:
the grouping’s settings tried before adopting them), over the tree’s model (model.js,
labels.js). Static modules cost nothing after the first load (they are
cached), so the budget of a navigation counts API calls only.
The keywords screen (pages/keywords.js, route /keywords) loads
pages/keywords/page.js: the three bands as tabs (?band=kept|check|aside)
with their counts, the counting unit and the languages in the head, the
warning when languages would split the themes; one band’s list
(list.js: the bands Kept, To check, Set aside and Rejected automatically,
where keeping is « put back »; search over the terms and their forms, language,
category, route and decision filters, a category column, range selection, keep, exclude, merge and undo for the rows
selected or for every row the filters keep, the route that decided each
keyword; ?q=<term>, a link from the map or the themes, keeps that keyword
and the candidates merged into it, in the band that holds them; a row’s
Enter or menu opens the people who use it, people.js, or its place on the
map); the merge dialog and the history of the decisions (dialogs.js);
the triage with AI (« Triage with AI », the primary button): with a copilot
(copilot.js: the bundle of the candidates an AI judges, kept, to check and
set aside by default, cut into parts; its results imported, merged and
reviewed term by term, review.js, with the kit’s counts and caveats; accept
all or some) and by API (api.js: what is sent, the estimate of calls and tokens,
consent, a build job). Opening it reads one page of the list. Once a copilot’s
triage is accepted for the current extraction, a note counts the candidates nobody
judged that the acceptance gate keeps out (gate.js), with « Send them to the AI »
(the copilot’s dialog on them alone) and « Keep them anyway » (after a question). The
Lexicon tab (?band=lexicon, lexicon.js) shows what the last vocabulary build
made: a word cloud of its most important keywords (an SVG the server draws with the
wordcloud package in the vendored Lato typeface, sized by score or by people,
coloured by top-level theme or by category, for the page’s light or dark theme), the
list paged on the server (a term per display language, twins merged; rank, people,
texts, category, theme, forms; search, category and theme filters, sorts), its CSV,
and keep, exclude and merge on the candidates a row gathers, with a note that they
apply at the next build.
The People screen (pages/people.js, route /people) loads
pages/people/page.js: the tabs People (people-tab.js), Identities
(identities.js, the queue, keyboard first: waiting, or accepted automatically
to review), Duplicates (duplicates-tab.js, groups of people who may be one, two or
more, compared side by side in duplicates-compare.js (a column per person, the one
kept and those taken in chosen there), merged, split or left for later with the
keyboard; the clear pairs, or every pair above a likelihood chosen with a slider,
50 % at first, merged in one undoable step after a preview, duplicates-auto.js);
« Same person… » for rows selected in the People tab and « Same person as… » in a
sheet (same-person.js: the people side by side, a search for more, one undoable
merge), Organisations (orgs-tab.js: an
organisation’s drawer with what people decide about it, org-drawer.js; the
pairs that may be one organisation, org-review.js; the people of institutions,
institutions.js), Texts (texts-tab.js), Collaborators
(collaborators-tab.js) and Coverage (coverage-tab.js); a person’s sheet
(sheet.js, with « Change identity… », identity-change.js: the identity choice
opened again for a decided person); the import and collect dialogs (import.js, collect.js; notice.js shows
what leaves the computer at the level the plan asks: none, brief or full, and
OpenAlex’s cost beyond its free daily budget); and common.js. Its lists are paged
(sheet.js: merges undone, affiliations removed or put back); the import and
collect dialogs (import.js, collect.js, whose
Notice shows what leaves the computer); and common.js. Other pages link to
/people?person=<id>, /people?tab=organisations&org=<id> and
/people?tab=texts&text=<id>, which open the drawers; the drawers’ « Show on
the map » opens /map?sel=person:<id> (organisation:, text:). Its lists are paged
on the server: usePaged(ctx, path, query, keyOf) gives the Table a row per
item of the list, placeholders until their page is read, and reads the pages
the Table says are in view (onRange); a query key starting with $ (a
version) restarts the list without being sent. The Table takes
onRange({first, last}) (the rows in view), onActiveChange(row) (the
active row, for a panel beside the list), rows marked $pending (shown as
placeholders) and size="fill" (what is left of its screen). A screen that fills the
window under the header (the people’s and keywords’ lists, the theme editor) has a least
height, never a fixed one: its list, or the editor’s columns, has no height of its own
(contain: size) and never shrinks below --cx-fill-min, so what is above it (an open
« Tune » panel, the word cloud, banners) pushes it down and the page scrolls to its end
(tests/browser/test_layout.py). The fixture
server answers the themes screen with a small tree
(tests/fixtures/ui/themes.example.json).
The atlas (pages/map.js, route /map) is the one atlas the app and the
offline site share (The atlas: one interface for the app and the offline site: static/atlas/, without a library, mounted
with a data source and the host’s capabilities). pages/map/page.js reads
GET /api/atlas, mounts it with the app’s source and host (source.js: the
API, the interface’s messages, the page’s address, the person’s preferences,
the app’s Dark / Bright, the links to People, Keywords and Themes) and keeps
around it what only the app has: the « Tune » panel, opened beside the atlas
(TuneSide of pages/tune/panel.js: « Tune the map » in the page’s head, the panel
in the card’s column, which waits on its rail meanwhile through atlas.hold, its
width dragged or moved with the arrows and kept per person as map.tune_width) and
its layout preview drawn on the atlas’s map (preview.js, atlas.setScene, the
preview’s bar at the top of the panel), the map versions (versions.js: flat or in
space, pinned or built beside, « Build it too », nearest kept, « Show ») and base
maps (bases.js), the distances’ exports (pages/share/distances.js).
Inside the atlas: the treemap of the themes (treemap.js: a click focuses, a
double click or Shift+Enter opens down to the keywords, ↑ / Backspace / Escape
go back, one tab stop with the arrows), the map (mapview.js, scene.js: the
focus lit in its own colours and the rest faded to a trace, organisation tiles
sized by their people, the network’s arcs from rings.js, names per kind), the
card of links (card.js, parts.js, Compare in compare.js), the layers panel
(layers.js; the texts drawn, every one or those of the focus, texts.js), « Filters » (filters.js), « Find » (find.js), the panes
(panes.js: dividers by pointer or arrows, rails, card right or below, full
screen for the atlas, the map alone, the treemap alone) and « Save view »
(save.js: PNG or SVG, with or without the legend). Its state is in the
address (state.js: sel, with, open, show, names, org, net, tx, f,
from, to, kc, kcol, view, base, as, map, traj); Back retraces the focus. The
layout and the colour scheme are the person’s preferences (atlas.*,
colour_scheme), and the scheme sets the hue tokens for the whole app
(core/look.js), so the Themes screen colours a theme as the atlas does.
Opening the page reads GET /api/atlas only; the atlas reads the rest when it
shows it (a keyword’s people, the network, a comparison, the keywords of a
focus, the texts, the time windows).
The share screen (pages/share.js, route /share) loads
pages/share/page.js: building the offline site (site.js: the name
question, the texts, the title and language, the map layouts it carries, the privacy summary and the
checks before publishing, each with its fix), the builds (builds.js: latest,
stale, open, download as one zip, the disk each takes) and figures, tables and files
(exports.js), each deleted after a question (delete.js). Opening it reads GET /api/share and GET /api/share/plan;
a build or an export is a job followed through the jobs poller (see
The offline site and the Share screen).
The parameters live on the pages of the steps they shape, each in a
« Tune » panel (pages/tune/panel.js, TunePanel({ctx, id})): keywords
on the keywords screen (keywords.extract, keywords.build, the AI
clean-up’s switch), themes on the theme editor (themes.space, its
space_unit first, and themes.group), map on the atlas (the pinned map
version’s layout, map-settings.js: the method, its parameters by tier and
its seed, saved as a new version, pinned; then map.layout and
map.trajectories) and texts on the People screen’s Texts tab
(corpus.assemble, and the build’s seed and pinned year): the texts are
collected and listed there, and the Build page has no step of its own for
them. A panel is collapsed by default (?tune=1 opens it; on the map it opens
beside the atlas, never above it); its header says
« defaults » or « N changed », from the project state’s changed_params, so
a closed panel reads nothing. Opened, it reads GET /api/params and its
step’s diagnostics (GET /api/method/<step>): the essential parameters,
then « More » and « Advanced » folded (a fold holding a changed value opens
by itself), each a ParamField under its short label
(param.label.<stage>.<name>, the code name beside it, small); save and
undo (params.js, shared with the settings’ build options, If-Match; a
save refreshes the project state); « Rebuild from here », the pre-flight
sheet with the panel’s first stage forced (/build?force=<stage>); and what
the step produced (texts.js, keywords.js, space.js, grouping.js,
layout.js). Where a change is cheap to show, the panel shows it before a
rebuild. On the keywords, the thresholds that only filter the stored
candidates (min_people and min_texts raised, max_share lowered,
max_keywords either way; thresholds.js, GET /api/method/keywords/preview)
give at once the candidates kept and dropped against the last build, by band,
the vocabulary’s size and the strongest that would leave or enter; nothing is
saved, and a value only a new extraction can show (a looser window) says so.
On the map, a change of the layout’s method, seed or a parameter is drawn on
the map itself (pages/map/preview.js): a moment after the last change, a
sample of at most 800 people drawn with it (POST /api/method/layout/preview,
a job the first time, then cached), in a « Preview » state with « Before »
(the map now on the same people) and « After », the nearest people each
keeps, « Keep » (a map version, pinned, then the build of the map) and
« Discard »; a change while a preview is computed cancels it and asks for the
newest. The draft outlives the panel closed (leaving the page asks first). The figures (charts.js: stacked bars, lines with marks, a
dendrogram) are SVG drawn from the numbers, each an image with a name and a
caption, its numbers folded under it. When a stage a page’s outputs depend
on needs an update, the panel’s note says so, names the first such stage and
offers to rebuild from it.
The Build page has two tabs: the build (the pre-flight sheet, the tracker,
the result) and the Recipe (build/recipe.js, /build?tab=recipe),
read-only: every parameter of every stage with its value, its default, its
origin (a rule with its reason, params.json, the map version), a mark when
it differs, filters « changed only » and by tier, a link to the panel that
edits it, and « Download as Markdown » or « CSV » (GET /api/recipe/export).
It reads GET /api/recipe only. /method and /method?step=<id>, the old
method screen’s addresses, send to the Recipe or to the step’s page with its
panel open (pages/method.js, a page placed hidden).
The map, from the app and from the offline site¶
The MapFrame (components/map-frame.js) is a thin Preact wrapper around four
modules that use no library: components/map/core.js (the view, fitting,
zooming, the detail a zoom shows, hit testing, hulls, label placement,
colours), canvas2d.js and webgl.js (the renderers) and controller.js
(events, the frame loop, createMapController({box, canvas, scene, renderer, onPick, onHover})). The offline site reuses them. A map in three dimensions
has its own: space.js (the orbit camera, projection, hit testing on the
projected points), webgl3d.js and canvas3d.js (the renderers) and
controller3d.js (createMapController3D, the same options and answers, and
the turn); the atlas swaps them by the scene’s dimensions (The atlas: one interface for the app and the offline site). A page opened from
file:// cannot load ES modules (the browser refuses them there), so these
modules follow three rules: they import only each other, with named imports
of ./x.js; they export only declarations (export function,
export const); they touch no global but the canvas and box they are given
and the page’s colour tokens. cartolex.app.static_files.classic_script(paths, name) turns them, in the order of MAP_MODULES, into one classic script
that sets window[name]; served over HTTP the site can load them as modules.
WebGL is tried once per page on a canvas of its own, since a canvas that gave
a WebGL context cannot give a 2D one. The treemap’s layout
(components/treemap-layout.js) follows the same rules, so the site’s themes
use it too, and so does the whole atlas (The atlas: one interface for the app and the offline site, ATLAS_MODULES: the
modules may import across folders, ../components/map/core.js, and the script
refuses two modules that declare the same top-level name). The world view draws the outline of the land
(static/data/world-land-110m.json, Natural Earth, public domain; see its
README).
Checks¶
python tools/ui_check.py # the static checks
python tools/check.py --only js browser # both checks, as before a merge
python tools/check.py --quick --only browser # without the slow browser tests
pytest tests/browser --ui-screenshots DIR # also write the review screenshots
js (tools/ui_check.py) parses every module with Node’s own parser (the
Node that comes with Playwright’s Python package; skipped without Node),
resolves imports, runs the literal-text lint and the bans, checks the vendored
hashes, the three catalogues (same keys, valid messages, the same arguments in
every language, every literal key present) and the contrast of the tokens.
browser runs tests/browser/ in headless Chromium through Playwright, with
an empty home folder and every request off the loopback interface refused; a
console error, an uncaught exception or a CSP violation fails a test:
axe-core (vendored in
tests/browser/vendor/) on the gallery in both themes and three languages, on the shell’s pages and on open dialogs, the drawer and menus: no serious or critical violation;keyboard scripts: Tab reaches every interactive section in order, and each component is operated with the keyboard alone;
budgets: every navigation ready in under 1 s with at most 5 API calls (static modules and style sheets, cached after the first load, and the jobs poller’s own reads are not counted);
teardown and leaks: ten round trips between the gallery and the overview; DOM nodes and event listeners within 2 %, the JS heap within 10 %, after a forced garbage collection;
the shell’s behaviour: start order, cached status dots, routing and focus, guards, late answers dropped, the extension API, the API client, the Activity drawer;
the People screen on the real app and the demo services (
tests/browser/test_corpus.py, world XS): what leaves the computer before a collection, the collection as a job, the identity queue with the keyboard, the clear matches in bulk, a person’s sheet, axe;the theme editor on the real app (
tests/browser/test_theme_editor.py, withapp_harness.py): the S demo world built as a project once per session, then each scenario on a fresh copy served by the app on a free loopback port (see The theme editor): the main flows, a keyboard script of the menu actions, axe, and one budget on the L world (markedslow). A test waits with a predicate that returns a boolean, never an element: an element handle would keep a page alive and read as a leak.the atlas on the real app (
tests/browser/test_atlas.py, the S world): the map drawn with WebGL in five API calls, the treemap explored to a keyword and its people, a person found with their co-authors drawn and a second ring, the layers panel, two organisations compared, the filters, the colour scheme and the layout kept across a reload, the map versions, the world view, axe; the map inside its frame in both themes; the same atlas mounted from a page opened fromfile://(atlas_file.py: the classic script, a synthetic bundle, the rings computed in the browser); and a budget: 10⁴ points panned in the gallery at 50 frames a second or more, a frame drawn in under 16 ms;tests/browser/test_atlas_3d.py: a map version in three dimensions fromfile://(a click on a projected person, the network’s second ring and the trajectory as 3D lines, an organisation’s hull from its members, the keyboard, Turn and Front, the view saved as SVG, axe), two built versions and the « Layout » select,map=in the address and the World view, and the same budget with 10⁵ people and 10⁵ texts in 3D turned; a focus given in the address centred in 2D and 3D;tests/browser/test_map_layouts.py(slow): a layout in space built beside the flat map through « Map versions », shown with « Layout » (texts of the focus, Network 2, the trajectory, in space), offered by « Share » and built into a site; an offline site carrying both layouts switched to the one in space;tests/browser/test_atlas_links.py: a person from the address with their co-authors, a keyword’s people and its way to the keywords and the themes screens, full screen (the atlas, the map alone) and back, the view saved as SVG and PNG.
The browser tests need tools/requirements-browser.txt (Playwright, which
tools/check.py installs into the quick Python’s environment) and a Chromium
build for it (python -m playwright install chromium); without either they
are skipped with the reason, and the rest of the suite runs. Firefox is not
run: no Firefox build for this Playwright version is installed on the
reference machine.