@commonpub/layer 0.21.3 → 0.21.5

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.
@@ -156,7 +156,7 @@ function removeTag(tag: string): void {
156
156
  padding: 7px 10px;
157
157
  font-size: 12px;
158
158
  color: var(--text);
159
- font-family: system-ui, -apple-system, sans-serif;
159
+ font-family: var(--font-sans);
160
160
  outline: none;
161
161
  margin-bottom: 6px;
162
162
  transition: border-color 0.15s;
@@ -14,6 +14,14 @@ const { data: userHubs } = useLazyFetch<{ items: Array<{ id: string; name: strin
14
14
  const sharing = ref(false);
15
15
  const selectedHub = ref('');
16
16
 
17
+ // Parent mounts/unmounts this modal via v-if, so it's always "open" while
18
+ // mounted. A local ref flipped on mount drives useFocusTrap's watcher
19
+ // (false -> true), activating the trap, Esc handler and scroll lock.
20
+ const contentRef = ref<HTMLElement | null>(null);
21
+ const visible = ref(false);
22
+ onMounted(() => { visible.value = true; });
23
+ useFocusTrap(contentRef, () => visible.value, () => emit('close'));
24
+
17
25
  async function handleShare(): Promise<void> {
18
26
  if (!selectedHub.value) return;
19
27
  sharing.value = true;
@@ -35,9 +43,9 @@ async function handleShare(): Promise<void> {
35
43
 
36
44
  <template>
37
45
  <div class="cpub-modal-backdrop" @click.self="emit('close')">
38
- <div class="cpub-modal-content">
46
+ <div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-share-hub-title">
39
47
  <div class="cpub-modal-header">
40
- <h3 class="cpub-modal-title">Share to Hub</h3>
48
+ <h3 id="cpub-share-hub-title" class="cpub-modal-title">Share to Hub</h3>
41
49
  <button class="cpub-modal-close" aria-label="Close" @click="emit('close')"><i class="fa-solid fa-xmark"></i></button>
42
50
  </div>
43
51
 
@@ -74,7 +74,7 @@ function dismissHero(): void {
74
74
  .cpub-hero-dismiss { position: absolute; top: 12px; right: 16px; background: transparent; border: none; color: var(--text-faint); font-size: 12px; cursor: pointer; padding: 4px; z-index: 2; }
75
75
  .cpub-hero-dismiss:hover { color: var(--text-dim); }
76
76
  .cpub-hero-inner { position: relative; z-index: 1; max-width: 1280px; margin: 0 auto; padding: 36px 32px; width: 100%; display: flex; align-items: center; gap: 48px; }
77
- .cpub-hero-content { flex: 1; }
77
+ .cpub-hero-content { flex: 1; min-width: 0; }
78
78
  .cpub-hero-eyebrow { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
79
79
  .cpub-hero-badge { font-size: 9px; font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; padding: 3px 9px; background: var(--yellow-bg); border: var(--border-width-default) solid var(--yellow); color: var(--yellow); }
80
80
  .cpub-hero-badge-live { background: var(--green-bg); border-color: var(--green); color: var(--green); display: flex; align-items: center; gap: 5px; }
@@ -83,5 +83,13 @@ function dismissHero(): void {
83
83
  .cpub-hero-title { font-size: 22px; font-weight: 700; line-height: 1.25; margin-bottom: 10px; }
84
84
  .cpub-hero-title span { color: var(--accent); }
85
85
  .cpub-hero-excerpt { font-size: 13px; color: var(--text-dim); line-height: 1.65; margin-bottom: 20px; max-width: 560px; }
86
- .cpub-hero-actions { display: flex; gap: 8px; }
86
+ .cpub-hero-actions { display: flex; flex-wrap: wrap; gap: 8px; }
87
+
88
+ @media (max-width: 640px) {
89
+ .cpub-hero-inner { flex-direction: column; align-items: flex-start; gap: 20px; padding: 24px 16px; }
90
+ .cpub-hero-title { font-size: 19px; }
91
+ .cpub-hero-excerpt { font-size: 13px; }
92
+ .cpub-hero-actions { width: 100%; }
93
+ .cpub-hero-actions :deep(.cpub-btn) { flex: 1 1 140px; justify-content: center; }
94
+ }
87
95
  </style>
@@ -1,17 +1,27 @@
1
1
  <script setup lang="ts">
2
2
  import type { HomepageSection } from '@commonpub/server';
3
3
 
4
- defineProps<{
4
+ const props = defineProps<{
5
5
  sections: HomepageSection[];
6
6
  /** Which zone to render: 'main' for feed column, 'sidebar' for sidebar */
7
7
  zone: 'main' | 'sidebar' | 'full-width';
8
+ /** If set, only render sections whose type is in this list (zone still applies). */
9
+ restrictTypes?: string[];
10
+ /** If set, skip sections whose type is in this list. */
11
+ excludeTypes?: string[];
8
12
  }>();
9
13
 
14
+ function typeAllowed(type: string): boolean {
15
+ if (props.restrictTypes && !props.restrictTypes.includes(type)) return false;
16
+ if (props.excludeTypes && props.excludeTypes.includes(type)) return false;
17
+ return true;
18
+ }
19
+
10
20
  const features = useFeatures();
11
21
 
12
22
  function isFeatureEnabled(featureGate?: string): boolean {
13
23
  if (!featureGate) return true;
14
- return (features.features as unknown as Record<string, boolean>)?.[featureGate] ?? true;
24
+ return (features.features.value as unknown as Record<string, boolean>)?.[featureGate] ?? true;
15
25
  }
16
26
 
17
27
  /** Section types that render in the full-width zone (above the 2-column layout) */
@@ -29,7 +39,7 @@ function sectionZone(section: HomepageSection): 'full-width' | 'main' | 'sidebar
29
39
 
30
40
  <template>
31
41
  <template v-for="section in sections" :key="section.id">
32
- <template v-if="section.enabled && sectionZone(section) === zone && isFeatureEnabled(section.config.featureGate)">
42
+ <template v-if="section.enabled && sectionZone(section) === zone && typeAllowed(section.type) && isFeatureEnabled(section.config.featureGate)">
33
43
  <HomepageHeroSection
34
44
  v-if="section.type === 'hero'"
35
45
  :config="section.config"
package/error.vue CHANGED
@@ -125,7 +125,7 @@ function handleBack(): void {
125
125
 
126
126
  .cpub-error-btn-primary {
127
127
  background: var(--accent, #5b9cf6);
128
- color: #fff;
128
+ color: var(--color-text-inverse, #fff);
129
129
  box-shadow: var(--shadow-md);
130
130
  }
131
131
 
@@ -110,18 +110,22 @@ const userUsername = computed(() => user.value?.username ?? '');
110
110
  <div class="cpub-topbar-spacer" />
111
111
 
112
112
  <div class="cpub-topbar-actions">
113
- <NuxtLink to="/search" class="cpub-search-btn" aria-label="Search">
113
+ <!-- Search/messages/notifications are desktop-only in the top bar.
114
+ On mobile they live in the hamburger menu (search) and the
115
+ avatar dropdown (messages/notifications) so the bar can't
116
+ overflow and hide the hamburger toggle. -->
117
+ <NuxtLink to="/search" class="cpub-search-btn cpub-topbar-desktop-only" aria-label="Search">
114
118
  <i class="fa-solid fa-magnifying-glass"></i>
115
119
  <span class="cpub-search-text">Search...</span>
116
120
  <span class="cpub-kbd">&lceil;K</span>
117
121
  </NuxtLink>
118
122
 
119
123
  <template v-if="isAuthenticated">
120
- <NuxtLink to="/messages" class="cpub-icon-btn" title="Messages" aria-label="Messages">
124
+ <NuxtLink to="/messages" class="cpub-icon-btn cpub-topbar-desktop-only" title="Messages" aria-label="Messages">
121
125
  <i class="fa-solid fa-envelope"></i>
122
126
  <span v-if="unreadMessages > 0" class="cpub-notif-badge" aria-label="unread messages">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
123
127
  </NuxtLink>
124
- <NuxtLink to="/notifications" class="cpub-icon-btn" title="Notifications" aria-label="Notifications">
128
+ <NuxtLink to="/notifications" class="cpub-icon-btn cpub-topbar-desktop-only" title="Notifications" aria-label="Notifications">
125
129
  <i class="fa-solid fa-bell"></i>
126
130
  <span v-if="unreadCount > 0" class="cpub-notif-badge" aria-label="unread notifications">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
127
131
  </NuxtLink>
@@ -136,6 +140,17 @@ const userUsername = computed(() => user.value?.username ?? '');
136
140
  </span>
137
141
  </button>
138
142
  <div v-if="userMenuOpen" class="cpub-user-dropdown" role="menu">
143
+ <!-- Mobile-only: messages/notifications relocated here from
144
+ the top bar (hidden on desktop, which keeps the icons). -->
145
+ <NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
146
+ <i class="fa-solid fa-envelope"></i> Messages
147
+ <span v-if="unreadMessages > 0" class="cpub-dropdown-count">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
148
+ </NuxtLink>
149
+ <NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile" role="menuitem" @click="userMenuOpen = false">
150
+ <i class="fa-solid fa-bell"></i> Notifications
151
+ <span v-if="unreadCount > 0" class="cpub-dropdown-count">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
152
+ </NuxtLink>
153
+ <div class="cpub-dropdown-divider cpub-dropdown-item--mobile" />
139
154
  <NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-user"></i> Profile</NuxtLink>
140
155
  <NuxtLink to="/dashboard" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
141
156
  <NuxtLink to="/settings" class="cpub-dropdown-item" role="menuitem" @click="userMenuOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
@@ -168,8 +183,7 @@ const userUsername = computed(() => user.value?.username ?? '');
168
183
  <div class="cpub-mobile-divider" />
169
184
  <NuxtLink to="/create" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-plus"></i> Create</NuxtLink>
170
185
  <NuxtLink to="/dashboard" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
171
- <NuxtLink to="/messages" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-envelope"></i> Messages</NuxtLink>
172
- <NuxtLink to="/notifications" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-bell"></i> Notifications</NuxtLink>
186
+ <!-- Messages/Notifications live in the avatar dropdown on mobile. -->
173
187
  </template>
174
188
  </div>
175
189
  </div>
@@ -323,6 +337,10 @@ const userUsername = computed(() => user.value?.username ?? '');
323
337
  .cpub-dropdown-item:hover { background: var(--surface2); color: var(--text); }
324
338
  .cpub-dropdown-item i { width: 14px; text-align: center; font-size: 11px; }
325
339
  .cpub-dropdown-divider { height: 2px; background: var(--border2); margin: 4px 12px; }
340
+ .cpub-dropdown-count { margin-left: auto; min-width: 18px; height: 16px; padding: 0 5px; border-radius: 8px; background: var(--accent); color: var(--color-text-inverse); font-size: 9px; font-weight: 700; font-family: var(--font-mono); line-height: 16px; text-align: center; }
341
+ /* Messages/Notifications in the avatar dropdown are mobile-only —
342
+ desktop keeps the dedicated top-bar icons. */
343
+ .cpub-dropdown-item--mobile { display: none; }
326
344
 
327
345
  .cpub-mobile-toggle { display: none; width: 32px; height: 32px; background: none; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 16px; cursor: pointer; align-items: center; justify-content: center; }
328
346
  .cpub-mobile-menu { display: none; position: fixed; inset: 0; top: 48px; z-index: 99; background: var(--color-surface-overlay-light); }
@@ -352,7 +370,10 @@ const userUsername = computed(() => user.value?.username ?? '');
352
370
 
353
371
  @media (max-width: 768px) {
354
372
  :deep(.cpub-topbar-nav) { display: none; }
355
- .cpub-search-btn { min-width: auto; padding: 6px 8px; }
373
+ /* Search / messages / notifications move off the top bar on mobile so
374
+ the row can't overflow and clip the hamburger + avatar. */
375
+ .cpub-topbar-desktop-only { display: none !important; }
376
+ .cpub-dropdown-item--mobile { display: flex; }
356
377
  .cpub-search-text, .cpub-kbd, .cpub-new-text { display: none; }
357
378
  .cpub-mobile-toggle { display: flex; }
358
379
  .cpub-mobile-menu { display: block; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -51,14 +51,14 @@
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
53
  "@commonpub/auth": "0.6.0",
54
+ "@commonpub/editor": "0.7.9",
54
55
  "@commonpub/docs": "0.6.3",
56
+ "@commonpub/protocol": "0.9.10",
55
57
  "@commonpub/learning": "0.5.2",
56
- "@commonpub/editor": "0.7.9",
57
58
  "@commonpub/explainer": "0.7.12",
58
- "@commonpub/schema": "0.16.0",
59
59
  "@commonpub/ui": "0.8.5",
60
- "@commonpub/protocol": "0.9.10",
61
60
  "@commonpub/server": "2.53.0",
61
+ "@commonpub/schema": "0.16.0",
62
62
  "@commonpub/config": "0.12.0"
63
63
  },
64
64
  "devDependencies": {
@@ -68,5 +68,7 @@
68
68
  "jsdom": "^25.0.1",
69
69
  "vitest": "^3.2.4"
70
70
  },
71
- "scripts": {}
71
+ "scripts": {
72
+ "test": "vitest run"
73
+ }
72
74
  }
package/pages/index.vue CHANGED
@@ -148,13 +148,28 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
148
148
  <!-- Full-width sections (hero) -->
149
149
  <HomepageSectionRenderer :sections="sortedSections" zone="full-width" />
150
150
 
151
+ <!-- Mobile only: hoist the contests section above the feed (contests
152
+ are time-sensitive). Desktop keeps contests in the sidebar; hubs
153
+ and stats stay after the feed on mobile. -->
154
+ <div class="cpub-mobile-contest-hoist">
155
+ <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" :restrict-types="['contests']" />
156
+ </div>
157
+
151
158
  <!-- 2-column layout: main + sidebar -->
152
159
  <div class="cpub-main-layout">
153
160
  <main class="cpub-feed-col">
154
161
  <HomepageSectionRenderer :sections="sortedSections" zone="main" />
155
162
  </main>
156
163
  <aside class="cpub-sidebar">
157
- <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" />
164
+ <!-- display:contents wrappers — layout-transparent, so the
165
+ sidebar's flex gap is unaffected. Desktop shows the full
166
+ sidebar; mobile shows it minus contests (hoisted above). -->
167
+ <div class="cpub-sidebar-desktop">
168
+ <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" />
169
+ </div>
170
+ <div class="cpub-sidebar-mobile">
171
+ <HomepageSectionRenderer :sections="sortedSections" zone="sidebar" :exclude-types="['contests']" />
172
+ </div>
158
173
  <!-- Powered badge -->
159
174
  <div class="cpub-powered-badge">
160
175
  <span class="cpub-powered-text">Powered by</span>
@@ -494,7 +509,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
494
509
  gap: 48px;
495
510
  }
496
511
 
497
- .cpub-hero-content { flex: 1; }
512
+ .cpub-hero-content { flex: 1; min-width: 0; }
498
513
 
499
514
  .cpub-hero-eyebrow {
500
515
  display: flex;
@@ -553,7 +568,7 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
553
568
  max-width: 560px;
554
569
  }
555
570
 
556
- .cpub-hero-actions { display: flex; gap: 8px; }
571
+ .cpub-hero-actions { display: flex; flex-wrap: wrap; gap: 8px; }
557
572
 
558
573
  .cpub-hero-meta {
559
574
  display: flex;
@@ -898,6 +913,17 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
898
913
  gap: 18px;
899
914
  }
900
915
 
916
+ /* Contests hoisted above the feed on mobile only. display:contents keeps
917
+ the sidebar's flex gap intact on desktop (wrapper boxes vanish). */
918
+ .cpub-mobile-contest-hoist { display: none; }
919
+ .cpub-sidebar-desktop { display: contents; }
920
+ .cpub-sidebar-mobile { display: none; }
921
+ @media (max-width: 768px) {
922
+ .cpub-mobile-contest-hoist { display: block; max-width: 1280px; margin: 0 auto; padding: 16px 16px 0; }
923
+ .cpub-sidebar-desktop { display: none; }
924
+ .cpub-sidebar-mobile { display: contents; }
925
+ }
926
+
901
927
  .cpub-sb-head {
902
928
  font-size: 10px;
903
929
  font-family: var(--font-mono);
@@ -1167,6 +1193,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
1167
1193
  .cpub-hero-inner {
1168
1194
  padding: 24px 16px;
1169
1195
  }
1196
+ .cpub-hero-actions { width: 100%; }
1197
+ .cpub-hero-actions .cpub-btn { flex: 1 1 140px; justify-content: center; }
1170
1198
  .cpub-main-layout {
1171
1199
  padding: 16px;
1172
1200
  }
package/pages/search.vue CHANGED
@@ -512,7 +512,7 @@ const { data: relatedCommunities } = await useFetch('/api/hubs', {
512
512
  font-size: 15px;
513
513
  font-weight: 500;
514
514
  color: var(--text);
515
- font-family: system-ui, -apple-system, sans-serif;
515
+ font-family: var(--font-sans);
516
516
  outline: none;
517
517
  transition: border-color 0.15s, box-shadow 0.15s;
518
518
  }
@@ -679,7 +679,7 @@ async function handleReport(): Promise<void> {
679
679
  background: none;
680
680
  cursor: pointer;
681
681
  border-bottom: 3px solid transparent;
682
- font-family: system-ui, -apple-system, sans-serif;
682
+ font-family: var(--font-sans);
683
683
  display: flex;
684
684
  align-items: center;
685
685
  gap: 6px;