annotepage-client 2.0.2 → 2.2.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
@@ -33,8 +33,8 @@ 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.0.2/dist/annotepage.js"
37
- integrity="sha384-aSkx1XQLhcYpDQrpoD8yUn0Wu00BHbi1KktWiXqMQufioNtReQw+tm804/ZzNfo8"
36
+ <script src="https://<your-cdn>/annotepage-client@2.2.0/dist/annotepage.js"
37
+ integrity="sha384-5wrAEkCKCLyEM3YJsVd6H7gFCOHt9f63XTDIM0Eu4fVYEAr3X4wgDWEDXkx/WVb/"
38
38
  crossorigin="anonymous"
39
39
  data-server="https://<your-server>/annotepage/api.php"
40
40
  data-setup
@@ -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.0.2/dist/annotepage.js"
58
- integrity="sha384-aSkx1XQLhcYpDQrpoD8yUn0Wu00BHbi1KktWiXqMQufioNtReQw+tm804/ZzNfo8"
57
+ <script src="https://<your-cdn>/annotepage-client@2.2.0/dist/annotepage.js"
58
+ integrity="sha384-5wrAEkCKCLyEM3YJsVd6H7gFCOHt9f63XTDIM0Eu4fVYEAr3X4wgDWEDXkx/WVb/"
59
59
  crossorigin="anonymous"
60
60
  data-server="https://<your-server>/annotepage/api.php"
61
61
  data-project="7Qb1kZ3xNvA9dLpEqKf2Zt"
@@ -93,7 +93,8 @@ browser.
93
93
  | Attribute | What it declares |
94
94
  |---|---|
95
95
  | `data-server` | the address of `api.php`. Required as soon as the client comes from a CDN. Without it, and only if the client is served by the site, the tool deduces `../api.php` from its own address -- as in version 1.2.0 |
96
- | `data-project` | the project id, 22 characters. Without it the tool does **nothing** (except with `data-setup`) |
96
+ | `data-key` | **the key itself**, 43 characters. The project is then **public**: the tool derives the project id from it, asks for nothing, stores nothing, and starts. See below |
97
+ | `data-project` | the project id, 22 characters. The project is then **confidential**: the key is asked for once per browser. Without either attribute the tool does **nothing** (except with `data-setup`) |
97
98
  | `data-setup` | opens the setup screen. To be removed once the project is created |
98
99
  | `data-mode` | `encrypted` (default) or `plain`. See below |
99
100
  | `data-path` | path prefix: the pages of the project. `/fr/` does not annotate `/en/` |
@@ -112,6 +113,63 @@ NOT deployed, the note stays visible.
112
113
  A standalone tool does not guess how a site names its version: without these
113
114
  attributes the fields stay empty, and that is intended.
114
115
 
116
+ ## The key in the tag, or the id in the tag
117
+
118
+ The tag carries **one of the two**, and which one it carries **is** the mode.
119
+ There is no setting anywhere else, and nothing in a stored note records it:
120
+
121
+ ```html
122
+ <!-- public: whoever can open the page reads AND writes -->
123
+ <script src="..." data-server="..." data-key="<43 characters>" defer></script>
124
+
125
+ <!-- confidential: each reviewer pastes the key once, per browser -->
126
+ <script src="..." data-server="..." data-project="<22 characters>" defer></script>
127
+ ```
128
+
129
+ The id is **not** written next to the key: the client derives it (HKDF label
130
+ `id`), so writing both is writing the same fact twice in a tag people copy by
131
+ hand. A tag carrying both is not a third mode -- the client checks that they
132
+ agree and **refuses** the whole tag if they do not, with nothing sent and
133
+ nothing decrypted, rather than guessing which of the two is the typo. A
134
+ `data-key` that is not 43 base64url characters is refused the same way, and
135
+ said on screen: somebody put that attribute there on purpose.
136
+
137
+ When the key is public, the tool says so **in the panel, at every draw** -- not
138
+ once at load time:
139
+
140
+ > End-to-end encrypted, and the key of this project is public: it is written
141
+ > into this page. Anyone who can open the page can read these notes AND write
142
+ > them -- the key gives both, and this format has no reader-only role.
143
+
144
+ ### What a public key actually opens, and it is not mainly reading
145
+
146
+ Reading is the obvious half. **The key gives read AND write, and there is no
147
+ reader role in this format**: a page anybody can open is a page anybody can
148
+ post to. Two consequences that have to be named before choosing it:
149
+
150
+ - the project's note cap is the only thing between that and a full database.
151
+ The relay's rate limit is per IP, which is not an answer to more than one of
152
+ them;
153
+ - **a copied tag writes into your project.** The domain lock is what normally
154
+ stops that -- and on a relay with open registration a project has no declared
155
+ origins to match, so it does not apply. Someone who lifts your public tag out
156
+ of your page source writes into your notes, from their own site.
157
+
158
+ On a staging site behind a login, a VPN or an IP allowlist, that audience is
159
+ your team: the notes are protected by the same thing that protects the site,
160
+ being able to reach it. On a public production page, that audience is the
161
+ internet.
162
+
163
+ And it is **irreversible**: the key is served to whoever the page is served to,
164
+ search engines and archive sites included. A project made public cannot be made
165
+ private again -- that would mean a new key, therefore a new project id,
166
+ therefore a new tag, and the old notes stay behind.
167
+
168
+ What does **not** change: the notes are encrypted exactly as before, the server
169
+ still cannot read a single one, and a note written on a public key is an
170
+ ordinary `encrypted` note. The public/confidential choice is not the
171
+ `data-mode` below -- they are two different words for two different things.
172
+
115
173
  ## Encrypted, or plain
