diff --git a/Cargo.lock b/Cargo.lock index 2c828da..bd2e6ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,7 +2674,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.90" +version = "0.2.91" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index c0a517b..d146c32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.90" +version = "0.2.91" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/PERFORMANCE_TEST_RESULTS.md b/PERFORMANCE_TEST_RESULTS.md deleted file mode 100644 index 7a779e1..0000000 --- a/PERFORMANCE_TEST_RESULTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Rustpad 0.2.88 — wyniki testów wydajności - -Zmiana: w `src/websocket/pad.rs` i `src/websocket/mod.rs` ograniczono sprawdzanie dostępu przy przychodzących zdarzeniach broadcast do najwyżej jednego sprawdzenia na sekundę na połączenie. Kontrola cykliczna i zdarzenia zmiany hasła pozostają aktywne. W zestawie pozostają optymalizacje renderowania z poprzedniej iteracji. - -## Symulacja deterministyczna (Node.js) - -Założenia: 10 s, 2 edycje/s na użytkownika, każda edycja rozsyłana do wszystkich N odbiorców (w tym nadawcy), bez rzeczywistej sieci, bazy danych, WebSocket czy renderowania. Symulacja porównuje liczby sprawdzeń dostępu, a **nie** rzeczywistą przepustowość ani opóźnienia Rustpada. - -| Użytkownicy | Dostawy zdarzeń | Sprawdzenia przed | Sprawdzenia po | Redukcja | -|---:|---:|---:|---:|---:| -| 5 | 500 | 500 | 50 | 90,00% | -| 100 | 200 000 | 200 000 | 1 000 | 99,50% | -| 300 | 1 800 000 | 1 800 000 | 3 000 | 99,83% | -| 500 | 5 000 000 | 5 000 000 | 5 000 | 99,90% | - -Uruchomienie: `node tests/broadcast-load-simulation.mjs`. - -## Testy - -- `node tests/feature-smoke-test.mjs` — PASS -- `node tests/mention-autocomplete-test.mjs` — PASS -- `node tests/mention-navigation-test.mjs` — PASS -- `node --check static/js/*.js` (osobno dla plików) — PASS -- `cargo test` — NIEURUCHOMIONY (brak Rust/cargo w środowisku) -- Test end-to-end z setkami aktywnych przeglądarek i prawdziwym WebSocket/PostgreSQL — NIEURUCHOMIONY. - -## Ograniczenia - -Nie można zagwarantować płynnej pracy kilkuset użytkowników bez testu na docelowym sprzęcie i bazie. Każdy zapis nadal przechodzi przez serializację zmian i trwałe utrwalenie w bazie; duża liczba operacji może nasycić bazę i sieć. Zmiana odświeżania uprawnień może wydłużyć wykrycie odwołania dostępu w trakcie ciągłego broadcastu do około 1 sekundy; istniejący timer kontroli okresowej pozostaje. Wersja 0.2.88 jest wydaniem z nową poprawką na życzenie użytkownika i nie należy jej mylić z wcześniej wydaną paczką 0.2.88. diff --git a/lang/en.json b/lang/en.json index d009f0b..5e3f3e3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -938,6 +938,9 @@ "workspace.noteDeletedTitle": "Note deleted", "workspace.noteName.placeholder": "Note name", "workspace.noteProtect": "Protect this note from deletion", + "workspace.noteProtection.explain": "When enabled, collaborators can edit but cannot delete or archive the note or remove its files. The note creator or workspace owner can still do so. This does not require a password.", + "workspace.noteProtection.archiveEnabled": "Archiving is enabled: authorized deletion moves the note, its history and files to Archived notes. The workspace owner can restore it.", + "workspace.noteProtection.archiveDisabled": "Archiving is disabled: authorized deletion permanently removes the note and its files. Enable archiving in Workspace settings if recovery is important.", "workspace.notes.copy": "Select a note or create a new one.", "workspace.notesView": "Notes view", "workspace.pagination": "Notes pagination", @@ -1013,6 +1016,8 @@ "files.less": "Less", "files.noMatchesHelp": "Try a different file name or type.", "files.link": "Link", - "files.markdown": "Markdown" + "files.markdown": "Markdown", + "home.account.loading": "Checking your account...", + "home.account.retry": "Reload page" } -} \ No newline at end of file +} diff --git a/lang/pl.json b/lang/pl.json index 21e4f16..afaf730 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -938,6 +938,9 @@ "workspace.noteDeletedTitle": "Notatka usunięta", "workspace.noteName.placeholder": "Nazwa notatki", "workspace.noteProtect": "Chroń tę notatkę przed usunięciem", + "workspace.noteProtection.explain": "Po włączeniu współedytujący mogą edytować notatkę, ale nie mogą jej usuwać ani archiwizować, ani usuwać załączników. Nadal może to zrobić twórca notatki lub właściciel workspace. To nie jest ochrona hasłem.", + "workspace.noteProtection.archiveEnabled": "Archiwizacja włączona: dozwolone usunięcie przenosi notatkę, jej historię i pliki do archiwum. Właściciel workspace może ją przywrócić.", + "workspace.noteProtection.archiveDisabled": "Archiwizacja wyłączona: dozwolone usunięcie trwale usuwa notatkę i jej pliki. Włącz archiwizację w ustawieniach workspace, jeśli przywracanie jest ważne.", "workspace.notes.copy": "Wybierz notatkę albo utwórz nową.", "workspace.notesView": "Widok notatek", "workspace.pagination": "Paginacja notatek", @@ -1013,6 +1016,8 @@ "files.less": "Mniej", "files.noMatchesHelp": "Spróbuj innej nazwy pliku lub typu.", "files.link": "Link", - "files.markdown": "Markdown" + "files.markdown": "Markdown", + "home.account.loading": "Sprawdzanie konta...", + "home.account.retry": "Odśwież stronę" } } \ No newline at end of file diff --git a/src/assets.rs b/src/assets.rs index 0e84e41..ad9e9e6 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -48,6 +48,7 @@ const MODULES: &[&str] = &[ "theme", "url-state", "vendor-libs", + "virtual-editor-layers", "security", "select", ]; @@ -73,8 +74,18 @@ pub fn render_html( urls.stylesheet("styles"), urls.stylesheet_path("libs/rustpad-player/player.css"), ); + let show_note_loader = matches!(entrypoint, "pad" | "note"); let html = template - .replace("__APP_THEME_BOOTSTRAP__", &format!("{}{}", theme_bootstrap(), language_bootstrap())) + .replace( + "__APP_THEME_BOOTSTRAP__", + &format!( + "{}{}{}", + theme_bootstrap(), + language_bootstrap(), + if show_note_loader { loading_bootstrap() } else { String::new() } + ), + ) + .replace("__APP_LOADING_OVERLAY__", if show_note_loader { loading_overlay() } else { "" }) .replace("__APP_STYLESHEET__", &app_stylesheets) .replace("__APP_IMPORT_MAP__", &urls.import_map()) .replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint)) @@ -101,6 +112,39 @@ pub fn render_html( response } +fn loading_bootstrap() -> String { + // Embedded critical CSS and controller are available before the external assets. + format!( + "", + include_str!("../static/css/boot-loading.css"), + include_str!("../static/js/boot-loading.js"), + ) +} + +fn loading_overlay() -> &'static str { + r#"
+
+ +

Loading…

+

Preparing the interface

+
+
+
+

0 of 5 stages completed

+
    +
  1. Interface
  2. +
  3. Language
  4. +
  5. Editor
  6. +
  7. Access
  8. +
  9. Note
  10. +
