Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/Controller/OverridesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ public function importOverrides(): JSONResponse {
return new JSONResponse(['error' => 'Could not read uploaded file'], 400);
}

$parsed = $this->cssParser->parseDeclarations($content);
// The light values only; an exported file also carries the dark blocks.
$parsed = $this->cssParser->parseOverridesFile(css: $content);
if ($parsed === null) {
return new JSONResponse(
['error' => 'No CSS custom property declarations found in the uploaded file'],
Expand Down
2 changes: 1 addition & 1 deletion lib/Service/ConfigBundleService.php
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ private function validateOverridesSection(array $bundle, array &$errors): array
return ['tokens' => [], 'skipped' => []];
}

$parsed = $this->cssParser->parseDeclarations(content: $css);
$parsed = $this->cssParser->parseOverridesFile(css: $css);
if ($parsed === null) {
$parsed = [];
}
Expand Down
28 changes: 28 additions & 0 deletions lib/Service/CssParserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,34 @@ public function parseRootBlock(string $css): array {
return [];
}//end parseRootBlock()

/**
* Read the light values out of a custom-overrides file.
*
* The file custom-overrides.css carries its light values in a `:root` block
* and, for brand colour overrides, the same tokens again in two dark blocks. Reading every
* declaration in the file would let the dark values overwrite the light
* ones on import. So a file with a `:root` block is read from that block
* alone; a file of bare declarations (a hand-written import) is read whole.
*
* @param string $css The raw file content.
*
* @return array<string, string>|null Token => light value, or null when the file declares nothing.
*
* @spec openspec/changes/authoring-token-value-types/tasks.md#task-2.2
*/
public function parseOverridesFile(string $css): ?array {
if (preg_match('/:root\s*\{/', $css) === 1) {
$root = $this->parseRootBlock(css: $css);
if (empty($root) === true) {
return null;
}

return $root;
}

return $this->parseDeclarations(content: $css);
}//end parseOverridesFile()

/**
* Parse hand-authored dark-mode declarations from a top-level
* `@media (prefers-color-scheme: dark) { :root { ... } }` block.
Expand Down
90 changes: 86 additions & 4 deletions lib/Service/CustomOverridesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@
* It validates all token names against the TokenRegistry before writing.
*
* The CSS file format is strictly controlled:
* - Single :root {} block
* - One :root {} block with the light values, read back by read()
* - For every brand-layer colour override, the two dark scopes of the generated dark
* stylesheets with its derived dark value, so a user who chose the dark
* theme (whose colours Nextcloud declares on body) and a user whose system
* is dark see the same colour
* - One declaration per line
* - Each declaration carries !important so user overrides win the cascade over
* the nldesign design-system stylesheets and Nextcloud core theming
* - No selectors other than :root
* - No selectors other than :root and the two dark scopes
*
* @spec openspec/changes/retrofit-2026-05-24-annotate-nldesign/tasks.md#task-8
* @spec openspec/changes/retrofit-2026-05-24-annotate-nldesign/tasks.md#task-28
Expand All @@ -54,6 +58,22 @@
*/
class CustomOverridesService {

/**
* The scope of a user whose system is dark and who chose no theme (as in
* the generated dark stylesheets).
*
* @var string
*/
private const SYSTEM_DARK_SELECTOR = 'body:not([data-theme-light]):not([data-theme-dark])'
. ':not([data-theme-light-highcontrast]):not([data-theme-dark-highcontrast])';

/**
* The scope of a user who chose the dark theme (as in the generated dark stylesheets).
*
* @var string
*/
private const CHOSEN_DARK_SELECTOR = 'body[data-theme-dark],' . PHP_EOL . 'body[data-themes*=dark]';

/**
* The CSS file header comment.
*
Expand All @@ -75,15 +95,25 @@ class CustomOverridesService {
*/
private CssParserService $cssParser;

/**
* The dark palette, which derives a colour override's dark value exactly
* as the generated dark stylesheets do.
*
* @var DarkPaletteService
*/
private DarkPaletteService $darkPalette;

/**
* Constructor.
*
* @param IAppManager $appManager The app manager.
* @param CssParserService $cssParser CSS parser for :root block extraction.
* @param DarkPaletteService $darkPalette Derives each colour override's dark value.
*/
public function __construct(IAppManager $appManager, CssParserService $cssParser) {
public function __construct(IAppManager $appManager, CssParserService $cssParser, DarkPaletteService $darkPalette) {
$this->appManager = $appManager;
$this->cssParser = $cssParser;
$this->darkPalette = $darkPalette;
}//end __construct()

/**
Expand Down Expand Up @@ -277,10 +307,62 @@ private function buildCss(array $tokens): string {
}

$lines = $this->buildDeclarationLines(tokens: $tokens);
$css = $header . ':root {' . PHP_EOL . implode(PHP_EOL, $lines) . PHP_EOL . '}' . PHP_EOL;

$darkLines = $this->buildDeclarationLines(tokens: $this->darkValues(tokens: $tokens));
if (empty($darkLines) === true) {
return $css;
}

return $header . ':root {' . PHP_EOL . implode(PHP_EOL, $lines) . PHP_EOL . '}' . PHP_EOL;
// The same two scopes the generated dark stylesheets use. A user who
// chose the dark theme gets Nextcloud's dark colours declared on body,
// which a :root value never reaches; a body-level declaration wins for
// both kinds of dark user.
$css .= '@media (prefers-color-scheme: dark) {' . PHP_EOL
. ' ' . self::SYSTEM_DARK_SELECTOR . ' {' . PHP_EOL
. ' ' . implode(PHP_EOL . ' ', $darkLines) . PHP_EOL
. ' }' . PHP_EOL
. '}' . PHP_EOL
. self::CHOSEN_DARK_SELECTOR . ' {' . PHP_EOL
. implode(PHP_EOL, $darkLines) . PHP_EOL
. '}' . PHP_EOL;

return $css;
}//end buildCss()

/**
* The dark value of every brand-layer colour override: derived as the
* generated dark stylesheets derive it, or the light value when it is not
* a colour literal, so both kinds of dark user still see the same thing.
*
* Only the brand layer (Nextcloud's own variables) is split today, because
* only those does Nextcloud re-declare on body for a chosen theme. Component
* tokens are left out: both kinds of dark user already get the same body
* value from the generated dark stylesheet, and a body-level copy here would
* outrank the primary-lock layer, which locks them at :root.
*
* @param array<string, string> $tokens Token name => light value.
*
* @return array<string, string> Colour token name => dark value.
*
* @SuppressWarnings(PHPMD.StaticAccess) - TokenRegistry uses static methods by design
*
* @spec openspec/changes/authoring-token-value-types/tasks.md#task-2.2
*/
private function darkValues(array $tokens): array {
$registry = TokenRegistry::getTokens();
$dark = [];
foreach ($tokens as $name => $value) {
if (($registry[$name]['type'] ?? '') !== 'color' || ($registry[$name]['group'] ?? '') !== 'brand') {
continue;
}

$dark[$name] = ($this->darkPalette->deriveDarkValue(token: $name, lightValue: $value, context: $tokens) ?? $value);
}

return $dark;
}//end darkValues()

/**
* Build individual CSS declaration lines from a token map.
*
Expand Down
34 changes: 28 additions & 6 deletions lib/Service/DarkPaletteService.php
Original file line number Diff line number Diff line change
Expand Up @@ -314,22 +314,44 @@ public function deriveDarkDeclarations(array $lightDeclarations): array {
// propagates through this alias in dark mode; the alternative is a
// dark mode that only ever half-applies.
$literal = $this->resolveAlias(value: $value, declarations: $lightDeclarations);
$rgba = $this->contrast->parseColorWithAlpha(value: $literal);
if ($rgba === null) {
$darkValue = $this->deriveDarkValue(token: $token, lightValue: $literal, context: $lightDeclarations);
if ($darkValue === null) {
// Unparseable (gradient, keyword, size, font stack, url(), an
// alias chain with no literal at the end) — skip.
continue;
}

// The dark channels come from the opaque colour; a translucent light
// value keeps its alpha, so an overlay stays an overlay in dark mode.
$dark[$token] = $this->deriveColorToken(token: $token, rgb: [$rgba[0], $rgba[1], $rgba[2]], lightDeclarations: $lightDeclarations)
. $this->alphaSuffix(alpha: $rgba[3]);
$dark[$token] = $darkValue;
}

return $this->regenerateRgbCompanions(lightDeclarations: $lightDeclarations, darkDeclarations: $dark);
}//end deriveDarkDeclarations()

/**
* Derive the dark value of one colour literal, as the generated dark
* stylesheets do, so the token editor and the generator never disagree.
*
* The dark channels come from the opaque colour; a translucent light value
* keeps its alpha, so an overlay stays an overlay in dark mode.
*
* @param string $token The token name (decides text-class or surface-class).
* @param string $lightValue The light colour literal.
* @param array<string, string> $context The light declarations around it, for the brand-primary exception.
*
* @return string|null The dark hex value, or null when the value is not a colour literal.
*
* @spec openspec/changes/authoring-token-value-types/tasks.md#task-2.3
*/
public function deriveDarkValue(string $token, string $lightValue, array $context = []): ?string {
$rgba = $this->contrast->parseColorWithAlpha(value: $lightValue);
if ($rgba === null) {
return null;
}

return $this->deriveColorToken(token: $token, rgb: [$rgba[0], $rgba[1], $rgba[2]], lightDeclarations: $context)
. $this->alphaSuffix(alpha: $rgba[3]);
}//end deriveDarkValue()

/**
* Follow a `var()` alias chain to the literal it ends at.
*
Expand Down
7 changes: 6 additions & 1 deletion tests/Unit/Controller/OverridesControllerValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
namespace OCA\Thematiq\Tests\Unit\Controller;

use OCA\Thematiq\Controller\OverridesController;
use OCA\Thematiq\Service\ContrastService;
use OCA\Thematiq\Service\CssParserService;
use OCA\Thematiq\Service\CustomOverridesService;
use OCA\Thematiq\Service\DarkPaletteService;
use OCA\Thematiq\Service\ThemingAuditService;
use OCP\App\IAppManager;
use OCP\IRequest;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
* Thematiq#694: a save with an unknown token name or an unsafe value answered
Expand Down Expand Up @@ -69,7 +72,9 @@ protected function setUp(): void {
$appManager = $this->createMock(IAppManager::class);
$appManager->method('getAppPath')->willReturn($this->appDir);

$this->overridesService = new CustomOverridesService($appManager, new CssParserService());
$parser = new CssParserService();
$darkPalette = new DarkPaletteService(new ContrastService(), $parser, $appManager, $this->createMock(LoggerInterface::class));
$this->overridesService = new CustomOverridesService($appManager, $parser, $darkPalette);
$this->overridesService->write(tokens: ['--color-primary' => '#000000']);

$this->auditService = $this->createMock(ThemingAuditService::class);
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/Service/ConfigBundleServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function (string $app, string $key, $value): void {
$customTokenSetValidator = new CustomTokenSetValidator();
$logger = $this->createMock(LoggerInterface::class);

$this->overridesService = new CustomOverridesService($appManager, $cssParser);
$this->overridesService = new CustomOverridesService($appManager, $cssParser, new DarkPaletteService($contrast, $cssParser, $appManager, $logger));
$this->customTokenSetService = new CustomTokenSetService(
$appManager,
$config,
Expand Down
Loading
Loading