diff --git a/Cargo.lock b/Cargo.lock index 72f7eec..5dcd93d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.8" +version = "0.2.9" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index afc698e..7c9fd7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.8" +version = "0.2.9" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/docker/example_files_vhost.conf b/docker/example_files_vhost.conf index b1c6008..37b15ef 100644 --- a/docker/example_files_vhost.conf +++ b/docker/example_files_vhost.conf @@ -22,7 +22,6 @@ server { return 404 "Not Found\n"; } - # Wszystko poza /f/ nie trafia do RustPada location / { default_type text/plain; return 404 "Not Found\n"; diff --git a/docker/example_hahproxy_files.conf b/docker/example_hahproxy_files.conf new file mode 100644 index 0000000..398b7ae --- /dev/null +++ b/docker/example_hahproxy_files.conf @@ -0,0 +1,26 @@ +frontend http_front + bind *:443 ssl crt /etc/haproxy/certs/example.pem + mode http + + acl rustpad_path path_beg /f/ + + use_backend rustpad_backend if rustpad_path + + http-request return status 404 \ + content-type "text/plain" \ + string "Not Found" unless rustpad_path + + +backend rustpad_backend + mode http + + http-request set-header X-Forwarded-Proto https + http-request del-header Cookie + http-request del-header Authorization + + http-response del-header Set-Cookie + http-response return status 404 \ + content-type "text/plain" \ + string "Not Found" if { status 404 } + + server rustpad rustpad:3000 check \ No newline at end of file diff --git a/migrations/mysql/0025_user_theme.sql b/migrations/mysql/0025_user_theme.sql new file mode 100644 index 0000000..b84cc82 --- /dev/null +++ b/migrations/mysql/0025_user_theme.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN theme VARCHAR(5) NOT NULL DEFAULT 'dark', +ADD CONSTRAINT chk_users_theme CHECK (theme IN ('dark', 'light')); diff --git a/migrations/postgres/0025_user_theme.sql b/migrations/postgres/0025_user_theme.sql new file mode 100644 index 0000000..8b7afd5 --- /dev/null +++ b/migrations/postgres/0025_user_theme.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark' +CHECK (theme IN ('dark', 'light')); diff --git a/migrations/sqlite/0025_user_theme.sql b/migrations/sqlite/0025_user_theme.sql new file mode 100644 index 0000000..8b7afd5 --- /dev/null +++ b/migrations/sqlite/0025_user_theme.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN theme TEXT NOT NULL DEFAULT 'dark' +CHECK (theme IN ('dark', 'light')); diff --git a/src/app/pages.rs b/src/app/pages.rs index 806dc4a..39bf29f 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -282,6 +282,7 @@ pub(super) fn error_response( asset_version: &str, ) -> Response { let html = include_str!("../../static/error.html") + .replace("__APP_THEME_BOOTSTRAP__", assets::theme_bootstrap()) .replace( "__APP_STYLESHEET__", &assets::stylesheet_tag(asset_version, "styles"), diff --git a/src/assets.rs b/src/assets.rs index 51fc2d6..e0610e6 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -31,6 +31,7 @@ const MODULES: &[&str] = &[ "session", "socket", "toast", + "theme", "url-state", "security", ]; @@ -47,6 +48,7 @@ pub fn render_html( let urls = AssetUrls::new(asset_version); let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes, external_auth); let html = template + .replace("__APP_THEME_BOOTSTRAP__", theme_bootstrap()) .replace("__APP_STYLESHEET__", &urls.stylesheet("styles")) .replace("__APP_IMPORT_MAP__", &urls.import_map()) .replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint)) @@ -68,6 +70,10 @@ pub fn render_html( response } +pub fn theme_bootstrap() -> &'static str { + r#""# +} + pub fn stylesheet_tag(asset_version: &str, name: &str) -> String { AssetUrls::new(asset_version).stylesheet(name) } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index fef03b3..cfc53c6 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -49,6 +49,7 @@ pub struct User { pub password_hash: String, pub confirmed_at: Option, pub is_active: i64, + pub theme: String, } #[derive(Deserialize)] @@ -95,6 +96,8 @@ pub struct ProfileUpdateRequest { password: String, #[serde(default)] editor_color: Option, + #[serde(default)] + theme: Option, } #[derive(Deserialize)] pub struct DeleteAccountRequest { @@ -190,6 +193,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for User { password_hash: crate::row_decode::text(row, "password_hash")?, confirmed_at: crate::row_decode::optional_text(row, "confirmed_at")?, is_active: row.try_get("is_active")?, + theme: crate::row_decode::text(row, "theme")?, }) } } @@ -268,6 +272,7 @@ pub struct SessionResponse { directory_organization: Option, suggested_nickname: Option, editor_color: Option, + theme: String, } #[derive(Serialize)] pub struct IdentityResponse { @@ -282,6 +287,7 @@ pub struct RegisterResponse { email: String, expires_at: Option, confirmation_required: bool, + theme: String, message: String, } @@ -433,6 +439,7 @@ pub async fn register( email: user.email, expires_at: None, confirmation_required: true, + theme: user.theme, message: "Account created. Check your e-mail and confirm the account before logging in." .into(), @@ -451,6 +458,7 @@ pub async fn register( email: session.email, expires_at: Some(session.expires_at), confirmation_required: false, + theme: session.theme, message: "Account created.".into(), }), ).into_response(); @@ -740,6 +748,7 @@ pub async fn me( directory_organization, suggested_nickname, editor_color, + theme: user.theme, }, state.user_session_ttl_days, )) @@ -855,10 +864,23 @@ pub async fn update_profile( .map_err(AuthError::database)?; } + let mut theme = user.theme.clone(); + if let Some(value) = req.theme.as_deref() { + theme = validate_theme(value)?.to_owned(); + sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_THEME)) + .bind(&theme) + .bind(Utc::now().to_rfc3339()) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + } + Ok(Json(serde_json::json!({ "ok": true, "nickname": nickname, "editor_color": req.editor_color.as_deref(), + "theme": theme, "email_pending": email_pending, "message": if email_pending { "Profile updated. Confirm the new e-mail address using the link sent to it." @@ -2052,6 +2074,7 @@ async fn create_session(state: &SharedState, user: &User) -> Result Result Result<&str, AuthError> { + match value.trim() { + "dark" => Ok("dark"), + "light" => Ok("light"), + _ => Err(AuthError::bad_request("Unknown interface theme.")), + } +} + fn validate_editor_color(value: &str) -> Result { let value = value.trim(); if value.len() == 7 diff --git a/src/queries/mod.rs b/src/queries/mod.rs index a26d28f..4a0cba5 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -26,6 +26,7 @@ pub enum Query { AUTH_LATEST_CONFIRMATION_CREATED_AT, AUTH_UPDATE_NICKNAME, AUTH_UPDATE_EDITOR_COLOR, + AUTH_UPDATE_THEME, AUTH_EDITOR_COLOR_BY_USER, RESOURCE_COLOR_BY_USER, RESOURCE_COLOR_DELETE, @@ -176,6 +177,7 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: Query = Query::POSTGRES_PAD_REVI pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: Query = Query::AUTH_LATEST_CONFIRMATION_CREATED_AT; pub const AUTH_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME; pub const AUTH_UPDATE_EDITOR_COLOR: Query = Query::AUTH_UPDATE_EDITOR_COLOR; +pub const AUTH_UPDATE_THEME: Query = Query::AUTH_UPDATE_THEME; pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER; pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER; pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index f1b19e1..0619daf 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_UPDATE_EDITOR_COLOR => { r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# } + Query::AUTH_UPDATE_THEME => { + r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::RESOURCE_COLOR_BY_USER => { r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# @@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str { r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"# } Query::AUTH_USER_BY_EXTERNAL_ID => { - r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE auth_provider = ? AND external_id = ?"# + r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE auth_provider = ? AND external_id = ?"# } Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, Query::AUTH_ANONYMIZE_USER => { @@ -150,19 +153,19 @@ pub fn get(query: Query) -> &'static str { } Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#, Query::AUTH_USER_BY_SESSION => { - r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"# + r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(u.theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"# } Query::AUTH_INSERT_SESSION => { r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"# } Query::AUTH_USER_BY_NICKNAME => { - r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE nickname_key = ?"# + r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE nickname_key = ?"# } Query::AUTH_USER_BY_EMAIL => { - r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE email_key = ?"# + r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE email_key = ?"# } Query::AUTH_USER_BY_SHARE_IDENTIFIER => { - r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"# + r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"# } Query::USER_ATTACH_WORKSPACE => { r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"# diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 69591f2..2c26475 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_UPDATE_EDITOR_COLOR => { r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"# } + Query::AUTH_UPDATE_THEME => { + r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#, Query::RESOURCE_COLOR_BY_USER => { r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"# @@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str { r#"SELECT auth_provider, directory_display_name FROM users WHERE id = $1"# } Query::AUTH_USER_BY_EXTERNAL_ID => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE auth_provider = $1 AND external_id = $2"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE auth_provider = $1 AND external_id = $2"# } Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#, Query::AUTH_ANONYMIZE_USER => { @@ -152,19 +155,19 @@ pub fn get(query: Query) -> &'static str { } Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = $1"#, Query::AUTH_USER_BY_SESSION => { - r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"# + r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"# } Query::AUTH_INSERT_SESSION => { r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"# } Query::AUTH_USER_BY_NICKNAME => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE nickname_key = $1"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE nickname_key = $1"# } Query::AUTH_USER_BY_EMAIL => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE email_key = $1"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE email_key = $1"# } Query::AUTH_USER_BY_SHARE_IDENTIFIER => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"# } Query::USER_ATTACH_WORKSPACE => { r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"# diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 624c843..06800b0 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_UPDATE_EDITOR_COLOR => { r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# } + Query::AUTH_UPDATE_THEME => { + r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::RESOURCE_COLOR_BY_USER => { r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# @@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str { r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"# } Query::AUTH_USER_BY_EXTERNAL_ID => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE auth_provider = ? AND external_id = ?"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE auth_provider = ? AND external_id = ?"# } Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, Query::AUTH_ANONYMIZE_USER => { @@ -150,19 +153,19 @@ pub fn get(query: Query) -> &'static str { } Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#, Query::AUTH_USER_BY_SESSION => { - r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"# + r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"# } Query::AUTH_INSERT_SESSION => { r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"# } Query::AUTH_USER_BY_NICKNAME => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE nickname_key = ?"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE nickname_key = ?"# } Query::AUTH_USER_BY_EMAIL => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE email_key = ?"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE email_key = ?"# } Query::AUTH_USER_BY_SHARE_IDENTIFIER => { - r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"# + r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active, theme FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"# } Query::USER_ATTACH_WORKSPACE => { r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"# diff --git a/static/css/styles.css b/static/css/styles.css index 227843e..2536085 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,32 +1,300 @@ /* - * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * 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, +:root[data-theme="dark"] { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; background: #0b0d11; color: #f4f6f8; + --bg: #0b0d11; --surface: #11141a; --surface-2: #151922; --surface-3: #1a1f29; + --surface-inset: #0d1015; + --surface-raised: #11151c; + --surface-toolbar: #10141a; + --surface-panel: #101319; + --control-bg: #0e1116; + --surface-deep: #0a0d12; + --code-bg: #0b0e13; + --surface-black: #080b10; + --surface-floating: #171b23; + --surface-dialog: #11151b; + --surface-card: #171c24; + --surface-hover: #242b36; + --surface-strong: #11161e; + --surface-strong-2: #262b35; + --control-active: #1a2029; + --panel: var(--surface-card); + --editor-canvas: #0d1015; + --preview-canvas: #0e1116; + --note-action-bg: #171c24; + --note-action-hover: #222a36; + --note-action-border: #3a4351; + --note-action-text: #e0e5ec; + --note-action-accent-bg: #272246; + --toolbar-action-bg: #171c24; + --toolbar-action-hover: #232b37; + --toolbar-action-border: #343d4a; + --toolbar-action-text: #d8dee7; + --border: #272d38; --border-strong: #343c49; + --divider: #202631; + --control-border: #303745; + --border-muted: #707887; + --select-border: #596273; + --border-neutral: #4b5565; + --text: #f4f6f8; + --text-label: #dce1e8; + --text-secondary: #d2d8e1; + --text-tertiary: #b8c0cc; + --text-heading: #dce2eb; + --editor-text: #edf1f6; + --editor-placeholder: #515a68; + --text-muted-strong: #abb5c3; + --accent-text: #aa9df8; + --text-dim: #c5ccd6; + --text-soft: #d1d7df; + --accent-text-strong: #9e92f5; + --text-bright: #e6eaf0; + --code-text: #c9d0da; + --text-on-owner: #eef1f5; + --text-max: #eef2f7; + --gutter-text: #596270; + --warning-text: #fff2a8; + --text-subtle: #b9c2cf; + --code-text-soft: #c3cad4; + --dialog-text: #d7dae0; + --footer-accent: #d9d2ff; + --success-soft: #8ed9a4; + --accent-pale: #d8d3ff; + --danger-soft: #ffb6bd; --muted: #929cab; --muted-2: #697383; - --accent: #7c68ee; - --accent-hover: #8d79f8; + --muted-fallback: #9ca3af; + --on-accent: #fff; + --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; + + --accent: #7561e0; + --accent-hover: #6955cb; --accent-soft: #272246; + --accent-border: #8372ef; + --focus: #7567db; + --link: #a99ef8; + --link-hover: #c3bbff; + --caret: #9b89ff; + --owner-fallback: #8b7af4; + --warning: #e3a94d; + --warning-muted: #d6a84b; --danger: #ff7b91; --success: #40d3a3; + --success-fallback: #42b883; + + --warning-bg: #6d5b16; + --danger-border: #7f3340; + --danger-bg: #6e2935; + --danger-hover: #7c3040; + --status-success-border: #2f855a; + --status-info-border: #3182ce; + --status-warning-border: #d69e2e; + --status-danger-border: #c53030; + --status-success-bg: rgba(47, 133, 90, .14); + --status-info-bg: rgba(49, 130, 206, .14); + --status-warning-bg: rgba(214, 158, 46, .14); + --status-danger-bg: rgba(197, 48, 48, .14); + + --syntax-keyword: #c792ea; + --syntax-string: #c3e88d; + --syntax-number: #f78c6c; + --live-gradient-start: #f0aa74; + --live-gradient-mid: #cf633d; + --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); + --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); + --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-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); + --overlay-72: rgba(4, 6, 10, .72); + --overlay-78: rgba(4, 6, 9, .78); + --overlay-82: rgba(4, 6, 9, .82); + --glass-92: rgba(13, 16, 21, .92); + --glass-toolbar: rgba(17, 21, 28, .88); +} + +:root[data-theme="light"] { + color-scheme: light; + background: #f7f4ee; + color: #29251f; + + --bg: #f7f4ee; + --surface: #fffdf9; + --surface-2: #f5f1ea; + --surface-3: #ece6de; + --surface-inset: #fbf8f3; + --surface-raised: #fffdf9; + --surface-toolbar: #faf7f2; + --surface-panel: #fcfaf6; + --control-bg: #fdfbf7; + --surface-deep: #efeae2; + --code-bg: #f6f2ec; + --surface-black: #e9e3da; + --surface-floating: #fffdf9; + --surface-dialog: #fffdf9; + --surface-card: #faf7f2; + --surface-hover: #f0ebe4; + --surface-strong: #fffbf6; + --surface-strong-2: #e9e3da; + --control-active: #eeeaf7; + --editor-canvas: #fdfcf9; + --preview-canvas: #fffdf9; + --note-action-bg: #fffdf9; + --note-action-hover: #f1ecf8; + --note-action-border: #c8c0b5; + --note-action-text: #403a33; + --note-action-accent-bg: #eee9f8; + --toolbar-action-bg: #fffdf9; + --toolbar-action-hover: #f0ebf8; + --toolbar-action-border: #d0c8bd; + --toolbar-action-text: #4b443c; + + --border: #e2dcd3; + --border-strong: #cbc3b8; + --divider: #e9e3db; + --control-border: #bcb2a6; + --border-muted: #a99d8f; + --select-border: #9d9183; + --border-neutral: #8f8376; + + --text: #29251f; + --text-label: #3a352e; + --text-secondary: #4a443c; + --text-tertiary: #655d53; + --text-heading: #302b25; + --editor-text: #2c2822; + --editor-placeholder: #8b8175; + --text-muted-strong: #625a50; + --accent-text: #5d50a5; + --text-dim: #575047; + --text-soft: #4a443c; + --accent-text-strong: #544795; + --text-bright: #322d27; + --code-text: #413b34; + --text-on-owner: #28231e; + --text-max: #211e1a; + --gutter-text: #82786d; + --warning-text: #624700; + --text-subtle: #5f574d; + --code-text-soft: #5a5249; + --dialog-text: #403a33; + --footer-accent: #554993; + --success-soft: #236948; + --accent-pale: #5b4fa1; + --danger-soft: #8b3340; + --muted: #6b6359; + --muted-2: #756c61; + --muted-fallback: #746b61; + --on-accent: #fff; + --danger-button-text: #7f2836; + --danger-subtle-bg: #f7e7e6; + --danger-subtle-hover: #efd4d4; + --theme-contrast: black; + --theme-shadow-mix: #6c6258; + + --accent: #6859b5; + --accent-hover: #5b4da5; + --accent-soft: #ece7f6; + --accent-border: #7566bd; + --focus: #6b5cb5; + --link: #5c4da3; + --link-hover: #4c3f8b; + --caret: #6859b5; + --owner-fallback: #7060c1; + --warning: #9a6816; + --warning-muted: #7e5d1d; + --danger: #9b3443; + --success: #237553; + --success-fallback: #277b58; + + --warning-bg: #f4e7be; + --danger-border: #c98289; + --danger-bg: #f2dddd; + --danger-hover: #e9cbcd; + --status-success-border: #4c8064; + --status-info-border: #56759c; + --status-warning-border: #a27831; + --status-danger-border: #a54b55; + --status-success-bg: rgba(61, 121, 88, .10); + --status-info-bg: rgba(68, 103, 148, .09); + --status-warning-bg: rgba(155, 112, 35, .11); + --status-danger-bg: rgba(155, 52, 67, .09); + + --syntax-keyword: #7046a5; + --syntax-string: #36704a; + --syntax-number: #a54c25; + --live-gradient-start: #c9794e; + --live-gradient-mid: #ae5d3c; + --live-gradient-end: #7c3d2c; + + --select-arrow: #6f665c; + --wash-faint: rgba(49, 43, 36, .015); + --wash-soft: rgba(49, 43, 36, .025); + --wash-subtle: rgba(49, 43, 36, .035); + --wash-medium: rgba(49, 43, 36, .05); + --wash-hover: rgba(49, 43, 36, .065); + --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-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); + --live-border: rgba(174, 93, 60, .23); + + --shadow-28: rgba(73, 61, 48, .10); + --shadow-38: rgb(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); + --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); } * { @@ -145,7 +413,7 @@ a { } .field label { - color: #dce1e8; + color: var(--text-label); font-size: .86rem; font-weight: 750; } @@ -169,7 +437,13 @@ input, select { border: 1px solid var(--border-strong); outline: none; - background: #0e1116; + background: var(--control-bg); + color: var(--text); +} + +select option, +select optgroup { + background: var(--control-bg); color: var(--text); } @@ -183,8 +457,8 @@ input { input:focus, select:focus, textarea:focus { - border-color: #7567db; - outline: 1px solid #7567db; + border-color: var(--focus); + outline: 1px solid var(--focus); outline-offset: 1px; } @@ -201,13 +475,13 @@ textarea:focus { transform: none; border: 0; background: transparent; - color: #a99ef8; + color: var(--link); padding: 4px 2px; font-size: .78rem; } .text-button:hover { - color: #c3bbff; + color: var(--link-hover); } .password-toggle { @@ -243,9 +517,9 @@ textarea:focus { .primary-button { width: 100%; - border: 1px solid #8372ef; + border: 1px solid var(--accent-border); background: var(--accent); - color: white; + color: var(--on-accent); padding: 0 16px; } @@ -256,7 +530,7 @@ textarea:focus { .secondary-button { border: 1px solid var(--border-strong); background: var(--surface-2); - color: #d2d8e1; + color: var(--text-secondary); padding: 0 13px; } @@ -279,7 +553,7 @@ textarea:focus { min-height: 70px; padding: 0 20px; border-bottom: 1px solid var(--border); - background: #0d1015; + background: var(--surface-inset); } .app-header__main, @@ -344,7 +618,7 @@ textarea:focus { width: 7px; height: 7px; border-radius: 50%; - background: #e3a94d; + background: var(--warning); } .status__dot.is-online { @@ -368,8 +642,8 @@ textarea:focus { padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-strong)); border-radius: 12px; - background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--danger)); - box-shadow: 0 16px 42px rgb(0 0 0 / 38%); + background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--danger)); + box-shadow: 0 16px 42px var(--shadow-38); opacity: 0; pointer-events: none; transform: translate(-50%, -10px) scale(.98); @@ -383,7 +657,7 @@ textarea:focus { .connection-notice.is-restored { border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong)); - background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--success)); + background: color-mix(in srgb, var(--surface-strong, var(--surface-strong)) 94%, var(--success)); } .connection-notice__signal { @@ -441,8 +715,17 @@ textarea:focus { } @keyframes connection-pulse { - 0%, 100% { transform: scaleY(.45); opacity: .48; } - 50% { transform: scaleY(1); opacity: 1; } + + 0%, + 100% { + transform: scaleY(.45); + opacity: .48; + } + + 50% { + transform: scaleY(1); + opacity: 1; + } } .editor-layout { @@ -473,7 +756,7 @@ textarea:focus { min-height: 52px; padding: 8px 12px; border-bottom: 1px solid var(--border); - background: #101319; + background: var(--surface-panel); } .toolbar-group, @@ -501,7 +784,7 @@ textarea:focus { border: 1px solid transparent; border-radius: 7px; background: transparent; - color: #b8c0cc; + color: var(--text-tertiary); padding: 0 9px; font-size: .78rem; } @@ -510,30 +793,30 @@ textarea:focus { .editor-toolbar select:hover { border-color: var(--border); background: var(--surface-2); - color: white; + color: var(--text); } .editor-toolbar select { border-color: var(--border); - background: #11151c; + background: var(--surface-raised); } .view-switch { padding: 3px; border: 1px solid var(--border); border-radius: 9px; - background: #0d1015; + background: var(--surface-inset); } .view-switch button.active { background: var(--surface-3); - color: white; + color: var(--text); } .workspace { display: grid; min-height: 0; - background: #0e1116; + background: var(--control-bg); } .workspace.view-split { @@ -581,8 +864,8 @@ textarea:focus { display: flex; align-items: center; padding: 0 18px; - border-bottom: 1px solid #202631; - background: #10141a; + border-bottom: 1px solid var(--divider); + background: var(--surface-toolbar); color: var(--muted-2); font-size: .7rem; font-weight: 750; @@ -599,25 +882,25 @@ textarea { padding: 24px; border: 0; outline: none; - background: #0d1015; - color: #edf1f6; + background: var(--surface-inset); + color: var(--editor-text); font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; - caret-color: #9b89ff; + caret-color: var(--caret); } textarea::placeholder { - color: #515a68; + color: var(--editor-placeholder); } textarea::selection { - background: rgba(124, 104, 238, .35); + background: var(--accent-a35); } .preview { overflow: auto; min-height: 0; - color: #dce2eb; + color: var(--text-heading); } @@ -644,9 +927,9 @@ textarea::selection { .markdown-body code { padding: .16em .36em; - border: 1px solid #303745; + border: 1px solid var(--control-border); border-radius: 5px; - background: #1a2029; + background: var(--control-active); } .markdown-body pre { @@ -654,7 +937,7 @@ textarea::selection { padding: 16px; border: 1px solid var(--border); border-radius: 10px; - background: #0a0d12; + background: var(--surface-deep); } .markdown-body pre code { @@ -667,11 +950,11 @@ textarea::selection { padding: .2em 1em; border-left: 3px solid var(--accent); - color: #abb5c3; + color: var(--text-muted-strong); } .markdown-body a { - color: #aa9df8; + color: var(--accent-text); } .markdown-body hr { @@ -701,7 +984,7 @@ textarea::selection { width: 340px; overflow: hidden; border-left: 1px solid var(--border); - background: #101319; + background: var(--surface-panel); transform: translateX(100%); transition: transform .18s ease; } @@ -735,7 +1018,7 @@ textarea::selection { border: 1px solid var(--border); border-radius: 8px; background: transparent; - color: #c5ccd6; + color: var(--text-dim); font-size: 1.2rem; } @@ -773,14 +1056,14 @@ textarea::selection { width: 11px; height: 11px; margin-top: 3px; - border: 2px solid #8b7af4; + border: 2px solid var(--owner-fallback); border-radius: 50%; } .revision time { display: block; - color: #d1d7df; + color: var(--text-soft); font-size: .8rem; } @@ -794,7 +1077,7 @@ textarea::selection { margin-top: 10px; border: 0; background: transparent; - color: #9e92f5; + color: var(--accent-text-strong); padding: 0; font-size: .76rem; font-weight: 750; @@ -816,7 +1099,7 @@ dialog { } dialog::backdrop { - background: rgba(4, 6, 9, .82); + background: var(--overlay-82); } .dialog-panel { @@ -850,8 +1133,8 @@ dialog::backdrop { padding: 11px 14px; border: 1px solid var(--border-strong); border-radius: 10px; - background: #171b23; - color: #e6eaf0; + background: var(--surface-floating); + color: var(--text-bright); font-size: .8rem; opacity: 0; transform: translateY(8px); @@ -967,7 +1250,7 @@ dialog::backdrop { .markdown-toggle.active { background: var(--surface-3) !important; - color: white !important; + color: var(--text) !important; } .preview--raw { @@ -1132,7 +1415,7 @@ dialog::backdrop { grid-template-columns: auto minmax(0, 1fr); min-height: 0; overflow: hidden; - background: #0d1015; + background: var(--surface-inset); } .line-gutter { @@ -1196,7 +1479,7 @@ dialog::backdrop { border: 0; border-radius: 7px; background: transparent; - color: #dce2eb; + color: var(--text-heading); font: inherit; font-size: .78rem; } @@ -1211,7 +1494,7 @@ dialog::backdrop { width: 10px; height: 10px; flex: 0 0 auto; - border: 1px solid color-mix(in srgb, var(--owner, var(--accent)) 72%, white); + border: 1px solid color-mix(in srgb, var(--owner, var(--accent)) 72%, var(--theme-contrast)); border-radius: 50%; background: var(--owner, var(--accent)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); @@ -1246,8 +1529,8 @@ dialog::backdrop { } .revision__marker { - border-color: var(--owner, #8b7af4); - background: var(--owner, #8b7af4); + border-color: var(--owner, var(--owner-fallback)); + background: var(--owner, var(--owner-fallback)); } .revision__meta { @@ -1275,8 +1558,8 @@ dialog::backdrop { padding: 10px; border: 1px solid var(--border); border-radius: 7px; - background: #0b0e13; - color: #c9d0da; + background: var(--code-bg); + color: var(--code-text); font: .72rem/1.5 ui-monospace, monospace; white-space: pre-wrap; } @@ -1290,7 +1573,7 @@ dialog::backdrop { padding: 12px; border: 1px solid var(--border); border-radius: 10px; - background: #0a0d12; + background: var(--surface-deep); } @media (max-width: 720px) { @@ -1341,7 +1624,7 @@ dialog::backdrop { min-height: 64px; padding: 0 max(20px, calc((100vw - 900px) / 2)); border-bottom: 1px solid var(--border); - background: rgba(13, 16, 21, .92); + background: var(--glass-92); backdrop-filter: blur(12px); } @@ -1440,17 +1723,17 @@ dialog::backdrop { } .home-footer a:hover { - color: white; + color: var(--text); } .home-header { background: transparent; width: min(1040px, calc(100% - 32px)); - border-bottom-color: rgba(195, 91, 54, .28); + border-bottom-color: var(--live-border); } .home-brand { - background: linear-gradient(110deg, #f0aa74 0%, #cf633d 48%, #8f3827 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; @@ -1472,8 +1755,8 @@ dialog::backdrop { padding: 2px 6px; border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); border-radius: 999px; - background: color-mix(in srgb, var(--owner) 18%, #0d1015); - color: #eef1f5; + background: color-mix(in srgb, var(--owner) 18%, var(--surface-inset)); + color: var(--text-on-owner); font: 600 10px/1.2 system-ui, sans-serif; text-overflow: ellipsis; white-space: nowrap; @@ -1521,8 +1804,8 @@ dialog::backdrop { padding: .14em .5em; border: 1px solid color-mix(in srgb, var(--owner) 62%, transparent); border-radius: 999px; - background: color-mix(in srgb, var(--owner) 18%, #0d1015); - color: #eef1f5; + background: color-mix(in srgb, var(--owner) 18%, var(--surface-inset)); + color: var(--text-on-owner); font: 600 calc(var(--editor-rendered-font-size, 14px) * .7143)/1.25 system-ui, sans-serif; opacity: .82; text-overflow: ellipsis; @@ -1693,12 +1976,12 @@ dialog::backdrop { padding: 0; border: 1px solid var(--border); border-radius: 14px; - background: #11151b; - color: #eef2f7; + background: var(--surface-dialog); + color: var(--text-max); } .image-editor-dialog::backdrop { - background: rgba(4, 6, 9, .78); + background: var(--overlay-78); backdrop-filter: blur(4px); } @@ -1732,7 +2015,7 @@ dialog::backdrop { overflow: hidden; border: 1px solid var(--border); border-radius: 10px; - background: #080b10; + background: var(--surface-black); } .image-crop-stage canvas { @@ -1838,7 +2121,7 @@ dialog::backdrop { } .file-flag.detached { - border-color: rgba(255, 123, 145, .45); + border-color: var(--danger-a45); color: var(--danger); } @@ -1846,9 +2129,14 @@ dialog::backdrop { width: 100%; min-height: 36px; border: 0; - border-top: 1px solid var(--border); - background: transparent; - color: var(--danger); + border-top: 1px solid color-mix(in srgb, var(--danger) 28%, var(--border)); + background: var(--danger-subtle-bg); + color: var(--danger-button-text); + font-weight: 650; +} + +.note-delete-button:hover { + background: var(--danger-subtle-hover); } .dialog-check { @@ -1923,7 +2211,7 @@ dialog::backdrop { padding: 12px; border: 1px solid var(--border); border-radius: 9px; - background: #0e1116; + background: var(--control-bg); } .file-name { @@ -2022,8 +2310,13 @@ dialog::backdrop { } .file-delete { - color: var(--danger) !important; - border-color: color-mix(in srgb, var(--danger) 45%, var(--border)) !important; + border-color: var(--danger-border) !important; + background: var(--danger-subtle-bg) !important; + color: var(--danger-button-text) !important; +} + +.file-delete:hover { + background: var(--danger-subtle-hover) !important; } @media (max-width: 760px) { @@ -2174,7 +2467,7 @@ dialog::backdrop { width: auto; min-height: 32px; padding: 0 10px; - border: 1px solid var(--border); + border: 1px solid var(--danger-border); border-radius: 6px; } @@ -2242,7 +2535,7 @@ dialog::backdrop { top: 0; left: var(--preview-line-left, -50px); width: 32px; - color: #596270; + color: var(--gutter-text); text-align: right; font: 400 .72rem/1.32 ui-monospace, SFMono-Regular, Consolas, monospace; user-select: none; @@ -2261,12 +2554,12 @@ dialog::backdrop { } .preview-editable:hover { - background: rgba(255, 255, 255, .025); + background: var(--wash-subtle); } .preview-editable:focus { - background: rgba(124, 104, 238, .08); - box-shadow: 0 0 0 1px rgba(124, 104, 238, .25); + background: var(--accent-a08); + box-shadow: 0 0 0 1px var(--accent-a25); } .markdown-body p { @@ -2325,8 +2618,8 @@ dialog::backdrop { .markdown-body mark { padding: .05em .18em; border-radius: 3px; - background: #6d5b16; - color: #fff2a8; + background: var(--warning-bg); + color: var(--warning-text); } .markdown-body sub, @@ -2344,7 +2637,7 @@ dialog::backdrop { .markdown-body dd { margin: .25em 0 .65em 1.5em; - color: #b9c2cf; + color: var(--text-subtle); } /* Task-list layout. Keep the checkbox in the same marker gutter as a bullet. */ @@ -2372,7 +2665,7 @@ dialog::backdrop { max-height: 1em; margin: .14em 0 0; padding: 0; - border: 1px solid #707887; + border: 1px solid var(--border-muted); border-radius: 2px; background: var(--surface-2); box-shadow: none; @@ -2393,7 +2686,7 @@ dialog::backdrop { } .markdown-body .task-checkbox:focus-visible { - box-shadow: 0 0 0 2px rgba(124, 104, 238, .3); + box-shadow: 0 0 0 2px var(--accent-a30); } .markdown-body .task-list-item>.list-item-content { @@ -2444,13 +2737,13 @@ dialog::backdrop { border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 6px; - background: #0d1015; - color: #e6eaf0; + background: var(--surface-inset); + color: var(--text-bright); font: 600 .76rem/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; } .shortcut-grid span { - color: #c3cad4; + color: var(--code-text-soft); font-size: .82rem; } @@ -2490,7 +2783,7 @@ dialog::backdrop { padding: 0 9px; border: 1px solid transparent; border-radius: 7px; - color: #b8c0cc; + color: var(--text-tertiary); font-size: .78rem; list-style: none; cursor: pointer; @@ -2504,7 +2797,7 @@ dialog::backdrop { .markdown-more[open]>summary { border-color: var(--border); background: var(--surface-2); - color: white; + color: var(--text); } .markdown-more-menu { @@ -2519,8 +2812,8 @@ dialog::backdrop { padding: 8px; border: 1px solid var(--border-strong); border-radius: 9px; - background: #11151c; - box-shadow: 0 14px 40px rgba(0, 0, 0, .4); + background: var(--surface-raised); + box-shadow: 0 14px 40px var(--shadow-40); } .editor-toolbar .markdown-more-menu button { @@ -2529,27 +2822,27 @@ dialog::backdrop { } .hljs { - color: #d7dae0; + color: var(--dialog-text); } .hljs-keyword, .hljs-selector-tag, .hljs-literal { - color: #c792ea; + color: var(--syntax-keyword); } .hljs-string, .hljs-attr { - color: #c3e88d; + color: var(--syntax-string); } .hljs-number, .hljs-symbol { - color: #f78c6c; + color: var(--syntax-number); } .hljs-comment { - color: #697383; + color: var(--muted-2); font-style: italic; } @@ -2717,7 +3010,7 @@ dialog::backdrop { .footer-action--primary { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); - color: #d9d2ff; + color: var(--footer-accent); } .app-dialog { @@ -2728,7 +3021,7 @@ dialog::backdrop { } .app-dialog::backdrop { - background: rgba(4, 6, 10, .72); + background: var(--overlay-72); backdrop-filter: blur(3px); } @@ -2740,6 +3033,56 @@ dialog::backdrop { padding: 28px; } +#profile-dialog { + width: min(820px, calc(100% - 32px)); + max-width: 820px; + max-height: min(90dvh, 760px); +} + +#profile-dialog .identity-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 20px; + max-height: min(90dvh, 760px); + overflow-y: auto; + scrollbar-gutter: stable; +} + +#profile-dialog .identity-panel__header, +#profile-dialog .identity-fields, +#profile-dialog .profile-actions, +#profile-dialog .form-message { + grid-column: 1 / -1; +} + +#profile-dialog .identity-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + column-gap: 18px; +} + +#profile-dialog .profile-theme-field, +#profile-dialog .profile-suggestion { + grid-column: 1 / -1; +} + +.profile-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.profile-actions:has(#profile-delete[hidden]) { + grid-template-columns: 1fr; +} + +.profile-actions>button { + width: 100%; +} + +.profile-actions .primary-button { + margin-top: 0; +} + .identity-panel__header { padding-right: 38px; } @@ -2784,7 +3127,7 @@ dialog::backdrop { .modal-close:hover { border-color: var(--border); background: var(--surface-2); - color: white; + color: var(--text); } .identity-links { @@ -2799,7 +3142,7 @@ dialog::backdrop { } .form-message.success { - color: #8ed9a4; + color: var(--success-soft); } @media (max-width: 600px) { @@ -2828,6 +3171,22 @@ dialog::backdrop { padding: 24px 20px 20px; } + #profile-dialog { + width: min(440px, calc(100% - 24px)); + } + + #profile-dialog .identity-panel, + #profile-dialog .identity-fields, + .profile-actions { + grid-template-columns: 1fr; + } + + #profile-dialog .identity-fields>*, + #profile-dialog .profile-actions, + #profile-dialog .form-message { + grid-column: 1; + } + .identity-links { align-items: flex-start; flex-direction: column; @@ -2870,13 +3229,13 @@ dialog::backdrop { } .danger-button { - border-color: #7f3340; - background: #6e2935; - color: white; + border-color: var(--danger-border); + background: var(--danger-bg); + color: var(--danger-button-text); } .danger-button:hover { - background: #7c3040; + background: var(--danger-hover); } /* Native hidden must win over component display declarations. */ @@ -3073,7 +3432,7 @@ dialog::backdrop { margin: .65em 0; border: 1px solid var(--border); border-radius: 8px; - background: rgba(255, 255, 255, .015); + background: var(--wash-faint); } .markdown-details>summary { @@ -3194,7 +3553,7 @@ dialog::backdrop { padding: 0 5px; border-radius: 999px; background: var(--accent); - color: #fff; + color: var(--on-accent); font-size: 10px; line-height: 17px; text-align: center; @@ -3213,7 +3572,7 @@ dialog::backdrop { border: 1px solid var(--border); border-radius: 10px; background: var(--surface); - box-shadow: 0 18px 50px rgb(0 0 0 / .38); + box-shadow: 0 18px 50px var(--shadow-38); color: var(--text); } @@ -3315,7 +3674,7 @@ dialog::backdrop { border: 1px solid var(--border); border-radius: 6px; background: var(--accent); - color: #fff; + color: var(--on-accent); cursor: pointer; } @@ -3502,7 +3861,7 @@ dialog::backdrop { } .share-dialog::backdrop { - background: rgb(0 0 0 / .68); + background: var(--overlay-68); backdrop-filter: blur(3px); } @@ -3528,7 +3887,7 @@ dialog::backdrop { gap: 16px; padding: 18px 22px 22px; overflow-y: auto; - background: color-mix(in srgb, var(--surface) 92%, black); + background: color-mix(in srgb, var(--surface) 92%, var(--theme-shadow-mix)); } .share-dialog-footer { @@ -3591,10 +3950,10 @@ dialog::backdrop { padding: 0 38px 0 12px; border: 1px solid var(--border-strong); border-radius: 8px; - background-color: #10141a; + background-color: var(--surface-toolbar); color: var(--text); appearance: none; - background-image: linear-gradient(45deg, transparent 50%, #9aa3b2 50%), linear-gradient(135deg, #9aa3b2 50%, transparent 50%); + background-image: linear-gradient(45deg, transparent 50%, var(--select-arrow) 50%), linear-gradient(135deg, var(--select-arrow) 50%, transparent 50%); background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; @@ -3602,12 +3961,12 @@ dialog::backdrop { } .share-dialog select:hover { - border-color: #596273; + border-color: var(--select-border); } .share-dialog select:focus { - border-color: #7567db; - box-shadow: 0 0 0 3px rgb(117 103 219 / .18); + border-color: var(--focus); + box-shadow: 0 0 0 3px var(--focus-ring); outline: none; } @@ -3625,13 +3984,13 @@ dialog::backdrop { min-height: 42px; border: 1px solid var(--border-strong); border-radius: 8px; - background: #10141a; + background: var(--surface-toolbar); overflow: hidden; } .share-hours-field:focus-within { - border-color: #7567db; - box-shadow: 0 0 0 3px rgb(117 103 219 / .18); + border-color: var(--focus); + box-shadow: 0 0 0 3px var(--focus-ring); } .share-hours-field input { @@ -3669,7 +4028,7 @@ dialog::backdrop { flex: 0 0 auto; border-radius: 50%; background: color-mix(in srgb, var(--accent) 20%, var(--surface-2)); - color: #d8d3ff; + color: var(--accent-pale); font-weight: 700; } @@ -3729,7 +4088,7 @@ dialog::backdrop { border: 1px solid var(--border-strong); border-radius: 18px; background: var(--surface); - box-shadow: 0 28px 90px rgb(0 0 0 / .58); + box-shadow: 0 28px 90px var(--shadow-58); } .share-dialog .share-panel { @@ -3751,7 +4110,7 @@ dialog::backdrop { .share-section { border-radius: 12px; - box-shadow: inset 0 1px 0 rgb(255 255 255 / .025); + box-shadow: inset 0 1px 0 var(--wash-subtle); } .resource-copy { @@ -3778,7 +4137,7 @@ dialog::backdrop { border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 12%, transparent); - color: color-mix(in srgb, var(--accent) 70%, white); + color: color-mix(in srgb, var(--accent) 70%, var(--theme-contrast)); font-size: .68rem; font-weight: 650; line-height: 1.2; @@ -3825,23 +4184,23 @@ dialog::backdrop { } .markdown-body .markdown-alert--success { - border-color: #2f855a; - background: rgba(47, 133, 90, .14); + border-color: var(--status-success-border); + background: var(--status-success-bg); } .markdown-body .markdown-alert--info { - border-color: #3182ce; - background: rgba(49, 130, 206, .14); + border-color: var(--status-info-border); + background: var(--status-info-bg); } .markdown-body .markdown-alert--warning { - border-color: #d69e2e; - background: rgba(214, 158, 46, .14); + border-color: var(--status-warning-border); + background: var(--status-warning-bg); } .markdown-body .markdown-alert--danger { - border-color: #c53030; - background: rgba(197, 48, 48, .14); + border-color: var(--status-danger-border); + background: var(--status-danger-bg); } /* Fenced code line numbers for every language: ```lang=, ```lang=101, ```= or ```=101. */ @@ -3960,7 +4319,7 @@ dialog::backdrop { padding: 0 9px; border: 1px solid transparent; border-radius: 7px; - color: #b8c0cc; + color: var(--text-tertiary); font-size: .78rem; list-style: none; cursor: pointer; @@ -3975,7 +4334,7 @@ dialog::backdrop { .emoji-picker[open]>summary { border-color: var(--border); background: var(--surface-2); - color: white; + color: var(--text); } .emoji-picker-panel { @@ -3987,8 +4346,8 @@ dialog::backdrop { padding: 10px; border: 1px solid var(--border-strong); border-radius: 10px; - background: #11151c; - box-shadow: 0 14px 40px rgba(0, 0, 0, .45); + background: var(--surface-raised); + box-shadow: 0 14px 40px var(--shadow-45); } .emoji-search { @@ -4093,14 +4452,14 @@ dialog::backdrop { padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 7px; - background: #10141a; + background: var(--surface-toolbar); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: .76rem; } .share-link-legacy { - color: var(--warning, #d6a84b) !important; + color: var(--warning, var(--warning-muted)) !important; } @media (max-width: 800px) { @@ -4226,9 +4585,9 @@ dialog::backdrop { .action-button--primary, .primary-button { - border-color: #8372ef; + border-color: var(--accent-border); background: var(--accent); - color: white; + color: var(--on-accent); } .action-button--primary:hover, @@ -4240,25 +4599,25 @@ dialog::backdrop { .secondary-button { border-color: var(--border-strong); background: var(--surface-2); - color: #d2d8e1; + color: var(--text-secondary); } .action-button--secondary:hover, .secondary-button:hover { - border-color: #4b5565; + border-color: var(--border-neutral); background: var(--surface-3); } .action-button--danger, .danger-button { - border-color: #7f3340; - background: #6e2935; - color: white; + border-color: var(--danger-border); + background: var(--danger-bg); + color: var(--danger-button-text); } .action-button--danger:hover, .danger-button:hover { - background: #7c3040; + background: var(--danger-hover); } .action-button.compact-button { @@ -4272,7 +4631,7 @@ dialog::backdrop { } .footer-access { - color: var(--muted, #9ca3af); + color: var(--muted, var(--muted-fallback)); font-weight: 600; } @@ -4309,6 +4668,88 @@ dialog::backdrop { cursor: pointer; } +.profile-theme-field { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.profile-theme-field legend { + margin-bottom: 8px; + color: var(--text-label); + font-size: .86rem; + font-weight: 750; +} + +.theme-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.theme-option { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 10px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--surface-2); + cursor: pointer; +} + +.theme-option:hover { + border-color: var(--accent-border); + background: var(--surface-3); +} + +.theme-option:has(input:checked) { + border-color: var(--accent-border); + background: var(--accent-soft); + box-shadow: 0 0 0 1px var(--accent-a25); +} + +.theme-option:has(input:focus-visible) { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.theme-option input { + width: 1rem; + min-height: auto; + margin: 2px 0 0; + accent-color: var(--accent); +} + +.theme-option span, +.theme-option strong, +.theme-option small { + display: block; + min-width: 0; +} + +.theme-option strong { + color: var(--text); + font-size: .84rem; +} + +.theme-option small { + margin-top: 3px; + color: var(--muted); + font-size: .72rem; + line-height: 1.35; +} + +@media (max-width: 520px) { + .theme-options { + grid-template-columns: 1fr; + } +} + /* Responsive note editor -------------------------------------------------- */ @media (max-width: 1499px) { .pad-page { @@ -4396,8 +4837,8 @@ dialog::backdrop { padding: 10px; border: 1px solid var(--border-strong); border-radius: 10px; - background: #0d1015; - box-shadow: 0 16px 40px rgb(0 0 0 / 45%); + background: var(--surface-inset); + box-shadow: 0 16px 40px var(--shadow-45); } .pad-page .header-actions.is-open { @@ -4477,7 +4918,7 @@ dialog::backdrop { right: 0; z-index: 2; align-self: center; - background: #0d1015; + background: var(--surface-inset); } .pad-page textarea, @@ -4555,6 +4996,7 @@ dialog::backdrop { } @media (prefers-reduced-motion: reduce) { + .connection-notice, .connection-notice__signal span { animation: none; @@ -4650,10 +5092,10 @@ dialog::backdrop { max-width: calc(100vw - 20px); padding: 5px; isolation: isolate; - border: 1px solid rgba(255, 255, 255, .12); + border: 1px solid var(--wash-border); border-radius: 999px; background: transparent; - box-shadow: 0 8px 24px rgba(0, 0, 0, .28); + box-shadow: 0 8px 24px var(--shadow-28); } .pad-page .mobile-editor-bubble::before { @@ -4661,7 +5103,7 @@ dialog::backdrop { z-index: -1; inset: 0; border-radius: inherit; - background: rgba(17, 21, 28, .88); + background: var(--glass-toolbar); backdrop-filter: blur(10px); content: ""; pointer-events: none; @@ -4676,7 +5118,7 @@ dialog::backdrop { place-items: center; border: 0; border-radius: 50%; - background: rgba(255, 255, 255, .07); + background: var(--wash-hover); color: var(--text); font-size: 1rem; touch-action: manipulation; @@ -4703,7 +5145,7 @@ dialog::backdrop { .mobile-color-dot { width: 15px; height: 15px; - border: 2px solid rgba(255, 255, 255, .75); + border: 2px solid var(--wash-strong); border-radius: 50%; background: var(--owner, var(--accent)); } @@ -4778,10 +5220,10 @@ dialog::backdrop { height: 17px; padding: 0 4px; place-items: center; - border: 2px solid #11151c; + border: 2px solid var(--surface-raised); border-radius: 999px; background: var(--danger); - color: white; + color: var(--on-accent); font-size: .58rem; font-weight: 700; line-height: 1; @@ -4820,7 +5262,7 @@ dialog::backdrop { } .authorship-mode-control button.active { - background: var(--surface-strong, #262b35); + background: var(--surface-strong, var(--surface-strong-2)); color: var(--text); } @@ -4890,7 +5332,7 @@ dialog::backdrop { /* Keep the whole editor surface consistent in Simple and Full modes. */ .editor-shell { - background: #0d1015; + background: var(--surface-inset); } .editor-shell textarea { @@ -4915,7 +5357,7 @@ dialog::backdrop { /* Stable editor canvas in both authorship modes. */ .pad-page .editor-column, .pad-page .editor-shell { - background: #0d1015; + background: var(--surface-inset); } .pad-page .editor-shell { @@ -4926,7 +5368,7 @@ dialog::backdrop { position: absolute; z-index: 0; inset: 0; - background: #0d1015; + background: var(--surface-inset); content: ""; pointer-events: none; } @@ -4942,11 +5384,11 @@ dialog::backdrop { height: auto; min-width: 0; min-height: 0; - background: #0d1015 !important; + background: var(--surface-inset) !important; } .pad-page .line-gutter { - background: #0d1015; + background: var(--surface-inset); } .resources-access-rules { @@ -4954,7 +5396,7 @@ dialog::backdrop { padding: 9px 10px; border: 1px solid var(--border); border-radius: 8px; - background: rgba(255, 255, 255, .02); + background: var(--wash-soft); color: var(--muted); font-size: .78rem; line-height: 1.45; @@ -5037,7 +5479,7 @@ dialog::backdrop { border: 1px solid var(--border); border-radius: 9px; background: var(--panel); - box-shadow: 0 12px 30px rgba(0, 0, 0, .28); + box-shadow: 0 12px 30px var(--shadow-28); } .page-settings-menu .public-task-toggle, @@ -5045,12 +5487,12 @@ dialog::backdrop { min-height: 30px; padding: 5px 7px; border-radius: 6px; - background: #171c24; + background: var(--surface-card); } .page-settings-menu .public-task-toggle:hover, .mobile-editor-options__panel .mobile-option-check:hover { - background: #242b36; + background: var(--surface-hover); } #publish-page:disabled { @@ -5082,11 +5524,11 @@ dialog::backdrop { padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; - background: var(--surface-strong, #262b35); + background: var(--surface-strong, var(--surface-strong-2)); color: var(--text); font-weight: 600; cursor: pointer; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, .04); + box-shadow: inset 0 1px 0 var(--wash-medium); } .markdown-more>summary:hover, @@ -5106,7 +5548,7 @@ dialog::backdrop { /* Final UI fixes: opaque Page settings and per-note authorship controls. */ .page-settings-menu { - background: #171c24; + background: var(--surface-card); opacity: 1; backdrop-filter: none; } @@ -5210,7 +5652,7 @@ dialog::backdrop { height: 16px; border: 1px solid var(--border); border-radius: 999px; - background: var(--surface-strong, #262b35); + background: var(--surface-strong, var(--surface-strong-2)); transition: .15s ease; } @@ -5228,7 +5670,7 @@ dialog::backdrop { .switch-control input:checked+.switch-control__track { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 28%, var(--surface-strong, #262b35)); + background: color-mix(in srgb, var(--accent) 28%, var(--surface-strong, var(--surface-strong-2))); } .switch-control input:checked+.switch-control__track::after { @@ -5336,7 +5778,7 @@ dialog::backdrop { height: 7px; overflow: hidden; border-radius: 999px; - background: #0b0e13; + background: var(--code-bg); } .upload-toast__progress>span { @@ -5349,7 +5791,7 @@ dialog::backdrop { } .upload-toast__progress.is-complete>span { - background: var(--success, #42b883); + background: var(--success, var(--success-fallback)); } .upload-toast__progress.is-error>span { @@ -5397,7 +5839,7 @@ dialog::backdrop { .upload-toast__error { margin: 0; - color: #ffb6bd; + color: var(--danger-soft); font-size: .73rem; line-height: 1.4; } @@ -5492,7 +5934,7 @@ dialog::backdrop { place-items: center; border: 0; border-radius: 50%; - background: rgba(255, 255, 255, .07); + background: var(--wash-hover); color: var(--text); cursor: pointer; list-style: none; @@ -5503,7 +5945,7 @@ dialog::backdrop { } .mobile-editor-options[open]>summary { - background: color-mix(in srgb, var(--accent) 24%, rgba(255, 255, 255, .07)); + background: color-mix(in srgb, var(--accent) 24%, var(--wash-hover)); } .mobile-editor-options__panel { @@ -5519,8 +5961,8 @@ dialog::backdrop { overflow-y: auto; border: 1px solid var(--border-strong); border-radius: 10px; - background: var(--surface-strong, #11161e); - box-shadow: 0 16px 40px rgb(0 0 0 / 45%); + background: var(--surface-strong, var(--surface-strong)); + box-shadow: 0 16px 40px var(--shadow-45); } .mobile-editor-options__panel>label:not(.mobile-option-check) { @@ -5538,4 +5980,103 @@ dialog::backdrop { } +} + +/* Note editor polish: clearer actions, lighter canvas, and aligned split columns. */ +.pad-page .header-actions>.secondary-button:not(.danger-button), +.pad-page .page-settings>summary.secondary-button { + border-color: var(--note-action-border); + background: var(--note-action-bg); + color: var(--note-action-text); + box-shadow: 0 1px 2px var(--shadow-28), inset 0 1px 0 var(--wash-medium); +} + +.pad-page .header-actions>.secondary-button:not(.danger-button):hover, +.pad-page .page-settings>summary.secondary-button:hover, +.pad-page .page-settings[open]>summary.secondary-button { + border-color: color-mix(in srgb, var(--accent) 52%, var(--note-action-border)); + background: var(--note-action-hover); + color: var(--text); + box-shadow: 0 4px 12px var(--shadow-28), inset 0 1px 0 var(--wash-medium); + transform: translateY(-1px); +} + +.pad-page .header-actions>.secondary-button:not(.danger-button):active, +.pad-page .page-settings>summary.secondary-button:active { + box-shadow: inset 0 1px 2px var(--shadow-28); + transform: translateY(0); +} + +.pad-page #publish-page:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 55%, var(--note-action-border)); + background: var(--note-action-accent-bg); + color: var(--accent-text-strong); +} + +.pad-page .header-actions>.secondary-button:focus-visible, +.pad-page .page-settings>summary.secondary-button:focus-visible, +.pad-page .editor-toolbar :is(button, summary, select):focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.pad-page .editor-toolbar .toolbar-group>button, +.pad-page .editor-toolbar .toolbar-group>.emoji-picker>summary, +.pad-page .editor-toolbar .toolbar-group>.markdown-more>summary, +.pad-page .editor-toolbar .toolbar-action { + 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-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[open]>summary, +.pad-page .editor-toolbar .toolbar-action: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-group>.emoji-picker>summary:active, +.pad-page .editor-toolbar .toolbar-group>.markdown-more>summary:active, +.pad-page .editor-toolbar .toolbar-action:active { + box-shadow: inset 0 1px 3px var(--shadow-28); + transform: translateY(1px); +} + +.pad-page .editor-column, +.pad-page .editor-shell, +.pad-page .editor-shell::before, +.pad-page .authorship-layer, +.pad-page .line-gutter { + background: var(--editor-canvas) !important; +} + +.pad-page .preview-column, +.pad-page .preview { + background: var(--preview-canvas); +} + +.pad-page .editor-column, +.pad-page .preview-column { + --editor-column-label-height: 30px; +} + +.pad-page .preview-column { + grid-template-rows: var(--editor-column-label-height) minmax(0, 1fr); +} + +.pad-page .column-label { + height: var(--editor-column-label-height); + min-height: var(--editor-column-label-height); +} + +.pad-page .participant-badges:empty { + display: none; } \ No newline at end of file diff --git a/static/editor.html b/static/editor.html index 08024bc..9f4ed32 100644 --- a/static/editor.html +++ b/static/editor.html @@ -4,9 +4,10 @@ - + __DOCUMENT_TITLE__ · __PARENT_TITLE__ - __APP_STYLESHEET__ + __APP_THEME_BOOTSTRAP__ + __APP_STYLESHEET__ __APP_IMPORT_MAP__ __APP_ENTRYPOINT__ @@ -136,7 +137,7 @@ disabled>Full -
+
diff --git a/static/error.html b/static/error.html index 3c5f10d..51b9f5e 100644 --- a/static/error.html +++ b/static/error.html @@ -4,9 +4,10 @@ - + __ERROR_TITLE__ · RustPad + __APP_THEME_BOOTSTRAP__ __APP_STYLESHEET__ diff --git a/static/home.html b/static/home.html index fb31e8f..c946f70 100644 --- a/static/home.html +++ b/static/home.html @@ -4,8 +4,9 @@ - + RustPad + __APP_THEME_BOOTSTRAP__ __APP_STYLESHEET__ __APP_IMPORT_MAP__ __APP_ENTRYPOINT__ @@ -144,6 +145,19 @@ +
+ Interface theme +
+ + +
+
@@ -152,8 +166,10 @@
- - +
+ + +

