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 docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ WIRECLOUD_OIDC_PLUGIN=keycloak
WIRECLOUD_OIDC_FULLY_SYNC_GROUPS=false
WIRECLOUD_OIDC_BACKCHANNEL_LOGOUT=false

# Optional themes
# Optional themes (included: defaulttheme, industrytheme, fiwaretheme,
# fiwarelabtheme, fiwarelabdarktheme)
WIRECLOUD_AVAILABLE_THEMES=defaulttheme
WIRECLOUD_THEME_ACTIVE=defaulttheme
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ For containers, configure the variables below in `docker/.env` or through your d
| `WIRECLOUD_INSTALLED_APPS` | core app list | Comma-separated Python modules that provide WireCloud plugins. |
| `WIRECLOUD_DEFAULT_LANGUAGE` | `en` | Fallback language code. It must be present in `WIRECLOUD_LANGUAGES`. |
| `WIRECLOUD_LANGUAGES` | `en:English,es:Spanish,pt:Portuguese` | Comma-separated `code:label` entries. |
| `WIRECLOUD_AVAILABLE_THEMES` | `defaulttheme` | Comma-separated theme names. |
| `WIRECLOUD_AVAILABLE_THEMES` | `defaulttheme` | Comma-separated theme names. Bundled themes: `defaulttheme`, `industrytheme` (light and dark color schemes), `fiwaretheme`, `fiwarelabtheme` and `fiwarelabdarktheme`. |
| `WIRECLOUD_THEME_ACTIVE` | `defaulttheme` | Default theme; it must be in the available theme list. |

