diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 08:05:08 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 08:05:08 -0500 |
| commit | fbb06ab09681079c01cc9b4eb13731ef17f4ab41 (patch) | |
| tree | 9d052f079aa6e1db4e8b89d7f76529cd66d30c38 | |
| parent | d809d86182ab802472ae7d5e74cab66ba7bfa397 (diff) | |
Show crypto download progress and centralize license links
| -rw-r--r-- | crates/chat-server/src/http.rs | 13 | ||||
| -rw-r--r-- | crates/chat-server/tests/progress.mjs | 127 | ||||
| -rw-r--r-- | crates/chat-server/web/app.js | 54 | ||||
| -rw-r--r-- | crates/chat-server/web/index.html | 2 | ||||
| -rw-r--r-- | crates/chat-server/web/javascript.html | 2 | ||||
| -rw-r--r-- | docs/CHAT-APPLICATION.txt | 13 | ||||
| -rw-r--r-- | docs/OPERATIONS.txt | 19 | ||||
| -rw-r--r-- | infra/containers/cgit/cgitrc | 2 | ||||
| -rw-r--r-- | infra/containers/cgit/license.lua | 47 | ||||
| -rw-r--r-- | infra/containers/chat/Containerfile | 1 | ||||
| -rw-r--r-- | infra/services.toml | 1 | ||||
| -rw-r--r-- | src/render.rs | 1 | ||||
| -rw-r--r-- | web/index.html | 6 | ||||
| -rw-r--r-- | web/message/index.html | 2 | ||||
| -rw-r--r-- | web/styles.css | 42 |
15 files changed, 258 insertions, 74 deletions
diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs index 324c593..7394b71 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/http.rs @@ -20,7 +20,7 @@ use axum::{ body::Body, extract::{DefaultBodyLimit, State}, http::{HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, }; use serde_json::{Value, json}; @@ -94,6 +94,14 @@ async fn api(State(app): State<App>, headers: HeaderMap, Json(request): Json<Val } async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { + if uri.path() == "/THIRD-PARTY-LICENSES.html" { + let Some(origin) = app.public.get("SITE_ORIGIN") else { + return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + return secured( + Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), + ); + } let (path, mime) = match uri.path() { "/" => ( "crates/chat-server/web/index.html", @@ -135,9 +143,6 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { }, "application/gzip", ), - "/THIRD-PARTY-LICENSES.html" => { - ("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8") - } "/LIMITATIONS.txt" => ("docs/CHAT-APPLICATION.txt", "text/plain; charset=utf-8"), "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), _ => return secured(StatusCode::NOT_FOUND.into_response()), diff --git a/crates/chat-server/tests/progress.mjs b/crates/chat-server/tests/progress.mjs new file mode 100644 index 0000000..0ee5614 --- /dev/null +++ b/crates/chat-server/tests/progress.mjs @@ -0,0 +1,127 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; + +const libre = process.env.CHAT_LIBREJS === '1'; +const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); +const bytes = await readFile(resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm')); +let mode = 'known'; +let transfer; +let configuration; +let configReady; +let signalConfig; +let origin; +const errors = []; +const server = createServer(async (request,response) => { + response.setHeader('Cache-Control','no-store'); + if (request.url === '/crypto.wasm') { + if (mode === 'failure') { response.writeHead(503); response.end(); return; } + response.setHeader('Content-Type','application/wasm'); + if (mode !== 'unknown') response.setHeader('Content-Length',bytes.length); + transfer = response; + response.write(bytes.subarray(0,1048576)); + return; + } + if (request.url === '/api') { configuration = response; signalConfig(); return; } + const assets = { '/':'crates/chat-server/web/index.html','/app.js':'crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; + const path = assets[request.url]; + if (!path) { response.writeHead(404); response.end(); return; } + const mime = request.url === '/' ? 'text/html' : request.url.endsWith('.js') ? 'text/javascript' : 'text/css'; + response.setHeader('Content-Type',mime); + let source = await readFile(resolve(path),'utf8'); + if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); + response.end(source); +}); +await new Promise(done => server.listen(0,'127.0.0.1',done)); +origin = `http://127.0.0.1:${server.address().port}`; +let browser; +let context; +if (libre) { + await mkdir(join(profile,'extensions')); + const addon = await readFile(resolve('.tools/librejs.xpi')); + await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); + context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); + browser = {close:() => context.close()}; +} else { + browser = await chromium.launch({headless:true,channel:'chromium'}); + context = await browser.newContext(); +} +async function open(selected) { + mode = selected; + configuration = undefined; + configReady = new Promise(done => { signalConfig = done; }); + const page = await context.newPage(); + page.on('pageerror',error => errors.push(error.message)); + await page.clock.install(); + await page.goto(origin); + return page; +} +async function waitStatus(page,part) { + await page.waitForFunction(value => document.getElementById('status').textContent.includes(value),part); + return page.locator('#status').textContent(); +} +async function complete(page,offset) { + transfer.end(bytes.subarray(offset)); + await waitStatus(page,'Connecting to chat'); + await configReady; + configuration.setHeader('Content-Type','application/json'); + configuration.end(JSON.stringify({origin,owner:''})); + await page.locator('#owner-choice').waitFor({state:'visible'}); + assert.equal(await page.locator('#status').textContent(),''); + assert.equal(await page.evaluate(async () => (await wasm).byteLength),bytes.length); + await page.close(); +} +try { + const known = await open('known'); + assert.match(await waitStatus(known,'1.0 /'),/\[#{3}-{17}\] 19%/); + await known.clock.fastForward(44000); + transfer.write(bytes.subarray(1048576,2097152)); + assert.match(await waitStatus(known,'2.0 /'),/39%/); + await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); + await known.clock.fastForward(44000); + assert.ok(!(await known.locator('#status').textContent()).includes('stalled')); + await complete(known,2097152); + const unknown = await open('unknown'); + assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); + await complete(unknown,1048576); + const failure = await open('failure'); + await waitStatus(failure,'HTTP 503'); + assert.equal(await failure.locator('#owner-choice').isVisible(),false); + await failure.close(); + const stalled = await open('known'); + await waitStatus(stalled,'1.0 /'); + await stalled.clock.fastForward(45001); + await waitStatus(stalled,'stalled for 45 seconds'); + assert.match(await stalled.locator('#status').textContent(),/reload/); + assert.equal(await stalled.locator('#owner-choice').isVisible(),false); + await stalled.close(); + assert.deepEqual(errors,[]); + if (libre) { + const state = JSON.parse(await readFile(join(profile,'extensions.json'),'utf8')); + assert.ok(state.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack')?.active); + } + console.log(`PASS: ${libre ? 'Firefox/LibreJS' : 'Chromium'} streamed percentage/bar/MiB, unknown length, exact byte recovery, slow continuous transfer, idle timeout, HTTP failure, and connection phase.`); +} finally { + await browser.close(); + server.closeAllConnections(); + await new Promise(done => server.close(done)); + await rm(profile,{recursive:true,force:true}); +} diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js index 3c97f66..4b0da63 100644 --- a/crates/chat-server/web/app.js +++ b/crates/chat-server/web/app.js @@ -356,12 +356,58 @@ element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); +async function downloadCrypto() { + const controller = new AbortController(); + let timer; + let reader; + const refreshTimeout = () => { + clearTimeout(timer); + timer = setTimeout(() => controller.abort(), 45000); + }; + try { + refreshTimeout(); + const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal }); + if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`); + const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length')); + const total = Number.isSafeInteger(length) && length > 0 ? length : 0; + const chunks = []; + let received = 0; + const progress = () => { + const loaded = (received / 1048576).toFixed(1); + if (total) { + const percent = Math.min(100, Math.floor(received * 100 / total)); + const filled = Math.floor(percent / 5); + status(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); + } else status(`Downloading cryptographic module... ${loaded} MiB received`); + }; + progress(); + reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + refreshTimeout(); + chunks.push(value); + received += value.byteLength; + progress(); + } + if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.'); + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return bytes.buffer; + } catch (error) { + if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.'); + throw error; + } finally { + clearTimeout(timer); + reader?.releaseLock(); + } +} + async function start() { - wasm = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => { - if (!response.ok) throw new Error('Build the WASI cryptographic module first'); - return response.arrayBuffer(); - }); + wasm = downloadCrypto(); await wasm; + status('Cryptographic module downloaded. Connecting to chat...'); config = await api({ op: 'config' }); if (config.origin !== location.origin) throw new Error('Server origin mismatch'); show('owner-entry'); diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html index b2a4363..70f3ebb 100644 --- a/crates/chat-server/web/index.html +++ b/crates/chat-server/web/index.html @@ -100,7 +100,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <noscript>This chat requires free JavaScript and WebAssembly for local cryptography. The main website remains JavaScript-free.</noscript> <footer class="source-footer" aria-label="Licensing and source"> <a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a> -<a class="dependency-licenses" href="/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> +<a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> <a href="/SOURCE.tar.gz">[ SOURCE CODE ]</a> </footer> </main> diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html index 205a103..644e5a1 100644 --- a/crates/chat-server/web/javascript.html +++ b/crates/chat-server/web/javascript.html @@ -24,5 +24,5 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <tr><td><a href="/wasi.mjs">wasi.mjs</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> </tbody></table></div> <p>The WebAssembly module is compiled from the Rust workspace and unmodified OpenSSL 3.6.5, under their respective free software licenses. The source archive includes first-party sources, Cargo dependencies, the OpenSSL source archive, build scripts, and notices. General-purpose Rust and WASI SDK tools are documented with verified download locations in CHAT-OPENSSL-MIGRATION.txt.</p> -<p><a href="/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> +<p><a href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> </main></body></html> diff --git a/docs/CHAT-APPLICATION.txt b/docs/CHAT-APPLICATION.txt index ea817a5..6a25776 100644 --- a/docs/CHAT-APPLICATION.txt +++ b/docs/CHAT-APPLICATION.txt @@ -29,6 +29,13 @@ WHAT EXISTS a capability-limited WASI host. Text is displayed through textContent. SearXNG and the other existing services are unchanged. + Startup shows an ASCII download bar, percentage, and received/total MiB + while fetching the cryptographic module. If its size is unavailable, it shows + received MiB. A 45-second gap without download progress aborts with a reload + instruction; slow transfers continue while bytes arrive. After download, + a separate status identifies the connection to chat. No browser cache or + storage was added. + Visitors choose CREATE NEW CHAT or OPEN CHAT FILE. Creation generates a purpose-specific identity locally and downloads its protected file. Reopening the exact downloaded file and entering its password is required before the @@ -342,6 +349,11 @@ LICENSES, SOURCE, AND VERIFICATION The local check used Alpine musl 1.2.6 headers with the host C compiler for the C object build and Rust's self-contained musl runtime for final linking. + The rendered dependency-license page is centralized on the main website at + https://doan.luuduct.ai/THIRD-PARTY-LICENSES.html. Chat links point there; its + previous local license-page URL redirects there. cgit keeps ordinary source + and raw-file views without a custom HTML renderer. + First-party software is GNU AGPL version 3 or later. Legal license headers are the exception to the no-comments convention. Documentation is plaintext. JavaScript has GNU LibreJS @license/@license-end markers and a linked @@ -371,6 +383,7 @@ LICENSES, SOURCE, AND VERIFICATION cargo stack check PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-crypto/tests/browser.mjs PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/browser.mjs + PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/progress.mjs Browser automation additionally needs Node, Playwright under .tools/browser, and its browser downloads under .tools/browsers. These are development tools diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt index a2534b6..5b1da25 100644 --- a/docs/OPERATIONS.txt +++ b/docs/OPERATIONS.txt @@ -338,11 +338,14 @@ RECOVERY LIMITS AND SECRET ROTATION The current command intentionally accepts only its verified format-2 archives. Fresh format-2 production backups replace the normal workflow. -LICENSE PAGE IN CGIT - - The stack repository's About tab renders only - web/THIRD-PARTY-LICENSES.html. Source and Raw links retain the normal cgit - views and preserve revision queries. Other files are not rendered as HTML. - The rendered document is isolated in a sandboxed frame, with scripts and - external resources blocked. The standalone website license page remains - available at /THIRD-PARTY-LICENSES.html. +CENTRAL THIRD-PARTY LICENSE PAGE + + cargo-about generates web/THIRD-PARTY-LICENSES.html. Caddy serves the rendered + document at https://doan.luuduct.ai/THIRD-PARTY-LICENSES.html, using the + configured SITE_HOST for other deployments. Website and chat license links + use that same canonical page. Local previews use the local main-site origin. + + cgit no longer has a custom license renderer, Lua filter, or license About + tab. The generated file remains accessible through its normal Source and Raw + views in the repository. The old chat /THIRD-PARTY-LICENSES.html URL redirects + to the canonical site page. Source exports retain license texts for offline use. diff --git a/infra/containers/cgit/cgitrc b/infra/containers/cgit/cgitrc index 0dacb99..fb9cae8 100644 --- a/infra/containers/cgit/cgitrc +++ b/infra/containers/cgit/cgitrc @@ -24,6 +24,4 @@ defbranch=main cache-root=/var/cache/cgit cache-size=1000 cache-scanrc-ttl=1 -readme=:web/THIRD-PARTY-LICENSES.html -about-filter=lua:/etc/cgit-license.lua scan-path=/srv/git diff --git a/infra/containers/cgit/license.lua b/infra/containers/cgit/license.lua deleted file mode 100644 index ff5685f..0000000 --- a/infra/containers/cgit/license.lua +++ /dev/null @@ -1,47 +0,0 @@ --- Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> --- --- This program is free software: you can redistribute it and/or modify --- it under the terms of the GNU Affero General Public License as --- published by the Free Software Foundation, either version 3 of the --- License, or (at your option) any later version. --- --- This program is distributed in the hope that it will be useful, --- but WITHOUT ANY WARRANTY; without even the implied warranty of --- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the --- GNU Affero General Public License for more details. --- --- You should have received a copy of the GNU Affero General Public License --- along with this program. If not, see <https://www.gnu.org/licenses/>. - -local chunks = {} -local rendered = false - -function filter_open(filename) - chunks = {} - rendered = filename == "web/THIRD-PARTY-LICENSES.html" -end - -function filter_write(buffer) - if rendered then - chunks[#chunks + 1] = buffer - else - html_txt(buffer) - end -end - -function filter_close() - if rendered then - local query = os.getenv("QUERY_STRING") or "" - local suffix = query ~= "" and ("?" .. query) or "" - html('<nav aria-label="License views"><strong>Rendered</strong> | <a href="') - html_attr("../tree/web/THIRD-PARTY-LICENSES.html" .. suffix) - html('">Source</a> | <a href="') - html_attr("../plain/web/THIRD-PARTY-LICENSES.html" .. suffix) - html('">Raw</a></nav>') - html('<iframe title="Third-party licenses" sandbox="" referrerpolicy="no-referrer" style="width:100%;height:80vh;border:0" srcdoc="') - html_attr([[<!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">]]) - html_attr(table.concat(chunks)) - html('"></iframe>') - end - return 0 -end diff --git a/infra/containers/chat/Containerfile b/infra/containers/chat/Containerfile index ff97f05..a63683e 100644 --- a/infra/containers/chat/Containerfile +++ b/infra/containers/chat/Containerfile @@ -19,7 +19,6 @@ COPY crypto.wasm /app/bin/chat/crypto.wasm COPY SOURCE.tar.gz /app/bin/chat/SOURCE.tar.gz COPY web /app/crates/chat-server/web COPY wasi.mjs /app/crates/chat-crypto/browser/wasi.mjs -COPY THIRD-PARTY-LICENSES.html /app/web/THIRD-PARTY-LICENSES.html COPY CHAT-APPLICATION.txt /app/docs/CHAT-APPLICATION.txt COPY CHAT-PRODUCTION.txt /app/docs/CHAT-PRODUCTION.txt COPY LICENSE /app/LICENSE diff --git a/infra/services.toml b/infra/services.toml index 09abfc5..7fea853 100644 --- a/infra/services.toml +++ b/infra/services.toml @@ -33,7 +33,6 @@ health = ["curl", "--fail", "--silent", "--max-time", "3", "http://127.0.0.1:808 mounts = [ { source = "var/data/repos", target = "/srv/git", read_only = true }, { source = "@cgit", target = "/etc/cgitrc", read_only = true }, - { source = "infra/containers/cgit/license.lua", target = "/etc/cgit-license.lua", read_only = true }, ] tmpfs = ["/run", "/var/cache/cgit"] diff --git a/src/render.rs b/src/render.rs index c141405..5171753 100644 --- a/src/render.rs +++ b/src/render.rs @@ -99,7 +99,6 @@ mod tests { let homepage = fs::read_to_string(output.join("web/index.html"))?; assert!(homepage.contains("https://git.example.net:8443")); assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt")); - assert!(homepage.contains("A home-grown experimental messenger. Know its")); settings.mode = Mode::Local; settings.port = 8180; prepare(&project, &settings)?; diff --git a/web/index.html b/web/index.html index 82cdc18..825e48e 100644 --- a/web/index.html +++ b/web/index.html @@ -51,7 +51,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. | <a href="{{CHAT_PUBLIC_ORIGIN}}/">Message Me</a> | | | | Email: <a href="mailto:doan@luuduct.ai">doan@luuduct.ai</a> | -| GnuPG: <a href="{{WKD_ORIGIN}}/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> | +| OpenPGP: <a href="{{WKD_ORIGIN}}/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> | | | | <a href="https://smp11.simplex.im/a#ikrTZ8sfleSZEifKV2wI8foAVwkbKfOoKc6TO2P4ZT8">SimpleX Chat</a> | | | @@ -77,14 +77,14 @@ ________________________________________________________________________________ <a href="{{CHAT_PUBLIC_ORIGIN}}/">{{CHAT_HOST}}</a> -* A home-grown experimental messenger. Know its <a href="{{CHAT_PUBLIC_ORIGIN}}/LIMITATIONS.txt">limitations</a>. +* A home-grown experimental messenger. <span class="librejs-note"><a href="https://www.gnu.org/software/librejs/" aria-describedby="chat-js-warning">LibreJS-compliant</a><span class="librejs-warning" id="chat-js-warning" role="tooltip">LibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously.</span></span>. Know its <a href="{{CHAT_PUBLIC_ORIGIN}}/LIMITATIONS.txt">limitations</a>. </pre> <nav class="source-footer" aria-label="Licensing and source"> <a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a> - <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a> + <a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> <a href="{{GIT_ORIGIN}}/stack/tree/web/index.html">[ SOURCE CODE ]</a> </nav> diff --git a/web/message/index.html b/web/message/index.html index d1739b0..dbab7cf 100644 --- a/web/message/index.html +++ b/web/message/index.html @@ -44,7 +44,7 @@ For now, you can reach me by <a href="mailto:doan@luuduct.ai">email</a> or use t </pre> <nav class="source-footer" aria-label="Licensing and source"> <a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a> - <a class="dependency-licenses" href="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a> + <a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a> <a href="{{GIT_ORIGIN}}/stack/tree/web/message/index.html">[ SOURCE CODE ]</a> </nav> </div> diff --git a/web/styles.css b/web/styles.css index a50bc37..20e469d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -137,3 +137,45 @@ a { color: #a3c7a8; } } + +.librejs-note { + position: relative; +} + +.librejs-note > a { + color: #806000; + text-decoration: underline wavy; + text-underline-offset: .2em; +} + +.librejs-warning { + display: none; + position: absolute; + bottom: 100%; + right: 0; + width: 48ch; + max-width: calc(100vw - 32px); + padding: 1em 2ch; + white-space: normal; + background: #fff; + color: #000; + border: 1px dashed #806000; + z-index: 1; +} + +.librejs-note:hover .librejs-warning, +.librejs-note:focus-within .librejs-warning { + display: block; +} + +@media (prefers-color-scheme: dark) { + .librejs-note > a { + color: #ffdf60; + } + + .librejs-warning { + background: #000; + color: #fff; + border-color: #ffdf60; + } +} |