diff --git a/static/js/home.js b/static/js/home.js index d15b7e7..92bc1b7 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -12,6 +12,7 @@ installGlobalDiagnostics(); import { bindIdentityDialog, handleAccountActionToken, handleAccountConfirmationToken, handleResetToken, handleShareInvitationToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui"; import { getAuthToken, getGuestId, setAccessToken } from "@rustpad/session"; +import { applyTheme, getTheme } from "@rustpad/theme"; import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { safeAppUrl } from "@rustpad/security"; @@ -313,6 +314,8 @@ if (identityDialog) { document.querySelector("#profile-nickname").value = currentSession?.nickname || ""; const profileColor = document.querySelector("#profile-color"); profileColor.value = currentSession?.editor_color || "#7c6cff"; + const selectedTheme = currentSession?.theme || getTheme(); + profileForm.querySelectorAll(`input[name="profile-theme"]`).forEach(input => { input.checked = input.value === selectedTheme; }); document.querySelector("#profile-current-email").value = currentSession?.email || ""; document.querySelector("#profile-email").value = ""; document.querySelector("#profile-new-password").value = ""; @@ -322,7 +325,7 @@ if (identityDialog) { profileMessage.classList.remove("success", "error"); const directoryManaged = Boolean(currentSession?.directory_managed); document.querySelector("#profile-copy").textContent = directoryManaged - ? "Directory account details are read-only. You can change only the displayed nickname." + ? "Directory account details are read-only. You can change the nickname, editor color, and interface theme." : "Manage your local RustPad account."; document.querySelectorAll("[data-local-profile-field]").forEach(element => { element.hidden = directoryManaged; }); document.querySelectorAll("[data-directory-profile-field]").forEach(element => { element.hidden = !directoryManaged; }); @@ -338,7 +341,7 @@ if (identityDialog) { profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); }); profileForm?.addEventListener("submit", async event => { event.preventDefault(); const message = document.querySelector("#profile-message"); message.textContent = ""; message.classList.remove("success", "error"); - try { const selectedColor = document.querySelector("#profile-color").value; const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null); const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null); const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result = await api("/api/auth/profile", { method: "POST", headers: authHeaders(), body: JSON.stringify({ nickname: document.querySelector("#profile-nickname").value.trim(), editor_color: selectedColor, new_email: newEmail, new_password: newPassword, password }) }); message.textContent = result.message; message.classList.add("success"); currentSession.nickname = result.nickname; currentSession.editor_color = result.editor_color; renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); } + try { const selectedColor = document.querySelector("#profile-color").value; const selectedTheme = profileForm.querySelector(`input[name="profile-theme"]:checked`)?.value || "dark"; const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null); const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null); const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result = await api("/api/auth/profile", { method: "POST", headers: authHeaders(), body: JSON.stringify({ nickname: document.querySelector("#profile-nickname").value.trim(), editor_color: selectedColor, theme: selectedTheme, new_email: newEmail, new_password: newPassword, password }) }); message.textContent = result.message; message.classList.add("success"); currentSession.nickname = result.nickname; currentSession.editor_color = result.editor_color; currentSession.theme = result.theme; applyTheme(result.theme); renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); } }); document.querySelector("#profile-delete")?.addEventListener("click", async () => { const message = document.querySelector("#profile-message"); const password = document.querySelector("#profile-password").value; diff --git a/static/js/note-editor.js b/static/js/note-editor.js index becf0a4..97aadde 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -21,6 +21,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { bindNoteFiles } from "@rustpad/note-files"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { toast } from "@rustpad/toast"; +import { getTheme } from "@rustpad/theme"; export function startNoteEditor(adapter) { const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); @@ -182,7 +183,7 @@ export function startNoteEditor(adapter) { setStatus(null, "Connecting…"); } function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; } - async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } + async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } } function renderParticipantBadges(owners) { if (!participantBadges) return; diff --git a/static/js/public.js b/static/js/public.js index c1a87c2..ddc0d2d 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -14,6 +14,7 @@ import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles } from "@rustpad/markdown"; import { toast } from "@rustpad/toast"; +import { getTheme } from "@rustpad/theme"; const token = location.pathname.split("/").filter(Boolean)[1]; const content = document.querySelector("#public-content"); @@ -21,7 +22,7 @@ const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle"); const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error"); let pagePassword = ""; function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; } -async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } +async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '

Failed to load Mermaid.

')); } } async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } } function lockPublicContent(allowTaskUpdates) { content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable')); diff --git a/static/js/session.js b/static/js/session.js index d6fd69a..a263857 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -7,6 +7,8 @@ * See LICENSE file in repository root for details. */ +import { applySessionTheme } from "@rustpad/theme"; + const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version"; const ACCESS_STORAGE_VERSION = "http-only-cookie-v1"; @@ -77,6 +79,7 @@ export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? " export function setAuthSession(session) { localStorage.setItem(AUTH_STATE_KEY, "1"); setNickname(session.nickname); + applySessionTheme(session); } export function clearAuthSession() { localStorage.removeItem(AUTH_STATE_KEY); diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..45f7c06 --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,46 @@ +/* + * 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 STORAGE_KEY = "rustpad:theme"; +const DEFAULT_THEME = "dark"; +const THEMES = new Set(["dark", "light"]); + +function normalizeTheme(value) { + return THEMES.has(value) ? value : DEFAULT_THEME; +} + +function updateBrowserChrome(theme) { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + document.querySelector('meta[name="color-scheme"]')?.setAttribute("content", theme); +} + +export function getTheme() { + return normalizeTheme(document.documentElement.dataset.theme); +} + +export function applyTheme(value, { persist = true } = {}) { + const theme = normalizeTheme(value); + const previousTheme = getTheme(); + updateBrowserChrome(theme); + if (persist) { + try { localStorage.setItem(STORAGE_KEY, theme); } catch { } + } + if (theme !== previousTheme) { + window.dispatchEvent(new CustomEvent("rustpad:theme-change", { detail: { theme } })); + } + return theme; +} + +export function applySessionTheme(session) { + if (session?.theme) applyTheme(session.theme); +} + +window.addEventListener("storage", event => { + if (event.key === STORAGE_KEY && event.newValue) applyTheme(event.newValue, { persist: false }); +}); diff --git a/static/public.html b/static/public.html index 1748d91..36936b1 100644 --- a/static/public.html +++ b/static/public.html @@ -4,8 +4,9 @@ - + Published note · RustPad + __APP_THEME_BOOTSTRAP__ __APP_STYLESHEET__ __APP_IMPORT_MAP__ __APP_ENTRYPOINT__ diff --git a/static/workspace.html b/static/workspace.html index b9a9aed..e2ac25a 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -4,9 +4,10 @@ - + __WORKSPACE_TITLE__ · RustPad - __APP_STYLESHEET__ + __APP_THEME_BOOTSTRAP__ + __APP_STYLESHEET__ __APP_IMPORT_MAP__ __APP_ENTRYPOINT__