The default installed apps are `wirecloud.commons`, `wirecloud.platform`, `wirecloud.catalogue`, `wirecloud.proxy`, `wirecloud.fiware`, and `wirecloud.keycloak`. The commons and catalogue apps are required. Enabling the platform app also requires the proxy app.
Expand Down
70 changes: 69 additions & 1 deletion docs/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ parent = "defaulttheme"
label = _("Acme")
```

Set `parent = None` only for a complete root theme that supplies everything WireCloud needs. An inherited theme is safer because new parent templates and assets remain available after an upgrade.
Set `parent = None` only for a complete root theme that supplies everything WireCloud needs. An inherited theme is safer because new parent templates and assets remain available after an upgrade. A root theme has no fallback for templates, static files or translations: when an optional module such as `wirecloud.oauth2provider` adds templates to the default theme, the root theme must add its own versions, and its catalogs must translate every string those templates pass to `trans()`.

The module name, directory name, and configured theme name must match exactly. A missing `parent` attribute is a configuration error. A parent that cannot be imported ends the inheritance chain, usually causing missing templates or assets later, so validate the name carefully.

Expand Down Expand Up @@ -90,6 +90,74 @@ Keep overrides narrow. Copying a large parent template freezes its structure in

Plugins may declare additional template fragments through their `get_templates` hook. Every enabled theme must supply or inherit those paths; otherwise `/api/theme/{theme}` reports the missing template. This is one reason presentation-only changes belong in themes while new behavior belongs in [plugins](plugins.md).

## Theme configuration and extension points

Themes can go beyond restyling. The following hooks are available to any theme; when a theme does not use them, the platform behaves as before.

### Client configuration

A theme module can declare a `config` dictionary. It is returned by `/api/theme/{theme}` and exposed as `Wirecloud.currentTheme.config` (child themes inherit and override their parent's entries):

```python
config = {
"color_schemes": ["light", "dark"], # schemes supported by the stylesheets
"keyboard_shortcuts": True, # enable Wirecloud.ui.shortcuts
"wiring_click_to_connect": True, # click an output, then an input
"shell": "rail", # free-form entries for the theme scripts
"icon_set": "lucide",
}
```

When `color_schemes` lists more than one scheme, the *Color scheme* platform preference (`system`, `light` or `dark`) is shown in the user settings.

### Root state attributes

The root element exposes the platform state, so themes can style states with CSS only:

| Attribute | Values |
| --- | --- |
| `data-wc-theme` | active theme name |
| `data-wc-view` | `classic`, `smartphone` or `embedded` |
| `data-wc-mode` | `view`, `edit`, `wiring`, `catalogue` or `marketplace` |
| `data-wc-color-scheme` | `light` or `dark` (also set on the widget documents) |
| `data-wc-screen-size` | name of the active screen size of the visible tab |

`Wirecloud.UserInterfaceManager.setRootState(name, value)` updates them. Before the platform code is loaded, `data-wc-color-scheme` is missing; style the system preference with `@media (prefers-color-scheme: dark)` on `:root:not([data-wc-color-scheme])`. The last resolved scheme is stored in `localStorage` (`wirecloud.color-scheme`) so pages can apply it before painting.

### Replaceable UI classes

Theme scripts (returned by `get_scripts` in `theme.ts`) run after the platform ones and can replace UI classes through a registry:

```javascript
class MyHeader extends Wirecloud.ui.WirecloudHeader { /* ... */ }
Wirecloud.ui.registry.register("WirecloudHeader", MyHeader);
```

The platform builds these pieces through the registry: `WirecloudHeader`, `WorkspaceView`, `WorkspaceToolbar` (header buttons of the workspace view), `WidgetView` (override `getTemplateComponents()` to change the widget chrome), `WiringEditor`, `WiringEditor.Component`, `WiringEditor.ComponentDraggable`, `MarketplaceView`, `MyResourcesView`, `MyResourcesSearchView` (search view of My resources), `CatalogueSearchView`, `ResourcePainter` and `CommandPalette`. `Wirecloud.ui.registry.get(name)` falls back to `Wirecloud.ui.<name>`.

`Wirecloud.ui.WirecloudHeader.defaults` holds the presentation defaults of the default header (button classes and icons, including `userMenuIconClasses` for each user menu entry: `settings`, `appconnections`, `djangoadmin`, `switchuser`, `adminpanel`, `backtouser` and `signout`) and `buildUserMenu(menu)` fills the user menu, so small changes do not require a new header.

### Semantic view API

Besides `getBreadcrumb()`, `getToolbarButtons()` and `getToolbarMenu()`, the workspace view and the wiring editor describe their actions so headers can render them in any form:

- `getModes()` → `[{id: "view"|"edit"|"wiring", label, icon, shortcut, active, enabled, activate()}]`
- `getPrimaryAction()` → `{id, label, icon, enabled, run()}` ("Add widget", "Add operator") or `null`
- `getSecondaryActions()` → e.g. `[{id: "share", ...}]`
- `deleteSelection()` and `cancel()` (used by the keyboard shortcuts)

The workspace view also tracks the selected widget while editing (`selectWidget(widget)`, `selectedWidget`, `selectionchange` event; the widget gets the `wc-widget-selected` class).

### Commands and keyboard shortcuts

`Wirecloud.commands` lists the commands available in the current context: workspaces, workspace/tab/widget/marketplace menus (built from the same menu builders), navigation and installed components. Use `list(query)` and `run(command)`, and `register()`/`registerProvider()` to add commands. `openPalette()` shows the palette registered as `CommandPalette`.

`Wirecloud.ui.shortcuts` handles `⌘/Ctrl+K` (palette), `V`/`E`/`W` (modes), `Delete` (remove the selected widget or connection) and `Esc` (cancel). Shortcuts are only processed when the theme sets `keyboard_shortcuts`; themes can `register()` more.

### The Industry theme

`industrytheme` is a complete root theme built on these hooks: a navigation rail, a header with a mode switch and a command palette, layout controls in the tab bar, a status bar, a faceted My resources view with a detail pane, light and dark color schemes, Barlow fonts and Lucide icons (Font Awesome class names used by the platform and widgets are mapped to Lucide icons in `static/css/base/_icon-map.scss`; run `scripts/update_icons.py` after adding icons). Its stylesheets are written against the CSS custom properties of `static/css/_tokens.scss`.

## Translations

For a translatable theme label or template strings, catalogue files use the theme name as their gettext domain:
Expand Down
3 changes: 2 additions & 1 deletion src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@
WIRECLOUD_HTTPS_VERIFY = True

AVAILABLE_THEMES = [
"defaulttheme"
"defaulttheme",
"industrytheme"
]
THEME_ACTIVE = "defaulttheme"

Expand Down
25 changes: 25 additions & 0 deletions src/wirecloud/commons/static/js/StyledElements/MenuItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,35 @@
}

priv.iconelement.className = "se-icon " + iconClass;
priv.iconClass = iconClass;

return this;
}

/**
* Whether this Menu Item runs a destructive action. Danger items are
* flagged with the `se-popup-menu-item-danger` class.
*
* @type {Boolean}
*/
get danger() {
return this.hasClassName('se-popup-menu-item-danger');
}

set danger(value) {
this.toggleClassName('se-popup-menu-item-danger', !!value);
}

/**
* Icon class currently associated with this Menu Item (if any)
*
* @type {String|null}
*/
get iconClass() {
const iconClass = privates.get(this).iconClass;
return iconClass != null ? iconClass : null;
}


get active() {
return this.hasClassName("active");
Expand Down
5 changes: 4 additions & 1 deletion src/wirecloud/commons/static/js/wirecloud/ui/Theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
Object.defineProperties(this, {
name: {value: desc.name},
label: {value: desc.label},
templates: {value: desc.templates}
templates: {value: desc.templates},
// Optional client configuration declared by the theme module
// (e.g. `color_schemes`, `shell`, `icon_set`)
config: {value: Object.freeze(Object.assign({}, desc.config))}
});
Object.freeze();
};
Expand Down
45 changes: 39 additions & 6 deletions src/wirecloud/commons/utils/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,41 @@ def get_available_themes(lang: str) -> list[dict[str, str]]:

return result

def get_theme_config(theme: str) -> dict:
"""
Returns the optional client configuration declared by a theme module (its
``config`` attribute), e.g. ``{"color_schemes": ["light", "dark"]}``. Themes
inherit their parent's configuration, child keys taking precedence.
"""
chain = []
try:
theme_module = import_module(f"wirecloud.themes.{theme}")
except ModuleNotFoundError:
raise NotFound(f"Theme not found: {theme}")

while theme_module is not None:
chain.append(theme_module)

parent = getattr(theme_module, "parent", None)
if parent is None:
theme_module = None
else:
try:
theme_module = import_module(f"wirecloud.themes.{parent}")
except ModuleNotFoundError:
theme_module = None

config = {}
for theme_module in reversed(chain):
module_config = getattr(theme_module, "config", None)
if module_config is None:
continue
if not isinstance(module_config, dict):
raise ValueError(f"Invalid theme: {theme}: config must be a dict")
config.update(module_config)

return config

def get_theme_static_path(theme: str, path: str) -> str:
from wirecloud.platform.core.plugins import WirecloudCorePlugin

Expand Down Expand Up @@ -171,14 +206,12 @@ def get_theme_static_path(theme: str, path: str) -> str:

found_path = None
while theme_module is not None:
if not os.path.exists(os.path.join(os.path.dirname(theme_module.__file__), 'static')):
theme_module = import_module(f"wirecloud.themes.{theme_module.parent}")
continue

if os.path.exists(os.path.join(os.path.dirname(theme_module.__file__), 'static', path)):
found_path = os.path.join(os.path.dirname(theme_module.__file__), 'static', path)
static_path = os.path.join(os.path.dirname(theme_module.__file__), 'static')
if os.path.exists(static_path) and os.path.exists(os.path.join(static_path, path)):
found_path = os.path.join(static_path, path)
break

# Root themes (parent = None) end the chain, even when they have no static directory
if theme_module.parent is None:
theme_module = None
else:
Expand Down
15 changes: 14 additions & 1 deletion src/wirecloud/platform/core/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
WirecloudPlugin, URLTemplate)
from wirecloud.platform.context.schemas import BaseContextKey, WorkspaceContextKey
from wirecloud.platform.preferences.routes import preferences_router
from wirecloud.platform.preferences.schemas import PreferenceKey, TabPreferenceKey
from wirecloud.platform.preferences.schemas import PreferenceKey, TabPreferenceKey, SelectEntry
from wirecloud.platform.urls import patterns
from wirecloud.platform.routes import get_current_theme, get_current_view
from wirecloud.commons.auth.schemas import UserAll, Session
Expand Down Expand Up @@ -356,6 +356,19 @@ def get_platform_preferences(self) -> list[PreferenceKey]:
type='text',
description=_('List of domains separated by commas that can be used to authenticate in external services. Use * to allow any domain (DANGEROUS, since that could expose it to non-authorised services).'),
defaultValue='*'
),
PreferenceKey(
name='color-scheme',
label=_('Color scheme'),
type='select',
description=_('Color scheme used by themes that support more than one. "System" follows the setting of your operating system.'),
initialEntries=[
SelectEntry(value='system', label=_('System')),
SelectEntry(value='light', label=_('Light')),
SelectEntry(value='dark', label=_('Dark')),
],
defaultValue='system',
requiredThemeConfig='color_schemes'
)
]

Expand Down
Loading
Loading