116
174
 
117
175
  Encryption is **on by default**. In encrypted mode, everything typed or
@@ -185,6 +243,12 @@ A refusal on the very first call is shown too, unlike 1.2.0: a firewall
185
243
  blocking everything made the tool entirely invisible, and one looked for the
186
244
  failure in the wrong file.
187
245
 
246
+ **And a tag that cannot be used is named too**, for the same reason: a
247
+ `data-key` that is not a key, or a `data-key` and a `data-project` that do not
248
+ derive one another, open a screen saying which of the two is wrong. Standing
249
+ down silently would be indistinguishable from a page with no tag at all, and
250
+ that is the difference nobody finds.
251
+
188
252
  ## What it does not touch
189
253
 
190
254
  The client adds **one single element** to the site, at the end of `<body>`, and
package/dist/HASHES.txt CHANGED
@@ -1,3 +1,5 @@
1
- 2.0.2 sha384-ozz/RzNqWwcqwcKVLVrrJht2k97gwDYfvH4AkZyUAxW0lPbKiip8wj9hDvD6PNhy 134184 bytes
1
+ 2.2.0 sha384-5wrAEkCKCLyEM3YJsVd6H7gFCOHt9f63XTDIM0Eu4fVYEAr3X4wgDWEDXkx/WVb/ 154893 bytes
2
+ 2.1.0 sha384-tMQlD7gryDE13ShW/GCVLMhJU8tqODcREl9RksXuf67eectM4YPTUZXLfkmakFoO 142816 bytes
3
+ 2.0.2 sha384-IeG681tUVluUYNg632FP8SMWIHZp1pEEP5/s6XT+PIJpbp7JJDkGThQPVx1xf31p 142816 bytes
2
4
  2.0.1 sha384-aSkx1XQLhcYpDQrpoD8yUn0Wu00BHbi1KktWiXqMQufioNtReQw+tm804/ZzNfo8 134184 bytes
3
5
  2.0.0 sha384-hqDI4neEKI/hhkIKK5P4GVNRQrJ5dFYvBu9JovxWccyK9irmi+ouvhrgrumlXnXl 134205 bytes
@@ -1,7 +1,7 @@
1
1
  /* ============================================================================
2
2
  annotepage -- the annotation layer, browser side.
3
3
 
4
- Package version : 2.0.2
4
+ Package version : 2.2.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.0.2";
19
+ const TOOL_VERSION = "2.2.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/* ----------------------------------------------------------------------------\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.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
 
@@ -62,7 +62,44 @@
62
62
  and a page that never shows a single note. */
63
63
  const DECLARED_PROJECT = read('project');
64
64
  const PROJECT_WELL_FORMED = /^[A-Za-z0-9_-]{22}$/.test(DECLARED_PROJECT);
