diff --git a/crates/beaver_shell/src/protocols/beaver.rs b/crates/beaver_shell/src/protocols/beaver.rs index c94b9dd..c993283 100644 --- a/crates/beaver_shell/src/protocols/beaver.rs +++ b/crates/beaver_shell/src/protocols/beaver.rs @@ -34,6 +34,7 @@ const KNOWN_APPS: &[&str] = &[ "shared", "system", "theme", + "themes", ]; pub struct BeaverProtocolHandler { @@ -82,6 +83,34 @@ impl ProtocolHandler for BeaverProtocolHandler { let relative_path = &path[1..]; // strip leading '/' + // Discovery endpoint: `beaver://theme/list.json` returns the valid theme + // ids (subdirs of ui/themes that contain an index.css) as a JSON array, + // so the UI can enumerate themes without hardcoding the list. Per-theme + // files are reachable via the generic `beaver://themes//...` root. + if app == "theme" && relative_path == "list.json" { + let themes_dir = self.ui_dir.join("themes"); + let mut ids: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&themes_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() && p.join("index.css").exists() { + if let Some(name) = entry.file_name().to_str() { + ids.push(name.to_owned()); + } + } + } + } + ids.sort(); + let json = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string()); + let mut response = Response::new( + request.current_url(), + ResourceFetchTiming::new(request.timing_type()), + ); + response.headers.typed_insert(ContentType::json()); + *response.body.lock() = ResponseBody::Done(json.into_bytes()); + return Box::pin(std::future::ready(response)); + } + let file_path = if app == "theme" { let theme = self.theme.lock(); self.ui_dir.join("themes").join(&*theme).join(relative_path) diff --git a/ui/atproto/feed.css b/ui/atproto/feed.css index 089782f..6c85258 100644 --- a/ui/atproto/feed.css +++ b/ui/atproto/feed.css @@ -111,6 +111,22 @@ h1 { padding-left: 30px; } +/* Single-account view: each record carries its own relative timestamp (the lone + byline at the top can't), shown small + muted above the record. */ +.account-item-time { + display: block; + text-align: right; + font-size: 0.72rem; + color: var(--color-text-tertiary, #8a817a); + font-variant-numeric: tabular-nums; +} + +/* A timestamp blanked by _dedupeAccountTimes() (same as the one above) takes no + vertical space, so the record tucks under its predecessor. */ +.account-item-time:empty { + display: none; +} + @keyframes feed-group-enter { from { opacity: 0; @@ -279,6 +295,125 @@ h1 { display: none; } +/* Compose panel, toggled by the feather in the actions row (logged-in only). */ +.composer { + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 8px); +} + +.composer[hidden] { + display: none; +} + +.composer-text { + box-sizing: border-box; + width: 100%; + font: inherit; + font-size: 0.95rem; + line-height: 1.5; + resize: none; + min-height: 3.6em; + max-height: 40vh; + padding: 10px 12px; + border-radius: 10px 11px 10px 11px; + border: 1px solid var(--color-border, #e7e2da); + background: var(--bg-app, #faf8f4); + color: var(--color-text, #2a2520); + overflow-wrap: anywhere; +} + +.composer-text::placeholder { + color: var(--color-text-tertiary, #8a817a); +} + +.composer-text:focus { + outline: none; + border-color: var(--color-primary, #a8500f); +} + +.composer-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-md, 16px); +} + +.composer-count { + font-size: 0.8rem; + color: var(--color-text-tertiary, #8a817a); + font-variant-numeric: tabular-nums; +} + +.composer-count.over { + color: var(--color-danger, #b3261e); + font-weight: var(--font-weight-bold, 600); +} + +.composer-post { + font: inherit; + font-weight: var(--font-weight-bold, 600); + font-size: 0.85rem; + padding: 6px 18px; + border: none; + border-radius: 9px 10px 9px 10px; + background: var(--color-primary, #a8500f); + color: var(--bg-menu, #fffdf9); + cursor: pointer; + transition: background var(--transition-fast, 0.12s ease); +} + +.composer-post:hover:not(:disabled) { + background: var(--color-primary-hover, #8f440d); +} + +.composer-post:disabled { + opacity: 0.5; + cursor: default; +} + +.composer-error { + color: var(--color-danger, #b3261e); + font-size: 0.8rem; +} + +.composer-error[hidden] { + display: none; +} + +.composer-context { + display: flex; + align-items: center; + gap: 0.4em; + font-size: 0.85rem; + color: var(--color-text-secondary, #6b6259); +} + +.composer-context[hidden] { + display: none; +} + +#composer-context-text { + margin-right: auto; +} + +.composer-context-cancel { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--color-text-tertiary, #8a817a); + cursor: pointer; +} + +.composer-context-cancel:hover { + background: var(--bg-hover, oklch(35% 0.03 105 / 0.08)); + color: var(--color-text, #2a2520); +} + .status { text-align: center; color: var(--color-text-tertiary, #8a817a); diff --git a/ui/atproto/feed.html b/ui/atproto/feed.html index 8bb8f26..60cef4b 100644 --- a/ui/atproto/feed.html +++ b/ui/atproto/feed.html @@ -25,6 +25,9 @@ + @@ -49,6 +52,30 @@ + + + `; + } + return html`
+ + + + + ${this._isOwnPost() + ? html`` + : ""} +
`; + } + // Author DID: prefer the envelope uri, fall back to the document host. _authorDid() { const uri = this.data?.uri || ""; @@ -355,10 +496,12 @@ class AppBskyFeedPost extends AtRecordElement { } if (type === "app.bsky.richtext.facet#mention" && f.did) { // Same as a byline tap: open the mentioned account's feed, not the raw - // profile record. - return html`${run.text}`; + // profile record. Hovering previews the account's profile card. + return previewLink( + `beaver://atproto/feed.html?did=${encodeURIComponent(f.did)}`, + `at://${f.did}/app.bsky.actor.profile/self`, + run.text, + ); } if (type === "app.bsky.richtext.facet#tag" && f.tag) { return html`` : ""; - // Like / repost actions: feed-only (compact) and only when logged in. - const actions = - this.compact && this.viewerDid - ? html`
- - -
` - : ""; + // Like / repost (+ delete on own posts): feed-only, logged-in only. + const actions = this._renderActions(); return html`
@@ -503,14 +624,7 @@ class AppBskyFeedPost extends AtRecordElement { parentUri ? html`` : "" } @@ -519,7 +633,7 @@ class AppBskyFeedPost extends AtRecordElement { this.compact ? actions : html`` : "" } + ${ + value.createdAt + ? html`
+ + ${formatDateTime(value.createdAt)} +
` + : "" + }
`; } diff --git a/ui/settings/index.css b/ui/settings/index.css index cb86abb..c7767cb 100644 --- a/ui/settings/index.css +++ b/ui/settings/index.css @@ -230,6 +230,7 @@ summary lucide-icon { .theme-preview-header { height: 18px; + background: var(--bg-header); } .theme-preview-main { @@ -238,12 +239,15 @@ summary lucide-icon { align-items: center; justify-content: center; padding: var(--spacing-xs); + background: var(--bg-main); } .theme-preview-webview { width: 80%; height: 100%; border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--bg-webview); box-shadow: 0 1px 3px var(--color-shadow); } @@ -262,56 +266,10 @@ summary lucide-icon { margin-top: var(--spacing-xs); } -/* Theme-specific preview colors */ -/* TODO: Don't hardcode the previews! */ -.preview-default .theme-preview-header { - background: oklch(94% 0.08 115); -} -.preview-default .theme-preview-main { - background: linear-gradient( - to bottom right, - oklch(94% 0.08 115), - oklch(82% 0.1 100) - ); -} -.preview-default .theme-preview-webview { - background: oklch(99% 0.005 105); -} - -.preview-lcars .theme-preview-header { - background: #ff9900; -} -.preview-lcars .theme-preview-main { - background: #000000; -} -.preview-lcars .theme-preview-webview { - background: #1a1a2e; -} - -.preview-meadow .theme-preview-header { - background: oklch(65% 0.08 55); -} -.preview-meadow .theme-preview-main { - background: linear-gradient( - to bottom, - oklch(92% 0.03 220), - oklch(88% 0.08 140) - ); -} -.preview-meadow .theme-preview-webview { - background: oklch(97% 0.01 90); -} - -.preview-winamp .theme-preview-header { - background: #3a3a3a; -} -.preview-winamp .theme-preview-main { - background: #232323; -} -.preview-winamp .theme-preview-webview { - background: #000000; - border: 1px solid #00ff00; -} +/* Preview swatches derive from each theme's own tokens: settings.js fetches the + theme's CSS and injects it scoped to .preview-, so the .theme-preview-* + rules above resolve --bg-header / --bg-main / --bg-webview from the real + theme. Nothing per-theme is hardcoded here. */ /* Setting row with toggle */ .setting-row { diff --git a/ui/settings/index.js b/ui/settings/index.js index 362fb91..6197618 100644 --- a/ui/settings/index.js +++ b/ui/settings/index.js @@ -155,14 +155,14 @@ function setupMobileNavigation() { } // Render theme cards -function renderThemeCards() { +async function renderThemeCards() { const grid = document.getElementById("theme-grid"); if (!grid) { return; } const currentTheme = getTheme(); - const themes = getAvailableThemes(); + const themes = await getAvailableThemes(); grid.innerHTML = themes .map( @@ -184,6 +184,13 @@ function renderThemeCards() { ) .join(""); + // Derive each preview swatch from the theme's own tokens: fetch the theme's + // CSS and inject it scoped to .preview-, so the swatch renders the real + // --bg-* rather than a hand-copied duplicate. + for (const theme of themes) { + injectThemePreview(theme.id); + } + // Add click handlers grid.querySelectorAll(".theme-card").forEach((card) => { card.addEventListener("click", () => { @@ -199,6 +206,25 @@ function renderThemeCards() { }); } +// Scope a theme's :root tokens to its preview card (.preview-) so the swatch +// reflects the theme's actual colors, fetched from the theme itself. Once per id. +async function injectThemePreview(id) { + const styleId = `theme-preview-css-${id}`; + if (document.getElementById(styleId)) { + return; + } + try { + const res = await fetch(`beaver://themes/${id}/index.css`); + const css = await res.text(); + const style = document.createElement("style"); + style.id = styleId; + style.textContent = css.replace(":root", `.preview-${id}`); + document.head.appendChild(style); + } catch (e) { + console.warn(`[settings] could not load theme preview for ${id}:`, e); + } +} + async function setupSearchEngines() { let engines = new SearchEngines(); await engines.ensureReady(); diff --git a/ui/shared/atproto/write.js b/ui/shared/atproto/write.js index e41f172..451e2ae 100644 --- a/ui/shared/atproto/write.js +++ b/ui/shared/atproto/write.js @@ -30,6 +30,160 @@ export async function createRecord(did, collection, record) { return { uri, cid, rkey: uri.split("/").pop() }; } +// UTF-8 byte length of a string (facet indices are byte offsets, not UTF-16 +// code units). +const encoder = new TextEncoder(); +function byteLen(text) { + return encoder.encode(text).length; +} + +// Build a facet spanning the matched run in `m` (a RegExp match): m[1] is the +// leading boundary (start-of-text / whitespace / "(") before the run and is +// excluded, so the facet starts after it and covers `runByteLen` bytes. +function facetAt(text, m, runByteLen, feature) { + const byteStart = byteLen(text.slice(0, m.index + m[1].length)); + return { + index: { byteStart, byteEnd: byteStart + runByteLen }, + features: [feature], + }; +} + +// Resolve an ATProto handle to its DID via the at:// handler (which resolves the +// handle identity and answers describeRepo with the DID). Cached per session, +// null included, so an unresolvable handle isn't retried on every re-detection. +const handleToDid = new Map(); +async function resolveHandle(handle) { + if (handleToDid.has(handle)) { + return handleToDid.get(handle); + } + let did = null; + try { + const resp = await fetch(`at://${handle}`); + if (resp.ok) { + did = (await resp.json())?.did || null; + } + } catch { + // network / decode failure: leave unresolved + } + handleToDid.set(handle, did); + return did; +} + +// Detect @handles and resolve each to an app.bsky.richtext.facet#mention (keyed +// by DID). A handle needs a domain (at least one dot), matching Bluesky; an +// unresolved handle is dropped so it stays plain text, not a broken link. +async function detectMentions(text) { + const re = + /(^|\s|\()@([a-zA-Z0-9][a-zA-Z0-9-]*(?:\.[a-zA-Z0-9][a-zA-Z0-9-]*)+)/g; + const facets = await Promise.all( + [...text.matchAll(re)].map(async (m) => { + const did = await resolveHandle(m[2]); + if (!did) { + return null; + } + return facetAt(text, m, byteLen(`@${m[2]}`), { + $type: "app.bsky.richtext.facet#mention", + did, + }); + }), + ); + return facets.filter(Boolean); +} + +// Detect #hashtags, links, and @mentions in `text` and build app.bsky.richtext +// facets, sorted by byte offset. Tags: a "#" at the start or after whitespace + +// a tag run (the facet covers "#tag"; the feature's `tag` drops the "#"). Links: +// explicit http(s):// URLs only (bare domains are skipped to avoid false +// positives like "e.g."). Trailing sentence punctuation + an unbalanced ")" are +// trimmed off both. Mentions resolve the handle to a DID, so this is async; +// unresolved handles are dropped (see detectMentions). +async function detectFacets(text) { + const facets = []; + + const tagRe = /(^|\s)#([^\s#]+)/gu; + let m; + while ((m = tagRe.exec(text)) !== null) { + const tag = m[2].replace(/[.,!?;:'")\]}>]+$/, ""); + if (!tag) { + continue; + } + facets.push( + facetAt(text, m, byteLen(`#${tag}`), { + $type: "app.bsky.richtext.facet#tag", + tag, + }), + ); + } + + const linkRe = /(^|\s|\()(https?:\/\/\S+)/gi; + while ((m = linkRe.exec(text)) !== null) { + let url = m[2]; + for (;;) { + const last = url[url.length - 1]; + if (".,;:!?'\"".includes(last)) { + url = url.slice(0, -1); + } else if (last === ")" && !url.includes("(")) { + url = url.slice(0, -1); + } else { + break; + } + } + if (!url) { + continue; + } + facets.push( + facetAt(text, m, byteLen(url), { + $type: "app.bsky.richtext.facet#link", + uri: url, + }), + ); + } + + facets.push(...(await detectMentions(text))); + + return facets.sort((a, b) => a.index.byteStart - b.index.byteStart); +} + +/** + * Create an app.bsky.feed.post in `did`'s repo from `text` (#hashtags, links, + * and @mentions in it become facets). Pass `{ reply: { root, parent } }` (strong + * refs) to make it a reply, and/or `{ embed }` (an app.bsky.embed.record) to + * quote a post. + * @returns {Promise<{uri: string, cid: string, rkey: string, value: object}>} + * the new post's ids plus the record value (for an optimistic render). + */ +export async function createPost(did, text, { reply, embed } = {}) { + const facets = await detectFacets(text); + const value = { + $type: "app.bsky.feed.post", + text, + createdAt: new Date().toISOString(), + ...(facets.length ? { facets } : {}), + ...(reply ? { reply } : {}), + ...(embed ? { embed } : {}), + }; + const ids = await createRecord(did, "app.bsky.feed.post", value); + return { ...ids, value }; +} + +/** + * Build the reply strong-refs for replying to a post: `parent` is that post, and + * `root` is the post's own thread root (or the post itself when it's top-level). + * @returns {{ root: {uri: string, cid: string}, parent: {uri: string, cid: string} }} + */ +export function replyRefFromPost(uri, cid, value) { + const parent = { uri, cid }; + return { root: value?.reply?.root || parent, parent }; +} + +/** + * Build an app.bsky.embed.record embed for quoting a post (a strong ref to it). + * @returns {{ $type: string, record: {uri: string, cid: string} }} + */ +export function embedRecordFromPost(uri, cid) { + return { $type: "app.bsky.embed.record", record: { uri, cid } }; +} + /** * Delete a record from `did`'s repo. Resolves on success, throws otherwise. * @returns {Promise} @@ -42,3 +196,11 @@ export async function deleteRecord(did, collection, rkey) { ); } } + +/** + * Delete an app.bsky.feed.post from `did`'s repo by its record key. + * @returns {Promise} + */ +export async function deletePost(did, rkey) { + await deleteRecord(did, "app.bsky.feed.post", rkey); +} diff --git a/ui/shared/theme.js b/ui/shared/theme.js index 636ae54..5cf9957 100644 --- a/ui/shared/theme.js +++ b/ui/shared/theme.js @@ -3,20 +3,45 @@ const THEME_PREF_KEY = "beaver.theme"; const DEFAULT_THEME = "default"; -// Available themes - TODO: make that dynamic -const AVAILABLE_THEMES = [ - { id: "default", name: "Default", description: "Vibrant gradient theme" }, - { id: "lcars", name: "LCARS", description: "Star Trek sci-fi aesthetic" }, - { id: "meadow", name: "Meadow", description: "Peaceful nature-inspired" }, - { id: "winamp", name: "Winamp", description: "Classic media player vibes" }, -]; +// Themes are discovered dynamically: `beaver://theme/list.json` lists the valid +// theme ids, and each theme's metadata lives in `beaver://themes//theme.json` +let themesPromise = null; + +async function loadThemes() { + try { + const ids = await (await fetch("beaver://theme/list.json")).json(); + return await Promise.all( + ids.map(async (id) => { + try { + const res = await fetch(`beaver://themes/${id}/theme.json`); + const meta = await res.json(); + return { + id, + name: meta.name || id, + description: meta.description || "", + }; + } catch { + // Missing/invalid theme.json: fall back to the id as the name. + return { id, name: id, description: "" }; + } + }), + ); + } catch (e) { + console.warn("[theme] could not load the theme list:", e); + return [{ id: DEFAULT_THEME, name: "Default", description: "" }]; + } +} /** - * Get the list of available themes - * @returns {Array<{id: string, name: string, description: string}>} + * Get the list of available themes (discovered from ui/themes/). Cached after + * the first call. + * @returns {Promise>} */ export function getAvailableThemes() { - return AVAILABLE_THEMES; + if (!themesPromise) { + themesPromise = loadThemes(); + } + return themesPromise; } /** @@ -43,15 +68,8 @@ export function getTheme() { * @param {string} themeId - The theme ID to set */ export function setTheme(themeId) { - // Validate theme exists - const theme = AVAILABLE_THEMES.find((t) => t.id === themeId); - if (!theme) { - console.warn(`Theme "${themeId}" not found, using default`); - themeId = DEFAULT_THEME; - } - - // Persist to preferences via navigator.servo API - // This will trigger preferencechanged event on navigator.embedder + // The id comes from getAvailableThemes() (the discovered list), so it's + // already valid; just persist it. An unknown folder would 404 on load. try { if (navigator.servo) { navigator.servo.setStringPreference(THEME_PREF_KEY, themeId); diff --git a/ui/system/desktop/index.js b/ui/system/desktop/index.js index 505feec..ab95289 100644 --- a/ui/system/desktop/index.js +++ b/ui/system/desktop/index.js @@ -181,7 +181,7 @@ const ACTIONS = [ kind: "system", id: "theme", title: "Switch theme…", - sub: "default · meadow · lcars · winamp", + sub: "Change the browser's color theme", icon: "icon-palette", }, { @@ -674,6 +674,10 @@ function closePeersPopover() { peersController?.close(); } +function closeReposPopover() { + reposController?.close(); +} + // Mouse-to-edge re-shows the dock when it's hidden to give users a way // out without needing to remember the shortcut. The edge tested depends // on the current dock position so the trigger always matches where the @@ -1038,6 +1042,7 @@ initShortcuts({ closeTopbarMenu, closeDeskSettings, closePeersPopover, + closeReposPopover, persist, }); const params = new URLSearchParams(window.location.search); diff --git a/ui/system/desktop/palette.js b/ui/system/desktop/palette.js index 6e5cd87..eef2a8f 100644 --- a/ui/system/desktop/palette.js +++ b/ui/system/desktop/palette.js @@ -14,6 +14,11 @@ results are still loading. */ import { SearchController } from "beaver://shared/search/controller.js"; +import { + getAvailableThemes, + getTheme, + setTheme, +} from "beaver://shared/theme.js"; let paletteSelected = 0; // Flat list of palette items rendered in the current frame: index @@ -25,6 +30,10 @@ let paletteFlatItems = []; // opening a new one. Set by openPalette({ editUrl: true }). let paletteEditMode = false; let searchGroups = []; // last groups emitted by SearchController.onResultsChanged +// When set, the palette shows the theme chooser submenu (entered from the +// "Switch theme…" command); `paletteThemes` caches the discovered list. +let paletteThemeSubmenu = false; +let paletteThemes = []; let _deps = null; let searchController = null; @@ -53,6 +62,11 @@ export function init(deps) { elPaletteInput.addEventListener("input", () => { paletteSelected = 0; const value = elPaletteInput.value; + // In the theme submenu, typing just filters the theme list locally. + if (paletteThemeSubmenu) { + renderPalette(value); + return; + } // Fan out to the search controller; its providers (top sites, open tabs, // remote views, fend) will call back via onResultsChanged. Clearing on // empty input flushes stale results so they don't linger as the user @@ -69,7 +83,13 @@ export function init(deps) { elPaletteInput.addEventListener("keydown", (e) => { const count = paletteFlatItems.length; if (e.key === "Escape") { - closePalette(); + // From the theme submenu, Escape steps back to the main palette; from the + // main list it closes the palette. + if (paletteThemeSubmenu) { + exitThemeSubmenu(); + } else { + closePalette(); + } e.preventDefault(); } else if (e.key === "ArrowDown") { paletteSelected = Math.min(count - 1, paletteSelected + 1); @@ -95,6 +115,9 @@ export function init(deps) { paletteSelected = e.detail.index; updatePaletteSelection(); }); + + // Warm the theme cache so the "Switch theme…" submenu opens instantly. + getAvailableThemes().catch(() => {}); } // Navigate to a URL: either reload the active view (edit-URL mode) or @@ -126,6 +149,7 @@ export function openPalette(options = {}) { searchGroups = []; searchController.clear(); paletteEditMode = !!options.editUrl; + paletteThemeSubmenu = false; let initial = ""; if (options.editUrl) { @@ -150,11 +174,59 @@ export function closePalette() { searchController.clear(); searchGroups = []; paletteEditMode = false; + paletteThemeSubmenu = false; +} + +// Enter the theme chooser: clear the query, load + show the theme list, and keep +// the palette open + focused so arrows + Enter pick one. Fully keyboard-driven. +async function enterThemeSubmenu() { + paletteThemeSubmenu = true; + paletteSelected = 0; + searchController.clear(); + searchGroups = []; + _deps.elPaletteInput.value = ""; + renderPalette(""); + paletteThemes = await getAvailableThemes(); + // The palette may have closed (or left the submenu) while the list loaded. + if (paletteThemeSubmenu && document.body.dataset.paletteOpen) { + paletteSelected = 0; + renderPalette(""); + } +} + +// Step back from the theme submenu to the main palette list. +function exitThemeSubmenu() { + paletteThemeSubmenu = false; + paletteSelected = 0; + _deps.elPaletteInput.value = ""; + renderPalette(""); } function buildResults(query) { const { DESKS, PEERS, ACTIONS } = _deps; const q = query.trim().toLowerCase(); + + // Theme chooser submenu: replace the palette contents with one entry per + // available theme (navigable with the same arrows + Enter as any result). + if (paletteThemeSubmenu) { + const current = getTheme(); + const items = paletteThemes + .filter( + (t) => + !q || + t.name.toLowerCase().includes(q) || + t.description.toLowerCase().includes(q), + ) + .map((t) => ({ + kind: "theme-option", + id: t.id, + title: t.name, + sub: t.id === current ? `${t.description} · current` : t.description, + icon: "icon-palette", + })); + return [{ group: "Switch theme", items }]; + } + const groups = []; // Live search results from the SearchController. Each provider becomes @@ -396,6 +468,15 @@ function activatePaletteItem(item) { if (navigator.embedder?.closeCurrentOSWindow) { navigator.embedder.closeCurrentOSWindow(); } + } else if (kind === "system" && id === "theme") { + // Open the theme chooser submenu in place (keeps the palette open). + enterThemeSubmenu(); + return; + } else if (kind === "theme-option") { + // A theme was picked from the submenu: apply it and close. + setTheme(id); + closePalette(); + return; } // Other kinds are mock-only, so just close. closePalette(); diff --git a/ui/system/desktop/popover.js b/ui/system/desktop/popover.js index b1a15f5..38711ad 100644 --- a/ui/system/desktop/popover.js +++ b/ui/system/desktop/popover.js @@ -1,8 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later /** - * Generic popover lifecycle helper. The desktop shell has three - * popovers (topbar page-menu, desk-settings, peers list) that all do + * Generic popover lifecycle helper. The desktop shell has four + * popovers (topbar page-menu, desk-settings, peers list, repos list) that all do * the same dance: toggle `[hidden]`, set a `body[data-Open]` * attribute, install an outside-click handler that closes when the * user clicks outside both the popover and its anchor, and tear @@ -74,6 +74,10 @@ export class Popover { } this.el.hidden = false; document.body.dataset[this.dataAttr] = "true"; + // Neutralize webviews (CSS: body.popover-open web-view) so an outside-click + // over a desk view falls through to the document handler below rather than + // being swallowed by the webview iframe. + document.body.classList.add("popover-open"); this._anchor = anchor; // Defer the outside-click hookup by one frame so the click that // opened the popover doesn't immediately close it via bubbling. @@ -94,6 +98,7 @@ export class Popover { if (!this.el) return; this.el.hidden = true; delete document.body.dataset[this.dataAttr]; + document.body.classList.remove("popover-open"); if (this._outsideHandler) { document.removeEventListener("mousedown", this._outsideHandler, true); this._outsideHandler = null; diff --git a/ui/system/desktop/shortcuts.js b/ui/system/desktop/shortcuts.js index 7a81272..3f9fbe8 100644 --- a/ui/system/desktop/shortcuts.js +++ b/ui/system/desktop/shortcuts.js @@ -17,6 +17,7 @@ export function init(deps) { closeTopbarMenu, closeDeskSettings, closePeersPopover, + closeReposPopover, persist, } = deps; @@ -36,6 +37,7 @@ export function init(deps) { if (document.body.dataset.deskSettingsOpen) closeDeskSettings(); else if (document.body.dataset.topbarMenuOpen) closeTopbarMenu(); else if (document.body.dataset.peersPopoverOpen) closePeersPopover(); + else if (document.body.dataset.reposPopoverOpen) closeReposPopover(); else if (document.body.dataset.paletteOpen) closePalette(); else if (document.body.dataset.overviewOpen) closeOverview(); else if (document.body.dataset.debug) delete document.body.dataset.debug; diff --git a/ui/system/desktop/working.css b/ui/system/desktop/working.css index 98d0fb6..97a15e4 100644 --- a/ui/system/desktop/working.css +++ b/ui/system/desktop/working.css @@ -69,12 +69,16 @@ overflow: hidden; } -/* While a resize drag is in flight, suppress iframe pointer events on - ALL webviews so the iframe content doesn't grab the pointer mid-drag. - - TODO: replace with pointer capture. - */ -body.workshop-resizing web-view { +/* Suppress iframe pointer events on ALL webviews so a pointer interaction over a + desk view reaches the desktop shell instead of the webview. Two independent + states want this: + - workshop-resizing: a resize drag is in flight, so iframe content can't grab + the pointer mid-drag (TODO: replace with pointer capture). + - popover-open: a dock/topbar popover is open, so an outside-click over a + desk view falls through to its document-level outside-click handler + (clicks over chrome already reach it). */ +body.workshop-resizing web-view, +body.popover-open web-view { pointer-events: none; } diff --git a/ui/themes/default/theme.json b/ui/themes/default/theme.json new file mode 100644 index 0000000..2e04dc4 --- /dev/null +++ b/ui/themes/default/theme.json @@ -0,0 +1,4 @@ +{ + "name": "Default", + "description": "Vibrant gradient theme" +} diff --git a/ui/themes/lcars/theme.json b/ui/themes/lcars/theme.json new file mode 100644 index 0000000..f7d7e5d --- /dev/null +++ b/ui/themes/lcars/theme.json @@ -0,0 +1,4 @@ +{ + "name": "LCARS", + "description": "Star Trek sci-fi aesthetic" +} diff --git a/ui/themes/meadow/theme.json b/ui/themes/meadow/theme.json new file mode 100644 index 0000000..f3d4309 --- /dev/null +++ b/ui/themes/meadow/theme.json @@ -0,0 +1,4 @@ +{ + "name": "Meadow", + "description": "Peaceful nature-inspired" +} diff --git a/ui/themes/ocean/index.css b/ui/themes/ocean/index.css new file mode 100644 index 0000000..81fc196 --- /dev/null +++ b/ui/themes/ocean/index.css @@ -0,0 +1,91 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +/* Ocean: sunlit tropical shallows. A warm, bright beach - pale sand and + sea-foam content surfaces, turquoise shallow water in the chrome and accents, + deep-lagoon text, and coral for warnings. The --bg-main backdrop washes from + bright shallows down to warm wet sand. Light, airy, warm. */ +:root { + /* ===== Typography ===== */ + --font-family-base: ReadexPro; + --font-weight-normal: normal; + --font-weight-bold: 600; + --font-size-xl: 1.5em; + --font-size-lg: 1.15em; + --font-size-menu: 0.9em; + --font-size-sm: 0.85em; + --font-size-xs: 0.75em; + + /* ===== Backgrounds ===== */ + /* The shore, seen top-down: turquoise shallows washing onto warm sand. */ + --bg-main: linear-gradient( + to bottom in oklab, + oklch(87% 0.09 200) 0%, + oklch(95% 0.035 88) 100% + ); + --bg-header: oklch(89% 0.06 200); + --bg-footer: oklch(83% 0.08 202); + /* Content surfaces: pale sea-foam, washed with the shallow water's color. */ + --bg-webview: oklch(97% 0.022 195); + --bg-menu: oklch(96% 0.03 194); + --bg-surface: oklch(94% 0.036 193); + /* A soft aqua ripple where the pointer rests. */ + --bg-hover: oklch(78% 0.09 200 / 0.28); + --bg-icon: oklch(85% 0.08 197); + + /* ===== Colors - Text ===== */ + /* Deep lagoon: dark, readable on the sand, tinted toward the water. */ + --color-text-menu: oklch(33% 0.045 205); + --color-text: oklch(33% 0.045 205); + --color-text-secondary: oklch(46% 0.04 203); + --color-text-tertiary: oklch(57% 0.03 200); + /* Sea-foam, sits on the turquoise primary. */ + --color-text-on-header: oklch(99% 0.01 190); + + /* ===== Colors - Interactive ===== */ + /* The shallow water: a clear tropical turquoise. */ + --color-focus-ring: oklch(60% 0.13 200 / 0.5); + --color-resize-handle-hover: oklch(60% 0.13 200 / 0.35); + --color-primary: oklch(54% 0.13 200); + /* Warm coral, like reef and sunburn. */ + --color-danger: oklch(57% 0.17 30); + + --color-menu-item-hover: oklch(80% 0.08 200 / 0.3); + + /* ===== Colors - Borders ===== */ + --color-border: oklch(55% 0.04 200 / 0.22); + + /* ===== Colors - Shadow ===== */ + --color-shadow: oklch(50% 0.05 215 / 0.16); + --color-shadow-menu: oklch(50% 0.05 215 / 0.13); + --color-backdrop: oklch(40% 0.05 210 / 0.35); + + /* ===== Border Radius ===== */ + /* Smoothed like sea glass and tide-worn stone. */ + --radius-sm: 0.6em; + --radius-md: 1.2em; + --radius-lg: 1.8em; + + /* ===== Spacing ===== */ + --spacing-xs: 0.25em; + --spacing-sm: 0.5em; + --spacing-md: 1em; + --spacing-lg: 1.5em; + --spacing-xl: 2.5em; + + /* ===== Sizes ===== */ + --size-header-height: 2em; + --size-footer-height: 1.5em; + --size-icon: 1em; + --size-icon-sm: 1.25em; + --size-menu-width: 155px; + --size-panel-width: 320px; + --size-logo: 3em; + --size-notification-icon: 2em; + + /* ===== Transitions ===== */ + /* A slow tidal drift. */ + --transition-fast: 0.22s cubic-bezier(0.16, 1, 0.3, 1); + + /* ===== Opacity ===== */ + --opacity-muted: 0.62; +} diff --git a/ui/themes/ocean/theme.json b/ui/themes/ocean/theme.json new file mode 100644 index 0000000..2a121a7 --- /dev/null +++ b/ui/themes/ocean/theme.json @@ -0,0 +1,4 @@ +{ + "name": "Ocean", + "description": "Sunlit tropical shallows" +} diff --git a/ui/themes/winamp/theme.json b/ui/themes/winamp/theme.json new file mode 100644 index 0000000..186ffd5 --- /dev/null +++ b/ui/themes/winamp/theme.json @@ -0,0 +1,4 @@ +{ + "name": "Winamp", + "description": "Classic media player vibes" +}