@commonpub/layer 0.72.2 → 0.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,8 +18,9 @@ const { hubs: hubsEnabled } = useFeatures();
18
18
 
19
19
  <template>
20
20
  <aside class="cpub-sidebar-col">
21
- <!-- Trending Searches -->
22
- <div class="cpub-sb-block">
21
+ <!-- Trending Searches — hidden entirely when there's no data (a header
22
+ over an empty list reads as broken on quiet/new instances). -->
23
+ <div v-if="trendingSearches?.length" class="cpub-sb-block">
23
24
  <div class="cpub-sb-heading">Trending Searches</div>
24
25
  <ul class="cpub-pop-search-list">
25
26
  <li
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import type { NavItem } from '@commonpub/server';
3
+ import { computeVisibleCount, buildMoreItem } from '../../utils/navOverflow';
3
4
 
4
- defineProps<{
5
+ const props = defineProps<{
5
6
  items: NavItem[];
6
7
  openDropdown: string | null;
7
8
  }>();
@@ -30,22 +31,103 @@ function isVisible(item: NavItem): boolean {
30
31
  if (item.visibleTo === 'admin' && !isAdmin.value) return false;
31
32
  return true;
32
33
  }
34
+
35
+ const shownItems = computed(() => props.items.filter(isVisible));
36
+
37
+ // --- Priority nav (overflow → "More" dropdown) --------------------------
38
+ // The bar used to push the search box and Log in/avatar off-screen whenever
39
+ // the links outgrew the viewport (any width between the 768px hamburger
40
+ // cutover and ~1100px, worse with extra links or wide-link themes). A hidden
41
+ // duplicate row renders EVERY item so their widths are measurable even when
42
+ // collapsed; computeVisibleCount decides the split. SSR renders everything
43
+ // (no measurement) and the client corrects after hydration.
44
+ const containerEl = ref<HTMLElement | null>(null);
45
+ const measureEl = ref<HTMLElement | null>(null);
46
+ const moreMeasureEl = ref<HTMLElement | null>(null);
47
+ const visibleCount = ref(Number.POSITIVE_INFINITY);
48
+
49
+ const displayItems = computed(() => shownItems.value.slice(0, visibleCount.value));
50
+ const moreItem = computed(() => buildMoreItem(shownItems.value.slice(displayItems.value.length)));
51
+
52
+ function measure(): void {
53
+ const container = containerEl.value;
54
+ const row = measureEl.value;
55
+ if (!container || !row) return;
56
+ const widths = Array.from(row.children).map((el) => (el as HTMLElement).offsetWidth);
57
+ const moreWidth = moreMeasureEl.value?.offsetWidth ?? 90;
58
+ visibleCount.value = computeVisibleCount(widths, container.clientWidth, moreWidth);
59
+ }
60
+
61
+ let resizeObserver: ResizeObserver | null = null;
62
+ onMounted(() => {
63
+ measure();
64
+ if (typeof ResizeObserver !== 'undefined' && containerEl.value) {
65
+ resizeObserver = new ResizeObserver(() => measure());
66
+ resizeObserver.observe(containerEl.value);
67
+ }
68
+ });
69
+ onUnmounted(() => resizeObserver?.disconnect());
70
+ watch(shownItems, () => nextTick(measure));
33
71
  </script>
34
72
 
35
73
  <template>
36
- <nav class="cpub-topbar-nav" aria-label="Main navigation">
37
- <template v-for="item in items" :key="item.id">
74
+ <nav ref="containerEl" class="cpub-topbar-nav" aria-label="Main navigation">
75
+ <template v-for="item in displayItems" :key="item.id">
38
76
  <NavDropdown
39
- v-if="item.type === 'dropdown' && isVisible(item)"
77
+ v-if="item.type === 'dropdown'"
40
78
  :item="item"
41
79
  :open="openDropdown === item.id"
42
80
  @toggle="emit('toggle-dropdown', item.id)"
43
81
  @close="emit('close-dropdowns')"
44
82
  />
45
83
  <NavLink
46
- v-else-if="isVisible(item)"
84
+ v-else
47
85
  :item="item"
48
86
  />
49
87
  </template>
88
+
89
+ <NavDropdown
90
+ v-if="moreItem"
91
+ :item="moreItem"
92
+ :open="openDropdown === '__more'"
93
+ @toggle="emit('toggle-dropdown', '__more')"
94
+ @close="emit('close-dropdowns')"
95
+ />
96
+
97
+ <!-- Hidden measurement row: every item at natural width + a More trigger
98
+ replica. visibility:hidden keeps it out of the a11y tree and tab
99
+ order; the layout owner styles .cpub-nav-measure to zero height. -->
100
+ <div ref="measureEl" class="cpub-nav-measure" aria-hidden="true">
101
+ <template v-for="item in shownItems" :key="`m-${item.id}`">
102
+ <span v-if="item.type === 'dropdown'" class="cpub-nav-link cpub-nav-trigger">
103
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
104
+ <i class="fa-solid fa-chevron-down cpub-nav-caret" />
105
+ </span>
106
+ <span v-else class="cpub-nav-link">
107
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
108
+ </span>
109
+ </template>
110
+ </div>
111
+ <div ref="moreMeasureEl" class="cpub-nav-measure" aria-hidden="true">
112
+ <span class="cpub-nav-link cpub-nav-trigger">
113
+ More <i class="fa-solid fa-chevron-down cpub-nav-caret" />
114
+ </span>
115
+ </div>
50
116
  </nav>
51
117
  </template>
118
+
119
+ <style scoped>
120
+ /* Measurement rows are layout-inert under ANY host layout (the base topbar
121
+ and forked layouts like deveco's both wrap this component) — carried here,
122
+ scoped, so a fork can't forget them and render the duplicates visibly. */
123
+ .cpub-nav-measure {
124
+ position: absolute;
125
+ visibility: hidden;
126
+ pointer-events: none;
127
+ height: 0;
128
+ overflow: hidden;
129
+ display: flex;
130
+ gap: 2px;
131
+ white-space: nowrap;
132
+ }
133
+ </style>
@@ -26,6 +26,22 @@ const userMenuOpen = ref(false);
26
26
  const mobileMenuOpen = ref(false);
27
27
  const openDropdown = ref<string | null>(null);
28
28
 
29
+ // Inline topbar search (replaces the old link-styled box that just navigated
30
+ // to /search — users read it as a broken input). Submit goes to the search
31
+ // page with the query; Cmd+K focuses it when visible.
32
+ const searchQuery = ref('');
33
+ const searchInputRef = ref<HTMLInputElement | null>(null);
34
+ function handleSearchSubmit(): void {
35
+ const q = searchQuery.value.trim();
36
+ if (q) {
37
+ navigateTo(`/search?q=${encodeURIComponent(q)}`);
38
+ searchQuery.value = '';
39
+ searchInputRef.value?.blur();
40
+ } else {
41
+ navigateTo('/search');
42
+ }
43
+ }
44
+
29
45
  // Fetch configurable nav items (falls back to defaults on server)
30
46
  // useAsyncData avoids Nuxt's typed route inference which triggers TS2589
31
47
  const { data: navItems } = await useAsyncData('nav-items', () =>
@@ -41,11 +57,17 @@ function closeDropdowns(): void {
41
57
  openDropdown.value = null;
42
58
  }
43
59
 
44
- // Cmd+K / Ctrl+K → search
60
+ // Cmd+K / Ctrl+K → focus the inline search (or go to /search when it's
61
+ // hidden, e.g. on mobile where the bar only shows the magnifier link).
45
62
  function handleGlobalKeydown(e: KeyboardEvent): void {
46
63
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
47
64
  e.preventDefault();
48
- navigateTo('/search');
65
+ const input = searchInputRef.value;
66
+ if (input && input.offsetParent !== null) {
67
+ input.focus();
68
+ } else {
69
+ navigateTo('/search');
70
+ }
49
71
  }
50
72
  }
51
73
 
@@ -114,11 +136,18 @@ const userUsername = computed(() => user.value?.username ?? '');
114
136
  On mobile they live in the hamburger menu (search) and the
115
137
  avatar dropdown (messages/notifications) so the bar can't
116
138
  overflow and hide the hamburger toggle. -->
117
- <NuxtLink to="/search" class="cpub-search-btn cpub-topbar-desktop-only" aria-label="Search">
118
- <i class="fa-solid fa-magnifying-glass"></i>
119
- <span class="cpub-search-text">Search...</span>
120
- <span class="cpub-kbd">&lceil;K</span>
121
- </NuxtLink>
139
+ <form class="cpub-search-btn cpub-topbar-desktop-only" role="search" @submit.prevent="handleSearchSubmit">
140
+ <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
141
+ <input
142
+ ref="searchInputRef"
143
+ v-model="searchQuery"
144
+ type="text"
145
+ class="cpub-search-input"
146
+ placeholder="Search..."
147
+ aria-label="Search"
148
+ />
149
+ <span class="cpub-kbd">&#8984;K</span>
150
+ </form>
122
151
 
123
152
  <template v-if="isAuthenticated">
124
153
  <NuxtLink to="/messages" class="cpub-icon-btn cpub-topbar-desktop-only" title="Messages" aria-label="Messages">
@@ -289,7 +318,17 @@ const userUsername = computed(() => user.value?.username ?? '');
289
318
  .cpub-topbar-logo { display: flex; align-items: center; flex-shrink: 0; text-decoration: none; color: var(--text); }
290
319
 
291
320
  /* Nav styles use :deep() to reach into NavRenderer/NavDropdown/NavLink child components */
292
- :deep(.cpub-topbar-nav) { display: flex; align-items: center; gap: 2px; margin-left: 24px; }
321
+ /* Containment: the nav takes the flexible middle (flex:1) and min-width:0 lets
322
+ it shrink below content width, so NavRenderer can measure its ACTUAL
323
+ allocated space and collapse links that don't fit into the "More" overflow
324
+ dropdown. Before this, a long link list (or a wide-link theme) pushed the
325
+ search box and Log in/avatar clean off the right edge between the 768px
326
+ hamburger cutover and ~1100px — on all three instances, with STOCK links. */
327
+ :deep(.cpub-topbar-nav) {
328
+ display: flex; align-items: center; gap: 2px; margin-left: 24px;
329
+ flex: 1 1 auto; min-width: 0;
330
+ }
331
+ :deep(.cpub-topbar-nav .cpub-nav-link) { white-space: nowrap; flex-shrink: 0; }
293
332
  /* Nav-link shape + active state are token-driven (--cpub-nav-link-*) so a theme can
294
333
  make pill-shaped/larger/accent-colored nav links (deveco) without forking. Defaults
295
334
  = the current 12px square neutral link. */
@@ -349,9 +388,20 @@ const userUsername = computed(() => user.value?.username ?? '');
349
388
  .cpub-topbar-spacer { flex: 1; }
350
389
  .cpub-topbar-actions { display: flex; align-items: center; gap: 6px; }
351
390
 
352
- .cpub-search-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); font-size: 12px; min-width: 180px; text-decoration: none; transition: border-color 0.15s; }
391
+ .cpub-search-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); font-size: 12px; min-width: 180px; text-decoration: none; transition: border-color 0.15s, box-shadow 0.15s; cursor: text; }
353
392
  .cpub-search-btn:hover { border-color: var(--accent-border); color: var(--text); }