65
- const PROJECT = PROJECT_WELL_FORMED ? DECLARED_PROJECT : '';
65
+ /* NOT a const: with data-key the id is DERIVED rather than declared, and
66
+ 90-boot writes it here once derive() has produced it. There is one PROJECT
67
+ in this scope and everything downstream reads it -- two would have
68
+ diverged. */
69
+ let PROJECT = PROJECT_WELL_FORMED ? DECLARED_PROJECT : '';
70
+
71
+ /* THE KEY, WRITTEN IN THE TAG -- and that attribute IS the mode.
72
+
73
+ data-key the key itself: the project is PUBLIC. Whoever can load the
74
+ page can read the notes and write them. Nothing is asked for,
75
+ nothing is stored, and no id is declared: derive() already
76
+ produces it from the key (HKDF label "id"), so writing both
77
+ would be writing the same fact twice in a tag people copy by
78
+ hand -- where the two can disagree.
79
+ data-project the id alone: confidential. The key is asked for once per
80
+ browser, and until it is there nothing is fetched and nothing
81
+ is decrypted. That is the behaviour of every version so far.
82
+ data-setup neither, temporarily.
83
+
84
+ THE KEY IS NOT DERIVED FROM THE DOMAIN, and it never will be. The browser
85
+ hands the relay an Origin header on every request (FORMAT.md section 6.2),
86
+ so a relay knows the domain of every project writing to it: a key that was
87
+ a function of the domain would be a key the relay can compute, and with it
88
+ the id, and with both every note it stores. That is plain mode sold as
89
+ encrypted. The key is random, it lives in the page, and the page is the
90
+ one thing the server never sees.
91
+
92
+ The SHAPE is not checked here: saltFromText() in 20-crypto is the single
93
+ judge of what a key looks like, and it lives in the section that owns the
94
+ format. What is recorded here is whether the attribute was WRITTEN at all
95
+ -- an empty data-key is a tag somebody meant to fill in, and it gets said
96
+ rather than ignored. */
97
+ const DECLARED_KEY = read('key');
98
+ const KEY_DECLARED = Object.prototype.hasOwnProperty.call(data, 'key');
99
+
100
+ /* True once the key in the tag has been checked and adopted. It is what the
101
+ interface says out loud, at every draw: see PUBLIC_KEY in 60-ui. */
102
+ let PUBLIC_KEY = false;
66
103
 
67
104
  /* The write mode for the notes TO COME. Encrypted by default: it is the only
68
105
  default that does not ask the installer to understand the threat model
@@ -497,6 +534,44 @@
497
534
  'salt.replace': 'Paste another key',
498
535
  'salt.forget': 'Forget the key on this browser',
499
536
 
537
+ /* -- A project whose key is IN the page ---------------------------
538
+ Said where the notes are, at every draw, and not once at startup: it
539
+ is a standing property of the project, not an event. The write half
540
+ is the one nobody expects -- the key gives both, and this format has
541
+ no reader-only role. */
542
+ 'public.notice':
543
+ 'End-to-end encrypted, and the key of this project is public: it is '
544
+ + 'written into this page. Anyone who can open the page can read these '
545
+ + 'notes AND write them -- the key gives both, and this format has no '
546
+ + 'reader-only role.',
547
+
548
+ /* -- A newer client exists, and this copy is not going to fetch it -
549
+ Shown ONLY when the file is served by the site itself: a copy served
550
+ by a CDN replaces itself instead of talking about it (80-upgrade).
551
+ So the sentence has to say what was NOT done, and whose call it is. */
552
+ 'upgrade.available':
553
+ 'A more recent annotepage client exists: {version}. This page is '
554
+ + 'running {current}, served by the site itself -- nothing was fetched '
555
+ + 'to replace it, and when to update the file is the owner\'s call.',
556
+
557
+ /* -- A tag that cannot be used as it stands ------------------------
558
+ Somebody put that tag there on purpose, so we speak instead of staying
559
+ silent -- and we refuse exactly as a wrong pasted key is refused
560
+ today: nothing sent, nothing decrypted. */
561
+ 'tag.title': 'This annotepage tag cannot be used',
562
+ 'tag.key_shape':
563
+ 'The data-key attribute of the tag on this page is not a key: 43 '
564
+ + 'characters are expected, from A-Z a-z 0-9 - _, with no space and no '
565
+ + 'decorative dash. Nothing was sent and nothing was decrypted. '
566
+ + 'Whoever installed the tool has to copy the key again, in one block.',
567
+ 'tag.key_mismatch':
568
+ 'The tag on this page carries a key and a project id that do not go '
569
+ + 'together: the key does not derive that id. Nothing was sent and '
570
+ + 'nothing was decrypted, and the tool does not guess which of the two '
571
+ + 'is right. A public tag needs the key alone -- the id is derived '
572
+ + 'from it -- so remove data-project, or correct whichever of the two '
573
+ + 'is wrong.',
574
+
500
575
  /* -- Setup --------------------------------------------------------- */
