+
+
@@ -62,5 +86,9 @@ const props = withDefaults(defineProps
(), {
background-color: var(--background-color-base);
border-inline: var(--border-width-base) solid var(--border-color-muted);
}
+
+ .mobile-wrapper__column--frameless {
+ border-inline: none;
+ }
}
diff --git a/src/components/SpecialPageWrapper.vue b/src/components/SpecialPageWrapper.vue
index 86e64146..43d169f5 100644
--- a/src/components/SpecialPageWrapper.vue
+++ b/src/components/SpecialPageWrapper.vue
@@ -144,8 +144,7 @@ const showHeaderNav = computed(
justify-content: space-between;
gap: var(--spacing-100, 16px);
width: 100%;
- margin-bottom: var(--spacing-100, 16px);
- padding-bottom: 2px;
+ margin-bottom: var(--spacing-50);
border-bottom: 1px solid var(--border-color-base, #a2a9b1);
}
@@ -166,7 +165,7 @@ const showHeaderNav = computed(
* Minerva / mobile — sans title (global `h1` is serif Heading 1); xx-large reads
* smaller than xxx-large without the desktop title-rule line under the header.
*/
-.special-page-wrapper[data-skin='mobile'] .special-page-wrapper__title {
+.special-page-wrapper[data-skin='mobile'] h1.special-page-wrapper__title {
padding-bottom: 0;
font-family:
var(--font-family-system-sans, system-ui, sans-serif), var(--font-family-base, sans-serif);
@@ -179,6 +178,7 @@ const showHeaderNav = computed(
.special-page-wrapper[data-skin='mobile'] .special-page-wrapper__header {
border-bottom: none;
padding-bottom: 0;
+ margin-bottom: var(--spacing-150);
}
.special-page-wrapper__header-aside {
@@ -216,8 +216,7 @@ const showHeaderNav = computed(
}
.special-page-wrapper[data-skin='mobile'] {
- padding: var(--spacing-100);
- padding-block: var(--spacing-150);
+ padding: var(--spacing-100) var(--spacing-100) var(--spacing-300);
}
.special-page-wrapper[data-skin='mobile'] .special-page-wrapper__help {
diff --git a/src/components/article/ArticleHeader.vue b/src/components/article/ArticleHeader.vue
index 7093d65e..6e69ffb5 100644
--- a/src/components/article/ArticleHeader.vue
+++ b/src/components/article/ArticleHeader.vue
@@ -2,6 +2,7 @@
import { computed, inject, ref, watch } from 'vue'
import { CdxButton, CdxIcon, CdxPopover, CdxTextInput } from '@wikimedia/codex'
import {
+ cdxIconBookmarkOutline,
cdxIconDownTriangle,
cdxIconDownload,
cdxIconEdit,
@@ -38,11 +39,17 @@ interface Props {
* Drives the structural mobile vs desktop layout (icon toolbar vs text actions).
*/
skin?: Skin
+ /**
+ * Mobile icon toolbar: watchlist star (default) vs reader bookmark (Minerva
+ * “save for later” affordance in onboarding flows).
+ */
+ bookmarkAffordance?: 'watch' | 'bookmark'
}
const props = withDefaults(defineProps(), {
languagesCount: 18,
skin: undefined,
+ bookmarkAffordance: 'watch',
})
const inheritedSkin = inject(PROTOWIKI_CHROME_SKIN)
@@ -50,6 +57,16 @@ const effectiveSkin = computed(() => props.skin ?? inheritedSkin?.value ??
const { user } = useConfig()
const isLoggedOut = computed(() => user.value === 'logged-out')
+const bookmarkLabel = computed(() =>
+ props.bookmarkAffordance === 'bookmark' ? 'Bookmark' : 'Watch',
+)
+const bookmarkIcon = computed(() =>
+ props.bookmarkAffordance === 'bookmark' ? cdxIconBookmarkOutline : cdxIconUnStar,
+)
+const bookmarkIconLoggedOut = computed(() =>
+ props.bookmarkAffordance === 'bookmark' ? cdxIconBookmarkOutline : cdxIconStar,
+)
+
const languagesButtonLabel = computed(() => {
const n = props.languagesCount ?? 18
return n === 1 ? '1 language' : `${n} languages`
@@ -71,6 +88,9 @@ const emit = defineEmits<{
const langMenuOpen = ref(false)
const langSearch = ref('')
const langAnchor = ref(null)
+const bookmarkAnchor = ref(null)
+
+defineExpose({ bookmarkAnchor })
const filteredLanguageLinks = computed(() => {
const q = langSearch.value.trim().toLowerCase()
@@ -148,10 +168,10 @@ function onLanguagePick(row: ArticleLanguageLink) {
-
+
@@ -184,12 +204,13 @@ function onLanguagePick(row: ArticleLanguageLink) {
-
+
-
+
6 weeks and < 1 year: MM/DD or DD/MM; ≥ 1 year: MM/DD/YYYY or DD/MM/YYYY.
+ */
+export function formatSearchLastUpdatedLabel(iso: string): string {
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) return iso
+
+ const diffMs = Date.now() - date.getTime()
+ const diffMinutes = Math.round(diffMs / 60_000)
+ if (diffMinutes < 1) return 'just now'
+ if (diffMinutes < 60) return `${diffMinutes}m ago`
+
+ const diffHours = Math.round(diffMinutes / 60)
+ if (diffHours < 48) return `${diffHours}h ago`
+
+ const diffDays = Math.max(1, Math.round(diffHours / 24))
+ if (diffDays <= SIX_WEEKS_DAYS) {
+ if (diffDays < 7) return `${diffDays}d ago`
+ const weeks = Math.max(1, Math.round(diffDays / 7))
+ return `${weeks}w ago`
+ }
+
+ return formatCompactCalendarDate(date, diffDays >= 365, prefersDayFirstDate())
+}
+
+/** Relative "Updated …" from an ISO timestamp. */
+export function formatRelativeUpdate(iso: string): string {
+ const duration = formatRelativeUpdateDuration(iso)
+ if (duration === iso) return iso
+ return duration === 'just now' ? 'Updated just now' : `Updated ${duration}`
+}
+
+/** Tooltip for compact page-view count in search cards. */
+export function pageViewsTooltip(count: number): string {
+ return `${count.toLocaleString()} views this month`
+}
+
+/** Tooltip for compact reference count in search cards. */
+export function referenceCountTooltip(count: number): string {
+ return `${count.toLocaleString()} reference${count === 1 ? '' : 's'}`
+}
+
+/** Tooltip for compact last-update label in search cards. */
+export function formatLastUpdatedTooltip(iso: string): string {
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) return `Last updated ${iso}`
- return `Last update ${date.toLocaleDateString(undefined, {
+ const formatted = date.toLocaleString(undefined, {
month: 'long',
+ day: 'numeric',
year: 'numeric',
- })}`
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+ return `Last updated ${formatted}`
}
export function isTrending(signals: {
diff --git a/src/components/chrome/ChromeFooter.vue b/src/components/chrome/ChromeFooter.vue
index 2ce9bdf1..f2cc9dd9 100644
--- a/src/components/chrome/ChromeFooter.vue
+++ b/src/components/chrome/ChromeFooter.vue
@@ -338,7 +338,7 @@ const mobileFooterLinks = [
}
.chrome-footer__mobile-body {
- padding: var(--spacing-100, 16px) var(--spacing-100, 16px) 0;
+ padding: var(--spacing-100, 16px);
background-color: var(--background-color-neutral-subtle, #f8f9fa);
}
diff --git a/src/components/chrome/ChromeHeader.vue b/src/components/chrome/ChromeHeader.vue
index fd7f2feb..b0251c3a 100644
--- a/src/components/chrome/ChromeHeader.vue
+++ b/src/components/chrome/ChromeHeader.vue
@@ -32,6 +32,8 @@ interface Props {
middle?: HeaderItem[]
/** Minerva bar only — override default **`right`** item array. */
right?: HeaderItem[]
+ /** Minerva bar only — when false, the default wordmark is decorative (not a link). */
+ brandLink?: boolean
}
const props = withDefaults(defineProps(), {
@@ -45,6 +47,7 @@ const props = withDefaults(defineProps(), {
left: undefined,
middle: undefined,
right: undefined,
+ brandLink: true,
})
const slots = useSlots()
@@ -77,16 +80,21 @@ const minervaMiddle = computed((): HeaderItem[] | undefined => {
component: defineComponent({
name: 'ChromeHeaderLogoSlot',
setup() {
- return () =>
- h(
+ return () => {
+ const logo = () => slots.logo?.() ?? null
+ if (!props.brandLink) {
+ return h('span', { class: 'minerva-chrome-header__brand' }, logo)
+ }
+ return h(
RouterLink,
{
class: 'minerva-chrome-header__brand',
to: '/',
'aria-label': 'Visit the main page',
},
- () => slots.logo?.() ?? null,
+ logo,
)
+ }
},
}),
},
@@ -129,5 +137,6 @@ const minervaRight = computed((): HeaderItem[] | undefined => props.right)
:right="minervaRight"
:wordmark-src="props.wordmarkSrc"
:mobile-wordmark-src="props.mobileWordmarkSrc"
+ :brand-link="props.brandLink"
/>
diff --git a/src/components/chrome/ChromeMenuPopover.vue b/src/components/chrome/ChromeMenuPopover.vue
new file mode 100644
index 00000000..991a4020
--- /dev/null
+++ b/src/components/chrome/ChromeMenuPopover.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/src/components/chrome/ChromeWrapper.vue b/src/components/chrome/ChromeWrapper.vue
index 5d7a6187..a299b946 100644
--- a/src/components/chrome/ChromeWrapper.vue
+++ b/src/components/chrome/ChromeWrapper.vue
@@ -34,6 +34,8 @@ interface Props {
lastEditedNotice?: boolean
/** When **`false`**, omit the default **`ChromeFooter`** (header-only chrome). */
showFooter?: boolean
+ /** When **`false`**, omit the default **`ChromeHeader`** (content-only chrome). */
+ showHeader?: boolean
/** Forwarded to **`ChromeHeader`** / **`ChromeFooter`** (Meta label; mobile footer line). */
username?: string
/** Forwarded to **`ChromeHeader`**. */
@@ -44,6 +46,8 @@ interface Props {
mobileWordmarkSrc?: string
/** Forwarded to **`ChromeHeader`** (desktop tools only). */
navTools?: ChromeNavTool[]
+ /** Forwarded to **`ChromeHeader`** — when false, the Minerva wordmark is decorative. */
+ brandLink?: boolean
}
const props = withDefaults(defineProps(), {
@@ -53,11 +57,13 @@ const props = withDefaults(defineProps(), {
theme: undefined,
lastEditedNotice: true,
showFooter: true,
+ showHeader: true,
username: undefined,
wordmarkSrc: undefined,
taglineSrc: undefined,
mobileWordmarkSrc: undefined,
navTools: undefined,
+ brandLink: true,
})
const { displayName } = useConfig()
@@ -78,21 +84,24 @@ provide(PROTOWIKI_CHROME_THEME, effectiveTheme)
:lang="props.lang"
:dir="props.dir"
>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -125,5 +134,6 @@ provide(PROTOWIKI_CHROME_THEME, effectiveTheme)
width: 100%;
margin: 0 auto;
padding: 0 0;
+ padding-bottom: var(--keyboard-inset, 0px);
}
diff --git a/src/components/chrome/MinervaChromeHeader.vue b/src/components/chrome/MinervaChromeHeader.vue
index da9c62f4..6359b42a 100644
--- a/src/components/chrome/MinervaChromeHeader.vue
+++ b/src/components/chrome/MinervaChromeHeader.vue
@@ -32,6 +32,8 @@ interface Props {
wordmarkSrc?: string
/** Minerva wordmark; defaults to **`wordmarkSrc`** then EN constant. */
mobileWordmarkSrc?: string
+ /** When false, the default wordmark is decorative (not a link). */
+ brandLink?: boolean
}
const props = withDefaults(defineProps(), {
@@ -41,6 +43,7 @@ const props = withDefaults(defineProps(), {
right: undefined,
wordmarkSrc: undefined,
mobileWordmarkSrc: undefined,
+ brandLink: true,
})
const effectiveTheme = computed(() => props.theme ?? globalTheme.value)
@@ -131,7 +134,7 @@ function isExternalHref(href: string): boolean {