narrarium-astro-reader 0.1.27 → 0.1.28

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.
@@ -0,0 +1,19 @@
1
+ /** Return the singleton salt for this build process, creating it on first call. */
2
+ export declare function getBuildSalt(): Buffer;
3
+ /** Base64-encoded build salt, ready to embed in an HTML attribute. */
4
+ export declare function getBuildSaltBase64(): string;
5
+ export interface EncryptedChunk {
6
+ /** Base64-encoded 12-byte random IV. */
7
+ iv: string;
8
+ /** Base64-encoded (ciphertext ∥ 16-byte GCM auth tag). */
9
+ ct: string;
10
+ }
11
+ /**
12
+ * Encrypt a UTF-8 string with AES-256-GCM using PBKDF2-derived key.
13
+ *
14
+ * The ciphertext field contains the encrypted bytes immediately followed by
15
+ * the 16-byte GCM authentication tag, so Web Crypto's AES-GCM decrypt can
16
+ * verify integrity without any additional framing.
17
+ */
18
+ export declare function encryptString(plaintext: string, password: string): EncryptedChunk;
19
+ //# sourceMappingURL=content-crypto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAcA,mFAAmF;AACnF,wBAAgB,YAAY,IAAI,MAAM,CAKrC;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAMD,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,CAWjF"}
@@ -0,0 +1,45 @@
1
+ import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
2
+ /**
3
+ * Build-time AES-256-GCM content encryption utilities.
4
+ *
5
+ * A single random 16-byte salt is generated once per Node process (i.e. once
6
+ * per Astro build). It is embedded publicly in the built HTML via
7
+ * `data-crypto-salt` on `<body>` so the client can run PBKDF2 key derivation
8
+ * with the same parameters. A public salt is fine — it only prevents rainbow
9
+ * tables; the security comes from the password entropy.
10
+ */
11
+ let _buildSalt = null;
12
+ /** Return the singleton salt for this build process, creating it on first call. */
13
+ export function getBuildSalt() {
14
+ if (!_buildSalt) {
15
+ _buildSalt = randomBytes(16);
16
+ }
17
+ return _buildSalt;
18
+ }
19
+ /** Base64-encoded build salt, ready to embed in an HTML attribute. */
20
+ export function getBuildSaltBase64() {
21
+ return getBuildSalt().toString("base64");
22
+ }
23
+ function deriveKey(password, salt) {
24
+ return pbkdf2Sync(password, salt, 100_000, 32, "sha256");
25
+ }
26
+ /**
27
+ * Encrypt a UTF-8 string with AES-256-GCM using PBKDF2-derived key.
28
+ *
29
+ * The ciphertext field contains the encrypted bytes immediately followed by
30
+ * the 16-byte GCM authentication tag, so Web Crypto's AES-GCM decrypt can
31
+ * verify integrity without any additional framing.
32
+ */
33
+ export function encryptString(plaintext, password) {
34
+ const salt = getBuildSalt();
35
+ const key = deriveKey(password, salt);
36
+ const iv = randomBytes(12);
37
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
38
+ const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
39
+ const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
40
+ return {
41
+ iv: iv.toString("base64"),
42
+ ct: Buffer.concat([body, tag]).toString("base64"),
43
+ };
44
+ }
45
+ //# sourceMappingURL=content-crypto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-crypto.js","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtE;;;;;;;;GAQG;AAEH,IAAI,UAAU,GAAkB,IAAI,CAAC;AAErC,mFAAmF;AACnF,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,UAAU,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC/B,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB;IAChC,OAAO,YAAY,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB,EAAE,IAAY;IAC/C,OAAO,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,CAAC;AAC3D,CAAC;AASD;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,QAAgB;IAC/D,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC/E,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,8BAA8B;IAC/D,OAAO;QACL,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACzB,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;KAClD,CAAC;AACJ,CAAC"}
@@ -1,2 +1,15 @@
1
1
  export declare function isFullCanonMode(): boolean;
2
+ /**
3
+ * Returns the raw NARRARIUM_READER_PASSWORD env var value, or null when the
4
+ * variable is not set. Used at build time to derive the AES-256-GCM key for
5
+ * content encryption. Never embedded in the built HTML.
6
+ */
7
+ export declare function getReaderPassword(): string | null;
8
+ /**
9
+ * Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
10
+ * or null when the variable is not set. The hash is embedded in the built
11
+ * HTML and compared against the user's input at runtime via SubtleCrypto
12
+ * as a fast pre-filter before the more expensive PBKDF2 key derivation.
13
+ */
14
+ export declare function getReaderPasswordHash(): string | null;
2
15
  //# sourceMappingURL=reader-mode.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reader-mode.d.ts","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAEA,wBAAgB,eAAe,IAAI,OAAO,CAMzC"}