501
576
  'setup.title': 'Install annotepage on this site',
502
577
  'setup.generate': 'Generate a key and create the project',
@@ -1788,6 +1863,38 @@
1788
1863
  empty(ui.body);
1789
1864
  empty(ui.footer);
1790
1865
 
1866
+ /* THE PROJECT RUNS ON A KEY THAT IS IN THE PAGE, AND IT SAYS SO -- here,
1867
+ where the notes are, at every draw and not once at startup.
1868
+
1869
+ It sits above everything else, failures included, because it is not an
1870
+ event: it describes what the notes underneath ARE. A one-off message
1871
+ at load time would be read by whoever happened to be looking, once,
1872
+ and by nobody who opens this panel a week later.
1873
+
1874
+ The half that has to survive being skim-read is the WRITE half: the
1875
+ key gives read and write, this format has no reader role, so a page
1876
+ anybody can open is a page anybody can post to. */
1877
+ if (PUBLIC_KEY) {
1878
+ const notice = create('div', 'ap-public', T('public.notice'));
1879
+ notice.setAttribute('role', 'note');
1880
+ ui.body.appendChild(notice);
1881
+ }
1882
+
1883
+ /* A NEWER CLIENT EXISTS, AND WE ARE NOT GOING TO FETCH IT. This copy is
1884
+ served by the site itself -- somebody took the file off a CDN on
1885
+ purpose -- so it says so and stops there (80-upgrade). A copy served
1886
+ BY a CDN never gets here: it replaced itself before the panel existed.
1887
+
1888
+ Said at every draw and not once at load, for the same reason as the
1889
+ notice above: a message shown at load time is read by whoever happened
1890
+ to be looking, and by nobody who opens this panel a week later. */
1891
+ if (upgradeAvailable) {
1892
+ const notice = create('div', 'ap-upgrade',
1893
+ T('upgrade.available', { version: upgradeAvailable, current: TOOL_VERSION }));
1894
+ notice.setAttribute('role', 'note');
1895
+ ui.body.appendChild(notice);
1896
+ }
1897
+
1791
1898
  if (currentFailure) {
1792
1899
  ui.body.appendChild(failureBlock(currentFailure, () => {
1793
1900
  currentFailure = null;
@@ -1871,7 +1978,10 @@
1871
1978
  origin and the salt has to be pasted once more, on every browser.
1872
1979
  Without this button, one would have to clear the storage by hand to
1873
1980
  get there. */
1874
- if (PROJECT && saltText) {
1981
+ /* Not offered when the key comes from the tag: there is nothing stored
1982
+ to replace, and a key pasted here would be overruled by the tag on the
1983
+ next load -- while quietly leaving a copy in localStorage. */
1984
+ if (PROJECT && saltText && !PUBLIC_KEY) {
1875
1985
  const changeSalt = create('button', 'ap-link', T('salt.replace'));
1876
1986
  changeSalt.type = 'button';
1877
1987
  changeSalt.title = T('salt.origin_changed');
@@ -2419,6 +2529,23 @@
2419
2529
  });
2420
2530
  };
2421
2531
 
2532
+ /* -- A tag that refuses itself ------------------------------------------
2533
+
2534
+ Same rule as a key pasted wrong: nothing sent, nothing decrypted, and the
2535
+ reason said out loud rather than a tool that quietly does not appear.
2536
+
2537
+ 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
2539
+ the screen names what has to change in the tag, and stops. */
2540
+
2541
+ const openTagScreen = (detail) => {
2542
+ const screen = blockingScreen(T('tag.title'), false);
2543
+ const block = create('div', 'ap-error');
2544
+ block.setAttribute('role', 'alert');
2545
+ block.appendChild(create('p', 'ap-error-detail', detail));
2546
+ screen.body.appendChild(block);
2547
+ };
2548
+
2422
2549
  /* -- The "this browser cannot" screen ------------------------------------ */
2423
2550
 
2424
2551
  const openContextScreen = () => {
@@ -2426,9 +2553,182 @@
2426
2553
  screen.body.appendChild(create('p', 'ap-help', T('context.help')));
2427
2554
  };
2428
2555
 
2556
+ /* ==== 80-upgrade.js ==== */
2557
+
2558
+ /* -- 19. A stale copy replaces itself ------------------------------------
2559
+ The problem, in one line: the distributed tag points at a RANGE on a CDN
2560
+ (annotepage-client@2), and jsDelivr serves that range with
2561
+ max-age=604800. A fix published today does not reach a visitor who came
2562
+ back within seven days. The lifetime is the CDN's to set and no attribute
2563
+ of <script> touches it -- integrity, crossorigin, defer, async, none of
2564
+ them. Cache-Control is a response header; the requester cannot overrule
2565
+ it.
2566
+
2567
+ So we do not fight the cache, we WALK AROUND IT. The stale copy does not
2568
+ refresh its own URL: it loads a DIFFERENT one, the pinned
2569
+ annotepage-client@X.Y.Z/dist/annotepage.js, which this browser has never
2570
+ fetched and which therefore no cache entry can answer.
2571
+
2572
+ WHERE THE CURRENT VERSION COMES FROM: the answer the client already asks
2573
+ for. `list` runs before the DOM is touched, and the server names the
2574
+ current client version in it. No second request, no extra file to host,
2575
+ and the answer lands exactly at the seam where the check belongs.
2576
+
2577
+ IT ANNOUNCES, IT NEVER GATES. The announced version says "there is
2578
+ something newer" and nothing else. It must never decide whether a request
2579
+ is allowed, and nothing here compares it to authorise anything:
2580
+ compatibility belongs to the FORMAT number and only to it (FORMAT.md
2581
+ section 7). 2.1.0 and 2.2.0 speak the same format by construction, and a
2582
+ client that refused to talk to a server one release ahead would break a
2583
+ pair that works.
2584
+
2585
+ WHY EVERY DOUBT IS A SILENCE. The announcement rides on an answer from a
2586
+ server that may be anybody's. A self-hosted server announces whatever it
2587
+ was installed with, which can be older than what is on this page. So:
2588
+ absent, unreadable, malformed, EQUAL or OLDER than ours -- carry on, say
2589
+ nothing. We only ever move forward, never back. A server cannot push a
2590
+ client downhill. */
2591
+
2592
+ /* THE SHAPE OF A VERSION, and it is the only gate that matters here.
2593
+
2594
+ This string arrives over the network from a server we do not own. It is
2595
+ never concatenated into a URL as it stands: it is matched against this
2596
+ expression, and the URL is then REBUILT from a base written here, in this
2597
+ file. A compromised or hostile server can therefore make us load a version
2598
+ of the official package that does not exist -- which fails, and is handled
2599
+ -- and nothing else. No host, no path, no scheme, no protocol-relative
2600
+ "//evil", no "../", no query, no "@" of its own.
2601
+
2602
+ Strict on purpose: three numbers, nothing around them. No pre-release
2603
+ suffix (a pre-release is not what one pushes to every visitor of every
2604
+ site), no leading zero, at most four digits per part. */
2605
+ const ANNOUNCED_SHAPE = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/;
2606
+
2607
+ /* The CDNs whose exact-version address we know how to write ourselves, and
2608
+ only those. `base` is the whole beginning of the URL: nothing of what the
2609
+ server said ever appears before the version number.
2610
+
2611
+ `prefix` is matched against the PATHNAME of the tag's own src, and the host
2612
+ is compared whole -- indexOf on the full URL would accept
2613
+ "cdn.jsdelivr.net.example.com". Both entries are the npm package, which is
2614
+ the only package this file will ever point at. */
2615
+ const CDNS = [
2616
+ {
2617
+ host: 'cdn.jsdelivr.net',
2618
+ prefix: '/npm/annotepage-client@',
2619
+ base: 'https://cdn.jsdelivr.net/npm/annotepage-client@'
2620
+ },
2621
+ {
2622
+ host: 'unpkg.com',
2623
+ prefix: '/annotepage-client@',
2624
+ base: 'https://unpkg.com/annotepage-client@'
2625
+ }
2626
+ ];
2627
+
2628
+ /**
2629
+ * Which CDN is serving THIS copy, or null -- and null is the interesting
2630
+ * case, because it means the site serves the file itself.
2631
+ */
2632
+ const cdnServing = (src) => {
2633
+ let url;
2634
+ try {
2635
+ url = new URL(String(src));
2636
+ } catch (e) {
2637
+ return null;
2638
+ }
2639
+ if (url.protocol !== 'https:') return null;
2640
+ for (let i = 0; i < CDNS.length; i += 1) {
2641
+ const cdn = CDNS[i];
2642
+ if (url.host === cdn.host && url.pathname.indexOf(cdn.prefix) === 0) return cdn;
2643
+ }
2644
+ return null;
2645
+ };
2646
+
2647
+ /** The address of one exact version, BUILT HERE. Null if anything is off. */
2648
+ const officialUrl = (cdn, version) => {
2649
+ // Checked again, on the very line that builds the string: the caller
2650
+ // already checked, and a second reader of this file should not have to
2651
+ // go and verify that it did.
2652
+ if (!cdn || !ANNOUNCED_SHAPE.test(String(version))) return null;
2653
+ return cdn.base + version + '/dist/annotepage.js';
2654
+ };
2655
+
2656
+ /**
2657
+ * The version the server announces, IF it is newer than ours. Null in every
2658
+ * other case, and that includes every case of doubt: no field, not a string,
2659
+ * not three numbers, equal to ours, older than ours.
2660
+ */
2661
+ const announcedVersion = (data) => {
2662
+ if (!data || typeof data !== 'object') return null;
2663
+ const announced = data.client_version;
2664
+ if (typeof announced !== 'string' || !ANNOUNCED_SHAPE.test(announced)) return null;
2665
+
2666
+ const theirs = versionNumbers(announced);
2667
+ const mine = versionNumbers(TOOL_VERSION);
2668
+ if (!theirs || !mine) return null;
2669
+ for (let i = 0; i < 3; i += 1) {
2670
+ if (theirs[i] !== mine[i]) return theirs[i] > mine[i] ? announced : null;
2671
+ }
2672
+ // Equal: nothing to say, and nothing to load.
2673
+ return null;
2674
+ };
2675
+
2676
+ /* Set when we are behind and we are NOT going to do anything about it --
2677
+ the file is served by the site itself. The panel says so, at every draw,
2678
+ the way the public-key notice does: a message shown once at load is read
2679
+ by whoever happened to be looking. */
2680
+ let upgradeAvailable = '';
2681
+
2682
+ /* True from the moment the replacement tag is in the document. It stops the
2683
+ old copy building anything, and it stops a second injection. */
2684
+ let handingOver = false;
2685
+
2686
+ /**
2687
+ * Injects the pinned version and stands down.
2688
+ *
2689
+ * The new tag CARRIES THE data- ATTRIBUTES OF THE OLD ONE, and it has to:
2690
+ * the client reads everything it knows from its own tag
2691
+ * (document.currentScript, 00-preamble), so a bare tag would produce a copy
2692
+ * with no server, no project and no key -- which is a copy that does
2693
+ * strictly nothing. The integrity attribute is deliberately NOT carried
2694
+ * over: it is the digest of the version we are leaving, and it would refuse
2695
+ * the version we are fetching.
2696
+ *
2697
+ * `onFailure` is called if that tag never loads -- a version announced but
2698
+ * never published, a CDN that is down. We are then back to being merely old,
2699
+ * which is the state we started in, and the tool boots normally. Losing the
2700
+ * tool entirely because a number was wrong somewhere would be a worse
2701
+ * outcome than being one release behind.
2702
+ */
2703
+ const handOverTo = (cdn, version, onFailure) => {
2704
+ const url = officialUrl(cdn, version);
2705
+ if (!url || handingOver) return false;
2706
+ handingOver = true;
2707
+
2708
+ // BEFORE the new copy builds anything: its element and its listeners go
2709
+ // first, or the page ends up carrying two pills. At the seam there is
2710
+ // usually nothing to remove -- which is the whole point of checking
2711
+ // before the work rather than after it.
2712
+ withdraw();
2713
+
2714
+ const fresh = document.createElement('script');
2715
+ const attributes = script.attributes;
2716
+ for (let i = 0; i < attributes.length; i += 1) {
2717
+ const name = attributes[i].name;
2718
+ if (name.indexOf('data-') === 0) fresh.setAttribute(name, attributes[i].value);
2719
+ }
2720
+ fresh.src = url;
2721
+ fresh.addEventListener('error', () => {
2722
+ handingOver = false;
2723
+ onFailure();
2724
+ });
2725
+ (document.head || document.documentElement).appendChild(fresh);
2726
+ return true;
2727
+ };
2728
+
2429
2729
  /* ==== 90-boot.js ==== */
2430
2730
 
2431
- /* -- 19. Reading the notes ----------------------------------------------- */
2731
+ /* -- 20. Reading the notes ----------------------------------------------- */
2432
2732
 
2433
2733
  const redraw = () => {
2434
2734
  if (!ui) return;
@@ -2457,7 +2757,7 @@
2457
2757
  });
2458
2758
  });
