TeamcenterKnowledge

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:

  1. Read the font back. Reports what you authored, not what paints.
  2. QFontMetrics. Ignores capitalization entirely, and reports 16px where the rendered block is 17. A width comparison built on it is noise.
  3. 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 ok at 5.96:1 and fail at 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.NoPen keeps its style through setColor and setWidth. Mutating p.pen() after setPen(Qt.NoPen) draws nothing. Build a fresh QPen.
  • Frame palette roles derive from Window when unset. Window is #ffffff in four of six themes, so every derived checkbox edge was white on white. Set Light, Midlight, Mid, Dark, Shadow explicitly.
  • QIcon synthesises a tinted Selected variant. On a selected row this turned identity #12447e into #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 plain QWidget divider, so it silently did not exist.
  • A QSS background on a plain QWidget subclass needs WA_StyledBackground. The rule parses, matches, paints nothing.
  • QTableWidgetItem.setBackground is dead once any QTableWidget::item QSS 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. Assert count("/*") == count("*/").
  • A widget-level setStyleSheet("color:…") loses to an app-level attribute selector like QLabel[role="hint"], which is more specific. Pick one scope.

5.3 Text, size and measurement

  • QHeaderView ignores the QFont's capitalization and letter-spacing the moment ANY application stylesheet exists, and honours only QSS text-transform / letter-spacing. QLabel and QTableWidgetItem do the exact opposite: they honour the QFont and ignore the QSS.
  • …and it MEASURES with the font it ignores. sectionSizeHint reports the mixed-case untracked width while painting the uppercase tracked string, so every column is ~25px too narrow. Compute from text.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. Sum blockBoundingRect instead.
  • QPlainTextEdit.document().size().height() is a LINE COUNT, not pixels.
  • QTextDocument carries its own 4px margin on top of border and padding.
  • blockBoundingRect is 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.Stretch columns cannot be dragged. The column most likely to need widening was the one column a user could not touch. Make every column Interactive and hand the slack out as a width, re-applied on resize, showEvent and viewportEvent (a scrollbar appearing changes the usable width without resizing the table).
  • A QSplitter sizes children below their layout minimum and lets contents clip. setChildrenCollapsible(False) only stops it reaching zero. Set a real setMinimumWidth on the pane, not just on the buttons inside it.
  • QMessageBox sizes 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 via QMessageBox QLabel#qt_msgbox_label and QMessageBox 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.clicked carries a checked bool. Connected straight to a callback, it arrives as the first positional argument. This killed a shipped Setup button: open_setup(page=None) received False, page is not None became true, and setCurrentWidget(False) raised a TypeError inside the signal machinery, where Qt prints to stderr and drops it. Wrap the connect in a zero-argument lambda.
  • deleteLater does 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.
  • QRunnable auto-deletes after run(), 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 deleted on a thread where nothing can catch it. Wrap the emit.
  • A toggled(bool) connected to a zero-argument Signal.emit raises 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 .ico is not the window icon. setWindowIcon runs at startup, so the app always looks right; the .ico PyInstaller 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 as append_images=frames and 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_ARGB32 is BGRA in memory on x86. Handing constBits() to Image.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_minutes is typed str = "10". Multiplying by 60 does not scale it, it repeats it, so "1" * 60 built 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_fail builds 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.

  1. Vendor awtheme.py and awshell.py into the package. Add the new app to $consumers in xcelerator-workspace/scripts/Sync-DesktopUi.ps1 and 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.py was 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 in xcelerator-workspace/docs/DESKTOP-UI.md.

  2. 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.

  3. 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.

  4. sh.page() for the working surface, sh.grid() + sh.size_grid() for tables, sh.Rail + sh.Detail + sh.split() for master/detail.

  5. Put configuration in a Setup location, not a dialog or a tab.

  6. 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.

  7. 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.