annotepage-client 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,25 +27,25 @@ described in `FORMAT.md`, at the root of the repository. When this file and
27
27
 
28
28
  ## Putting the tool on a site
29
29
 
30
- ### 1. Generate the salt, and put it away
30
+ ### 1. Generate the key, and put it away
31
31
 
32
32
  Load the client once, on a page of the site, with `data-setup` and **without**
33
33
  `data-project`:
34
34
 
35
35
  ```html
36
- <script src="https://<your-cdn>/annotepage-client@2.2.0/dist/annotepage.js"
37
- integrity="sha384-5wrAEkCKCLyEM3YJsVd6H7gFCOHt9f63XTDIM0Eu4fVYEAr3X4wgDWEDXkx/WVb/"
36
+ <script src="https://<your-cdn>/annotepage-client@2.4.0/dist/annotepage.js"
37
+ integrity="sha384-RbO8lASKNckSOum5Gm462DTZ8y6/+yR6ZFKyphrAe9Hf3x95tJ57eY/6GX4XfBJr"
38
38
  crossorigin="anonymous"
39
39
  data-server="https://<your-server>/annotepage/api.php"
40
40
  data-setup
41
41
  defer></script>
42
42
  ```
43
43
 
44
- The setup screen generates a **256-bit salt** and gives you four things to
45
- copy: the salt, the project id, the final tag, and the three lines to declare
44
+ The setup screen generates a **256-bit key** and gives you four things to
45
+ copy: the key, the project id, the final tag, and the three lines to declare
46
46
  on the server. No network request is made at that point.
47
47
 
48
- > **SALT LOST = NOTES LOST.** The salt is the only secret of the project. It
48
+ > **KEY LOST = NOTES LOST.** The key is the only secret of the project. It
49
49
  > never leaves the browser, the server receives it in no form whatsoever, and
50
50
  > nobody can give it back to you. There is no recovery, no security question,
51
51
  > no escrow third party. Put it where your team keeps its passwords **before**
@@ -54,8 +54,8 @@ on the server. No network request is made at that point.
54
54
  ### 2. Paste the final tag, at the end of `<body>`
55
55
 