2459
2759
 
2460
- /* -- 20. Startup ----------------------------------------------------------
2760
+ /* -- 21. Startup ----------------------------------------------------------
2461
2761
  The order matters: we ask the API BEFORE touching the DOM. If it does not
2462
2762
  answer what it should, the site never saw anything go by.
2463
2763
 
@@ -2486,7 +2786,22 @@
2486
2786
  });
2487
2787
  };
2488
2788
 
2789
+ /**
2790
+ * The tool leaves the page, and leaves NOTHING behind.
2791
+ *
2792
+ * Removing the host element is not enough on its own and never was: in
2793
+ * annotation mode the listeners sit on `document` and on `window`, not on the
2794
+ * host, and a repeating timer is running. Dropping the element would leave
2795
+ * them hovering, clicking and measuring a layer that no longer exists. That
2796
+ * did not show while withdrawal only ever happened before anything was built;
2797
+ * it does the moment a copy withdraws in favour of a newer one (80-upgrade).
2798
+ *
2799
+ * leaveMode() is the one place that knows the whole list, and it is called
2800
+ * rather than copied: two lists drift, and the one that drifts is the one
2801
+ * nobody runs.
2802
+ */
2489
2803
  const withdraw = () => {
2804
+ if (ui && mode) leaveMode();
2490
2805
  if (host) host.remove();
2491
2806
  host = null;
2492
2807
  root = null;
@@ -2536,24 +2851,51 @@
2536
2851
  return null;
2537
2852
  }
