Skills
Xcelerator Desktop Ui
Skill
xcelerator-desktop-ui. Build a PySide6 desktop app that looks and behaves like Active Workspace, using Teamcenter's real palette rather than an approximation of it. Covers capturing the six AW themes out of a live web tier, the shell structure that makes an app read as a Teamcenter sibling, and a catalogue of ~30 Qt failures that are silent by construction: the mechanism runs, nothing is drawn, and no exception is raised. Use for any Qt/PySide6 tool that sits beside Teamcenter, for "my stylesheet does nothing" investigations, and before writing any test that claims a widget renders correctly.
Two shipped apps use this: CameoTcConfig (the Cameo Switcher) and
TcSubscriptions. Both sit on the same screen as Active Workspace all day.
The premise. Approximate agreement with Teamcenter is worse than none. A petrol that is nearly AW's reads as a rendering fault; one that is exactly AW's reads as a family resemblance. So the palette is captured, not chosen, and the structure is copied too, because that is the part a colour swap cannot fake.
The working code is the reference implementation. Copy these two files rather than re-deriving them:
CameoTcConfig/cameocfg/awtheme.py tokens, palette, QSS, generated assets
CameoTcConfig/cameocfg/awshell.py band, rail, detail, grid, status marks
CameoTcConfig/tools/aw_tokens.json the raw capture, for diffing a new release
CameoTcConfig/DESIGN.md the prose version of all of this
They are byte-identical in both apps. That duplication is deliberate for now (PyInstaller makes a cross-repo import awkward) and is the first thing to fix if a third consumer appears.
1. The one rule
An assertion that a mechanism was called is not a test.
Every failure in section 5 has the same shape: the call succeeds, returns normally, raises nothing, and draws the wrong thing or nothing at all. Reading back the value you just set confirms only that you set it.
Three ways to get a confident wrong answer about painted output, all hit for real:
- Read the font back. Reports what you authored, not what paints.
QFontMetrics. Ignores capitalization entirely, and reports 16px where the rendered block is 17. A width comparison built on it is noise.bytes(img.constBits()). A view into a buffer that can be freed before the comparison runs, so the same assertion flips between runs.
What works: compare QImage == QImage (content-based) while holding both,
or count ink in a region, or measure geometry after processEvents().
Grab from MAPPED windows. QT_QPA_PLATFORM=offscreen reports zero font
families on this machine, so every glyph is tofu and the screenshot proves
nothing about type. grab() on an unmapped top level renders children at stale
geometry, with no margins and content clipped on the right.
2. Capturing the palette
Active Workspace ships all six themes in its CSS bundle. Reproducible:
curl http://<web-tier>/ # find the runtime chunk map
curl .../static/js/runtime~main.*.js # miniCssF -> the CSS chunk ids
curl .../static/css/<id>.<hash>.chunk.css # ~45 chunks, ~5.7 MB
Every rule is scoped .ui-<theme>, so parsing on that prefix yields all six at
once: lightTheme, lightSiemensTheme, hybridSiemensTheme,
darkSiemensTheme, lightHorizonTheme, darkHorizonTheme.
The content surface is .sw-main; the dark identity band is
.afx-accent-background; the working surface is .aw-layout-subLocation. Rules
outside .sw-main style the chrome, which is a different surface and a
different set of values. Pull both.
The product mark is not in the CSS. It is GET /favicon.svg:
#00C1B6 body, #005058 base band at 11/32 height, #000028 glyph, rx 1.5/32.
Note #00C1B6, not the #00CCCC anyone would guess from the Siemens palette.
Do not trust a screenshot for any of this. Sampling a PNG of the header gave a plausible and wrong body colour; the favicon gave the real one.
Two things AW does not give you
- Status is colour-only in AW. A green button is green and nothing else. A
tool reporting machine health needs three channels: shape, then word,
then colour. Delete the colour and the meaning must still arrive. This matters
more than it sounds: AW's light theme puts
okat 5.96:1 andfailat 5.19:1, 0.77 apart, so hue alone cannot separate them under deuteranopia. - AW has no environment identity colour, because it shows one site per tab. A multi-environment tool needs a family disjoint from every alarm hue, plus a silhouette per slot.
One place AW is wrong, and you must not copy it
darkHorizonTheme paints the light theme's status inks on its own #003750
panel: 2.12:1, 2.44:1, 2.43:1. All three fail even the 3:1 floor for a
non-text component. Lighten them and assert the ratio.
3. Colour arithmetic that bites
Eight-digit hex means opposite things. CSS #RRGGBBAA, Qt #AARRGGBB.
AW's #00000026 is black at 15%; handed straight to Qt it paints a nearly
opaque navy. Flatten every alpha against the surface it actually sits on,
at theme-build time. Assert no eight-digit hex survives into the stylesheet.
Lighten in HSV, not toward white. When a status colour is too dim on the
band, blending toward white turns #dc0000 into #f09191: legible, and pink.
A washed pink does not say "down". Raise HSV value first, keeping saturation,
and only give up saturation if full brightness still is not enough.
Never substitute the background's ink for an illegible status colour. That turned "unreachable" from red into plain white: perfectly legible, and saying nothing. Losing the channel is worse than dimming it.
4. The shell
Colour parity alone does not make a sibling. AW's structure is what a user recognises:
+-----------------------------------------------------------+
| MARK PRODUCT | IDENTITY health actions Setup | global band
+===========================================================+ 3px accent rule
| Sub-location title |
| Tab Tab Tab | underline tabs
| ---------------------------------------------------------|
| working surface (rail + detail, or a grid) |
+-----------------------------------------------------------+
| status line version |
+-----------------------------------------------------------+
- The band answers "where am I, and does it work" before anything is read. It is the single highest-value element in a multi-environment tool. Fixed height; everything inside elides rather than wraps, or a long server message relayouts the whole window.
- Setup is a location, not a dialog. A modal is something you must dismiss before looking at anything else, which is backwards for settings you check against what the main surface says. Slide the working surface out, slide a settings rail in, turn the Setup button into Done.
- Master/detail, not stacked panels. A panel per item stacks summary and evidence vertically, so eleven items become a thousand-pixel scroll. Splitting the axis puts every item in view and gives the selected one the other half. Used for both health checks and environments.
- A section is a heading and space, never a group box.
- Spacing 4 / 8 / 16 / 32, each step doubling. Different steps for different relationships: XS binds a label to its control, SM separates sibling rows, MD introduces a heading's content, LG separates sections.
- Square. AW rounds almost nothing; only popups and chips round.
Type
AW is html{font-size:75%; line-height:1.32} on a 1.2 modular scale. Use
points, not pixels, so Windows text scaling applies, and anchor at 11pt rather
than AW's 9pt equivalent:
| role | pt | weight | use |
|---|---|---|---|
| display | 19.0 | Light | empty-state headline |
| title | 15.8 | Normal | ONE per surface: the page title |
| subtitle | 13.2 | DemiBold | tabs, navigation rail |
| body | 11.0 | Normal | |
| strong | 11.0 | DemiBold | section headings on dense surfaces |
| label | 10.5 | DemiBold, uppercase, +8% tracking | column headers, chips |
| caption | 10.5 | Normal | text accompanying something already legible |
| mono | 10.5 | Normal | paths, property names, raw values |
Nothing below 10.5pt. These get demoed on projectors.
Two hierarchy failures worth naming, both shipped:
- A section heading at the same size as the page title. "Setup" and "What Cameo may do" were both 15.8pt and nothing said which contained which.
- Measure, not size. Prose running ~95 characters reads as oversized however correct the type is, because the eye loses the line return. Cap at 65-75ch (about 560px at 11pt). Controls stay full width; a short input floating in a wide pane looks broken.
5. The trap catalogue
Every one of these was hit, measured, and fixed in a shipped app. They are grouped by what they break.
5.1 Styled sub-controls draw nothing (four instances)
The pattern: styling a widget at all puts Qt on the stylesheet path for its sub-controls. With no rule for the sub-control, Qt draws a degenerate glyph or nothing at all.
| widget | symptom | fix |
|---|---|---|
QCheckBox::indicator |
tick, no box (unstyled) or box, no tick (styled) | supply an image: |
QSpinBox::up-arrow |
two flat dashes | supply an image: |
QComboBox::down-arrow |
plain rectangle, no affordance | supply an image: |
QRadioButton::indicator |
invisible when unchecked | supply an image: |
Removing the rule does not fix it. That was tried and measured on the
checkbox: still zero ink. Only supplying it works. Generate the images at
theme-apply time into a temp directory (six themes x four assets beats carrying
24 files through PyInstaller) and reference them with forward-slash url().
Three wrong fixes for the spin arrows, all aimed at padding, all measured
and all wrong: removing a padding-right override, taking QSpinBox out of the
shared input rule, giving it a zero-padding rule of its own. Padding was never
the cause.
Generation needs a QGuiApplication. Constructing a QPixmap without one
aborts the process, and build_qss is legitimately called headless by tests
that only want the stylesheet text. Guard it and return the path anyway.
5.2 Qt draws, but not what you asked
- A pixmap with a device pixel ratio paints in LOGICAL coordinates. Painting in device pixels puts three quarters of the shape off-canvas. Invisible on a square (a clipped square is still a square); the filled disc exposed it.
Qt.NoPenkeeps its style throughsetColorandsetWidth. Mutatingp.pen()aftersetPen(Qt.NoPen)draws nothing. Build a freshQPen.- Frame palette roles derive from
Windowwhen unset.Windowis#ffffffin four of six themes, so every derived checkbox edge was white on white. SetLight,Midlight,Mid,Dark,Shadowexplicitly. QIconsynthesises a tintedSelectedvariant. On a selected row this turned identity#12447einto#4b749f: still blue, no longer that blue, on the one row the reader is looking at. Add the pixmap for every mode.- A QSS type selector does not match parent classes.
QFrame[role="rule"]never matched a plainQWidgetdivider, so it silently did not exist. - A QSS background on a plain
QWidgetsubclass needsWA_StyledBackground. The rule parses, matches, paints nothing. QTableWidgetItem.setBackgroundis dead once anyQTableWidget::itemQSS rule exists. Use a delegate.- An unbalanced QSS comment silently discards the rest of the stylesheet.
One stray
*/reverted a 940px dialog to 236px with no error. Assertcount("/*") == count("*/"). - A widget-level
setStyleSheet("color:…")loses to an app-level attribute selector likeQLabel[role="hint"], which is more specific. Pick one scope.
5.3 Text, size and measurement
QHeaderViewignores the QFont's capitalization and letter-spacing the moment ANY application stylesheet exists, and honours only QSStext-transform/letter-spacing.QLabelandQTableWidgetItemdo the exact opposite: they honour the QFont and ignore the QSS.- …and it MEASURES with the font it ignores.
sectionSizeHintreports the mixed-case untracked width while painting the uppercase tracked string, so every column is ~25px too narrow. Compute fromtext.upper()and add the tracking by hand. - Measure header text PER LINE. A heading with a newline paints on two lines but measures as one long one, so a column holding "Yes" reserved 250px.
fontMetrics().lineSpacing()is 16px where the rendered block is 17. A height computed from the font is short by a pixel per line: invisible at one line, slices the third in half at three. SumblockBoundingRectinstead.QPlainTextEdit.document().size().height()is a LINE COUNT, not pixels.QTextDocumentcarries its own 4px margin on top of border and padding.blockBoundingRectis origin-relative for OFF-SCREEN blocks. A clipping test built on the last block reports "visible" however badly it is clipped. This produced a test that passed against a deliberately broken box.- Never
.upper()the string. Render uppercase via the font or QSS so the widget keeps the authored case for sorting, copy and assistive tech.
5.4 Layout
- A layout with no trailing stretch hands spare height to whatever expands.
A checkbox ended up 190px below its own heading in an empty pane. Add
addStretch(1), and make helpers insert above it rather than append. QHeaderView.Stretchcolumns cannot be dragged. The column most likely to need widening was the one column a user could not touch. Make every columnInteractiveand hand the slack out as a width, re-applied on resize,showEventandviewportEvent(a scrollbar appearing changes the usable width without resizing the table).- A
QSplittersizes children below their layout minimum and lets contents clip.setChildrenCollapsible(False)only stops it reaching zero. Set a realsetMinimumWidthon the pane, not just on the buttons inside it. QMessageBoxsizes to its shortest label and never reconsiders when the details pane opens: measured at 235x227 with a 213x100 details box, wrapping a 105-character path four times mid-word. Force it in QSS viaQMessageBox QLabel#qt_msgbox_labelandQMessageBox QTextEdit.- A transparent scrollbar track means the bar only exists where the handle is, so a long grid looks like a short one with a stray smudge beside it.
- Vertical grid rules are the resize affordance. AW's grid has none, and copying that was fine until the columns became draggable: with no separator there is nothing showing where the grip is.
5.5 Signals, threads and lifetime
QPushButton.clickedcarries acheckedbool. Connected straight to a callback, it arrives as the first positional argument. This killed a shipped Setup button:open_setup(page=None)receivedFalse,page is not Nonebecame true, andsetCurrentWidget(False)raised aTypeErrorinside the signal machinery, where Qt prints to stderr and drops it. Wrap the connect in a zero-argument lambda.deleteLaterdoes not unparent. Taking a widget out of a layout leaves it a child, so Qt keeps painting it at stale geometry until the event loop runs. Rebuild a panel twice without returning to the loop and both contents render on top of each other.setParent(None)first.QRunnableauto-deletes afterrun(), destroying the QObject carrying the result signal before Qt delivers it, so callbacks silently never fire.setAutoDelete(False)and hold a strong reference until the slot has run.- Emitting into a deleted QObject from a worker raises
RuntimeError: Signal source has been deletedon a thread where nothing can catch it. Wrap the emit. - A
toggled(bool)connected to a zero-argumentSignal.emitraises TypeError inside the signal machinery and is dropped. An entire tab's Save button had never enabled, in any released version.
5.6 Packaging the mark
- The
.icois not the window icon.setWindowIconruns at startup, so the app always looks right; the.icoPyInstaller bakes in only ever appears on a pinned shortcut, on someone else's taskbar. Ours was a year stale and no test, screenshot or launch could have shown it. Generate it from the same function the window uses, in the build script, and assert the shipped file still matches. - Draw each size, do not resample one. Stroke width and glyph size are
fractions of
size, so a 256 scaled to 16 carries four times the rim weight it should and the two letters become mush. - Pillow's ICO writer silently resamples.
img.save(p, format="ICO", sizes=[...])LANCZOS-thumbnails that one image for every size it cannot match against a supplied frame. The file it writes has every entry present at every correct pixel count, and reports success: exactly the artefact the careful per-size rendering existed to avoid. Pass the whole ladder asappend_images=framesand it matches by size. Caught only by a pixel comparison against a fresh render (mean channel drift 5.1, threshold 2.0). QIcon.pixmap(n, n)does not give you n pixels when the icon was painted at devicePixelRatio 2. It hands back the 2x pixmap, so a "256" entry is 512 wide. Scale it down explicitly, which keeps the supersampling as antialiasing.- Qt's
QImage::Format_ARGB32is BGRA in memory on x86. HandingconstBits()toImage.frombytes(..., "raw", "RGBA")swaps red and blue and turns the teal mark mauve, with no error anywhere.
5.7 Data, not Qt
- A Teamcenter preference is a STRING.
daemon_sleep_minutesis typedstr = "10". Multiplying by 60 does not scale it, it repeats it, so"1" * 60built a 60-character string. Coerce, with a fallback. - Never assert state from configuration. A row painted green because
settings describe a site is the failure a config tool exists to prevent, one
level up. Grey until a probe returns, and an empty cell rather than
0 ms, which reads as instant.
6. Testing this
Three kinds, and they are not interchangeable.
Token tests check captured values and arithmetic. Cheap. Catch a bad edit.
Contrast tests assert the palette per theme rather than trusting it: body, hint, chrome ink, tab, table header, primary ink, and every status word.
Render tests paint something and measure ink or geometry. Expensive, and the only thing that catches section 5.
def _ink_bounds(image): # non-transparent bounding box
def _evidence_fit(box): # (pixels needed, pixels available)
def indicator_ink(ctrl): # ink in one widget's sub-control region
Guard the guard. A test for a rendering defect is worth nothing until it has been shown to notice one.
test_the_clipping_check_can_actually_failbuilds the defect on purpose, because the first version of that test passed against a box deliberately made a line too short.
Click the button, do not call the slot. Calling a slot proves the slot
works; only clicking proves the button does. A test that called
win.open_setup() directly passed for weeks while the button was completely
dead. Capture stderr during the click and fail on TypeError, because that is
where this class of bug goes to hide.
7. Starting a new app
The two modules already exist. The canonical copy is
xcelerator-workspace/python/xcelerator_ui/; do not re-derive them and do not
start from a consumer's copy, which is a mirror.
Vendor
awtheme.pyandawshell.pyinto the package. Add the new app to$consumersinxcelerator-workspace/scripts/Sync-DesktopUi.ps1and run it, then copy the build gate and the drift test from either existing app.Vendored, not imported. These apps ship as PyInstaller bundles that must build on a machine that has never seen the workspace, so an import from a sibling checkout works for the author and fails for everyone else. Both modules are package-name agnostic (relative imports only), so the mirror is byte-identical and the check is a plain hash.
A mirror without a check is a copy that will drift and say nothing when it does.
tc_client.pywas vendored with a careful header note, no sync and no comparison, and became a file nobody could tell was stale. Give the check three outcomes, not two: in sync, drifted, and could not compare. A clone without the workspace has to build, so the check cannot simply fail there, but a check that quietly skips reads as one that passed. Contract inxcelerator-workspace/docs/DESKTOP-UI.md.theme = aw.apply(app, name)before the first widget exists. Every widget reads the palette at construction; a stylesheet swap does not reach inline styles or painted pixmaps.sh.GlobalBand(theme, "Product Name", letters="Xx")for the band, with two letters for the mark. Two, never one: a single letter at 16px is a smudge.sh.page()for the working surface,sh.grid()+sh.size_grid()for tables,sh.Rail+sh.Detail+sh.split()for master/detail.Put configuration in a Setup location, not a dialog or a tab.
Put the version in the status bar. Three versions get quoted about any of these tools, and without the app's own on screen "it does not do that on mine" cannot be settled.
Single-source the version in
__init__.py; have the build script read it and generate the Windows version resource. Three files disagreeing about one number is worse than no number.
Live theme switching: restyle, then rebuild only what a stylesheet cannot reach (the band, which bakes the palette into pixmaps). Do not rebuild the window: sessions, watchers and baselines hang off it, so a rebuild silently disconnects everything the moment someone looks at a colour swatch.
Generated from skills/xcelerator-desktop-ui/SKILL.md in the tc-automation-skills library, which is the canonical copy and also serves as the agent skill set for Teamcenter work.