1
+ {"version":3,"file":"reader-mode.d.ts","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAGA,wBAAgB,eAAe,IAAI,OAAO,CAMzC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAEjD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,IAAI,CAIrD"}
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readReaderEnv } from "./env.js";
2
3
  export function isFullCanonMode() {
3
4
  const raw = String(readReaderEnv(["NARRARIUM_READER_CANON_MODE", "NARRARIUM_READER_ALLOW_FULL_CANON"]) ?? "")
@@ -5,4 +6,24 @@ export function isFullCanonMode() {
5
6
  .toLowerCase();
6
7
  return raw === "1" || raw === "true" || raw === "full" || raw === "author" || raw === "spoilers";
7
8
  }
9
+ /**
10
+ * Returns the raw NARRARIUM_READER_PASSWORD env var value, or null when the
11
+ * variable is not set. Used at build time to derive the AES-256-GCM key for
12
+ * content encryption. Never embedded in the built HTML.
13
+ */
14
+ export function getReaderPassword() {
15
+ return readReaderEnv(["NARRARIUM_READER_PASSWORD"]) ?? null;
16
+ }
17
+ /**
18
+ * Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
19
+ * or null when the variable is not set. The hash is embedded in the built
20
+ * HTML and compared against the user's input at runtime via SubtleCrypto
21
+ * as a fast pre-filter before the more expensive PBKDF2 key derivation.
22
+ */
23
+ export function getReaderPasswordHash() {
24
+ const raw = readReaderEnv(["NARRARIUM_READER_PASSWORD"]);
25
+ if (!raw)
26
+ return null;
27
+ return createHash("sha256").update(raw).digest("hex");
28
+ }
8
29
  //# sourceMappingURL=reader-mode.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"reader-mode.js","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,eAAe;IAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,aAAa,CAAC,CAAC,6BAA6B,EAAE,mCAAmC,CAAC,CAAC,IAAI,EAAE,CAAC;SAC1G,IAAI,EAAE;SACN,WAAW,EAAE,CAAC;IAEjB,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,UAAU,CAAC;AACnG,CAAC"}
1
+ {"version":3,"file":"reader-mode.js","sourceRoot":"","sources":["../../src/lib/reader-mode.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,eAAe;IAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,aAAa,CAAC,CAAC,6BAA6B,EAAE,mCAAmC,CAAC,CAAC,IAAI,EAAE,CAAC;SAC1G,IAAI,EAAE;SACN,WAAW,EAAE,CAAC;IAEjB,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,UAAU,CAAC;AACnG,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,aAAa,CAAC,CAAC,2BAA2B,CAAC,CAAC,IAAI,IAAI,CAAC;AAC9D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB;IACnC,MAAM,GAAG,GAAG,aAAa,CAAC,CAAC,2BAA2B,CAAC,CAAC,CAAC;IACzD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACxD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "narrarium-astro-reader",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "Astro reader and scaffolding CLI for Narrarium book repositories.",
6
6
  "license": "MIT",
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { getReaderPassword } from "../lib/reader-mode.js";
3
+ import { encryptString } from "../lib/content-crypto.js";
4
+
5
+ interface Props {
6
+ /** Raw HTML string to either render directly or encrypt at build time. */
7
+ html: string;
8
+ }
9
+
10
+ const { html } = Astro.props;
11
+ const password = getReaderPassword();
12
+ const encrypted = password ? encryptString(html, password) : null;
13
+ ---
14
+
15
+ {encrypted
16
+ ? <span data-enc-html data-enc-iv={encrypted.iv} data-enc-ct={encrypted.ct} hidden></span>
17
+ : <Fragment set:html={html} />
18
+ }
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import type { GlossaryEntry } from "../lib/glossary";
3
+ import { getReaderPassword } from "../lib/reader-mode.js";
4
+ import { encryptString } from "../lib/content-crypto.js";
3
5
 
4
6
  interface Props {
5
7
  glossary: GlossaryEntry[];
@@ -8,7 +10,9 @@ interface Props {
8
10
  }
9
11
 
10
12
  const { glossary, chapters } = Astro.props;
11
- const glossaryJson = JSON.stringify(glossary).replace(/</g, "\\u003c");
13
+ const password = getReaderPassword();
14
+ const rawGlossaryJson = JSON.stringify(glossary).replace(/</g, "\\u003c");
15
+ const glossaryEncrypted = password ? encryptString(rawGlossaryJson, password) : null;
12
16
  const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
13
17
  ---
14
18
 
@@ -144,7 +148,10 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
144
148
  <div class="tts-status" data-tts-status>Speech will resume from the saved sentence when available.</div>
145
149
  </aside>
146
150
 
147
- <script type="application/json" id="canon-glossary-data" set:html={glossaryJson}></script>
151
+ {glossaryEncrypted
152
+ ? <script type="application/json" id="canon-glossary-data" data-enc-iv={glossaryEncrypted.iv} data-enc-ct={glossaryEncrypted.ct}></script>
153
+ : <script type="application/json" id="canon-glossary-data" set:html={rawGlossaryJson}></script>
154
+ }
148
155
  <script type="application/json" id="reader-chapter-data" set:html={chaptersJson}></script>
149
156
 
150
157
  <script>
@@ -160,21 +167,39 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
160
167
  const ttsRateStorageKey = "narrarium-reader-tts-rate";
161
168
  const ttsProgressStorageKey = "narrarium-reader-tts-progress";
162
169
  const ttsPlayerDismissedStorageKey = "narrarium-reader-tts-player-dismissed";
163
- const glossaryDataElement = document.getElementById("canon-glossary-data");
164
170
  const chapterDataElement = document.getElementById("reader-chapter-data");
165
- const glossaryEntries = glossaryDataElement ? JSON.parse(glossaryDataElement.textContent || "[]") : [];
166
171
  const chapterEntries = chapterDataElement ? JSON.parse(chapterDataElement.textContent || "[]") : [];
167
172
  const currentPageChapterRaw = document.body.dataset.readerChapterNumber;
168
173
  const currentPageChapterNumber = currentPageChapterRaw ? Number(currentPageChapterRaw) : Number.NaN;
169
174
 
175
+ // glossaryEntries is populated after content decryption (or immediately when
176
+ // there is no password protection). Declared as let so _loadGlossaryAndInit
177
+ // can reassign before dependent initializers run.
178
+ // eslint-disable-next-line prefer-const
179
+ let glossaryEntries: any[] = [];
180
+
181
+ function _loadGlossaryAndInit() {
182
+ const el = document.getElementById("canon-glossary-data");
183
+ // Guard: skip if the element still carries encrypted attrs (not yet decrypted).
184
+ if (!el || el.hasAttribute("data-enc-iv")) return;
185
+ try { glossaryEntries = JSON.parse(el.textContent || "[]"); } catch { glossaryEntries = []; }
186
+ initializeCanonMentions();
187
+ initializeReadAloud();
188
+ }
189
+
170
190
  initializeThemeToggle();
171
191
  initializeReaderPreferences();
172
192
  initializeReaderSettings();
173
193
  initializeSearchOverlay();
174
194
  initializeCanonModeToggle();
175
195
  initializeReadingProgress();
176
- initializeCanonMentions();
177
- initializeReadAloud();
196
+
197
+ // Wait for content to be available before initializing glossary-dependent features.
198
+ ((window as any)._narrariumContentReady || Promise.resolve("plain")).then((state: string) => {
199
+ if (state !== "locked") _loadGlossaryAndInit();
200
+ });
201
+ // Re-init after manual password unlock (state was "locked" when promise resolved).
202
+ document.addEventListener("narrarium:content-unlocked", _loadGlossaryAndInit);
178
203
 
179
204
  function initializeThemeToggle() {
180
205
  const saved = readStorage(themeStorageKey);
@@ -1,12 +1,16 @@
1
1
  ---
2
2
  import type { SearchEntry } from "../lib/search";
3
+ import { getReaderPassword } from "../lib/reader-mode.js";
4
+ import { encryptString } from "../lib/content-crypto.js";
3
5
 
4
6
  interface Props {
5
7
  entries: SearchEntry[];
6
8
  }
7
9
 
8
10
  const { entries } = Astro.props;
9
- const entriesJson = JSON.stringify(entries).replace(/</g, "\\u003c");
11
+ const password = getReaderPassword();
12
+ const rawEntriesJson = JSON.stringify(entries).replace(/</g, "\\u003c");
13
+ const entriesEncrypted = password ? encryptString(rawEntriesJson, password) : null;
10
14
  const filters = [
11
15
  { key: "all", label: "All" },
12
16
  ...Array.from(new Map(entries.map((entry) => [entry.kindKey, entry.kind])).entries()).map(([key, label]) => ({ key, label })),
@@ -40,15 +44,26 @@ const filters = [
40
44
  </div>
41
45
  </div>
42
46
 
43
- <script type="application/json" id="reader-search-data" set:html={entriesJson}></script>
47
+ {entriesEncrypted
48
+ ? <script type="application/json" id="reader-search-data" data-enc-iv={entriesEncrypted.iv} data-enc-ct={entriesEncrypted.ct}></script>
49
+ : <script type="application/json" id="reader-search-data" set:html={rawEntriesJson}></script>
50
+ }
44
51
 
45
52
  <script is:inline>
46
53
  (() => {
47
54
  const spoilerLimitStorageKey = "narrarium-reader-spoiler-limit";
48
- const searchDataElement = document.getElementById("reader-search-data");
49
- const searchEntries = searchDataElement ? JSON.parse(searchDataElement.textContent || "[]") : [];
50
55
  const searchRoot = document.querySelector("[data-site-search]");
51
56
 
57
+ // searchEntries is populated once content is ready (after decryption when
58
+ // password-protected, or immediately for plain builds).
59
+ var searchEntries = [];
60
+
61
+ function loadSearchEntries() {
62
+ const el = document.getElementById("reader-search-data");
63
+ if (!el || el.hasAttribute("data-enc-iv")) return; // still encrypted
64
+ try { searchEntries = JSON.parse(el.textContent || "[]"); } catch { searchEntries = []; }
65
+ }
66
+
52
67
  if (searchRoot) {
53
68
  const input = searchRoot.querySelector("[data-search-input]");
54
69
  const results = searchRoot.querySelector("[data-search-results]");
@@ -102,6 +117,13 @@ const filters = [
102
117
  });
103
118
  }
104
119
 
120
+ // Load search entries once content is available.
121
+ (window._narrariumContentReady || Promise.resolve("plain")).then(function (state) {
122
+ if (state !== "locked") loadSearchEntries();
123
+ });
124
+ // Re-load after manual password unlock.
125
+ document.addEventListener("narrarium:content-unlocked", loadSearchEntries);
126
+
105
127
  function getSpoilerLimit() {
106
128
  const value = Number(localStorage.getItem(spoilerLimitStorageKey) || "");
107
129
  return Number.isFinite(value) ? value : null;
@@ -5,7 +5,8 @@ import SiteSearch from "../components/SiteSearch.astro";
5
5
  import { listChapters } from "narrarium";
6
6
  import { getBookRoot } from "../lib/book.js";
7
7
  import { loadCanonGlossary } from "../lib/glossary.js";
8
- import { isFullCanonMode } from "../lib/reader-mode.js";
8
+ import { isFullCanonMode, getReaderPasswordHash, getReaderPassword } from "../lib/reader-mode.js";
9
+ import { getBuildSaltBase64 } from "../lib/content-crypto.js";
9
10
  import { loadSearchIndex } from "../lib/search.js";
10
11
 
11
12
  interface Props {
@@ -29,6 +30,8 @@ const canonGlossary = await loadCanonGlossary(currentChapterNumber);
29
30
  const searchIndex = await loadSearchIndex(currentChapterNumber);
30
31
  const readerChapters = await listChapters(getBookRoot()).catch(() => []);
31
32
  const fullCanonMode = isFullCanonMode();
33
+ const passwordHash = getReaderPasswordHash();
34
+ const cryptoSalt = getReaderPassword() ? getBuildSaltBase64() : null;
32
35
  const isChapterPage = Number.isFinite(currentChapterNumber);
33
36
  ---
34
37
 
@@ -59,7 +62,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
59
62
  })();
60
63
  </script>
61
64
  </head>
62
- <body class:list={[isChapterPage && "page-chapter"]} data-reader-chapter-number={currentChapterNumber} data-full-canon={fullCanonMode ? "true" : "false"}>
65
+ <body class:list={[isChapterPage && "page-chapter"]} data-reader-chapter-number={currentChapterNumber} data-full-canon={fullCanonMode ? "true" : "false"} data-reader-password-hash={passwordHash ?? ""} data-crypto-salt={cryptoSalt ?? undefined}>
63
66
  <script is:inline>
64
67
  (() => {
65
68
  try {
@@ -70,6 +73,138 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
70
73
  } catch {}
71
74
  })();
72
75
  </script>
76
+ <script is:inline>
77
+ (function () {
78
+ /* ── Content-ready promise ────────────────────────────────────────────
79
+ Resolved values:
80
+ "plain" – no encryption, content available immediately
81
+ "locked" – encrypted, no stored key, waiting for user password
82
+ "decrypted" – encrypted, auto-decrypted from stored AES key
83
+ ──────────────────────────────────────────────────────────────────── */
84
+ var salt = document.body.dataset.cryptoSalt;
85
+
86
+ if (!salt) {
87
+ // No encryption configured — content is always available.
88
+ window._narrariumContentReady = Promise.resolve("plain");
89
+ return;
90
+ }
91
+
92
+ // ── Helpers ──────────────────────────────────────────────────────────
93
+ function b64ToBytes(b64) {
94
+ var bin = atob(b64);
95
+ var bytes = new Uint8Array(bin.length);
96
+ for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
97
+ return bytes;
98
+ }
99
+
100
+ function aesDecrypt(cryptoKey, ivB64, ctB64) {
101
+ return crypto.subtle.decrypt(
102
+ { name: "AES-GCM", iv: b64ToBytes(ivB64) },
103
+ cryptoKey,
104
+ b64ToBytes(ctB64)
105
+ ).then(function (buf) { return new TextDecoder().decode(buf); });
106
+ }
107
+
108
+ function decryptAllContent(cryptoKey) {
109
+ var tasks = [];
110
+
111
+ // Decrypt JSON script data tags
112
+ document.querySelectorAll('script[type="application/json"][data-enc-iv]').forEach(function (el) {
113
+ var iv = el.getAttribute("data-enc-iv");
114
+ var ct = el.getAttribute("data-enc-ct");
115
+ if (!iv || !ct) return;
116
+ tasks.push(
117
+ aesDecrypt(cryptoKey, iv, ct).then(function (plaintext) {
118
+ el.textContent = plaintext;
119
+ el.removeAttribute("data-enc-iv");
120
+ el.removeAttribute("data-enc-ct");
121
+ })
122
+ );
123
+ });
124
+
125
+ // Decrypt inline HTML placeholder spans
126
+ document.querySelectorAll("[data-enc-html]").forEach(function (el) {
127
+ var iv = el.getAttribute("data-enc-iv");
128
+ var ct = el.getAttribute("data-enc-ct");
129
+ if (!iv || !ct) return;
130
+ tasks.push(
131
+ aesDecrypt(cryptoKey, iv, ct).then(function (html) {
132
+ var tpl = document.createElement("template");
133
+ tpl.innerHTML = html;
134
+ el.parentNode.insertBefore(tpl.content, el);
135
+ el.parentNode.removeChild(el);
136
+ })
137
+ );
138
+ });
139
+
140
+ return Promise.all(tasks);
141
+ }
142
+
143
+ // ── Deferred promise setup ───────────────────────────────────────────
144
+ var _resolveReady;
145
+ window._narrariumContentReady = new Promise(function (res) { _resolveReady = res; });
146
+
147
+ /**
148
+ * Decrypt all page content with the given CryptoKey, remove the lock
149
+ * class, and resolve _narrariumContentReady.
150
+ * @param {boolean} isAutoDecrypt - true when called from stored-key path;
151
+ * false (default) when called after the user submits the password form.
152
+ * When false, dispatches narrarium:content-unlocked so late-init scripts
153
+ * that already observed "locked" can re-initialize.
154
+ */
155
+ window._narrariumDecryptPage = function (cryptoKey, isAutoDecrypt) {
156
+ return decryptAllContent(cryptoKey).then(function () {
157
+ document.body.classList.remove("reader-auth-locked");
158
+ _resolveReady("decrypted");
159
+ if (!isAutoDecrypt) {
160
+ document.dispatchEvent(new CustomEvent("narrarium:content-unlocked"));
161
+ }
162
+ });
163
+ };
164
+
165
+ // ── Try auto-decrypt from stored AES key ─────────────────────────────
166
+ var storedKeyB64 = null;
167
+ try { storedKeyB64 = localStorage.getItem("narrarium-reader-aes-key"); } catch (_) {}
168
+
169
+ if (storedKeyB64) {
170
+ crypto.subtle.importKey(
171
+ "raw", b64ToBytes(storedKeyB64),
172
+ { name: "AES-GCM", length: 256 },
173
+ false, ["decrypt"]
174
+ )
175
+ .then(function (key) { return window._narrariumDecryptPage(key, true); })
176
+ .catch(function () {
177
+ // Stored key is stale or corrupt — clear it and show lock screen.
178
+ try { localStorage.removeItem("narrarium-reader-aes-key"); } catch (_) {}
179
+ document.body.classList.add("reader-auth-locked");
180
+ _resolveReady("locked");
181
+ });
182
+ } else {
183
+ document.body.classList.add("reader-auth-locked");
184
+ _resolveReady("locked");
185
+ }
186
+ })();
187
+ </script>
188
+ {passwordHash && (
189
+ <div id="reader-password-gate" role="dialog" aria-modal="true" aria-label="Access required">
190
+ <div class="reader-password-gate__inner">
191
+ <p class="eyebrow">Narrarium Reader</p>
192
+ <h1 class="reader-password-gate__title">Access Required</h1>
193
+ <p class="reader-password-gate__desc">Enter the password to access this book.</p>
194
+ <form id="reader-password-form" class="reader-password-gate__form" novalidate>
195
+ <input
196
+ type="password"
197
+ id="reader-password-input"
198
+ class="reader-password-gate__input"
199
+ placeholder="Password"
200
+ autocomplete="current-password"
201
+ />
202
+ <button type="submit" class="reader-password-gate__button">Unlock</button>
203
+ </form>
204
+ <p id="reader-password-error" class="reader-password-gate__error" hidden>Incorrect password. Please try again.</p>
205
+ </div>
206
+ </div>
207
+ )}
73
208
  <main class="shell">
74
209
  <header class="masthead">
75
210
  <a class="brand" href="./">Narrarium</a>
@@ -95,5 +230,80 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
95
230
  </main>
96
231
  <SiteSearch entries={searchIndex} />
97
232
  <ReaderRuntime glossary={canonGlossary} chapters={readerChapters} currentChapterNumber={currentChapterNumber} />
233
+ {passwordHash && (
234
+ <script is:inline>
235
+ (function initPasswordGate() {
236
+ var gate = document.getElementById("reader-password-gate");
237
+ var form = document.getElementById("reader-password-form");
238
+ var input = document.getElementById("reader-password-input");
239
+ var errorEl = document.getElementById("reader-password-error");
240
+ var passwordHash = document.body.dataset.readerPasswordHash;
241
+ var cryptoSalt = document.body.dataset.cryptoSalt;
242
+ if (!gate || !form || !passwordHash) return;
243
+ if (input) { input.focus(); }
244
+
245
+ function buf2hex(buffer) {
246
+ return Array.from(new Uint8Array(buffer))
247
+ .map(function (b) { return b.toString(16).padStart(2, "0"); })
248
+ .join("");
249
+ }
250
+
251
+ function b64ToBytes(b64) {
252
+ var bin = atob(b64);
253
+ var bytes = new Uint8Array(bin.length);
254
+ for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
255
+ return bytes;
256
+ }
257
+
258
+ form.addEventListener("submit", function (event) {
259
+ event.preventDefault();
260
+ var password = input ? input.value : "";
261
+ if (!password) return;
262
+
263
+ var encoded = new TextEncoder().encode(password);
264
+
265
+ // Quick SHA-256 check before the expensive PBKDF2 step.
266
+ crypto.subtle.digest("SHA-256", encoded).then(function (hashBuffer) {
267
+ if (buf2hex(hashBuffer) !== passwordHash) {
268
+ if (errorEl) { errorEl.hidden = false; }
269
+ if (input) { input.value = ""; input.focus(); }
270
+ return;
271
+ }
272
+
273
+ // Hash matched — derive the AES-256-GCM key via PBKDF2.
274
+ var saltBytes = b64ToBytes(cryptoSalt);
275
+ crypto.subtle.importKey("raw", encoded, "PBKDF2", false, ["deriveKey"])
276
+ .then(function (keyMaterial) {
277
+ return crypto.subtle.deriveKey(
278
+ { name: "PBKDF2", salt: saltBytes, iterations: 100000, hash: "SHA-256" },
279
+ keyMaterial,
280
+ { name: "AES-GCM", length: 256 },
281
+ true, // extractable so we can export and store it
282
+ ["decrypt"]
283
+ );
284
+ })
285
+ .then(function (aesKey) {
286
+ // Persist the raw key bytes so auto-decrypt works on reload.
287
+ crypto.subtle.exportKey("raw", aesKey).then(function (rawBuf) {
288
+ var rawBytes = new Uint8Array(rawBuf);
289
+ var b64 = btoa(String.fromCharCode.apply(null, Array.from(rawBytes)));
290
+ try { localStorage.setItem("narrarium-reader-aes-key", b64); } catch (_) {}
291
+ });
292
+
293
+ // Decrypt the page — passes false = manual unlock, so the
294
+ // content-unlocked event will fire for late-init scripts.
295
+ return window._narrariumDecryptPage(aesKey, false);
296
+ })
297
+ .catch(function () {
298
+ if (errorEl) { errorEl.hidden = false; }
299
+ if (input) { input.value = ""; input.focus(); }
300
+ });
301
+ }).catch(function () {
302
+ if (errorEl) { errorEl.hidden = false; }
303
+ });
304
+ });
305
+ })();
306
+ </script>
307
+ )}
98
308
  </body>
99
309
  </html>
@@ -0,0 +1,57 @@
1
+ import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * Build-time AES-256-GCM content encryption utilities.
5
+ *
6
+ * A single random 16-byte salt is generated once per Node process (i.e. once
7
+ * per Astro build). It is embedded publicly in the built HTML via
8
+ * `data-crypto-salt` on `<body>` so the client can run PBKDF2 key derivation
9
+ * with the same parameters. A public salt is fine — it only prevents rainbow
10
+ * tables; the security comes from the password entropy.
11
+ */
12
+
13
+ let _buildSalt: Buffer | null = null;
14
+
15
+ /** Return the singleton salt for this build process, creating it on first call. */
16
+ export function getBuildSalt(): Buffer {
17
+ if (!_buildSalt) {
18
+ _buildSalt = randomBytes(16);
19
+ }
20
+ return _buildSalt;
21
+ }
22
+
23
+ /** Base64-encoded build salt, ready to embed in an HTML attribute. */
24
+ export function getBuildSaltBase64(): string {
25
+ return getBuildSalt().toString("base64");
26
+ }
27
+
28
+ function deriveKey(password: string, salt: Buffer): Buffer {
29
+ return pbkdf2Sync(password, salt, 100_000, 32, "sha256");
30
+ }
31
+
32
+ export interface EncryptedChunk {
33
+ /** Base64-encoded 12-byte random IV. */
34
+ iv: string;
35
+ /** Base64-encoded (ciphertext ∥ 16-byte GCM auth tag). */
36
+ ct: string;
37
+ }
38
+
39
+ /**
40
+ * Encrypt a UTF-8 string with AES-256-GCM using PBKDF2-derived key.
41
+ *
42
+ * The ciphertext field contains the encrypted bytes immediately followed by
43
+ * the 16-byte GCM authentication tag, so Web Crypto's AES-GCM decrypt can
44
+ * verify integrity without any additional framing.
45
+ */
46
+ export function encryptString(plaintext: string, password: string): EncryptedChunk {
47
+ const salt = getBuildSalt();
48
+ const key = deriveKey(password, salt);
49
+ const iv = randomBytes(12);
50
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
51
+ const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
52
+ const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
53
+ return {
54
+ iv: iv.toString("base64"),
55
+ ct: Buffer.concat([body, tag]).toString("base64"),
56
+ };
57
+ }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readReaderEnv } from "./env.js";
2
3
 
3
4
  export function isFullCanonMode(): boolean {
@@ -7,3 +8,24 @@ export function isFullCanonMode(): boolean {
7
8
 
8
9
  return raw === "1" || raw === "true" || raw === "full" || raw === "author" || raw === "spoilers";
9
10
  }
11
+
12
+ /**
13
+ * Returns the raw NARRARIUM_READER_PASSWORD env var value, or null when the
14
+ * variable is not set. Used at build time to derive the AES-256-GCM key for
15
+ * content encryption. Never embedded in the built HTML.
16
+ */
17
+ export function getReaderPassword(): string | null {
18
+ return readReaderEnv(["NARRARIUM_READER_PASSWORD"]) ?? null;
19
+ }
20
+
21
+ /**
22
+ * Returns a SHA-256 hex hash of the NARRARIUM_READER_PASSWORD env var,
23
+ * or null when the variable is not set. The hash is embedded in the built
24
+ * HTML and compared against the user's input at runtime via SubtleCrypto
25
+ * as a fast pre-filter before the more expensive PBKDF2 key derivation.
26
+ */
27
+ export function getReaderPasswordHash(): string | null {
28
+ const raw = readReaderEnv(["NARRARIUM_READER_PASSWORD"]);
29
+ if (!raw) return null;
30
+ return createHash("sha256").update(raw).digest("hex");
31
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { listChapters, pathExists } from "narrarium";
5
5
  import AssetFigure from "../../components/AssetFigure.astro";
6
6
  import ChapterPager from "../../components/ChapterPager.astro";
7
+ import EncryptedHtml from "../../components/EncryptedHtml.astro";
7
8
  import LinkedValue from "../../components/LinkedValue.astro";
8
9
  import MetadataSection from "../../components/MetadataSection.astro";
9
10
  import RelatedLinks from "../../components/RelatedLinks.astro";
@@ -89,8 +90,9 @@ const renderedParagraphs = await Promise.all(
89
90
  data-reader-marker-scope="chapter-intro"
90
91
  data-tts-root="chapter-intro"
91
92
  data-tts-label="Chapter opening"
92
- set:html={chapterHtml}
93
- />
93
+ >
94
+ <EncryptedHtml html={chapterHtml} />
95
+ </article>
94
96
  )}
95
97
 
96
98
  {chapterFigure && <AssetFigure figure={chapterFigure} className="scene-media focus-hidden" />}
@@ -156,7 +158,9 @@ const renderedParagraphs = await Promise.all(
156
158
  </p>
157
159
  )}
158
160
 
159
- <div class="prose" data-tts-root={paragraph.anchorId} data-tts-label={paragraph.metadata.title} set:html={paragraph.html} />
161
+ <div class="prose" data-tts-root={paragraph.anchorId} data-tts-label={paragraph.metadata.title}>
162
+ <EncryptedHtml html={paragraph.html} />
163
+ </div>
160
164
  {paragraph.figure && <AssetFigure figure={paragraph.figure} className="scene-media focus-hidden" />}
161
165
  </article>
162
166
  ))
@@ -214,7 +218,7 @@ const renderedParagraphs = await Promise.all(
214
218
  </aside>
215
219
 
216
220
  <div class="focus-hint" data-reader-focus-hint aria-hidden="true">
217
- Move the mouse, tap, or press a key to show controls.
221
+ Double-tap or double-click to show controls, or press a key.
218
222
  </div>
219
223
 
220
224
  <script is:inline>
@@ -240,6 +244,8 @@ const renderedParagraphs = await Promise.all(
240
244
  let railPointerInside = false;
241
245
  let focusHintShownForSession = false;
242
246
  let refreshScheduled = false;
247
+ let lastTapTime = 0;
248
+ const doubleTapThresholdMs = 300;
243
249
 
244
250
  applyFocusMode(readStorage(focusModeKey) === "true", false, { allowFullscreenRequest: false });
245
251
  updateMarkerUi(readMarker());
@@ -247,8 +253,16 @@ const renderedParagraphs = await Promise.all(
247
253
 
248
254
  window.addEventListener("scroll", scheduleSceneRefresh, { passive: true });
249
255
  window.addEventListener("resize", scheduleSceneRefresh);
250
- document.addEventListener("pointermove", handleFocusRailWake, { passive: true });
251
- document.addEventListener("touchstart", handleFocusRailWake, { passive: true });
256
+ document.addEventListener("dblclick", handleFocusRailWake);
257
+ document.addEventListener("touchend", (event) => {
258
+ const now = Date.now();
259
+ if (now - lastTapTime < doubleTapThresholdMs) {
260
+ handleFocusRailWake();
261
+ lastTapTime = 0;
262
+ } else {
263
+ lastTapTime = now;
264
+ }
265
+ }, { passive: true });
252
266
  document.addEventListener("fullscreenchange", handleFullscreenChange);
253
267
 
254
268
  focusRail?.addEventListener("pointerenter", () => {
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { listEntities, pathExists } from "narrarium";
4
4
  import AssetFigure from "../../components/AssetFigure.astro";
5
+ import EncryptedHtml from "../../components/EncryptedHtml.astro";
5
6
  import MetadataSection from "../../components/MetadataSection.astro";
6
7
  import RelatedLinks from "../../components/RelatedLinks.astro";
7
8
  import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("character", entity);
53
54
 
54
55
  {view.html && (
55
56
  <section class="section">
56
- <article class="scene prose" set:html={view.html} />
57
+ <article class="scene prose"><EncryptedHtml html={view.html} /></article>
57
58
  </section>
58
59
  )}
59
60
  </BaseLayout>
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { listEntities, pathExists } from "narrarium";
4
4
  import AssetFigure from "../../components/AssetFigure.astro";
5
+ import EncryptedHtml from "../../components/EncryptedHtml.astro";
5
6
  import MetadataSection from "../../components/MetadataSection.astro";
6
7
  import RelatedLinks from "../../components/RelatedLinks.astro";
7
8
  import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("faction", entity);
53
54
 
54
55
  {view.html && (
55
56
  <section class="section">
56
- <article class="scene prose" set:html={view.html} />
57
+ <article class="scene prose"><EncryptedHtml html={view.html} /></article>
57
58
  </section>
58
59
  )}
59
60
  </BaseLayout>
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { listEntities, pathExists } from "narrarium";
4
4
  import AssetFigure from "../../components/AssetFigure.astro";
5
+ import EncryptedHtml from "../../components/EncryptedHtml.astro";
5
6
  import MetadataSection from "../../components/MetadataSection.astro";
6
7
  import RelatedLinks from "../../components/RelatedLinks.astro";
7
8
  import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("item", entity);
53
54
 
54
55
  {view.html && (
55
56
  <section class="section">
56
- <article class="scene prose" set:html={view.html} />
57
+ <article class="scene prose"><EncryptedHtml html={view.html} /></article>
57
58
  </section>
58
59
  )}
59
60
  </BaseLayout>
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { listEntities, pathExists } from "narrarium";
4
4
  import AssetFigure from "../../components/AssetFigure.astro";
5
+ import EncryptedHtml from "../../components/EncryptedHtml.astro";
5
6
  import MetadataSection from "../../components/MetadataSection.astro";
6
7
  import RelatedLinks from "../../components/RelatedLinks.astro";
7
8
  import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("location", entity);
53
54
 
54
55
  {view.html && (
55
56
  <section class="section">
56
- <article class="scene prose" set:html={view.html} />
57
+ <article class="scene prose"><EncryptedHtml html={view.html} /></article>
57
58
  </section>
58
59
  )}
59
60
  </BaseLayout>
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { listEntities, pathExists } from "narrarium";
4
4
  import AssetFigure from "../../components/AssetFigure.astro";
5
+ import EncryptedHtml from "../../components/EncryptedHtml.astro";
5
6
  import MetadataSection from "../../components/MetadataSection.astro";
6
7
  import RelatedLinks from "../../components/RelatedLinks.astro";
7
8
  import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("secret", entity);
53
54
 
54
55
  {view.html && (
55
56
  <section class="section">
56
- <article class="scene prose" set:html={view.html} />
57
+ <article class="scene prose"><EncryptedHtml html={view.html} /></article>
57
58
  </section>
58
59
  )}
59
60
  </BaseLayout>
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { listEntities, pathExists } from "narrarium";
4
4
  import AssetFigure from "../../components/AssetFigure.astro";
5
+ import EncryptedHtml from "../../components/EncryptedHtml.astro";
5
6
  import MetadataSection from "../../components/MetadataSection.astro";
6
7
  import RelatedLinks from "../../components/RelatedLinks.astro";
7
8
  import BaseLayout from "../../layouts/BaseLayout.astro";
@@ -53,7 +54,7 @@ const view = await buildCanonPageView("timeline-event", entity);
53
54
 
54
55
  {view.html && (
55
56
  <section class="section">
56
- <article class="scene prose" set:html={view.html} />
57
+ <article class="scene prose"><EncryptedHtml html={view.html} /></article>
57
58
  </section>
58
59
  )}
59
60
  </BaseLayout>
@@ -119,6 +119,7 @@ html[data-theme="dark"] .focus-rail__button.is-primary {
119
119
 
120
120
  body.is-focus-mode {
121
121
  background: var(--app-bg);
122
+ touch-action: manipulation;
122
123
  }
123
124
 
124
125
  body.is-focus-mode .masthead,
@@ -1464,3 +1465,98 @@ mark {
1464
1465
  gap: 0 1rem;
1465
1466
  }
1466
1467
  }
1468
+
1469
+ /* ─── Password gate ──────────────────────────────────────────── */
1470
+ #reader-password-gate {
1471
+ display: none;
1472
+ position: fixed;
1473
+ inset: 0;
1474
+ z-index: 200;
1475
+ background: var(--app-bg);
1476
+ align-items: center;
1477
+ justify-content: center;
1478
+ flex-direction: column;
1479
+ padding: 1.5rem;
1480
+ }
1481
+
1482
+ body.reader-auth-locked {
1483
+ overflow: hidden;
1484
+ }
1485
+
1486
+ body.reader-auth-locked #reader-password-gate {
1487
+ display: flex;
1488
+ }
1489
+
1490
+ .reader-password-gate__inner {
1491
+ width: min(380px, 100%);
1492
+ text-align: center;
1493
+ }
1494
+
1495
+ .reader-password-gate__title {
1496
+ font-size: clamp(1.5rem, 3vw, 2rem);
1497
+ margin: 0.5rem 0 0;
1498
+ letter-spacing: -0.02em;
1499
+ }
1500
+
1501
+ .reader-password-gate__desc {
1502
+ margin: 0.75rem 0 0;
1503
+ color: var(--text-muted);
1504
+ font-size: 0.95rem;
1505
+ }
1506
+
1507
+ .reader-password-gate__form {
1508
+ display: flex;
1509
+ flex-direction: column;
1510
+ gap: 0.75rem;
1511
+ margin-top: 1.75rem;
1512
+ }
1513
+
1514
+ .reader-password-gate__input {
1515
+ width: 100%;
1516
+ padding: 0.7rem 1rem;
1517
+ border: 1px solid var(--line);
1518
+ border-radius: 10px;
1519
+ background: var(--surface);
1520
+ color: var(--text);
1521
+ font: inherit;
1522
+ font-size: 1rem;
1523
+ text-align: center;
1524
+ transition: border-color 120ms ease, box-shadow 120ms ease;
1525
+ }
1526
+
1527
+ .reader-password-gate__input:focus {
1528
+ outline: none;
1529
+ border-color: var(--accent);
1530
+ box-shadow: 0 0 0 3px var(--accent-soft);
1531
+ }
1532
+
1533
+ .reader-password-gate__button {
1534
+ display: inline-flex;
1535
+ align-items: center;
1536
+ justify-content: center;
1537
+ padding: 0.65rem 1.5rem;
1538
+ border: 1px solid var(--accent);
1539
+ border-radius: 100px;
1540
+ background: var(--accent);
1541
+ color: #fff;
1542
+ font: inherit;
1543
+ font-size: 0.95rem;
1544
+ font-weight: 600;
1545
+ cursor: pointer;
1546
+ transition: background 120ms ease, border-color 120ms ease;
1547
+ }
1548
+
1549
+ .reader-password-gate__button:hover {
1550
+ background: color-mix(in srgb, var(--accent) 85%, #000);
1551
+ border-color: color-mix(in srgb, var(--accent) 85%, #000);
1552
+ }
1553
+
1554
+ .reader-password-gate__error {
1555
+ margin: 0.75rem 0 0;
1556
+ color: #c0392b;
1557
+ font-size: 0.875rem;
1558
+ }
1559
+
1560
+ html[data-theme="dark"] .reader-password-gate__error {
1561
+ color: #e74c3c;
1562
+ }