2538
2853
 
2539
- // From here on the tool EXISTS, and will no longer keep quiet
2540
- // about its failures.
2541
- buildHost();
2542
- return loadLocalLabels().then(() => {
2543
- clearLayer();
2544
- buildUi();
2545
- if (first.ok) {
2546
- return readList(first.data).then((read) => {
2547
- notes = read;
2548
- redraw();
2549
- return null;
2550
- });
2551
- }
2552
- currentFailure = failureFrom(first, 'error.title_read');
2854
+ /* THE SEAM. This is the one moment where a copy can discover it
2855
+ is out of date having drawn nothing, listened to nothing and
2856
+ decrypted nothing -- so it withdraws instead of undoing. The
2857
+ answer that carries the announcement is the one we were
2858
+ waiting for anyway: nothing was added in front of the boot,
2859
+ and a page that is up to date pays exactly nothing.
2860
+
2861
+ WHAT HAPPENS NEXT DEPENDS ON WHERE THIS FILE CAME FROM, and
2862
+ that distinction is the whole design:
2863
+
2864
+ from a CDN -- the seven-day cache is what put us here, and
2865
+ pulling the pinned version walks around it. We hand over.
2866
+
2867
+ from anywhere else -- the site serves its own copy, which
2868
+ somebody CHOSE to do, and going to a CDN behind their back
2869
+ would add the dependency they deliberately removed. We say
2870
+ it in the panel and we load nothing. */
2871
+ const newer = first.ok ? announcedVersion(first.data) : null;
2872
+ const cdn = newer ? cdnServing(script.src) : null;
2873
+ if (cdn && handOverTo(cdn, newer, () => { proceed(first); })) return null;
2874
+ if (newer) upgradeAvailable = newer;
2875
+
2876
+ return proceed(first);
2877
+ });
2878
+ }
2879
+
2880
+ /** Everything the tool does once it has decided to stay. */
2881
+ function proceed(first) {
2882
+ // From here on the tool EXISTS, and will no longer keep quiet
2883
+ // about its failures.
2884
+ buildHost();
2885
+ return loadLocalLabels().then(() => {
2886
+ clearLayer();
2887
+ buildUi();
2888
+ if (first.ok) {
2889
+ return readList(first.data).then((read) => {
2890
+ notes = read;
2553
2891
  redraw();
2554
2892
  return null;
2555
2893
  });
2556
- });
2894
+ }
2895
+ currentFailure = failureFrom(first, 'error.title_read');
2896
+ redraw();
2897
+ return null;
2898
+ });
2557
2899
  }