393
+ /* The form ring is the ONE focus indicator. */
394
+ .cpub-search-btn:focus-within { border-color: var(--accent); box-shadow: var(--accent-focus-ring); }
354
395
  .cpub-search-btn i { font-size: 11px; }
396
+ .cpub-search-input {
397
+ flex: 1; min-width: 0; border: none; background: none; padding: 0;
398
+ font-size: 12px; font-family: inherit; color: var(--text);
399
+ }
400
+ .cpub-search-input::placeholder { color: var(--text-dim); }
401
+ /* Suppress BOTH outline and box-shadow on the input itself — themes (stoa)
402
+ put a box-shadow glow on every :focus-visible, which would draw a second
403
+ ring inside the form's ring (the deveco double-trace bug). */
404
+ .cpub-search-input:focus-visible { outline: none; box-shadow: none; }
355
405
  .cpub-kbd { margin-left: auto; font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; background: var(--surface3); border: var(--border-width-default) solid var(--border2); color: var(--text-faint); }
356
406
 
357
407
  .cpub-icon-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: transparent; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 13px; position: relative; transition: all 0.15s; text-decoration: none; }
@@ -412,7 +462,7 @@ const userUsername = computed(() => user.value?.username ?? '');
412
462
  the row can't overflow and clip the hamburger + avatar. */
