From e51a526ba54050e9cb731beba53c5c3c3f85405f Mon Sep 17 00:00:00 2001 From: Fardel Pierre Date: Thu, 8 Oct 2026 14:55:28 +0200 Subject: [PATCH 1/2] feat(PageOutput): let controllers add the active theme's stylesheets and scripts Controllers that build their own (settings and admin pages) had no theme-aware call available on AssetCollector, so they hardcoded "/default/screen.css" and never loaded the scripts a theme declares in $theme_scripts. Add ViewModeConfigurator::addThemeStylesheets(), which resolves the user's theme and walks the existing CSS cascade (default first, the theme layered on top, only files that exist), and addThemeScripts(), which does what DesktopChromeRenderer already does with discoverTheme(). The new constructor arguments are optional, so the methods are no-ops when the services are unavailable. --- src/PageOutput/ViewModeConfigurator.php | 69 +++++++++++++++++++ .../ViewModeConfiguratorFactory.php | 4 ++ 2 files changed, 73 insertions(+) diff --git a/src/PageOutput/ViewModeConfigurator.php b/src/PageOutput/ViewModeConfigurator.php index a5aabe2e..bc2432b8 100644 --- a/src/PageOutput/ViewModeConfigurator.php +++ b/src/PageOutput/ViewModeConfigurator.php @@ -16,7 +16,11 @@ namespace Horde\Core\PageOutput; +use Horde\Core\Assets\CssDiscoverer; +use Horde\Core\Assets\CssDiscoveryRequest; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\JsDiscoveryRequest; +use Horde\Core\Assets\ThemeResolver; use Horde\Core\Service\PrefsService; use Horde\Core\Session\HordeSession; use Horde\Core\Session\SessionAccess; @@ -45,8 +49,73 @@ public function __construct( private readonly SessionAccess $session, private readonly JsDiscoverer $jsDiscoverer, private readonly Token $tokenService, + private readonly ?CssDiscoverer $cssDiscoverer = null, + private readonly ?ThemeResolver $themeResolver = null, ) {} + /** + * Adds themed stylesheets to the output. + * + * Resolves the user's theme and walks the asset cascade, so a theme that + * does not ship $files still gets the default theme's version, and one + * that does gets it layered on top. + * + * Controllers outside the Horde_PageOutput path used to hardcode + * '/default/' in the stylesheet URI, which pinned those pages to the + * default theme whatever the user had selected. + * + * @param list $files Stylesheet names, e.g. ['screen.css']. + */ + public function addThemeStylesheets( + AssetCollector $collector, + array $files, + string $app = 'horde', + ): void { + if ($this->cssDiscoverer === null || $this->themeResolver === null) { + return; + } + + $theme = $this->resolveTheme($app); + + $result = $this->cssDiscoverer->discover( + new CssDiscoveryRequest(files: $files, app: $app, theme: $theme) + ); + + foreach ($result as $entry) { + $collector->addStylesheet($entry->uri); + } + } + + /** + * Adds the scripts the active theme ships for itself (info.php + * $theme_scripts), as DesktopChromeRenderer does. + * + * Call it AFTER configure(): theme scripts may rely on the core + * scripts (prototype.js, horde.js) it adds. + */ + public function addThemeScripts(AssetCollector $collector, string $app = 'horde'): void + { + if ($this->themeResolver === null) { + return; + } + + $scripts = $this->jsDiscoverer->discoverTheme( + new JsDiscoveryRequest(app: $app, theme: $this->resolveTheme($app)) + ); + foreach ($scripts as $entry) { + $collector->addScript($entry->uri); + } + } + + private function resolveTheme(string $app): string + { + $uid = $this->session->getAuthId() ?? ''; + + return $uid === '' + ? 'default' + : $this->themeResolver->resolve($uid, null, $app); + } + public function configure(AssetCollector $collector, ViewMode $mode): void { $this->addBasicScripts($collector); diff --git a/src/PageOutput/ViewModeConfiguratorFactory.php b/src/PageOutput/ViewModeConfiguratorFactory.php index 3f349cb7..43ad1653 100644 --- a/src/PageOutput/ViewModeConfiguratorFactory.php +++ b/src/PageOutput/ViewModeConfiguratorFactory.php @@ -16,7 +16,9 @@ namespace Horde\Core\PageOutput; +use Horde\Core\Assets\CssDiscoverer; use Horde\Core\Assets\JsDiscoverer; +use Horde\Core\Assets\ThemeResolver; use Horde\Core\Service\PrefsService; use Horde\Core\Session\SessionAccess; use Horde_Injector; @@ -42,6 +44,8 @@ public function create(Horde_Injector|Injector $injector): ViewModeConfigurator $injector->get(SessionAccess::class), $injector->get(JsDiscoverer::class), $injector->get(Token::class), + $injector->get(CssDiscoverer::class), + $injector->get(ThemeResolver::class), ); } } From a737985918da3791a6b015a23c8220fce53381d4 Mon Sep 17 00:00:00 2001 From: Fardel Pierre Date: Mon, 12 Oct 2026 10:41:52 +0200 Subject: [PATCH 2/2] fix(PageOutput): resolve the theme in the horde scope for theme assets The theme preference is Horde-wide. Resolving it in the app's scope ignored a user's horde:theme for non-horde apps, as the chrome renderer factories already avoid. --- src/PageOutput/ViewModeConfigurator.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/PageOutput/ViewModeConfigurator.php b/src/PageOutput/ViewModeConfigurator.php index bc2432b8..1e8539ea 100644 --- a/src/PageOutput/ViewModeConfigurator.php +++ b/src/PageOutput/ViewModeConfigurator.php @@ -75,7 +75,7 @@ public function addThemeStylesheets( return; } - $theme = $this->resolveTheme($app); + $theme = $this->resolveTheme(); $result = $this->cssDiscoverer->discover( new CssDiscoveryRequest(files: $files, app: $app, theme: $theme) @@ -100,20 +100,25 @@ public function addThemeScripts(AssetCollector $collector, string $app = 'horde' } $scripts = $this->jsDiscoverer->discoverTheme( - new JsDiscoveryRequest(app: $app, theme: $this->resolveTheme($app)) + new JsDiscoveryRequest(app: $app, theme: $this->resolveTheme()) ); foreach ($scripts as $entry) { $collector->addScript($entry->uri); } } - private function resolveTheme(string $app): string + /** + * The theme is a Horde-wide preference: resolve it in the horde scope, + * as DesktopChromeRendererFactory does, whatever app the assets belong + * to. $app only selects which app's files are discovered. + */ + private function resolveTheme(): string { $uid = $this->session->getAuthId() ?? ''; return $uid === '' ? 'default' - : $this->themeResolver->resolve($uid, null, $app); + : $this->themeResolver->resolve($uid); } public function configure(AssetCollector $collector, ViewMode $mode): void