diff --git a/apps/extension/package.json b/apps/extension/package.json
index 4cce4f3..f549337 100644
--- a/apps/extension/package.json
+++ b/apps/extension/package.json
@@ -1,6 +1,6 @@
{
"name": "@dockmark/extension",
- "version": "1.5.4",
+ "version": "1.5.5",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/extension/public/newtab-library-cache.js b/apps/extension/public/newtab-library-cache.js
index 335a08f..2439873 100644
--- a/apps/extension/public/newtab-library-cache.js
+++ b/apps/extension/public/newtab-library-cache.js
@@ -10,12 +10,13 @@
}
const previousSessions = asArray(snapshot.sessions);
- const [bookmarkPayload, categoryPayload, workspacePayload, enginePayload, settingsPayload] = await Promise.all([
+ const [bookmarkPayload, categoryPayload, workspacePayload, enginePayload, settingsPayload, appearancePayload] = await Promise.all([
requestJson("/api/bookmarks"),
requestJson("/api/categories"),
requestJson("/api/workspaces"),
requestJson("/api/search-engines"),
requestJson("/api/settings/browser"),
+ requestJson("/api/settings/appearance").catch(() => null),
]);
let sessions = previousSessions;
@@ -32,6 +33,9 @@
: {};
settings = normalizeSettings(settingsPayload?.settings);
+ const appearancePreference = window.dockmarkAppearance?.apply(
+ appearancePayload?.settings?.preference ?? snapshot.appearancePreference ?? "system",
+ ) ?? "system";
snapshot = {
version: CACHE_VERSION,
origin,
@@ -46,6 +50,7 @@
searchEngines: asArray(enginePayload?.engines),
smartCollections: asArray(settingsPayload?.smartCollections),
inboxBookmarkIds: asArray(settingsPayload?.inboxBookmarkIds).filter((id) => typeof id === "string" && id.trim()),
+ appearancePreference,
};
await chrome.storage.local.set({
diff --git a/apps/extension/public/newtab-theme.js b/apps/extension/public/newtab-theme.js
new file mode 100644
index 0000000..0ab31bf
--- /dev/null
+++ b/apps/extension/public/newtab-theme.js
@@ -0,0 +1,38 @@
+(() => {
+ const STORAGE_KEY = "dockmarkAppearanceV1";
+
+ function normalize(value) {
+ return value === "light" || value === "dark" || value === "system" ? value : "system";
+ }
+
+ function resolved(preference) {
+ if (preference === "system") {
+ return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
+ }
+ return preference;
+ }
+
+ function apply(preference, persist = true) {
+ const normalized = normalize(preference);
+ const theme = resolved(normalized);
+ document.documentElement.dataset.appearance = normalized;
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.style.colorScheme = theme;
+ const meta = document.querySelector('meta[name="theme-color"]');
+ if (meta) meta.setAttribute("content", theme === "light" ? "#f4f7f5" : "#0a0f0c");
+ if (persist) localStorage.setItem(STORAGE_KEY, normalized);
+ return normalized;
+ }
+
+ function current() {
+ return normalize(localStorage.getItem(STORAGE_KEY));
+ }
+
+ apply(current(), false);
+ const media = matchMedia("(prefers-color-scheme: light)");
+ media.addEventListener("change", () => {
+ if (current() === "system") apply("system", false);
+ });
+
+ window.dockmarkAppearance = { apply, current };
+})();
diff --git a/apps/extension/public/newtab.css b/apps/extension/public/newtab.css
index 04ced49..0c5cb47 100644
--- a/apps/extension/public/newtab.css
+++ b/apps/extension/public/newtab.css
@@ -1,5 +1,5 @@
:root {
- color-scheme: dark;
+ color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--dm-bg: #0a0f0c;
--dm-surface: #0f1512;
@@ -119,3 +119,55 @@ button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid
.footer { align-items: flex-start; flex-direction: column; }
.footer-actions { justify-content: flex-start; flex-wrap: wrap; }
}
+
+
+html[data-theme="light"] {
+ --dm-bg: #f4f7f5;
+ --dm-surface: #edf2ef;
+ --dm-surface-raised: #ffffff;
+ --dm-surface-hover: #e7efea;
+ --dm-surface-selected: #dceae1;
+ --dm-input-bg: #ffffff;
+ --dm-border: #d1ddd6;
+ --dm-border-strong: #b7c9bf;
+ --dm-text: #173126;
+ --dm-text-soft: #4e665b;
+ --dm-text-muted: #73867c;
+ --dm-placeholder: #91a097;
+ --dm-accent: #4f8063;
+ --dm-accent-strong: #315f47;
+ --dm-focus-ring: rgba(79, 128, 99, .14);
+}
+
+html[data-theme="light"] body {
+ background:
+ radial-gradient(circle at 18% 0%, rgba(81, 132, 101, .11), transparent 34rem),
+ radial-gradient(circle at 85% 12%, rgba(86, 115, 98, .05), transparent 26rem),
+ var(--dm-bg);
+}
+html[data-theme="light"] .state-chip,
+html[data-theme="light"] .icon-button,
+html[data-theme="light"] .ghost-button,
+html[data-theme="light"] .command-input,
+html[data-theme="light"] .panel,
+html[data-theme="light"] .setup-card {
+ border-color: var(--dm-border);
+ background: rgba(255,255,255,.86);
+ color: var(--dm-text-soft);
+}
+html[data-theme="light"] .command-input { box-shadow: 0 18px 60px rgba(40,67,54,.08); color: var(--dm-text); }
+html[data-theme="light"] .command-results { border-color: var(--dm-border-strong); background: rgba(255,255,255,.985); box-shadow: 0 26px 80px rgba(40,67,54,.14); }
+html[data-theme="light"] .result-row { border-color: var(--dm-border); }
+html[data-theme="light"] .bookmark-card,
+html[data-theme="light"] .workspace-card { border-color: var(--dm-border); background: #fff; color: var(--dm-text-soft); }
+html[data-theme="light"] .bookmark-card:hover,
+html[data-theme="light"] .workspace-card:hover,
+html[data-theme="light"] .result-row:hover,
+html[data-theme="light"] .result-row.active,
+html[data-theme="light"] .icon-button:hover,
+html[data-theme="light"] .ghost-button:hover { border-color: #a8c7b5; background: var(--dm-surface-selected); color: var(--dm-text); }
+html[data-theme="light"] .state-chip.online { border-color: #a8cbb5; color: #315f47; background: #eef8f1; }
+html[data-theme="light"] .state-chip.offline,
+html[data-theme="light"] .offline-banner { border-color: #dfc995; color: #7a6328; background: #fff8e9; }
+html[data-theme="light"] .footer,
+html[data-theme="light"] .footer-button { color: var(--dm-text-muted); }
diff --git a/apps/extension/public/newtab.html b/apps/extension/public/newtab.html
index ccd31d5..3ace7a7 100644
--- a/apps/extension/public/newtab.html
+++ b/apps/extension/public/newtab.html
@@ -3,10 +3,11 @@
-
+
Dockmark New Tab
+
diff --git a/apps/extension/public/newtab.js b/apps/extension/public/newtab.js
index 0b99545..d3b9e93 100644
--- a/apps/extension/public/newtab.js
+++ b/apps/extension/public/newtab.js
@@ -52,6 +52,7 @@ function emptySnapshot() {
categories: [],
workspaces: [],
searchEngines: [],
+ appearancePreference: "system",
};
}
@@ -284,15 +285,19 @@ async function refreshCloud({ quiet = false } = {}) {
throw new Error("Dockmark site access is not granted. Open the extension popup and reconnect this origin.");
}
- const [bookmarkPayload, categoryPayload, workspacePayload, enginePayload, settingsPayload] = await Promise.all([
+ const [bookmarkPayload, categoryPayload, workspacePayload, enginePayload, settingsPayload, appearancePayload] = await Promise.all([
requestJson("/api/bookmarks"),
requestJson("/api/categories"),
requestJson("/api/workspaces"),
requestJson("/api/search-engines"),
requestJson("/api/settings/browser"),
+ requestJson("/api/settings/appearance").catch(() => null),
]);
settings = normalizeSettings(settingsPayload.settings);
+ const appearancePreference = window.dockmarkAppearance?.apply(
+ appearancePayload?.settings?.preference ?? snapshot.appearancePreference ?? "system",
+ ) ?? "system";
snapshot = {
version: CACHE_VERSION,
origin,
@@ -301,6 +306,7 @@ async function refreshCloud({ quiet = false } = {}) {
categories: asArray(categoryPayload.categories),
workspaces: asArray(workspacePayload.workspaces),
searchEngines: asArray(enginePayload.engines),
+ appearancePreference,
};
await chrome.storage.local.set({
[CACHE_KEY]: snapshot,
@@ -568,6 +574,7 @@ async function bootstrap() {
origin = validOrigin(stored[SERVER_KEY]);
settings = normalizeSettings(stored[SETTINGS_KEY]);
if (cacheIsUsable(stored[CACHE_KEY], origin)) snapshot = stored[CACHE_KEY];
+ if (snapshot.appearancePreference) window.dockmarkAppearance?.apply(snapshot.appearancePreference);
await loadOpenTabs();
renderSnapshot();
diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts
index 6e66f5f..3450082 100644
--- a/apps/extension/wxt.config.ts
+++ b/apps/extension/wxt.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
modules: ["@wxt-dev/module-react"],
manifest: ({ browser }) => ({
name: "Dockmark",
- version: "1.5.4",
+ version: "1.5.5",
description: "Browser bridge for Dockmark tabs, reviewed Capture Inbox, sessions, Smart Collection-aware local-first search, reviewed bookmark sync, local health checks and device pairing.",
icons: {
16: "icons/dockmark-16.png",
diff --git a/apps/web/src/BookmarkManager.tsx b/apps/web/src/BookmarkManager.tsx
index fcd6f19..daf791f 100644
--- a/apps/web/src/BookmarkManager.tsx
+++ b/apps/web/src/BookmarkManager.tsx
@@ -1,5 +1,7 @@
import { useMemo, useState } from "react";
import type { Bookmark, Category } from "@dockmark/core";
+import { AiOrganizationSettings } from "./AiOrganizationSettings";
+import { BookmarkMaintenance } from "./BookmarkMaintenance";
import { BookmarkManager as ExistingBookmarkManager } from "./BookmarkManagerLegacy";
import { ChangeJournal } from "./ChangeJournal";
import { LibraryBulkMaintenance } from "./LibraryBulkMaintenance";
@@ -13,7 +15,17 @@ interface Props {
onChanged: () => Promise;
}
+type LibraryView = "library" | "organize" | "health" | "history";
+
+const views: Array<{ id: LibraryView; label: string; detail: string }> = [
+ { id: "library", label: "Library", detail: "Create, edit, categorize and reorder bookmarks." },
+ { id: "organize", label: "Organize", detail: "Bulk moves, tags and AI-assisted organization." },
+ { id: "health", label: "Health", detail: "Metadata, health checks, cleanup and archive review." },
+ { id: "history", label: "History", detail: "Review recent library changes." },
+];
+
export function BookmarkManager({ bookmarks, categories, bookmarkTags, loading, onChanged }: Props) {
+ const [view, setView] = useState("library");
const [journalRevision, setJournalRevision] = useState(0);
const categoryNameById = useMemo(
() => new Map(categories.map((category) => [category.id, category.name])),
@@ -26,27 +38,61 @@ export function BookmarkManager({ bookmarks, categories, bookmarkTags, loading,
}
return (
- <>
-
-
-
-
- >
+
+
+ {views.map((item) => (
+ setView(item.id)}
+ >
+ {item.label}
+ {item.detail}
+
+ ))}
+
+
+ {view === "library" && (
+
+ )}
+
+ {view === "organize" && (
+
+ )}
+
+ {view === "health" && (
+
+
+
+
+ )}
+
+ {view === "history" && }
+
);
}
diff --git a/apps/web/src/PublicPageSettings.tsx b/apps/web/src/PublicPageSettings.tsx
index c4da430..2495562 100644
--- a/apps/web/src/PublicPageSettings.tsx
+++ b/apps/web/src/PublicPageSettings.tsx
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import type { Bookmark, Category } from "@dockmark/core";
+import { reorderBookmarks, reorderCategories } from "./reorder-api";
interface Props {
bookmarks: Bookmark[];
categories: Category[];
+ onChanged: () => Promise;
}
async function loadSelection() {
@@ -22,10 +24,20 @@ async function setPublished(id: string, published: boolean) {
if (!response.ok) throw new Error(payload.error?.message ?? `Could not update public page (${response.status}).`);
}
-export function PublicPageSettings({ bookmarks, categories }: Props) {
+function swapIds(ids: string[], leftId: string, rightId: string) {
+ const left = ids.indexOf(leftId);
+ const right = ids.indexOf(rightId);
+ if (left < 0 || right < 0) return ids;
+ const next = [...ids];
+ [next[left], next[right]] = [next[right]!, next[left]!];
+ return next;
+}
+
+export function PublicPageSettings({ bookmarks, categories, onChanged }: Props) {
const [published, setPublishedIds] = useState>(() => new Set());
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
+ const [sorting, setSorting] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
@@ -45,9 +57,33 @@ export function PublicPageSettings({ bookmarks, categories }: Props) {
};
}, []);
- const categoryNames = useMemo(
- () => new Map(categories.map((category) => [category.id, category.name])),
- [categories],
+ const bookmarksByCategory = useMemo(() => {
+ const map = new Map();
+ for (const bookmark of bookmarks) {
+ const key = bookmark.categoryId ?? "";
+ const list = map.get(key) ?? [];
+ list.push(bookmark);
+ map.set(key, list);
+ }
+ return map;
+ }, [bookmarks]);
+
+ const groups = useMemo(() => {
+ const categoryGroups = categories.map((category) => ({
+ id: category.id,
+ name: category.name,
+ bookmarks: (bookmarksByCategory.get(category.id) ?? []).filter((bookmark) => published.has(bookmark.id)),
+ }));
+ const uncategorized = (bookmarksByCategory.get("") ?? []).filter((bookmark) => published.has(bookmark.id));
+ return [
+ ...categoryGroups.filter((group) => group.bookmarks.length),
+ ...(uncategorized.length ? [{ id: "", name: "Elsewhere", bookmarks: uncategorized }] : []),
+ ];
+ }, [bookmarksByCategory, categories, published]);
+
+ const unpublished = useMemo(
+ () => bookmarks.filter((bookmark) => !published.has(bookmark.id)),
+ [bookmarks, published],
);
async function toggle(bookmark: Bookmark) {
@@ -69,13 +105,53 @@ export function PublicPageSettings({ bookmarks, categories }: Props) {
}
}
+ async function moveCategory(groupId: string, direction: -1 | 1) {
+ if (!groupId) return;
+ const publicCategoryIds = groups.map((group) => group.id).filter(Boolean);
+ const index = publicCategoryIds.indexOf(groupId);
+ const targetId = publicCategoryIds[index + direction];
+ if (!targetId) return;
+ const allIds = categories.map((category) => category.id);
+ setSorting(true);
+ setError(null);
+ try {
+ await reorderCategories(swapIds(allIds, groupId, targetId));
+ await onChanged();
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : "Could not reorder public categories.");
+ } finally {
+ setSorting(false);
+ }
+ }
+
+ async function moveBookmark(bookmark: Bookmark, direction: -1 | 1) {
+ const categoryKey = bookmark.categoryId ?? "";
+ const publicIds = (bookmarksByCategory.get(categoryKey) ?? [])
+ .filter((candidate) => published.has(candidate.id))
+ .map((candidate) => candidate.id);
+ const index = publicIds.indexOf(bookmark.id);
+ const targetId = publicIds[index + direction];
+ if (!targetId) return;
+ const allIds = (bookmarksByCategory.get(categoryKey) ?? []).map((candidate) => candidate.id);
+ setSorting(true);
+ setError(null);
+ try {
+ await reorderBookmarks(bookmark.categoryId ?? null, swapIds(allIds, bookmark.id, targetId));
+ await onChanged();
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : "Could not reorder public bookmarks.");
+ } finally {
+ setSorting(false);
+ }
+ }
+
return (
PUBLIC PAGE
-
Curate what visitors can see.
-
The public homepage exposes only the bookmarks selected here. Workspaces, Sessions, settings and private bookmarks remain behind authentication.
+
Publish and arrange the curated view.
+
Public groups reuse the canonical Library category and bookmark order. Reordering here updates that shared order, so Web, New Tab and the public page stay consistent instead of drifting into separate layouts.
{published.size} published
@@ -84,30 +160,84 @@ export function PublicPageSettings({ bookmarks, categories }: Props) {
{error &&
{error}
}
-
-
- {bookmarks.map((bookmark) => {
- const checked = published.has(bookmark.id);
- return (
-
- void toggle(bookmark)}
- />
+
+
+
+
+
+
Public layout
+
Move published categories and links without leaving Settings.
+
+
{groups.length} groups
+
+
+
+ {groups.map((group, groupIndex) => (
+
+
+
+ {group.name}
+ {group.bookmarks.length} links
+
+ {group.id && (
+
+ void moveCategory(group.id, -1)}>↑ Group
+ void moveCategory(group.id, 1)}>↓ Group
+
+ )}
+
+
+
+ {group.bookmarks.map((bookmark, index) => (
+
+
⠿
+
+ {bookmark.title}
+ {bookmark.url}
+
+
+ void moveBookmark(bookmark, -1)}>↑
+ void moveBookmark(bookmark, 1)}>↓
+ void toggle(bookmark)}>Unpublish
+
+
+ ))}
+
+
+ ))}
+ {!loading && !groups.length &&
Nothing is public yet. Publish bookmarks from the list beside this preview.
}
+ {loading &&
Loading public selection…
}
+
+
+
+
+
+
+
Add to public page
+
Private bookmarks remain private until explicitly published.
+
+
{unpublished.length}
+
+
+ {unpublished.map((bookmark) => (
+
void toggle(bookmark)}
+ >
{bookmark.title}
- {categoryNames.get(bookmark.categoryId ?? "") ?? "Uncategorized"} · {bookmark.url}
+ {categories.find((category) => category.id === bookmark.categoryId)?.name ?? "Uncategorized"} · {bookmark.url}
- {checked ? "PUBLIC" : "PRIVATE"}
-
- );
- })}
- {!loading && !bookmarks.length && Add bookmarks first, then choose which ones belong on the public page.
}
- {loading && Loading public selection…
}
-
-
+
+ PUBLISH
+
+ ))}
+ {!loading && !unpublished.length &&
Every active bookmark is currently published.
}
+
+
+
);
}
diff --git a/apps/web/src/SettingsPage.tsx b/apps/web/src/SettingsPage.tsx
index 18e9f5f..5436a95 100644
--- a/apps/web/src/SettingsPage.tsx
+++ b/apps/web/src/SettingsPage.tsx
@@ -1,52 +1,103 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import type { Bookmark, Category } from "@dockmark/core";
import { listBookmarks, listCategories } from "./api";
-import { AiOrganizationSettings } from "./AiOrganizationSettings";
import { AppearanceSettings } from "./AppearanceSettings";
-import { BookmarkMaintenance } from "./BookmarkMaintenance";
import { BrowserSettingsManager } from "./BrowserSettingsManager";
import { PublicPageSettings } from "./PublicPageSettings";
import { SecuritySettings } from "./SecuritySettings";
import "./settings.css";
import "./security.css";
+type SettingsView = "appearance" | "browser" | "security" | "public";
+
+const sections: Array<{ id: SettingsView; label: string; detail: string }> = [
+ { id: "appearance", label: "Appearance", detail: "Theme and visual preferences." },
+ { id: "browser", label: "Browser", detail: "New Tab and sync behavior." },
+ { id: "security", label: "Security", detail: "Pairing and trusted devices." },
+ { id: "public", label: "Public Page", detail: "Publish and arrange curated links." },
+];
+
+function settingsView(pathname: string): SettingsView {
+ const segment = pathname.split("/").filter(Boolean).at(-1);
+ return segment === "browser" || segment === "security" || segment === "public" || segment === "appearance"
+ ? segment
+ : "appearance";
+}
+
+function navigate(view: SettingsView) {
+ window.history.pushState({}, "", `/app/settings/${view}`);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
+
export function SettingsPage() {
+ const [path, setPath] = useState(window.location.pathname);
const [bookmarks, setBookmarks] = useState([]);
const [categories, setCategories] = useState([]);
+ const [publicDataLoaded, setPublicDataLoaded] = useState(false);
const [error, setError] = useState(null);
+ const view = useMemo(() => settingsView(path), [path]);
- const refreshData = useCallback(async () => {
+ useEffect(() => {
+ const onPopState = () => setPath(window.location.pathname);
+ window.addEventListener("popstate", onPopState);
+ return () => window.removeEventListener("popstate", onPopState);
+ }, []);
+
+ const refreshPublicData = useCallback(async () => {
setError(null);
try {
const [nextBookmarks, nextCategories] = await Promise.all([listBookmarks(), listCategories()]);
setBookmarks(nextBookmarks);
setCategories(nextCategories);
+ setPublicDataLoaded(true);
} catch (caught) {
- setError(caught instanceof Error ? caught.message : "Could not load settings data.");
+ setError(caught instanceof Error ? caught.message : "Could not load public-page data.");
throw caught;
}
}, []);
useEffect(() => {
- void refreshData().catch(() => undefined);
- }, [refreshData]);
+ if (view === "public" && !publicDataLoaded) {
+ void refreshPublicData().catch(() => undefined);
+ }
+ }, [publicDataLoaded, refreshPublicData, view]);
+
+ const active = sections.find((section) => section.id === view) ?? sections[0]!;
return (
DOCKMARK SETTINGS
-
Private controls, explicit policies.
-
Manage appearance, security, browser sync behavior, metadata and health review, AI-assisted organization, New Tab preferences and the curated public homepage from one place.
+
Policies and preferences, without the library clutter.
+
{active.detail} Bookmark editing, organization, health and change history now live together under Workspace → Bookmarks.
+
+
+ {sections.map((section) => (
+ navigate(section.id)}
+ >
+ {section.label}
+ {section.detail}
+
+ ))}
+
+
{error && {error}
}
-
-
-
-
-
-
+ {view === "appearance" && }
+ {view === "browser" && }
+ {view === "security" && }
+ {view === "public" && (
+ publicDataLoaded
+ ?
+ : Loading public-page library…
+ )}
);
}
diff --git a/apps/web/src/extension-distribution.ts b/apps/web/src/extension-distribution.ts
index 9a3331a..49e94a6 100644
--- a/apps/web/src/extension-distribution.ts
+++ b/apps/web/src/extension-distribution.ts
@@ -1,4 +1,4 @@
-export const EXTENSION_RELEASE_VERSION = "1.5.4";
+export const EXTENSION_RELEASE_VERSION = "1.5.5";
// Fill these when browser-store listings are public. The UI will promote the
// store action while keeping manual downloads visible.
diff --git a/apps/web/src/settings.css b/apps/web/src/settings.css
index d6d79de..e3537f1 100644
--- a/apps/web/src/settings.css
+++ b/apps/web/src/settings.css
@@ -37,3 +37,70 @@
.settings-actions-row { align-items: stretch; flex-direction: column; }
.settings-actions-row .extension-actions { justify-content: flex-end; }
}
+
+
+.settings-subnav {
+ position: sticky;
+ top: calc(var(--dm-private-nav-height) + 14px);
+ z-index: 20;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ margin: -18px 0 28px;
+ padding: 7px;
+ border: 1px solid var(--dm-border);
+ border-radius: 16px;
+ background: color-mix(in srgb, var(--dm-bg) 92%, transparent);
+ backdrop-filter: blur(14px);
+}
+.settings-subnav button {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ padding: 11px 12px;
+ border: 1px solid transparent;
+ border-radius: 11px;
+ color: var(--dm-text-muted);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+.settings-subnav button:hover { color: var(--dm-text); background: var(--dm-bg-soft); }
+.settings-subnav button.active { border-color: var(--dm-border-strong); color: var(--dm-text); background: var(--dm-bg-raised); }
+.settings-subnav strong { font-size: 12px; }
+.settings-subnav span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
+.settings-loading-card { padding: 28px; border: 1px dashed var(--dm-border); border-radius: 16px; color: var(--dm-text-muted); text-align: center; }
+
+.public-curation-layout { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); gap: 14px; }
+.public-layout-card, .public-publish-card { min-width: 0; }
+.public-layout-groups { display: grid; gap: 14px; }
+.public-layout-group { overflow: hidden; border: 1px solid var(--dm-border); border-radius: 14px; background: color-mix(in srgb, var(--dm-bg-soft) 72%, transparent); }
+.public-layout-group > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 11px 12px; border-bottom: 1px solid var(--dm-border); }
+.public-layout-group > header > div:first-child { display: grid; gap: 3px; min-width: 0; }
+.public-layout-group > header strong { font-size: 12px; }
+.public-layout-group > header span { color: var(--dm-text-muted); font-size: 10px; }
+.public-layout-items { display: grid; }
+.public-layout-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; min-height: 58px; padding: 9px 12px; border-top: 1px solid var(--dm-border); }
+.public-layout-item:first-child { border-top: 0; }
+.public-layout-item > span:nth-child(2) { display: grid; gap: 3px; min-width: 0; }
+.public-layout-item strong, .public-layout-item small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.public-layout-item strong { font-size: 12px; }
+.public-layout-item small { color: var(--dm-text-muted); font-size: 10px; }
+.public-order-actions { display: flex; align-items: center; gap: 9px; white-space: nowrap; }
+.public-order-actions button:disabled { opacity: .35; cursor: default; }
+.public-publish-card .public-bookmark-picker { max-height: 640px; }
+.public-bookmark-add { grid-template-columns: minmax(0, 1fr) auto; width: 100%; text-align: left; }
+.public-bookmark-add:disabled { opacity: .55; cursor: default; }
+
+@media (max-width: 860px) {
+ .settings-subnav { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .public-curation-layout { grid-template-columns: 1fr; }
+}
+@media (max-width: 560px) {
+ .settings-subnav { position: static; grid-template-columns: 1fr; margin-top: -24px; }
+ .settings-subnav span { white-space: normal; }
+ .public-layout-group > header, .public-layout-item { align-items: flex-start; }
+ .public-layout-group > header { flex-direction: column; }
+ .public-layout-item { grid-template-columns: auto minmax(0, 1fr); }
+ .public-layout-item .public-order-actions { grid-column: 2; }
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 5f709d0..0f9abd1 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -144,3 +144,42 @@ kbd { padding: 6px 8px; color: #77827c; border: 1px solid #303934; border-radius
.bookmark-actions { width: 100%; padding-left: 55px; }
.row-actions { opacity: 1; }
}
+
+
+.bookmark-management-hub { padding-top: 26px; }
+.section-tabs {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ margin-bottom: -28px;
+ padding: 7px;
+ border: 1px solid var(--dm-border);
+ border-radius: 16px;
+ background: var(--dm-bg-soft);
+}
+.section-tabs button {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ padding: 11px 12px;
+ border: 1px solid transparent;
+ border-radius: 11px;
+ color: var(--dm-text-muted);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+.section-tabs button:hover { color: var(--dm-text); background: var(--dm-bg-card); }
+.section-tabs button.active { border-color: var(--dm-border-strong); color: var(--dm-text); background: var(--dm-bg-raised); }
+.section-tabs strong { font-size: 12px; }
+.section-tabs span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
+.bookmark-section-stack { padding-top: 40px; }
+.bookmark-section-stack > :first-child { margin-top: 0; }
+
+@media (max-width: 760px) {
+ .section-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: -10px; }
+}
+@media (max-width: 520px) {
+ .section-tabs { grid-template-columns: 1fr; }
+ .section-tabs span { white-space: normal; }
+}
diff --git a/scripts/chromium-newtab-smoke.mjs b/scripts/chromium-newtab-smoke.mjs
index 463c58f..242644e 100644
--- a/scripts/chromium-newtab-smoke.mjs
+++ b/scripts/chromium-newtab-smoke.mjs
@@ -100,6 +100,9 @@ try {
}],
};
}
+ if (path === "/api/settings/appearance") {
+ return { settings: { version: 1, preference: "light", updatedAt: now } };
+ }
if (path === "/api/settings/browser") {
return {
settings: {
@@ -136,6 +139,9 @@ try {
}, clickedTargetUrl);
assert.equal(refreshProbe.refreshed, true);
assert.equal(refreshProbe.counts["/api/settings/browser"], 1, "A current New Tab refresh should read /api/settings/browser exactly once.");
+ assert.equal(refreshProbe.counts["/api/settings/appearance"], 1, "A current New Tab refresh should read the shared Web appearance exactly once.");
+ assert.equal(refreshProbe.snapshot.appearancePreference, "light");
+ assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), "light");
assert.deepEqual(refreshProbe.snapshot.bookmarks?.[0]?.tags, ["online", "dev"]);
assert.equal(refreshProbe.snapshot.smartCollections?.[0]?.name, "Online dev");
assert.deepEqual(refreshProbe.snapshot.inboxBookmarkIds, ["online-bookmark"]);
diff --git a/scripts/package-newtab-extension.mjs b/scripts/package-newtab-extension.mjs
index 5de387f..ebcf12c 100644
--- a/scripts/package-newtab-extension.mjs
+++ b/scripts/package-newtab-extension.mjs
@@ -25,7 +25,7 @@ manifest.description = "Dockmark browser bridge with a local-first, offline-capa
manifest.chrome_url_overrides = { newtab: "newtab.html" };
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
-for (const required of ["newtab.html", "newtab.js", "newtab-search.js", "newtab-ux.js", "newtab-parity.js", "newtab-auth.js", "newtab.css", "newtab-icon.svg"]) {
+for (const required of ["newtab.html", "newtab.js", "newtab-search.js", "newtab-ux.js", "newtab-parity.js", "newtab-auth.js", "newtab-theme.js", "newtab.css", "newtab-icon.svg"]) {
await readFile(path.join(variantDir, required));
}