413
463
  .cpub-topbar-desktop-only { display: none !important; }
414
464
  .cpub-dropdown-item--mobile { display: flex; }
415
- .cpub-search-text, .cpub-kbd, .cpub-new-text { display: none; }
465
+ .cpub-new-text { display: none; }
416
466
  .cpub-mobile-toggle { display: flex; }
417
467
  .cpub-mobile-menu { display: block; }
418
468
  .cpub-footer-inner { grid-template-columns: 1fr 1fr; gap: 24px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.72.2",
3
+ "version": "0.73.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,17 +53,17 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/docs": "0.6.3",
57
- "@commonpub/editor": "0.7.11",
58
- "@commonpub/config": "0.21.0",
59
- "@commonpub/schema": "0.40.1",
60
56
  "@commonpub/auth": "0.8.0",
57
+ "@commonpub/config": "0.22.1",
58
+ "@commonpub/editor": "0.7.11",
59
+ "@commonpub/learning": "0.5.2",
61
60
  "@commonpub/protocol": "0.13.0",
61
+ "@commonpub/schema": "0.40.1",
62
62
  "@commonpub/theme-studio": "0.6.1",
63
- "@commonpub/learning": "0.5.2",
64
- "@commonpub/ui": "0.13.1",
65
63
  "@commonpub/server": "2.84.1",
66
- "@commonpub/explainer": "0.7.15"
64
+ "@commonpub/explainer": "0.7.15",
65
+ "@commonpub/ui": "0.13.1",
66
+ "@commonpub/docs": "0.6.3"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@testing-library/jest-dom": "^6.9.1",
package/pages/search.vue CHANGED
@@ -222,18 +222,22 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
222
222
  </span>
223
223
  </div>
224
224
 
225
- <!-- FILTER STRIP -->
225
+ <!-- FILTER STRIP — pills scroll in their own region; the sort/filter
226
+ cluster is pinned outside it so it can never be clipped mid-word
227
+ (the strip used to scroll as one unit, cutting the sort select). -->
226
228
  <div class="cpub-filter-strip">
227
- <button
228
- v-for="pill in typePills"
229
- :key="pill.value"
230
- class="cpub-type-pill"
231
- :class="{ active: activeType === pill.value }"
232
- @click="activeType = pill.value"
233
- >
234
- <i v-if="pill.icon" :class="pill.icon" style="font-size: 10px"></i>
235
- {{ pill.label }}
236
- </button>
229
+ <div class="cpub-type-pills">
230
+ <button
231
+ v-for="pill in typePills"
232
+ :key="pill.value"
233
+ class="cpub-type-pill"
234
+ :class="{ active: activeType === pill.value }"
235
+ @click="activeType = pill.value"
236
+ >
237
+ <i v-if="pill.icon" :class="pill.icon" style="font-size: 10px"></i>
238
+ {{ pill.label }}
239
+ </button>
240
+ </div>
237
241
 
238
242
  <div class="cpub-filter-right">
239
243
  <SortSelect
@@ -565,11 +569,18 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
565
569
  border-top: var(--border-width-default) solid var(--border);
566
570
  margin-top: 14px;
567
571
  padding-top: 0;
572
+ }
573
+
574
+ /* Pills scroll; the sort/filter cluster never moves or clips. */
575
+ .cpub-type-pills {
576
+ display: flex;
577
+ align-items: center;
578
+ flex: 1;
579
+ min-width: 0;
568
580
  overflow-x: auto;
569
581
  scrollbar-width: none;
570
582
  }
