VYPR
Medium severity6.5OSV Advisory· Published Oct 17, 2025· Updated Apr 15, 2026

CVE-2025-62508

CVE-2025-62508

Description

Citizen is a MediaWiki skin that makes extensions part of the cohesive experience. Citizen from 3.3.0 to 3.9.0 are vulnerable to stored cross-site scripting in the sticky header button message handling. In stickyHeader.js the copyButtonAttributes function assigns innerHTML from a source element’s textContent when copying button labels. This causes escaped HTML in system message content (such as citizen-share, citizen-view-history, citizen-view-edit, and nstab-talk) to be interpreted as HTML in the sticky header, allowing injection of arbitrary script by a user with the ability to edit interface messages. The vulnerability allows a user with the editinterface right but without the editsitejs right (by default the sysop group has editinterface but may not have editsitejs) to execute arbitrary JavaScript in other users’ sessions, enabling unauthorized access to sensitive data or actions. The issue is fixed in 3.9.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
starcitizentools/citizen-skinPackagist
>= 3.3.0, < 3.9.03.9.0

Affected products

1

Patches

2
e006923c6dbf

fix(SECURITY): 🐛 fix stored XSS in sticky header button messages

1 file changed · +1 1
  • resources/skins.citizen.scripts/stickyHeader.js+1 1 modified
    @@ -36,7 +36,7 @@ function copyButtonAttributes( from, to ) {
     	copyAttribute( from, to, 'title' );
     	// Copy button labels
     	if ( to.lastElementChild && from.lastElementChild ) {
    -		to.lastElementChild.innerHTML = from.lastElementChild.textContent || '';
    +		to.lastElementChild.textContent = from.lastElementChild.textContent || '';
     	}
     }
     
    
fbb1d4fe9627

