From 110ddce7e5de4e6c59b9d8f7634aa5aeb42536ba Mon Sep 17 00:00:00 2001 From: Vonavy Date: Thu, 25 Sep 2025 20:52:55 +1000 Subject: [PATCH 1/5] Add configurable header position Introduces a new 'CitizenHeaderPosition' config option allowing the header to be positioned on the left, right, top, or bottom of the desktop layout. Updates PHP, Less, and skin.json to support dynamic header placement, adjusting layout, sticky elements, drawers, dropdowns, and extension styles accordingly. --- includes/SkinCitizen.php | 9 +++++ resources/mixins.less | 10 ++++- .../components/Drawer.less | 28 ++++++++++++- .../components/Dropdown.less | 30 +++++++++++++- .../components/Header.less | 39 ++++++++++++++++--- .../components/StickyHeader.less | 18 ++++++++- .../components/TableOfContents.less | 12 +++++- resources/skins.citizen.styles/layout.less | 16 +++++++- skin.json | 6 +++ skinStyles/extensions/Echo/ext.echo.ui.less | 34 ++++++++++++++++ .../mediawiki/debug/mediawiki.debug.less | 12 +++++- 11 files changed, 200 insertions(+), 14 deletions(-) diff --git a/includes/SkinCitizen.php b/includes/SkinCitizen.php index 03a2c630b..5730c7d17 100644 --- a/includes/SkinCitizen.php +++ b/includes/SkinCitizen.php @@ -337,5 +337,14 @@ private function buildSkinFeatures( array &$options ): void { if ( $config->get( 'CitizenEnableARFonts' ) === true ) { $options['styles'][] = 'skins.citizen.styles.fonts.ar'; } + + // Header position + $headerPosition = $config->get( 'CitizenHeaderPosition' ); + + if ( !in_array( $headerPosition, [ 'left', 'right', 'top', 'bottom' ] ) ) { + $headerPosition = 'left'; + } + + $this->getOutput()->addHtmlClasses( 'citizen-header-position-' . $headerPosition ); } } diff --git a/resources/mixins.less b/resources/mixins.less index 4570eead4..7172f7643 100644 --- a/resources/mixins.less +++ b/resources/mixins.less @@ -55,7 +55,15 @@ } .mixin-citizen-sticky-header-element() { - top: var( --height-sticky-header ) !important; + --header-offset: var( --height-sticky-header ); + + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-top & { + --header-offset: calc( var( --header-size ) + var( --height-sticky-header ) ); + } + } + + top: var( --header-offset ) !important; transition-timing-function: var( --transition-timing-function-ease ); transition-duration: var( --transition-duration-medium ); transition-property: top; diff --git a/resources/skins.citizen.styles/components/Drawer.less b/resources/skins.citizen.styles/components/Drawer.less index 62317cec4..7a9526772 100644 --- a/resources/skins.citizen.styles/components/Drawer.less +++ b/resources/skins.citizen.styles/components/Drawer.less @@ -4,7 +4,33 @@ transform-origin: var( --transform-origin-offset-start ) var( --transform-origin-offset-end ); @media ( min-width: @min-width-breakpoint-desktop ) { - transform-origin: var( --transform-origin-offset-start ) var( --transform-origin-offset-start ); + .citizen-header-position-left & { + right: unset; + left: 100%; + transform-origin: var( --transform-origin-offset-start ) var( --transform-origin-offset-start ); + } + + .citizen-header-position-right & { + right: 100%; + left: unset; + transform-origin: var( --transform-origin-offset-end ) var( --transform-origin-offset-start ); + } + + .citizen-header-position-top & { + top: 100%; + right: unset; + left: 0; + bottom: unset; + transform-origin: var( --transform-origin-offset-start ) var( --transform-origin-offset-start ); + } + + .citizen-header-position-bottom & { + top: unset; + right: unset; + left: 0; + bottom: 100%; + transform-origin: var( --transform-origin-offset-start ) var( --transform-origin-offset-end ); + } } } diff --git a/resources/skins.citizen.styles/components/Dropdown.less b/resources/skins.citizen.styles/components/Dropdown.less index f3dd45669..538fc1b8b 100644 --- a/resources/skins.citizen.styles/components/Dropdown.less +++ b/resources/skins.citizen.styles/components/Dropdown.less @@ -85,8 +85,34 @@ .mixin-citizen-header-card( end ); transform-origin: var( --transform-origin-offset-end ) var( --transform-origin-offset-end ); - @media ( min-width: @min-width-breakpoint-desktop ) { - transform-origin: var( --transform-origin-offset-start ) var( --transform-origin-offset-end ); + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-left & { + right: unset; + left: 100%; + transform-origin: var( --transform-origin-offset-start ) var( --transform-origin-offset-end ); + } + + .citizen-header-position-right & { + right: 100%; + left: unset; + transform-origin: var( --transform-origin-offset-end ) var( --transform-origin-offset-end ); + } + + .citizen-header-position-top & { + top: 100%; + right: 0; + left: unset; + bottom: unset; + transform-origin: var( --transform-origin-offset-end ) var( --transform-origin-offset-start ); + } + + .citizen-header-position-bottom & { + top: unset; + right: 0; + left: unset; + bottom: 100%; + transform-origin: var( --transform-origin-offset-end ) var( --transform-origin-offset-end ); + } } } diff --git a/resources/skins.citizen.styles/components/Header.less b/resources/skins.citizen.styles/components/Header.less index d1eccba69..88ccedf07 100644 --- a/resources/skins.citizen.styles/components/Header.less +++ b/resources/skins.citizen.styles/components/Header.less @@ -157,18 +157,47 @@ @media ( min-width: @min-width-breakpoint-desktop ) { .citizen-header { - --header-direction: column; top: 0; right: unset; left: 0; border-top: 0; - border-right: var( --border-base ); - - &__logo { + + .citizen-header-position-left &__logo, + .citizen-header-position-right &__logo { padding: 0 0 var( --space-xs ) 0; margin: var( --space-xxs ) 0; border-right: 0; - border-bottom: var( --border-subtle ); + border-bottom: var( --border-width-base ) solid var( --border-color-base ); + } + + .citizen-header-position-left & { + --header-direction: column; + right: unset; + left: 0; + border-right: var( --border-width-base ) solid var( --border-color-base ); + } + + .citizen-header-position-right & { + --header-direction: column; + right: 0; + left: unset; + border-left: var( --border-width-base ) solid var( --border-color-base ); + } + + .citizen-header-position-top & { + top: 0; + right: 0; + bottom: unset; + left: 0; + border-bottom: var( --border-width-base ) solid var( --border-color-base ); + } + + .citizen-header-position-bottom & { + top: unset; + right: 0; + bottom: 0; + left: 0; + border-top: var( --border-width-base ) solid var( --border-color-base ); } } } diff --git a/resources/skins.citizen.styles/components/StickyHeader.less b/resources/skins.citizen.styles/components/StickyHeader.less index c9d7dc5e9..1279f45c6 100644 --- a/resources/skins.citizen.styles/components/StickyHeader.less +++ b/resources/skins.citizen.styles/components/StickyHeader.less @@ -39,7 +39,17 @@ transition-property: transform, visibility; @media ( min-width: @min-width-breakpoint-desktop ) { - margin-left: var( --header-size ); + .citizen-header-position-left & { + margin-left: var( --header-size ); + } + + .citizen-header-position-right & { + margin-right: var( --header-size ); + } + + .citizen-header-position-top & { + transform: translateY( calc( var( --header-size ) - 100% ) ); + } } } @@ -168,6 +178,12 @@ &-container { visibility: visible; transform: none; + + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-top & { + transform: translateY( calc( var( --header-size ) + 1px ) ); + } + } } } } diff --git a/resources/skins.citizen.styles/components/TableOfContents.less b/resources/skins.citizen.styles/components/TableOfContents.less index a57f75a00..7dbc094da 100644 --- a/resources/skins.citizen.styles/components/TableOfContents.less +++ b/resources/skins.citizen.styles/components/TableOfContents.less @@ -210,10 +210,18 @@ @media ( min-width: @min-width-breakpoint-desktop ) { .citizen-toc { + --header-offset: var( --height-sticky-header ); + + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-top & { + --header-offset: calc( var( --header-size ) + var( --height-sticky-header ) ); + } + } + position: -webkit-sticky; position: sticky; - top: var( --height-sticky-header ); - max-height: ~'calc( 100vh - var( --height-sticky-header ) )'; + top: var( --header-offset ); + max-height: ~'calc( 100vh - var( --header-offset ) )'; padding: var( --space-xs ) 0; overflow-y: auto; overscroll-behavior: contain; diff --git a/resources/skins.citizen.styles/layout.less b/resources/skins.citizen.styles/layout.less index cb997d04d..b57840767 100644 --- a/resources/skins.citizen.styles/layout.less +++ b/resources/skins.citizen.styles/layout.less @@ -39,7 +39,21 @@ @media ( min-width: @min-width-breakpoint-desktop ) { .citizen-page-container { // Reserve space for header - margin-left: var( --header-size ); + .citizen-header-position-left & { + margin-left: var( --header-size ); + } + + .citizen-header-position-right & { + margin-right: var( --header-size ); + } + + .citizen-header-position-top & { + margin-top: var( --header-size ); + } + + .citizen-header-position-bottom & { + margin-bottom: var( --header-size ); + } } .citizen-toc-enabled { diff --git a/skin.json b/skin.json index 7ab8be033..647fb287c 100644 --- a/skin.json +++ b/skin.json @@ -893,6 +893,12 @@ "description": "Enables or disable the command palette. Disable to use the old search module.", "descriptionmsg": "citizen-config-enablecommandpalette", "public": true + }, + "HeaderPosition": { + "value": "left", + "description": "Position of the header on the desktop layout. Possible values: 'left', 'right', 'top' and 'bottom'", + "descriptionmsg": "citizen-config-headerposition", + "public": true } }, "manifest_version": 2 diff --git a/skinStyles/extensions/Echo/ext.echo.ui.less b/skinStyles/extensions/Echo/ext.echo.ui.less index aaba9f972..034e37b48 100644 --- a/skinStyles/extensions/Echo/ext.echo.ui.less +++ b/skinStyles/extensions/Echo/ext.echo.ui.less @@ -22,6 +22,33 @@ margin: var( --space-xs ); box-shadow: var( --box-shadow-large ); + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-left & { + right: unset !important; + left: var( --header-size ) !important; + bottom: 0 !important; + } + + .citizen-header-position-right & { + right: var( --header-size ) !important; + left: unset !important; + bottom: 0 !important; + } + + .citizen-header-position-top & { + right: 0 !important; + left: unset !important; + top: var( --header-size ) !important; + bottom: unset !important; + } + + .citizen-header-position-bottom & { + right: 0 !important; + left: unset !important; + bottom: var( --header-size ) !important; + } + } + @media ( min-width: @min-width-breakpoint-desktop ) { bottom: 0 !important; left: var( --header-size ) !important; @@ -84,6 +111,13 @@ bottom: 0; z-index: @z-index-overlay; // Higher than header + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-top & { + top: 0; + bottom: unset; + } + } + // Add dismiss affordnance backdrop @media ( max-width: @max-width-breakpoint-tablet ) { &::before { diff --git a/skinStyles/mediawiki/debug/mediawiki.debug.less b/skinStyles/mediawiki/debug/mediawiki.debug.less index b16bb83a1..41ccff306 100644 --- a/skinStyles/mediawiki/debug/mediawiki.debug.less +++ b/skinStyles/mediawiki/debug/mediawiki.debug.less @@ -147,7 +147,17 @@ a.mw-debug-panelabel:visited { @media ( min-width: @min-width-breakpoint-desktop ) { // So that it is not hidden by header - margin-left: var( --header-size ); + .citizen-header-position-left & { + margin-left: var( --header-size ); + } + + .citizen-header-position-right & { + margin-right: var( --header-size ); + } + + .citizen-header-position-top & { + margin-top: var( --header-size ); + } } } From 3fbfefc240d536154efd72d0b5f00298f50990c2 Mon Sep 17 00:00:00 2001 From: Vonavy Date: Thu, 25 Sep 2025 22:00:57 +1000 Subject: [PATCH 2/5] Remove generic default rules --- skinStyles/extensions/Echo/ext.echo.ui.less | 5 ----- 1 file changed, 5 deletions(-) diff --git a/skinStyles/extensions/Echo/ext.echo.ui.less b/skinStyles/extensions/Echo/ext.echo.ui.less index 034e37b48..28a6c6dec 100644 --- a/skinStyles/extensions/Echo/ext.echo.ui.less +++ b/skinStyles/extensions/Echo/ext.echo.ui.less @@ -49,11 +49,6 @@ } } - @media ( min-width: @min-width-breakpoint-desktop ) { - bottom: 0 !important; - left: var( --header-size ) !important; - } - .oo-ui-popupWidget-body { height: auto !important; max-height: ~'calc( var( --header-card-maxheight ) - 2 * 3.1428571em )'; // 3.1428571em = height of .oo-ui-popupWidget-head & .oo-ui-popupWidget-footer From 973f1cccd5622a71ee3fc75ea4aca43a184e41a3 Mon Sep 17 00:00:00 2001 From: Vonavy Date: Fri, 26 Sep 2025 22:41:00 +1000 Subject: [PATCH 3/5] VisualEditor adjustments for header positions --- .../components/TableOfContents.less | 14 ++++++++++---- .../VisualEditor/ext.visualEditor.core.less | 6 ++++++ ...ext.visualEditor.desktopArticleTarget.init.less | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/resources/skins.citizen.styles/components/TableOfContents.less b/resources/skins.citizen.styles/components/TableOfContents.less index 7dbc094da..86b895503 100644 --- a/resources/skins.citizen.styles/components/TableOfContents.less +++ b/resources/skins.citizen.styles/components/TableOfContents.less @@ -212,10 +212,16 @@ .citizen-toc { --header-offset: var( --height-sticky-header ); - @media ( min-width: @min-width-breakpoint-desktop ) { - .citizen-header-position-top & { - --header-offset: calc( var( --header-size ) + var( --height-sticky-header ) ); - } + .citizen-header-position-top & { + --header-offset: calc( var( --header-size ) + var( --height-sticky-header ) ); + } + + .ve-active & { + --header-offset: 48px; + } + + .citizen-header-position-top.ve-active & { + --header-offset: calc( var( --header-size ) + 48px ); } position: -webkit-sticky; diff --git a/skinStyles/extensions/VisualEditor/ext.visualEditor.core.less b/skinStyles/extensions/VisualEditor/ext.visualEditor.core.less index aba62ec64..140f57442 100644 --- a/skinStyles/extensions/VisualEditor/ext.visualEditor.core.less +++ b/skinStyles/extensions/VisualEditor/ext.visualEditor.core.less @@ -14,6 +14,12 @@ /* Fix weird gap between save button and toolbar bottom */ border-bottom: 0; box-shadow: 0 1px 0 0 var( --border-color-base ); + + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-top .ve-ui-toolbar-floating& { + top: var( --header-size ); + } + } } /* ve.ce.BranchNode.css */ diff --git a/skinStyles/extensions/VisualEditor/ext.visualEditor.desktopArticleTarget.init.less b/skinStyles/extensions/VisualEditor/ext.visualEditor.desktopArticleTarget.init.less index cd54e0cf2..212d1136d 100644 --- a/skinStyles/extensions/VisualEditor/ext.visualEditor.desktopArticleTarget.init.less +++ b/skinStyles/extensions/VisualEditor/ext.visualEditor.desktopArticleTarget.init.less @@ -49,6 +49,12 @@ &-bar { border-bottom-color: var( --border-color-base ); box-shadow: none; + + @media ( min-width: @min-width-breakpoint-desktop ) { + .citizen-header-position-top & { + top: var( --header-size ); + } + } } } From 866e4559802461c05673061a86fb94b3dec431b4 Mon Sep 17 00:00:00 2001 From: Vonavy Date: Fri, 26 Sep 2025 22:44:31 +1000 Subject: [PATCH 4/5] Misc header position handling adjustments --- .../skins.citizen.styles/components/Footer.less | 7 +++++-- .../skins.citizen.styles/components/Header.less | 17 +++++++++-------- .../components/StickyHeader.less | 2 +- .../CookieWarning/ext.CookieWarning.styles.less | 10 +++++++++- .../MediaSearch/mediasearch.styles.less | 4 ++++ ...diawiki.special.preferences.styles.ooui.less | 4 ++++ 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/resources/skins.citizen.styles/components/Footer.less b/resources/skins.citizen.styles/components/Footer.less index f235ffa81..6e6994e1d 100644 --- a/resources/skins.citizen.styles/components/Footer.less +++ b/resources/skins.citizen.styles/components/Footer.less @@ -3,14 +3,17 @@ clear: both; padding: var( --space-xxl ) var( --padding-page ); margin-top: 8rem; - // Reserve space for header - margin-bottom: var( --header-size ); contain: content; color: var( --color-subtle ); background-color: var( --color-surface-1 ); direction: ltr; .mixin-citizen-font-styles( 'small' ); + .citizen-feature-autohide-navigation-clientpref-0 & { + // Reserve space for header, add only if autohide is off to avoid empty space + margin-bottom: var( --header-size ); + } + &__container { max-width: var( --width-page ); margin-inline: auto; diff --git a/resources/skins.citizen.styles/components/Header.less b/resources/skins.citizen.styles/components/Header.less index 88ccedf07..4be6387ca 100644 --- a/resources/skins.citizen.styles/components/Header.less +++ b/resources/skins.citizen.styles/components/Header.less @@ -12,7 +12,8 @@ gap: var( --space-xxs ); padding: ~'var( --space-xs ) max( env( safe-area-inset-right ), var( --space-xs ) ) max( env( safe-area-inset-bottom ), var( --space-xs ) ) max( env( safe-area-inset-left ), var( --space-xs ) )'; background-color: var( --color-surface-0 ); - border-top: var( --border-base ); + border: 0 solid var( --border-color-base ); + border-top-width: var( --border-width-base ); &__item { display: flex; @@ -69,7 +70,8 @@ &__logo { padding: 0 var( --space-xs ) 0 0; margin: 0 var( --space-xxs ); - border-right: var( --border-subtle ); + border: 0 solid var( --border-color-subtle ); + border-right-width: var( --border-width-base ); img { margin: auto; @@ -160,28 +162,27 @@ top: 0; right: unset; left: 0; - border-top: 0; .citizen-header-position-left &__logo, .citizen-header-position-right &__logo { padding: 0 0 var( --space-xs ) 0; margin: var( --space-xxs ) 0; border-right: 0; - border-bottom: var( --border-width-base ) solid var( --border-color-base ); + border-bottom-width: var( --border-width-base ); } .citizen-header-position-left & { --header-direction: column; right: unset; left: 0; - border-right: var( --border-width-base ) solid var( --border-color-base ); + border-right-width: var( --border-width-base ); } .citizen-header-position-right & { --header-direction: column; right: 0; left: unset; - border-left: var( --border-width-base ) solid var( --border-color-base ); + border-left-width: var( --border-width-base ); } .citizen-header-position-top & { @@ -189,7 +190,7 @@ right: 0; bottom: unset; left: 0; - border-bottom: var( --border-width-base ) solid var( --border-color-base ); + border-bottom-width: var( --border-width-base ); } .citizen-header-position-bottom & { @@ -197,7 +198,7 @@ right: 0; bottom: 0; left: 0; - border-top: var( --border-width-base ) solid var( --border-color-base ); + border-top-width: var( --border-width-base ); } } } diff --git a/resources/skins.citizen.styles/components/StickyHeader.less b/resources/skins.citizen.styles/components/StickyHeader.less index 1279f45c6..9fb3d6870 100644 --- a/resources/skins.citizen.styles/components/StickyHeader.less +++ b/resources/skins.citizen.styles/components/StickyHeader.less @@ -181,7 +181,7 @@ @media ( min-width: @min-width-breakpoint-desktop ) { .citizen-header-position-top & { - transform: translateY( calc( var( --header-size ) + 1px ) ); + transform: translateY( calc( var( --header-size ) + var( --border-width-base ) ) ); } } } diff --git a/skinStyles/extensions/CookieWarning/ext.CookieWarning.styles.less b/skinStyles/extensions/CookieWarning/ext.CookieWarning.styles.less index c9b84adbd..4747178f9 100644 --- a/skinStyles/extensions/CookieWarning/ext.CookieWarning.styles.less +++ b/skinStyles/extensions/CookieWarning/ext.CookieWarning.styles.less @@ -28,7 +28,15 @@ @media only screen and ( min-width: @min-width-breakpoint-desktop ) { right: unset; - left: var( --header-size ); + left: 0; + + .citizen-header-position-left & { + left: var( --header-size ); + } + } + + .citizen-header-position-bottom & { + bottom: var( --header-size ); } .mw-cookiewarning-text { diff --git a/skinStyles/extensions/MediaSearch/mediasearch.styles.less b/skinStyles/extensions/MediaSearch/mediasearch.styles.less index d1a02c65c..82ba88fc6 100644 --- a/skinStyles/extensions/MediaSearch/mediasearch.styles.less +++ b/skinStyles/extensions/MediaSearch/mediasearch.styles.less @@ -780,6 +780,10 @@ border-bottom-right-radius: 0; border-bottom-left-radius: 0; } + + .citizen-header-position-bottom & { + padding-bottom: var( --header-size ); + } } } diff --git a/skinStyles/mediawiki/special/mediawiki.special.preferences.styles.ooui.less b/skinStyles/mediawiki/special/mediawiki.special.preferences.styles.ooui.less index e7d853e9c..74f3932e4 100644 --- a/skinStyles/mediawiki/special/mediawiki.special.preferences.styles.ooui.less +++ b/skinStyles/mediawiki/special/mediawiki.special.preferences.styles.ooui.less @@ -36,6 +36,10 @@ @media ( max-width: @max-width-breakpoint-tablet ) { bottom: var( --header-size ); } + + .citizen-header-position-bottom & { + bottom: var( --header-size ); + } } } From b7e315eb38bc4fb827963b469c4f2c4981a8e703 Mon Sep 17 00:00:00 2001 From: Vonavy Date: Fri, 26 Sep 2025 23:04:38 +1000 Subject: [PATCH 5/5] Scope footer margin-bottom to non-desktop view --- resources/skins.citizen.styles/components/Footer.less | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/skins.citizen.styles/components/Footer.less b/resources/skins.citizen.styles/components/Footer.less index 6e6994e1d..2eaa4acf1 100644 --- a/resources/skins.citizen.styles/components/Footer.less +++ b/resources/skins.citizen.styles/components/Footer.less @@ -9,9 +9,11 @@ direction: ltr; .mixin-citizen-font-styles( 'small' ); - .citizen-feature-autohide-navigation-clientpref-0 & { - // Reserve space for header, add only if autohide is off to avoid empty space - margin-bottom: var( --header-size ); + @media ( max-width: @min-width-breakpoint-desktop ) { + .citizen-feature-autohide-navigation-clientpref-0 & { + // Reserve space for header, add only if autohide is off to avoid empty space + margin-bottom: var( --header-size ); + } } &__container {