Ontology CSS
See also: Ontology authoring · Overriding shared ontology nodes · CSS / LESS architecture · Themes · dd_object
An ontology node can carry its own CSS: a small JSON object in the node's
CSS field (ontology16, stored as properties.css). Dédalo scopes every rule
to that one element and writes it into the page at runtime. This page explains,
for ontology authors first and developers second, what you can write, which
selector each entry becomes, and how to check the result in the browser.
When to use it
Use ontology CSS for layout decisions that belong to one node: how many grid columns a field spans in its edit form, how a portal lays out its rows, the height of one field's editor, a background colour that groups a few fields. These are part of the definition of a section, travel with the ontology, and reach every installation that uses it.
Do not use it for anything that belongs to a component type (every
component_input_text, every portal). Styling shared by all instances of a
type lives in the LESS sources of the code base and is built into main.css —
see CSS / LESS architecture. As a rule: if the same
rule would be copied into many nodes, it belongs in the LESS.
The shape
properties.css is an object. Each key is a selector fragment; each
value is an object of CSS declarations, written as in a stylesheet but in JSON:
{
".wrapper_component": { "grid-column": "span 4", "background-color": "#fefed5" },
".content_data": { "min-height": "40vh" }
}
- A declaration value is a string (
"span 4") or a number ("order": 2becomesorder:2). Booleans, arrays andnullare ignored. contentvalues are always wrapped in single quotes:"content": "A"becomescontent:'A'. A value such asnoneorcounter(x)therefore cannot be written forcontent.- A value may carry
!important("width": "10px !important"); it is passed through as written. - A declaration value can also be a nested block — a media query, a hover state — see Nested blocks.
You never write the element's own class names: the engine adds them — see How a fragment is scoped.
Your first rule
- Open the node in the Ontology area, for example rsc85 (Name overwrite).
- Write the JSON in its CSS field (
ontology16):{ ".wrapper_component": { "grid-column": "span 4", "background-color": "#fefed5" } } - Save the record.
- Run Process (
tools/tool_ontology) on the record. Saving changes only the editable record; the runtime keeps serving the old compileddd_ontologyrow until the record is processed or its TLD regenerated — see How changes apply live. - Reload a People record and inspect the field. The engine has inserted:
.rsc197_rsc85.rsc85.edit.wrapper_component { grid-column:span 4; background-color:#fefed5 }
plus its dark-theme counterpart. The rest of this page
explains where .rsc197_rsc85.rsc85.edit comes from.
How a fragment is scoped
Every element on the page carries a scope class built from the section it is
shown in and its own tipo: <section_tipo>_<tipo>. For example, the field
rsc85 (Name overwrite) in a record of
the People section rsc197 has the
class rsc197_rsc85, next to rsc85 and the mode, edit.
section_tipo is the section of the record, not the node's parent
rsc85 belongs to section rsc75
(inside its group rsc76), but
People records are stored in the virtual section rsc197, so the scope
is rsc197_rsc85. The same component shown in another (virtual) section gets
another scope class. This is what keeps one section's CSS from touching
another's.
The engine joins a scope key (the classes, one after another) with each fragment, in one of two ways:
| Your fragment starts with… | Rule | Result for scope key K |
|---|---|---|
.wrapper (.wrapper_component, .wrapper_grouper, .wrapper_section, .wrapper_area) |
compound: glued to the scope key — the element's own wrapper | .K.wrapper_component |
> (> .content_data, >.list_body) |
appended after one space | .K >.list_body |
anything else (.content_data, .list_body) |
direct child of the wrapper | .K > .content_data |
Whatever follows the first part is kept as written, so
.wrapper_component .input_value becomes .K.wrapper_component .input_value —
any descendant of the wrapper.
A fragment that is a comma list is split, and every branch is scoped on its
own: .content_data, .label becomes .K > .content_data, .K > .label. Commas
inside brackets or quotes (:is(.a, .b)) do not split.
Scope key per element
Which classes make up K depends on the element. The table below is reference
material: it is exactly what each renderer passes to the engine.
| Element | Scope key K |
Notes |
|---|---|---|
| component, edit | <section_tipo>_<tipo>.<tipo>.edit |
|
| component, list cell | <section_tipo>_<tipo>.<tipo>.list |
only when the server sends list css (see Modes); never for component_filter cells nor for Time Machine (dd15) cells |
| component, search | — | not applied by the search wrapper; css.wrapper / css.label are read there only as extra class-name lists. Exception: component_security_access, whose search view reuses the edit wrapper, gets it with <section_tipo>_<tipo>.<tipo>.search |
| section, edit | <section_tipo>_<section_tipo>.edit |
also reads add_class |
| section, list | <section_tipo>_<section_tipo>.list |
the CSS comes from the section's section_list child; merged with the computed column grid, see Section lists |
section_group |
<section_tipo>_<tipo>.edit |
its wrapper class is wrapper_grouper |
section_tab / tab |
<section_tipo>_<tipo>.edit |
|
component_portal, edit |
<section_tipo>_<tipo>.<tipo>.edit |
like any component; the portal view also writes its own column grid, see Portals |
| area | <tipo>_<tipo>.<mode> |
an area's section_tipo is its own tipo; opened from the menu the mode is list; also reads add_class |
| button (in the section list toolbar) | — | no rules: a button reads only css.style, a class name, see Buttons |
Section lists
A section in list mode does not use the section node's CSS. It uses the CSS of
its section_list child (for example
rsc169, the list of the Audiovisual
section rsc167). The list view first
builds a rule for the column grid from the columns it shows, then adds the
child's entries over it:
{ ".list_body": { "grid-template-columns": "<computed from the columns>" } }
A .list_body key replaces the computed grid
The merge is by key. A section_list CSS with its own .list_body entry
replaces the whole computed object, grid-template-columns included —
repeat the grid in your entry, or the columns lose their widths.
Portals
A portal is a component, so its node CSS is scoped like any component
(.<section_tipo>_<tipo>.<tipo>.edit). Its view separately writes the row grid
from its columns under a shorter key, .<section_tipo>_<tipo>.edit.view_<view> >
.list_body (list: .<section_tipo>_<tipo>.list.view_<view>, not for the
mosaic view). The node rule has more classes, so a grid-template-columns you
write on .wrapper_component > .list_body wins over the computed one.
Buttons
Buttons do not go through the engine. A button node shown in the section list
toolbar may carry "css": {"style": "<class name>"}; that class is added to the
button. Example: the Add images button
tch136 (button_import, section
Catalogue tch7) has {"style": "import_files"}, so its button gets the class
import_files, styled in the LESS.
Modes
The server decides which CSS an element receives in each mode, before the client sees it.
- Edit, and every mode other than list (search, Time Machine…): the node's own CSS.
- List: a component's edit CSS is not sent — a grid placement written
for the edit form makes no sense in a list cell. Sections and portals in list
mode take the CSS of their
section_listchild instead (no child: none). Areas are the exception: an area has a single render, so its CSS is sent in every mode. - Reserved keys
listandsearch. A CSS object may carry alistand/or asearchkey holding a separate fragment map for that mode. In list modecss.listis sent even where the edit CSS is stripped; in search modecss.searchreplaces the base set. The reserved keys never reach another mode, and they are reserved words — you cannot uselistorsearchas a selector fragment.
{
".wrapper_component": { "grid-column": "span 4" },
"list": { ".wrapper_component": { "font-weight": "600" } }
}
No live node uses these keys on this install; shape only. Note that the
search wrapper does not inject ontology CSS, so css.search has a visible
effect only on components whose search view reuses the edit wrapper
(component_security_access).
- Section override. A section node can set the CSS of one of its
components: in the section's properties.css, a key that is the component's
tipo holds that component's whole CSS map. It replaces the component's own
CSS (no merge), in every mode, and is not stripped in list mode. No live
section uses it on this install; shape only:
{ "rsc85": { ".wrapper_component": { "grid-column": "span 2" } } }
(written on the section node — here it would be rsc197's CSS field).
A local override of a shared node replaces the CSS as a whole, too — see Overriding shared ontology nodes.
Nested blocks: media and hover
Inside a declarations object, a key whose value is an object opens a nested block. The engine writes it with native CSS nesting, inside the same rule:
| Nested key | Written as | Meaning |
|---|---|---|
@media …, @supports … |
as written | the declarations apply under that condition |
:hover, :focus-within, ::before (starts with :) |
&:hover |
the element itself in that state |
anything containing & (&.active, .dark_panel &) |
as written | your own nesting selector |
any other selector (.dark_panel) |
.dark_panel & |
the element when it is inside .dark_panel |
Unlike LESS, a nested plain selector is an ancestor
In LESS a nested .x is a descendant; here it means .x &. Write the &
yourself for anything else:
- descendant:
{ ".wrapper_component": { "& .input_value": { "color": "#333" } } } - same element with another class:
{ ".wrapper_component": { "&.active": { "color": "#333" } } }
{ ".wrapper_component": { "grid-column": "span 5",
"@media screen and (max-width: 1024px)": { "grid-column": "span 10" } } }
Put the media query inside a fragment, as above. A media query as a
top-level key ("@media …": { ".wrapper_component": {…} }) is scoped like a
selector, produces an invalid rule and is dropped with a console warning.
add_class
add_class is not CSS: it adds class names to nodes, so you can reuse a style
that already exists in the LESS. It is read by sections in edit mode and by
areas, and two targets are supported, wrapper and content_data:
{ "add_class": { "wrapper": ["bg_warning"], "content_data": ["my_class"] } }
Areas also accept a single string ("wrapper": "bg_warning"). On a section a
string is split into single letters (b, g, _…), so always use an array.
Other elements ignore add_class. No live node uses it on this install.
Dark theme
Colours in ontology CSS are written against the light palette, so the engine
adds a dark-theme counterpart for every literal colour it finds in
background, background-color, border*-color, outline-color, color,
fill and stroke. The counterpart keeps the hue and moves the lightness to
the dark band, using CSS relative colours:
| Property kind | Dark value |
|---|---|
surface (background, background-color) |
hsl(from <colour> h calc(s * 0.42) 18%) |
line (border-*-color, outline-color) |
hsl(from <colour> h calc(s * 0.42) 34%) |
ink (color, fill, stroke) |
hsl(from <colour> h calc(s * 0.55) 78%) |
It is a separate rule, :root[data-theme="dark"] <selector> { … } (for a comma
list, every branch gets the prefix), so switching theme needs no reload. Values that are already theme-aware (var(--…)),
keywords (transparent, inherit, currentColor), gradients, shorthands
(1px solid #ccc) and values with !important are left alone — use a
var(--…) token when you need full control in both themes (see
Themes).
What happens at runtime
flowchart LR
A["ontology16<br/>(node CSS field)"] --> B["dd_ontology<br/>properties.css"]
B --> C["server: pick CSS<br/>for this mode"]
C --> D["context.css<br/>(sent to the client)"]
D --> E["renderer calls<br/>set_element_css(K, css)"]
E --> F["queue, one per<br/>animation frame"]
F --> G["style#elements_style_sheet<br/>(one shared sheet)"]
In plain words:
- Server. After the record is processed (
tools/tool_ontology→ Process, or a TLD regenerate), its CSS field becomesproperties.cssindd_ontology. For each element it renders, the server picks the CSS for that mode (Modes) and sends it as the element'scontext.css—properties.cssitself is never sent. Once processed, an edit to a node's CSS is served from the next request on. - Client. The renderer of the element (the wrapper builder) calls
set_element_css(K, css)fromclient/dedalo/core/page/js/css.js, which turns each fragment into one rule (plus its dark counterpart). - One shared sheet. All rules live in a single
<style id="elements_style_sheet">added to the end of<head>. Writes are queued and applied once per animation frame, so drawing a whole form causes one update. - No duplicates. A rule is remembered by its selector. Drawing the same element again with the same CSS does nothing; with different CSS the old rule is removed and the new one added. Two elements sharing a media query keep their own blocks, because the media query lives inside each element's rule.
- Cleanup. After navigation, once more than 500 rules are held, the rules whose selector no longer matches anything on the page are removed. Rules of elements still on screen are never removed.
Because the rules come after main.css in the page, an ontology rule wins over
a LESS rule of equal specificity, and usually has more classes anyway.
Limitations and gotchas
Legacy v5 style and mixin blocks are ignored
Many nodes still hold the old shape
{".column_numisdata77": {"style": {"width": "200px"}}} (for example the
list numisdata122 of section
numisdata3, monedaiberica install), or "mixin": [".vertical"]. These
blocks never had any effect in this engine and are skipped: a style block
logs a console warning when debug is on, a mixin array is skipped silently. To make one work, rewrite it in the current
shape — and check it, because it will then change the layout.
>means direct child..column_numisdata77is scoped as.K > .column_numisdata77, but list columns sit several levels below the section wrapper. For deep nodes start the fragment with.wrapper_…and write a descendant path (.wrapper_section .column_rsc244 …), as in Example 4..wrap_component(v5) matches nothing. The v7 wrapper class is.wrapper_component(.wrapper_grouperfor groups).listandsearchare reserved top-level keys (Modes).- List mode drops component CSS. If your rule shows in the edit form but
not in the list, that is intended; use
css.listor the section'ssection_listchild. - Search forms get no ontology CSS, except components whose search view
reuses the edit wrapper (
component_security_access). - The CSS field is replaced whole by a local override or a section override — copy what you want to keep.
- One rule per element and mode, not per instance. Two lists of the same section on one page share the scope key, so the last one drawn sets the column grid for both.
How to debug
- Inspect the element. In DevTools select the field and look at its
classes:
wrapper_component component_input_text rsc85 rsc197_rsc85 edit view_default. Those are the classes your scope key must match. - Find the rule. In the Elements panel open
<head> > style#elements_style_sheet; the Styles pane of the selected element lists the matching rules from it. Or list them all in the console:
[...window.elements_style_sheet.cssRules].map(rule => rule.cssText)
The browser re-serializes the text (>.list_body shows as > .list_body,
color:white as color: white;), but the rule is the same.
3. No rule at all? Check that the server sent it: the element's
context.css in the API response (in list mode it is null for a plain
component). With debug on, the response debug panel also lists
the engine's registry (elements_css_object, the get_inserted_rules() map:
selector → {rule_text}).
4. Rule present but no effect? It is a selector that matches nothing (a
> that is not a direct child, a v5 class) or a more specific LESS rule wins
— the Styles pane shows which.
Worked examples
The tipos below are real nodes; the CSS is copied from dd_ontology of the
monedaiberica install (numisdata, dd, rsc, ontology TLDs). The output
is the rule text the engine inserts.
Example 1: a field's width and colour
rsc85 (component_input_text,
Name overwrite), shown in a People record (section rsc197), edit mode:
{ ".wrapper_component": { "grid-column": "span 4", "background-color": "#fefed5" } }
.rsc197_rsc85.rsc85.edit.wrapper_component { grid-column:span 4; background-color:#fefed5 }
:root[data-theme="dark"] .rsc197_rsc85.rsc85.edit.wrapper_component { background-color:hsl(from #fefed5 h calc(s * 0.42) 18%) }
In list mode the server sends no CSS for rsc85, so the list cell is unstyled.
Example 2: a responsive field with a nested media query
ontology16 (component_json, the
CSS field of the ontology editor itself). Editing a node of the dd TLD, the
record is in section dd0:
{
".wrapper_component": {
"grid-column": "span 5",
"@media screen and (max-width: 1024px)": { "grid-column": "span 10" }
},
".wrapper_component >.content_data": { "max-height": "40rem", "min-height": "40vh" },
".wrapper_component >.content_data .jse-menu": { "background-color": "#826b85" }
}
.dd0_ontology16.ontology16.edit.wrapper_component { grid-column:span 5; @media screen and (max-width: 1024px) { grid-column:span 10 } }
.dd0_ontology16.ontology16.edit.wrapper_component >.content_data { max-height:40rem; min-height:40vh }
.dd0_ontology16.ontology16.edit.wrapper_component >.content_data .jse-menu { background-color:#826b85 }
:root[data-theme="dark"] .dd0_ontology16.ontology16.edit.wrapper_component >.content_data .jse-menu { background-color:hsl(from #826b85 h calc(s * 0.42) 18%) }
Its siblings ontology17–ontology19 carry the same media query on the same
form; each keeps its own block.
Example 3: a portal's row grid
dd1504 (component_portal, Images) in a
Projects record (section dd153), edit
mode:
{
".wrapper_component": { "grid-column": "span 4" },
".wrapper_component > .list_body": {
"grid-template-columns": "1fr 1fr 1fr 1fr 1fr",
"@media screen and (max-width: 1024px)": { "width": "100%", "display": "contents" }
},
".wrapper_component >.list_body >.content_data .section_record .component_image >.content_data": { "height": "12rem" }
}
.dd153_dd1504.dd1504.edit.wrapper_component { grid-column:span 4 }
.dd153_dd1504.dd1504.edit.wrapper_component > .list_body { grid-template-columns:1fr 1fr 1fr 1fr 1fr; @media screen and (max-width: 1024px) { width:100%; display:contents } }
.dd153_dd1504.dd1504.edit.wrapper_component >.list_body >.content_data .section_record .component_image >.content_data { height:12rem }
The portal view also writes .dd153_dd1504.edit.view_<view> > .list_body {
grid-template-columns:… } from its columns; the node rule above has more
classes and wins.
Example 4: a section list column
The Audiovisual section rsc167 in list
mode takes the CSS of its section_list child
rsc169, which styles the tags inside the
column of the widget rsc244
(component_info):
{ ".wrapper_section .column_rsc244 .wrapper_widget .widget .values_container .tags": { "display": "grid", "grid-template-columns": "1fr 1fr" } }
.rsc167_rsc167.list > .list_body { grid-template-columns:<computed from the columns> }
.rsc167_rsc167.list.wrapper_section .column_rsc244 .wrapper_widget .widget .values_container .tags { display:grid; grid-template-columns:1fr 1fr }
The fragment starts with .wrapper_section, so it is glued to the section's own
wrapper and the rest is a descendant path — the way to reach a cell deep in the
list.
Example 5: a group's inner grid
rsc334 (section_group,
Identification) of a Bibliographical reference record (section rsc332,
virtual of rsc333):
{ ".content_data": { "grid-template-columns": "repeat(10, 1fr)" } }
.rsc332_rsc334.edit > .content_data { grid-template-columns:repeat(10, 1fr) }
Example 6: an area
The area dd355 (Cultural), opened from
the menu (mode list):
{ ".wrapper_area": { "color": "white", "background-color": "#4fb492" } }
.dd355_dd355.list.wrapper_area { color:white; background-color:#4fb492 }
:root[data-theme="dark"] .dd355_dd355.list.wrapper_area { color:hsl(from white h calc(s * 0.55) 78%); background-color:hsl(from #4fb492 h calc(s * 0.42) 18%) }
For developers
- Engine:
client/dedalo/core/page/js/css.js—set_element_css(key, value),prune_rules,prune_orphan_rules,get_inserted_rules,get_elements_style_sheet. A value that is a function (used internally for the phone-card column labels,#col_<id>::before { content:'<label>' }) returns its own{selector, value}. - Rules are tracked by the
CSSRuleobject, not by selector text, so replace and prune work even when the browser re-serializes a selector (:before→::before). - Server: the mode decision is
resolveEmittedPropertiesAndCssinsrc/core/resolve/structure_context.ts. - Tests:
client/dedalo/test/client/js/test_css.js(browser, the engine) andtest/unit/structure_context_css.test.ts(server, the mode table).