571
-
572
- .cpub-filter-strip::-webkit-scrollbar { display: none; }
583
+ .cpub-type-pills::-webkit-scrollbar { display: none; }
573
584
 
574
585
  .cpub-type-pill {
575
586
  font-size: 12px;
@@ -1,5 +1,5 @@
1
- import { searchContent, listHubs, escapeLike } from '@commonpub/server';
2
- import type { ContentSearchOptions, MeiliClient } from '@commonpub/server';
1
+ import { searchContent, listContent, listHubs, escapeLike } from '@commonpub/server';
2
+ import type { ContentSearchOptions, ContentFilters, MeiliClient } from '@commonpub/server';
3
3
  import { users, follows, hubs } from '@commonpub/schema';
4
4
  import { sql, desc, ilike, or, and, isNull, eq, inArray } from 'drizzle-orm';
5
5
  import { z } from 'zod';
@@ -112,6 +112,48 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
112
112
  }
113
113
  } catch { /* Meilisearch not available */ }
114
114
 
115
+ // Mirror-aware path: on a federation-enabled instance the homepage feed is a
116
+ // MERGED local+federated stream (listContent, session 179). Search must see
117
+ // the same universe — on a mirror-heavy instance (commonpub.io) the local
118
+ // content table is nearly empty, so the old local-only search returned 0 for
119
+ // items the instance's own homepage shows. Delegate to listContent (same
120
+ // merge, same pagination invariants, items already in ContentCard's shape)
121
+ // whenever the request uses only filters listContent supports. Search-only
122
+ // filters (author, date range, multiple tags) keep the dedicated local
123
+ // path: federated rows aren't indexed and don't carry those fields.
124
+ // NOTE this branch deliberately OUTRANKS Meilisearch when federation is on:
125
+ // meili only ever indexes LOCAL content, so on a mirror-heavy instance a
126
+ // configured-but-mostly-empty index would shadow the merge and return 0
127
+ // (exactly what happened on commonpub.io, whose compose stack sets
128
+ // MEILI_URL — the first ship of this fix gated on `!meiliClient` and was
129
+ // inert there). `resolveContentQuery` pins status=published +
130
+ // visibility=public so this path can never widen what search exposes.
131
+ const tagList = params.tags?.split(',').map((t) => t.trim()).filter(Boolean) ?? [];
132
+ const usesSearchOnlyFilters = !!(params.author || params.dateFrom || params.dateTo || tagList.length > 1);
133
+ const CONTENT_TYPES = new Set(['project', 'article', 'blog', 'explainer']);
134
+ if (config.features.seamlessFederation && !usesSearchOnlyFilters) {
135
+ const raw: ContentFilters = {
136
+ search: q,
137
+ type: params.type && CONTENT_TYPES.has(params.type) ? (params.type as ContentFilters['type']) : undefined,
138
+ // difficulty + tag suppress the federated merge inside listContent
139
+ // (canMergeFederated) — federated rows lack those columns, so those
140
+ // queries are local-only by construction rather than leaking past the filter.
141
+ difficulty: params.difficulty as ContentFilters['difficulty'],
142
+ tag: tagList[0],
143
+ // Postgres has no relevance ranking (that's Meilisearch's job) — the old
144
+ // path also fell back to recency for 'relevance'.
145
+ sort: params.sort === 'popular' ? 'popular' : 'recent',
146
+ limit,
147
+ offset,
148
+ };
149
+ const { filters, options } = resolveContentQuery(event, raw);
150
+ const result = await listContent(db, filters, options);
151
+ return {
152
+ items: result.items.map((item) => ({ ...item, _resultType: 'content' })),
153
+ total: result.total,
154
+ };
155
+ }
156
+
115
157
  const opts: ContentSearchOptions = {
116
158
  query: q,
117
159
  type: params.type,
@@ -26,9 +26,13 @@ export default defineEventHandler(async (event) => {
26
26
  const path = getRequestURL(event).pathname;
27
27
  if (path.startsWith('/api') || path.startsWith('/_nuxt') || path.startsWith('/__nuxt')) return;
28
28
 
29
- // Collect IDs of code-registered themes so we accept them as valid
30
- const config = useConfig() as unknown as { themes?: Array<{ id: string }> };
31
- const registeredIds = new Set((config.themes ?? []).map((t) => t.id));
29
+ // Code-registered themes: full metadata (family/isDark/pairId), so the
30
+ // resolver can flip light/dark WITHIN a registered family; plus the thin
31
+ // app's config-pinned default theme.
32
+ const config = useConfig() as unknown as {
33
+ themes?: Array<{ id: string; family?: string; isDark?: boolean; pairId?: string }>;
34
+ defaultTheme?: string;
35
+ };
32
36
 
33
37
  // Read user's light/dark preference from cookie
34
38
  const schemeCookie = getCookie(event, 'cpub-color-scheme');
@@ -36,7 +40,7 @@ export default defineEventHandler(async (event) => {
36
40
  ? schemeCookie
37
41
  : null;
38
42
 
39
- const ctx = await resolveThemeContext(userScheme, registeredIds);
43
+ const ctx = await resolveThemeContext(userScheme, config.themes ?? [], config.defaultTheme);
40
44
 
41
45
  event.context.instanceTheme = ctx.instanceTheme;
42
46
  event.context.resolvedTheme = ctx.resolvedTheme;
@@ -32,7 +32,8 @@ export function sanitizeRenderTokens(tokens: Record<string, string>): Record<str
32
32
 
33
33
  interface CachedThemeState {
34
34
  /** The admin's chosen default theme (built-in id, custom data-attr, or registered id) */
35
- defaultTheme: string;
35
+ /** Admin-picked default theme id, or null when never set in the DB. */
36
+ defaultTheme: string | null;
36
37
  /** All DB-stored custom themes, keyed by their data-theme attribute (`cpub-custom-<slug>`) */
37
38
  customByAttr: Map<string, CustomThemeRecord>;
38
39
  /** Instance-wide token overrides applied on top of the active theme */
@@ -45,12 +46,14 @@ let cacheTime = 0;
45
46
  async function loadThemeState(): Promise<CachedThemeState> {
46
47
  const db = useDB();
47
48
 
48
- // 1. Default theme ID.
49
- // Fallback is 'stoa' the default CommonPub theme for fresh installs and any
50
- // instance that has NOT explicitly set `theme.default` in the DB. Instances
51
- // with an explicit setting (e.g. commonpub.io=agora-dark, branded instances)
52
- // are unaffected; this only changes what an unconfigured instance shows.
53
- let defaultTheme = 'stoa';
49
+ // 1. Default theme ID from the DB. `null` when the admin never picked one —
50
+ // resolveThemeContext then falls back to the thin app's `config.defaultTheme`
51
+ // (a branded instance pins its identity in code) and finally to 'stoa' (the
52
+ // CommonPub default for fresh installs). Tracking absence here, instead of
53
+ // baking 'stoa' in, is what lets a config-pinned brand theme take effect —
54
+ // deveco rode the stoa fallback for months, which made its dark mode and
55
+ // theme identity wrong.
56
+ let defaultTheme: string | null = null;
54
57
  try {
55
58
  const [row] = await db
56
59
  .select({ value: instanceSettings.value })
@@ -91,6 +94,14 @@ async function getState(): Promise<CachedThemeState> {
91
94
  return cached;
92
95
  }
93
96
 
97
+ /** The registry metadata the theme resolver needs (subset of RegisteredTheme). */
98
+ export interface RegisteredThemeMeta {
99
+ id: string;
100
+ family?: string;
101
+ isDark?: boolean;
102
+ pairId?: string;
103
+ }
104
+
94
105
  /** Validate a theme ID against built-in, custom, and registered themes. */
95
106
  function isKnownThemeId(id: string, state: CachedThemeState, registeredIds: Set<string>): boolean {
96
107
  if (VALID_THEME_IDS.has(id)) return true;
@@ -99,9 +110,54 @@ function isKnownThemeId(id: string, state: CachedThemeState, registeredIds: Set<
99
110
  return false;
100
111
  }
101
112
 
113
+ /** A registered theme's dark-ness: explicit flag, else inferred from the
114
+ * `-dark` id suffix — naming a theme `foo-dark` should just work. */
115
+ function registeredIsDark(meta: RegisteredThemeMeta): boolean {
116
+ return meta.isDark ?? /(^|-)dark$/.test(meta.id);
117
+ }
118
+
119
+ /**
120
+ * Pick a registered theme's variant for the user's light/dark preference.
121
+ * Pure (exported for tests). Sibling resolution order:
122
+ * 1. explicit `pairId`
123
+ * 2. same `family`, opposite dark-ness
124
+ * 3. NAME CONVENTION: `<id>` ↔ `<id>-dark` — registering two themes named
125
+ * like a pair auto-detects with no family/pairId declared at all.
126
+ * Falls back to the theme itself when no opposite-mode sibling exists.
127
+ */
128
+ export function resolveRegisteredVariant(
129
+ themeId: string,
130
+ userScheme: 'light' | 'dark' | null,
131
+ registered: RegisteredThemeMeta[],
132
+ ): { resolved: string; isDark: boolean; pair: { lightAttr: string; darkAttr: string } | null } {
133
+ const meta = registered.find((t) => t.id === themeId);
134
+ if (!meta) return { resolved: themeId, isDark: false, pair: null };
135
+ const metaDark = registeredIsDark(meta);
136
+
137
+ const conventionId = metaDark ? meta.id.replace(/-dark$/, '') : `${meta.id}-dark`;
138
+ const sibling =
139
+ (meta.pairId ? registered.find((t) => t.id === meta.pairId) : undefined) ??
140
+ (meta.family
141
+ ? registered.find((t) => t.id !== meta.id && t.family === meta.family && registeredIsDark(t) !== metaDark)
142
+ : undefined) ??
143
+ (conventionId !== meta.id
144
+ ? registered.find((t) => t.id === conventionId && registeredIsDark(t) !== metaDark)
145
+ : undefined);
146
+
147
+ const light = metaDark ? sibling : meta;
148
+ const dark = metaDark ? meta : sibling;
149
+ const pair = light && dark ? { lightAttr: light.id, darkAttr: dark.id } : null;
150
+
151
+ let resolvedMeta = meta;
152
+ if (userScheme === 'dark' && dark) resolvedMeta = dark;
153
+ else if (userScheme === 'light' && light) resolvedMeta = light;
154
+
155
+ return { resolved: resolvedMeta.id, isDark: registeredIsDark(resolvedMeta), pair };
156
+ }
157
+
102
158
  export async function getInstanceDefaultTheme(): Promise<string> {
103
159
  const state = await getState();
104
- return state.defaultTheme;
160
+ return state.defaultTheme ?? 'stoa';
105
161
  }
106
162
 
107
163
  /**
@@ -111,7 +167,8 @@ export async function getInstanceDefaultTheme(): Promise<string> {
111
167
  */
112
168
  export async function resolveThemeContext(
113
169
  userScheme: 'light' | 'dark' | null,
114
- registeredIds: Set<string>,
170
+ registered: RegisteredThemeMeta[],
171
+ configDefaultTheme?: string,
115
172
  ): Promise<{
116
173
  /** Final data-theme value for <html> */
117
174
  resolvedTheme: string;
@@ -134,9 +191,16 @@ export async function resolveThemeContext(
134
191
  fontHref: string;
135
192
  }> {
136
193
  const state = await getState();
194
+ const registeredIds = new Set(registered.map((t) => t.id));
137
195
 
138
- // Validate the admin's choice fall back to base if missing/unknown
139
- const admin = isKnownThemeId(state.defaultTheme, state, registeredIds) ? state.defaultTheme : 'base';
196
+ // Default resolution chain: explicit DB setting the thin app's
197
+ // config.defaultTheme (brand identity pinned in code) 'stoa' (CommonPub
198
+ // default). Each candidate must be a KNOWN id; an unknown choice falls
199
+ // through rather than rendering an unstyled attr.
200
+ const candidates = [state.defaultTheme, configDefaultTheme, 'stoa'];
201
+ const admin = candidates.find(
202
+ (c): c is string => !!c && isKnownThemeId(c, state, registeredIds),
203
+ ) ?? 'base';
140
204
 
141
205
  const activeCustom = state.customByAttr.get(admin);
142
206
  let resolved = admin;
@@ -167,14 +231,23 @@ export async function resolveThemeContext(
167
231
  // Load every variant's fonts so a client-side flip already has them.
168
232
  const allFonts = [...new Set(members.flatMap((m) => m.rec.fonts ?? []))];
169
233
  fontHref = allFonts.length ? googleHref(allFonts) : '';
170
- } else {
171
- // Built-in / registered: flip via the family's CSS variants on round-trip.
172
- if (userScheme !== null && VALID_THEME_IDS.has(admin)) {
234
+ } else if (VALID_THEME_IDS.has(admin)) {
235
+ // Built-in: flip via the family's CSS variants.
236
+ if (userScheme !== null) {
173
237
  const family = THEME_TO_FAMILY[admin] ?? 'classic';
174
238
  const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
175
239
  resolved = userScheme === 'dark' ? variants.dark : variants.light;
176
240
  }
177
241
  isDark = IS_DARK[resolved] ?? false;
242
+ } else {
243
+ // Code-registered theme: flip within ITS registered family (pairId or
244
+ // family+isDark), and expose the pair so the client toggle can switch
245
+ // instantly — previously registered themes had NO light/dark support and
246
+ // the toggle silently did nothing (or fell back to the layer family).
247
+ const r = resolveRegisteredVariant(admin, userScheme, registered);
248
+ resolved = r.resolved;
249
+ isDark = r.isDark;
250
+ pair = r.pair;
178
251
  }
179
252
 
180
253
  return {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Priority-nav fit computation — how many top-level nav items fit in the
3
+ * bar's allocated width before the rest collapse into the "More" dropdown.
4
+ * Pure so it's unit-testable; NavRenderer feeds it measured pixel widths.
5
+ */
6
+ import type { NavItem } from '@commonpub/server';
7
+
8
+ /**
9
+ * Greedy fit: if everything fits, show everything; otherwise reserve room
10
+ * for the More trigger and take items in order until the next one would
11
+ * overflow. Zero/unmeasured widths (SSR, jsdom) mean "show everything" —
12
+ * the client re-measures after hydration.
13
+ */
14
+ export function computeVisibleCount(
15
+ itemWidths: number[],
16
+ containerWidth: number,
17
+ moreWidth: number,
18
+ gap = 2,
19
+ ): number {
20
+ if (itemWidths.length === 0) return 0;
21
+ if (containerWidth <= 0 || itemWidths.every((w) => w <= 0)) return itemWidths.length;
22
+
23
+ let total = 0;
24
+ for (let i = 0; i < itemWidths.length; i++) total += itemWidths[i]! + (i > 0 ? gap : 0);
25
+ if (total <= containerWidth) return itemWidths.length;
26
+
27
+ let used = moreWidth;
28
+ let count = 0;
29
+ for (const w of itemWidths) {
30
+ const next = used + gap + w;
31
+ if (next > containerWidth) break;
32
+ used = next;
33
+ count++;
34
+ }
35
+ return count;
36
+ }
37
+
38
+ /**
39
+ * Collapse the overflowed tail into a single synthetic dropdown item.
40
+ * Top-level links become children; an overflowed dropdown contributes its
41
+ * children directly (the panel re-checks feature/auth gates per child, so
42
+ * spreading is safe). Returns null when nothing overflows.
43
+ */
44
+ export function buildMoreItem(overflow: NavItem[]): NavItem | null {
45
+ if (overflow.length === 0) return null;
46
+ const children: NavItem[] = [];
47
+ for (const item of overflow) {
48
+ if (item.type === 'dropdown') {
49
+ children.push(...(item.children ?? []));
50
+ } else {
51
+ children.push(item);
52
+ }
53
+ }
54
+ if (children.length === 0) return null;
55
+ return { id: '__more', type: 'dropdown', label: 'More', icon: '', children } as NavItem;
56
+ }