scshafe-qt
The SCSHAFE native component library: the QML module Scshafe.Ui (Qt Quick) for
Python + PySide6 desktop apps, themed from the same token registry as the web library
@scshafe/ui, so web and native share one source of truth for colour, spacing, radii,
type and motion.
- Distribution
scshafe-qt, importscshafe_qt, QML moduleScshafe.Ui. - Qt 6.11 through
PySide6-Essentials6.11.x (6.11.2). Open-source Qt patch releases track the current minor (6.8 LTS patches for open source ended at 6.8.3), so the library follows the current minor; 6.12 comes when PySide6 6.12 ships. - Python 3.14 (the 6.11 wheels are abi3 for CPython 3.10+ and declare
requires-python <3.15). - Status: 0.1.2:
SuiTheme(tokens) and the v0.1 component set below. - Untrusted text is safe to pass: every component renders caller strings as plain text
(
textFormat: Text.PlainText); markup is shown literally and never fetches anything. 0.1.0 rendered HTML-looking strings as rich text (fixed in 0.1.1; upgrade).
Install (consumers)
From PyPI (from 0.1.2):
uv add scshafe-qt # or: pip install scshafe-qt
Every release is also a GitHub Release of scshafe/scshafe-qt with the same wheel and
sdist, their sha256 in the notes and a SHA256SUMS file. publish.yml uploads the
very files it attached to the Release (PyPI and the Release are byte-identical), with
PEP 740 attestations from trusted publishing. As a fallback, install straight from a
Release; the repository is public, so the download URLs need no token:
pip install https://github.com/scshafe/scshafe-qt/releases/download/v0.1.2/scshafe_qt-0.1.2-py3-none-any.whl
# or, in a uv project, a pinned URL source (uv.lock records its sha256):
uv add "scshafe-qt @ https://github.com/scshafe/scshafe-qt/releases/download/v0.1.2/scshafe_qt-0.1.2-py3-none-any.whl"
To check the files yourself: gh release download v0.1.2 -R scshafe/scshafe-qt -p '*.whl' -p SHA256SUMS,
then sha256sum -c --ignore-missing SHA256SUMS.
The library accepts the Qt minor it is tested on (PySide6-Essentials>=6.11.2,<6.12);
the app's own uv.lock pins one exact PySide6.
Usage
import sys
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
import scshafe_qt
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
scshafe_qt.register(engine) # adds scshafe_qt.qml_import_path() once
engine.load("main.qml")
sys.exit(app.exec())
import QtQuick
import Scshafe.Ui
Window {
visible: true
color: SuiTheme.bg
SuiButton { text: "Sort"; variant: "primary"; onClicked: console.log("sorted") }
}
Imports are versionless (import Scshafe.Ui).
Components
| Component | What it is | Keyboard / accessibility |
|---|---|---|
SuiAppShell |
sidebar | content | inspector frame; sidebar, content, inspector slots; inspectorOpen / sidebarOpen collapse a pane |
splitters are Tab stops (role Separator): ←/→ resize by resizeStep (Shift ×4), Home/End min/max, Enter collapses; drag resizes; panes are named Panes |
SuiSidebarList + SuiSidebarItem |
navigation list: icon, label, count badge, health dot (status: ok / held / failing / none), section headers (section role) |
one Tab stop; ↑/↓ Home/End PgUp/PgDn move (skip disabled), Enter/Return/Space activate (activated(index), selectedIndex); status and count in the accessible description |
SuiList + SuiListRow |
single-selection ListView (currentIndex) of two-line rows (title, subtitle, meta, unread; children go to a trailing slot) |
one Tab stop; ↓/j, ↑/k emit nextRequested() / previousRequested() and move (unless autoNavigate: false); Enter / double-click emit activated(index); selectNext() etc. for app shortcuts |
SuiChip, SuiBadge |
pills: tone neutral / info / ok / warn / danger or a bucket bucket1…bucket8 (or 1–8); badge shows count (99+) |
StaticText named by the text |
SuiButton, SuiIconButton |
buttons; the icon button needs label (its accessible name; a required property) and icon.name (built-in) or icon.source; checkable toggles |
Space/Enter; role Button (CheckBox when checkable) |
SuiTextField, SuiSearchField |
inputs; search has an icon, a clear button and a / hook |
/ anywhere (not while typing in another field) fires focusRequested() then focuses and selects; Escape clears (cleared()), then propagates |
SuiEmptyState |
icon, title, description, action slot | Grouping named by the title |
SuiBanner |
inline status (tone info / ok / warn / danger), dismissible |
warn/danger are AlertMessages; the dismiss button is a Tab stop |
SuiToast, SuiToastHost |
host.show(text, { title, tone, timeout, actionText, onAction }), dismiss(id), clear(); stacks bottom-right, at most maxToasts |
AlertMessage; announced with Accessible.announce (assertive for danger); the timeout pauses on hover / focus; Escape dismisses |
SuiDialog, SuiSheet |
modal dialog (title, description, content, actions) and a sheet sliding from an edge |
focus moves in on open, Tab is trapped inside, Escape rejects, focus returns to the opener (with its ring if it had keyboard focus); role Dialog named by the title |
SuiTrail |
vertical steps: title, outcome (+ outcomeTone), detail, via tag, warning marker |
List of ListItems ("2. Classifier: Receipts", description = detail, via, warning) |
SuiShortcutOverlay |
the ? overlay of the app's shortcuts (keys, description, group, separator) |
? toggles (not while typing); a SuiDialog |
SuiIcon, SuiKbd |
built-in vector icons (SuiIcon.names) or a tinted image; a keyboard-key badge |
icons are decorative unless given a label |
import QtQuick
import Scshafe.Ui
Window {
width: 1100; height: 700; visible: true; color: SuiTheme.bg
Shortcut { sequence: "j"; onActivated: inbox.selectNext() } // from anywhere
SuiAppShell {
anchors.fill: parent
sidebar: SuiSidebarList {
label: "Mailboxes"; selectedIndex: 0
model: [ { section: "Buckets", text: "Receipts", iconName: "tag", count: 3, status: "ok" } ]
}
content: SuiList {
id: inbox; label: "Messages"; model: messages
delegate: SuiListRow {
width: ListView.view.width
title: model.sender; subtitle: model.subject; meta: model.time; unread: model.unread
SuiChip { text: model.bucketName; tone: model.bucket; dot: true } // trailing slot
}
onActivated: (index) => sheet.open()
}
inspector: SuiTrail { steps: [ { title: "Classifier", outcome: "Receipts", outcomeTone: 1, via: "model" } ] }
}
SuiToastHost { id: toasts; anchors.fill: parent; z: 100 }
}
Every interactive component is keyboard-focusable and draws its focus ring for
keyboard focus only (text fields for any focus, as browsers do). Lists are one
Tab stop with roving focus on the current row, which holds active focus (so a
screen reader announces it); the ring returns after a pointer click as soon as
an arrow key is used (the :focus-visible heuristic).
Gallery
examples/gallery.py shows every component in one window with made-up data:
uv run python examples/gallery.py # follows the OS theme
uv run python examples/gallery.py --theme dark --reduced-motion
uv run python examples/gallery.py --screenshot DIR # PNGs of four views x two themes
In the window: Tab / Shift+Tab, j / k, 1–8 (a toast), /, ?.
Theme and motion
SuiTheme is a singleton with every registry token as a property (--sui-text-strong
is SuiTheme.textStrong; SuiTheme.registry maps CSS names to property names).
SuiTheme.mode:"system"(default) followsQt.styleHints.colorScheme;"light"and"dark"pin a theme.SuiTheme.dark/SuiTheme.themeNamereport the result.SuiTheme.reducedMotion: whentrue,SuiTheme.duration(every transition) is 0. Qt (through 6.11) exposes no OS reduced-motion preference (QStyleHints has none;QAccessibilityHints, 6.10+, only carriescontrastPreference), so the application sets it, e.g. from GNOME'sorg.gnome.desktop.interface enable-animationsor macOS's "Reduce motion" setting.- Units: lengths are logical pixels (CSS px), durations milliseconds; shadows are
{ offsetX, offsetY, blur, spread, color }forMultiEffect. - Native-only tokens (not in the registry) live in the clearly marked NATIVE-ONLY
section of
tools/gen_tokens.pyand are generated intoSuiTheme.qml: focus ring, control metrics and layout defaults, type scale, the bucket palette, tone helpers (toneBase,toneText,toneFill,toneBorder,statusColor),monoFamilyandalpha(color, amount).
Tones and the bucket palette
Status tones reuse the registry's tone tokens: info blue, ok green, warn
yellow, danger red; neutral is --sui-text on --sui-tint. A chip's fill is
its tone at toneTint (12 %, the web library's TONE_TINT), its border at 45 %.
bucket1…bucket8 are native-only categorical tones for user-defined groups,
chosen in OKLCH about 45° apart (blue, teal, green, olive, amber, rust, rose,
violet). tests/test_tokens.py checks, in both themes, that each bucket's text
reaches 4.5:1 on its chip fill over every surface and every overlay (selection,
hover, tint), that each base (dots, swatches) reaches 3:1 on every surface, and that
the bases stay pairwise distinct (OKLab distance ≥ 0.07). Lowest ratios today:
| blue | teal | green | olive | amber | rust | rose | violet | |
|---|---|---|---|---|---|---|---|---|
| light text on chip | 5.08 | 4.99 | 4.98 | 5.01 | 5.04 | 4.98 | 4.96 | 4.96 |
| dark text on chip | 6.01 | 6.03 | 6.08 | 6.01 | 6.01 | 6.02 | 6.12 | 6.02 |
| light base on surfaces | 3.69 | 3.73 | 3.73 | 3.68 | 3.75 | 3.76 | 3.82 | 3.77 |
| dark base on surfaces | 7.20 | 7.62 | 7.66 | 7.37 | 7.10 | 6.87 | 6.75 | 6.90 |
Monospace
--sui-mono is a CSS stack; a Qt font takes one family, so SuiTheme.monoFamily
is the first installed candidate for the platform (assign it to override):
- Linux:
monospace, fontconfig's alias for the user's configured monospace face (DejaVu Sans Mono, Noto Sans Mono, Liberation Mono or Ubuntu Mono on common distributions); - macOS: SF Mono (when installed), else Menlo (always present), Monaco;
- Windows: Cascadia Mono, Consolas, Courier New;
- fallback:
monospace.
Accessibility contract
Every interactive component is keyboard-focusable, shows a focus ring
(SuiTheme.focusRing, outside the control, or inset on list rows) for keyboard
focus only (Qt's visualFocus, the native :focus-visible), sets
Accessible.role and Accessible.name, and animates only on SuiTheme.duration.
Components carry no colour literals (tests/test_rules.py enforces it, as
@scshafe/ui does for its stylesheets) and every animation runs on
SuiTheme.duration, so reducedMotion stops them all (checked statically and at
runtime over the gallery).
Qt version notes
The QML targets Qt 6.11 and uses no 6.9–6.11-only QML API: the suite also passes on PySide6 6.8.3 except for the dialog's accessible name (below). Worth knowing:
SuiIcontints image icons withIconImagefromQtQuick.Controls.impl, the module Qt's own styles use; it is not a public API with compatibility promises, so a Qt upgrade re-checks it (the tests load it).SuiToastHostannounces toasts withAccessible.announce()(Qt 6.8+), guarded.- A dialog's accessible name comes from Qt: on 6.11
T.Dialognames its popup bytitleonce accessibility is active (an assistive technology is running); Qt 6.8.3 leaves it unnamed. - Qt (through 6.11) reports no OS reduced-motion preference; the app sets
SuiTheme.reducedMotion.
Token pipeline
@scshafe/ui ./tokens export (lib/tokens.js), cross-checked with src/tokens.ts
│ tools/gen_tokens.py --refresh (node reads the registry)
▼
tokens/sui-tokens.json committed snapshot: version, file sha256,
│ source commit, registry hash, tokens
│ tools/gen_tokens.py
▼
src/scshafe_qt/qml/Scshafe/Ui/SuiTheme.qml committed, header records the source
uv run python tools/gen_tokens.py --refresh [--from DIR]re-reads the registry (DIR: a scshafe-ui checkout or an installednode_modules/@scshafe/ui; default$SCSHAFE_UI_DIR, then a sibling../scshafe-ui) and rewrites both files. Needs Node.uv run python tools/gen_tokens.pyregeneratesSuiTheme.qmlfrom the snapshot.uv run python tools/gen_tokens.py --checkfails ifSuiTheme.qmlor the qmldirsingletonentry is stale, and, when the registry is reachable, if the snapshot is. CI has no scshafe-ui checkout, so there it checks QML against the snapshot and says it skipped the registry comparison (--require-sourcemakes that a failure).tests/test_tokens.pychecks every token in both themes against the snapshot (and the live registry when reachable).
Change a token in scshafe-ui, release it, then --refresh here in one commit.
Development
Toolchain: uv 0.12.21 ([tool.uv] required-version),
Python 3.14 (.python-version; uv uses a matching interpreter or downloads a managed
CPython 3.14, as CI does), uv.lock committed.
uv sync --frozen # .venv with PySide6 + pytest-qt
uv run python tools/gen_tokens.py --check
uv run pytest # offscreen; includes the QML TestCases
uv run python tests/qml_runner.py # QML TestCases alone (tests/qml/tst_*.qml)
SCSHAFE_QT_SCREENSHOTS=DIR uv run pytest tests/test_gallery.py # gallery PNGs into DIR
uv build && uv run python tools/check_dist.py --smoke
Tests run under QT_QPA_PLATFORM=offscreen and QT_QUICK_BACKEND=software (set by
tests/conftest.py and tests/qml_runner.py). PySide6 wheels ship no
qmltestrunner; tests/qml_runner.py uses PySide6.QtQuickTest's
QUICK_TEST_MAIN_WITH_SETUP with scshafe_qt.register. Any Qt or QML warning
fails a test (qt_log_level_fail = "WARNING"). The gallery screenshot test writes
its PNGs to $SCSHAFE_QT_SCREENSHOTS (else pytest's temporary directory); they are
for review and never committed.
On Ubuntu the wheels need these system libraries (CI installs them): libegl1 libgl1 libxkbcommon0 libfontconfig1 libfreetype6 libx11-6 libglib2.0-0t64 libdbus-1-3 and a
font (fonts-dejavu-core). ICU is bundled.
To try a change in an app before a release, install this checkout into the app's
environment (uv pip install -e <path> in the app's venv) and never commit that.
CI
.github/workflows/ci.yml, GitHub-hosted runners only, contents: read, actions pinned
by SHA: Linux (ubuntu-latest) on every push and pull request; macOS (macos-latest,
Apple Silicon) on workflow_dispatch and weekly, to keep macOS minutes low (release
tags run publish.yml, which verifies on both).
Both run uv sync --frozen, the token check, the tests, uv build and the
distribution check (wheel carries the QML module, payload scan, install-and-load smoke).
Releasing
The library standard's Python variant. pyproject.toml version is the authority: a
release commit bumps it and adds ## <x.y.z> — <date> to CHANGELOG.md; after CI is
green on main the owning agent runs the dry run
(gh workflow run publish.yml -f dry_run=true, every check on Linux and macOS) and then
pushes the annotated tag v<x.y.z> on that commit. .github/workflows/publish.yml is
the only publisher. It refuses tags not on main, not annotated or not matching the
version, and lock files with non-registry sources; tests, builds and smoke-installs on
Linux and macOS; rebuilds the tag and requires the same bytes (hatchling builds are
reproducible); creates a draft Release with the wheel, sdist and SHA256SUMS;
downloads those assets back, checks their hashes and runs the payload check and the
install-and-load smoke on the downloaded wheel; and only then publishes the Release.
Every Release asset is the build job's own file (passed as a run artifact); the rebuild
only proves reproducibility. Last, the pypi job (environment pypi, deployable from
v* tags only; id-token: write, no stored token) checks the same files against the
build digests and the Release's SHA256SUMS, uploads them to PyPI by trusted publishing
(pypa/gh-action-pypi-publish, with attestations) and confirms PyPI serves those
digests. If that job fails, the Release stays; re-run the failed job. The dry run stops
before the Release and PyPI. Versions are never reused, moved or deleted, here or on
PyPI (PyPI never accepts a file name twice).
License
MIT, see LICENSE.
Metadata
Release files for scshafe-qt 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| scshafe_qt-0.1.2.tar.gz | 83.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scshafe_qt-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 134.3 kB
Release files / scshafe_qt-0.1.2.tar.gz
| Download URL | scshafe_qt-0.1.2.tar.gz |
|---|---|
| Size | 83.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
dbd994259908a40e93d596e002be448b2be9a9ffae5d78b37a2e2aaf651dea53
|
|
BLAKE2b-256 checksum How to use checksums |
e86efb0cdceba43b05aaebad32c9724081631508ecf0374c6168ddb67f4c40b3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logRelease files / scshafe_qt-0.1.2-py3-none-any.whl
| Download URL | scshafe_qt-0.1.2-py3-none-any.whl |
|---|---|
| Size | 50.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
61d71f26766f759fcb2165fd817cbd23d7131156e76000465e656737ec302d78
|
|
BLAKE2b-256 checksum How to use checksums |
6bf65100bad22c60d7e26f5e82e15d39e8e783460109feb5107bae8245abda3e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency log