You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(theme): core color schemes and backgrounds, account settings tab cleanup - #3945
Moves the ThemeBundle plugin's features into core:
Color schemes: 11 more schemes (Copper, Mustard, Nebula, Fuchsia, Fruit, Highlands, Moss, Morning, Ocean, Energy, Forest) in Theme::$colorSchemes. The keys are unchanged, so schemes users picked through the plugin still apply.
Background tab in the user's own settings, plus a "Background" link in the user menu. Users pick the color gradient, upload an image or choose an Unsplash photo. Each change saves at once and swaps the page background in place (out-of-band swap of #backgroundImageSetter), with no reload.
Unsplash only when configured: set LEAN_UNSPLASH_ACCESS_KEY (documented in config/sample.env). There is no built-in key. Without one the tab offers uploads only.
Changes compared with the plugin
Photos are resolved on the server by their Unsplash id. The plugin accepted any URL from the browser, stored it and printed it into inline JS (updateBg('…') with FILTER_SANITIZE_URL, which keeps quotes). Only https://images.unsplash.com URLs are stored now.
Unsplash API guidelines: results credit the photographer with referral links, and choosing a photo reports the download (download_location).
Uploads must be real JPG/PNG/GIF/WebP images (extension + getimagesize). SVG is refused. The file is stored in the owner-restricted user module. A failed upload no longer crashes on the error string Files::upload() returns.
The header's background <style> moved to sections.backgroundStyle, so the tab reuses the same CSS for the swap.
The plugin's "Save settings" button only showed a toast and is gone, since every change already saves.
Code
app/Domain/Users/Services/Backgrounds.php: background type, Unsplash search/selection, upload. Uses Laravel's HTTP client Factory through DI (no facade).
app/Domain/Users/Hxcontrollers/Background.php + users::partials.background: the tab, with current-background and unsplash-results fragments.
Strings translated into all 43 languages. Arabic, Persian, kaa, ks-IN and km-KH would benefit from a native review.
Account settings tabs
Tabs are now: Profile, Security, Settings, Notifications, Webhooks, Theme, Background, plugin tabs, Personal Access Tokens (always last).
Webhooks tab: the personal webhook moved out of the Notifications tab into its own tab and form. Users::saveOwnWebhookSettings() (@api) saves it, and saveOwnNotificationPreferences() no longer touches the webhook. API clients that sent webhook fields along with the notification preferences must call the new method (no compatibility path).
Personal Access Tokens: the page renders this tab after @dispatchEvent('tabs') instead of an Auth listener injecting it, so plugin tabs can't push it out of last place. The ShowPersonalToken* listeners are removed.
Fix: clearing the webhook URL never worked. ConvertEmptyStringsToNull turns the emptied field into null, which was rejected as an invalid URL. A missing or null URL now clears the webhook.
The webhook description pointed to the settings "above"; it now names the Notifications tab (all languages).
Follow-ups (not in this PR)
Remove ThemeBundle from the plugins repo, otherwise installs with both get two Background tabs.
Rotate the Unsplash key that was hardcoded in the plugin's Config/UnsplashConfig.php, and set the new one as LEAN_UNSPLASH_ACCESS_KEY on Cloud. The free demo tier allows 50 requests/hour.
Test plan
New unit tests tests/Unit/app/Domain/Users/Services/BackgroundsTest.php (8 tests): no key → Unsplash off and no requests; search mapping + auth header; failure → empty; photo selection stores the resized URL and reports the download; non-Unsplash host refused; bad photo id refused without a request; unknown type refused; fake image upload refused.
Existing ThemeTest, HeaderCssStringTest, EditOwnTest, OnboardingServiceTest and UsersServiceTest pass. Pint and PHPStan are clean on the changed files.
In the browser (worktree site), without a key: only upload is shown; gradient, upload, fake-image rejection; the background persists across pages.
In the browser, with a key: initial wallpapers, search, attribution, photo selection applied live, Unsplash/Upload toggle.
Theme tab lists all 15 schemes; the user menu shows the Background link.
Webhook tests moved to saveOwnWebhookSettings(), plus new tests: notification preferences never touch the webhook; a null URL clears it; the controller routes savewebhooks to #webhooks.
In the browser: tab order; Webhooks tab rejects an http URL, saves an https one, and clearing the URL works; saving Notifications leaves the webhook unchanged; Personal Access Tokens tab loads.
… core
The ThemeBundle plugin's features are now part of core:
- 11 more color schemes (Copper, Mustard, Nebula, ...). Their keys are
unchanged, so schemes users picked through the plugin still apply.
- A Background tab in the user's own settings (and a user menu link):
the color gradient, an uploaded image or an Unsplash photo. Every
change saves right away and swaps the page background in place.
- Unsplash is only offered when LEAN_UNSPLASH_ACCESS_KEY is set. There is
no built-in key; without one the tab offers uploads only.
Changes from the plugin:
- Photos are resolved on the server by their Unsplash id, so a client can
no longer store an arbitrary URL as background (the plugin printed it
into inline JS). Selecting a photo reports the download to Unsplash and
results credit the photographer, as the Unsplash API guidelines require.
- Uploads must be JPG, PNG, GIF or WebP images (SVG is refused), and a
failed upload no longer crashes on the error string Files::upload returns.
- The header's background <style> moved to sections.backgroundStyle so the
tab reuses it for the out-of-band swap.
Strings are translated into all 43 languages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…last
Account settings tabs are now: Profile, Security, Settings, Notifications,
Webhooks, Theme, Background, plugin tabs, Personal Access Tokens.
- The personal webhook moves out of the Notifications tab into a Webhooks
tab with its own form. Users::saveOwnWebhookSettings() saves it;
saveOwnNotificationPreferences() no longer touches the webhook.
- The Personal Access Tokens tab is rendered by the page after the plugin
tabs instead of through an Auth event listener, so it is always last.
The two listener classes are gone.
- Clearing the webhook URL works again: ConvertEmptyStringsToNull turns the
emptied field into null, which was rejected as an invalid URL. A missing
or null URL now clears the webhook.
- The webhook description pointed to the settings "above"; it now names
the Notifications tab (all languages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marcelfolaron
changed the title
feat(theme): bring the ThemeBundle color schemes and backgrounds into core
feat(theme): core color schemes and backgrounds, account settings tab cleanup
Oct 18, 2026
- Background changes (type, Unsplash photo, upload) only act on POST and
read the type from the body. The Frontcontroller dispatches GET to these
actions too and the origin check skips GET, so a cross-site link could
reset a user's background.
- Uploads are checked by the detected image type too: getimagesize() also
recognizes TIFF, BMP and others, which browsers can't show.
- The gradient/image tiles are native buttons with aria-pressed, so the
choice works with the keyboard (global::selectable is mouse-only).
- "Show other images" asks for the next page of a typed search instead of
the same results; typing a new query starts at page 1 again.
- The Background tab loads when it is shown (intersect once), so visits to
other account settings tabs no longer query Unsplash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Upload lifecycle and theme accessibility concerns remain unresolved, and the external API and live-update integration need final human validation.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Improve primary-button contrast in Ocean, Nebula, and Energy themes
app/Core/UI/Theme.php:160
Ocean, Nebula, and Energy give white primary-button labels contrast ratios of only 1.85:1, 2.89:1, and 2.97:1 respectively, below the 4.5:1 requirement for normal text. Both built-in themes keep --accent1-color white, and public/assets/css/components/forms.css:376–385 uses it through --main-action-color on the accent background. This makes actions such as Save and Upload difficult to read; the navigation scrim does not cover these buttons. Provide a contrasting action foreground or darker action background for these schemes.
Replace background uploads without deleting obsolete files
app/Domain/Users/Services/Backgrounds.php:213
Each background upload creates a new file and database row, but replacing it with another upload, an Unsplash photo, or the gradient never removes the old file. The theme only replaces or clears the saved URL, so repeated changes accumulate unused storage. Track the background upload's file ID and delete that specific file after a replacement is successfully saved. Do not delete all files in the user module, which also holds profile pictures.
…kground uploads
- Ocean, Nebula and Energy primaries are darkened (same hue) so white
button labels reach WCAG AA (1.85/2.89/2.97:1 -> 4.6:1).
- The uploaded background's file id is stored per user; when another
upload, an Unsplash photo or the gradient replaces it, that one file is
deleted. Other "user" module files (profile pictures) are never touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addressed the two issues from the latest Copilot review summary (they had no inline threads to resolve) in db4bfd4:
Contrast: Ocean, Nebula and Energy primaries darkened with the same hue so white button labels pass AA: #73ccd5 → #298089, #d18475 → #bf5440, #FA6821 → #cf4705 (all ≈4.6:1). I didn't flip --accent1-color to dark text globally, because --main-action-color derives from it and is also used on dark, non-accent backgrounds (e.g. project avatars).
Upload lifecycle: the background's uploaded file id is stored in usersettings.{id}.backgroundFileId. A new upload, an Unsplash photo or the gradient deletes that one file via Files::deleteFile(); other user module files (profile pictures) are untouched. New unit tests cover all three replacements, and it was verified against the dev DB.
Forest theme has unreadable dark-mode text contrast
app/Core/UI/Theme.php:170
Forest uses #74211c unchanged in dark mode because getColorSchemeForMode() only adjusts grayscale schemes. Both built-in dark themes use this as the active-project text color on #404040 (nav.css:644–648), giving approximately 1.02:1 contrast. Global links also use this primary color. Add a readable dark-mode variant through getColorSchemeForMode(), preserving the forrest key and meeting text contrast requirements.
…in dark mode
- Background uploads get a unique stored name. With
LEAN_FILESYSTEM_RENAME_FILES=false files keep their own name, so two
uploads of the same file shared one stored file and deleting the
replaced upload deleted the new background.
- The background type and image are read from the settings cache on every
request instead of a session copy, so a change (and the deleted upload)
made in another session shows up there too. Saves already invalidate
that cache.
- Forest gets a dark-mode variant (#db7069 / #ac8874): its near-black red
was 1.0:1 on dark panels; the lighter red keeps 3.2:1 on #404040 and
under white button labels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ckground cleanup
- Primary buttons get --main-action-label-color: where the white label is
below AA on the first accent and dark text reads better, the header sets
it to #1a1a1a (Forest in dark mode: 3.2:1 -> 5.6:1). Other uses of
--main-action-color are unchanged.
- Background uploads are named background{16 hex}: files/get strips
everything but letters and digits from the name before the lookup, so
the hyphenated name returned 404 when file renaming is off.
- The tracked upload stores its URL too, and the previous upload is only
deleted when it is not the background that ended up saved, so two
changes racing in two tabs can never delete the image in use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ten new palettes other than Forest keep their light-mode accents in dark mode. Active dropdown labels use the primary accent on #404040 (public/theme/default/css/dark.css:26,73–74 and public/assets/css/components/dropdowns.css:704–708). Contrast ranges from 1.43:1 for Copper to only 2.25:1, making these labels difficult to read. Provide contrasting dark-mode variants or a separate accessible text color. The new button-label token does not affect dropdown text.
…ttes, contrast-aware preview
- Background changes (gradient, Unsplash photo, upload) save, track the
uploaded file and delete the replaced one under a per-user cache lock,
so two tabs can't delete the upload that ends up in use. The URL guard
is gone; it compared against request-local cached settings. A change
that can't get the lock within 10s reports "not saved" and removes its
fresh upload. Unsplash lookups and file storage stay outside the lock.
- All eleven added palettes get dark-mode colors that reach ~4.6:1 as text
on #404040 (active menu/dropdown items, links); their primary buttons
get the dark label (#1a1a1a, ~7.8:1). The dark-mode overrides, grayscale
included, are one table.
- The theme tab's live preview (snippets.toggleColors) applies the same
contrast rules as the header: nav scrim and dark button label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addressed the remaining summary issue (light-mode accents in dark mode) in 8a6f922: all eleven added palettes now have dark-mode colors that reach ~4.6:1 as text on #404040 (active menu/dropdown items, links). Their primary buttons switch to the dark label (~7.8:1) through --main-action-label-color. The dark-mode overrides, grayscale included, are now one table (Theme::DARK_MODE_COLOR_SCHEMES). This replaces the 3:1 compromise from my earlier comment. The default Leantime/company colors are unchanged in dark mode; that's an existing limitation outside this PR.
Serialize requests across this picker, not just each individual button. Selecting a photo saves its URL and releases the server lock before waiting for Unsplash download tracking. During that wait, clicking the gradient can save and display the gradient. The photo response can then arrive last with its request-local cached image and restore that image on screen, although the saved preference is gradient. A shared HTMX request queue keeps these responses ordered; aborting a request alone would not stop its server-side writes.
The gradient button, photo buttons and upload form sync on
#backgroundPicker with "drop": clicks while a change is running are
ignored, so a slower response can no longer repaint a background the user
already replaced. "queue" lost them instead, because the response replaces
the gradient button and htmx drops queued requests of removed elements.
While a change runs the picker dims the current background and shows a
progress cursor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addressed the summary issue "Serialize picker requests" in ed08fd8. The gradient button, photo buttons and upload form share hx-sync="#backgroundPicker:drop", so only one background change runs at a time and a slower response can't repaint a background the user already replaced. While it runs, the picker dims the current background and shows a progress cursor (hx-indicator="#backgroundPicker").
I used drop rather than queue on purpose. Each response replaces #currentBackground, including the gradient button, and htmx discards queued requests whose element was removed. With queue, the gradient click was silently lost (verified in the browser).
Verified: clicking a photo and then immediately the gradient runs only the photo request; screen and saved setting both show the photo; a later gradient click works normally.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Moves the ThemeBundle plugin's features into core:
Theme::$colorSchemes. The keys are unchanged, so schemes users picked through the plugin still apply.#backgroundImageSetter), with no reload.LEAN_UNSPLASH_ACCESS_KEY(documented inconfig/sample.env). There is no built-in key. Without one the tab offers uploads only.Changes compared with the plugin
updateBg('…')withFILTER_SANITIZE_URL, which keeps quotes). Onlyhttps://images.unsplash.comURLs are stored now.download_location).getimagesize). SVG is refused. The file is stored in the owner-restrictedusermodule. A failed upload no longer crashes on the error stringFiles::upload()returns.<style>moved tosections.backgroundStyle, so the tab reuses the same CSS for the swap.Code
app/Domain/Users/Services/Backgrounds.php: background type, Unsplash search/selection, upload. Uses Laravel's HTTP clientFactorythrough DI (no facade).app/Domain/Users/Hxcontrollers/Background.php+users::partials.background: the tab, withcurrent-backgroundandunsplash-resultsfragments.Account settings tabs
Tabs are now: Profile, Security, Settings, Notifications, Webhooks, Theme, Background, plugin tabs, Personal Access Tokens (always last).
Users::saveOwnWebhookSettings()(@api) saves it, andsaveOwnNotificationPreferences()no longer touches the webhook. API clients that sent webhook fields along with the notification preferences must call the new method (no compatibility path).@dispatchEvent('tabs')instead of an Auth listener injecting it, so plugin tabs can't push it out of last place. TheShowPersonalToken*listeners are removed.ConvertEmptyStringsToNullturns the emptied field intonull, which was rejected as an invalid URL. A missing or null URL now clears the webhook.Follow-ups (not in this PR)
ThemeBundlefrom the plugins repo, otherwise installs with both get two Background tabs.Config/UnsplashConfig.php, and set the new one asLEAN_UNSPLASH_ACCESS_KEYon Cloud. The free demo tier allows 50 requests/hour.Test plan
tests/Unit/app/Domain/Users/Services/BackgroundsTest.php(8 tests): no key → Unsplash off and no requests; search mapping + auth header; failure → empty; photo selection stores the resized URL and reports the download; non-Unsplash host refused; bad photo id refused without a request; unknown type refused; fake image upload refused.saveOwnWebhookSettings(), plus new tests: notification preferences never touch the webhook; a null URL clears it; the controller routessavewebhooksto#webhooks.🤖 Generated with Claude Code