56
56
  ```html
57
- <script src="https://<your-cdn>/annotepage-client@2.2.0/dist/annotepage.js"
58
- integrity="sha384-5wrAEkCKCLyEM3YJsVd6H7gFCOHt9f63XTDIM0Eu4fVYEAr3X4wgDWEDXkx/WVb/"
57
+ <script src="https://<your-cdn>/annotepage-client@2.4.0/dist/annotepage.js"
58
+ integrity="sha384-RbO8lASKNckSOum5Gm462DTZ8y6/+yR6ZFKyphrAe9Hf3x95tJ57eY/6GX4XfBJr"
59
59
  crossorigin="anonymous"
60
60
  data-server="https://<your-server>/annotepage/api.php"
61
61
  data-project="7Qb1kZ3xNvA9dLpEqKf2Zt"
@@ -66,7 +66,7 @@ on the server. No network request is made at that point.
66
66
 
67
67
  **`integrity` is not decorative.** As soon as the client goes to a CDN, the
68
68
  real risk of this architecture is the supply chain: a file swapped at the CDN's
69
- host runs in your page, with access to `localStorage` -- hence to the salt. The
69
+ host runs in your page, with access to `localStorage` -- hence to the key. The
70
70
  SRI digest is what makes that swap useless. `crossorigin="anonymous"` goes with
71
71
  it: without it the browser does not check the digest of a cross-origin
72
72
  resource.
@@ -80,12 +80,12 @@ that is exactly its job.
80
80
  `document.currentScript`: the client could no longer read its own attributes
81
81
  and would stand down in silence.
82
82
 
83
- ### 3. Declare the project on the server, and give the salt to the team
83
+ ### 3. Declare the project on the server, and give the key to the team
84
84
 
85
85
  The server receives the project id (public) and the list of allowed origins.
86
- The salt travels **out of band** -- the tool provides no channel for it. Each
86
+ The key travels **out of band** -- the tool provides no channel for it. Each
87
87
  reviewer pastes it once: the tool shows them the pasting screen, checks that
88
- the salt really derives the id declared by the page, and remembers it in their
88
+ the key really derives the id declared by the page, and remembers it in their
89
89
  browser.
90
90
 
91
91
  ## The tag attributes
@@ -190,7 +190,7 @@ it is.
190
190
 
191
191
  ## What the server never sees, and what it sees anyway
192
192
 
193
- It receives neither the salt, nor the key, nor the path of your pages: it
193
+ It receives neither the key, nor the key, nor the path of your pages: it
194
194
  groups by **blind index**, an HMAC of the path that it cannot invert. It does
195
195
  see the number of projects and notes, the number of distinct pages, the time of
196
196
  every write, the shape of the threads, the approximate length of each remark,
@@ -203,7 +203,7 @@ remarks".
203
203
  The server's domain lock is an **anti-abuse** measure: it stops another site
204
204
  from consuming a project id found in the source of a page. **It is not a
205
205
  protection against XSS**: an XSS runs INSIDE the target page, so with the
206
- legitimate origin, and it has access to `localStorage`, hence to the salt.
206
+ legitimate origin, and it has access to `localStorage`, hence to the key.
207
207
 
208
208
  The path prefix (`data-path`) is checked **by the client** -- the server does
209
209
  not see paths. It is **tidiness**, not a security boundary.
@@ -264,19 +264,19 @@ variables nor the theme of the host site.
264
264
  `textContent` everywhere, `innerHTML` nowhere: the text of a note is typed by a
265
265
  human and is never interpreted as markup.
266
266
 
267
- ## The interface says "key", the format says "salt"
267
+ ## The interface says "key", the format says "key"
268
268
 
269
269
  Since 2.0.2 every string a person reads says **key** -- "The key of this
270
270
  project is needed", "The project key (43 characters)". Cryptographically that
271
271
  is what it is: 32 bytes of key material, from which HKDF derives the project
272
- id, the AES-256-GCM key and the blind-index key. A salt, by definition, is
272
+ id, the AES-256-GCM key and the blind-index key. A key, by definition, is
273
273
  public; this is the only secret there is, and the word invited people to treat
274
274
  it as if it were not.
275
275
 
276
- What did NOT change, deliberately: the label KEYS are still `salt.title`,
277
- `salt.help` and so on -- renaming them would silently break every translation
276
+ What did NOT change, deliberately: the label KEYS are still `key.title`,
277
+ `key.help` and so on -- renaming them would silently break every translation
278
278
  file already written against them -- and `FORMAT.md`, the storage key
279
- `annotepage/salt/<project>`, and the MCP's `salt` configuration field are
279
+ `annotepage/key/<project>`, and the MCP's `key` configuration field are
280
280
  unchanged, because those are a format and a contract rather than prose.
281
281
 
282
282
  ## Translating, or changing a word
@@ -329,7 +329,7 @@ something one can read.
329
329
 
330
330
  `npm test` cross-checks the format vectors against a second implementation of
331
331
  HKDF-SHA-256 written by hand from RFC 5869. That is what guarantees that the
332
- salt is the input keying material and `annotepage/1` the HKDF salt, and not the
332
+ key is the input keying material and `annotepage/1` the HKDF salt, and not the
333
333
  other way round: both "work", only one is the format. The PHP server and the
334
334
  MCP package can copy these vectors to check that they speak the same format.
335
335
 
@@ -349,12 +349,12 @@ repository:
349
349
  src/00-preamble.js reading the tag: server, project, scope, limits
350
350
  src/10-utils.js labels, base64url, dates, versions
351
351
  src/15-labels.js EVERY text shown, English by default
352
- src/20-crypto.js salt, HKDF, blind index, AES-256-GCM envelope
352
+ src/20-crypto.js key, HKDF, blind index, AES-256-GCM envelope
353
353
  src/30-state.js state, browser memory, scope
354
354
  src/40-api.js the calls, the refusals, what goes out encrypted or plain
355
355
  src/50-anchors.js finding the element of a note, or calling it orphaned
356
356
  src/60-ui.js all the DOM, inside the shadow root
357
- src/70-setup.js the two screens that show or ask for the salt
357
+ src/70-setup.js the two screens that show or ask for the key
358
358
  src/90-boot.js the order of ignition, and the silences
359
359
  src/styles.css confined styles, inlined by the build
360
360
  tools/build.mjs the assembly, and the SRI digest
@@ -371,16 +371,16 @@ This is a choice, not an oversight:
371
371
 
372
372
  - **no authentication.** The name typed in is a convenience, not an identity.
373
373
  The project id is a bearer token: whoever has it can read and write. In
374
- encrypted mode, what they read is useless without the salt;
374
+ encrypted mode, what they read is useless without the key;
375
375
  - **no moderation, and no deletion.** A note that is posted stays. The only
376
376
  state it can change is "resolved", and that state can be taken back;
377
- - **no salt rotation.** There is no mechanism: a leaked salt means starting
377
+ - **no key rotation.** There is no mechanism: a leaked key means starting
378
378
  from a fresh project, abandoning the notes;
379
- - **no channel for handing the salt** to the second reviewer;
379
+ - **no channel for handing the key** to the second reviewer;
380
380
  - **no masking of the length** of the remarks: the size of the envelope gives
381
381
  it away to within a few bytes.
382
382
 
383
- The salt is remembered **per browser and per origin**. The day staging becomes
383
+ The key is remembered **per browser and per origin**. The day staging becomes
384
384
  production, every reviewer pastes it once more on the new domain -- the notes
385
385
  themselves do not move. That is exactly what the rule "the domain is not in the
386
386
  key" buys.
package/dist/HASHES.txt CHANGED
@@ -1,4 +1,6 @@
1
- 2.2.0 sha384-5wrAEkCKCLyEM3YJsVd6H7gFCOHt9f63XTDIM0Eu4fVYEAr3X4wgDWEDXkx/WVb/ 154893 bytes
1
+ 2.4.0 sha384-RbO8lASKNckSOum5Gm462DTZ8y6/+yR6ZFKyphrAe9Hf3x95tJ57eY/6GX4XfBJr 160669 bytes
2
+ 2.3.0 sha384-oZAAGkI1KL5aAJCD+v7QPGqxcg5fzizz8hsnUHfNJPXeNVccDX251y5+EZRDCHEl 154823 bytes
3
+ 2.2.0 sha384-+/SJuJv8IJRDsV5d5yXvlmLRiVmZHmX1NMph76iwNJuqFbQgQgbdDmseIxUl+DWz 154823 bytes
2
4
  2.1.0 sha384-tMQlD7gryDE13ShW/GCVLMhJU8tqODcREl9RksXuf67eectM4YPTUZXLfkmakFoO 142816 bytes
3
5
  2.0.2 sha384-IeG681tUVluUYNg632FP8SMWIHZp1pEEP5/s6XT+PIJpbp7JJDkGThQPVx1xf31p 142816 bytes
4
6
  2.0.1 sha384-aSkx1XQLhcYpDQrpoD8yUn0Wu00BHbi1KktWiXqMQufioNtReQw+tm804/ZzNfo8 134184 bytes
@@ -1,7 +1,7 @@
1
1
  /* ============================================================================
2
2
  annotepage -- the annotation layer, browser side.
3
3
 
4
- Package version : 2.2.0
4
+ Package version : 2.4.0
5
5
  Format version : 2 (see FORMAT.md)
6
6
  Licence : MIT
7
7
 
@@ -16,9 +16,9 @@
16
16
  /* Injected by the build: they come from package.json and from
17
17
  src/styles.css, so that no value is written in two places and can
18
18
  therefore diverge. */
19
- const TOOL_VERSION = "2.2.0";
19
+ const TOOL_VERSION = "2.4.0";
20
20
  const FORMAT = 2;
21
- const STYLES = "/* ============================================================================\n styles.css -- THE STYLES OF THE TOOL, AND OF NO OTHER ELEMENT.\n\n This sheet is INLINED into the served file by the build, then put into the\n tool's shadow root -- as a constructed sheet when the browser can do it, in\n a <style> otherwise. It was loaded by a <link> in the original tool; the\n move to a CDN under SRI brought it inside the file, so that there is only\n one digest to keep up to date. The containment itself has not changed, and\n is still twofold:\n\n - from the tool towards the site: no rule from here can reach an element\n of the host site, the browser sees to that. That is what makes the\n claim \"the layer touches nothing\" checkable rather than promised;\n - from the site towards the tool: no rule of the site can reach an\n element here. A redesign of the site's stylesheet therefore cannot\n distort the tool, nor the other way round.\n\n The \"ap-\" prefix on every class is the third safeguard: the day somebody\n loads these styles WITHOUT a shadow root -- by mistake, or to debug --\n nothing would answer a selector of the site.\n\n NO RULE TARGETS html, body, * OR ANY SELECTOR OF THE SITE. That is the one\n absolute prohibition of this file.\n\n COLOURS: the tool has its OWN palette, defined on the shadow root. It\n reads neither the site's variables nor its theme attribute: it has no\n reason to know how the site names its colours, and it must stay readable\n on a light site as on a dark one. The switch follows the system\n preference, the only information the tool has without asking anyone.\n ============================================================================ */\n\n\n:host {\n --ap-bg: #ffffff;\n --ap-bg-soft: #f4f6f8;\n --ap-bg-raised: #e9edf2;\n --ap-text: #1a1d21;\n --ap-text-soft: #5b6570;\n --ap-border: #d5dbe2;\n --ap-accent: #2f6fed;\n --ap-accent-dark: #1d55c8;\n --ap-accent-text: #ffffff;\n --ap-accent-veil: rgba(47, 111, 237, 0.14);\n --ap-alert-bg: #fdeceb;\n --ap-alert-border: #e3a9a4;\n --ap-alert-text: #8a1f16;\n --ap-shadow: 0 6px 24px rgba(16, 24, 40, 0.18);\n --ap-radius: 10px;\n --ap-font: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\",\n Arial, sans-serif;\n}\n\n@media (prefers-color-scheme: dark) {\n :host {\n --ap-bg: #1d2126;\n --ap-bg-soft: #262b32;\n --ap-bg-raised: #323942;\n --ap-text: #e9ecf0;\n --ap-text-soft: #a4adb8;\n --ap-border: #3a424c;\n --ap-accent: #6d9bff;\n --ap-accent-dark: #8fb4ff;\n --ap-accent-text: #10151c;\n --ap-accent-veil: rgba(109, 155, 255, 0.18);\n --ap-alert-bg: #3a1f1c;\n --ap-alert-border: #7c3a33;\n --ap-alert-text: #ffb9b1;\n --ap-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);\n }\n}\n\n/* ----------------------------------------------------------------------------\n The layer.\n\n It covers the viewport and receives NO click: that is what lets the page\n behave exactly as usual as long as the tool is not in annotation mode.\n Each widget re-enables clicks for itself alone.\n ---------------------------------------------------------------------------- */\n\n.ap-layer {\n position: absolute;\n inset: 0;\n pointer-events: none;\n font-family: var(--ap-font);\n font-size: 14px;\n line-height: 1.45;\n color: var(--ap-text);\n text-align: left;\n -webkit-font-smoothing: antialiased;\n}\n\n.ap-layer button,\n.ap-layer input,\n.ap-layer textarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: inherit;\n margin: 0;\n box-sizing: border-box;\n}\n\n/* ----------------------------------------------------------------------------\n The button: the only thing visible when the tool is at rest.\n ---------------------------------------------------------------------------- */\n\n.ap-button {\n position: fixed;\n right: 16px;\n bottom: 16px;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 9px 14px;\n border: 1px solid var(--ap-border);\n border-radius: 999px;\n background: var(--ap-bg);\n color: var(--ap-text);\n box-shadow: var(--ap-shadow);\n cursor: pointer;\n pointer-events: auto;\n opacity: 0.92;\n transition: opacity 0.15s ease, transform 0.15s ease;\n}\n\n.ap-button:hover,\n.ap-button:focus-visible {\n opacity: 1;\n transform: translateY(-1px);\n}\n\n.ap-button:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n.ap-button[aria-pressed=\"true\"] {\n background: var(--ap-accent);\n border-color: var(--ap-accent);\n color: var(--ap-accent-text);\n opacity: 1;\n}\n\n.ap-button-dot {\n display: inline-block;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--ap-accent);\n flex: none;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-dot {\n background: var(--ap-accent-text);\n}\n\n.ap-button-count {\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--ap-bg-raised);\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-count {\n background: rgba(255, 255, 255, 0.22);\n color: var(--ap-accent-text);\n}\n\n/* ----------------------------------------------------------------------------\n The pointing highlight.\n\n It is DRAWN HERE, from the coordinates of the element being pointed at.\n Nothing is put on the element itself: no class, no attribute, no style. So\n the site cannot move by a single pixel because of the pointing.\n ---------------------------------------------------------------------------- */\n\n.ap-highlight {\n position: fixed;\n border: 2px solid var(--ap-accent);\n border-radius: 3px;\n background: var(--ap-accent-veil);\n pointer-events: none;\n display: none;\n}\n\n.ap-highlight-label {\n position: fixed;\n max-width: 320px;\n padding: 4px 8px;\n border-radius: 6px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n display: none;\n box-shadow: var(--ap-shadow);\n}\n\n/* ----------------------------------------------------------------------------\n The markers: \"there are already notes here\".\n ---------------------------------------------------------------------------- */\n\n.ap-marker {\n position: fixed;\n min-width: 22px;\n height: 22px;\n padding: 0 6px;\n border: 2px solid var(--ap-bg);\n border-radius: 999px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n font-weight: 700;\n line-height: 18px;\n text-align: center;\n cursor: pointer;\n pointer-events: auto;\n box-shadow: var(--ap-shadow);\n}\n\n.ap-marker:focus-visible {\n outline: 2px solid var(--ap-accent-dark);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The panel.\n ---------------------------------------------------------------------------- */\n\n.ap-panel {\n position: fixed;\n top: 12px;\n right: 12px;\n bottom: 72px;\n width: 360px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n overflow: hidden;\n}\n\n.ap-panel.ap-open {\n display: flex;\n}\n\n.ap-panel-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n}\n\n.ap-panel-title {\n font-size: 15px;\n font-weight: 600;\n flex: 1 1 auto;\n}\n\n.ap-panel-instructions {\n padding: 10px 14px;\n border-bottom: 1px solid var(--ap-border);\n color: var(--ap-text-soft);\n font-size: 13px;\n}\n\n.ap-panel-body {\n flex: 1 1 auto;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 4px 14px 14px;\n}\n\n.ap-panel-footer {\n padding: 8px 14px;\n border-top: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.ap-section-title {\n margin: 14px 0 6px;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.ap-section-help {\n margin: 0 0 8px;\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-empty {\n margin: 16px 0;\n color: var(--ap-text-soft);\n}\n\n/* The standing mention of a public key. Deliberately NOT the alert colours:\n this is not a failure and it is on screen for ever -- an alarm that never\n goes away stops being read. It is a fact about the project, stated in the\n panel's own tone, and it stays at the top of every draw. */\n/* The public-key notice and the \"a newer client exists\" line are the same\n object on screen: a standing statement about what one is looking at, above\n the notes and above the failures. One rule, so they cannot drift apart. */\n.ap-public,\n.ap-upgrade {\n margin: 0 0 10px;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n line-height: 1.45;\n}\n\n/* ----------------------------------------------------------------------------\n A note, and its replies.\n ---------------------------------------------------------------------------- */\n\n.ap-note {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n}\n\n.ap-note.ap-orphan {\n background: var(--ap-bg-soft);\n}\n\n.ap-note.ap-focused {\n border-color: var(--ap-accent);\n box-shadow: 0 0 0 3px var(--ap-accent-veil);\n}\n\n.ap-note-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-note-author {\n font-weight: 600;\n}\n\n.ap-note-date {\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-note-target {\n margin: 4px 0 0;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-note-text {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.ap-note-actions {\n margin-top: 8px;\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-replies {\n margin: 8px 0 0;\n padding-left: 10px;\n border-left: 2px solid var(--ap-border);\n}\n\n.ap-reply {\n margin: 8px 0 0;\n}\n\n/* ----------------------------------------------------------------------------\n The form, anchored near the element pointed at.\n ---------------------------------------------------------------------------- */\n\n.ap-form {\n position: fixed;\n width: 340px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n gap: 8px;\n padding: 14px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n}\n\n.ap-form.ap-open {\n display: flex;\n}\n\n.ap-form-title {\n font-size: 15px;\n font-weight: 600;\n}\n\n.ap-form-target {\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-label {\n display: block;\n margin-bottom: 3px;\n font-size: 12px;\n font-weight: 600;\n color: var(--ap-text-soft);\n}\n\n.ap-help {\n margin: 3px 0 0;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n.ap-field,\n.ap-area {\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n}\n\n.ap-field:focus,\n.ap-area:focus {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n.ap-area {\n min-height: 92px;\n resize: vertical;\n}\n\n.ap-actions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-counter {\n margin-left: auto;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n/* ----------------------------------------------------------------------------\n Buttons.\n ---------------------------------------------------------------------------- */\n\n.ap-primary,\n.ap-secondary,\n.ap-link {\n border-radius: 8px;\n cursor: pointer;\n pointer-events: auto;\n}\n\n.ap-primary {\n padding: 8px 14px;\n border: 1px solid var(--ap-accent);\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-weight: 600;\n}\n\n.ap-primary:hover {\n background: var(--ap-accent-dark);\n border-color: var(--ap-accent-dark);\n}\n\n.ap-secondary {\n padding: 8px 14px;\n border: 1px solid var(--ap-border);\n background: var(--ap-bg);\n color: var(--ap-text);\n}\n\n.ap-secondary:hover {\n background: var(--ap-bg-raised);\n}\n\n.ap-link {\n padding: 2px 4px;\n border: 0;\n background: none;\n color: var(--ap-accent);\n text-decoration: underline;\n font-size: 13px;\n}\n\n.ap-primary:disabled,\n.ap-secondary:disabled,\n.ap-link:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.ap-primary:focus-visible,\n.ap-secondary:focus-visible,\n.ap-link:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The failures.\n\n They are RED, at the top of the block concerned, and carry the message the\n server returned as it stands: that is how a non-technical team learns that\n its remark is not saved, instead of believing it is.\n ---------------------------------------------------------------------------- */\n\n.ap-error {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-alert-border);\n border-radius: var(--ap-radius);\n background: var(--ap-alert-bg);\n color: var(--ap-alert-text);\n}\n\n.ap-error-title {\n font-weight: 700;\n margin-bottom: 4px;\n}\n\n.ap-error-detail {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n font-size: 13px;\n}\n\n.ap-error .ap-link {\n color: var(--ap-alert-text);\n}\n\n/* ----------------------------------------------------------------------------\n Narrow: the panel takes the full width, and so does the form.\n ---------------------------------------------------------------------------- */\n\n/* ----------------------------------------------------------------------------\n Narrow.\n\n DEFECT OBSERVED at 375 px wide: a panel taking the full height covers the\n whole page, and no element can be pointed at any more -- every click lands\n on the panel. So it becomes a bottom band, which leaves the top half of\n the viewport free; one scrolls the page there to bring the wanted element\n into view. The form, for its part, hides the panel while typing (see\n notes.js): on a screen that size, writing and reading the list at the same\n time does not hold.\n ---------------------------------------------------------------------------- */\n\n/* On a narrow screen the panel becomes a bottom band and the form takes the\n full width.\n\n THE WIDTH CEILING IS KEPT, and it comes from a measured defect: \"left: 8;\n right: 8\" sizes the element against its CONTAINING BLOCK, which the host\n site's horizontal overflow can make wider than the visible window.\n Measured, in mobile emulation at 390 px: the site overflows to 407 px\n (with the tool and without it), and the panel came out 391 px wide\n starting at 8, that is 9 px off screen. \"100vw\" is the window, not the\n containing block: the ceiling therefore does nothing when the site does\n not overflow, and pulls the width back when it does. */\n@media (max-width: 560px) {\n .ap-panel {\n top: auto;\n right: 8px;\n left: 8px;\n bottom: 66px;\n height: 52vh;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n\n .ap-form {\n left: 8px;\n right: 8px;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ap-button {\n transition: none;\n }\n}\n\n/* The failure shows without opening the panel: the button's dot changes\n colour. A team that does not click must be able to see that something is\n wrong. */\n.ap-button.ap-failed .ap-button-dot {\n background: var(--ap-alert-text);\n}\n\n.ap-button.ap-failed {\n border-color: var(--ap-alert-border);\n}\n\n/* Signature reminder, in the note form.\n The name was shown at the foot of the panel only: invisible at the moment\n one writes. A user reported not knowing which name they were writing\n under. */\n.ap-form-signature {\n display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;\n margin: 0 0 .6rem; font-size: .85rem; opacity: .8;\n}\n\n/* Resolution state, said on the card.\n Two cases NOT to be confused: resolved and online, resolved but not\n deployed yet. The second keeps the defect on the reviewer's screen; hiding\n it or announcing it as fixed would cost them their trust in the tool. */\n.ap-state-mark {\n display: inline-block; margin: 0 0 .5rem;\n padding: .15rem .55rem; border-radius: 4px;\n font-size: .75rem; font-weight: 600; letter-spacing: .02em;\n}\n.ap-note.ap-resolved { opacity: .72; }\n.ap-note.ap-resolved .ap-state-mark {\n color: #0f7a52; background: rgba(16, 185, 129, .14);\n}\n.ap-note.ap-resolved-pending .ap-state-mark {\n color: #8a5a00; background: rgba(245, 158, 11, .16);\n}\n/* The \"it is fixed\" / \"reopen\" block, opened under the card. Same shape as\n the reply block: it is the same gesture, one answers a remark. */\n.ap-resolve,\n.ap-reply-form {\n margin-top: .6rem;\n padding-top: .6rem;\n border-top: 1px solid var(--ap-border);\n}\n\n.ap-history-toggle {\n display: block; width: 100%; margin: 1rem 0 .25rem;\n padding: .5rem .75rem; border: 1px dashed currentColor; border-radius: 6px;\n background: none; color: inherit; font: inherit; opacity: .7; cursor: pointer;\n}\n.ap-history-toggle:hover { opacity: 1; }\n\n\n/* ----------------------------------------------------------------------------\n Setup and pasting the salt.\n\n These are the only screens where something is copied by hand. Everything\n there is SELECTABLE and monospaced: a 43-character salt copied wrong\n cannot be recovered, and nothing helps less than a font that confuses I, l\n and 1.\n ---------------------------------------------------------------------------- */\n\n.ap-panel-wide {\n width: 560px;\n}\n\n.ap-copy {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n margin: 0 0 12px;\n}\n\n.ap-code {\n flex: 1 1 auto;\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\",\n monospace;\n font-size: 12.5px;\n line-height: 1.5;\n resize: vertical;\n white-space: pre;\n overflow-x: auto;\n}\n\n.ap-code:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n@media (max-width: 560px) {\n .ap-panel-wide {\n width: auto;\n }\n\n .ap-copy {\n flex-direction: column;\n }\n}\n";
21
+ const STYLES = "/* ============================================================================\n styles.css -- THE STYLES OF THE TOOL, AND OF NO OTHER ELEMENT.\n\n This sheet is INLINED into the served file by the build, then put into the\n tool's shadow root -- as a constructed sheet when the browser can do it, in\n a <style> otherwise. It was loaded by a <link> in the original tool; the\n move to a CDN under SRI brought it inside the file, so that there is only\n one digest to keep up to date. The containment itself has not changed, and\n is still twofold:\n\n - from the tool towards the site: no rule from here can reach an element\n of the host site, the browser sees to that. That is what makes the\n claim \"the layer touches nothing\" checkable rather than promised;\n - from the site towards the tool: no rule of the site can reach an\n element here. A redesign of the site's stylesheet therefore cannot\n distort the tool, nor the other way round.\n\n The \"ap-\" prefix on every class is the third safeguard: the day somebody\n loads these styles WITHOUT a shadow root -- by mistake, or to debug --\n nothing would answer a selector of the site.\n\n NO RULE TARGETS html, body, * OR ANY SELECTOR OF THE SITE. That is the one\n absolute prohibition of this file.\n\n COLOURS: the tool has its OWN palette, defined on the shadow root. It\n reads neither the site's variables nor its theme attribute: it has no\n reason to know how the site names its colours, and it must stay readable\n on a light site as on a dark one. The switch follows the system\n preference, the only information the tool has without asking anyone.\n ============================================================================ */\n\n\n:host {\n --ap-bg: #ffffff;\n --ap-bg-soft: #f4f6f8;\n --ap-bg-raised: #e9edf2;\n --ap-text: #1a1d21;\n --ap-text-soft: #5b6570;\n --ap-border: #d5dbe2;\n --ap-accent: #2f6fed;\n --ap-accent-dark: #1d55c8;\n --ap-accent-text: #ffffff;\n --ap-accent-veil: rgba(47, 111, 237, 0.14);\n --ap-alert-bg: #fdeceb;\n --ap-alert-border: #e3a9a4;\n --ap-alert-text: #8a1f16;\n --ap-shadow: 0 6px 24px rgba(16, 24, 40, 0.18);\n --ap-radius: 10px;\n --ap-font: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\",\n Arial, sans-serif;\n}\n\n@media (prefers-color-scheme: dark) {\n :host {\n --ap-bg: #1d2126;\n --ap-bg-soft: #262b32;\n --ap-bg-raised: #323942;\n --ap-text: #e9ecf0;\n --ap-text-soft: #a4adb8;\n --ap-border: #3a424c;\n --ap-accent: #6d9bff;\n --ap-accent-dark: #8fb4ff;\n --ap-accent-text: #10151c;\n --ap-accent-veil: rgba(109, 155, 255, 0.18);\n --ap-alert-bg: #3a1f1c;\n --ap-alert-border: #7c3a33;\n --ap-alert-text: #ffb9b1;\n --ap-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);\n }\n}\n\n/* ----------------------------------------------------------------------------\n The layer.\n\n It covers the viewport and receives NO click: that is what lets the page\n behave exactly as usual as long as the tool is not in annotation mode.\n Each widget re-enables clicks for itself alone.\n ---------------------------------------------------------------------------- */\n\n.ap-layer {\n position: absolute;\n inset: 0;\n pointer-events: none;\n font-family: var(--ap-font);\n font-size: 14px;\n line-height: 1.45;\n color: var(--ap-text);\n text-align: left;\n -webkit-font-smoothing: antialiased;\n}\n\n.ap-layer button,\n.ap-layer input,\n.ap-layer textarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: inherit;\n margin: 0;\n box-sizing: border-box;\n}\n\n/* ----------------------------------------------------------------------------\n The button: the only thing visible when the tool is at rest.\n ---------------------------------------------------------------------------- */\n\n.ap-button {\n position: fixed;\n right: 16px;\n bottom: 16px;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 9px 14px;\n border: 1px solid var(--ap-border);\n border-radius: 999px;\n background: var(--ap-bg);\n color: var(--ap-text);\n box-shadow: var(--ap-shadow);\n cursor: pointer;\n pointer-events: auto;\n opacity: 0.92;\n transition: opacity 0.15s ease, transform 0.15s ease;\n}\n\n.ap-button:hover,\n.ap-button:focus-visible {\n opacity: 1;\n transform: translateY(-1px);\n}\n\n.ap-button:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n.ap-button[aria-pressed=\"true\"] {\n background: var(--ap-accent);\n border-color: var(--ap-accent);\n color: var(--ap-accent-text);\n opacity: 1;\n}\n\n.ap-button-dot {\n display: inline-block;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--ap-accent);\n flex: none;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-dot {\n background: var(--ap-accent-text);\n}\n\n.ap-button-count {\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--ap-bg-raised);\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-count {\n background: rgba(255, 255, 255, 0.22);\n color: var(--ap-accent-text);\n}\n\n/* ----------------------------------------------------------------------------\n The pointing highlight.\n\n It is DRAWN HERE, from the coordinates of the element being pointed at.\n Nothing is put on the element itself: no class, no attribute, no style. So\n the site cannot move by a single pixel because of the pointing.\n ---------------------------------------------------------------------------- */\n\n.ap-highlight {\n position: fixed;\n border: 2px solid var(--ap-accent);\n border-radius: 3px;\n background: var(--ap-accent-veil);\n pointer-events: none;\n display: none;\n}\n\n.ap-highlight-label {\n position: fixed;\n max-width: 320px;\n padding: 4px 8px;\n border-radius: 6px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n display: none;\n box-shadow: var(--ap-shadow);\n}\n\n/* ----------------------------------------------------------------------------\n The markers: \"there are already notes here\".\n ---------------------------------------------------------------------------- */\n\n.ap-marker {\n position: fixed;\n min-width: 22px;\n height: 22px;\n padding: 0 6px;\n border: 2px solid var(--ap-bg);\n border-radius: 999px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n font-weight: 700;\n line-height: 18px;\n text-align: center;\n cursor: pointer;\n pointer-events: auto;\n box-shadow: var(--ap-shadow);\n}\n\n.ap-marker:focus-visible {\n outline: 2px solid var(--ap-accent-dark);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The panel.\n ---------------------------------------------------------------------------- */\n\n.ap-panel {\n position: fixed;\n top: 12px;\n right: 12px;\n bottom: 72px;\n width: 360px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n overflow: hidden;\n}\n\n.ap-panel.ap-open {\n display: flex;\n}\n\n.ap-panel-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n}\n\n.ap-panel-title {\n font-size: 15px;\n font-weight: 600;\n flex: 1 1 auto;\n}\n\n.ap-panel-instructions {\n padding: 10px 14px;\n border-bottom: 1px solid var(--ap-border);\n color: var(--ap-text-soft);\n font-size: 13px;\n}\n\n.ap-panel-body {\n flex: 1 1 auto;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 4px 14px 14px;\n}\n\n.ap-panel-footer {\n padding: 8px 14px;\n border-top: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.ap-section-title {\n margin: 14px 0 6px;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.ap-section-help {\n margin: 0 0 8px;\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-empty {\n margin: 16px 0;\n color: var(--ap-text-soft);\n}\n\n/* The standing mention of a public key. Deliberately NOT the alert colours:\n this is not a failure and it is on screen for ever -- an alarm that never\n goes away stops being read. It is a fact about the project, stated in the\n panel's own tone, and it stays at the top of every draw. */\n/* The public-key notice and the \"a newer client exists\" line are the same\n object on screen: a standing statement about what one is looking at, above\n the notes and above the failures. One rule, so they cannot drift apart. */\n.ap-public,\n.ap-upgrade {\n margin: 0 0 10px;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n line-height: 1.45;\n}\n\n/* ----------------------------------------------------------------------------\n A note, and its replies.\n ---------------------------------------------------------------------------- */\n\n.ap-note {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n}\n\n.ap-note.ap-orphan {\n background: var(--ap-bg-soft);\n}\n\n.ap-note.ap-focused {\n border-color: var(--ap-accent);\n box-shadow: 0 0 0 3px var(--ap-accent-veil);\n}\n\n.ap-note-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-note-author {\n font-weight: 600;\n}\n\n.ap-note-date {\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-note-target {\n margin: 4px 0 0;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-note-text {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.ap-note-actions {\n margin-top: 8px;\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-replies {\n margin: 8px 0 0;\n padding-left: 10px;\n border-left: 2px solid var(--ap-border);\n}\n\n.ap-reply {\n margin: 8px 0 0;\n}\n\n/* ----------------------------------------------------------------------------\n The form, anchored near the element pointed at.\n ---------------------------------------------------------------------------- */\n\n.ap-form {\n position: fixed;\n width: 340px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n gap: 8px;\n padding: 14px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n}\n\n.ap-form.ap-open {\n display: flex;\n}\n\n.ap-form-title {\n font-size: 15px;\n font-weight: 600;\n}\n\n.ap-form-target {\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-label {\n display: block;\n margin-bottom: 3px;\n font-size: 12px;\n font-weight: 600;\n color: var(--ap-text-soft);\n}\n\n.ap-help {\n margin: 3px 0 0;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n.ap-field,\n.ap-area {\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n}\n\n.ap-field:focus,\n.ap-area:focus {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n.ap-area {\n min-height: 92px;\n resize: vertical;\n}\n\n.ap-actions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-counter {\n margin-left: auto;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n/* ----------------------------------------------------------------------------\n Buttons.\n ---------------------------------------------------------------------------- */\n\n.ap-primary,\n.ap-secondary,\n.ap-link {\n border-radius: 8px;\n cursor: pointer;\n pointer-events: auto;\n}\n\n.ap-primary {\n padding: 8px 14px;\n border: 1px solid var(--ap-accent);\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-weight: 600;\n}\n\n.ap-primary:hover {\n background: var(--ap-accent-dark);\n border-color: var(--ap-accent-dark);\n}\n\n.ap-secondary {\n padding: 8px 14px;\n border: 1px solid var(--ap-border);\n background: var(--ap-bg);\n color: var(--ap-text);\n}\n\n.ap-secondary:hover {\n background: var(--ap-bg-raised);\n}\n\n.ap-link {\n padding: 2px 4px;\n border: 0;\n background: none;\n color: var(--ap-accent);\n text-decoration: underline;\n font-size: 13px;\n}\n\n.ap-primary:disabled,\n.ap-secondary:disabled,\n.ap-link:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.ap-primary:focus-visible,\n.ap-secondary:focus-visible,\n.ap-link:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The failures.\n\n They are RED, at the top of the block concerned, and carry the message the\n server returned as it stands: that is how a non-technical team learns that\n its remark is not saved, instead of believing it is.\n ---------------------------------------------------------------------------- */\n\n.ap-error {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-alert-border);\n border-radius: var(--ap-radius);\n background: var(--ap-alert-bg);\n color: var(--ap-alert-text);\n}\n\n.ap-error-title {\n font-weight: 700;\n margin-bottom: 4px;\n}\n\n.ap-error-detail {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n font-size: 13px;\n}\n\n.ap-error .ap-link {\n color: var(--ap-alert-text);\n}\n\n/* ----------------------------------------------------------------------------\n Narrow: the panel takes the full width, and so does the form.\n ---------------------------------------------------------------------------- */\n\n/* ----------------------------------------------------------------------------\n Narrow.\n\n DEFECT OBSERVED at 375 px wide: a panel taking the full height covers the\n whole page, and no element can be pointed at any more -- every click lands\n on the panel. So it becomes a bottom band, which leaves the top half of\n the viewport free; one scrolls the page there to bring the wanted element\n into view. The form, for its part, hides the panel while typing (see\n notes.js): on a screen that size, writing and reading the list at the same\n time does not hold.\n ---------------------------------------------------------------------------- */\n\n/* On a narrow screen the panel becomes a bottom band and the form takes the\n full width.\n\n THE WIDTH CEILING IS KEPT, and it comes from a measured defect: \"left: 8;\n right: 8\" sizes the element against its CONTAINING BLOCK, which the host\n site's horizontal overflow can make wider than the visible window.\n Measured, in mobile emulation at 390 px: the site overflows to 407 px\n (with the tool and without it), and the panel came out 391 px wide\n starting at 8, that is 9 px off screen. \"100vw\" is the window, not the\n containing block: the ceiling therefore does nothing when the site does\n not overflow, and pulls the width back when it does. */\n@media (max-width: 560px) {\n .ap-panel {\n top: auto;\n right: 8px;\n left: 8px;\n bottom: 66px;\n height: 52vh;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n\n .ap-form {\n left: 8px;\n right: 8px;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ap-button {\n transition: none;\n }\n}\n\n/* The failure shows without opening the panel: the button's dot changes\n colour. A team that does not click must be able to see that something is\n wrong. */\n.ap-button.ap-failed .ap-button-dot {\n background: var(--ap-alert-text);\n}\n\n.ap-button.ap-failed {\n border-color: var(--ap-alert-border);\n}\n\n/* Signature reminder, in the note form.\n The name was shown at the foot of the panel only: invisible at the moment\n one writes. A user reported not knowing which name they were writing\n under. */\n.ap-form-signature {\n display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;\n margin: 0 0 .6rem; font-size: .85rem; opacity: .8;\n}\n\n/* Resolution state, said on the card.\n Two cases NOT to be confused: resolved and online, resolved but not\n deployed yet. The second keeps the defect on the reviewer's screen; hiding\n it or announcing it as fixed would cost them their trust in the tool. */\n.ap-state-mark {\n display: inline-block; margin: 0 0 .5rem;\n padding: .15rem .55rem; border-radius: 4px;\n font-size: .75rem; font-weight: 600; letter-spacing: .02em;\n}\n.ap-note.ap-resolved { opacity: .72; }\n.ap-note.ap-resolved .ap-state-mark {\n color: #0f7a52; background: rgba(16, 185, 129, .14);\n}\n.ap-note.ap-resolved-pending .ap-state-mark {\n color: #8a5a00; background: rgba(245, 158, 11, .16);\n}\n/* The \"it is fixed\" / \"reopen\" block, opened under the card. Same shape as\n the reply block: it is the same gesture, one answers a remark. */\n.ap-resolve,\n.ap-reply-form {\n margin-top: .6rem;\n padding-top: .6rem;\n border-top: 1px solid var(--ap-border);\n}\n\n/* The question asked before the key is dropped, at the foot of the list.\n Framed like the resolution block -- it is the same shape of gesture, one\n answers before something changes -- and set apart from the notes above it,\n because it is not about a note. */\n.ap-forget {\n margin-top: 1rem;\n padding-top: .6rem;\n border-top: 1px solid var(--ap-border);\n}\n\n.ap-forget .ap-actions {\n margin-top: .5rem;\n}\n\n.ap-history-toggle {\n display: block; width: 100%; margin: 1rem 0 .25rem;\n padding: .5rem .75rem; border: 1px dashed currentColor; border-radius: 6px;\n background: none; color: inherit; font: inherit; opacity: .7; cursor: pointer;\n}\n.ap-history-toggle:hover { opacity: 1; }\n\n\n/* ----------------------------------------------------------------------------\n Setup and pasting the salt.\n\n These are the only screens where something is copied by hand. Everything\n there is SELECTABLE and monospaced: a 43-character salt copied wrong\n cannot be recovered, and nothing helps less than a font that confuses I, l\n and 1.\n ---------------------------------------------------------------------------- */\n\n.ap-panel-wide {\n width: 560px;\n}\n\n.ap-copy {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n margin: 0 0 12px;\n}\n\n.ap-code {\n flex: 1 1 auto;\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\",\n monospace;\n font-size: 12.5px;\n line-height: 1.5;\n resize: vertical;\n white-space: pre;\n overflow-x: auto;\n}\n\n.ap-code:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n@media (max-width: 560px) {\n .ap-panel-wide {\n width: auto;\n }\n\n .ap-copy {\n flex-direction: column;\n }\n}\n";
22
22
 
23
23
  /* ==== 00-preamble.js ==== */
24
24
 
@@ -89,7 +89,7 @@
89
89
  encrypted. The key is random, it lives in the page, and the page is the
90
90
  one thing the server never sees.
91
91
 
92
- The SHAPE is not checked here: saltFromText() in 20-crypto is the single
92
+ The SHAPE is not checked here: keyFromText() in 20-crypto is the single
93
93
  judge of what a key looks like, and it lives in the section that owns the
94
94
  format. What is recorded here is whether the attribute was WRITTEN at all
95
95
  -- an empty data-key is a tag somebody meant to fill in, and it gets said
@@ -117,7 +117,7 @@
117
117
  FORMAT.md section 4). So it is TIDINESS -- the tag can stay at the foot of
118
118
  every page of the site without the online documentation collecting the
119
119
  staging notes -- and NOT a security boundary: whoever has the project id
120
- and the salt writes wherever they like. */
120
+ and the key writes wherever they like. */
121
121
  const PATH_PREFIX = read('path');
122
122
 
123
123
  /* The project origins. The real lock is the server's (FORMAT.md section
@@ -506,33 +506,45 @@
506
506
  'marker.one': '1 note here',
507
507
  'marker.n': '{n} notes here',
508
508
 
509
- /* -- The salt: the only secret, and it cannot be recovered ---------- */
510
- 'salt.title': 'The key of this project is needed',
511
- 'salt.help':
509
+ /* -- The key: the only secret, and it cannot be recovered ---------- */
510
+ 'key.title': 'The key of this project is needed',
511
+ 'key.help':
512
512
  'The notes of this project are encrypted in your browser. Without the '
513
513
  + 'project key, this browser can neither read them nor write any. Ask '
514
514
  + 'whoever installed the tool for it, and paste it below. It will be '
515
515
  + 'remembered by this browser, for this site.',
516
- 'salt.label': 'The project key (43 characters)',
517
- 'salt.confirm': 'Use this key',
518
- 'salt.empty': 'Paste the key before confirming.',
519
- 'salt.shape':
516
+ 'key.label': 'The project key (43 characters)',
517
+ 'key.confirm': 'Use this key',
518
+ 'key.empty': 'Paste the key before confirming.',
519
+ 'key.shape':
520
520
  'This is not a key: 43 characters are expected, from A-Z a-z 0-9 - _, '
521
521
  + 'with no space and no decorative dash. Copy it in one block.',
522
- 'salt.wrong':
522
+ 'key.wrong':
523
523
  'This key is not the one for this project. Nothing was sent, nothing '
524
524
  + 'was decrypted. Check that you are pasting the key of the right '
525
525
  + 'project.',
526
- 'salt.origin_changed':
526
+ 'key.origin_changed':
527
527
  'This key is remembered per browser AND per domain. The day staging '
528
528
  + 'becomes production, it has to be pasted once more on the new domain '
529
529
  + '-- the notes themselves do not move.',
530
- 'salt.not_kept':
530
+ 'key.not_kept':
531
531
  'This browser refuses to remember the key (private browsing, or '
532
532
  + 'storage blocked). The tool works for this page, but the key will '
533
533
  + 'have to be pasted again on the next load.',
534
- 'salt.replace': 'Paste another key',
535
- 'salt.forget': 'Forget the key on this browser',
534
+ 'key.replace': 'Paste another key',
535
+ 'key.forget': 'Forget the key on this browser',
536
+ /* Asked BEFORE, because the gesture cannot be taken back from here --
537
+ and it says what is really lost, which is the convenience and not the
538
+ notes. Announcing "your notes will be lost" would be a lie that stops
539
+ somebody from cleaning up a shared machine; saying nothing at all
540
+ would strand whoever no longer has the key anywhere else. */
541
+ 'key.forget_confirm':
542
+ 'This browser will stop keeping the key of this project: the tool '
543
+ + 'will ask for it again here, and on every page of this site. The '
544
+ + 'notes are not touched -- they stay on the server, encrypted, and '
545
+ + 'they come back as soon as the key is pasted again. Make sure you '
546
+ + 'can still get hold of the key before confirming: nobody, not even '
547
+ + 'the server, can hand it back.',
536
548
 
537
549
  /* -- A project whose key is IN the page ---------------------------
538
550
  Said where the notes are, at every draw, and not once at startup: it
@@ -581,7 +593,7 @@
581
593
  + 'it: not the server, not the author of the tool, nobody you can ask. '
582
594
  + 'KEY LOST = NOTES LOST, for good, with no recovery. Put it away now, '
583
595
  + 'where your team keeps its passwords, before continuing.',
584
- 'setup.salt': 'The project key -- keep it',
596
+ 'setup.key': 'The project key -- keep it',
585
597
  'setup.project': 'The project id -- public, it goes into the page',
586
598
  'setup.tag': 'The tag to paste at the end of <body>, on the pages to annotate',
587
599
  'setup.server': 'To declare in the server configuration',
@@ -617,18 +629,18 @@
617
629
 
618
630
  /* ==== 20-crypto.js ==== */
619
631
 
620
- /* -- 6. The salt, the three derivations, the envelope --------------------
632
+ /* -- 6. The key, the three derivations, the envelope --------------------
621
633
 
622
634
  This whole file implements FORMAT.md sections 1, 3 and 4, and nothing
623
635
  else. When a line here contradicts FORMAT.md, this line is wrong.
624
636
 
625
- THE SALT NEVER LEAVES THE BROWSER. It is not sent to the server in any
637
+ THE KEY NEVER LEAVES THE BROWSER. It is not sent to the server in any
626
638
  form, in any mode, derived forms included. The only path out of here is
627
639
  the setup screen, which shows it to the person who has just generated it
628
640
  so that they can put it away. */
629
641
 
630
642
  const HKDF_SALT_STRING = 'annotepage/1';
631
- const SALT_LENGTH = 43; // 32 bytes in base64url without padding
643
+ const KEY_LENGTH = 43; // 32 bytes in base64url without padding
632
644
  const NONCE_LENGTH = 16; // 12 bytes in base64url without padding
633
645
 
634
646
  /* WebCrypto only exists in a SECURE context: https, or localhost. On a
@@ -646,14 +658,14 @@
646
658
  };
647
659
 
648
660
  /**
649
- * The text of a salt -> its 32 bytes, or null.
661
+ * The text of a key -> its 32 bytes, or null.
650
662
  *
651
663
  * We refuse anything that has not exactly the right shape rather than
652
- * "cleaning up" spaces or dashes: an almost-right salt derives a wrong
653
- * project id, and the message "this salt is not the salt of this project"
664
+ * "cleaning up" spaces or dashes: an almost-right key derives a wrong
665
+ * project id, and the message "this key is not the key of this project"
654
666
  * would then send someone looking in the wrong place.
655
667
  */
656
- const saltFromText = (text) => {
668
+ const keyFromText = (text) => {
657
669
  const t = String(text == null ? '' : text).trim();
658
670
  if (!/^[A-Za-z0-9_-]{43}$/.test(t)) return null;
659
671
  const bytes = fromB64url(t);
@@ -664,26 +676,26 @@
664
676
  * The three derivations, in one go.
665
677
  *
666
678
  * TRAP, named because it costs dearly: HKDF's "salt" parameter is NOT our
667
- * salt. Our salt is the input keying material (IKM); HKDF's salt is the
679
+ * key. Our key is the input keying material (IKM); HKDF's salt is the
668
680
  * fixed, public string "annotepage/1", which separates this tool from any
669
681
  * other software one might one day trust with the same secret. Swapping them
670
682
  * produces a system that works, that encrypts, and whose notes become
671
683
  * unreadable on the first reimplementation.
672
684
  */
673
- const derive = (saltBytes) => {
685
+ const derive = (keyBytes) => {
674
686
  const params = (label) => ({
675
687
  name: 'HKDF',
676
688
  hash: 'SHA-256',
677
- salt: utf8(HKDF_SALT_STRING), // NOT the salt: see above
689
+ salt: utf8(HKDF_SALT_STRING), // HKDF's salt, NOT our key: see above
678
690
  info: utf8(label)
679
691
  });
680
692
 
681
693
  return CRYPTO.subtle
682
- .importKey('raw', saltBytes, 'HKDF', false, ['deriveBits', 'deriveKey'])
694
+ .importKey('raw', keyBytes, 'HKDF', false, ['deriveBits', 'deriveKey'])
683
695
  .then((master) => Promise.all([
684
696
  CRYPTO.subtle.deriveBits(params('id'), master, 256),
685
697
  // The encryption key is generated NON-EXTRACTABLE. That is
686
- // hygiene, not a barrier: the salt sleeps in localStorage right
698
+ // hygiene, not a barrier: the key sleeps in localStorage right
687
699
  // next to it, and whoever reads one rebuilds the other in three
688
700
  // lines. We write it down so that nobody takes this "false" for
689
701
  // a protection it is not.
@@ -783,7 +795,7 @@
783
795
  * 'newer' the envelope carries a format number above ours. We do not
784
796
  * guess at cryptography: flat refusal, the note is skipped
785
797
  * and counted, and the tool SAYS that it exists.
786
- * 'unreadable' invalid shape, or decryption failed -- wrong salt, note
798
+ * 'unreadable' invalid shape, or decryption failed -- wrong key, note
787
799
  * moved by the server, damaged bytes. All three are worth
788
800
  * the same to the reader: there is nothing to read.
789
801
  */
@@ -850,10 +862,10 @@
850
862
  it: a note skipped in silence is a remark that disappears. */
851
863
  let skipped = { newer: 0, unreadable: 0, unknown: 0 };
852
864
 
853
- /* The salt of this project, and everything derived from it. "keys" stays
854
- null as long as the salt is unknown: no request, no decryption goes out
865
+ /* The key of this project, and everything derived from it. "keys" stays
866
+ null as long as the key is unknown: no request, no decryption goes out
855
867
  before then. */
856
- let saltText = '';
868
+ let keyText = '';
857
869
  let keys = null; // { id, encryptionKey, indexKey }
858
870
  let PAGE_INDEX = ''; // blind index of the current page
859
871
 
@@ -869,20 +881,20 @@
869
881
  // the name is there to know who to talk to, not to prove who one is.
870
882
  const AUTHOR_KEY = 'annotepage/author';
871
883
 
872
- /* The salt is stored UNDER THE PROJECT ID. That naming is not cosmetic: two
884
+ /* The key is stored UNDER THE PROJECT ID. That naming is not cosmetic: two
873
885
  projects reviewed from the same browser must not overwrite each other.
874
886
 
875
887
  An unpleasant consequence, to be stated: localStorage is PER ORIGIN. The
876
- day staging becomes production, every reviewer has to paste the salt once
888
+ day staging becomes production, every reviewer has to paste the key once
877
889
  more on the new domain. The notes themselves do not move -- and that is
878
890
  exactly what the rule "the domain is not in the key" buys. */
879
- const saltKey = (project) => 'annotepage/salt/' + project;
891
+ const keyKey = (project) => 'annotepage/key/' + project;
880
892
 
881
893
  const readSalt = (project) => {
882
894
  try {
883
- return String(window.localStorage.getItem(saltKey(project)) || '').trim();
895
+ return String(window.localStorage.getItem(keyKey(project)) || '').trim();
884
896
  } catch (e) {
885
- // Without storage the salt will be asked for on every visit: that is
897
+ // Without storage the key will be asked for on every visit: that is
886
898
  // less comfortable, it is not a failure.
887
899
  return '';
888
900
  }
@@ -890,10 +902,10 @@
890
902
 
891
903
  const writeSalt = (project, text) => {
892
904
  try {
893
- window.localStorage.setItem(saltKey(project), text);
905
+ window.localStorage.setItem(keyKey(project), text);
894
906
  return true;
895
907
  } catch (e) {
896
- // We return false so the screen can SAY it: a salt that is not kept
908
+ // We return false so the screen can SAY it: a key that is not kept
897
909
  // will have to be pasted again on every page, and it is better to
898
910
  // know that straight away than on the third time.
899
911
  return false;
@@ -902,7 +914,7 @@
902
914
 
903
915
  const forgetSalt = (project) => {
904
916
  try {
905
- window.localStorage.removeItem(saltKey(project));
917
+ window.localStorage.removeItem(keyKey(project));
906
918
  } catch (e) {
907
919
  // Nothing to do: there was no storage in the first place.
908
920
  }
@@ -1386,11 +1398,11 @@
1386
1398
  * once the texts are available.
1387
1399
  */
1388
1400
  const buildHost = () => {
1389
- // IDEMPOTENT, and this is not a stylistic precaution: the salt-pasting
1401
+ // IDEMPOTENT, and this is not a stylistic precaution: the key-pasting
1390
1402
  // screen built the host BEFORE the normal startup asked for it in turn.
1391
1403
  // Without this guard, the site received TWO elements, one of them empty
1392
1404
  // and orphaned -- the promise "one single element added" fell over at the
1393
- // first pasted salt.
1405
+ // first pasted key.
1394
1406
  if (host) return;
1395
1407
  host = document.createElement('annotepage-notes');
1396
1408
  // These properties are set INLINE and with !important, on our own
@@ -1859,6 +1871,40 @@
1859
1871
  return { block: block, field: field };
1860
1872
  };
1861
1873
 
1874
+ /**
1875
+ * The question asked before the key is dropped.
1876
+ *
1877
+ * It is asked, and not merely announced afterwards, because from this button
1878
+ * there is no way back: the tool cannot re-derive a key it has just removed,
1879
+ * and neither can the server. It sits at the FOOT of the list, right above
1880
+ * the link that opened it, so the answer appears where the question was
1881
+ * clicked rather than at the top of a panel that may be scrolled elsewhere.
1882
+ *
1883
+ * What the sentence must not do is claim the notes are lost. They are not:
1884
+ * they stay on the server, and the key brings them back. Overstating it
1885
+ * would scare somebody out of a perfectly reasonable clean-up -- and the
1886
+ * first time a reviewer forgot the key and found their notes again, they
1887
+ * would stop believing the warnings that are true.
1888
+ */
1889
+ const forgetForm = () => {
1890
+ const block = create('div', 'ap-forget');
1891
+ block.setAttribute('role', 'group');
1892
+ block.appendChild(create('div', 'ap-section-title', T('key.forget')));
1893
+ block.appendChild(create('p', 'ap-help', T('key.forget_confirm')));
1894
+
1895
+ const actions = create('div', 'ap-actions');
1896
+ const confirm = create('button', 'ap-primary', T('key.forget'));
1897
+ confirm.type = 'button';
1898
+ confirm.addEventListener('click', () => forgetKey());
1899
+ const cancel = create('button', 'ap-secondary', T('note.cancel'));
1900
+ cancel.type = 'button';
1901
+ cancel.addEventListener('click', () => block.remove());
1902
+ actions.appendChild(confirm);
1903
+ actions.appendChild(cancel);
1904
+ block.appendChild(actions);
1905
+ return block;
1906
+ };
1907
+
1862
1908
  const drawPanel = () => {
1863
1909
  empty(ui.body);
1864
1910
  empty(ui.footer);
@@ -1973,20 +2019,41 @@
1973
2019
  ui.footer.appendChild(change);
1974
2020
  }
1975
2021
 
1976
- /* The salt gets pasted again from here. This is not a convenience
2022
+ /* The key gets pasted again from here. This is not a convenience
1977
2023
  setting: the day staging becomes production, localStorage changes
1978
- origin and the salt has to be pasted once more, on every browser.
2024
+ origin and the key has to be pasted once more, on every browser.
1979
2025
  Without this button, one would have to clear the storage by hand to
1980
2026
  get there. */
1981
2027
  /* Not offered when the key comes from the tag: there is nothing stored
1982
2028
  to replace, and a key pasted here would be overruled by the tag on the
1983
2029
  next load -- while quietly leaving a copy in localStorage. */
1984
- if (PROJECT && saltText && !PUBLIC_KEY) {
1985
- const changeSalt = create('button', 'ap-link', T('salt.replace'));
2030
+ /* And the same key gets forgotten from here, under the same condition
2031
+ and for the same reason: this footer is where the panel already
2032
+ answers "and what about MY browser" -- the name it remembers, the key
2033
+ it remembers. Somebody handing back a borrowed laptop looks for it
2034
+ next to the name, not in a settings screen this tool does not have.
2035
+
2036
+ The pair is deliberate: replacing a key and dropping it are the two
2037
+ halves of the same question, and offering only the first is what
2038
+ forced people to clear the storage by hand. */
2039
+ if (PROJECT && keyText && !PUBLIC_KEY) {
2040
+ const changeSalt = create('button', 'ap-link', T('key.replace'));
1986
2041
  changeSalt.type = 'button';
1987
- changeSalt.title = T('salt.origin_changed');
2042
+ changeSalt.title = T('key.origin_changed');
1988
2043
  changeSalt.addEventListener('click', () => openSaltScreen());
1989
2044
  ui.footer.appendChild(changeSalt);
2045
+
2046
+ const forget = create('button', 'ap-link', T('key.forget'));
2047
+ forget.type = 'button';
2048
+ forget.addEventListener('click', () => {
2049
+ // One at a time: a second click on the link asks nothing new,
2050
+ // it would just stack the same question.
2051
+ if (ui.body.querySelector('.ap-forget')) return;
2052
+ const block = forgetForm();
2053
+ ui.body.appendChild(block);
2054
+ block.scrollIntoView({ block: 'nearest' });
2055
+ });
2056
+ ui.footer.appendChild(forget);
1990
2057
  }
1991
2058
 
1992
2059
  const total = notes.length;
@@ -2273,18 +2340,18 @@
2273
2340
 
2274
2341
  /* ==== 70-setup.js ==== */
2275
2342
 
2276
- /* -- 18. Setup, and the salt one pastes ----------------------------------
2343
+ /* -- 18. Setup, and the key one pastes ----------------------------------
2277
2344
 
2278
- These screens are the only places where the salt is shown or typed in.
2279
- They are BLOCKING: as long as the salt is unknown, the tool shows neither
2345
+ These screens are the only places where the key is shown or typed in.
2346
+ They are BLOCKING: as long as the key is unknown, the tool shows neither
2280
2347
  an annotation button nor a panel of notes. There is nothing to annotate
2281
- without a salt -- not even in plain mode, where the page index is already
2348
+ without a key -- not even in plain mode, where the page index is already
2282
2349
  an HMAC.
2283
2350
 
2284
2351
  None of these screens makes a network request. A consequence to be
2285
2352
  stated: a page carrying a tag with a project, on a site whose server is
2286
- not configured yet, will still show the "paste the salt" screen. That is
2287
- accepted: without a salt we cannot even ask for the list of notes, so we
2353
+ not configured yet, will still show the "paste the key" screen. That is
2354
+ accepted: without a key we cannot even ask for the list of notes, so we
2288
2355
  cannot check that the server answers. The tag, on the other hand, was put
2289
2356
  there by somebody. */
2290
2357
 
@@ -2407,23 +2474,23 @@
2407
2474
  + ' origins ' + location.origin + '\n'
2408
2475
  + ' mode ' + MODE;
2409
2476
 
2410
- /* -- The "paste the salt" screen ---------------------------------------- */
2477
+ /* -- The "paste the key" screen ---------------------------------------- */
2411
2478
 
2412
2479
  const openSaltScreen = () => {
2413
- const screen = blockingScreen(T('salt.title'), false);
2414
- screen.body.appendChild(create('p', 'ap-help', T('salt.help')));
2415
- screen.body.appendChild(create('p', 'ap-help', T('salt.origin_changed')));
2480
+ const screen = blockingScreen(T('key.title'), false);
2481
+ screen.body.appendChild(create('p', 'ap-help', T('key.help')));
2482
+ screen.body.appendChild(create('p', 'ap-help', T('key.origin_changed')));
2416
2483
 
2417
- screen.body.appendChild(create('div', 'ap-label', T('salt.label')));
2484
+ screen.body.appendChild(create('div', 'ap-label', T('key.label')));
2418
2485
  const field = create('input', 'ap-field');
2419
2486
  field.type = 'text';
2420
2487
  field.setAttribute('autocomplete', 'off');
2421
2488
  field.setAttribute('spellcheck', 'false');
2422
- field.setAttribute('maxlength', String(SALT_LENGTH + 8));
2489
+ field.setAttribute('maxlength', String(KEY_LENGTH + 8));
2423
2490
  screen.body.appendChild(field);
2424
2491
 
2425
2492
  const actions = create('div', 'ap-actions');
2426
- const confirm = create('button', 'ap-primary', T('salt.confirm'));
2493
+ const confirm = create('button', 'ap-primary', T('key.confirm'));
2427
2494
  confirm.type = 'button';
2428
2495
  actions.appendChild(confirm);
2429
2496
  screen.body.appendChild(actions);
@@ -2433,30 +2500,30 @@
2433
2500
  if (previous) previous.remove();
2434
2501
  if (detail) {
2435
2502
  screen.body.insertBefore(
2436
- failureBlock({ title: T('salt.title'), detail: detail }), screen.body.firstChild);
2503
+ failureBlock({ title: T('key.title'), detail: detail }), screen.body.firstChild);
2437
2504
  }
2438
2505
  };
2439
2506
 
2440
2507
  confirm.addEventListener('click', () => {
2441
2508
  const raw = normalize(field.value).replace(/\s+/g, '');
2442
- if (!raw) return say(T('salt.empty'));
2443
- const bytes = saltFromText(raw);
2444
- if (!bytes) return say(T('salt.shape'));
2509
+ if (!raw) return say(T('key.empty'));
2510
+ const bytes = keyFromText(raw);
2511
+ if (!bytes) return say(T('key.shape'));
2445
2512
  say(null);
2446
2513
  confirm.disabled = true;
2447
2514
 
2448
2515
  /* The check happens HERE: we re-derive the project id and compare it
2449
- with the tag's. Equal, the salt is the right one. Nothing is sent
2516
+ with the tag's. Equal, the key is the right one. Nothing is sent
2450
2517
  to the network and nothing is decrypted before this test -- which
2451
- is what saves us from carrying a checksum alongside the salt: the
2518
+ is what saves us from carrying a checksum alongside the key: the
2452
2519
  project id already plays that part, and it is public. */
2453
2520
  derive(bytes).then((derived) => {
2454
2521
  confirm.disabled = false;
2455
- if (derived.id !== PROJECT) return say(T('salt.wrong'));
2522
+ if (derived.id !== PROJECT) return say(T('key.wrong'));
2456
2523
  if (!writeSalt(PROJECT, raw)) {
2457
2524
  // Storage refuses: we carry on for this page anyway, but we
2458
2525
  // do not let anyone believe it is remembered.
2459
- say(T('salt.not_kept'));
2526
+ say(T('key.not_kept'));
2460
2527
  }
2461
2528
  startWithSalt(raw, derived);
2462
2529
  }, () => {
@@ -2483,11 +2550,11 @@
2483
2550
  generate.addEventListener('click', () => {
2484
2551
  generate.disabled = true;
2485
2552
  const fresh = generateSalt();
2486
- const bytes = saltFromText(fresh);
2553
+ const bytes = keyFromText(fresh);
2487
2554
  derive(bytes).then((derived) => {
2488
2555
  empty(screen.body);
2489
2556
 
2490
- /* The warning comes BEFORE the salt, and before the button that
2557
+ /* The warning comes BEFORE the key, and before the button that
2491
2558
  continues. It is spelled out in full, not in a footnote: it is
2492
2559
  the only secret of the project, and there is no recovery. */
2493
2560
  const warning = create('div', 'ap-error');
@@ -2496,7 +2563,7 @@
2496
2563
  warning.appendChild(create('p', 'ap-error-detail', T('setup.warning')));
2497
2564
  screen.body.appendChild(warning);
2498
2565
 
2499
- copyBlock(screen.body, T('setup.salt'), fresh);
2566
+ copyBlock(screen.body, T('setup.key'), fresh);
2500
2567
  copyBlock(screen.body, T('setup.project'), derived.id);
2501
2568
  copyBlock(screen.body, T('setup.tag'), tagToPaste(derived.id));
2502
2569
  copyBlock(screen.body, T('setup.server'), serverConfig(derived.id));
@@ -2515,7 +2582,7 @@
2515
2582
  proceed.addEventListener('click', () => {
2516
2583
  const kept = writeSalt(derived.id, fresh);
2517
2584
  const done = create('p', 'ap-help',
2518
- kept ? T('setup.done') : T('salt.not_kept'));
2585
+ kept ? T('setup.done') : T('key.not_kept'));
2519
2586
  actions.replaceWith(done);
2520
2587
  });
2521
2588
  actions.appendChild(proceed);
@@ -2535,7 +2602,7 @@
2535
2602
  reason said out loud rather than a tool that quietly does not appear.
2536
2603
 
2537
2604
  There is no field to correct here, and that is the difference with the
2538
- salt screen: the mistake is in the page's source, not in this browser. So
2605
+ key screen: the mistake is in the page's source, not in this browser. So
2539
2606
  the screen names what has to change in the tag, and stops. */
2540
2607
 
2541
2608
  const openTagScreen = (detail) => {
@@ -2761,8 +2828,8 @@
2761
2828
  The order matters: we ask the API BEFORE touching the DOM. If it does not
2762
2829
  answer what it should, the site never saw anything go by.
2763
2830
 
2764
- One exception, accepted: the setup and salt-pasting screens, which CANNOT
2765
- ask the API -- without a salt there is no page index to give it. They are
2831
+ One exception, accepted: the setup and key-pasting screens, which CANNOT
2832
+ ask the API -- without a key there is no page index to give it. They are
2766
2833
  declared (data-setup) or asked for by a tag that already carries a
2767
2834
  project: either way, somebody put that tag here on purpose. */
2768
2835
 
@@ -2831,11 +2898,11 @@
2831
2898
  r.cause === 'server' || r.cause === 'failure' || String(r.cause).indexOf('refused') === 0;
2832
2899
 
2833
2900
  /**
2834
- * The salt is known and checked: we derive the page index, we ask the
2901
+ * The key is known and checked: we derive the page index, we ask the
2835
2902
  * server, and the tool takes its normal shape.
2836
2903
  */
2837
2904
  function startWithSalt(text, derived) {
2838
- saltText = text;
2905
+ keyText = text;
2839
2906
  keys = derived;
2840
2907
 
2841
2908
  return indexOfPath(keys.indexKey, pagePath())
@@ -2845,7 +2912,7 @@
2845
2912
  })
2846
2913
  .then((first) => {
2847
2914
  if (!first.ok && !speaksAtStartup(first)) {
2848
- // Complete silence: no node, no pixel, no message. If a salt
2915
+ // Complete silence: no node, no pixel, no message. If a key
2849
2916
  // screen was open, it goes away with the rest.
2850
2917
  withdraw();
2851
2918
  return null;
@@ -2877,6 +2944,46 @@
2877
2944
  });
2878
2945
  }
2879
2946
 
2947
+ /**
2948
+ * THE OTHER WAY ROUND: the key leaves this browser.
2949
+ *
2950
+ * The exact undoing of startWithSalt, and it is written next to it for that
2951
+ * reason -- the state a key brings in is the state its removal has to take
2952
+ * back out. Anything left behind here is a note decrypted with a key the
2953
+ * tool now claims not to have.
2954
+ *
2955
+ * What actually leaves is ONE entry, `annotepage/key/<project>`: this
2956
+ * project, this origin. Another project reviewed from the same browser keeps
2957
+ * its own key, and so does this project on another domain -- localStorage is
2958
+ * per origin, and forgetting cannot reach further than it (30-state).
2959
+ *
2960
+ * Nothing is deleted anywhere else. The notes stay on the server, encrypted;
2961
+ * pasting the key again brings the whole page back. That is what the
2962
+ * confirmation says, and it is the whole reason it can be said calmly.
2963
+ */
2964
+ const forgetKey = () => {
2965
+ forgetSalt(PROJECT);
2966
+
2967
+ // Annotation mode holds listeners on `document` and `window` and a
2968
+ // repeating timer, none of which sit on the host: leaving the mode is
2969
+ // what takes them down. It has to happen while `ui` still exists.
2970
+ if (ui && mode) leaveMode();
2971
+
2972
+ keyText = '';
2973
+ keys = null;
2974
+ PAGE_INDEX = '';
2975
+ notes = [];
2976
+ anchored = [];
2977
+ orphans = [];
2978
+ historyOpen = false;
2979
+ currentFailure = null;
2980
+ skipped = { newer: 0, unreadable: 0, unknown: 0 };
2981
+
2982
+ // And the tool is back where it was before the key was pasted: the
2983
+ // screen that asks for it. openSaltScreen clears the layer it replaces.
2984
+ openSaltScreen();
2985
+ };
2986
+
2880
2987
  /** Everything the tool does once it has decided to stay. */
2881
2988
  function proceed(first) {
2882
2989
  // From here on the tool EXISTS, and will no longer keep quiet
@@ -2919,7 +3026,7 @@
2919
3026
  day the tag changes. The interface then says so at every draw
2920
3027
  (PUBLIC_KEY, 60-ui). */
2921
3028
  if (KEY_DECLARED) {
2922
- const keyBytes = saltFromText(DECLARED_KEY);
3029
+ const keyBytes = keyFromText(DECLARED_KEY);
2923
3030
  if (!keyBytes) {
2924
3031
  /* An attribute somebody wrote on purpose, and it is not a key.
2925
3032
  Staying silent here would be the behaviour of a tag carrying
@@ -2958,7 +3065,7 @@
2958
3065
  }
2959
3066
 
2960
3067
  const text = readSalt(PROJECT);
2961
- const bytes = saltFromText(text);
3068
+ const bytes = keyFromText(text);
2962
3069
  if (!bytes) {
2963
3070
  showScreen(openSaltScreen);
2964
3071
  return;
@@ -2966,7 +3073,7 @@
2966
3073
 
2967
3074
  derive(bytes).then((derived) => {
2968
3075
  if (derived.id !== PROJECT) {
2969
- // The salt stored under this key does not derive this id: the
3076
+ // The key stored under this key does not derive this id: the
2970
3077
  // tag has changed project, or the storage was tampered with. We
2971
3078
  // ask again, we do not guess.
2972
3079
  showScreen(openSaltScreen);
package/labels/fr.json CHANGED
@@ -72,17 +72,18 @@
72
72
  "read.title_partial": "Certaines notes n’ont pas pu être lues",
73
73
  "marker.one": "1 note ici",
74
74
  "marker.n": "{n} notes ici",
75
- "salt.title": "La clé de ce projet est nécessaire",
76
- "salt.help": "Les notes de ce projet sont chiffrées dans votre navigateur. Sans la clé du projet, ce navigateur ne peut ni les lire, ni en écrire. Demandez-la à la personne qui a installé l’outil, et collez-la ci-dessous. Elle sera retenue par ce navigateur, pour ce site.",
77
- "salt.label": "La clé du projet (43 caractères)",
78
- "salt.confirm": "Utiliser cette clé",
79
- "salt.empty": "Collez la clé avant de valider.",
80
- "salt.shape": "Ce n’est pas une clé : on attend 43 caractères parmi A-Z a-z 0-9 - _, sans espace ni tiret décoratif. Recopiez-la d’un seul bloc.",
81
- "salt.wrong": "Cette clé n’est pas celle de ce projet. Rien n’a été envoyé, rien n’a été déchiffré. Vérifiez que vous collez la clé du bon projet.",
82
- "salt.origin_changed": "Cette clé est retenue par navigateur ET par domaine. Le jour où la préproduction devient la production, il faut la recoller une fois sur le nouveau domaine — les notes, elles, ne bougent pas.",
83
- "salt.not_kept": "Ce navigateur refuse de retenir la clé (navigation privée, ou stockage bloqué). L’outil fonctionne pour cette page, mais la clé sera à recoller au prochain chargement.",
84
- "salt.replace": "Coller une autre clé",
85
- "salt.forget": "Oublier la clé sur ce navigateur",
75
+ "key.title": "La clé de ce projet est nécessaire",
76
+ "key.help": "Les notes de ce projet sont chiffrées dans votre navigateur. Sans la clé du projet, ce navigateur ne peut ni les lire, ni en écrire. Demandez-la à la personne qui a installé l’outil, et collez-la ci-dessous. Elle sera retenue par ce navigateur, pour ce site.",
77
+ "key.label": "La clé du projet (43 caractères)",
78
+ "key.confirm": "Utiliser cette clé",
79
+ "key.empty": "Collez la clé avant de valider.",
80
+ "key.shape": "Ce n’est pas une clé : on attend 43 caractères parmi A-Z a-z 0-9 - _, sans espace ni tiret décoratif. Recopiez-la d’un seul bloc.",
81
+ "key.wrong": "Cette clé n’est pas celle de ce projet. Rien n’a été envoyé, rien n’a été déchiffré. Vérifiez que vous collez la clé du bon projet.",
82
+ "key.origin_changed": "Cette clé est retenue par navigateur ET par domaine. Le jour où la préproduction devient la production, il faut la recoller une fois sur le nouveau domaine — les notes, elles, ne bougent pas.",
83
+ "key.not_kept": "Ce navigateur refuse de retenir la clé (navigation privée, ou stockage bloqué). L’outil fonctionne pour cette page, mais la clé sera à recoller au prochain chargement.",
84
+ "key.replace": "Coller une autre clé",
85
+ "key.forget": "Oublier la clé sur ce navigateur",
86
+ "key.forget_confirm": "Ce navigateur cessera de retenir la clé de ce projet : l’outil la redemandera ici, et sur toutes les pages de ce site. Les notes ne sont pas touchées — elles restent sur le serveur, chiffrées, et reviennent dès que la clé est recollée. Assurez-vous de pouvoir encore vous procurer la clé avant de valider : personne, pas même le serveur, ne peut la redonner.",
86
87
  "public.notice": "Chiffré de bout en bout, et la clé de ce projet est publique : elle est écrite dans cette page. Quiconque peut ouvrir la page peut lire ces notes ET en écrire — la clé donne les deux, et ce format n’a pas de rôle lecteur seul.",
87
88
  "upgrade.available": "Un client annotepage plus récent existe : {version}. Cette page exécute la version {current}, servie par le site lui-même — rien n’a été chargé pour la remplacer, et le moment de mettre à jour le fichier appartient au propriétaire.",
88
89
  "tag.title": "Cette balise annotepage est inutilisable telle quelle",
@@ -92,7 +93,7 @@
92
93
  "setup.generate": "Engendrer une clé et créer le projet",
93
94
  "setup.warning_title": "À lire avant de continuer",
94
95
  "setup.warning": "La clé ci-dessous est le SEUL secret du projet, et personne d’autre ne l’a : ni le serveur, ni l’auteur de l’outil, ni personne à qui la demander. CLÉ PERDUE = NOTES PERDUES, définitivement, sans recours. Rangez-la maintenant, là où votre équipe range ses mots de passe, avant de continuer.",
95
- "setup.salt": "La clé du projet — à conserver",
96
+ "setup.key": "La clé du projet — à conserver",
96
97
  "setup.project": "L’identifiant du projet — public, il va dans la page",
97
98
  "setup.tag": "La balise à coller en fin de <body>, sur les pages à annoter",
98
99
  "setup.server": "À déclarer dans la configuration du serveur",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "annotepage-client",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "The annotepage annotation layer: click an element of a page, leave a remark on it, it is encrypted in the browser.",
5
5
  "license": "MIT",
6
6
  "repository": {