feat(stickyHeader): ✨ re-implement sticky header (#1073)

23 files changed · +726 113
  • includes/Components/CitizenComponentButton.php+100 0 added
    @@ -0,0 +1,100 @@
    +<?php
    +
    +declare( strict_types=1 );
    +
    +namespace MediaWiki\Skins\Citizen\Components;
    +
    +/**
    + * CitizenComponentButton component
    + *
    + * This implements the Codex CSS-only button component
    + * Based on VectorComponentButton
    + * @see https://doc.wikimedia.org/codex/main/components/demos/button.html
    + */
    +class CitizenComponentButton implements CitizenComponent {
    +
    +	public function __construct(
    +		private string $label = '',
    +		private ?string $icon = null,
    +		private ?string $id = null,
    +		private ?string $class = null,
    +		private array $attributes = [],
    +		private string $weight = 'normal',
    +		private string $action = 'default',
    +		private string $size = 'medium',
    +		private bool $iconOnly = false,
    +		private ?string $href = null
    +	) {
    +		// Weight can only be normal, primary, or quiet
    +		if ( $this->weight !== 'primary' && $this->weight !== 'quiet' ) {
    +			$this->weight = 'normal';
    +		}
    +		// Action can only be default, progressive or destructive
    +		if ( $this->action !== 'progressive' && $this->action !== 'destructive' ) {
    +			$this->action = 'default';
    +		}
    +		// Size can only be medium or large
    +		if ( $this->size !== 'medium' && $this->size !== 'large' ) {
    +			$this->size = 'medium';
    +		}
    +	}
    +
    +	/**
    +	 * Constructs button classes based on the props
    +	 */
    +	private function getClasses(): string {
    +		$classes = 'cdx-button';
    +		if ( $this->href ) {
    +			$classes .= ' cdx-button--fake-button cdx-button--fake-button--enabled';
    +		}
    +		switch ( $this->weight ) {
    +			case 'primary':
    +				$classes .= ' cdx-button--weight-primary';
    +				break;
    +			case 'quiet':
    +				$classes .= ' cdx-button--weight-quiet';
    +				break;
    +		}
    +		switch ( $this->action ) {
    +			case 'progressive':
    +				$classes .= ' cdx-button--action-progressive';
    +				break;
    +			case 'destructive':
    +				$classes .= ' cdx-button--action-destructive';
    +				break;
    +		}
    +		switch ( $this->size ) {
    +			case 'large':
    +				$classes .= ' cdx-button--size-large';
    +				break;
    +		}
    +		if ( $this->iconOnly ) {
    +			$classes .= ' cdx-button--icon-only';
    +		}
    +		if ( $this->class ) {
    +			$classes .= ' ' . $this->class;
    +		}
    +		return $classes;
    +	}
    +
    +	/**
    +	 * @inheritDoc
    +	 */
    +	public function getTemplateData(): array {
    +		$arrayAttributes = [];
    +		foreach ( $this->attributes as $key => $value ) {
    +			if ( $value === null ) {
    +				continue;
    +			}
    +			$arrayAttributes[] = [ 'key' => $key, 'value' => $value ];
    +		}
    +		return [
    +			'label' => $this->label,
    +			'icon' => $this->icon,
    +			'id' => $this->id,
    +			'class' => $this->getClasses(),
    +			'href' => $this->href,
    +			'array-attributes' => $arrayAttributes
    +		];
    +	}
    +}
    
  • includes/Components/CitizenComponentStickyHeader.php+102 0 added
    @@ -0,0 +1,102 @@
    +<?php
    +
    +declare( strict_types=1 );
    +
    +namespace MediaWiki\Skins\Citizen\Components;
    +
    +/**
    + * CitizenComponentStickyHeader component
    + */
    +class CitizenComponentStickyHeader implements CitizenComponent {
    +
    +	private const SHARE_ICON = [
    +		'id' => 'citizen-share-sticky-header',
    +		'clickTarget' => '#citizen-share',
    +		'icon' => 'wikimedia-share'
    +	];
    +
    +	private const TALK_ICON = [
    +		'id' => 'ca-talk-sticky-header',
    +		'clickTarget' => '#ca-talk > a',
    +		'icon' => 'speechBubbles'
    +	];
    +
    +	private const SUBJECT_ICON = [
    +		'id' => 'ca-subject-sticky-header',
    +		'clickTarget' => '#ca-subject > a',
    +		'icon' => 'article'
    +	];
    +
    +	private const HISTORY_ICON = [
    +		'id' => 'ca-history-sticky-header',
    +		'clickTarget' => '#ca-history > a',
    +		'icon' => 'wikimedia-history'
    +	];
    +
    +	private const EDIT_VE_ICON = [
    +		'id' => 'ca-ve-edit-sticky-header',
    +		'clickTarget' => '#ca-ve-edit > a',
    +		'icon' => 'wikimedia-edit'
    +	];
    +
    +	private const EDIT_WIKITEXT_ICON = [
    +		'id' => 'ca-edit-sticky-header',
    +		'clickTarget' => '#ca-edit > a',
    +		'icon' => 'wikimedia-wikiText'
    +	];
    +
    +	private const EDIT_PROTECTED_ICON = [
    +		'id' => 'ca-viewsource-sticky-header',
    +		'clickTarget' => '#ca-viewsource > a',
    +		'icon' => 'wikimedia-editLock'
    +	];
    +
    +	public function __construct(
    +		private bool $visualEditorTabPositionFirst = false
    +	) {
    +	}
    +
    +	/**
    +	 * Creates array of Button components in the sticky header
    +	 */
    +	private function getIconButtons(): array {
    +		$icons = [
    +			self::SHARE_ICON,
    +			self::HISTORY_ICON,
    +			$this->visualEditorTabPositionFirst ? self::EDIT_VE_ICON : self::EDIT_WIKITEXT_ICON,
    +			$this->visualEditorTabPositionFirst ? self::EDIT_WIKITEXT_ICON : self::EDIT_VE_ICON,
    +			self::EDIT_PROTECTED_ICON,
    +			self::TALK_ICON,
    +			self::SUBJECT_ICON
    +		];
    +		$iconButtons = [];
    +		foreach ( $icons as $icon ) {
    +			$button = new CitizenComponentButton(
    +				"",
    +				$icon[ 'icon' ],
    +				$icon[ 'id' ],
    +				$icon[ 'class' ] ?? '',
    +				[
    +					'tabindex' => '-1',
    +					'data-mw-citizen-click-target' => $icon[ 'clickTarget' ] ?? null,
    +				],
    +				'quiet',
    +				'default',
    +				'large',
    +				true,
    +				null
    +			);
    +			$iconButtons[] = $button->getTemplateData();
    +		}
    +		return $iconButtons;
    +	}
    +
    +	/**
    +	 * @inheritDoc
    +	 */
    +	public function getTemplateData(): array {
    +		return [
    +			'array-icon-buttons' => $this->getIconButtons()
    +		];
    +	}
    +}
    
  • includes/SkinCitizen.php+31 1 modified
    @@ -32,6 +32,7 @@
     use MediaWiki\Skins\Citizen\Components\CitizenComponentPageTools;
     use MediaWiki\Skins\Citizen\Components\CitizenComponentSearchBox;
     use MediaWiki\Skins\Citizen\Components\CitizenComponentSiteStats;
    +use MediaWiki\Skins\Citizen\Components\CitizenComponentStickyHeader;
     use MediaWiki\Skins\Citizen\Components\CitizenComponentUserInfo;
     use MediaWiki\Skins\Citizen\Partials\BodyContent;
     use MediaWiki\Skins\Citizen\Partials\Metadata;
    @@ -155,6 +156,9 @@ public function getTemplateData(): array {
     				$title,
     				$user,
     				$parentData['data-portlets']['data-user-page']
    +			),
    +			'data-sticky-header' => new CitizenComponentStickyHeader(
    +				$this->isVisualEditorTabPositionFirst( $parentData['data-portlets']['data-views'] )
     			)
     		];
     
    @@ -165,16 +169,42 @@ public function getTemplateData(): array {
     			}
     		}
     
    +		// HACK: So that we only get the tagline once
    +		$parentData['data-sticky-header']['html-tagline'] = $parentData['data-page-heading']['html-tagline'];
    +
     		// HACK: So that we can use Icon.mustache in Header__logo.mustache
     		$parentData['data-logos']['icon-home'] = 'home';
     
    +		$isTocEnabled = !empty( $parentData['data-toc'][ 'array-sections' ] );
    +		if ( $isTocEnabled ) {
    +			$this->getOutput()->addBodyClasses( 'citizen-toc-enabled' );
    +		}
    +
     		return array_merge( $parentData, [
     			// Booleans
    -			'toc-enabled' => !empty( $parentData['data-toc'] ),
    +			'toc-enabled' => $isTocEnabled,
     			'html-body-content--formatted' => $bodycontent->decorateBodyContent( $parentData['html-body-content'] )
     		] );
     	}
     
    +	/**
    +	 * Check whether Visual Editor Tab Position is first
    +	 * From Vector 2022
    +	 *
    +	 * @param array $dataViews
    +	 * @return bool
    +	 */
    +	private function isVisualEditorTabPositionFirst( array $dataViews ): bool {
    +		$names = [ 've-edit', 'edit' ];
    +		// find if under key 'name' 've-edit' or 'edit' is the before item in the array
    +		for ( $i = 0; $i < count( $dataViews[ 'array-items' ] ); $i++ ) {
    +			if ( in_array( $dataViews[ 'array-items' ][ $i ][ 'name' ], $names ) ) {
    +				return $dataViews[ 'array-items' ][ $i ][ 'name' ] === $names[ 0 ];
    +			}
    +		}
    +		return false;
    +	}
    +
     	/**
     	 * @inheritDoc
     	 *
    
  • resources/mixins.less+0 1 modified
    @@ -65,7 +65,6 @@
     
     // Header card popups
     .mixin-citizen-header-card( @position ) {
    -	position: absolute;
     	right: 0;
     	bottom: 100%;
     	left: 0;
    
  • resources/skins.citizen.scripts/setupObservers.js+7 39 modified
    @@ -1,7 +1,6 @@
     // Adopted from Vector 2022
     const
     	scrollObserver = require( './scrollObserver.js' ),
    -	resizeObserver = require( './resizeObserver.js' ),
     	initSectionObserver = require( './sectionObserver.js' ),
     	stickyHeader = require( './stickyHeader.js' ),
     	initTableOfContents = require( './tableOfContents.js' ),
    @@ -187,13 +186,11 @@ const main = () => {
     
     	const
     		stickyHeaderElement = document.getElementById( stickyHeader.STICKY_HEADER_ID ),
    -		stickyIntersection = document.getElementById( 'citizen-page-header-sticky-sentinel' ),
    -		stickyPlaceholder = document.getElementById( stickyHeader.STICKY_HEADER_PLACEHOLDER_ID );
    +		stickyIntersection = document.getElementById( 'citizen-page-header-sticky-sentinel' );
     
     	// eslint-disable-next-line es-x/no-optional-chaining
     	const shouldStickyHeader = getComputedStyle( stickyIntersection )?.getPropertyValue( 'display' ) !== 'none';
     	const isStickyHeaderAllowed = !!stickyHeaderElement &&
    -		!!stickyPlaceholder &&
     		!!stickyIntersection &&
     		shouldStickyHeader;
     
    @@ -209,13 +206,17 @@ const main = () => {
     		10
     	);
     
    +	if ( isStickyHeaderAllowed ) {
    +		stickyHeader.init( stickyHeaderElement );
    +	}
    +
     	const resumeStickyHeader = () => {
     		if (
     			isStickyHeaderAllowed &&
     			!document.body.classList.contains( stickyHeader.STICKY_HEADER_VISIBLE_CLASS ) &&
     			document.body.classList.contains( PAGE_TITLE_INTERSECTION_CLASS )
     		) {
    -			stickyHeader.show( stickyHeaderElement, stickyPlaceholder );
    +			stickyHeader.show( stickyHeaderElement );
     			if ( document.documentElement.classList.contains( 'citizen-feature-autohide-navigation-clientpref-1' ) ) {
     				scrollDirectionObserver.resume();
     			}
    @@ -224,7 +225,7 @@ const main = () => {
     
     	const pauseStickyHeader = () => {
     		if ( document.body.classList.contains( stickyHeader.STICKY_HEADER_VISIBLE_CLASS ) ) {
    -			stickyHeader.hide( stickyHeaderElement, stickyPlaceholder );
    +			stickyHeader.hide( stickyHeaderElement );
     			scrollDirectionObserver.pause();
     		}
     	};
    @@ -242,39 +243,6 @@ const main = () => {
     
     	pageHeaderObserver.observe( stickyIntersection );
     
    -	// Initialize var
    -	let bodyWidth = 0;
    -	const bodyObserver = resizeObserver.initResizeObserver(
    -		// onResize
    -		() => {},
    -		// onResizeStart
    -		( entry ) => {
    -			// eslint-disable-next-line es-x/no-optional-chaining
    -			bodyWidth = entry.borderBoxSize?.[ 0 ].inlineSize;
    -			// Disable all CSS animation during resize
    -			if ( document.documentElement.classList.contains( 'citizen-animations-ready' ) ) {
    -				document.documentElement.classList.remove( 'citizen-animations-ready' );
    -			}
    -		},
    -		// onResizeEnd
    -		( entry ) => {
    -			// eslint-disable-next-line es-x/no-optional-chaining
    -			const newBodyWidth = entry.borderBoxSize?.[ 0 ].inlineSize;
    -			const shouldRecalcStickyHeader =
    -				document.body.classList.contains( PAGE_TITLE_INTERSECTION_CLASS ) &&
    -				typeof newBodyWidth === 'number' &&
    -				bodyWidth !== newBodyWidth;
    -
    -			// Enable CSS animation after resize is finished
    -			document.documentElement.classList.add( 'citizen-animations-ready' );
    -			// Recalculate sticky header height at the end of the resize
    -			if ( shouldRecalcStickyHeader ) {
    -				resumeStickyHeader();
    -			}
    -		}
    -	);
    -	bodyObserver.observe( document.body );
    -
     	mw.hook( 've.activationStart' ).add( () => {
     		pauseStickyHeader();
     	} );
    
  • resources/skins.citizen.scripts/skin.js+7 6 modified
    @@ -2,11 +2,8 @@
      * @return {void}
      */
     function deferredTasks() {
    -	const
    -		setupObservers = require( './setupObservers.js' ),
    -		speculationRules = require( './speculationRules.js' );
    +	const speculationRules = require( './speculationRules.js' );
     
    -	setupObservers.main();
     	speculationRules.init();
     	registerServiceWorker();
     
    @@ -19,6 +16,8 @@ function deferredTasks() {
     	window.addEventListener( 'pagehide', () => {
     		document.documentElement.classList.remove( 'citizen-loading' );
     	} );
    +
    +	document.documentElement.classList.add( 'citizen-animations-ready' );
     }
     
     /**
    @@ -77,11 +76,13 @@ function main( window ) {
     		search = require( './search.js' ),
     		dropdown = require( './dropdown.js' ),
     		lastModified = require( './lastModified.js' ),
    -		share = require( './share.js' );
    +		share = require( './share.js' ),
    +		setupObservers = require( './setupObservers.js' );
     
    -	dropdown.init();
     	search.init( window );
     	echo();
    +	setupObservers.main();
    +	dropdown.init();
     	lastModified.init();
     	share.init();
     
    
  • resources/skins.citizen.scripts/stickyHeader.js+144 13 modified
    @@ -1,6 +1,5 @@
     const
    -	STICKY_HEADER_ID = 'citizen-page-header',
    -	STICKY_HEADER_PLACEHOLDER_ID = 'citizen-page-header-sticky-placeholder',
    +	STICKY_HEADER_ID = 'citizen-sticky-header',
     	STICKY_HEADER_VISIBLE_CLASS = 'citizen-sticky-header-visible';
     
     /**
    @@ -13,42 +12,174 @@ function setCSSVariable( value ) {
     	document.documentElement.style.setProperty( '--height-sticky-header', `${ value }px` );
     }
     
    +/**
    + * Copies attribute from an element to another.
    + *
    + * @param {Element} from
    + * @param {Element} to
    + * @param {string} attribute
    + */
    +function copyAttribute( from, to, attribute ) {
    +	const fromAttr = from.getAttribute( attribute );
    +	if ( fromAttr ) {
    +		to.setAttribute( attribute, fromAttr );
    +	}
    +}
    +
    +/**
    + * Copies attribute from an element to another.
    + *
    + * @param {Element} from
    + * @param {Element} to
    + */
    +function copyButtonAttributes( from, to ) {
    +	copyAttribute( from, to, 'title' );
    +	// Copy button labels
    +	if ( to.lastElementChild && from.lastElementChild ) {
    +		to.lastElementChild.innerHTML = from.lastElementChild.textContent || '';
    +	}
    +}
    +
    +/**
    + * Prepare the more menu dropdown for the sticky header.
    + *
    + * @param {HTMLElement} moreMenuDropdown
    + * @return {HTMLElement}
    + */
    +function prepareMoreMenuDropdown( moreMenuDropdown ) {
    +	const
    +		moreMenuDropdownClone = moreMenuDropdown.cloneNode( true ),
    +		moreMenuDropdownStickyElementsWithIds = moreMenuDropdownClone.querySelectorAll( '[ id ]' ),
    +		moreMenuDropdownButton = moreMenuDropdownClone.querySelector( '.citizen-dropdown-summary' );
    +
    +	moreMenuDropdownStickyElementsWithIds.forEach( ( stickyElement ) => {
    +		// Remove the id attribute to prevent duplicate ids
    +		stickyElement.removeAttribute( 'id' );
    +	} );
    +
    +	// Make the button look like a cdx-button
    +	moreMenuDropdownButton.classList.add(
    +		'cdx-button',
    +		'cdx-button--fake-button',
    +		'cdx-button--fake-button--enabled',
    +		'cdx-button--weight-quiet',
    +		'cdx-button--size-large',
    +		'cdx-button--icon-only'
    +	);
    +	moreMenuDropdownButton.setAttribute( 'tabindex', '-1' );
    +
    +	return moreMenuDropdownClone;
    +}
    +
    +/**
    + * Get the target element of a fake button.
    + *
    + * @param {HTMLElement} fakeButton
    + * @return {HTMLElement | null}
    + */
    +function getClickTarget( fakeButton ) {
    +	return document.querySelector( fakeButton.getAttribute( 'data-mw-citizen-click-target' ) );
    +}
    +
    +/**
    + * Handle click on a fake button.
    + *
    + * @param {MouseEvent} event
    + * @return {void}
    + */
    +function handleClick( event ) {
    +	const fakeButton = event.target.closest( '.citizen-sticky-header-fake-button' );
    +	if ( fakeButton ) {
    +		const target = getClickTarget( fakeButton );
    +		if ( target !== null ) {
    +			target.click();
    +		}
    +	}
    +}
    +
    +/**
    + * Unbind event listeners from sticky header.
    + *
    + * @param {HTMLElement} stickyHeader
    + * @return {void}
    + */
    +function unbind( stickyHeader ) {
    +	stickyHeader.removeEventListener( 'click', handleClick );
    +}
    +
    +/**
    + * Bind event listeners to sticky header.
    + *
    + * @param {HTMLElement} stickyHeader
    + * @return {void}
    + */
    +function bind( stickyHeader ) {
    +	stickyHeader.addEventListener( 'click', handleClick );
    +}
    +
     /**
      * Show the sticky header.
      *
      * @param {HTMLElement} stickyHeader
    - * @param {HTMLElement} placeholder
      * @return {void}
      */
    -function show( stickyHeader, placeholder ) {
    -	const staticHeight = stickyHeader.getBoundingClientRect().height;
    +function show( stickyHeader ) {
     	document.body.classList.add( STICKY_HEADER_VISIBLE_CLASS );
    -	const stickyHeight = stickyHeader.getBoundingClientRect().height;
    -	placeholder.style.height = `${ staticHeight - stickyHeight }px`;
    -	setCSSVariable( stickyHeight );
    +	setCSSVariable( stickyHeader.getBoundingClientRect().height );
    +	bind( stickyHeader );
     }
     
     /**
      * Hide the sticky header.
      *
      * @param {HTMLElement} stickyHeader
    - * @param {HTMLElement} placeholder
      * @return {void}
      */
    -function hide( stickyHeader, placeholder ) {
    +function hide( stickyHeader ) {
     	// Dismiss dropdown menus and search if active
     	if ( stickyHeader && stickyHeader.contains( document.activeElement ) ) {
     		document.body.click();
     	}
    -	placeholder.style.height = '0px';
     	setCSSVariable( 0 );
     	document.body.classList.remove( STICKY_HEADER_VISIBLE_CLASS );
    +	unbind( stickyHeader );
    +}
    +
    +/**
    + * Initialize sticky header.
    + *
    + * @param {HTMLElement} stickyHeader
    + * @return {void}
    + */
    +function init( stickyHeader ) {
    +	const fakeButtons = stickyHeader.querySelectorAll( '.cdx-button[data-mw-citizen-click-target]' );
    +
    +	fakeButtons.forEach( ( fakeButton ) => {
    +		const target = getClickTarget( fakeButton );
    +
    +		if ( !target ) {
    +			fakeButton.remove();
    +			return;
    +		}
    +
    +		copyButtonAttributes( target, fakeButton );
    +		fakeButton.classList.add( 'citizen-sticky-header-fake-button' );
    +	} );
    +
    +	const
    +		moreMenuDropdown = document.getElementById( 'citizen-page-more-dropdown' ),
    +		moreMenuDropdownContainer = document.getElementById( 'citizen-sticky-header-more' );
    +
    +	if ( moreMenuDropdown && moreMenuDropdownContainer ) {
    +		const moreMenuDropdownClone = prepareMoreMenuDropdown( moreMenuDropdown );
    +		moreMenuDropdownContainer.appendChild( moreMenuDropdownClone );
    +	}
     }
     
     module.exports = {
     	STICKY_HEADER_ID,
    -	STICKY_HEADER_PLACEHOLDER_ID,
     	STICKY_HEADER_VISIBLE_CLASS,
     	show,
    -	hide
    +	hide,
    +	init
     };
    
  • resources/skins.citizen.styles/common/features.less+1 7 modified
    @@ -131,8 +131,6 @@
     			--height-sticky-header: 0px !important;
     
     			.citizen-header,
    -			.citizen-page-header,
    -			.citizen-page-heading,
     			.citizen-toc,
     			.page-actions {
     				transition-timing-function: var( --transition-timing-function-ease-in );
    @@ -151,13 +149,9 @@
     			}
     
     			&.citizen-sticky-header-visible {
    -				.citizen-page-header {
    +				.citizen-sticky-header-container {
     					transform: translate3d( 0, -100%, 0 );
     				}
    -
    -				.citizen-page-heading {
    -					opacity: 0;
    -				}
     			}
     		}
     
    
  • resources/skins.citizen.styles/components/Button.less+6 0 added
    @@ -0,0 +1,6 @@
    +.cdx-button.cdx-button--icon-only {
    +	span + span {
    +		.mixin-citizen-screen-reader-only;
    +		.user-select( none );
    +	}
    +}
    
  • resources/skins.citizen.styles/components/Dropdown.less+4 0 modified
    @@ -52,6 +52,10 @@
     	}
     
     	&-details {
    +		+ .citizen-menu__card {
    +			position: absolute;
    +		}
    +
     		&[ open ] {
     			+ .citizen-menu__card {
     				transform: none;
    
  • resources/skins.citizen.styles/components/PageHeader.less+1 0 modified
    @@ -1,4 +1,5 @@
     .citizen-page-header {
    +	position: relative;
     	z-index: @z-index-above-content;
     	padding-inline: var( --padding-page );
     	margin-top: var( --space-xl );
    
  • resources/skins.citizen.styles/components/Pagetools.less+0 1 modified
    @@ -10,7 +10,6 @@
     
     	.citizen-menu {
     		&__card {
    -			position: absolute;
     			right: ~'calc( var( --space-xs ) * -1 )'; // counteract margin
     			display: grid;
     			gap: var( --space-xs );
    
  • resources/skins.citizen.styles/components/StickyHeader.less+123 35 modified
    @@ -13,62 +13,150 @@
     	}
     }
     
    -.citizen-page-header {
    -	position: -webkit-sticky;
    -	position: sticky;
    -}
    +.citizen-sticky-header {
    +	padding-inline: var( --padding-page );
    +	border-bottom: var( --border-base );
    +	.mixin-citizen-frosted-glass( citizen-sticky-header-backdrop, 0 );
     
    -.citizen-sticky-header-visible {
    -	.citizen-page-header {
    +	&-container {
    +		position: fixed;
     		top: 0;
    -		z-index: @z-index-stacking-3;
    -		flex-wrap: nowrap;
    -		padding-top: env( safe-area-inset-top );
    -		white-space: nowrap;
    -		// So that it won't change the height of the header
    -		box-shadow: 0 0 0 1px var( --border-color-base );
    -		.mixin-citizen-frosted-glass( citizen-page-header-backdrop, 0 );
    +		right: 0;
    +		left: 0;
    +		z-index: @z-index-sticky;
    +		visibility: hidden;
    +		transform: translateY( -100% );
    +		transition-timing-function: var( --transition-timing-function-ease-out );
    +		transition-duration: var( --transition-duration-medium );
    +		transition-property: transform, visibility;
     
    -		.citizen-page-header-inner {
    -			padding-block: var( --space-sm );
    +		@media ( min-width: @min-width-breakpoint-desktop ) {
    +			margin-left: var( --header-size );
     		}
    +	}
    +
    +	&-inner {
    +		display: flex;
    +		gap: var( --space-md );
    +		align-items: center;
    +		justify-content: space-between;
    +		max-width: var( --width-page );
    +		min-height: 3.25rem;
    +		padding-block: var( --space-xxs );
    +		margin-inline: auto;
    +	}
    +
    +	&-start {
    +		display: flex;
    +		flex-grow: 1;
    +		align-items: center;
    +		margin-inline-start: -16px; // Compensate for the cdx button padding
    +		overflow: hidden;
    +	}
     
    -		#siteSub,
    -		.mw-indicators {
    +	&-end {
    +		display: flex;
    +		align-items: center;
    +
    +		// Already have fixed page tools in narrow screen
    +		@media ( max-width: @max-width-breakpoint-tablet ) {
     			display: none;
     		}
     	}
     
    -	.citizen-page-heading {
    -		position: relative;
    -		min-width: 0;
    +	&-backtotop {
    +		flex-grow: 1;
    +
    +		&.cdx-button {
    +			gap: var( --space-xs );
    +			justify-content: flex-start;
    +			max-width: none;
    +		}
    +
    +		&:hover {
    +			.citizen-ui-icon {
    +				opacity: 1;
    +			}
    +
    +			.citizen-sticky-header-page-info {
    +				transform: translateX( calc( var( --size-icon ) + var( --space-xs ) ) );
    +			}
    +		}
    +
    +		.citizen-ui-icon {
    +			position: absolute;
    +			opacity: 0;
    +			transform: rotate( 90deg ) !important;
    +			transition-timing-function: var( --transition-timing-function-ease );
    +			transition-duration: var( --transition-duration-medium );
    +			transition-property: opacity;
    +		}
     	}
     
    -	.firstHeading {
    +	&-page-info {
    +		overflow: hidden;
    +		transition-timing-function: var( --transition-timing-function-ease );
    +		transition-duration: var( --transition-duration-base );
    +		transition-property: transform;
    +	}
    +
    +	&-page-title,
    +	&-page-tagline {
     		overflow: hidden;
     		text-overflow: ellipsis;
    -		.mixin-citizen-font-styles( 'heading-4' );
    +		white-space: nowrap;
     	}
     
    -	.citizen-jumptotop {
    -		position: absolute;
    -		inset: 0 0 0 0;
    -		margin: ~'calc( var(  --space-xs ) * -1 )';
    -		border-radius: var( --border-radius-base );
    +	&-page-title {
    +		font-size: var( --font-size-large );
    +		font-weight: var( --font-weight-semi-bold );
    +		color: var( --color-emphasized );
    +	}
     
    -		&:hover {
    -			background-color: var( --background-color-button-quiet--hover );
    +	&-page-tagline {
    +		display: none;
    +		font-size: var( --font-size-small );
    +		color: var( --color-subtle );
    +	}
    +
    +	&-more {
    +		.citizen-dropdown-details {
    +			&[ open ] {
    +				.citizen-dropdown-summary {
    +					background-color: var( --background-color-button-quiet--active );
    +				}
    +			}
     		}
     
    -		&:active {
    -			background-color: var( --background-color-button-quiet--active );
    +		.citizen-dropdown {
    +			position: relative;
    +		}
    +
    +		.citizen-menu__card {
    +			top: calc( 100% + var( --space-xs ) );
    +			right: calc( var( --space-xs ) * -1 );
    +			max-width: 80vw;
    +			max-height: 60vh;
    +			padding-block: var( --space-xs );
    +			transform-origin: var( --transform-origin-offset-end ) var( --transform-origin-offset-start );
    +		}
    +	}
    +
    +	// The buttons are not supposed to be focusable
    +	.cdx-button {
    +		&:focus {
    +			border-color: transparent !important;
    +			box-shadow: none !important;
     		}
     	}
    +}
     
    -	// Collapse page tools label in sticky header
    -	.page-actions > .mw-portlet li > a {
    -		gap: 0;
    -		font-size: 0;
    +.citizen-sticky-header-visible {
    +	.citizen-sticky-header {
    +		&-container {
    +			visibility: visible;
    +			transform: none;
    +		}
     	}
     }
     
    
  • resources/skins.citizen.styles/components/TableOfContents.less+3 1 modified
    @@ -35,6 +35,8 @@
     			height: 0;
     			padding-block: 0;
     			opacity: 0;
    +			transition-timing-function: var( --transition-timing-function-ease-out );
    +			transition-duration: var( --transition-duration-medium );
     			transition-property: opacity, height;
     		}
     
    @@ -165,7 +167,6 @@
     		pointer-events: none; // HACK: Make background clickable
     
     		&-card {
    -			position: absolute;
     			// Get consistent margin
     			bottom: ~'calc( 100% - var( --space-xs ) )';
     			width: max-content;
    @@ -224,6 +225,7 @@
     		overscroll-behavior: contain;
     
     		.citizen-menu__card {
    +			position: relative;
     			min-width: auto;
     			margin: 0;
     			background: transparent;
    
  • resources/skins.citizen.styles/layout.less+3 3 modified
    @@ -54,17 +54,17 @@
     		}
     	}
     
    -	.citizen-page-header-inner,
    +	.citizen-page-header,
     	.citizen-body-container {
     		transition-timing-function: var( --transition-timing-function-ease );
     		transition-duration: var( --transition-duration-medium );
     	}
     
    -	.citizen-page-header-inner {
    +	.citizen-page-header {
     		transition-property: max-width;
     	}
     
    -	.citizen-page-header-inner,
    +	.citizen-page-header,
     	.firstHeading-container {
     		flex-wrap: nowrap;
     	}
    
  • resources/skins.citizen.styles/skin.less+1 0 modified
    @@ -25,6 +25,7 @@
     	@import 'common/links.less';
     
     	// Components
    +	@import 'components/Button.less';
     	@import 'components/Header.less';
     	@import 'components/Drawer.less';
     	@import 'components/Drawer__button.less';
    
  • templates/Button.mustache+4 0 added
    @@ -0,0 +1,4 @@
    +{{#href}}<a href="{{.}}"{{/href}}{{^href}}<button{{/href}} class="{{class}}"{{#id}} id="{{.}}"{{/id}}{{#array-attributes}} {{key}}="{{value}}"{{/array-attributes}}>{{!
    +	}}{{#icon}}{{>Icon}}{{/icon}}{{!
    +	}}<span>{{label}}</span>
    +{{#href}}</a>{{/href}}{{^href}}</button>{{/href}}
    \ No newline at end of file
    
  • templates/PageHeader.mustache+0 2 modified
    @@ -1,9 +1,7 @@
     <header class="mw-body-header citizen-page-header" id="citizen-page-header">
    -	<div class="citizen-page-header-backdrop"></div>
     	<div class="citizen-page-header-inner">
     		{{#data-page-heading}}{{>PageHeading}}{{/data-page-heading}}
     		{{#data-page-tools}}{{>PageTools}}{{/data-page-tools}}
     	</div>
     </header>
    -<div id="citizen-page-header-sticky-placeholder"></div>
     <div id="citizen-page-header-sticky-sentinel"></div>
    
  • templates/PageHeading.mustache+0 2 modified
    @@ -3,13 +3,11 @@
     		"featured article". An empty array if none are defined.
     	string html-title-heading--formatted
     	string html-tagline
    -	string html-citizen-jumptotop Jump to top title text
     }}
     <div class="citizen-page-heading">
     	<div class="firstHeading-container">
     		{{{html-title-heading}}}
     		{{>Indicators}}
     	</div>
     	<div id="siteSub">{{{html-tagline}}}</div>
    -	<a href="#top" class="citizen-jumptotop" title="{{msg-citizen-jumptotop}}"></a>
     </div>
    \ No newline at end of file
    
  • templates/PageTools__more.mustache+4 1 modified
    @@ -1,4 +1,7 @@
    -<div class="page-actions-more page-actions__item citizen-dropdown">
    +<div
    +	id="citizen-page-more-dropdown"
    +	class="page-actions-more page-actions__item citizen-dropdown"
    +>
     	<details class="citizen-dropdown-details">
     		<summary
     			class="citizen-dropdown-summary" 
    
  • templates/skin.mustache+2 1 modified
    @@ -11,7 +11,7 @@
     }}
     
     {{>Header}}
    -<div class="citizen-page-container {{#toc-enabled}}citizen-toc-enabled{{/toc-enabled}}">
    +<div class="citizen-page-container">
     	<div class="citizen-sitenotice-container">{{{html-site-notice}}}</div>
     	<main class="mw-body" id="content">
     		{{>PageHeader}}
    @@ -29,3 +29,4 @@
     	{{{html-after-content}}}
     	{{#data-footer}}{{>Footer}}{{/data-footer}}
     </div>
    +{{#data-sticky-header}}{{>StickyHeader}}{{/data-sticky-header}}
    
  • templates/StickyHeader.mustache+28 0 added
    @@ -0,0 +1,28 @@
    +<div class="citizen-sticky-header-container">
    +	<div id="citizen-sticky-header" class="citizen-sticky-header">
    +		<div class="citizen-sticky-header-backdrop"></div>
    +		<div class="citizen-sticky-header-inner">
    +			<div class="citizen-sticky-header-start">
    +				<a
    +					href="#top"
    +					title="{{msg-citizen-jumptotop}}"
    +					class="citizen-sticky-header-backtotop cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--size-large cdx-button--weight-quiet"
    +					tabindex="-1"
    +					aria-hidden="true"
    +				>
    +					<div class="citizen-ui-icon mw-ui-icon-first mw-ui-icon-wikimedia-first"></div>
    +					<div class="citizen-sticky-header-page-info">
    +						<div class="citizen-sticky-header-page-title">{{{html-title}}}</div>
    +						<div class="citizen-sticky-header-page-tagline">{{{html-tagline}}}</div>
    +					</div>
    +				</a>
    +			</div>
    +			<div class="citizen-sticky-header-end" aria-hidden="true">
    +				{{#array-icon-buttons}}
    +					{{>Button}}
    +				{{/array-icon-buttons}}
    +				<div id="citizen-sticky-header-more" class="citizen-sticky-header-more"></div>
    +			</div>
    +		</div>
    +	</div>
    +</div>
    
  • tests/phpunit/Unit/Components/CitizenComponentButtonTest.php+155 0 added
    @@ -0,0 +1,155 @@
    +<?php
    +
    +declare( strict_types=1 );
    +
    +namespace MediaWiki\Skins\Citizen\Tests\Components;
    +
    +use MediaWiki\Skins\Citizen\Components\CitizenComponentButton;
    +use MediaWikiUnitTestCase;
    +
    +/**
    + * @group Citizen
    + * @group Components
    + * @coversDefaultClass \MediaWiki\Skins\Citizen\Components\CitizenComponentButton
    + */
    +class CitizenComponentButtonTest extends MediaWikiUnitTestCase {
    +
    +	/**
    +	 * Provides various configurations of CitizenComponentButton to test different scenarios.
    +	 * Each case includes different combinations of the button's properties.
    +	 *
    +	 * @return array[] An array of test cases with parameters and expected values.
    +	 */
    +	public static function provideButtonData(): array {
    +		return [
    +			'Basic Button' => [
    +				// The visible text on the button.
    +				'label' => 'Click Me',
    +				// CSS classes expected without additional properties.
    +				'expectedClasses' => 'cdx-button',
    +				// Default button weight.
    +				'weight' => 'normal',
    +				// Indicates that the button is not icon-only.
    +				'iconOnly' => false,
    +				// No link for a basic button.
    +				'href' => null,
    +			],
    +			'Button With Primary Weight' => [
    +				// The visible text indicating a primary action.
    +				'label' => 'Primary Action',
    +				// Additional classes are expected due to the primary weight.
    +				'expectedClasses' =>
    +					'cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-primary',
    +				// Indicates primary visual importance.
    +				'weight' => 'primary',
    +				// Still not an icon-only button.
    +				'iconOnly' => false,
    +				// Providing an href activates additional styles.
    +				'href' => '/mock-link',
    +			],
    +			'Icon Only Button' => [
    +				// No visible text for an icon-only button.
    +				'label' => '',
    +				// CSS classes specifically for icon-only.
    +				'expectedClasses' => 'cdx-button cdx-button--icon-only',
    +				// Default weight even for icon-only buttons.
    +				'weight' => 'normal',
    +				// This button is icon-only.
    +				'iconOnly' => true,
    +				// No link for this icon-only button.
    +				'href' => null,
    +			],
    +		];
    +	}
    +
    +	/**
    +	 * Tests CSS class generation logic within CitizenComponentButton.
    +	 * This method verifies that the class string is generated correctly based on the button's properties.
    +	 *
    +	 * @covers ::getClasses
    +	 */
    +	public function testGetClasses() {
    +		$basicButton = new CitizenComponentButton( 'Label' );
    +		$templateData = $basicButton->getTemplateData();
    +		$this->assertStringContainsString( 'cdx-button', $templateData['class'],
    +			'Basic button should have cdx-button class.' );
    +
    +		$primaryButton = new CitizenComponentButton( 'Label', null, null, null, [], 'primary' );
    +		$templateData = $primaryButton->getTemplateData();
    +		$this->assertStringContainsString( 'cdx-button--weight-primary', $templateData['class'],
    +			'Primary button should have primary weight class.' );
    +
    +		$iconOnlyButton = new CitizenComponentButton(
    +			'Label', null, null, null, [], 'normal', 'default', 'medium', true );
    +		$templateData = $iconOnlyButton->getTemplateData();
    +		$this->assertStringContainsString( 'cdx-button--icon-only', $templateData['class'],
    +			'Icon-only button should have icon-only class.' );
    +
    +		$destructiveButton = new CitizenComponentButton( 'Label', null, null, null, [],
    +			'normal', 'destructive' );
    +		$templateData = $destructiveButton->getTemplateData();
    +		$this->assertStringContainsString( 'cdx-button--action-destructive', $templateData['class'],
    +			'Destructive button should have destructive action class.' );
    +
    +		$progressiveButton = new CitizenComponentButton( 'Label', null, null, null, [],
    +			'normal', 'progressive' );
    +		$templateData = $progressiveButton->getTemplateData();
    +		$this->assertStringContainsString( 'cdx-button--action-progressive', $templateData['class'],
    +			'Progressive button should have progressive action class.' );
    +
    +		$quietButton = new CitizenComponentButton( 'Label', null, null, null, [], 'quiet' );
    +		$templateData = $quietButton->getTemplateData();
    +		$this->assertStringContainsString( 'cdx-button--weight-quiet', $templateData['class'],
    +			'Quiet button should have quiet weight class.' );
    +
    +		$largeButton = new CitizenComponentButton( 'Label', null, null, null, [], 'normal', 'default', 'large' );
    +		$templateData = $largeButton->getTemplateData();
    +		$this->assertStringContainsString( 'cdx-button--size-large', $templateData['class'],
    +			'Large button should have large size class.' );
    +	}
    +
    +	/**
    +	 * Tests the `getTemplateData` method of CitizenComponentButton component.
    +	 * Each data set provided by `provideButtonData` is passed here to verify the component's output.
    +	 *
    +	 * @covers ::__construct
    +	 * @dataProvider provideButtonData
    +	 */
    +	public function testGetTemplateData(
    +		string $label,
    +		string $expectedClasses,
    +		string $weight,
    +		bool $iconOnly,
    +		?string $href
    +	) {
    +		// Instantiate the component with the provided configuration.
    +		$button = new CitizenComponentButton(
    +			$label,
    +			'icon-sample',
    +			'btn-id',
    +			'additional-class',
    +			// Custom data attribute as an example.
    +			[ 'data-test' => 'true' ],
    +			$weight,
    +			// Default action type.
    +			'default',
    +			// Default button size.
    +			'medium',
    +			$iconOnly,
    +			$href
    +		);
    +
    +		// Acquire the generated template data from the component.
    +		$templateData = $button->getTemplateData();
    +
    +		// Assert each aspect of the template data matches expectations.
    +		$this->assertEquals( $label, $templateData['label'] );
    +		$this->assertEquals( 'icon-sample', $templateData['icon'] );
    +		$this->assertEquals( 'btn-id', $templateData['id'] );
    +		// Ensures the class string contains all expected CSS classes.
    +		$this->assertStringContainsString( $expectedClasses, $templateData['class'] );
    +		$this->assertEquals( $href, $templateData['href'] );
    +		// Verifies custom attributes are included appropriately.
    +		$this->assertContains( [ 'key' => 'data-test', 'value' => 'true' ], $templateData['array-attributes'] );
    +	}
    +}
    \ No newline at end of file
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.