+ +
+
"# +} + pub fn theme_bootstrap() -> &'static str { r#""# } @@ -154,13 +198,16 @@ impl<'a> AssetUrls<'a> { } fn stylesheet_path(&self, path: &str) -> String { - format!(r#""#, self.url(path)) + format!( + r#""#, + self.url(path) + ) } fn entrypoint(&self, name: &str) -> String { let entrypoint = self.url(&format!("js/{name}.js")); format!( - r#""#, + r#""#, escape_js_string(&entrypoint) ) } diff --git a/src/config/mod.rs b/src/config/mod.rs index f99a89d..c710f4f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -174,7 +174,7 @@ impl Config { "GUEST_UPLOAD_MAX_SIZE_MB", guest_upload_max_size_mb, )?, - asset_version: env!("CARGO_PKG_VERSION").to_owned(), + asset_version: format!("{}-ui3", env!("CARGO_PKG_VERSION")), http_compression: values.bool("HTTP_COMPRESSION", false)?, asset_cache_max_age_seconds: values .nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, @@ -241,10 +241,7 @@ fn multipart_body_limit_bytes( file_limit.saturating_add(1024 * 1024) } -fn megabytes_to_bytes( - name: &str, - megabytes: u64, -) -> Result> { +fn megabytes_to_bytes(name: &str, megabytes: u64) -> Result> { let bytes = megabytes .checked_mul(1024 * 1024) .ok_or_else(|| format!("{name} is too large"))?; diff --git a/static/css/boot-loading.css b/static/css/boot-loading.css new file mode 100644 index 0000000..a90ae5e --- /dev/null +++ b/static/css/boot-loading.css @@ -0,0 +1,240 @@ +#app-boot-overlay { + --boot-bg: #0b0d11; + --boot-panel: #151922; + --boot-border: #343c49; + --boot-text: #f4f6f8; + --boot-muted: #abb5c3; + --boot-accent: #a99ef8; + --boot-brand-start: #f0aa74; + --boot-brand-mid: #cf633d; + --boot-brand-end: #8f3827; + position: fixed; + inset: 0; + z-index: 2147483647; + display: grid; + place-items: center; + padding: 24px; + background: var(--boot-bg); + color: var(--boot-text); + font: 500 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; + text-align: center; + opacity: 1; + visibility: visible !important; + transition: opacity .18s ease; +} + +:root[data-theme=light] #app-boot-overlay { + --boot-bg: #f7f4ee; + --boot-panel: #fffdf9; + --boot-border: #cbc3b8; + --boot-text: #29251f; + --boot-muted: #625a50; + --boot-accent: #6251bf; + --boot-brand-start: #c9794e; + --boot-brand-mid: #ae5d3c; + --boot-brand-end: #7c3d2c; +} + +#app-boot-overlay.is-ready { + opacity: 0; + pointer-events: none; +} + +#app-boot-overlay .app-boot-card { + width: min(100%, 390px); +} + +#app-boot-overlay .app-boot-brand { + position: relative; + display: inline-block; + max-width: 100%; + margin: 0 auto 25px; + padding: 0 8px 11px; + line-height: 1; +} + +#app-boot-overlay .app-boot-wordmark { + position: relative; + display: block; + white-space: nowrap; + font-size: calc(clamp(42px, 10vw, 57px)); + font-weight: 800; + letter-spacing: -.035em; + line-height: 1.13; + background: linear-gradient(110deg, + var(--live-gradient-start, var(--boot-brand-start)) 0, + var(--live-gradient-mid, var(--boot-brand-mid)) 48%, + var(--live-gradient-end, var(--boot-brand-end)) 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + animation: app-boot-wordmark-breathe 2.8s ease-in-out infinite; +} + +#app-boot-overlay .app-boot-wordmark:after { + content: "RustPad"; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(100deg, + transparent 35%, hsla(0, 0%, 100%, .7) 50%, transparent 65%); + background-size: 250% 100%; + background-position: 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + animation: app-boot-wordmark-shine 3.1s ease-in-out infinite; +} + +#app-boot-overlay .app-boot-brand:after { + content: ""; + position: absolute; + bottom: 0; + left: 12%; + width: 76%; + height: 2px; + border-radius: 50px; + background: linear-gradient(90deg, transparent, var(--boot-accent), transparent); + opacity: .75; + transform: scaleX(.35); + animation: app-boot-brand-underline 2.8s ease-in-out infinite; +} + +#app-boot-overlay h2 { + margin: 0 0 9px; + font-size: 21px; + font-weight: 720; + letter-spacing: -.035em; + color: var(--boot-text); +} + +#app-boot-overlay #app-boot-stage { + margin: 0 0 23px; + color: var(--boot-muted); + min-height: 22px; +} + +#app-boot-overlay .app-boot-track { + height: 6px; + overflow: hidden; + border-radius: 100px; + background: var(--boot-border); +} + +#app-boot-overlay #app-boot-progress-fill { + height: 100%; + width: 0; + background: var(--boot-accent); + border-radius: inherit; + transition: width .14s ease; +} + +#app-boot-overlay #app-boot-count { + margin: 10px 0 18px; + font-size: 12px; + color: var(--boot-muted); + overflow-wrap: anywhere; +} + +#app-boot-overlay .app-boot-steps { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 7px; + padding: 0; + margin: 0; + list-style: none; +} + +#app-boot-overlay .app-boot-steps li { + padding: 4px 8px; + border: 1px solid var(--boot-border); + border-radius: 7px; + font-size: 10px; + color: var(--boot-muted); + background: var(--boot-panel); +} + +#app-boot-overlay .app-boot-steps li[data-status=done] { + color: var(--boot-accent); + border-color: var(--boot-accent); +} + +#app-boot-overlay .app-boot-steps li[data-status=active] { + color: var(--boot-text); +} + +#app-boot-overlay #app-boot-retry { + margin-top: 18px; + padding: 10px 18px; + border: 1px solid var(--boot-accent); + border-radius: 8px; + background: var(--boot-panel); + color: var(--boot-text); + cursor: pointer; +} + +#app-boot-overlay #app-boot-retry[hidden] { + display: none; +} + +@keyframes app-boot-wordmark-breathe { + + 0%, + to { + transform: translateY(0); + } + + 50% { + transform: translateY(-2px); + } +} + +@keyframes app-boot-wordmark-shine { + + 0%, + 20% { + background-position: 100%; + } + + 70%, + to { + background-position: -100%; + } +} + +@keyframes app-boot-brand-underline { + + 0%, + to { + transform: scaleX(.35); + opacity: .4; + } + + 50% { + transform: scaleX(1); + opacity: .85; + } +} + +#app-boot-overlay.has-error .app-boot-brand:after, +#app-boot-overlay.has-error .app-boot-wordmark, +#app-boot-overlay.has-error .app-boot-wordmark:after { + animation: none; +} + +@media(prefers-reduced-motion:reduce) { + + #app-boot-overlay, + #app-boot-overlay #app-boot-progress-fill { + transition: none; + } + + #app-boot-overlay .app-boot-brand:after, + #app-boot-overlay .app-boot-wordmark, + #app-boot-overlay .app-boot-wordmark:after { + animation: none; + } +} \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css index ea59e28..02c37ad 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,16 +1,7 @@ -/* - * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl - * Source-Available Code / Dual-Licensed. - * - * Free for non-commercial and evaluation use under terms of BSL/GPLv3. - * Commercial or production use requires a valid paid license. - * See LICENSE file in repository root for details. - */ - :root, -:root[data-theme="dark"] { +:root[data-theme=dark] { color-scheme: dark; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; font-synthesis: none; background: #0b0d11; color: #f4f6f8; @@ -87,8 +78,8 @@ --danger-button-text: #fff4f5; --danger-subtle-bg: rgba(255, 123, 145, .08); --danger-subtle-hover: rgba(255, 123, 145, .14); - --theme-contrast: white; - --theme-shadow-mix: black; + --theme-contrast: #fff; + --theme-shadow-mix: #000; --accent: #7561e0; --accent-hover: #6955cb; @@ -127,27 +118,27 @@ --live-gradient-end: #8f3827; --select-arrow: #9aa3b2; - --wash-faint: rgba(255, 255, 255, .015); - --wash-soft: rgba(255, 255, 255, .02); - --wash-subtle: rgba(255, 255, 255, .025); - --wash-medium: rgba(255, 255, 255, .04); - --wash-hover: rgba(255, 255, 255, .07); - --wash-border: rgba(255, 255, 255, .12); - --wash-strong: rgba(255, 255, 255, .75); + --wash-faint: hsla(0, 0%, 100%, .015); + --wash-soft: hsla(0, 0%, 100%, .02); + --wash-subtle: hsla(0, 0%, 100%, .025); + --wash-medium: hsla(0, 0%, 100%, .04); + --wash-hover: hsla(0, 0%, 100%, .07); + --wash-border: hsla(0, 0%, 100%, .12); + --wash-strong: hsla(0, 0%, 100%, .75); --accent-a08: rgba(124, 104, 238, .08); --accent-a25: rgba(124, 104, 238, .25); --accent-a30: rgba(124, 104, 238, .3); --accent-a35: rgba(124, 104, 238, .35); - --focus-ring: rgb(117 103 219 / .18); + --focus-ring: rgba(117, 103, 219, .18); --danger-a45: rgba(255, 123, 145, .45); --live-border: rgba(195, 91, 54, .28); --shadow-28: rgba(0, 0, 0, .28); - --shadow-38: rgb(0 0 0 / 38%); + --shadow-38: rgba(0, 0, 0, .38); --shadow-40: rgba(0, 0, 0, .4); - --shadow-45: rgb(0 0 0 / 45%); - --shadow-58: rgb(0 0 0 / .58); - --overlay-68: rgb(0 0 0 / .68); + --shadow-45: rgba(0, 0, 0, .45); + --shadow-58: rgba(0, 0, 0, .58); + --overlay-68: rgba(0, 0, 0, .68); --overlay-72: rgba(4, 6, 10, .72); --overlay-78: rgba(4, 6, 9, .78); --overlay-82: rgba(4, 6, 9, .82); @@ -157,7 +148,7 @@ --top-header-control-height: 34px; } -:root[data-theme="light"] { +:root[data-theme=light] { color-scheme: light; background: #f7f4ee; color: #29251f; @@ -233,7 +224,7 @@ --danger-button-text: #7f2836; --danger-subtle-bg: #f7e7e6; --danger-subtle-hover: #efd4d4; - --theme-contrast: black; + --theme-contrast: #000; --theme-shadow-mix: #6c6258; --accent: #6859b5; @@ -260,7 +251,7 @@ --status-info-border: #56759c; --status-warning-border: #a27831; --status-danger-border: #a54b55; - --status-success-bg: rgba(61, 121, 88, .10); + --status-success-bg: rgba(61, 121, 88, .1); --status-info-bg: rgba(68, 103, 148, .09); --status-warning-bg: rgba(155, 112, 35, .11); --status-danger-bg: rgba(155, 52, 67, .09); @@ -281,24 +272,24 @@ --wash-border: rgba(49, 43, 36, .13); --wash-strong: rgba(49, 43, 36, .56); --accent-a08: rgba(104, 89, 181, .08); - --accent-a25: rgba(104, 89, 181, .20); + --accent-a25: rgba(104, 89, 181, .2); --accent-a30: rgba(104, 89, 181, .24); --accent-a35: rgba(104, 89, 181, .28); - --focus-ring: rgb(104 89 181 / .17); - --danger-a45: rgba(155, 52, 67, .30); + --focus-ring: rgba(104, 89, 181, .17); + --danger-a45: rgba(155, 52, 67, .3); --live-border: rgba(174, 93, 60, .23); - --shadow-28: rgba(73, 61, 48, .10); - --shadow-38: rgb(73 61 48 / 14%); + --shadow-28: rgba(73, 61, 48, .1); + --shadow-38: rgba(73, 61, 48, .14); --shadow-40: rgba(73, 61, 48, .16); - --shadow-45: rgb(73 61 48 / 18%); - --shadow-58: rgb(73 61 48 / .22); - --overlay-68: rgb(47 42 36 / .44); + --shadow-45: rgba(73, 61, 48, .18); + --shadow-58: rgba(73, 61, 48, .22); + --overlay-68: rgba(47, 42, 36, .44); --overlay-72: rgba(47, 42, 36, .48); --overlay-78: rgba(47, 42, 36, .52); --overlay-82: rgba(47, 42, 36, .56); --glass-92: rgba(255, 253, 249, .94); - --glass-toolbar: rgba(255, 253, 249, .90); + --glass-toolbar: rgba(255, 253, 249, .9); } * { @@ -324,8 +315,8 @@ textarea { font: inherit; } -button, a, +button, input, select, textarea { @@ -365,7 +356,7 @@ a { } .site-header { - width: min(720px, calc(100% - 32px)); + width: min(720px, 100% - 32px); min-height: var(--top-header-height); margin: 0 auto; display: flex; @@ -374,7 +365,7 @@ a { } .home-layout { - width: min(520px, calc(100% - 32px)); + width: min(520px, 100% - 32px); margin: 0 auto; padding: 72px 0; } @@ -385,7 +376,7 @@ a { .home-intro h1 { margin: 0; - font-size: clamp(2rem, 7vw, 3rem); + font-size: calc(clamp(2rem, 7vw, 3rem)); letter-spacing: -.04em; } @@ -422,17 +413,17 @@ a { font-weight: 750; } -.label-row, -.field-meta { +.field-meta, +.label-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.label-row span, +.field small, .field-meta, -.field small { +.label-row span { color: var(--muted-2); font-size: .73rem; } @@ -445,8 +436,8 @@ select { color: var(--text); } -select option, -select optgroup { +select optgroup, +select option { background: var(--control-bg); color: var(--text); } @@ -506,9 +497,9 @@ textarea:focus { color: var(--danger); } +.inline-button, .primary-button, -.secondary-button, -.inline-button { +.secondary-button { display: inline-flex; align-items: center; justify-content: center; @@ -570,9 +561,9 @@ textarea:focus { } .app-header .secondary-button, -.public-header .secondary-button, .pad-page .header-actions>.secondary-button, -.pad-page .page-settings>summary.secondary-button { +.pad-page .page-settings>summary.secondary-button, +.public-header .secondary-button { min-height: var(--top-header-control-height); padding-inline: 10px; } @@ -624,7 +615,7 @@ textarea:focus { transition: color .15s ease, text-shadow .15s ease, transform .15s ease; } -.favorite-toggle:not([aria-pressed="true"]):hover { +.favorite-toggle:not([aria-pressed=true]):hover { color: var(--text); } @@ -633,7 +624,7 @@ textarea:focus { text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent); } -.favorite-toggle[aria-pressed="true"] { +.favorite-toggle[aria-pressed=true] { color: var(--accent); text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); transform: scale(1.08); @@ -650,8 +641,8 @@ textarea:focus { outline: none; } -.document-heading--copy:hover, -.document-heading--copy:focus-visible { +.document-heading--copy:focus-visible, +.document-heading--copy:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 10%, transparent); } @@ -711,7 +702,7 @@ textarea:focus { left: 50%; z-index: 18; display: flex; - width: min(520px, calc(100% - 32px)); + width: min(520px, 100% - 32px); box-sizing: border-box; align-items: center; gap: 12px; @@ -728,7 +719,7 @@ textarea:focus { .connection-notice.is-visible { opacity: 1; - transform: translate(-50%, 0) scale(1); + transform: translate(-50%) scale(1); } .connection-notice.is-error { @@ -806,7 +797,7 @@ textarea:focus { @keyframes connection-pulse { 0%, - 100% { + to { transform: scaleY(.45); opacity: .48; } @@ -950,7 +941,7 @@ textarea:focus { backdrop-filter: blur(10px); } -.pad-page.toolbar-collapsed .editor-toolbar> :not(.toolbar-collapse-toggle):not(.view-switch) { +.pad-page.toolbar-collapsed .editor-toolbar>:not(.toolbar-collapse-toggle):not(.view-switch) { display: none !important; } @@ -1316,8 +1307,8 @@ textarea::selection { background: var(--surface-card); } -.history-calendar__month-head, -.history-archive__selected-head { +.history-archive__selected-head, +.history-calendar__month-head { display: flex; align-items: baseline; justify-content: space-between; @@ -1325,19 +1316,19 @@ textarea::selection { margin-bottom: 8px; } -.history-calendar__month-head strong, -.history-archive__selected-head strong { +.history-archive__selected-head strong, +.history-calendar__month-head strong { font-size: .78rem; } -.history-calendar__month-head span, -.history-archive__selected-head span { +.history-archive__selected-head span, +.history-calendar__month-head span { color: var(--muted-2); font-size: .64rem; } -.history-calendar__weekdays, -.history-calendar__days { +.history-calendar__days, +.history-calendar__weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; @@ -1426,7 +1417,7 @@ button.history-calendar__day { padding: 13px 0; } -.revision::after { +.revision:after { content: ""; position: absolute; top: 25px; @@ -1436,7 +1427,7 @@ button.history-calendar__day { background: var(--border); } -.revision:last-child::after { +.revision:last-child:after { display: none; } @@ -1481,7 +1472,7 @@ button.history-calendar__day { } dialog { - width: min(420px, calc(100% - 24px)); + width: min(420px, 100% - 24px); padding: 0; border: 0; background: transparent; @@ -1521,8 +1512,8 @@ dialog::backdrop { bottom: 20px; z-index: 100; display: flex; - width: min(390px, calc(100vw - 32px)); - max-height: min(720px, calc(100dvh - 40px)); + width: calc(min(390px, 100vw - 32px)); + max-height: calc(min(720px, 100dvh - 40px)); flex-direction: column; gap: 10px; pointer-events: none; @@ -1677,7 +1668,7 @@ dialog::backdrop { } @keyframes toast-countdown { - from { + 0% { transform: scaleX(1); } @@ -1686,12 +1677,12 @@ dialog::backdrop { } } -@media (max-width: 520px) { +@media(max-width:520px) { .toast-region { right: 10px; bottom: calc(10px + env(safe-area-inset-bottom, 0px)); left: auto; - width: min(300px, calc(100vw - 20px)); + width: calc(min(300px, 100vw - 20px)); max-height: calc(100dvh - 20px); gap: 7px; } @@ -1739,8 +1730,8 @@ dialog::backdrop { gap: 6px; } - .upload-toast__filename, - .upload-toast__error { + .upload-toast__error, + .upload-toast__filename { font-size: .67rem; } @@ -1765,7 +1756,7 @@ dialog::backdrop { } } -@media (prefers-reduced-motion: reduce) { +@media(prefers-reduced-motion:reduce) { .toast-card { transition-duration: .01ms; } @@ -1781,7 +1772,7 @@ dialog::backdrop { .error-page h1 { margin: 0; - font-size: clamp(2rem, 6vw, 4rem); + font-size: calc(clamp(2rem, 6vw, 4rem)); letter-spacing: -.05em; } @@ -1789,7 +1780,7 @@ dialog::backdrop { color: var(--muted); } -@media (max-width: 980px) { +@media(max-width:980px) { .app-header { align-items: flex-start; min-height: auto; @@ -1818,10 +1809,10 @@ dialog::backdrop { } } -@media (max-width: 720px) { +@media(max-width:720px) { - .site-header, - .home-layout { + .home-layout, + .site-header { width: min(100% - 24px, 520px); } @@ -1843,14 +1834,14 @@ dialog::backdrop { } .app-header .secondary-button, - .public-header .secondary-button, - .pad-page .page-settings>summary.secondary-button { + .pad-page .page-settings>summary.secondary-button, + .public-header .secondary-button { min-height: 36px; } } -@media (max-width: 480px) { +@media(max-width:480px) { .create-panel { padding: 20px; } @@ -1877,7 +1868,7 @@ dialog::backdrop { } .workspace-page { - width: min(1100px, calc(100% - 32px)); + width: min(1100px, 100% - 32px); margin: 0 auto; padding: 44px 0 80px; } @@ -1962,7 +1953,7 @@ dialog::backdrop { } .error-card h1 { - font-size: clamp(1.8rem, 6vw, 3rem); + font-size: calc(clamp(1.8rem, 6vw, 3rem)); } .error-card>p:not(.error-code) { @@ -1978,7 +1969,7 @@ dialog::backdrop { margin-top: 24px; } -@media (max-width: 480px) { +@media(max-width:480px) { .error-card { padding: 24px 18px; } @@ -1993,7 +1984,7 @@ dialog::backdrop { } .home-layout--wide { - width: min(1040px, calc(100% - 32px)); + width: min(1040px, 100% - 32px); } .create-grid { @@ -2027,7 +2018,7 @@ dialog::backdrop { width: 100%; } -@media (max-width: 760px) { +@media(max-width:760px) { .home-layout--wide { width: min(100% - 24px, 560px); } @@ -2042,7 +2033,6 @@ dialog::backdrop { grid-template-columns: auto minmax(0, 1fr); min-height: 0; overflow: hidden; - background: var(--surface-inset); } .line-gutter { @@ -2093,7 +2083,7 @@ dialog::backdrop { position: relative; display: inline-flex; align-items: center; - gap: 0.35rem; + gap: .35rem; } .user-chip { @@ -2109,8 +2099,8 @@ dialog::backdrop { font-size: .78rem; } -.user-chip:hover, -.user-chip:focus-visible { +.user-chip:focus-visible, +.user-chip:hover { background: var(--surface-2); outline: none; } @@ -2186,7 +2176,6 @@ dialog::backdrop { background: var(--code-bg); color: var(--code-text); font: .72rem/1.5 ui-monospace, monospace; - white-space: pre-wrap; } .revision button+button { @@ -2201,7 +2190,7 @@ dialog::backdrop { background: var(--surface-deep); } -@media (max-width: 720px) { +@media(max-width:720px) { .line-gutter { width: 48px; padding-top: 18px; @@ -2247,7 +2236,7 @@ dialog::backdrop { align-items: center; justify-content: space-between; min-height: var(--top-header-height); - padding: 0 max(20px, calc((100vw - 900px) / 2)); + padding: 0 calc(max(20px, 50vw - 450px)); border-bottom: 1px solid var(--border); background: var(--glass-92); backdrop-filter: blur(12px); @@ -2261,7 +2250,7 @@ dialog::backdrop { .public-document>h1 { margin: 0; - font-size: clamp(2rem, 6vw, 4rem); + font-size: calc(clamp(2rem, 6vw, 4rem)); line-height: 1.08; } @@ -2287,7 +2276,7 @@ dialog::backdrop { padding-left: 32px; } -@media (max-width: 600px) { +@media(max-width:600px) { .public-document { padding-top: 32px; } @@ -2348,18 +2337,18 @@ dialog::backdrop { .home-header { background: transparent; - width: min(1040px, calc(100% - 32px)); + width: min(1040px, 100% - 32px); border-bottom-color: var(--live-border); } .home-brand { - background: linear-gradient(110deg, var(--live-gradient-start) 0%, var(--live-gradient-mid) 48%, var(--live-gradient-end) 100%); + background: linear-gradient(110deg, var(--live-gradient-start) 0, var(--live-gradient-mid) 48%, var(--live-gradient-end) 100%); -webkit-background-clip: text; background-clip: text; color: transparent; } -@media (max-width: 760px) { +@media(max-width:760px) { .home-footer { width: min(100% - 24px, 560px); margin-bottom: 20px; @@ -2386,8 +2375,8 @@ dialog::backdrop { position: absolute; right: calc(var(--editor-rendered-font-size, 14px) * .57); box-sizing: border-box; - max-width: min(calc(var(--editor-rendered-font-size, 14px) * 12.8571), 45%); - max-height: calc(var(--editor-line-height, 29px) - 2px); + max-width: min(var(--editor-rendered-font-size, 14px) * 12.8571, 45%); + max-height: calc(-2px + var(--editor-line-height, 29px)); overflow: hidden; padding: .14em .5em; border: 1px solid color-mix(in srgb, var(--owner) 62%, transparent); @@ -2404,7 +2393,7 @@ dialog::backdrop { left: 0; } -@media (max-width: 720px) { +@media(max-width:720px) { .line-gutter { width: 42px; } @@ -2420,7 +2409,6 @@ dialog::backdrop { } .owner-labels { - z-index: 4; top: 0; bottom: 0; height: auto; @@ -2453,28 +2441,28 @@ dialog::backdrop { font-size: .74rem; } -.editor-workspace-font-system textarea, -.editor-workspace-font-system .preview { +.editor-workspace-font-system .preview, +.editor-workspace-font-system textarea { font-family: Inter, ui-sans-serif, system-ui, sans-serif; } -.editor-workspace-font-serif textarea, -.editor-workspace-font-serif .preview { - font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif; +.editor-workspace-font-serif .preview, +.editor-workspace-font-serif textarea { + font-family: ui-serif, Georgia, Cambria, Times New Roman, serif; } -.editor-workspace-font-arial textarea, -.editor-workspace-font-arial .preview { +.editor-workspace-font-arial .preview, +.editor-workspace-font-arial textarea { font-family: Arial, Helvetica, sans-serif; } -.editor-workspace-font-georgia textarea, -.editor-workspace-font-georgia .preview { - font-family: Georgia, "Times New Roman", serif; +.editor-workspace-font-georgia .preview, +.editor-workspace-font-georgia textarea { + font-family: Georgia, Times New Roman, serif; } -.editor-workspace-font-mono textarea, -.editor-workspace-font-mono .preview { +.editor-workspace-font-mono .preview, +.editor-workspace-font-mono textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } @@ -2489,20 +2477,20 @@ dialog::backdrop { font-size: .68rem; } -.compact-editor textarea, -.compact-editor .preview { +.compact-editor .preview, +.compact-editor textarea { padding-top: 14px; padding-bottom: 14px; - font-size: calc(var(--editor-font-size, 18px) - 2px); + font-size: calc(-2px + var(--editor-font-size, 18px)); line-height: 1.45; } -.workspace textarea, -.workspace .preview { +.workspace .preview, +.workspace textarea { font-size: var(--editor-font-size, 18px); } -@media (max-width: 900px) { +@media(max-width:900px) { .editor-controls { order: 3; width: 100%; @@ -2533,11 +2521,11 @@ dialog::backdrop { } .compact-editor .line-gutter { - font-size: calc(var(--editor-font-size, 18px) - 2px); + font-size: calc(-2px + var(--editor-font-size, 18px)); } .image-editor-dialog { - width: min(920px, calc(100% - 28px)); + width: min(920px, 100% - 28px); max-width: none; padding: 0; border: 1px solid var(--border); @@ -2630,7 +2618,7 @@ dialog::backdrop { font-weight: 600; } -.image-editor-controls input[type="range"] { +.image-editor-controls input[type=range] { height: 40px; min-height: 40px; margin: 0; @@ -2644,14 +2632,14 @@ dialog::backdrop { cursor: pointer; } -.image-editor-controls input[type="range"]::-webkit-slider-runnable-track { +.image-editor-controls input[type=range]::-webkit-slider-runnable-track { height: 5px; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--surface-strong-2); } -.image-editor-controls input[type="range"]::-webkit-slider-thumb { +.image-editor-controls input[type=range]::-webkit-slider-thumb { width: 17px; height: 17px; margin-top: -7px; @@ -2664,29 +2652,29 @@ dialog::backdrop { transition: transform .12s ease, background-color .12s ease; } -.image-editor-controls input[type="range"]:hover::-webkit-slider-thumb { +.image-editor-controls input[type=range]:hover::-webkit-slider-thumb { background: var(--accent-hover); transform: scale(1.08); } -.image-editor-controls input[type="range"]:focus-visible::-webkit-slider-thumb { +.image-editor-controls input[type=range]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 1px var(--accent-border), 0 0 0 4px var(--focus-ring); } -.image-editor-controls input[type="range"]::-moz-range-track { +.image-editor-controls input[type=range]::-moz-range-track { height: 5px; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--surface-strong-2); } -.image-editor-controls input[type="range"]::-moz-range-progress { +.image-editor-controls input[type=range]::-moz-range-progress { height: 5px; border-radius: 999px; background: var(--accent); } -.image-editor-controls input[type="range"]::-moz-range-thumb { +.image-editor-controls input[type=range]::-moz-range-thumb { width: 13px; height: 13px; border: 2px solid var(--surface-panel); @@ -2696,22 +2684,22 @@ dialog::backdrop { transition: transform .12s ease, background-color .12s ease; } -.image-editor-controls input[type="range"]:hover::-moz-range-thumb { +.image-editor-controls input[type=range]:hover::-moz-range-thumb { background: var(--accent-hover); transform: scale(1.08); } -.image-editor-controls input[type="range"]:focus-visible::-moz-range-thumb { +.image-editor-controls input[type=range]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 1px var(--accent-border), 0 0 0 4px var(--focus-ring); } -.image-editor-controls input[type="range"]:disabled { +.image-editor-controls input[type=range]:disabled { cursor: not-allowed; opacity: .45; } -.image-editor-controls input[type="range"]:disabled::-webkit-slider-thumb, -.image-editor-controls input[type="range"]:disabled::-moz-range-thumb { +.image-editor-controls input[type=range]:disabled::-moz-range-thumb, +.image-editor-controls input[type=range]:disabled::-webkit-slider-thumb { background: var(--muted-2); box-shadow: 0 0 0 1px var(--border-strong); transform: none; @@ -2723,7 +2711,7 @@ dialog::backdrop { gap: 10px; } -@media (max-width: 680px) { +@media(max-width:680px) { .image-editor-controls { grid-template-columns: 1fr; } @@ -2734,11 +2722,11 @@ dialog::backdrop { } .public-header { - padding-inline: max(20px, calc((100vw - 1180px) / 2)); + padding-inline: calc(max(20px, 50vw - 590px)); } .public-document { - width: min(1180px, calc(100% - 32px)); + width: min(1180px, 100% - 32px); } .public-page.public-full-width .public-header { @@ -2751,7 +2739,7 @@ dialog::backdrop { } .public-content { - padding: clamp(24px, 4vw, 52px); + padding: calc(clamp(24px, 4vw, 52px)); } .preview { @@ -2859,8 +2847,8 @@ dialog::backdrop { cursor: pointer; } -.image-alias-tools button:hover, -.image-alias-tools button.active { +.image-alias-tools button.active, +.image-alias-tools button:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } @@ -2897,7 +2885,7 @@ dialog::backdrop { user-select: none; } -@media (max-width: 680px) { +@media(max-width:680px) { .image-alias-tools { top: 4px; right: 4px; @@ -2963,7 +2951,7 @@ dialog::backdrop { transition: color .15s ease, text-shadow .15s ease, transform .15s ease; } -.note-favorite-button:not([aria-pressed="true"]):hover { +.note-favorite-button:not([aria-pressed=true]):hover { color: var(--text); } @@ -2972,7 +2960,7 @@ dialog::backdrop { text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent); } -.note-favorite-button[aria-pressed="true"] { +.note-favorite-button[aria-pressed=true] { color: var(--accent); text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); transform: scale(1.08); @@ -2983,8 +2971,8 @@ dialog::backdrop { cursor: wait; } -.protect-badge, -.file-flag { +.file-flag, +.protect-badge { display: inline-flex; padding: 3px 7px; border: 1px solid var(--border-strong); @@ -3026,6 +3014,26 @@ dialog::backdrop { min-height: auto; } +.note-protection-help { + margin: 4px 0 12px; + padding: 10px 12px; + border-left: 2px solid var(--accent-border); + border-radius: 0 8px 8px 0; + background: var(--wash-subtle); + color: var(--text-secondary); + font-size: .76rem; + line-height: 1.5; +} + +.note-protection-help p { + margin: 0; +} + +.note-protection-help p+p { + margin-top: 8px; + color: var(--muted); +} + .footer-link { border: 0; background: transparent; @@ -3037,14 +3045,14 @@ dialog::backdrop { } .files-dialog { - width: min(940px, calc(100% - 28px)); + width: min(940px, 100% - 28px); overflow: hidden; } .files-panel { grid-template-rows: auto auto minmax(0, 1fr); min-height: 0; - height: min(650px, calc(100dvh - 28px)); + height: calc(min(650px, 100dvh - 28px)); max-height: calc(100dvh - 28px); gap: 14px; } @@ -3081,9 +3089,9 @@ dialog::backdrop { flex: 0 0 auto; } +.files-head-actions .danger-button, .files-head-actions .primary-button, -.files-head-actions .secondary-button, -.files-head-actions .danger-button { +.files-head-actions .secondary-button { width: auto; } @@ -3171,7 +3179,7 @@ dialog::backdrop { font-size: .7rem; } -.file-meta>span:not(.file-flag)+span:not(.file-flag)::before { +.file-meta>span:not(.file-flag)+span:not(.file-flag):before { content: "·"; margin-right: 8px; color: var(--border-strong); @@ -3201,13 +3209,13 @@ dialog::backdrop { min-width: 102px; } -.file-more-button[aria-expanded="true"] { +.file-more-button[aria-expanded=true] { border-color: var(--border-strong); background: var(--surface-2); } .file-secondary-actions { - grid-column: 1 / -1; + grid-column: 1/-1; display: grid; gap: 8px; padding-top: 9px; @@ -3268,7 +3276,7 @@ dialog::backdrop { color: var(--text); } -.file-secondary-actions [data-show-file-code][aria-pressed="true"] { +.file-secondary-actions [data-show-file-code][aria-pressed=true] { border-color: var(--border-strong); background: var(--surface-2); } @@ -3307,9 +3315,9 @@ dialog::backdrop { line-height: 1.5; } +.files-state .danger-button, .files-state .primary-button, -.files-state .secondary-button, -.files-state .danger-button { +.files-state .secondary-button { margin-top: 14px; } @@ -3360,7 +3368,7 @@ dialog::backdrop { } @keyframes file-skeleton-pulse { - from { + 0% { background-position: 100% 0; } @@ -3388,11 +3396,11 @@ dialog::backdrop { @keyframes ui-spinner-rotate { to { - transform: rotate(360deg); + transform: rotate(1turn); } } -@media (prefers-reduced-motion: reduce) { +@media(prefers-reduced-motion:reduce) { .file-row, .file-skeleton, @@ -3402,9 +3410,9 @@ dialog::backdrop { } } -@media (max-width: 720px) { +@media(max-width:720px) { .files-panel { - height: min(720px, calc(100dvh - 18px)); + height: calc(min(720px, 100dvh - 18px)); padding: 14px; } @@ -3420,9 +3428,9 @@ dialog::backdrop { gap: 6px; } + .files-head-actions .danger-button, .files-head-actions .primary-button, - .files-head-actions .secondary-button, - .files-head-actions .danger-button { + .files-head-actions .secondary-button { padding-inline: 9px; } @@ -3455,7 +3463,7 @@ dialog::backdrop { } .video-insert-dialog { - width: min(560px, calc(100vw - 24px)); + width: calc(min(560px, 100vw - 24px)); max-width: 560px; } @@ -3477,9 +3485,9 @@ dialog::backdrop { gap: 18px; } -.video-insert-dialog__panel h2, +.video-insert-dialog__panel .dialog-copy, .video-insert-dialog__panel .eyebrow, -.video-insert-dialog__panel .dialog-copy { +.video-insert-dialog__panel h2 { margin: 0; } @@ -3517,7 +3525,7 @@ dialog::backdrop { color: var(--text-secondary); } -@media (max-width: 600px) { +@media(max-width:600px) { .video-choice-actions { grid-template-columns: minmax(0, 1fr); } @@ -3593,7 +3601,7 @@ dialog::backdrop { } .workspace-password-card>.form-message { - grid-column: 1 / -1; + grid-column: 1/-1; margin: 0; font-size: .72rem; } @@ -3621,15 +3629,15 @@ dialog::backdrop { .workspace-settings-card__copy { display: grid; gap: 2px; - grid-column: 1 / -1; + grid-column: 1/-1; } .workspace-settings-card__copy strong { font-size: .82rem; } -.workspace-settings-card__copy small, -.workspace-settings-card>.form-message { +.workspace-settings-card>.form-message, +.workspace-settings-card__copy small { color: var(--muted); font-size: .72rem; } @@ -3650,7 +3658,7 @@ dialog::backdrop { } .workspace-settings-card>.form-message { - grid-column: 1 / -1; + grid-column: 1/-1; margin: 0; } @@ -3659,12 +3667,12 @@ dialog::backdrop { } .archived-notes-dialog { - width: min(620px, calc(100% - 24px)); + width: min(620px, 100% - 24px); } .archived-notes-panel { grid-template-rows: auto minmax(0, 1fr); - max-height: min(78dvh, 720px); + max-height: calc(min(78dvh, 720px)); overflow: hidden; } @@ -3714,8 +3722,8 @@ dialog::backdrop { white-space: nowrap; } -.archived-note-card__copy span, -.archived-note-card__copy small { +.archived-note-card__copy small, +.archived-note-card__copy span { color: var(--muted); font-size: .74rem; } @@ -3785,8 +3793,8 @@ dialog::backdrop { border-collapse: collapse; } -.notes-table th, -.notes-table td { +.notes-table td, +.notes-table th { padding: 13px 15px; border-bottom: 1px solid var(--border); text-align: left; @@ -3856,7 +3864,7 @@ dialog::backdrop { border-radius: 6px; } -@media (max-width: 700px) { +@media(max-width:700px) { .workspace-top { align-items: flex-start; flex-direction: column; @@ -3932,10 +3940,10 @@ dialog::backdrop { min-height: 1.32em; } -.preview-source-line::before { +.preview-source-line:before { content: attr(data-source-line); position: absolute; - top: var(--preview-line-top, 0px); + top: var(--preview-line-top, 0); left: var(--preview-line-left, -50px); width: 32px; color: var(--gutter-text); @@ -3952,7 +3960,7 @@ dialog::backdrop { -webkit-user-select: text; } -.preview-editable[contenteditable="true"] { +.preview-editable[contenteditable=true] { caret-color: auto; } @@ -3979,8 +3987,8 @@ dialog::backdrop { margin: .3em 0; } -.markdown-body ul, -.markdown-body ol { +.markdown-body ol, +.markdown-body ul { margin: .22em 0; } @@ -3992,7 +4000,7 @@ dialog::backdrop { padding-left: 24px; } -.hide-preview-line-numbers .preview-source-line::before { +.hide-preview-line-numbers .preview-source-line:before { display: none; } @@ -4006,8 +4014,8 @@ dialog::backdrop { border-collapse: collapse; } -.markdown-body th, -.markdown-body td { +.markdown-body td, +.markdown-body th { padding: .55em .7em; border: 1px solid var(--border); vertical-align: top; @@ -4078,7 +4086,7 @@ dialog::backdrop { border-color: var(--accent); background-color: var(--accent); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M2 6.5 4.7 9 10 3'/%3E%3C/svg%3E"); - background-position: center; + background-position: 50%; background-repeat: no-repeat; background-size: .78em .78em; } @@ -4109,8 +4117,8 @@ dialog::backdrop { opacity: .6; } -.markdown-body .task-list-item>ul, -.markdown-body .task-list-item>ol { +.markdown-body .task-list-item>ol, +.markdown-body .task-list-item>ul { min-width: 0; } @@ -4159,7 +4167,7 @@ dialog::backdrop { font-size: .82rem; } -@media (max-width: 520px) { +@media(max-width:520px) { .shortcut-grid { grid-template-columns: 1fr; gap: 5px; @@ -4221,7 +4229,7 @@ dialog::backdrop { z-index: 55; left: 0; bottom: calc(100% + 9px); - width: min(400px, calc(100vw - 24px)); + width: calc(min(400px, 100vw - 24px)); padding: 12px; border: 1px solid var(--border-strong); border-radius: 10px; @@ -4254,7 +4262,7 @@ dialog::backdrop { } .connection-diagnostics-grid__wide { - grid-column: 1 / -1; + grid-column: 1/-1; } .connection-diagnostics-grid dt { @@ -4276,15 +4284,15 @@ dialog::backdrop { line-height: 1.35; } -:is(.connection-details, .mobile-connection-details):is(.is-quality-excellent, .is-quality-good) [data-connection-diagnostic="quality"] { +:is(.connection-details, .mobile-connection-details):is(.is-quality-excellent, .is-quality-good) [data-connection-diagnostic=quality] { color: var(--success); } -:is(.connection-details, .mobile-connection-details).is-quality-degraded [data-connection-diagnostic="quality"] { +:is(.connection-details, .mobile-connection-details).is-quality-degraded [data-connection-diagnostic=quality] { color: var(--warning); } -:is(.connection-details, .mobile-connection-details).is-quality-poor [data-connection-diagnostic="quality"] { +:is(.connection-details, .mobile-connection-details).is-quality-poor [data-connection-diagnostic=quality] { color: var(--danger); } @@ -4347,13 +4355,13 @@ dialog::backdrop { } .hljs-keyword, -.hljs-selector-tag, -.hljs-literal { +.hljs-literal, +.hljs-selector-tag { color: var(--syntax-keyword); } -.hljs-string, -.hljs-attr { +.hljs-attr, +.hljs-string { color: var(--syntax-string); } @@ -4371,7 +4379,7 @@ dialog::backdrop { pointer-events: auto; } -@media (max-width:720px) { +@media(max-width:720px) { .footer-left, .footer-right { @@ -4447,7 +4455,7 @@ dialog::backdrop { } .home-footer { - width: min(1040px, calc(100% - 32px)); + width: min(1040px, 100% - 32px); margin: auto auto 24px; padding-top: 24px; color: var(--muted); @@ -4463,6 +4471,106 @@ dialog::backdrop { border-top: 1px solid var(--border); } +.home-footer__account-slot { + position: relative; + display: flex; + flex: 1 1 auto; + align-items: center; + min-width: 0; + min-height: 52px; +} + +.home-footer__account-pending { + position: absolute; + inset: 0 auto 0 0; + display: flex; + align-items: center; + gap: 10px; + width: min(100%, 270px); + pointer-events: none; + opacity: 1; + transition: opacity .18s ease, visibility .18s; +} + +.home-footer__loading-action, +.home-footer__loading-line { + display: block; + height: 13px; + flex: 0 1 135px; + border-radius: 6px; + background: var(--surface-2); + box-shadow: inset 0 0 0 1px var(--border); + animation: home-account-wait 1.4s ease-in-out infinite; +} + +.home-footer__loading-action { + flex: 0 0 82px; + height: 34px; + border-radius: 8px; + animation-delay: .15s; +} + +.home-footer__account-slot[data-state=error] .home-footer__account-pending, +.home-footer__account-slot[data-state=ready] .home-footer__account-pending { + opacity: 0; + visibility: hidden; +} + +.home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]) { + animation: home-account-reveal .25s ease-out both; +} + +@keyframes home-account-wait { + + 0%, + to { + opacity: .45; + } + + 50% { + opacity: .85; + } +} + +@keyframes home-account-reveal { + 0% { + opacity: 0; + transform: translateY(5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media(prefers-reduced-motion:reduce) { + + .home-footer__account-slot[data-state=ready]>.home-footer__account:not([hidden]), + .home-footer__loading-action, + .home-footer__loading-line { + animation: none; + } + + .home-footer__account-pending { + transition: none; + } +} + +@media(max-width:600px) { + .home-footer__account-slot { + width: 100%; + min-height: 52px; + justify-content: center; + } + + .home-footer__account-pending { + inset: 0; + justify-content: center; + margin: auto; + } +} + .home-footer__account { display: inline-flex; min-width: 0; @@ -4474,7 +4582,7 @@ dialog::backdrop { .home-footer__user { display: grid; min-width: 0; - max-width: min(360px, 42vw); + max-width: calc(min(360px, 42vw)); gap: 2px; color: var(--text); font-weight: 600; @@ -4528,7 +4636,7 @@ dialog::backdrop { } .app-dialog { - width: min(440px, calc(100% - 28px)); + width: min(440px, 100% - 28px); padding: 0; border: 0; background: transparent; @@ -4548,24 +4656,24 @@ dialog::backdrop { } #profile-dialog { - width: min(820px, calc(100% - 32px)); + width: min(820px, 100% - 32px); max-width: 820px; - max-height: min(90dvh, 760px); + max-height: calc(min(90dvh, 760px)); } #profile-dialog .identity-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 20px; - max-height: min(90dvh, 760px); + max-height: calc(min(90dvh, 760px)); overflow-y: auto; scrollbar-gutter: stable; } -#profile-dialog .identity-panel__header, +#profile-dialog .form-message, #profile-dialog .identity-fields, -#profile-dialog .profile-actions, -#profile-dialog .form-message { - grid-column: 1 / -1; +#profile-dialog .identity-panel__header, +#profile-dialog .profile-actions { + grid-column: 1/-1; } #profile-dialog .identity-fields { @@ -4574,21 +4682,21 @@ dialog::backdrop { column-gap: 18px; } -#profile-dialog .profile-theme-field, #profile-dialog .profile-language-field, -#profile-dialog .profile-suggestion { - grid-column: 1 / -1; +#profile-dialog .profile-suggestion, +#profile-dialog .profile-theme-field { + grid-column: 1/-1; } #profile-dialog, -#profile-dialog .identity-panel, #profile-dialog .identity-fields, #profile-dialog .identity-fields>*, +#profile-dialog .identity-panel, #profile-dialog .profile-actions, -#profile-dialog .profile-theme-field, #profile-dialog .profile-language-field, -#profile-dialog .theme-options, -#profile-dialog .theme-option { +#profile-dialog .profile-theme-field, +#profile-dialog .theme-option, +#profile-dialog .theme-options { min-width: 0; box-sizing: border-box; } @@ -4597,7 +4705,7 @@ dialog::backdrop { overflow-x: hidden; } -#profile-dialog .identity-fields input:not([type="radio"]):not([type="checkbox"]), +#profile-dialog .identity-fields input:not([type=radio]):not([type=checkbox]), #profile-dialog .identity-fields select, #profile-dialog .identity-fields textarea { width: 100%; @@ -4606,11 +4714,11 @@ dialog::backdrop { box-sizing: border-box; } +#profile-dialog .profile-actions button, #profile-dialog .theme-option, -#profile-dialog .theme-option span, -#profile-dialog .theme-option strong, #profile-dialog .theme-option small, -#profile-dialog .profile-actions button { +#profile-dialog .theme-option span, +#profile-dialog .theme-option strong { overflow-wrap: anywhere; } @@ -4717,7 +4825,7 @@ dialog::backdrop { color: var(--success-soft); } -@media (max-width: 600px) { +@media(max-width:600px) { .home-footer__inner { align-items: stretch; flex-direction: column; @@ -4763,16 +4871,16 @@ dialog::backdrop { padding-right: 36px; } - #profile-dialog .identity-panel, #profile-dialog .identity-fields, + #profile-dialog .identity-panel, #profile-dialog .profile-actions, #profile-dialog .theme-options { grid-template-columns: minmax(0, 1fr); } + #profile-dialog .form-message, #profile-dialog .identity-fields>*, - #profile-dialog .profile-actions, - #profile-dialog .form-message { + #profile-dialog .profile-actions { grid-column: 1; } @@ -4787,11 +4895,11 @@ dialog::backdrop { } } -.home-footer__account[hidden], +.auth-panel[hidden], .footer-action[hidden], -.identity-links [hidden], +.home-footer__account[hidden], .identity-fields [hidden], -.auth-panel[hidden] { +.identity-links [hidden] { display: none !important; } @@ -4806,8 +4914,8 @@ dialog::backdrop { margin-top: 4px; } -.secondary-button, -.danger-button { +.danger-button, +.secondary-button { min-height: 42px; padding: 0 16px; border: 1px solid var(--border); @@ -4846,7 +4954,7 @@ dialog::backdrop { } #resources-dialog { - width: min(760px, calc(100% - 28px)); + width: min(760px, 100% - 28px); max-width: 760px; } @@ -4942,8 +5050,8 @@ dialog::backdrop { transition: color .15s ease, text-shadow .15s ease, transform .15s ease; } -.resource-favorite-remove:hover, -.resource-favorite-remove:focus-visible { +.resource-favorite-remove:focus-visible, +.resource-favorite-remove:hover { background: transparent; color: var(--accent); text-shadow: 0 0 10px color-mix(in srgb, var(--accent) 70%, transparent); @@ -4966,7 +5074,7 @@ dialog::backdrop { border-radius: 10px; } -.resource-row> :first-child { +.resource-row>:first-child { min-width: 0; } @@ -5058,8 +5166,8 @@ dialog::backdrop { cursor: pointer; } -.resource-password-menu__item:hover, -.resource-password-menu__item:focus-visible { +.resource-password-menu__item:focus-visible, +.resource-password-menu__item:hover { background: var(--surface-3); } @@ -5067,8 +5175,8 @@ dialog::backdrop { color: var(--danger); } -.resource-password-menu__item--danger:hover, -.resource-password-menu__item--danger:focus-visible { +.resource-password-menu__item--danger:focus-visible, +.resource-password-menu__item--danger:hover { background: var(--danger-subtle-bg); } @@ -5140,12 +5248,11 @@ dialog::backdrop { .line-gutter { position: relative; - z-index: 5; flex: none; } -.preview, -.editor-shell textarea { +.editor-shell textarea, +.preview { scrollbar-gutter: stable; } @@ -5172,11 +5279,11 @@ dialog::backdrop { padding: .35em .8em .7em; } -.markdown-details__content> :first-child { +.markdown-details__content>:first-child { margin-top: 0; } -.markdown-details__content> :last-child { +.markdown-details__content>:last-child { margin-bottom: 0; } @@ -5209,8 +5316,8 @@ dialog::backdrop { } .pad-page .editor-column, -.pad-page .preview-column, -.pad-page .editor-shell { +.pad-page .editor-shell, +.pad-page .preview-column { min-height: 0; overflow: hidden; } @@ -5229,13 +5336,13 @@ dialog::backdrop { background: var(--surface); } -@media (max-width: 980px) { +@media(max-width:980px) { .pad-page .editor-layout { height: auto; } } -.markdown-details__content .preview-source-line::before { +.markdown-details__content .preview-source-line:before { display: none; } @@ -5284,8 +5391,8 @@ dialog::backdrop { z-index: 40; display: grid; grid-template-columns: 150px minmax(280px, 380px); - width: min(560px, calc(100vw - 24px)); - max-height: min(430px, 70vh); + width: calc(min(560px, 100vw - 24px)); + max-height: calc(min(430px, 70vh)); overflow: hidden; border: 1px solid var(--border); border-radius: 10px; @@ -5300,8 +5407,8 @@ dialog::backdrop { border-right: 1px solid var(--border); } -.room-users strong, -.room-chat__head strong { +.room-chat__head strong, +.room-users strong { display: block; margin-bottom: 8px; font-size: .75rem; @@ -5396,7 +5503,7 @@ dialog::backdrop { cursor: pointer; } -@media (max-width: 700px) { +@media(max-width:700px) { .room-popover { grid-template-columns: 1fr; left: auto; @@ -5519,8 +5626,8 @@ dialog::backdrop { border-top: 0; } -.share-list-row strong, -.share-list-row small { +.share-list-row small, +.share-list-row strong { display: block; overflow-wrap: anywhere; } @@ -5539,8 +5646,8 @@ dialog::backdrop { grid-template-columns: minmax(170px, 1fr) minmax(130px, auto) 100px auto auto auto; } -.share-link-row input, -.share-link-row .app-select--field { +.share-link-row .app-select--field, +.share-link-row input { min-width: 0; } @@ -5549,12 +5656,12 @@ dialog::backdrop { color: var(--muted); } -@media (max-width:800px) { +@media(max-width:800px) { .share-form-grid, .share-link-form, - .share-list-row, - .share-link-row { + .share-link-row, + .share-list-row { grid-template-columns: 1fr; align-items: stretch; } @@ -5569,9 +5676,9 @@ dialog::backdrop { } .share-dialog { - width: min(900px, calc(100% - 28px)); + width: min(900px, 100% - 28px); max-width: 900px; - max-height: min(88dvh, 860px); + max-height: calc(min(88dvh, 860px)); padding: 0; overflow: hidden; } @@ -5584,7 +5691,7 @@ dialog::backdrop { .share-dialog .share-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; - max-height: min(88dvh, 860px); + max-height: calc(min(88dvh, 860px)); gap: 0; } @@ -5669,8 +5776,8 @@ dialog::backdrop { height: 42px; } -.share-dialog input[type="text"], -.share-dialog input[type="number"] { +.share-dialog input[type=number], +.share-dialog input[type=text] { min-height: 42px; border-radius: 8px; padding: 0 12px; @@ -5731,8 +5838,8 @@ dialog::backdrop { font-weight: 700; } -.share-list-identity>div, -.share-link-info { +.share-link-info, +.share-list-identity>div { min-width: 0; } @@ -5747,7 +5854,7 @@ dialog::backdrop { padding: 7px 11px; } -@media (max-width: 800px) { +@media(max-width:800px) { .share-dialog { width: calc(100% - 16px); max-height: calc(100dvh - 16px); @@ -5847,7 +5954,7 @@ dialog::backdrop { background: color-mix(in srgb, var(--accent) 4%, transparent); } -@media (max-width: 640px) { +@media(max-width:640px) { #resources-dialog { width: calc(100% - 12px); } @@ -5895,8 +6002,8 @@ dialog::backdrop { gap: 4px; } - .resource-actions>button, .resource-actions>.resource-password-menu, + .resource-actions>button, .resource-password-menu>summary { width: 100%; min-width: 0; @@ -5922,7 +6029,7 @@ dialog::backdrop { } } -@media (max-width: 640px) { +@media(max-width:640px) { .share-dialog, .share-dialog .share-panel, @@ -5948,11 +6055,11 @@ dialog::backdrop { border-radius: 8px; } -.markdown-body .markdown-alert> :first-child { +.markdown-body .markdown-alert>:first-child { margin-top: 0; } -.markdown-body .markdown-alert> :last-child { +.markdown-body .markdown-alert>:last-child { margin-bottom: 0; } @@ -5990,7 +6097,7 @@ dialog::backdrop { white-space: pre; } -.markdown-body pre.code-with-lines .code-line::before { +.markdown-body pre.code-with-lines .code-line:before { content: attr(data-line); position: absolute; left: 0; @@ -6039,12 +6146,12 @@ dialog::backdrop { margin-left: 0; } -.markdown-body ul, -.markdown-body ol { +.markdown-body ol, +.markdown-body ul { padding-left: 1.75em; } -.markdown-body .list-source-line::before { +.markdown-body .list-source-line:before { right: auto; } @@ -6058,7 +6165,7 @@ dialog::backdrop { list-style: none; } -@media (max-width: 720px) { +@media(max-width:720px) { .pad-page .editor-shell { grid-template-columns: 38px minmax(0, 1fr); min-width: 0; @@ -6119,7 +6226,7 @@ dialog::backdrop { top: calc(100% + 8px); left: 0; z-index: 40; - width: min(360px, calc(100vw - 24px)); + width: calc(min(360px, 100vw - 24px)); padding: 10px; border: 1px solid var(--border-strong); border-radius: 10px; @@ -6182,7 +6289,7 @@ dialog::backdrop { font-size: 1rem; } -.editor-toolbar .emoji-category[aria-pressed="true"] { +.editor-toolbar .emoji-category[aria-pressed=true] { border-color: var(--border-strong); background: var(--surface-2); } @@ -6204,8 +6311,8 @@ dialog::backdrop { line-height: 1; } -.editor-toolbar .emoji-item:hover, -.editor-toolbar .emoji-item:focus-visible { +.editor-toolbar .emoji-item:focus-visible, +.editor-toolbar .emoji-item:hover { border-color: var(--border); background: var(--surface-2); transform: scale(1.08); @@ -6218,7 +6325,7 @@ dialog::backdrop { font-size: .82rem; } -@media (max-width: 720px) { +@media(max-width:720px) { .emoji-picker-panel { position: fixed; left: 12px; @@ -6230,7 +6337,7 @@ dialog::backdrop { .emoji-grid { grid-template-columns: repeat(7, minmax(0, 1fr)); - max-height: min(45vh, 320px); + max-height: calc(min(45vh, 320px)); } } @@ -6264,16 +6371,13 @@ dialog::backdrop { color: var(--warning, var(--warning-muted)) !important; } -@media (max-width: 800px) { +@media(max-width:800px) { .share-link-inline { grid-template-columns: 1fr; } } .editor-column-label { - display: flex; - align-items: center; - justify-content: space-between; gap: 10px; } @@ -6281,7 +6385,6 @@ dialog::backdrop { position: absolute; z-index: 1; inset: 0 0 0 48px; - overflow: hidden; padding: 24px; color: transparent; font: inherit; @@ -6300,15 +6403,13 @@ dialog::backdrop { .editor-shell textarea { position: relative; - z-index: 3; - background: transparent; } .hide-editor-line-numbers .authorship-layer { left: 0; } -@media (max-width: 720px) { +@media(max-width:720px) { .authorship-layer { left: 42px; } @@ -6318,7 +6419,6 @@ dialog::backdrop { } } -/* The author labels scroll as one layer; clipping still belongs to .owner-labels. */ .owner-labels-canvas { position: absolute; inset: 0; @@ -6329,7 +6429,7 @@ dialog::backdrop { position: absolute; right: calc(var(--editor-rendered-font-size, 14px) * .57); display: flex; - max-width: min(calc(var(--editor-rendered-font-size, 14px) * 30), 70%); + max-width: min(var(--editor-rendered-font-size, 14px) * 30, 70%); gap: calc(var(--editor-rendered-font-size, 14px) * .2857); overflow: hidden; transform: translateY(-50%); @@ -6350,9 +6450,9 @@ dialog::backdrop { } .action-button, +.danger-button, .primary-button, -.secondary-button, -.danger-button { +.secondary-button { display: inline-flex; align-items: center; justify-content: center; @@ -6437,7 +6537,7 @@ dialog::backdrop { margin-left: auto; } -.profile-color-field input[type="color"] { +.profile-color-field input[type=color] { width: 100%; min-height: 42px; padding: 4px; @@ -6474,7 +6574,7 @@ dialog::backdrop { } .profile-language-control:hover, -.profile-language-control[data-open="true"] { +.profile-language-control[data-open=true] { border-color: var(--accent-border); background: var(--surface-3); } @@ -6484,8 +6584,8 @@ dialog::backdrop { box-shadow: 0 0 0 3px var(--focus-ring), inset 0 1px 0 var(--wash-soft); } -.profile-language-control__icon, -.profile-language-control__chevron { +.profile-language-control__chevron, +.profile-language-control__icon { width: 18px; height: 18px; fill: none; @@ -6503,7 +6603,7 @@ dialog::backdrop { transition: transform .16s ease; } -.profile-language-control[data-open="true"] .profile-language-control__chevron { +.profile-language-control[data-open=true] .profile-language-control__chevron { transform: rotate(180deg); } @@ -6539,7 +6639,7 @@ dialog::backdrop { right: -1px; display: grid; gap: 2px; - max-height: min(260px, 42dvh); + max-height: calc(min(260px, 42dvh)); padding: 4px; overflow-y: auto; border: 1px solid var(--border-strong); @@ -6573,14 +6673,14 @@ dialog::backdrop { transition: border-color .12s ease, background-color .12s ease, color .12s ease; } -.profile-language-option:hover, -.profile-language-option:focus-visible { +.profile-language-option:focus-visible, +.profile-language-option:hover { border-color: var(--border-strong); outline: 0; background: var(--surface-hover); } -.profile-language-option[aria-selected="true"] { +.profile-language-option[aria-selected=true] { border-color: var(--accent-a35); background: var(--accent-soft); } @@ -6596,7 +6696,7 @@ dialog::backdrop { font-weight: 900; } -.profile-language-option[aria-selected="true"] .profile-language-option__check { +.profile-language-option[aria-selected=true] .profile-language-option__check { background: var(--accent-a25); } @@ -6608,8 +6708,8 @@ dialog::backdrop { overflow: hidden; } -.profile-language-option__copy strong, -.profile-language-option__copy small { +.profile-language-option__copy small, +.profile-language-option__copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -6691,9 +6791,9 @@ dialog::backdrop { accent-color: var(--accent); } +.theme-option small, .theme-option span, -.theme-option strong, -.theme-option small { +.theme-option strong { display: block; min-width: 0; } @@ -6710,13 +6810,13 @@ dialog::backdrop { line-height: 1.35; } -@media (max-width: 520px) { +@media(max-width:520px) { .theme-options { grid-template-columns: 1fr; } } -@media (min-width: 1500px) and (max-width: 1699px) { +@media(min-width:1500px) and (max-width:1699px) { .pad-page .editor-toolbar { flex-wrap: nowrap; overflow: visible; @@ -6745,7 +6845,7 @@ dialog::backdrop { } } -@media (max-width: 1499px) { +@media(max-width:1499px) { .pad-page { display: flex; flex-direction: column; @@ -6809,15 +6909,15 @@ dialog::backdrop { transition: transform .18s ease, opacity .18s ease; } - .pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(1) { + .pad-page .header-menu-toggle[aria-expanded=true] span:first-child { transform: translateY(7px) rotate(45deg); } - .pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(2) { + .pad-page .header-menu-toggle[aria-expanded=true] span:nth-child(2) { opacity: 0; } - .pad-page .header-menu-toggle[aria-expanded="true"] span:nth-child(3) { + .pad-page .header-menu-toggle[aria-expanded=true] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } @@ -6827,8 +6927,8 @@ dialog::backdrop { right: 0; z-index: 20; display: none; - width: min(320px, calc(100vw - 20px)); - max-height: min(70dvh, 520px); + width: calc(min(320px, 100vw - 20px)); + max-height: calc(min(70dvh, 520px)); align-items: stretch; flex-direction: column; gap: 8px; @@ -6905,8 +7005,8 @@ dialog::backdrop { } .pad-page .editor-controls, - .pad-page .toolbar-action, .pad-page .line-toggle, + .pad-page .toolbar-action, .pad-page .toolbar-fill { display: none; } @@ -6952,9 +7052,9 @@ dialog::backdrop { white-space: nowrap; } - .pad-page textarea, + .pad-page .authorship-layer, .pad-page .preview, - .pad-page .authorship-layer { + .pad-page textarea { padding: 16px; } @@ -6978,7 +7078,7 @@ dialog::backdrop { } } -@media (max-width: 760px) { +@media(max-width:760px) { .connection-notice { top: 76px; width: calc(100% - 20px); @@ -7007,7 +7107,7 @@ dialog::backdrop { min-width: 62px; } - .pad-page .view-switch button[data-view="split"] { + .pad-page .view-switch button[data-view=split] { display: none; } @@ -7041,7 +7141,7 @@ dialog::backdrop { } } -@media (prefers-reduced-motion: reduce) { +@media(prefers-reduced-motion:reduce) { .connection-notice, .connection-notice__signal span { @@ -7050,13 +7150,13 @@ dialog::backdrop { } } -@media (max-width: 720px) { +@media(max-width:720px) { .pad-page #save-state { display: none; } } -@media (max-width: 520px) { +@media(max-width:520px) { .pad-page .app-header { padding-inline: 10px; } @@ -7088,14 +7188,14 @@ dialog::backdrop { padding-inline: 8px; } - .pad-page textarea, + .pad-page .authorship-layer, .pad-page .preview, - .pad-page .authorship-layer { + .pad-page textarea { padding: 12px; } } -@media (min-width: 1921px) { +@media(min-width:1921px) { .pad-page .workspace.view-split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } @@ -7132,7 +7232,7 @@ dialog::backdrop { display: none; } -@media (max-width: 1499px) { +@media(max-width:1499px) { .pad-page .mobile-editor-bubble { position: fixed; right: 10px; @@ -7150,7 +7250,7 @@ dialog::backdrop { box-shadow: 0 8px 24px var(--shadow-28); } - .pad-page .mobile-editor-bubble::before { + .pad-page .mobile-editor-bubble:before { position: absolute; z-index: -1; inset: 0; @@ -7161,8 +7261,8 @@ dialog::backdrop { pointer-events: none; } - .mobile-editor-bubble>button, - .mobile-editor-bubble>.mobile-color-button { + .mobile-editor-bubble>.mobile-color-button, + .mobile-editor-bubble>button { display: inline-grid; width: 34px; height: 34px; @@ -7183,7 +7283,7 @@ dialog::backdrop { cursor: pointer; } - .mobile-color-button input[type="color"] { + .mobile-color-button input[type=color] { position: absolute; inset: 0; width: 100%; @@ -7240,7 +7340,7 @@ dialog::backdrop { right: 12px; bottom: calc(64px + env(safe-area-inset-bottom, 0px)); left: auto; - width: min(400px, calc(100vw - 24px)); + width: calc(min(400px, 100vw - 24px)); max-height: calc(100dvh - 88px); overflow-y: auto; overscroll-behavior: contain; @@ -7252,7 +7352,7 @@ dialog::backdrop { } } -@media (max-width: 1499px) { +@media(max-width:1499px) { .pad-page .mobile-editor-bubble { user-select: none; } @@ -7267,8 +7367,8 @@ dialog::backdrop { bottom: calc(72px + env(safe-area-inset-bottom, 0px)); left: auto; z-index: 60; - width: min(560px, calc(100vw - 24px)); - max-height: min(560px, calc(100dvh - 96px)); + width: calc(min(560px, 100vw - 24px)); + max-height: calc(min(560px, 100dvh - 96px)); } .pad-page .mobile-editor-bubble.is-dragging { @@ -7374,7 +7474,7 @@ dialog::backdrop { font: 600 11px/1.3 system-ui, sans-serif; } -@media (max-width: 720px) { +@media(max-width:720px) { .editor-column-label { align-items: center; } @@ -7423,7 +7523,7 @@ dialog::backdrop { isolation: isolate; } -.pad-page .editor-shell::before { +.pad-page .editor-shell:before { position: absolute; z-index: 0; inset: 0; @@ -7460,7 +7560,7 @@ dialog::backdrop { line-height: 1.45; } -.pad-page .editor-shell::after { +.pad-page .editor-shell:after { position: absolute; z-index: 6; top: 0; @@ -7479,12 +7579,12 @@ dialog::backdrop { border-right: 0; } -.pad-page.hide-editor-line-numbers .editor-shell::after { +.pad-page.hide-editor-line-numbers .editor-shell:after { display: none; } -@media (max-width: 720px) { - .pad-page .editor-shell::after { +@media(max-width:720px) { + .pad-page .editor-shell:after { left: 38px; } } @@ -7608,16 +7708,16 @@ dialog::backdrop { background: var(--border); } -.page-settings-menu .public-task-toggle, -.mobile-editor-options__panel .mobile-option-check { +.mobile-editor-options__panel .mobile-option-check, +.page-settings-menu .public-task-toggle { min-height: 30px; padding: 5px 7px; border-radius: 6px; background: var(--surface-card); } -.page-settings-menu .public-task-toggle:hover, -.mobile-editor-options__panel .mobile-option-check:hover { +.mobile-editor-options__panel .mobile-option-check:hover, +.page-settings-menu .public-task-toggle:hover { background: var(--surface-hover); } @@ -7642,8 +7742,6 @@ dialog::backdrop { .authorship-controls { display: inline-flex; - align-items: center; - gap: 7px; } .authorship-layer { @@ -7669,11 +7767,10 @@ dialog::backdrop { } .share-link-row .share-link-inline { - width: 100%; text-align: left; } -@media (max-width: 800px) { +@media(max-width:800px) { .share-link-row .share-link-info { justify-self: stretch; width: 100%; @@ -7725,7 +7822,7 @@ dialog::backdrop { transition: .15s ease; } -.switch-control__track::after { +.switch-control__track:after { content: ""; position: absolute; width: 10px; @@ -7742,7 +7839,7 @@ dialog::backdrop { background: color-mix(in srgb, var(--accent) 28%, var(--surface-strong, var(--surface-strong-2))); } -.switch-control input:checked+.switch-control__track::after { +.switch-control input:checked+.switch-control__track:after { transform: translateX(14px); background: var(--accent); } @@ -7750,7 +7847,6 @@ dialog::backdrop { .share-link-row .share-link-info { margin-top: 8px; justify-self: start; - text-align: left; } .preview-editable--source { @@ -7781,7 +7877,7 @@ dialog::backdrop { } .share-link-row .share-link-info { - grid-column: 1 / -1; + grid-column: 1/-1; width: 100%; justify-self: stretch; text-align: left; @@ -7807,7 +7903,7 @@ dialog::backdrop { width: min(100%, 520px); } -@media (max-width: 800px) { +@media(max-width:800px) { .share-link-row { grid-template-columns: 1fr; } @@ -7877,7 +7973,7 @@ dialog::backdrop { transform: translateX(180%); } - 100% { + to { transform: translateX(420%); } } @@ -7958,8 +8054,8 @@ dialog::backdrop { pointer-events: auto; } -.line-links-enabled .line-number-button:hover, -.line-links-enabled .line-number-button:focus-visible { +.line-links-enabled .line-number-button:focus-visible, +.line-links-enabled .line-number-button:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--text); outline: none; @@ -7976,7 +8072,7 @@ dialog::backdrop { color: var(--text); } -@media (max-width: 1499px) { +@media(max-width:1499px) { .mobile-editor-options { position: relative; flex: 0 0 auto; @@ -8010,7 +8106,7 @@ dialog::backdrop { bottom: calc(64px + env(safe-area-inset-bottom, 0px)); left: auto; display: grid; - width: min(360px, calc(100vw - 24px)); + width: calc(min(360px, 100vw - 24px)); max-height: calc(100dvh - 88px); gap: 5px; padding: 12px; @@ -8071,54 +8167,54 @@ dialog::backdrop { color: var(--accent-text-strong); } +.pad-page .editor-toolbar :is(button, summary, select):focus-visible, .pad-page .header-actions>.secondary-button:focus-visible, -.pad-page .page-settings>summary.secondary-button:focus-visible, .pad-page .page-settings-action:focus-visible, -.pad-page .editor-toolbar :is(button, summary, select):focus-visible { +.pad-page .page-settings>summary.secondary-button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } -.pad-page .editor-toolbar .toolbar-group>button, +.pad-page .editor-toolbar .toolbar-action, .pad-page .editor-toolbar .toolbar-group>.emoji-picker>summary, .pad-page .editor-toolbar .toolbar-group>.markdown-more>summary, -.pad-page .editor-toolbar .toolbar-action { +.pad-page .editor-toolbar .toolbar-group>button { border-color: var(--toolbar-action-border); background: var(--toolbar-action-bg); color: var(--toolbar-action-text); box-shadow: inset 0 1px 0 var(--wash-medium); } -.pad-page .editor-toolbar .toolbar-group>button:hover, +.pad-page .editor-toolbar .toolbar-action:hover, .pad-page .editor-toolbar .toolbar-group>.emoji-picker>summary:hover, -.pad-page .editor-toolbar .toolbar-group>.markdown-more>summary:hover, .pad-page .editor-toolbar .toolbar-group>.emoji-picker[open]>summary, +.pad-page .editor-toolbar .toolbar-group>.markdown-more>summary:hover, .pad-page .editor-toolbar .toolbar-group>.markdown-more[open]>summary, -.pad-page .editor-toolbar .toolbar-action:hover { +.pad-page .editor-toolbar .toolbar-group>button:hover { border-color: color-mix(in srgb, var(--accent) 48%, var(--toolbar-action-border)); background: var(--toolbar-action-hover); color: var(--text); filter: none; } -.pad-page .editor-toolbar .toolbar-group>button:active, +.pad-page .editor-toolbar .toolbar-action:active, .pad-page .editor-toolbar .toolbar-group>.emoji-picker>summary:active, .pad-page .editor-toolbar .toolbar-group>.markdown-more>summary:active, -.pad-page .editor-toolbar .toolbar-action:active { +.pad-page .editor-toolbar .toolbar-group>button:active { box-shadow: inset 0 1px 3px var(--shadow-28); transform: translateY(1px); } +.pad-page .authorship-layer, .pad-page .editor-column, .pad-page .editor-shell, -.pad-page .editor-shell::before, -.pad-page .authorship-layer, +.pad-page .editor-shell:before, .pad-page .line-gutter { background: var(--editor-canvas) !important; } -.pad-page .preview-column, -.pad-page .preview { +.pad-page .preview, +.pad-page .preview-column { background: var(--preview-canvas); } @@ -8186,14 +8282,14 @@ dialog::backdrop { color: var(--text-heading); } -.resource-brand__kind:hover, .resource-brand__kind:focus-visible, -.resource-brand__logo:hover, -.resource-brand__logo:focus-visible { +.resource-brand__kind:hover, +.resource-brand__logo:focus-visible, +.resource-brand__logo:hover { color: var(--link-hover); } -@media (max-width: 520px) { +@media(max-width:520px) { .resource-brand { min-width: 70px; } @@ -8213,22 +8309,22 @@ dialog::backdrop { scrollbar-color: var(--border-strong) var(--surface-inset); } -*::-webkit-scrollbar { +::-webkit-scrollbar { width: 10px; height: 10px; } -*::-webkit-scrollbar-track { +::-webkit-scrollbar-track { background: var(--surface-inset); } -*::-webkit-scrollbar-thumb { +::-webkit-scrollbar-thumb { border: 2px solid var(--surface-inset); border-radius: 999px; background: var(--border-strong); } -*::-webkit-scrollbar-thumb:hover { +::-webkit-scrollbar-thumb:hover { background: var(--muted-2); } @@ -8302,7 +8398,7 @@ dialog::backdrop { cursor: not-allowed; } -@media (max-width: 620px) { +@media(max-width:620px) { .pagination { justify-content: space-between; } @@ -8336,7 +8432,7 @@ dialog::backdrop { flex: 0 0 auto; } -@media (max-width: 1699px) { +@media(max-width:1699px) { .pad-page :is(#mode-toggle, .view-switch) .control-label-full { display: none; } @@ -8358,7 +8454,7 @@ dialog::backdrop { } } -@media (max-width: 760px) and (orientation: portrait) { +@media(max-width:760px) and (orientation:portrait) { .pad-page .editor-toolbar { grid-template-columns: minmax(0, 1fr) auto auto auto; width: 100%; @@ -8370,7 +8466,7 @@ dialog::backdrop { } .pad-page .view-switch button, - .pad-page .view-switch button[data-view="split"] { + .pad-page .view-switch button[data-view=split] { display: inline-flex; width: 34px; min-width: 34px; @@ -8408,12 +8504,12 @@ dialog::backdrop { grid-template-columns: 12px minmax(0, 1fr); } +.revision time, .revision>div, .revision__meta, .revision__meta strong, -.revision time, -.revision__snippet, -.revision__preview { +.revision__preview, +.revision__snippet { min-width: 0; max-width: 100%; } @@ -8422,10 +8518,10 @@ dialog::backdrop { flex-wrap: wrap; } -.revision__meta strong, .revision time, -.revision__snippet, -.revision__preview { +.revision__meta strong, +.revision__preview, +.revision__snippet { overflow-wrap: anywhere; word-break: break-word; } @@ -8440,9 +8536,9 @@ dialog::backdrop { white-space: pre-wrap; } -.history-header>div, .history-header h2, .history-header p, +.history-header>div, .history-list .empty, .history-list .error { min-width: 0; @@ -8559,7 +8655,7 @@ dialog::backdrop { display: none; } -@media (max-width: 760px) { +@media(max-width:760px) { .pad-page .toolbar-group { grid-column: 1; grid-row: 1; @@ -8577,7 +8673,7 @@ dialog::backdrop { .pad-page .mobile-upload-button { display: inline-flex; - grid-column: 1 / -1; + grid-column: 1/-1; grid-row: 2; width: 100%; min-height: 1vh; @@ -8605,7 +8701,7 @@ dialog::backdrop { } } -@media (max-width: 760px) and (orientation: portrait) { +@media(max-width:760px) and (orientation:portrait) { .pad-page #mode-toggle { grid-column: 2; grid-row: 1; @@ -8631,12 +8727,12 @@ dialog::backdrop { } .public-page.hide-preview-line-numbers.line-links-enabled .public-content { - padding-left: clamp(24px, 4vw, 52px); + padding-left: calc(clamp(24px, 4vw, 52px)); } .public-content .public-permalink { position: absolute; - top: var(--preview-line-top, 0px); + top: var(--preview-line-top, 0); left: var(--preview-line-left, -50px); z-index: 3; display: none; @@ -8661,24 +8757,24 @@ dialog::backdrop { display: block; } -.public-page.line-links-enabled .public-content .preview-source-line::before { +.public-page.line-links-enabled .public-content .preview-source-line:before { transition: opacity .1s ease; } -.public-page.line-links-enabled .public-content .preview-source-line:hover::before, -.public-page.line-links-enabled .public-content .preview-source-line:focus-within::before { +.public-page.line-links-enabled .public-content .preview-source-line:focus-within:before, +.public-page.line-links-enabled .public-content .preview-source-line:hover:before { opacity: 0; } -.public-page.line-links-enabled .public-content .preview-source-line:hover>.public-permalink, .public-page.line-links-enabled .public-content .preview-source-line:focus-within>.public-permalink, +.public-page.line-links-enabled .public-content .preview-source-line:hover>.public-permalink, .public-page.line-links-enabled .public-content .public-permalink:focus-visible { opacity: 1; transform: translateX(0); } -.public-page.line-links-enabled .public-content .public-permalink:hover, -.public-page.line-links-enabled .public-content .public-permalink:focus-visible { +.public-page.line-links-enabled .public-content .public-permalink:focus-visible, +.public-page.line-links-enabled .public-content .public-permalink:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--text); outline: none; @@ -8697,19 +8793,18 @@ dialog::backdrop { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent); } -@media (hover: none), -(pointer: coarse) { +@media(hover:none), (pointer:coarse) { .public-page.line-links-enabled .public-content .public-permalink { opacity: .62; transform: none; } - .public-page.line-links-enabled .public-content .preview-source-line::before { + .public-page.line-links-enabled .public-content .preview-source-line:before { opacity: 0; } } -@media (prefers-reduced-motion: reduce) { +@media(prefers-reduced-motion:reduce) { .public-page .public-content { transition: none; } @@ -8747,8 +8842,8 @@ dialog::backdrop { transition: transform .18s ease; } -.pad-page .navbar-collapse-toggle>span::before, -.pad-page .navbar-collapse-toggle>span::after { +.pad-page .navbar-collapse-toggle>span:after, +.pad-page .navbar-collapse-toggle>span:before { content: ""; position: absolute; top: 3px; @@ -8758,23 +8853,23 @@ dialog::backdrop { background: currentColor; } -.pad-page .navbar-collapse-toggle>span::before { +.pad-page .navbar-collapse-toggle>span:before { left: 0; transform: rotate(-36deg); transform-origin: right center; } -.pad-page .navbar-collapse-toggle>span::after { +.pad-page .navbar-collapse-toggle>span:after { right: 0; transform: rotate(36deg); transform-origin: left center; } .pad-page .app-header:hover+.navbar-collapse-toggle, -.pad-page .navbar-collapse-toggle:hover, .pad-page .navbar-collapse-toggle:focus-visible, -.pad-page.navbar-collapsed .navbar-collapse-toggle:hover, -.pad-page.navbar-collapsed .navbar-collapse-toggle:focus-visible { +.pad-page .navbar-collapse-toggle:hover, +.pad-page.navbar-collapsed .navbar-collapse-toggle:focus-visible, +.pad-page.navbar-collapsed .navbar-collapse-toggle:hover { width: 32px; color: var(--text); opacity: 1; @@ -8799,8 +8894,7 @@ dialog::backdrop { flex: 1 1 0; } -@media (hover: none), -(pointer: coarse) { +@media(hover:none), (pointer:coarse) { .pad-page .navbar-collapse-toggle { width: 32px; color: var(--text); @@ -8847,8 +8941,8 @@ dialog::backdrop { } .app-select-trigger:hover, -.app-select[open]>.app-select-trigger, -.app-select.is-open>.app-select-trigger { +.app-select.is-open>.app-select-trigger, +.app-select[open]>.app-select-trigger { border-color: var(--border-strong); background: var(--surface-3); color: var(--text); @@ -8906,8 +9000,8 @@ dialog::backdrop { transition: transform .14s ease; } -.app-select[open] .app-select-trigger__chevron, -.app-select.is-open .app-select-trigger__chevron { +.app-select.is-open .app-select-trigger__chevron, +.app-select[open] .app-select-trigger__chevron { transform: rotate(180deg); } @@ -8919,7 +9013,7 @@ dialog::backdrop { display: grid; width: max-content; min-width: 164px; - max-width: min(260px, calc(100vw - 24px)); + max-width: calc(min(260px, 100vw - 24px)); gap: 2px; padding: 5px; border: 1px solid var(--border-strong); @@ -8951,14 +9045,14 @@ dialog::backdrop { text-align: left; } -.app-select-option:hover, -.app-select-option:focus-visible { +.app-select-option:focus-visible, +.app-select-option:hover { border-color: var(--border-strong); outline: 0; background: var(--surface-hover); } -.app-select-option[aria-selected="true"] { +.app-select-option[aria-selected=true] { border-color: var(--accent-a35); background: var(--accent-soft); } @@ -9045,28 +9139,28 @@ dialog::backdrop { left: 0; z-index: 10000; min-width: 0; - max-height: min(320px, calc(100dvh - 16px)); + max-height: calc(min(320px, 100dvh - 16px)); overflow: auto; overscroll-behavior: contain; } .app-select-menu--portal .app-select-option__copy { - max-width: min(320px, calc(100vw - 72px)); + max-width: calc(min(320px, 100vw - 72px)); } .home-header { justify-content: space-between; } -@media (max-width: 720px) { +@media(max-width:720px) { .app-select--header>.app-select-trigger { min-width: 36px; height: 34px; padding-inline: 8px; } - .app-select--header.app-select--theme .app-select-trigger__value, - .app-select--header .app-select-trigger__chevron { + .app-select--header .app-select-trigger__chevron, + .app-select--header.app-select--theme .app-select-trigger__value { display: none; } @@ -9082,7 +9176,7 @@ dialog::backdrop { } } -@media (max-width: 520px) { +@media(max-width:520px) { .header-preferences { gap: 4px; } @@ -9117,7 +9211,7 @@ dialog::backdrop { } #pdf-export-dialog { - width: min(520px, calc(100% - 28px)); + width: min(520px, 100% - 28px); max-width: 520px; } @@ -9126,9 +9220,9 @@ dialog::backdrop { gap: 16px; } -.pdf-export-panel h2, .pdf-export-panel .dialog-copy, -.pdf-export-panel .pdf-export-help { +.pdf-export-panel .pdf-export-help, +.pdf-export-panel h2 { margin: 0; } @@ -9147,8 +9241,8 @@ dialog::backdrop { font-weight: 650; } -.pdf-export-fields .app-select-input, -.pdf-export-fields .app-select { +.pdf-export-fields .app-select, +.pdf-export-fields .app-select-input { width: 100%; } @@ -9157,8 +9251,8 @@ dialog::backdrop { gap: 9px; } -.pdf-export-title-toggle, -.pdf-export-color-toggle { +.pdf-export-color-toggle, +.pdf-export-title-toggle { width: fit-content; } @@ -9179,7 +9273,7 @@ dialog::backdrop { margin: 0; } -@media (max-width: 560px) { +@media(max-width:560px) { .pdf-export-fields { grid-template-columns: 1fr; } @@ -9215,9 +9309,9 @@ dialog::backdrop { vertical-align: middle; } -.markdown-body :is(h1, h2, h3, h4, h5, h6):hover>.heading-permalink, +.markdown-body .heading-permalink:focus-visible, .markdown-body .heading-permalink:hover, -.markdown-body .heading-permalink:focus-visible { +.markdown-body :is(h1, h2, h3, h4, h5, h6):hover>.heading-permalink { opacity: 1; color: var(--accent); } @@ -9226,13 +9320,13 @@ dialog::backdrop { position: absolute; z-index: 30; box-sizing: border-box; - width: min(290px, calc(100% - 12px)); + width: min(290px, 100% - 12px); max-height: min(300px, 75%); overflow: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-card); - box-shadow: 0 14px 36px rgb(0 0 0 / .25); + box-shadow: 0 14px 36px rgba(0, 0, 0, .25); color: var(--text); font: 500 13px/1.4 system-ui, sans-serif; overscroll-behavior: contain; @@ -9262,7 +9356,7 @@ dialog::backdrop { } .mention-suggestions__option:hover, -.mention-suggestions__option[aria-selected="true"] { +.mention-suggestions__option[aria-selected=true] { background: var(--accent-a15, color-mix(in srgb, var(--accent) 15%, transparent)); } @@ -9356,7 +9450,7 @@ dialog::backdrop { background: color-mix(in srgb, var(--owner) 32%, var(--surface-inset)); } -@media (max-width: 720px) { +@media(max-width:720px) { .mentioned-users { padding: 7px 8px; } @@ -9384,4 +9478,38 @@ dialog::backdrop { .workspace:not(.view-preview) .mentioned-users--preview { display: none; +} + +.line-gutter .line-gutter-virtual-spacer, +.line-gutter .line-gutter-virtual-window { + box-sizing: border-box; + padding-right: 0; +} + +.workspace-startup-error { + max-width: 780px; + margin: 2rem auto; + padding: 1.2rem 1.5rem; + border: 1px solid var(--border, currentColor); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.workspace-startup-error[hidden] { + display: none; +} + +.workspace-startup-error p { + margin: 0; +} + +@media(max-width:560px) { + .workspace-startup-error { + margin: 1rem; + flex-direction: column; + align-items: stretch; + } } \ No newline at end of file diff --git a/static/editor.html b/static/editor.html index a5c1b83..852ffc5 100644 --- a/static/editor.html +++ b/static/editor.html @@ -13,6 +13,7 @@ + __APP_LOADING_OVERLAY__
diff --git a/static/home.html b/static/home.html index 1356dba..9872673 100644 --- a/static/home.html +++ b/static/home.html @@ -13,7 +13,9 @@ - +

Write. Share. Collaborate.

@@ -73,18 +75,27 @@
@@ -161,7 +173,8 @@

Favorites

-

Favorite notes are shown only while your current account, share link, or password access can still open them. If access is lost, they are hidden and return automatically when access is restored.

+

Favorite notes are shown only while your current account, share link, or password access can still open + them. If access is lost, they are hidden and return automatically when access is restored.

@@ -239,6 +252,18 @@

+
diff --git a/static/js/authorship.js b/static/js/authorship.js index d07fa2d..dc34edf 100644 --- a/static/js/authorship.js +++ b/static/js/authorship.js @@ -60,7 +60,61 @@ export function parseAuthorship(content, raw) { } export function serializeAuthorship(model, contentLength) { - return JSON.stringify({ version: VERSION, spans: normalize(model?.spans, contentLength) }); + const canonical = canonicalSpans(model, contentLength); + const spans = canonical + ? canonical.map(span => ({ start: span.start, end: span.end, owner: span.owner })) + : normalize(model?.spans, contentLength); + return JSON.stringify({ version: VERSION, spans }); +} + +function canonicalSpans(model, length) { + const spans = model?.spans; + if (!Array.isArray(spans)) return null; + let previousEnd = 0, previousOwner = ""; + for (const span of spans) { + if (!span || typeof span.owner !== "string" || !span.owner + || !Number.isFinite(span.start) || !Number.isFinite(span.end) + || span.start < previousEnd || span.end <= span.start || span.end > length + || (span.start === previousEnd && span.owner === previousOwner)) return null; + previousEnd = span.end; + previousOwner = span.owner; + } + return spans; +} + +function appendCanonicalSpan(result, start, end, owner) { + if (!owner || end <= start) return; + const previous = result.at(-1); + if (previous?.owner === owner && start <= previous.end) { + previous.end = Math.max(previous.end, end); + } else result.push({ start, end, owner }); +} + +function fastAuthorshipEdit(spans, prefix, oldSuffix, insertedLength, delta, owner) { + const result = []; + let inserted = false; + const insert = () => { + if (!inserted) { + appendCanonicalSpan(result, prefix, prefix + insertedLength, owner ? String(owner) : ""); + inserted = true; + } + }; + for (const span of spans) { + if (span.end <= prefix) { + appendCanonicalSpan(result, span.start, span.end, span.owner); + } else if (span.start >= oldSuffix) { + insert(); + appendCanonicalSpan(result, span.start + delta, span.end + delta, span.owner); + } else { + if (span.start < prefix) appendCanonicalSpan(result, span.start, prefix, span.owner); + insert(); + if (span.end > oldSuffix) { + appendCanonicalSpan(result, prefix + insertedLength, span.end + delta, span.owner); + } + } + } + insert(); + return { version: VERSION, spans: result }; } export function applyAuthorshipEdit(model, previousText, nextText, owner) { @@ -79,6 +133,8 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) { const removedLength = oldSuffix - prefix; const insertedLength = newSuffix - prefix; const delta = insertedLength - removedLength; + const canonical = canonicalSpans(model, previousText.length); + if (canonical) return fastAuthorshipEdit(canonical, prefix, oldSuffix, insertedLength, delta, owner); const updated = []; for (const source of normalize(model?.spans, previousText.length)) { @@ -99,13 +155,22 @@ export function applyAuthorshipEdit(model, previousText, nextText, owner) { } export function replaceAuthorshipOwner(model, matcher, replacement, contentLength) { + const canonical = canonicalSpans(model, contentLength); + if (canonical) { + const spans = []; + for (const span of canonical) { + const owner = matcher(span.owner) ? String(replacement || "") : span.owner; + appendCanonicalSpan(spans, span.start, span.end, owner); + } + return { version: VERSION, spans }; + } + return { version: VERSION, spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength), }; } - export function authorshipOwners(model) { const owners = new Map(); for (const span of model?.spans || []) { @@ -120,13 +185,11 @@ export function lineAuthors(content, model) { const starts = [0]; for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1); - // Parsed authorship spans are sorted and non-overlapping. Retain the old - // result for non-canonical callers instead of silently changing semantics. let canonical = true; for (let i = 0, previousEnd = 0; i < spans.length; i++) { const span = spans[i]; if (!Number.isFinite(span?.start) || !Number.isFinite(span?.end) - || span.start < previousEnd || span.end < span.start) { + || span.start < previousEnd || span.end < span.start) { canonical = false; break; } @@ -187,8 +250,6 @@ export function syncAuthorshipLayer(layer, editor) { const canvas = layer.querySelector(".authorship-canvas"); if (!canvas) return; canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`; - // The canvas already mirrors the textarea scroll through its transform. - // Keeping the overlay element itself at zero prevents a double offset. layer.scrollTop = 0; layer.scrollLeft = 0; } diff --git a/static/js/boot-loading.js b/static/js/boot-loading.js new file mode 100644 index 0000000..a045535 --- /dev/null +++ b/static/js/boot-loading.js @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +(() => { + const order = ["assets", "language", "libraries", "access", "files"]; + const resourceParts = new Set(); + const locale = String(document.documentElement.lang || "en").toLowerCase().startsWith("pl") ? "pl" : "en"; + const strings = { + en: { + assets: "Preparing the interface", language: "Setting up language", + libraries: "Starting the editor", access: "Checking access", files: "Opening the note", + complete: "Ready", error: "The note could not be opened", retry: "Reload page", + errorDetails: "Check your connection and try again.", progress: "{done} of {total} stages completed", + timeout: "Still waiting for the note. Check the connection or reload." + }, + pl: { + assets: "Przygotowanie interfejsu", language: "Ustawianie j\u0119zyka", + libraries: "Uruchamianie edytora", access: "Sprawdzanie dost\u0119pu", files: "Otwieranie notatki", + complete: "Gotowe", error: "Nie uda\u0142o si\u0119 otworzy\u0107 notatki", retry: "Za\u0142aduj ponownie", + errorDetails: "Sprawd\u017a po\u0142\u0105czenie i spr\u00f3buj ponownie.", progress: "Uko\u0144czono {done} z {total} etap\u00f3w", + timeout: "Nadal oczekujemy na notatk\u0119. Sprawd\u017a po\u0142\u0105czenie lub od\u015bwie\u017c stron\u0119." + } + }[locale]; + const completed = new Set(); + const loadedStyleElements = new WeakSet(); + let loadedStyleCount = 0; + let readyRequested = false; + let dismissed = false; + let failure = ""; + const element = id => document.getElementById(id); + + function update() { + const overlay = element("app-boot-overlay"); + if (!overlay || dismissed) return; + const count = completed.size; + const progress = Math.round((count / order.length) * 100); + const next = order.find(stage => !completed.has(stage)); + const heading = element("app-boot-heading"); + if (heading) heading.textContent = locale === "pl" ? "Ładowanie…" : "Loading…"; + const stageText = element("app-boot-stage"); + const stageCount = element("app-boot-count"); + const bar = element("app-boot-progress"); + const fill = element("app-boot-progress-fill"); + const retry = element("app-boot-retry"); + if (stageText) stageText.textContent = failure ? strings.error : (next ? strings[next] : strings.complete); + if (stageCount) stageCount.textContent = failure || strings.progress.replace("{done}", count).replace("{total}", order.length); + if (bar) bar.setAttribute("aria-valuenow", String(progress)); + if (fill) fill.style.width = `${progress}%`; + if (retry) { retry.hidden = !failure; retry.textContent = strings.retry; } + overlay.classList.toggle("has-error", Boolean(failure)); + for (const stage of order) { + const node = element(`app-boot-step-${stage}`); + if (!node) continue; + const isDone = completed.has(stage); + node.dataset.status = isDone ? "done" : (stage === next && !failure ? "active" : "waiting"); + node.querySelector("span")?.replaceChildren(document.createTextNode(strings[stage])); + } + if (readyRequested && count === order.length && !failure) dismiss(); + } + + function dismiss() { + if (dismissed) return; + dismissed = true; + const overlay = element("app-boot-overlay"); + if (!overlay) return; + overlay.classList.add("is-ready"); + const remove = () => overlay.remove(); + overlay.addEventListener("transitionend", remove, { once: true }); + setTimeout(remove, 300); + } + + function complete(stage) { + if (dismissed || failure) return; + if (stage === "css" || stage === "js") { + resourceParts.add(stage); + if (resourceParts.has("css") && resourceParts.has("js")) completed.add("assets"); + } else if (order.includes(stage)) { + completed.add(stage); + } else { + return; + } + update(); + } + + function fail(reason) { + if (dismissed || failure) return; + const safeMessage = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : ""; + failure = safeMessage && safeMessage.length < 180 ? `${strings.error}: ${safeMessage}` : strings.errorDetails; + update(); + } + + document.addEventListener("load", event => { + if (!(event.target instanceof HTMLLinkElement) || !event.target.matches("link[data-rustpad-shell-css]")) return; + if (loadedStyleElements.has(event.target)) return; + loadedStyleElements.add(event.target); + loadedStyleCount += 1; + const expected = document.querySelectorAll("link[data-rustpad-shell-css]").length; + if (expected > 0 && loadedStyleCount >= expected) complete("css"); + }, true); + document.addEventListener("error", event => { + if (event.target instanceof HTMLLinkElement && event.target.matches("link[data-rustpad-shell-css]")) { + fail(strings.errorDetails); + } + }, true); + document.addEventListener("DOMContentLoaded", () => { + element("app-boot-retry")?.addEventListener("click", () => location.reload()); + update(); + }, { once: true }); + + const timeout = setTimeout(() => { + if (!dismissed) fail(strings.timeout); + }, 45000); + + window.__RUSTPAD_LOADING__ = Object.freeze({ + complete, + skip: complete, + ready() { readyRequested = true; update(); if (dismissed) clearTimeout(timeout); }, + fail, + stageDone(stage) { + return stage === "css" || stage === "js" ? resourceParts.has(stage) : completed.has(stage); + } + }); +})(); diff --git a/static/js/collaboration-session.js b/static/js/collaboration-session.js index 78f37a8..264b475 100644 --- a/static/js/collaboration-session.js +++ b/static/js/collaboration-session.js @@ -105,10 +105,15 @@ export class CollaborationSession { const replacements = mergeOwnerReplacements([], ownerReplacements); if (!hasTextEffect(operation) && !replacements.length) return null; - const localLength = this.localDocument().content.length; + const localLength = this.buffer + ? operationTargetLength(this.buffer.operation) + : this.outstanding + ? operationTargetLength(this.outstanding.operation) + : this.serverContent.length; if (operationBaseLength(operation) !== localLength) { throw new Error("Local operation base length does not match the collaborative document"); } + const targetLength = operationTargetLength(operation); if (!this.buffer) { this.buffer = pendingEnvelope(this.clientId, this.nextUpdateId++, operation, replacements); @@ -117,7 +122,7 @@ export class CollaborationSession { this.buffer.ownerReplacements = mergeOwnerReplacements(this.buffer.ownerReplacements, replacements); } - if (operationTargetLength(this.buffer.operation) !== this.localDocument().content.length) { + if (operationTargetLength(this.buffer.operation) !== targetLength) { throw new Error("Buffered operation target length does not match the collaborative document"); } return this.buffer; diff --git a/static/js/home.js b/static/js/home.js index 8aa2e16..f4a9ff4 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -100,6 +100,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even await handleAccountConfirmationToken() || await handleResetToken() || await handleAccountActionToken() || await handleShareInvitationToken(); const identityDialog = document.querySelector("#identity-dialog"); +const accountSlot = document.querySelector("#footer-account-slot"); const guestAccount = document.querySelector("#footer-account-guest"); const userAccount = document.querySelector("#footer-account-user"); const userLabel = document.querySelector("#footer-user-label"); @@ -121,6 +122,7 @@ const aboutDialog = document.querySelector("#about-dialog"); const profileForm = document.querySelector("#profile-form"); let currentSession = null; +let accountStateRevision = 0; function authHeaders() { return {}; } function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; } @@ -440,6 +442,7 @@ resourcesPagination?.addEventListener("click", event => { const button = event.t function renderAccount(session) { + accountStateRevision += 1; currentSession = session; guestAccount.hidden = Boolean(session); userAccount.hidden = !session; @@ -473,6 +476,13 @@ function renderAccount(session) { userLabelSecondary.hidden = true; } registerLink.hidden = !registrationEnabled; + + if (accountSlot) { + accountSlot.dataset.state = "ready"; + accountSlot.setAttribute("aria-busy", "false"); + const retry = document.querySelector("#footer-account-retry"); + if (retry) retry.hidden = true; + } } document.querySelector("#footer-about")?.addEventListener("click", () => aboutDialog?.showModal()); @@ -523,7 +533,7 @@ if (identityDialog) { profileDialog.showModal(); }); document.addEventListener("rustpad:languagechange", () => { - renderAccount(currentSession); + if (accountSlot?.dataset.state === "ready") renderAccount(currentSession); if (!profileDialog?.open) return; populateLanguageSelect(document.querySelector("#profile-language"), getLanguage()); }); @@ -601,6 +611,13 @@ if (identityDialog) { }); window.addEventListener("rustpad:session-expired", () => renderAccount(null)); - renderAccount(null); - validateCurrentSession().then(renderAccount); + const lookupRevision = accountStateRevision; + validateCurrentSession() + .then(session => { + if (accountStateRevision === lookupRevision) renderAccount(session); + }) + .catch(error => { + console.warn("Could not validate the saved session", error); + if (accountStateRevision === lookupRevision) renderAccount(null); + }); } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index e78f94a..6898ee5 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -16,6 +16,7 @@ import { identityOperation, operationFromEdit } from "@rustpad/collaboration"; import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session"; import { copyText } from "@rustpad/clipboard"; import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links"; +import { buildLineStarts, createVirtualEditorLayers, VIRTUAL_LINE_THRESHOLD } from "@rustpad/virtual-editor-layers"; import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format"; import { createMentionAutocomplete } from "@rustpad/mention-autocomplete"; import { createMentionNavigator } from "@rustpad/mention-navigation"; @@ -52,6 +53,18 @@ export function startNoteEditor(adapter) { ? crypto.randomUUID().replaceAll("-", "") : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; const collaboration = new CollaborationSession(collaborationClientId); + const bootLoading = window.__RUSTPAD_LOADING__; + let bootDocumentReady = false; + let bootFilesReady = false; + let bootFinishing = false; + + function releaseBootForAccessDialog() { + if (!bootLoading || bootFinishing) return; + bootFinishing = true; + bootLoading.complete("access"); + bootLoading.skip("files"); // Identity/password dialog is ready to use. + bootLoading.ready(); + } let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", lastRevealedHeadingHash = "", flushRequested = false, accountSession = null; let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false; let pendingPreviewViewport = null; @@ -549,6 +562,7 @@ export function startNoteEditor(adapter) { } function syncEditorLayers() { + virtualLayers.sync(); gutter.scrollTop = editor.scrollTop; syncAuthorshipLayer(authorshipLayer, editor); syncOwnerLabels(); @@ -557,19 +571,34 @@ export function startNoteEditor(adapter) { let gutterLineCount = 0; let gutterLineHeight = 0; let highlightedGutterLine = null; + let cachedLineText = null, cachedLineStarts = [0]; + const virtualLayers = createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName }); + function updateLinkedLineHighlight() { + const lineCount = cachedLineText === editor.value + ? cachedLineStarts.length : Math.max(1, (editor.value.match(/\n/g) || []).length + 1); + const linkedLine = lineFromHash(location.hash, lineCount); + if (virtualLayers.active) virtualLayers.highlight(linkedLine); + else { + gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked"); + gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked"); + } + highlightedGutterLine = linkedLine; + } function renderGutter() { document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked); document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked); gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked)); - const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); - const lines = Array.from({ length: lineCount }); + const content = editor.value; + cachedLineText = content; + cachedLineStarts = buildLineStarts(content); + const lineCount = cachedLineStarts.length; const owners = authorshipOwners(authorship); renderedAuthorshipOwners = owners; const showAuthorship = authorshipColorsEnabled && owners.length > 0; - const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : []; const full = authorshipMode === "full"; + const authorsByLine = showAuthorship && full ? lineAuthors(content, authorship) : []; authorshipLayer.hidden = !showAuthorship; ownerLabels.hidden = !full || !showAuthorship; renderParticipantBadges(authorshipColorsEnabled ? owners : []); @@ -577,8 +606,26 @@ export function startNoteEditor(adapter) { editorWorkspace.dataset.authorshipMode = authorshipMode; const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, fontSize = parseFloat(style.fontSize) || 14, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24; gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`; - // Physical rows represent sequential line numbers, not specific text lines. - // Reuse them across character edits and add/remove only the changed tail. + ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`); + ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`); + if (lineCount >= VIRTUAL_LINE_THRESHOLD) { + if (!virtualLayers.active) gutterLineCount = 0; + virtualLayers.update({ + content, lineStarts: cachedLineStarts, model: authorship, authorsByLine, + full, visible: showAuthorship, link: lineFromHash(location.hash, lineCount), + computedStyle: style, + }); + updateLinkedLineHighlight(); + syncEditorLayers(); + return; + } + if (virtualLayers.active) { + virtualLayers.destroy(); + gutterLineCount = 0; + gutterLineHeight = 0; + highlightedGutterLine = null; + } + const lines = Array.from({ length: lineCount }); if (gutterLineHeight !== lineHeight) { for (const row of gutter.children) row.style.height = `${lineHeight}px`; gutterLineHeight = lineHeight; @@ -593,8 +640,6 @@ export function startNoteEditor(adapter) { for (let i = gutterLineCount; i > lineCount; i--) gutter.lastElementChild?.remove(); } gutterLineCount = lineCount; - ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`); - ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`); if (full) { let previousAuthorSignature = null; ownerLabels.innerHTML = `
${lines.map((_, i) => { @@ -610,12 +655,7 @@ export function startNoteEditor(adapter) { } else ownerLabels.replaceChildren(); if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); else authorshipLayer.replaceChildren(); - const linkedLine = lineFromHash(location.hash, lineCount); - if (highlightedGutterLine !== linkedLine) { - gutter.querySelector(`[data-line="${highlightedGutterLine}"]`)?.classList.remove("is-linked"); - gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked"); - highlightedGutterLine = linkedLine; - } + updateLinkedLineHighlight(); syncEditorLayers(); } @@ -644,10 +684,12 @@ export function startNoteEditor(adapter) { function revealLinkedLine() { if (!location.hash || location.hash === lastRevealedLineHash) return; - const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); + const hasCurrentIndex = cachedLineText === editor.value; + const lineCount = hasCurrentIndex ? cachedLineStarts.length + : Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const line = lineFromHash(location.hash, lineCount); if (!line) return; - const offset = lineStartOffset(editor.value, line); + const offset = hasCurrentIndex ? cachedLineStarts[line - 1] : lineStartOffset(editor.value, line); if (offset == null) return; const style = getComputedStyle(editor); @@ -1135,8 +1177,6 @@ export function startNoteEditor(adapter) { let cachedPreviewHeight = -1; let cachedPreviewWidth = -1; function invalidatePreviewPositions() { previewPositionCache = null; } - // The (rect.top - container.top + scrollTop) coordinates are invariant to - // scrolling; only rebuild them when layout/content actually changes. if (typeof ResizeObserver !== "undefined") { new ResizeObserver(invalidatePreviewPositions).observe(preview); } @@ -1149,10 +1189,11 @@ export function startNoteEditor(adapter) { preview.addEventListener("toggle", invalidatePreviewPositions, true); preview.addEventListener("load", invalidatePreviewPositions, true); window.addEventListener("resize", invalidatePreviewPositions); + window.addEventListener("resize", () => { + if (virtualLayers.active) virtualLayers.sync(); + }); document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions); function previewLinePositions() { - // Height/width checks catch dynamic embeds that change geometry without - // a DOM mutation (e.g. a media player expanding after initialization). const height = preview.scrollHeight, width = preview.clientWidth; if (previewPositionCache && cachedPreviewHeight === height && cachedPreviewWidth === width) return previewPositionCache; const previewRect = preview.getBoundingClientRect(); @@ -1312,11 +1353,9 @@ export function startNoteEditor(adapter) { let previewStale = true; let forcePreviewRender = false; + let initialPreviewEnhancements = Promise.resolve(); function renderNow() { const previewViewport = pendingPreviewViewport; - // In editor-only view this DOM is display:none. Building a full Markdown - // tree (and highlighting / Mermaid) on each remote edit is wasted work. - // Keep the exceptional consumers working: heading deep-links and PDF export. const headingRevealPending = uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash) && lastRevealedHeadingHash !== location.hash; @@ -1337,9 +1376,7 @@ export function startNoteEditor(adapter) { scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)"; if (activeView() !== "edit" || previewNeededForPdf) { - renderMermaid(); - renderCodeHighlight(); - renderMediaPlayers(); + initialPreviewEnhancements = Promise.allSettled([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); } } else { preview.classList.add("preview--raw"); @@ -1360,16 +1397,35 @@ export function startNoteEditor(adapter) { revealHeadingAnchor(); }); } + async function finishInitialEditorBoot() { + if (!bootLoading || bootFinishing || !bootDocumentReady || !bootFilesReady) return; + bootFinishing = true; + try { + if (deferredRenderTimer !== null) { + clearTimeout(deferredRenderTimer); + deferredRenderTimer = null; + firstDeferredRenderAt = 0; + } + renderNow(); + await initialPreviewEnhancements; + await new Promise(resolve => requestAnimationFrame(resolve)); + bootLoading.complete("files"); + bootLoading.ready(); + } catch (error) { + bootLoading.fail(error); + } + } + const render = createRenderQueue(renderNow); - // Collaborative edits can arrive many times per second. Keep typing and - // WebSocket processing responsive by batching expensive Markdown/DOM work. let deferredRenderTimer = null; let firstDeferredRenderAt = 0; + let lastLocalInputAt = -Infinity; function scheduleDocumentRender() { const now = performance.now(); if (deferredRenderTimer === null) firstDeferredRenderAt = now; else clearTimeout(deferredRenderTimer); - const delay = Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt))); + const activeTyping = now - lastLocalInputAt < 450; + const delay = activeTyping ? 300 : Math.min(180, Math.max(0, 750 - (now - firstDeferredRenderAt))); deferredRenderTimer = setTimeout(() => { deferredRenderTimer = null; firstDeferredRenderAt = 0; @@ -1607,7 +1663,7 @@ export function startNoteEditor(adapter) { throw new Error("Server snapshot does not match the collaborative operation"); } if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map); - if (renderView) applyCollaborativeView({ resetHistory: result.remote }); + if (renderView && !result.ownAck) applyCollaborativeView({ resetHistory: result.remote }); return result; } @@ -1665,8 +1721,7 @@ export function startNoteEditor(adapter) { } } - - const { loadFiles } = bindNoteFiles({ + const { loadFiles, initialFilesReady } = bindNoteFiles({ editor, toast, getAccessToken: () => accessToken, getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0, canDelete: () => Boolean(info?.can_delete_files), @@ -1753,6 +1808,8 @@ export function startNoteEditor(adapter) { applyCollaborativeView({ resetHistory: true }); flushRequested = false; saveState.textContent = "Read only"; + bootDocumentReady = true; + void finishInitialEditorBoot(); return; } handleCollaborativeAuthentication(message); @@ -1760,6 +1817,8 @@ export function startNoteEditor(adapter) { else if (collaboration.buffer) flushCollaborativeUpdate(); else saveState.textContent = "Changes are saved automatically"; editor.focus(); + bootDocumentReady = true; + void finishInitialEditorBoot(); }, onDocument: handleCollaborativeDocument, onResync: message => recoverCollaborativeSnapshot(message, "resync"), @@ -1780,6 +1839,7 @@ export function startNoteEditor(adapter) { toast.warning("A password was set for this note. Enter it to continue editing.", { title: "Password required", duration: 6500 }); if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); + releaseBootForAccessDialog(); try { await loadNoteInfo(); adapter.configureView?.(info); @@ -1803,6 +1863,7 @@ export function startNoteEditor(adapter) { if (saveState.textContent === "Saving…") saveState.textContent = "Save failed"; if (/nickname|session|account/i.test(message)) { if (!identityDialog.open) identityDialog.showModal(); + releaseBootForAccessDialog(); return; } if (info?.protected && isResourceAccessError(message)) { @@ -1811,6 +1872,7 @@ export function startNoteEditor(adapter) { document.querySelector("#password-error").textContent = friendly; if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); + releaseBootForAccessDialog(); return; } toast.danger(friendly, { title: "Editor connection error" }); @@ -1842,23 +1904,35 @@ export function startNoteEditor(adapter) { nickname = accountSession?.nickname || getNickname(); if (!nickname) { if (!identityDialog.open) identityDialog.showModal(); + releaseBootForAccessDialog(); return; } accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); await loadNoteInfo(); + bootLoading?.complete("access"); document.title = adapter.title(info); publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls(); adapter.configureView?.(info); applyUi({ write: true, replace: true }); updateCurrentUser(); - if (info.protected && info.access_level === "none") passwordDialog.showModal(); - else { loadFiles(); connect(); } + if (info.protected && info.access_level === "none") { + passwordDialog.showModal(); + releaseBootForAccessDialog(); + } else { + void loadFiles(); + void initialFilesReady.then(() => { + bootFilesReady = true; + void finishInitialEditorBoot(); + }); + connect(); + } } catch (e) { if (e.status === 403 || e.status === 404) { await showSystemNotFound(); return; } + bootLoading?.fail(e); document.body.innerHTML = `

Page could not be loaded

${escapeHtml(e.message)}

`; } } @@ -2159,7 +2233,7 @@ export function startNoteEditor(adapter) { } } window.addEventListener("popstate", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); }); - window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; renderGutter(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); }); + window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; lastRevealedHeadingHash = ""; updateLinkedLineHighlight(); if (activeView() === "edit" && uiState.mode === "markdown" && location.hash && !/^#L[1-9]\d*$/i.test(location.hash)) render(); requestAnimationFrame(() => { revealLinkedLine(); revealHeadingAnchor(); }); }); preview.addEventListener("click", event => { const link = event.target instanceof Element ? event.target.closest('a[href^="#"]') : null; if (!link) return; @@ -2626,6 +2700,7 @@ export function startNoteEditor(adapter) { authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length); authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner()); previousContent = nextContent; + if (!applyingRemote && !applyingHistory) lastLocalInputAt = performance.now(); scheduleDocumentRender(); if (applyingRemote || applyingHistory) return; queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship)); diff --git a/static/js/note-files.js b/static/js/note-files.js index a0e18ba..4f124da 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -182,6 +182,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS let currentFiles = []; let filesLoaded = false; let loadSequence = 0; + let finishInitialFiles; + const initialFilesReady = new Promise(resolve => { finishInitialFiles = resolve; }); function summaryText(files, shown = files.length) { const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); @@ -279,9 +281,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS footer.textContent = tp("files.summary", currentFiles.length, { size: formatBytes(totalSize) }, `${currentFiles.length} ${currentFiles.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`); renderFiles(); onFilesChanged(currentFiles); + finishInitialFiles(); } catch (error) { if (sequence !== loadSequence) return; renderLoadError(error); + finishInitialFiles(); if (open) notify("danger", error.message, { title: t("files.loadFailed", {}, "Could not load files") }); } } @@ -500,5 +504,5 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS } }); - return { loadFiles }; + return { loadFiles, initialFilesReady }; } diff --git a/static/js/public.js b/static/js/public.js index d72432a..bf5e9eb 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -128,7 +128,7 @@ function settleInitialPublicAnchor(hash) { window.addEventListener("load", scheduleCorrection, { once: true }); cleanups.push(() => window.removeEventListener("load", scheduleCorrection)); } - document.fonts?.ready?.then(scheduleCorrection).catch(() => {}); + document.fonts?.ready?.then(scheduleCorrection).catch(() => { }); if (typeof ResizeObserver === "function") { observer = new ResizeObserver(scheduleCorrection); @@ -183,6 +183,7 @@ function restoreFullWidth() { async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); + window.__RUSTPAD_LOADING__?.complete("access"); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; publicTitle.textContent = page.title; @@ -197,12 +198,17 @@ async function initialize() { installPublicPermalinks(); alignPreviewLineNumbers(content); settleInitialPublicAnchor(location.hash); + window.__RUSTPAD_LOADING__?.complete("files"); + window.__RUSTPAD_LOADING__?.ready(); } catch (error) { + window.__RUSTPAD_LOADING__?.complete("access"); publicAnchorSettleCleanup?.(); if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? t("public.passwordAuthorized", {}, "Sign in with an authorized account or enter the resource password.") : t("public.passwordCorrect", {}, "Enter the correct password."); if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); + window.__RUSTPAD_LOADING__?.skip("files"); + window.__RUSTPAD_LOADING__?.ready(); return; } content.replaceChildren(); @@ -211,6 +217,8 @@ async function initialize() { message.textContent = String(error.message); content.append(message); toast.danger(error.message, { title: "Could not load published page" }); + window.__RUSTPAD_LOADING__?.skip("files"); + window.__RUSTPAD_LOADING__?.ready(); } } content.addEventListener("click", async event => { diff --git a/static/js/virtual-editor-layers.js b/static/js/virtual-editor-layers.js new file mode 100644 index 0000000..35c5274 --- /dev/null +++ b/static/js/virtual-editor-layers.js @@ -0,0 +1,263 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Virtualize only decorative editor layers. The textarea, OT document, + * preview renderer, selection, history, and line link URLs are unchanged. + */ + +export const VIRTUAL_LINE_THRESHOLD = 1500; +const OVERSCAN = 8; + +export function buildLineStarts(text) { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) starts.push(i + 1); + } + return starts; +} + +export function visibleLineWindow(scrollTop, viewportHeight, paddingTop, lineHeight, total, overscan = OVERSCAN) { + const height = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0; + const top = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0; + const rowHeight = Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20; + const offset = Number.isFinite(paddingTop) ? paddingTop : 0; + const from = Math.max(0, Math.floor((top - offset) / rowHeight)); + const to = Math.max(from + 1, Math.ceil((top + height - offset) / rowHeight)); + return { + first: Math.max(0, Math.min(total, from - overscan)), + end: Math.max(0, Math.min(total, to + overscan)), + }; +} + +export function labelGroupLines(authorsByLine, ownerName) { + const groups = []; + let previousSignature = null; + for (let i = 0; i < authorsByLine.length; i++) { + const authors = authorsByLine[i] || []; + if (!authors.length) continue; + const signature = authors.map(ownerName).sort((a, b) => a.localeCompare(b)).join('\u0000'); + if (signature === previousSignature) continue; + previousSignature = signature; + groups.push({ line: i, authors }); + } + return groups; +} + +function lowerBound(items, value, selector = item => item) { + let left = 0, right = items.length; + while (left < right) { + const mid = (left + right) >>> 1; + if (selector(items[mid]) < value) left = mid + 1; + else right = mid; + } + return left; +} + +export function createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName }) { + let active = false; + let starts = [0], text = '', spans = [], groups = []; + let showAuthorship = false, showLabels = false, linkedLine = null; + let lineHeight = 20, paddingTop = 0, paddingLeft = 0; + let spacer, gutterWindow, labelCanvas, colorCanvas, colorViewport; + let renderedFirst = -1, renderedEnd = -1; + let lineNodes = new Map(), labelNodes = new Map(); + + function initialize() { + if (active) return; + active = true; + spacer = document.createElement('div'); + spacer.className = 'line-gutter-virtual-spacer'; + spacer.style.position = 'relative'; + spacer.style.width = '100%'; + gutterWindow = document.createElement('div'); + gutterWindow.className = 'line-gutter-virtual-window'; + gutterWindow.style.position = 'absolute'; + gutterWindow.style.left = '0'; + gutterWindow.style.right = '0'; + spacer.append(gutterWindow); + gutter.replaceChildren(spacer); + labelCanvas = document.createElement('div'); + labelCanvas.className = 'owner-labels-canvas'; + ownerLabels.replaceChildren(labelCanvas); + colorCanvas = document.createElement('div'); + colorCanvas.className = 'authorship-canvas'; + colorViewport = document.createElement('div'); + colorViewport.className = 'authorship-viewport'; + colorViewport.style.position = 'absolute'; + colorViewport.style.whiteSpace = 'pre'; + colorCanvas.append(colorViewport); + authorshipLayer.replaceChildren(colorCanvas); + } + + function createLineNode() { + const row = document.createElement('div'); + row.className = 'line-number-row'; + const button = document.createElement('button'); + button.className = 'line-number-button'; + button.type = 'button'; + button.tabIndex = -1; + row.append(button); + return row; + } + + function updateGutter(first, end, force) { + if (!force && first === renderedFirst && end === renderedEnd) return; + const available = [...lineNodes.values()].filter(node => { + const line = Number(node.firstElementChild.dataset.line); + return line < first + 1 || line > end; + }); + const next = new Map(), fragment = document.createDocumentFragment(); + for (let i = first; i < end; i++) { + const line = i + 1; + const row = lineNodes.get(line) || available.pop() || createLineNode(); + row.style.height = `${lineHeight}px`; + const button = row.firstElementChild; + if (button.dataset.line !== String(line)) { + button.dataset.line = String(line); + button.textContent = String(line); + button.setAttribute('aria-label', `Copy link to line ${line}`); + button.classList.remove('is-copied'); + } + button.classList.toggle('is-linked', linkedLine === line); + next.set(line, row); + fragment.append(row); + } + gutterWindow.style.top = `${first * lineHeight}px`; + gutterWindow.replaceChildren(fragment); + lineNodes = next; + } + + function createLabelNode(group) { + const node = document.createElement('span'); + node.className = 'owner-label-group'; + for (const owner of group.authors) { + const badge = document.createElement('span'); + badge.className = 'owner-label'; + badge.style.setProperty('--owner', colorFor(owner)); + badge.textContent = ownerName(owner); + node.append(badge); + } + return node; + } + + function updateLabels(first, end, force) { + if (!showLabels) { + if (labelNodes.size) labelCanvas.replaceChildren(); + labelNodes.clear(); + return; + } + if (!force && first === renderedFirst && end === renderedEnd) return; + const next = new Map(), fragment = document.createDocumentFragment(); + for (let i = lowerBound(groups, first, item => item.line); i < groups.length && groups[i].line < end; i++) { + const group = groups[i]; + const node = labelNodes.get(group.line) || createLabelNode(group); + node.style.top = `${paddingTop + group.line * lineHeight + lineHeight / 2}px`; + next.set(group.line, node); + fragment.append(node); + } + labelCanvas.replaceChildren(fragment); + labelNodes = next; + } + + function updateColors(first, end, force) { + if (!showAuthorship) { + if (colorViewport.hasChildNodes()) colorViewport.replaceChildren(); + return; + } + if (!force && first === renderedFirst && end === renderedEnd) return; + const from = starts[first] ?? text.length; + const to = end < starts.length ? starts[end] : text.length; + const fragment = document.createDocumentFragment(); + let cursor = from; + let index = lowerBound(spans, from + 1, item => item.end); + while (index < spans.length) { + const span = spans[index++]; + if (span.start >= to) break; + const start = Math.max(cursor, span.start, from); + const stop = Math.min(span.end, to); + if (start > cursor) fragment.append(document.createTextNode(text.slice(cursor, start))); + if (stop <= start) continue; + const mark = document.createElement('span'); + mark.className = 'authorship-fragment'; + mark.style.setProperty('--owner', colorFor(span.owner)); + mark.textContent = text.slice(start, stop); + mark.title = String(span.owner).split('\u001f', 1)[0]; + fragment.append(mark); + cursor = stop; + } + if (cursor < to) fragment.append(document.createTextNode(text.slice(cursor, to))); + if (end === starts.length && !text.endsWith('\n')) fragment.append(document.createTextNode('\n')); + colorViewport.style.top = `${paddingTop + first * lineHeight}px`; + colorViewport.style.left = `${paddingLeft}px`; + colorViewport.replaceChildren(fragment); + } + + function updateViewport(force = false) { + if (!active) return; + const { first, end } = visibleLineWindow( + editor.scrollTop, editor.clientHeight, paddingTop, lineHeight, starts.length, + ); + if (!force && first === renderedFirst && end === renderedEnd) return; + updateGutter(first, end, force); + updateLabels(first, end, force); + updateColors(first, end, force); + renderedFirst = first; + renderedEnd = end; + } + + return { + get active() { return active; }, + update({ content, lineStarts, model, authorsByLine, full, visible, link, computedStyle }) { + initialize(); + text = content; + starts = lineStarts; + spans = model?.spans || []; + showAuthorship = visible; + showLabels = visible && full; + linkedLine = link; + const style = computedStyle; + lineHeight = parseFloat(style.lineHeight) || 29; + paddingTop = parseFloat(style.paddingTop) || 24; + paddingLeft = parseFloat(style.paddingLeft) || 0; + spacer.style.height = `${starts.length * lineHeight}px`; + gutterWindow.style.lineHeight = `${lineHeight}px`; + colorCanvas.style.paddingTop = style.paddingTop; + colorCanvas.style.paddingRight = style.paddingRight; + colorCanvas.style.paddingBottom = style.paddingBottom; + colorCanvas.style.paddingLeft = style.paddingLeft; + colorCanvas.style.fontFamily = style.fontFamily; + colorCanvas.style.fontSize = style.fontSize; + colorCanvas.style.fontWeight = style.fontWeight; + colorCanvas.style.lineHeight = style.lineHeight; + colorCanvas.style.letterSpacing = style.letterSpacing; + colorCanvas.style.minWidth = `${editor.scrollWidth}px`; + colorCanvas.style.minHeight = `${editor.scrollHeight}px`; + authorshipLayer.style.left = `${editor.offsetLeft}px`; + groups = showLabels ? labelGroupLines(authorsByLine, ownerName) : []; + labelNodes = new Map(); + updateViewport(true); + }, + sync() { updateViewport(); }, + highlight(line) { + linkedLine = line; + for (const [number, row] of lineNodes) { + row.firstElementChild.classList.toggle('is-linked', number === line); + } + }, + destroy() { + if (!active) return; + active = false; + gutter.replaceChildren(); + ownerLabels.replaceChildren(); + authorshipLayer.replaceChildren(); + lineNodes = new Map(); + labelNodes = new Map(); + renderedFirst = renderedEnd = -1; + groups = []; + spans = []; + text = ''; + }, + get range() { return { first: renderedFirst, end: renderedEnd }; }, + }; +} diff --git a/static/js/workspace.js b/static/js/workspace.js index d26b139..001c2f1 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -489,7 +489,17 @@ workspaceSettingsForm.addEventListener("submit", async event => { submit.disabled = false; } }); -document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); +function updateNoteProtectionExplanation() { + const message = document.querySelector("#note-archive-explanation"); + if (!message) return; + message.textContent = info?.archive_deleted_notes + ? t("workspace.noteProtection.archiveEnabled", {}, "Archiving is enabled: authorized deletion moves the note, its history and files to Archived notes. The workspace owner can restore it.") + : t("workspace.noteProtection.archiveDisabled", {}, "Archiving is disabled: authorized deletion permanently removes the note and its files. Enable archiving in Workspace settings if recovery is important."); +} +document.querySelector("#new-note-button").addEventListener("click", () => { + updateNoteProtectionExplanation(); + document.querySelector("#note-dialog").showModal(); +}); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close()); document.querySelector("#note-form").addEventListener("submit", async e => { e.preventDefault(); @@ -542,21 +552,47 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async ( catch (e) { toast.danger(e.message, { title: "Could not copy workspace link" }); } }); async function startAuthorizedWorkspace() { - const session = await validateCurrentSession(); - accountSession = session; - nickname = session?.nickname || getNickname(); + const loading = window.__RUSTPAD_LOADING__; + document.querySelector("#workspace-startup-error")?.setAttribute("hidden", ""); + try { + const session = await validateCurrentSession(); + accountSession = session; + nickname = session?.nickname || getNickname(); - if (!nickname) { + if (!nickname) { + loading?.complete("access"); + workspaceContent.hidden = true; + if (!identityDialog.open) identityDialog.showModal(); + loading?.skip("files"); + loading?.ready(); + return; + } + + accessToken = shareToken || getAccessToken("workspace", slug); + workspaceContent.hidden = false; + await init(); + loading?.complete("access"); + if (workspaceLockedForPassword) loading?.skip("files"); + else loading?.complete("files"); + loading?.ready(); + } catch (error) { + loading?.fail(error); + console.error("Workspace initialization failed", error); workspaceContent.hidden = true; - if (!identityDialog.open) identityDialog.showModal(); - return; + const panel = document.querySelector("#workspace-startup-error"); + const message = document.querySelector("#workspace-startup-message"); + if (message) message.textContent = `${t("workspace.connectionError", {}, "Workspace connection error")}: ${error?.message || "Please retry."}`; + if (panel) panel.hidden = false; } - - accessToken = shareToken || getAccessToken("workspace", slug); - workspaceContent.hidden = false; - await init(); } +document.querySelector("#workspace-startup-retry")?.addEventListener("click", async event => { + const button = event.currentTarget; + button.disabled = true; + try { await startAuthorizedWorkspace(); } + finally { button.disabled = false; } +}); + bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { diff --git a/static/workspace.html b/static/workspace.html index b690dfb..9ec7bd9 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -27,6 +27,11 @@
+
@@ -34,8 +39,10 @@

Select a note or create a new one.

- - + +
@@ -50,9 +57,12 @@ @@ -78,7 +88,8 @@

Archived notes

-

Restore notes that were archived instead of permanently deleted.

+

Restore notes that were archived instead + of permanently deleted.

@@ -127,14 +138,22 @@

New note

+ class="dialog-check">Protect this note from deletion +
+

When enabled, + collaborators can edit but cannot delete or archive the note or remove its files. The note creator + or workspace owner can still do so. This does not require a password.

+

+

-
+
\ No newline at end of file diff --git a/tests/api-error-classification-test.mjs b/tests/api-error-classification-test.mjs deleted file mode 100644 index 7dbbc4e..0000000 --- a/tests/api-error-classification-test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -const events = globalThis.__apiEvents = []; -let source = readFileSync('static/js/api.js', 'utf8') - .replace('import { logDebug, logError, logWarn } from "@rustpad/logger";', - 'const logDebug = () => {}; const logError = (...args) => globalThis.__apiEvents.push(["error", ...args]); const logWarn = (...args) => globalThis.__apiEvents.push(["warn", ...args]);') - .replace('import { formatNumber, restoreGuestLanguage, translateApiMessage } from "@rustpad/i18n";', - 'const formatNumber = value => String(value); const restoreGuestLanguage = () => {}; const translateApiMessage = message => message;') - .replace('import { restoreGuestTheme } from "@rustpad/theme";', - 'const restoreGuestTheme = () => {};'); -const { api } = await import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source)); - -globalThis.window = {}; -globalThis.fetch = async () => new Response(JSON.stringify({ error: 'Access denied.' }), { - status: 403, - headers: { 'content-type': 'application/json' }, -}); -await assert.rejects(api('/api/auth/resources/mentions?kind=pad&slug=test'), error => error.status === 403); -assert.equal(events.filter(([level, name]) => level === 'warn' && name === 'api.failed').length, 1); -assert.equal(events.filter(([level, name]) => level === 'error' && name === 'api.network_error').length, 0); - -events.length = 0; -globalThis.fetch = async () => new Response(JSON.stringify({ users: [] }), { - status: 200, - headers: { 'content-type': 'application/json' }, -}); -assert.deepEqual(await api('/api/auth/resources/mentions?kind=pad&slug=test'), { users: [] }); -assert.equal(events.length, 0); - -events.length = 0; -globalThis.fetch = async () => { throw new TypeError('Network down'); }; -await assert.rejects(api('/api/auth/resources/mentions?kind=pad&slug=test'), TypeError); -assert.equal(events.filter(([level, name]) => level === 'error' && name === 'api.network_error').length, 1); -console.log('PASS: HTTP 403 logged once, empty mentions succeeds, transport failures logged as network errors'); diff --git a/tests/broadcast-load-simulation.mjs b/tests/broadcast-load-simulation.mjs deleted file mode 100644 index 18b8b59..0000000 --- a/tests/broadcast-load-simulation.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict'; -import { performance } from 'node:perf_hooks'; -// Deterministic broadcast/access-check simulation. Not a live WebSocket/DB benchmark. -const durationSeconds = 10; -const editsPerUserPerSecond = 2; -for (const users of [5, 100, 300, 500]) { - const checks = new Int32Array(users); - const lastCheckSecond = new Int32Array(users).fill(-1); - const start = performance.now(); - let fanouts = 0; - for (let second = 0; second < durationSeconds; second++) { - for (let event = 0; event < users * editsPerUserPerSecond; event++) { - for (let recipient = 0; recipient < users; recipient++) { - fanouts++; - if (lastCheckSecond[recipient] !== second) { - checks[recipient]++; - lastCheckSecond[recipient] = second; - } - } - } - } - const optimized = checks.reduce((a,b) => a+b, 0); - const baseline = fanouts; - assert.equal(optimized, users * durationSeconds); - assert.ok(optimized <= baseline); - console.log(`${users} clients | ${fanouts.toLocaleString('en-US')} broadcast deliveries | auth reads: ${baseline.toLocaleString('en-US')} -> ${optimized.toLocaleString('en-US')} | reduction ${(100*(1-optimized/baseline)).toFixed(2)}% | simulation ${(performance.now()-start).toFixed(1)} ms`); -} diff --git a/tests/feature-smoke-test.mjs b/tests/feature-smoke-test.mjs deleted file mode 100644 index 7717645..0000000 --- a/tests/feature-smoke-test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -globalThis.location = {origin:'https://example.org'}; -let source = readFileSync('static/js/markdown.js','utf8') - .replace(/^import .*;$/gm, ''); -source = `const EMOJI_SHORTCODES = {}; const t = (key,vars,alternative) => alternative; const parseImageAlias = () => null;\n` + source; -const {renderMarkdown, setMarkdownMentions} = await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64')); -function has(re, text, message) { assert.match(text,re,message); } - -let html = renderMarkdown('# Instrukcja testowa\n[Przejdź](#instrukcja-testowa)\n# Instrukcja testowa\n[TOC]\n## Etap drugi'); -has(/

]*style="margin-left:2ch"[^>]*>const value = 1;\n console\.log\(value\);<\/code><\/pre>/,html,'code container indentation + dedent'); -has(/]*>after<\/p>/,html,'code closed'); -html = renderMarkdown(' ```python=42\n print(42)\n ```'); -has(/class="code-with-lines[^>]*style="margin-left:4ch"/,html,'numbered fenced code whole block shifted'); -has(/data-line="42">print\(42\)/,html,'numbered content dedented'); -html = renderMarkdown(' ```\n ~~~\n ```'); -has(/~~~<\/code>/,html,'other marker must remain inside code'); -html = renderMarkdown('```\n## hidden in code\n```\n## Visible'); -assert.doesNotMatch(html, /id="hidden-in-code"/); -has(/id="visible"/,html,'headings outside code'); - -setMarkdownMentions([{nickname:'Ala',color:'#aabbcc'},{nickname:'John+Dev',color:'red'}]); -html = renderMarkdown('Cześć @Ala i @John+Dev! @Other @Alana a@Ala `@Ala` [@Ala](https://site.test)'); -assert.equal((html.match(/class="markdown-mention"/g)||[]).length,2,'valid mentions only'); -has(/style="--owner:#aabbcc"/,html,'real author color'); -has(/@Other @Alana a@Ala/,html,'nonmembers and suffixes not chips'); -has(/@Ala<\/code>/,html,'inline code excluded'); -has(/]*>@Ala<\/a>/,html,'links excluded'); -assert.doesNotMatch(renderMarkdown('```\n@Ala\n```'), /markdown-mention/); -html = renderMarkdown('Sentence @Ala. Domain @Ala.example'); -assert.equal((html.match(/class="markdown-mention"/g) || []).length, 1, 'punctuation after a mention is allowed, domains are not'); -setMarkdownMentions([]); -assert.doesNotMatch(renderMarkdown('@Ala'), /markdown-mention/); - -console.log('PASS: anchors, TOC, duplicate headings, indented fences, numbered blocks, mentions, exclusions'); -html = renderMarkdown('[TOC]\n# Procedura\n
\nSzczegóły\n## Procedura\n
'); -has(/id="procedura"/,html); -has(/id="procedura-2"/,html,'nested heading anchor unique'); -has(/href="#procedura-2"/,html,'TOC link for heading inside details'); -console.log('PASS: anchors in nested details match TOC'); -setMarkdownMentions([{nickname: 'a[1]\\x', color: '#123456'}, {nickname: '&"', color: '#112233'}]); -html = renderMarkdown('Hello @a[1]\\x and @&" but not @a[1]\\xy'); -assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames'); -assert.doesNotMatch(html, //, 'no raw HTML from nickname'); -console.log('PASS: special-character nicknames remain escaped'); - -html = renderMarkdown('- [ ] Open\n- [x] Done\n- [~] Cancelled\n - [~] Nested cancelled'); -has(/task-list-item--cancelled/, html, 'cancelled task has its own class'); -assert.equal((html.match(/task-list-item--cancelled/g) || []).length, 2, 'both cancelled tasks recognized'); -has(/data-source-line="3" disabled aria-label="Cancelled task"/, html, 'cancelled checkbox cannot be clicked'); -has(/data-source-prefix="- \[~\] "/, html, 'cancelled markdown marker preserved'); -has(/data-source-line="1"(?! disabled)/, html, 'open task remains interactive'); -console.log('PASS: cancelled tasks are disabled and retain their source markers'); diff --git a/tests/frontend-optimizations-browser.py b/tests/frontend-optimizations-browser.py deleted file mode 100644 index 138ef27..0000000 --- a/tests/frontend-optimizations-browser.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 -"""Isolated browser tests of DOM reconciliation, preview alignment and owner labels. -Run from the repository root. Does not emulate the server or OT protocol. -""" -from __future__ import annotations - -import base64 -from pathlib import Path - -from playwright.sync_api import sync_playwright - -ROOT = Path(__file__).resolve().parents[1] - - -def module_url(content: str) -> str: - return "data:text/javascript;base64," + base64.b64encode(content.encode()).decode() - - -def main(): - reconcile = (ROOT / "static/js/dom-reconcile.js").read_text() - editor_source = (ROOT / "static/js/note-editor.js").read_text() - presence_source = editor_source.split(" function updatePresence(users) {", 1)[1].split(" function updateLatency", 1)[0] - presence_source = "function updatePresence(users) {" + presence_source - badges_source = editor_source.split(" let renderedAuthorshipOwners = [];", 1)[1].split(" function syncOwnerLabels()", 1)[0] - badges_source = "let renderedAuthorshipOwners = [];" + badges_source - geometry_source = "let previewPositionCache = null;" + editor_source.split(" let previewPositionCache = null;", 1)[1].split(" function previewScrollAnchor()", 1)[0] - markdown = (ROOT / "static/js/markdown.js").read_text() - markdown = "const EMOJI_SHORTCODES = {}; const t = (key, v, fallback) => fallback; const parseImageAlias = () => null;\n" + "\n".join( - line for line in markdown.splitlines() if not line.startswith("import ") - ) + "\nexport { firstRenderedTextRect };\n" - html = ''' -
      -
      Alice
      -
      - '''.replace("__RECONCILE__", module_url(reconcile)).replace("__MARKDOWN__", module_url(markdown)).replace("__BADGES_SOURCE__", badges_source).replace("__PRESENCE_SOURCE__", presence_source).replace("__GEOMETRY_SOURCE__", geometry_source) - with sync_playwright() as playwright: - browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox", "--disable-dev-shm-usage"]) - try: - page = browser.new_page(viewport={"width": 1100, "height": 780}) - errors = [] - page.on("pageerror", lambda error: errors.append(str(error))) - page.set_content(html) - try: - page.wait_for_function("window.frontendTestsPassed===true", timeout=15000) - except Exception: - raise AssertionError(f"Browser errors: {errors}") - assert not errors, errors - print("PASS: keyed DOM list updates preserve node identity; identical list has zero mutations") - print("PASS: real presence update handles 1000 users with row/badge reuse, duplicates and XSS-safe text") - print("PASS: owner-label canvas transforms; preview alignment parity for 150 lines in Chromium") - print("PASS: preview geometry cache reuses measurements on scroll and invalidates on content change") - finally: - browser.close() - - -if __name__ == "__main__": - main() diff --git a/tests/frontend-optimizations-test.mjs b/tests/frontend-optimizations-test.mjs deleted file mode 100644 index 584bdec..0000000 --- a/tests/frontend-optimizations-test.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { performance } from 'node:perf_hooks'; - -const source = readFileSync('static/js/authorship.js', 'utf8'); -const { lineAuthors, parseAuthorship } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64')); - -// Reference implementation from 0.2.89; include it to detect attribution, -// color and tie-breaking regressions independently of the optimized code. -function referenceLineAuthors(content, model) { - const starts = [0]; - for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1); - return starts.map((start, index) => { - const end = index + 1 < starts.length ? starts[index + 1] : content.length; - const totals = new Map(); - const representatives = new Map(); - for (const span of model?.spans || []) { - const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start)); - if (!overlap) continue; - const identity = String(span.owner || '').split('\u001f', 1)[0]; - totals.set(identity, (totals.get(identity) || 0) + overlap); - representatives.set(identity, span.owner); - } - return [...totals.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([identity]) => representatives.get(identity)); - }); -} - -let seed = 42; -function random(n) { seed = (seed * 1664525 + 1013904223) >>> 0; return seed % n; } -const owners = ['A\u001f#ff0000', 'B', 'A\u001f#00ff00', 'C', 'D', '']; -for (let sample = 0; sample < 400; sample++) { - const content = Array.from({ length: 1 + random(65) }, (_, i) => { - const base = ['text', '', '🙂 éół', ' x', '......'][random(5)]; - return base + (i ? '\n' : ''); - }).join(''); - const spans = []; - for (let i = 0; i < 4 + random(100); i++) { - const start = random(content.length + 1); - spans.push({ start, end: start + random(content.length + 4), owner: owners[random(owners.length)] }); - } - const canonical = parseAuthorship(content, { version: 2, spans }); - assert.deepEqual(lineAuthors(content, canonical), referenceLineAuthors(content, canonical), `canonical parity sample ${sample}`); - assert.deepEqual(lineAuthors(content, { spans }), referenceLineAuthors(content, { spans }), `noncanonical parity sample ${sample}`); -} -for (const content of ['', 'a', '\n', '🙂\n\nB\n', 'line1\nline2\n']) { - for (const model of [{ spans: [] }, { spans: [{ start: 0, end: 999, owner: 'one' }] }]) { - assert.deepEqual(lineAuthors(content, model), referenceLineAuthors(content, model)); - } -} - -const n = 6000; -const lines = [], spans = []; -let offset = 0; -for (let i = 0; i < n; i++) { - const line = `row ${String(i).padStart(5, '0')} lorem ipsum\n`; - lines.push(line); - spans.push({ start: offset, end: offset + line.length, owner: `user${i % 37}` }); - offset += line.length; -} -const text = lines.join(''); -const model = { version: 2, spans }; -assert.deepEqual(lineAuthors(text, model), referenceLineAuthors(text, model)); -function medianDuration(fn) { - const ms = []; - for (let i = 0; i < 7; i++) { const start = performance.now(); fn(); ms.push(performance.now() - start); } - ms.sort((a, b) => a - b); - return ms[3]; -} -medianDuration(() => lineAuthors(text, model)); -const oldMs = medianDuration(() => referenceLineAuthors(text, model)); -const newMs = medianDuration(() => lineAuthors(text, model)); -console.log(`PASS: 400 randomized canonical + noncanonical parity cases; 6000 lines, 6000 spans: reference ${oldMs.toFixed(2)} ms; optimized ${newMs.toFixed(2)} ms; ${(oldMs / newMs).toFixed(1)}x`); diff --git a/tests/mention-autocomplete-browser.py b/tests/mention-autocomplete-browser.py deleted file mode 100644 index b05b67d..0000000 --- a/tests/mention-autocomplete-browser.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""Browser checks for @mention suggestions. Run from repository root.""" - -from __future__ import annotations - -from pathlib import Path -from playwright.sync_api import sync_playwright - -ROOT = Path(__file__).resolve().parents[1] -HTML = ''' - -__STYLES__ - -
      -
      - -
      -''' - - -def main(): - source = (ROOT / 'static/js/mention-autocomplete.js').read_text() - source = source.replace('import { t } from "@rustpad/i18n";', 'const t = (key, vars, fallback) => fallback;') - styles = (ROOT / 'static/css/styles.css').read_text() - html = HTML.replace('__STYLES__', f'').replace('__SCRIPT__', source) - with sync_playwright() as playwright: - browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"]) - try: - page = browser.new_page(viewport={"width": 870, "height": 550}) - errors = [] - page.on("pageerror", lambda error: errors.append(str(error))) - page.set_content(html) - page.wait_for_function("!!window.autocomplete") - editor = page.locator('#editor') - options = page.locator('.mention-suggestions__option') - page.evaluate('''() => window.autocomplete.setUsers([ - {nickname:'Dominik',color:'#ff0000'}, - {nickname:'Dora',color:'#00ff00'}, - {nickname:'Jan Domański',color:'#ff8800'}, - {nickname:'Anna',color:'red'} - ])''') - - editor.fill('Witaj @do') - assert options.count() == 3, options.all_text_contents() - assert options.all_text_contents() == ['@Dominik', '@Dora', '@Jan Domański'] - assert editor.get_attribute('aria-expanded') == 'true' - assert options.nth(0).get_attribute('aria-selected') == 'true' - editor.press('ArrowDown') - assert options.nth(1).get_attribute('aria-selected') == 'true' - editor.press('Enter') - assert editor.input_value() == 'Witaj @Dora ' - assert page.evaluate('window.mentionInputTypes.at(-1)') == 'insertReplacementText' - assert page.locator('.mention-suggestions').is_hidden() - - editor.fill('Test @do') - editor.press('ArrowDown') - editor.press('ArrowDown') - editor.press('Tab') - assert editor.input_value() == 'Test @Jan Domański ' - - editor.fill('@d') - assert options.count() == 3 - editor.press('Escape') - assert page.locator('.mention-suggestions').is_hidden() - editor.press('ArrowDown') - assert page.locator('.mention-suggestions').is_hidden(), 'Escape must dismiss the query' - editor.fill('@do') - options.first.click() - assert editor.input_value() == '@Dominik ' - - editor.fill('user@example.org') - assert page.locator('.mention-suggestions').is_hidden(), 'email should not trigger suggestions' - editor.fill('hello @unknown') - assert page.locator('.mention-suggestions').is_hidden(), 'only authorized members should be shown' - editor.fill('Hi @') - assert options.count() == 4 - page.evaluate('window.autocomplete.setUsers([])') - assert page.locator('.mention-suggestions').is_hidden(), 'clear users on logout' - editor.fill('Hi @do') - assert page.locator('.mention-suggestions').is_hidden(), 'no guest suggestions' - - page.evaluate('window.autocomplete.setUsers([{nickname:"Dominik",color:"#ff0000"}])') - editor.fill('Hi @do') - assert options.count() == 1 - geometry = page.locator('.mention-suggestions').bounding_box() - shell_geometry = page.locator('.editor-shell').bounding_box() - assert geometry['x'] >= shell_geometry['x'] - 1 - assert geometry['y'] >= shell_geometry['y'] - 1 - assert geometry['x'] + geometry['width'] <= shell_geometry['x'] + shell_geometry['width'] + 1 - assert geometry['y'] + geometry['height'] <= shell_geometry['y'] + shell_geometry['height'] + 1 - - editor.fill('Edycja @do.') - page.evaluate('''() => {let e = document.querySelector('#editor'); e.setSelectionRange(10,10); e.dispatchEvent(new Event('keyup'));}''') - assert options.count() == 1 - editor.press('Enter') - assert editor.input_value() == 'Edycja @Dominik.' # no unnecessary space before punctuation - - assert not errors, errors - print('PASS: Chromium editor completion, keyboard/mouse, privacy, positioning, punctuation') - finally: - browser.close() - - -if __name__ == '__main__': - main() diff --git a/tests/mention-autocomplete-test.mjs b/tests/mention-autocomplete-test.mjs deleted file mode 100644 index c831b0e..0000000 --- a/tests/mention-autocomplete-test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -let source = readFileSync('static/js/mention-autocomplete.js', 'utf8'); -source = source.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, vars, fallback) => fallback;'); -const { mentionAtCaret, filterMentionUsers, normalizeMentionUsers } = await import( - 'data:text/javascript;base64,' + Buffer.from(source).toString('base64') -); - -assert.deepEqual(mentionAtCaret('Cześć @do', 9), { start: 6, end: 9, query: 'do' }); -assert.deepEqual(mentionAtCaret('@', 1), { start: 0, end: 1, query: '' }); -assert.deepEqual(mentionAtCaret('Pierwsza linia\nWitaj (@Żół', 26), { start: 22, end: 26, query: 'Żół' }); -assert.equal(mentionAtCaret('john@example.org', 12), null, 'do not suggest in addresses'); -assert.equal(mentionAtCaret('abc@do', 6), null, 'no mention inside a word'); -assert.equal(mentionAtCaret('Hello @do ', 10), null, 'space ends a query'); -assert.equal(mentionAtCaret('Hello @do-more', 14)?.query, 'do-more', 'support dashes'); -assert.equal(mentionAtCaret('Hello @@do', 10), null, 'double at is not a mention'); -assert.equal(mentionAtCaret('Hello @do', -1), null); - -const members = normalizeMentionUsers([ - { nickname: 'Dominik', color: '#ff0000', email: 'secret@example.org' }, - { nickname: 'DOMINIK', color: '#aaaaaa' }, - { nickname: 'Dora', color: 'red' }, - { nickname: 'Jan Domański', color: '#00ff00' }, - { nickname: '', color: '#00ff00' }, - { nickname: 'Joanna', color: '#123abc' }, -]); -assert.equal(members.length, 4, 'deduplicate case insensitive, skip empty'); -assert.deepEqual(filterMentionUsers(members, 'do').map(user => user.nickname), ['Dominik', 'Dora', 'Jan Domański']); -assert.equal(filterMentionUsers(members, 'DO')[0].nickname, 'Dominik'); -assert.equal(filterMentionUsers(members, 'zz').length, 0); -assert.equal(members[0].color, '#ff0000'); -assert.match(members[1].color, /^hsl\(/, 'invalid user color falls back safely'); -assert.equal(Object.hasOwn(members[0], 'email'), false, 'no email addresses in UI model'); -console.log('PASS: mention context, filtering, normalization, privacy'); diff --git a/tests/mention-navigation-browser.py b/tests/mention-navigation-browser.py deleted file mode 100644 index 6444fbc..0000000 --- a/tests/mention-navigation-browser.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -"""Browser integration test for the mentioned-users bar and cyclic jumps.""" - -from __future__ import annotations - -import base64 -from pathlib import Path -from playwright.sync_api import sync_playwright - -ROOT = Path(__file__).resolve().parents[1] - -HTML = ''' - - - -
      -
      -
      Editor
      -
      Autor
      - -
      -
      -
      -
      Markdown preview
      - -
      -
      -
      -''' - - -def as_data_url(source): - return 'data:text/javascript;base64,' + base64.b64encode(source.encode('utf-8')).decode('ascii') - - -def create_fixture(): - autocomplete = (ROOT / 'static/js/mention-autocomplete.js').read_text() - autocomplete = autocomplete.replace('import { t } from "@rustpad/i18n";', - 'const t = (key, values, fallback) => fallback;') - navigation = (ROOT / 'static/js/mention-navigation.js').read_text() - navigation = navigation.replace('import { t } from "@rustpad/i18n";', - 'const t = (key, values, fallback) => fallback;') - navigation = navigation.replace('"@rustpad/mention-autocomplete"', - f'"{as_data_url(autocomplete)}"') - markdown = (ROOT / 'static/js/markdown.js').read_text() - markdown = '\n'.join(line for line in markdown.split('\n') if not line.startswith('import ')) - markdown = ('const EMOJI_SHORTCODES = {}; ' - 'const t = (key, values, fallback) => fallback; ' - 'const parseImageAlias = () => null;\n') + markdown - styles = (ROOT / 'static/css/styles.css').read_text() - return (HTML.replace('__STYLES__', styles) - .replace('__NAVIGATION_URL__', as_data_url(navigation)) - .replace('__MARKDOWN_URL__', as_data_url(markdown))) - - -def main(): - fixture = create_fixture() - with sync_playwright() as playwright: - browser = playwright.chromium.launch(executable_path='/usr/bin/chromium', args=['--no-sandbox']) - try: - page = browser.new_page(viewport={'width': 1020, 'height': 680}) - errors = [] - page.on('pageerror', lambda error: errors.append(str(error))) - page.set_content(fixture) - page.wait_for_function('window.ready === true') - lines = [ - 'Na początku @Dominik i @Dominik, do tego @Dora.', - '`@Dominik` [@Dominik](https://example.com/@Dora)', - '```sh', - '@Dominik', - '```', - *[f'Wypełnienie linii {i}' for i in range(1, 18)], - '
      ', - 'Szczegóły', - 'Wewnątrz @Dominik', - '
      ', - *[f'Treść linii {i}' for i in range(18, 38)], - 'Na końcu @Dominik.', - ] - content = '\n'.join(lines) - users = [{'nickname': 'Dominik', 'color': '#123456'}, {'nickname':'Dora', 'color':'#654321', 'email':'private@example.org'}] - page.evaluate('([text,users]) => window.runCase(text,users)', [content, users]) - bar = page.locator('#mentioned-users') - assert bar.is_visible() - badges = page.locator('#mentioned-users-list .mentioned-users__badge') - preview_badges = page.locator('#mentioned-users-preview-list .mentioned-users__badge') - assert page.locator('#mentioned-users-preview').is_hidden() - assert badges.count() == 2 - assert preview_badges.count() == 2 - assert badges.all_text_contents() == ['@Dominik4', '@Dora1'] - assert 'private' not in bar.inner_text() - button = badges.first - # The two code/link occurrences are intentionally skipped. - source_starts = [content.index('@Dominik'),content.index('@Dominik',content.index('@Dominik')+1),content.index('@Dominik',content.index('Wewnątrz')), content.rindex('@Dominik')] - for step, start in enumerate(source_starts, 1): - button.click() - assert page.evaluate('document.querySelector("#editor").selectionStart') == start, step - assert page.evaluate('document.querySelector("#editor").selectionEnd') == start + len('@Dominik') - assert page.locator('.markdown-mention.is-mention-target').count() == 1 - assert button.locator('.mentioned-users__count').inner_text() == f'{step}/4' - assert page.locator('details.markdown-details').evaluate('element => element.open') is True - button.click() - assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[0] - assert button.locator('.mentioned-users__count').inner_text() == '1/4' - dora = badges.nth(1) - dora.click() - assert page.evaluate('document.querySelector("#editor").selectionStart') == content.index('@Dora') - assert dora.locator('.mentioned-users__count').inner_text() == '1/1' - page.evaluate('window.setView("preview")') - assert bar.is_hidden() - assert page.locator('#mentioned-users-preview').is_visible() - assert page.locator('#preview').is_visible() - original_selection = page.evaluate('document.querySelector("#editor").selectionStart') - preview_badges.first.click() - assert page.evaluate('document.querySelector("#editor").selectionStart') == original_selection - assert page.locator('.markdown-mention.is-mention-target').count() == 1 - page.evaluate('window.setView("edit")') - assert bar.is_visible() - assert page.locator('#mentioned-users-preview').is_hidden() - button.click() - assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[2] - page.evaluate('window.setUsers([])') - assert bar.is_hidden() - assert page.locator('#mentioned-users-preview').is_hidden() - assert badges.count() == 0 - assert preview_badges.count() == 0 - assert not errors, errors - print('PASS: Chromium mentions list, access, cyclic navigation, source selection, details, view switching') - finally: - browser.close() - - -if __name__ == '__main__': - main() diff --git a/tests/mention-navigation-test.mjs b/tests/mention-navigation-test.mjs deleted file mode 100644 index d426331..0000000 --- a/tests/mention-navigation-test.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -const autocomplete = readFileSync('static/js/mention-autocomplete.js', 'utf8') - .replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;'); -const autocompleteModule = await import('data:text/javascript;base64,' + Buffer.from(autocomplete).toString('base64')); -const navigation = readFileSync('static/js/mention-navigation.js', 'utf8') - .replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;') - .replace(/^import \{ normalizeMentionUsers \} from "@rustpad\/mention-autocomplete";$/m, - `const normalizeMentionUsers = ${autocompleteModule.normalizeMentionUsers.toString()};`); -const { collectMentionOccurrences } = await import('data:text/javascript;base64,' + Buffer.from(navigation).toString('base64')); - -const users = [ - {nickname:'Dominik',color:'#1256aa',email:'private@example.org'}, - {nickname:'dOMINIK',color:'#abc123'}, - {nickname:'Dora',color:'#dd00dd'}, - {nickname:'Ala+Dev',color:'#123abc'}, -]; -function matches(source, options) { return collectMentionOccurrences(source, users, options); } -function valid(source, options) { return matches(source, options).map(mention => source.slice(mention.start, mention.end)); } -assert.deepEqual(valid('Tu @Dominik, @Dora oraz @dominik.'), ['@Dominik', '@Dora', '@dominik']); -assert.deepEqual(valid('Przykład @DominiK @Ala+Dev, @Dominik'), ['@DominiK', '@Ala+Dev', '@Dominik']); -assert.deepEqual(valid('john@Dominik.org a@Dora @@Dominik @Dominika @Other'), []); -assert.deepEqual(valid('`@Dominik` i @Dora [@Dominik](https://example.org) oraz https://example.org/@Dora'), ['@Dora']); -assert.deepEqual(valid('[file=secret@Dominik,key] i [image=image@Dora] oraz @Dominik'), ['@Dominik']); -assert.deepEqual(valid('```sh\n@Dominik\n```\nPo @Dora\n~~~\n@Dora\n~~~\n@Dominik'), ['@Dora', '@Dominik']); -assert.deepEqual(valid(' ```js\n@Dominik\n ```\n@Dominik'), ['@Dominik']); -assert.deepEqual(valid('````rust\n@Dominik\n```\n@Dora\n````\n@Dora'), ['@Dora']); -assert.deepEqual(valid('@Dora', { markdown:false }), ['@Dora']); -assert.deepEqual(valid('```\n@Dominik\n```', { markdown:false }), ['@Dominik']); -const source = '🙂 @Dominik\nDrugi @Dora i @Dominik'; -const items = matches(source); -assert.deepEqual(items.map(item=>item.start), [source.indexOf('@Dominik'),source.indexOf('@Dora'),source.lastIndexOf('@Dominik')]); -assert.deepEqual(items.map(item=>item.sourceLine), [1,2,2]); -assert.ok(items.every(item => !Object.hasOwn(item, 'email'))); -assert.equal(collectMentionOccurrences('Hej @Dominik', []).length, 0); -console.log('PASS: mention navigation positions, permissions, Markdown exclusions, UTF-16, code fences'); diff --git a/tests/random_data.py b/tests/random_data.py deleted file mode 100755 index 05e8cf9..0000000 --- a/tests/random_data.py +++ /dev/null @@ -1,829 +0,0 @@ -#!/usr/bin/env python3 -"""Populate RustPad through its HTTP API with generated test data.""" - -from __future__ import annotations - -import argparse -import concurrent.futures -import getpass -from html.parser import HTMLParser -import json -import mimetypes -import os -import random -import ssl -import string -import sys -import threading -import time -from dataclasses import dataclass -from http.cookies import SimpleCookie -from typing import Any, Iterable -from urllib.error import HTTPError, URLError -from urllib.parse import quote, urljoin, urlparse -from urllib.request import HTTPSHandler, Request, build_opener - -UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} -WIKIPEDIA_RANDOM_URL = "https://en.wikipedia.org/wiki/Special:Random" -MAX_SOURCE_BYTES = 1_500_000 -MAX_DOCUMENT_BYTES = 1_800_000 -MAX_WIKIPEDIA_ATTACHMENT_BYTES = 5_000_000 -WIKIMEDIA_IMAGE_HOST_SUFFIX = ".wikimedia.org" -WIKIPEDIA_IMAGE_EXTENSIONS = (".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp") -WIKIPEDIA_IMAGE_MIME_TYPES = { - "image/avif", - "image/bmp", - "image/gif", - "image/jpeg", - "image/png", - "image/webp", -} - - -class ApiFailure(RuntimeError): - def __init__(self, status: int, message: str, path: str) -> None: - super().__init__(f"{status} {path}: {message}") - self.status = status - self.message = message - self.path = path - - -class RustPadClient: - def __init__( - self, - base_url: str, - *, - timeout: float, - retries: int, - insecure: bool, - ) -> None: - self.base_url = base_url.rstrip("/") + "/" - self.timeout = timeout - self.retries = retries - self.cookies: dict[str, str] = {} - self.csrf_token: str | None = None - self.lock = threading.RLock() - self.context = ssl._create_unverified_context() if insecure else ssl.create_default_context() - self.local = threading.local() - - def _opener(self) -> Any: - opener = getattr(self.local, "opener", None) - if opener is None: - opener = build_opener(HTTPSHandler(context=self.context)) - self.local.opener = opener - return opener - - def _cookie_header(self) -> str: - with self.lock: - return "; ".join(f"{name}={value}" for name, value in self.cookies.items()) - - def _store_cookies(self, headers: Any) -> None: - values = headers.get_all("Set-Cookie") or [] - if not values: - return - with self.lock: - for raw in values: - parsed = SimpleCookie() - parsed.load(raw) - for name, morsel in parsed.items(): - if morsel["max-age"] == "0" or not morsel.value: - self.cookies.pop(name, None) - else: - self.cookies[name] = morsel.value - - def refresh_csrf(self) -> str: - with self.lock: - data = self.request("GET", "/api/security/csrf", retry_csrf=False) - token = str(data.get("token") or "") - if not token: - raise RuntimeError("The server did not return a CSRF token.") - self.csrf_token = token - return token - - def _request_bytes( - self, - method: str, - path: str, - body: bytes | None, - content_type: str | None, - *, - retry_csrf: bool = True, - ) -> dict[str, Any]: - method = method.upper() - url = urljoin(self.base_url, path.lstrip("/")) - - for attempt in range(self.retries + 1): - headers = { - "Accept": "application/json", - "User-Agent": "RustPad-random-data/1.0", - } - if content_type: - headers["Content-Type"] = content_type - cookie = self._cookie_header() - if cookie: - headers["Cookie"] = cookie - if method in UNSAFE_METHODS: - if not self.csrf_token: - self.refresh_csrf() - headers["X-Rustpad-CSRF"] = self.csrf_token or "" - - request = Request(url, data=body, headers=headers, method=method) - try: - with self._opener().open(request, timeout=self.timeout) as response: - self._store_cookies(response.headers) - raw = response.read() - if not raw: - return {} - return json.loads(raw.decode("utf-8")) - except HTTPError as error: - self._store_cookies(error.headers) - raw = error.read() - try: - data = json.loads(raw.decode("utf-8")) if raw else {} - except (UnicodeDecodeError, json.JSONDecodeError): - data = {} - message = str(data.get("error") or error.reason or "Request failed") - if ( - error.code == 403 - and retry_csrf - and "security token" in message.lower() - ): - with self.lock: - self.csrf_token = None - self.refresh_csrf() - return self._request_bytes( - method, - path, - body, - content_type, - retry_csrf=False, - ) - if error.code in {429, 500, 502, 503, 504} and attempt < self.retries: - retry_after = error.headers.get("Retry-After") - try: - delay = float(retry_after) if retry_after else min(10.0, 0.5 * (2**attempt)) - except ValueError: - delay = min(10.0, 0.5 * (2**attempt)) - time.sleep(delay + random.random() * 0.25) - continue - raise ApiFailure(error.code, message, path) from error - except (URLError, TimeoutError, json.JSONDecodeError) as error: - if attempt < self.retries: - time.sleep(min(10.0, 0.5 * (2**attempt)) + random.random() * 0.25) - continue - raise RuntimeError(f"Request to {path} failed: {error}") from error - - raise RuntimeError(f"Request to {path} failed after retries.") - - def request( - self, - method: str, - path: str, - payload: dict[str, Any] | None = None, - *, - retry_csrf: bool = True, - ) -> dict[str, Any]: - body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") - content_type = "application/json" if body is not None else None - return self._request_bytes( - method, - path, - body, - content_type, - retry_csrf=retry_csrf, - ) - - def upload_multipart( - self, - path: str, - *, - filename: str, - data: bytes, - mime_type: str, - ) -> dict[str, Any]: - boundary = f"rustpad-{''.join(random.choices(string.ascii_letters + string.digits, k=32))}" - chunks = [ - f"--{boundary}\r\n".encode("ascii"), - b'Content-Disposition: form-data; name="access_token"\r\n\r\n', - b"\r\n", - f"--{boundary}\r\n".encode("ascii"), - ( - "Content-Disposition: form-data; name=\"file\"; " - f"filename=\"{filename.replace(chr(34), '_')}\"\r\n" - ).encode("utf-8"), - f"Content-Type: {mime_type}\r\n\r\n".encode("ascii"), - data, - b"\r\n", - f"--{boundary}--\r\n".encode("ascii"), - ] - return self._request_bytes( - "POST", - path, - b"".join(chunks), - f"multipart/form-data; boundary={boundary}", - ) - - def login(self, user: str, password: str) -> dict[str, Any]: - self.refresh_csrf() - session = self.request( - "POST", - "/api/auth/login", - {"email": user, "password": password}, - ) - verified = self.request("GET", "/api/auth/me") - return verified or session - - def create_pad(self, name: str, content: str) -> dict[str, Any]: - return self.request("POST", "/api/pads", {"name": name, "content": content}) - - def create_workspace(self, name: str) -> dict[str, Any]: - return self.request("POST", "/api/workspaces", {"name": name}) - - def create_workspace_note(self, workspace_slug: str, name: str, content: str) -> dict[str, Any]: - return self.request( - "POST", - f"/api/workspaces/{quote(workspace_slug, safe='')}/notes", - {"name": name, "content": content}, - ) - - def upload_pad_attachment(self, pad_slug: str, attachment: SourceAttachment) -> dict[str, Any]: - return self.upload_multipart( - f"/api/pads/{quote(pad_slug, safe='')}/files", - filename=attachment.filename, - data=attachment.data, - mime_type=attachment.mime_type, - ) - - def upload_workspace_note_attachment( - self, - workspace_slug: str, - note_slug: str, - attachment: SourceAttachment, - ) -> dict[str, Any]: - return self.upload_multipart( - ( - f"/api/workspaces/{quote(workspace_slug, safe='')}/notes/" - f"{quote(note_slug, safe='')}/files" - ), - filename=attachment.filename, - data=attachment.data, - mime_type=attachment.mime_type, - ) - - -class ReadableHtmlParser(HTMLParser): - BLOCK_TAGS = {"h1", "h2", "h3", "h4", "p", "li", "blockquote", "pre", "br"} - SKIP_TAGS = {"script", "style", "svg", "noscript", "nav", "footer"} - - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.parts: list[str] = [] - self.skip_depth = 0 - self.title = "Wikipedia snapshot" - self.in_title = False - self.images: list[tuple[str, str, int | None, int | None]] = [] - - @staticmethod - def _dimension(value: str | None) -> int | None: - if not value: - return None - try: - return int(float(value)) - except ValueError: - return None - - @staticmethod - def _srcset_url(value: str | None) -> str: - candidates = [] - for item in str(value or "").split(","): - parts = item.strip().split() - if not parts: - continue - descriptor = parts[1] if len(parts) > 1 else "1x" - try: - weight = float(descriptor.removesuffix("w").removesuffix("x")) - except ValueError: - weight = 1.0 - candidates.append((weight, parts[0])) - if not candidates: - return "" - candidates.sort() - for weight, url in candidates: - if weight >= 640: - return url - return candidates[-1][1] - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - attributes = {name.lower(): value for name, value in attrs} - if tag in self.SKIP_TAGS: - self.skip_depth += 1 - if tag == "title": - self.in_title = True - if tag == "img" and not self.skip_depth: - src = str(attributes.get("data-src") or attributes.get("src") or "") - src = src or self._srcset_url(attributes.get("srcset")) - if src: - alt = " ".join(str(attributes.get("alt") or "Wikipedia image").split()) - self.images.append((src, alt, self._dimension(attributes.get("width")), self._dimension(attributes.get("height")))) - if not self.skip_depth and tag in self.BLOCK_TAGS: - self.parts.append("\n") - if tag == "li": - self.parts.append("- ") - - def handle_endtag(self, tag: str) -> None: - if tag == "title": - self.in_title = False - if tag in self.SKIP_TAGS and self.skip_depth: - self.skip_depth -= 1 - if not self.skip_depth and tag in self.BLOCK_TAGS: - self.parts.append("\n") - - def handle_data(self, data: str) -> None: - if self.skip_depth: - return - text = " ".join(data.split()) - if not text: - return - if self.in_title: - self.title = text.removesuffix(" - Wikipedia") - return - self.parts.append(text + " ") - - def wikipedia_images(self, source_url: str, limit: int) -> list[tuple[str, str]]: - result: list[tuple[str, str]] = [] - seen: set[str] = set() - for raw_url, alt, width, height in self.images: - url = urljoin(source_url, raw_url) - parsed = urlparse(url) - host = parsed.hostname or "" - path = parsed.path.lower() - if parsed.scheme != "https" or not host.endswith(WIKIMEDIA_IMAGE_HOST_SUFFIX): - continue - if not path.endswith(WIKIPEDIA_IMAGE_EXTENSIONS): - continue - if (width is not None and width < 160) or (height is not None and height < 120): - continue - if any(fragment in path for fragment in ("/icons/", "wikimedia-button", "poweredby_mediawiki", "commons-logo")): - continue - if url in seen: - continue - seen.add(url) - safe_alt = alt.replace("[", "(").replace("]", ")").replace("\n", " ").strip() - result.append((url, safe_alt or "Wikipedia image")) - if len(result) >= limit: - break - return result - - def markdown(self, source_url: str, attachments: tuple[SourceAttachment, ...]) -> str: - lines = [" ".join(line.split()) for line in "".join(self.parts).splitlines()] - lines = [line for line in lines if line] - content = "\n\n".join(lines[:350]) - image_markdown = "\n\n".join( - f"[image={attachment.filename},{attachment.label}]" - for attachment in attachments - ) - return ( - f"# {self.title}\n\n" - f"> Test-data snapshot from Wikipedia. Source: {source_url}\n\n" - f"{image_markdown}\n\n" - f"{content}\n" - ) - - -@dataclass(frozen=True) -class SourceAttachment: - filename: str - data: bytes - mime_type: str - label: str - - -@dataclass(frozen=True) -class SourceDocument: - title: str - content: str - attachments: tuple[SourceAttachment, ...] = () - - -class SourcePool: - def __init__(self, documents: list[SourceDocument], seed: int | None) -> None: - self.documents = documents - self.random = random.Random(seed) - self.lock = threading.Lock() - - def for_item(self, index: int, label: str) -> SourceDocument: - with self.lock: - source = self.random.choice(self.documents) - suffix = "".join(self.random.choices(string.ascii_lowercase + string.digits, k=8)) - title = f"{label} {index:06d} {suffix}" - content = ( - f"{source.content.rstrip()}\n\n" - f"---\n\nLoad-test item: `{label}-{index:06d}-{suffix}`\n" - ) - encoded = content.encode("utf-8") - if len(encoded) > MAX_DOCUMENT_BYTES: - content = encoded[:MAX_DOCUMENT_BYTES].decode("utf-8", errors="ignore") - return SourceDocument( - title=title[:80], - content=content, - attachments=source.attachments, - ) - - -def generated_document(index: int) -> SourceDocument: - rng = random.Random(index * 7919 + 17) - words = [ - "architecture", "latency", "workspace", "revision", "markdown", "session", - "security", "collaboration", "storage", "deployment", "monitoring", "testing", - ] - paragraphs = [] - for paragraph_index in range(8): - sentence_words = [rng.choice(words) for _ in range(rng.randint(35, 70))] - paragraphs.append(" ".join(sentence_words).capitalize() + ".") - content = ( - f"# Generated document {index}\n\n" - f"- [ ] Validate record {index}\n" - f"- [x] Generate deterministic content\n" - f"- [ ] Review WebSocket diagnostics\n\n" - + "\n\n".join(paragraphs) - + f"\n\n```json\n{{\"index\": {index}, \"seed\": {rng.randint(1, 999999)}}}\n```\n" - ) - return SourceDocument(title=f"Generated source {index}", content=content) - - -def image_extension(url: str, mime_type: str) -> str: - mapping = { - "image/avif": ".avif", - "image/bmp": ".bmp", - "image/gif": ".gif", - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - } - if mime_type in mapping: - return mapping[mime_type] - path_extension = os.path.splitext(urlparse(url).path)[1].lower() - if path_extension in WIKIPEDIA_IMAGE_EXTENSIONS: - return ".jpg" if path_extension == ".jpeg" else path_extension - return mimetypes.guess_extension(mime_type) or ".bin" - - -def download_wikipedia_attachment( - opener: Any, - url: str, - label: str, - index: int, - timeout: float, -) -> SourceAttachment: - request = Request( - url, - headers={ - "Accept": "image/avif,image/webp,image/png,image/jpeg,image/gif,image/bmp;q=0.9,*/*;q=0.1", - "User-Agent": "RustPad-random-data/1.2 (test data generator)", - }, - ) - with opener.open(request, timeout=timeout) as response: - content_length = response.headers.get("Content-Length") - if content_length: - try: - if int(content_length) > MAX_WIKIPEDIA_ATTACHMENT_BYTES: - raise RuntimeError(f"Wikipedia image exceeds {MAX_WIKIPEDIA_ATTACHMENT_BYTES} bytes") - except ValueError: - pass - mime_type = str(response.headers.get_content_type() or "application/octet-stream").lower() - if mime_type not in WIKIPEDIA_IMAGE_MIME_TYPES: - raise RuntimeError(f"Unsupported Wikipedia image type: {mime_type}") - data = response.read(MAX_WIKIPEDIA_ATTACHMENT_BYTES + 1) - if len(data) > MAX_WIKIPEDIA_ATTACHMENT_BYTES: - raise RuntimeError(f"Wikipedia image exceeds {MAX_WIKIPEDIA_ATTACHMENT_BYTES} bytes") - if not data: - raise RuntimeError("Wikipedia image is empty") - extension = image_extension(url, mime_type) - filename = f"wikipedia-{index:02d}{extension}" - safe_label = label.replace("]", ")").replace("\r", " ").replace("\n", " ").strip() - return SourceAttachment( - filename=filename, - data=data, - mime_type=mime_type, - label=safe_label or f"Wikipedia image {index}", - ) - - -def fetch_wikipedia_snapshot( - index: int, - timeout: float, - insecure: bool, - image_limit: int, - attempts: int, -) -> SourceDocument: - context = ssl._create_unverified_context() if insecure else ssl.create_default_context() - opener = build_opener(HTTPSHandler(context=context)) - last_error: Exception | None = None - for attempt in range(1, attempts + 1): - request = Request( - WIKIPEDIA_RANDOM_URL, - headers={"User-Agent": "RustPad-random-data/1.1 (test data generator)"}, - ) - try: - with opener.open(request, timeout=timeout) as response: - raw = response.read(MAX_SOURCE_BYTES) - source_url = response.geturl() - charset = response.headers.get_content_charset() or "utf-8" - parser = ReadableHtmlParser() - parser.feed(raw.decode(charset, errors="replace")) - image_candidates = parser.wikipedia_images(source_url, image_limit * 3) - if not image_candidates: - last_error = RuntimeError(f"Wikipedia page had no usable images (attempt {attempt}/{attempts})") - continue - attachments: list[SourceAttachment] = [] - for image_url, label in image_candidates: - try: - attachments.append( - download_wikipedia_attachment( - opener, - image_url, - label, - len(attachments) + 1, - timeout, - ) - ) - except Exception as error: # noqa: BLE001 - another candidate may still work. - last_error = error - continue - if len(attachments) >= image_limit: - break - if not attachments: - last_error = RuntimeError( - f"Wikipedia page images could not be downloaded (attempt {attempt}/{attempts}): {last_error}" - ) - continue - attachment_tuple = tuple(attachments) - content = parser.markdown(source_url, attachment_tuple) - return SourceDocument( - title=parser.title or f"Wikipedia {index}", - content=content, - attachments=attachment_tuple, - ) - except Exception as error: # noqa: BLE001 - retries cover transient Wikipedia failures. - last_error = error - raise RuntimeError(f"Could not fetch a Wikipedia article with images: {last_error}") - - -def build_source_pool(args: argparse.Namespace, total_items: int) -> SourcePool: - pool_size = max(1, min(args.source_pool_size, max(1, total_items))) - if args.source == "generated": - return SourcePool([generated_document(index) for index in range(pool_size)], args.seed) - - documents: list[SourceDocument] = [] - failures = 0 - with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.source_workers, pool_size)) as executor: - futures = [ - executor.submit( - fetch_wikipedia_snapshot, - index, - args.timeout, - args.insecure, - args.wikipedia_images, - args.wikipedia_attempts, - ) - for index in range(pool_size) - ] - for index, future in enumerate(concurrent.futures.as_completed(futures), start=1): - try: - documents.append(future.result()) - print(f"Source snapshots: {index}/{pool_size}", end="\r", flush=True) - except Exception as error: # noqa: BLE001 - all source failures are reported below. - failures += 1 - print(f"\nWikipedia source failed: {error}", file=sys.stderr) - print() - if not documents: - raise RuntimeError("No Wikipedia snapshot with a usable image could be downloaded.") - if failures: - print( - f"Using {len(documents)} Wikipedia snapshots; {failures} source downloads failed. " - "No generated fallback was added.", - file=sys.stderr, - ) - return SourcePool(documents, args.seed) - - -class Progress: - def __init__(self, total: int) -> None: - self.total = total - self.completed = 0 - self.failed = 0 - self.started = time.monotonic() - self.lock = threading.Lock() - - def record(self, success: bool) -> None: - with self.lock: - self.completed += 1 - if not success: - self.failed += 1 - if self.completed == self.total or self.completed % 100 == 0: - elapsed = max(0.001, time.monotonic() - self.started) - rate = self.completed / elapsed - print( - f"Created: {self.completed}/{self.total} | failures: {self.failed} | {rate:.1f}/s", - flush=True, - ) - - -def execute_tasks( - tasks: Iterable[tuple[str, str, str | None, int]], - *, - client: RustPadClient, - sources: SourcePool, - workers: int, - total: int, -) -> tuple[list[str], int]: - progress = Progress(total) - failures: list[str] = [] - failures_lock = threading.Lock() - - def run(task: tuple[str, str, str | None, int]) -> None: - kind, label, workspace_slug, index = task - document = sources.for_item(index, label) - try: - if kind == "pad": - created = client.create_pad(document.title, document.content) - pad_slug = str(created.get("slug") or "") - if not pad_slug: - raise RuntimeError("Created pad response did not contain a slug.") - for attachment in document.attachments: - client.upload_pad_attachment(pad_slug, attachment) - else: - if not workspace_slug: - raise RuntimeError("Workspace slug is missing.") - created = client.create_workspace_note(workspace_slug, document.title, document.content) - note_slug = str(created.get("slug") or "") - if not note_slug: - raise RuntimeError("Created workspace note response did not contain a slug.") - for attachment in document.attachments: - client.upload_workspace_note_attachment( - workspace_slug, - note_slug, - attachment, - ) - progress.record(True) - except Exception as error: # noqa: BLE001 - all failures are reported after the run. - with failures_lock: - if len(failures) < 50: - failures.append(str(error)) - progress.record(False) - - with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: - list(executor.map(run, tasks, chunksize=1)) - return failures, progress.failed - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Create standalone notes, workspaces and workspace notes through the RustPad API.", - ) - parser.add_argument("--ip", default="localhost", help="RustPad host name or IP address") - parser.add_argument("--port", type=int, default=3000, help="RustPad port") - parser.add_argument("--scheme", choices=("http", "https"), default="http") - parser.add_argument("--base-url", help="Complete base URL; overrides --ip, --port and --scheme") - parser.add_argument("--source", choices=("generated", "wikipedia"), default="generated") - parser.add_argument("--notes", type=int, default=0, help="Total number of notes to create") - parser.add_argument("--workspaces", type=int, default=0, help="Number of workspaces") - parser.add_argument( - "--notes-in-workspaces", - type=int, - default=0, - help="Number of notes from --notes distributed across all workspaces", - ) - parser.add_argument("--user", required=True, help="Login e-mail or LDAP/AD username") - parser.add_argument("--password", help="Account password; otherwise RUSTPAD_TEST_PASSWORD or a prompt is used") - parser.add_argument("--workers", type=int, default=12, help="Concurrent API requests") - parser.add_argument("--source-workers", type=int, default=6, help="Concurrent website downloads") - parser.add_argument("--source-pool-size", type=int, default=40, help="Website/generated source documents reused by test items") - parser.add_argument( - "--wikipedia-images", - type=int, - default=3, - help="Maximum Wikipedia images downloaded and uploaded as attachments to each note", - ) - parser.add_argument("--wikipedia-attempts", type=int, default=8, help="Random articles tried when a Wikipedia page has no usable image") - parser.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout in seconds") - parser.add_argument("--retries", type=int, default=5, help="Retries for transient API errors") - parser.add_argument("--seed", type=int, help="Deterministic random seed") - parser.add_argument("--prefix", default="Load test", help="Workspace name prefix") - parser.add_argument("--insecure", action="store_true", help="Disable TLS certificate verification") - parser.add_argument("--dry-run", action="store_true", help="Print counts without writing data") - args = parser.parse_args() - - for name in ("notes", "workspaces", "notes_in_workspaces"): - if getattr(args, name) < 0: - parser.error(f"--{name.replace('_', '-')} cannot be negative") - if args.workers < 1 or args.source_workers < 1 or args.source_pool_size < 1: - parser.error("worker and source-pool values must be positive") - if not 1 <= args.wikipedia_images <= 10: - parser.error("--wikipedia-images must be between 1 and 10") - if args.wikipedia_attempts < 1: - parser.error("--wikipedia-attempts must be positive") - if args.notes_in_workspaces > args.notes: - parser.error("--notes-in-workspaces cannot exceed --notes") - if args.notes_in_workspaces and not args.workspaces: - parser.error("--workspaces must be positive when --notes-in-workspaces is used") - return args - - -def base_url(args: argparse.Namespace) -> str: - if args.base_url: - return args.base_url.rstrip("/") - host = f"[{args.ip}]" if ":" in args.ip and not args.ip.startswith("[") else args.ip - return f"{args.scheme}://{host}:{args.port}" - - -def resolve_password(args: argparse.Namespace) -> str: - password = args.password or os.environ.get("RUSTPAD_TEST_PASSWORD") - if password: - return password - if not sys.stdin.isatty(): - raise RuntimeError("Set --password or RUSTPAD_TEST_PASSWORD when stdin is not interactive.") - return getpass.getpass("RustPad password: ") - - -def main() -> int: - args = parse_args() - random.seed(args.seed) - workspace_note_total = args.notes_in_workspaces - standalone_note_total = args.notes - workspace_note_total - item_total = args.notes - per_workspace = [] - if args.workspaces: - base_count, remainder = divmod(workspace_note_total, args.workspaces) - per_workspace = [ - base_count + (1 if index < remainder else 0) - for index in range(args.workspaces) - ] - print( - f"Target: {args.notes} notes total: {standalone_note_total} standalone and " - f"{workspace_note_total} across {args.workspaces} workspaces." - ) - if per_workspace: - minimum = min(per_workspace) - maximum = max(per_workspace) - distribution = str(minimum) if minimum == maximum else f"{minimum}-{maximum}" - print(f"Workspace distribution: {distribution} notes per workspace.") - if args.dry_run: - return 0 - if item_total == 0 and args.workspaces == 0: - print("Nothing to create.") - return 0 - - client = RustPadClient( - base_url(args), - timeout=args.timeout, - retries=args.retries, - insecure=args.insecure, - ) - session = client.login(args.user, resolve_password(args)) - print(f"Logged in as {session.get('nickname') or args.user}.") - - sources = build_source_pool(args, max(1, item_total)) - - workspace_slugs: list[str] = [] - for index in range(1, args.workspaces + 1): - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - name = f"{args.prefix} workspace {index:04d} {suffix}"[:80] - workspace = client.create_workspace(name) - workspace_slugs.append(str(workspace["slug"])) - print(f"Workspaces: {index}/{args.workspaces}", end="\r", flush=True) - if args.workspaces: - print() - - tasks: list[tuple[str, str, str | None, int]] = [] - for index in range(1, standalone_note_total + 1): - tasks.append(("pad", f"Standalone note", None, index)) - absolute_index = standalone_note_total - for workspace_index, (slug, note_count) in enumerate( - zip(workspace_slugs, per_workspace, strict=True), - start=1, - ): - for _note_index in range(1, note_count + 1): - absolute_index += 1 - tasks.append(("workspace-note", f"Workspace {workspace_index:04d} note", slug, absolute_index)) - - failures, failed_count = execute_tasks( - tasks, - client=client, - sources=sources, - workers=args.workers, - total=len(tasks), - ) if tasks else ([], 0) - - if failed_count: - print(f"Completed with {failed_count} failures ({len(failures)} shown):", file=sys.stderr) - for failure in failures: - print(f"- {failure}", file=sys.stderr) - return 1 - - print("Data generation completed successfully.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())