Design Loop¶
Use this page for bringing the web UI of a role into the corporate design and for the recurring design maintenance of all UI roles. For the deploy mechanics see the Compose Loop, for spec iteration the Playwright Spec Loop. For tokens, the cascade layer and the role stylesheet contract see style.css.
When to use¶
A role with a web UI has no design pass yet, or its app version or the shared design base moved since its last pass.
A recurring agent maintains the design of every UI role.
The queue¶
make design-queueprints the roles that are due, in the order you MUST work them:new: roles without a committedfiles/playwright/test-design.js, newest role first.stale: roles whose image version moved since their last design pass, largest version gap first, followed by roles for which only the shared design base changed.Ties: the most recently changed role first.
reviewroles carry an uncommitted design spec. They wait for the operator and MUST NOT be picked again unless the operator rejected their gallery.currentroles need no work.You MUST run exactly one compose stack at a time. One role is in progress at a time unless the operator ordered parallel passes.
Cadence¶
The loop iterates every 30 minutes. On each tick: read the queue, continue the role in progress, or start the first due role.
A long-running deploy, gate or gallery run wakes the loop on completion. The 30 minute tick is the fallback.
When nothing is due and no gallery awaits an answer, report the status matrix (approved, in review, rejected, due) and wait for the next tick.
A role pass has a wall-clock budget: 80 minutes when the injected role stylesheet carries the palette alone, 120 with an in-house carrier or any provisioning change, 180 for a role with several separate surfaces. At the budget, stop polishing, close the pass and list what is left.
While the role in progress occupies the stack, a second agent MAY prepare the next due role: it reads the upstream sources of that app and drafts outside the repository. It MUST NOT touch the working tree or the stack.
Parallel passes¶
On the operator’s order up to four role passes run at the same time on the one stack, each by its own agent.
One baseline deploy carries the whole batch:
apps=<role>,<role>,...with the variant and the disable list of a single pass. A role’s own service key is taken out of the list and restored throughINFINITO_INVENTORY_EXTRA_VARS. Pass agents run no full deploy.Every command that touches the stack, the test suite or a lint target runs as
flock /tmp/design-pass.lock <command>, one command per lock. Editing files and reading sources needs no lock.Every locked command carries its own cap,
flock /tmp/design-pass.lock timeout <seconds> <command>: 300 for adiag:run or single gallery views, 900 for a spec run, a full gallery or the palette check withsync=design, 2700 for the palette check withsync=role, 1500 for a role sync, 600 formake testor a lint target. A run that hits its cap is measured with adiag:run at one viewport and not repeated with a longer cap.A ready wait in a gallery
prepareor a page helper is at most 10 seconds and has passed in adiag:run at one viewport before a run captures more than one view.Wait for the lock with
timeout 570 flock /tmp/design-pass.lock true: exit 124 means it is still held.An image pull or any other download runs without the lock and under
timeout.An agent edits only
roles/<its role>/. A test or lint finding in another role’s in-flight files belongs to that role’s agent: name the path in the report and do not fix it.A role stylesheet with a broken
url()fails the stack-wide unithlth-cspfor every role: read the unit’s journal before blaming your own change for a red role sync.The orchestrator verifies each pass as it reports, then runs
make quality-highonce over the batch while every agent holds still.While a batch runs, further agents prepare the next batch as described under “Cadence”.
Gates¶
A role pass runs
make testafter its last edit, then the lint target of every file type it touched:make lint-javascriptandmake lint-playwrightfor a spec,make lint-cssfor a stylesheet,make lint-markdownfor aTODO.mdor README,make lint-ansiblefor a task, handler orvars/file, and the lint target of the language of every script it ships. Mark new files withgit add -Nfirst. On a red run fix every finding, rerun the failed target, thenmake testonce more.make quality-highruns once per two or three role passes, at every merge of the main branch and before a role is offered for commit. No role pass runs it. On a red gate attribute every finding to its role, fix it there, rerun the failed target, then run the full gate once more.Record the tree after every green gate:
GIT_INDEX_FILE=<scratch file> git read-tree HEAD, thengit add -Aandgit write-treewith the sameGIT_INDEX_FILE. A working tree that writes the same hash needs no new gate.The baseline deploy of a role runs on the tree the previous pass closed green.
The stack¶
Apps stay deployed between roles. Do not purge ahead of need.
A deployed UI role that is still due gets its pass before it is removed, also when it only runs as a provider for another role. Schedule that pass directly after the role that needed the provider.
Purge only when the next deploy does not fit: compare
free -ganddf -hwith themem_limitandmin_storagethe next role declares in itsmeta/services.yml.When it does not fit, remove the fewest finished roles that close the gap with
make compose-entity-purge apps=<role>and report the measured numbers.A finished role that publishes a host port the next deploy binds does not fit either. The mail role holds port 25, which the local mail relay of every deploy with
emaildisabled starts on: remove it directly after its pass, and check withmake compose-exec cmd="ss -ltn"that the port is free before the next deploy.
One role pass¶
Read
roles/<role>/AGENTS.mdwhen present. Role changes stay underroles/<role>/. A defect of the shared base goes into its own base commit.Deploy against the running stack, on the tree the previous pass closed green (see Gates), with
INFINITO_PLAYWRIGHT_KEEP=true make compose-deploy apps=<role> variant=0 disable=<every disableable service except design>. Keep the defaultinitializemode, a single pass, nofull_cycle, no teardown between roles. Start this deploy as soon as the previous pass reported, the stack serves the default palette and the containers of that role are healthy, and verify the previous pass from its logs and screenshots while the deploy runs. A pass runs this one full deploy only.Find the carriers of the app’s design in the live stack before you write CSS. List its in-house options first: theming configuration (config, CLI, API or admin setting), theme files or a custom CSS setting, logo and title settings. Then fetch the app’s stylesheets through
make compose-exec cmd="curl -sk <url>"and read its real variable names.Apply the palette through the app’s own configuration. An in-house option MUST be used before the injected role stylesheet, in this order:
Theming configuration (config, CLI, API or admin setting): feed the palette values server side, as web-app-nextcloud does with
occ theming:config. Ship no stylesheet.In-house theme or custom CSS option (theme file, custom CSS setting): ship the mapping of the app’s variables onto the tokens through that option, as web-app-gitea does with themes generated from Gitea’s built-in ones.
Injected role stylesheet, only for what no in-house option reaches:
CSS variable design system: map the app’s variables onto the tokens in
templates/style.css.j2.Bootstrap app: set
bootstrap: truein the role’sdesign:entry to link the shared component mapping. Replace the app’s hard-coded colors in the role stylesheet, as web-app-dashboard does.No design of its own: the shared element defaults apply. The role stylesheet only covers what stays hard-coded.
Set logo and title through the app’s own mechanism from
lookup('design', application_id). Declare the slot sizes the app renders in the role’sdesign:entry: measure the box the app gives its logo in every place (expanded and collapsed navigation, sign-in page) and size one slot per shape. Where the app shows a symbol plus a wordmark, declare a wide slot, so the generated lockup carries the title next to the logo. A lone symbol in a wide box is a defect. A slot the app shows on brand-colored chrome (a header, sidebar or sign-in background mapped onto--design-frame) getsframe: true: its text is then rendered in the frame text color. A slot on a page or panel stays without it.An app with its own theme switch ships
templates/design.jsthat mirrors the chosen theme intodata-design-themeon<html>. It reads branding through the injectedINFINITO_DESIGNobject and branches on it at runtime; only a script that needs Jinja stays a.j2.Iterate without a further full deploy:
a changed stylesheet or logo:
make design-sync app=<role> variant=0,a changed
design.js, task, template ormeta/file:make compose-role-sync role=<role> variant=0, which re-runs only the app role against the running stack,a changed spec or
playwright.env.j2: addpw="--grep design: --grep-invert gallery"to that call. It stages the spec again, renders its.envagain and runs the design assertions.a changed spec alone:
make compose-playwright role=<role> pw="--grep design: --grep-invert gallery". It reuses the rendered.envand also works for a deployed role that a later deploy dropped from the inventory.
Write
files/playwright/test-design.jsand register it inplaywright.spec.js:one test that calls
assertDesignTokens,assertTokenon a core surface, the primary action and the body text (the tokens, or the values the app’s theming mechanism received), and assertions that logo and title are the configured ones,assertLightAndDarkon a core surface andassertReadableon the core text. Every role MUST support light and dark mode, whatever carries its palette,every open question about the role as an assertion, proven once by a negative control on the running stack. Axes the stack cannot run (SSO with LDAP, Tor, Swarm, other variants) are left to CI,
every failure found on the running stack and fixed as an assertion that fails on the state before the fix,
one gallery test with at least 20 views of the frontend and, where the app has one, the backend. Prefer settings, administration, forms, lists and detail pages. Log in where the app has accounts and seed showcase content idempotently where a list would be empty.
an account for the signed-in interface. When the role provisions none, add an Ansible task to the role that creates the platform administrator through the app’s own CLI or API on every deploy, idempotent and with no password in
argv. Use OIDC instead where the app supports it natively, and run the design pass of the identity provider in the same go. A gallery of visitor pages alone is not a finished pass.
Look at views while you iterate with
make design-gallery app=<role> views=<view>[,<view>...]: it runs the gallery test alone and captures only the named views. Such a run adds its screenshots to the existing ones, so it also recaptures a single view after the full run. Capture the full gallery withmake design-gallery app=<role>once, on the final design, and look at every screenshot in both modes and both viewports. The gallery holds the designed state only.before=trueadds each view with the injected snippets stripped, for a role the operator asks a comparison for; a view whose state only exists with the injected snippets, such as a menu the role’s own script opens, carriesafterOnly: trueand is skipped on that side.Check the palette with one further base color, blue
#001f3f. The design spec MUST stay green with it. No gallery run.make design-palette app=<role> variant=0 base='#001f3f' sync=designrenders the shared CSS, the role stylesheet and the logos with that base, runs the design assertions and puts the base of the inventory back, also when an assertion fails.A role that bakes palette values at deploy time (theming configuration, theme file, mounted asset) passes
sync=role, which re-runs the app role for both renders.The spec asserts the text of every primary-filled control against
--design-on-primary, which is dark on a light base and light on a dark one.A red assertion names a hard-coded color or a value baked at another time than the tokens: fix it and repeat.
The target needs an inventory that a full deploy provisioned from a development inventory vars file (
INFINITO_INVENTORY_VARS_FILE) that definesDESIGN_BASE_COLOR.
Run
make testand the lint targets of the file types you touched to green (see Gates).Close the pass on the final state:
run
make compose-role-sync role=<role> variant=0, then the complete role spec withmake compose-playwright role=<role> pw="--grep-invert gallery",a pass that changed provisioning (a task, a handler,
vars/, a template or file the role renders or mounts into a container,meta/outside thedesign:entry) runs the role sync twice in a row before that spec. In the second run every provisioning task the pass added MUST report no change.
In the step that verifies the pass, publish the gallery as one private Artifact for the role, link it under “Further Resources” in the role README, add the role to the overview Artifact and report the link. Do not wait for the answer; take the next role.
Commit only when the operator asks for it, one bundle per role (
roles/<role>/). On rejection rework from the comment, run the gate again and republish to the same Artifact.
Rules for the stylesheet¶
Colors come from
--design-*tokens only.Text and its background MUST come from the same source. A token color on an app-owned background, or the reverse, voids the contrast guarantee.
Keep the app’s contrast relations: frame against content, panel against page, selected against unselected. Flattening them is a regression.
Hovered, pressed, open and selected controls use
--design-surface-hover,--design-surface-activeand--design-<fill>-hover,--design-<fill>-active.No gradients, no filter effects for state, no hard-coded hex values.
Delete rules whose selectors match nothing in the deployed app.
Every
url()that is neitherdata:nor adesign.urlsvalue starts with the origin of the app, e.g.url("{{ XWIKI_URL }}/resources/icons/silk/user.png"). The stylesheet is served from the CDN, so a root-relative or bare target resolves there. A rule that only repeats an image of the app is deleted instead.
Review checklist for every screenshot¶
Text, icons and control borders are readable against their background in light and in dark mode.
Hover, focus, pressed, open and selected states are visible and readable.
The view shows the state it is named after. A view that equals the start page is dead and MUST be replaced.
No image is broken, no menu is clipped, nothing is caught in the middle of an animation.
Traps¶
Measure, do not guess. Add a temporary
diag:test to the role spec, run it withmake compose-exec cmd="bash scripts/tests/e2e/rerun-spec.sh <role> --grep diag:", read the computed styles or console errors, then delete the test.Animated apps.
captureDesignGallerywaits up to 5 seconds for finite animations before each screenshot. An app that animates longer, or that swaps content after the animation, needs its own wait in the view’sprepare.Framework state colors. Frameworks hard-code hover, active and disabled colors per button variant. Map every state variable, not only the resting one.
Legacy stylesheets. A role stylesheet inherited from before the overhaul may assign variables the app never reads. Verify every mapped name against the app’s real CSS.
getComputedStyleand visited links. It reports the unvisited color. A link that looks different in a screenshot is not proven different by the computed value.design-syncand scripts. It renders static files only. An injected script keeps its deployed content because its CSP hash lives in the vhost.compose-role-syncrenders both.Login URLs under SSO. The variant that enables
designusually enablesssotoo, and many apps then redirect their login URL to the identity provider. Sign in through the role’s login helper and assert on a page the app renders itself.Parallel spec runs. Two spec runs of one role share a reports directory and fail with
ENOENTunder/reports/test-results. Run one at a time.Overlays caught mid-animation.
toBeVisiblepasses on the first frame of an opening animation, and the gallery helper does not see animations inside shadow roots. A dialog, drawer, menu or sheet view waits in itsprepareuntil the panel runs no animation and its opacity is 1. In the screenshots a panel the page shows through, or content that sits inset and half transparent, was captured too early. Check every such view at the mobile viewport in light and dark.Untracked files and the gate. Lints that enumerate through
git ls-filesskip a new file untilgit add -N <file>marks it.Plain side of the gallery. With
before=true, a view whose “before” side still carries the injected snippets fails with that message. Give the view the URL its document ends on, and reach a page behind a redirect through its URL instead of a click insideprepare.Negative control without a sync. For a stylesheet fix, strip the fixed rule from the response with
page.route()in a temporary copy of the test, watch the assertion fail, then delete the route. The stack stays untouched.A deploy that hangs in
Apply stateforhlth-csp. The unit checks every domain on the stack, so the blocked resource can belong to a role of an earlier pass. Read it withmake compose-exec cmd="journalctl -u <unit> --no-pager -n 40 -o cat", fix that role, and add the test “every image the role stylesheet references is served” of the XWiki design tests to its spec.Apps that walk
document.styleSheets. The injected sheets are linked withoutcrossorigin, so app code can neither read nor rewrite their rules, and an app that readscssRulesunguarded throws on them. Keep the links that way: with readable sheets LibreTranslate rewrites theprefers-color-schemerule of the shared sheet at load and the tokens stop following the system. Shield such an app in the role’s design script, asroles/web-svc-libretranslate/templates/design.jsdoes, and pin it with the test “the app’s theme switch raises no page error and leaves the injected sheets alone”.Gallery links. An Artifact whose sharing the operator changed can stop accepting updates from the session. Publish a new one, tell the operator that the old link stays on the old state, and update the README link.