2558
2900
 
2559
2901
  const start = () => {
@@ -2566,7 +2908,47 @@
2566
2908
  if (!CRYPTO) {
2567
2909
  // Without a secure context nothing is possible -- but if somebody
2568
2910
  // declared a project here, they have a right to know why.
2569
- if (PROJECT || SETUP_REQUESTED) showScreen(openContextScreen);
2911
+ if (PROJECT || KEY_DECLARED || SETUP_REQUESTED) showScreen(openContextScreen);
2912
+ return;
2913
+ }
2914
+
2915
+ /* THE TAG CARRIES THE KEY: the project is public, and that settles the
2916
+ mode before anything else. Nothing is asked, nothing is read from
2917
+ localStorage and NOTHING IS WRITTEN TO IT -- the key is in the page,
2918
+ and a stored copy would buy nothing except a divergent state on the
2919
+ day the tag changes. The interface then says so at every draw
2920
+ (PUBLIC_KEY, 60-ui). */
2921
+ if (KEY_DECLARED) {
2922
+ const keyBytes = saltFromText(DECLARED_KEY);
2923
+ if (!keyBytes) {
2924
+ /* An attribute somebody wrote on purpose, and it is not a key.
2925
+ Staying silent here would be the behaviour of a tag carrying
2926
+ no project at all, and the difference is exactly what nobody
2927
+ would find. */
2928
+ showScreen(() => openTagScreen(T('tag.key_shape')));
2929
+ return;
2930
+ }
2931
+
2932
+ derive(keyBytes).then((derived) => {
2933
+ /* Both attributes on one tag: they have to AGREE, and the id is
2934
+ the one thing the key can check itself against (FORMAT.md
2935
+ 1.2). Disagreement is refused exactly as a wrongly pasted key
2936
+ is -- nothing sent, nothing decrypted -- and no winner is
2937
+ picked: one of the two is a typo, and guessing buries it in a
2938
+ project whose notes nobody will ever see. */
2939
+ if (DECLARED_PROJECT && derived.id !== DECLARED_PROJECT) {
2940
+ showScreen(() => openTagScreen(T('tag.key_mismatch')));
2941
+ return null;
2942
+ }
2943
+ // The id is DERIVED, never declared twice: see 00-preamble.
2944
+ PROJECT = derived.id;
2945
+ PUBLIC_KEY = true;
2946
+ return startWithSalt(DECLARED_KEY, derived);
2947
+ }, () => {
2948
+ // derive() only fails when WebCrypto itself does, which is what
2949
+ // that screen is about.
2950
+ showScreen(openContextScreen);
2951
+ });
2570
2952
  return;
2571
2953
  }
2572
2954
 
package/labels/fr.json CHANGED
@@ -83,6 +83,11 @@
83
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
84
  "salt.replace": "Coller une autre clé",
85
85
  "salt.forget": "Oublier la clé sur ce navigateur",
86
+ "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
+ "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
+ "tag.title": "Cette balise annotepage est inutilisable telle quelle",
89
+ "tag.key_shape": "L’attribut data-key de la balise de cette page n’est pas une clé : 43 caractères sont attendus, parmi A-Z a-z 0-9 - _, sans espace et sans tiret décoratif. Rien n’a été envoyé, rien n’a été déchiffré. La personne qui a installé l’outil doit recopier la clé, d’un seul bloc.",
90
+ "tag.key_mismatch": "La balise de cette page porte une clé et un identifiant de projet qui ne vont pas ensemble : la clé ne dérive pas cet identifiant. Rien n’a été envoyé, rien n’a été déchiffré, et l’outil ne devine pas lequel des deux a raison. Une balise publique se suffit de la clé — l’identifiant en est dérivé — donc retirez data-project, ou corrigez celui des deux qui est faux.",
86
91
  "setup.title": "Installer annotepage sur ce site",
87
92
  "setup.generate": "Engendrer une clé et créer le projet",
88
93
  "setup.warning_title": "À lire avant de continuer",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "annotepage-client",
3
- "version": "2.0.2",
3
+ "version": "2.2.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": {