3lc-compute-plugin-sdk 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/.github/workflows/docs.yml +4 -3
  2. 3lc_compute_plugin_sdk-0.3.2/1.0-READINESS.md +434 -0
  3. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/CHANGELOG.md +10 -0
  4. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/PKG-INFO +1 -1
  5. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/docs/plugin-guide.md +152 -101
  6. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/pyproject.toml +1 -1
  7. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/data_source_routes.py +11 -3
  8. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/tests/test_data_source_routes.py +8 -0
  9. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/uv.lock +1 -1
  10. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/.github/workflows/ci.yml +0 -0
  11. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/.github/workflows/release.yml +0 -0
  12. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/.gitignore +0 -0
  13. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/CLAUDE.md +0 -0
  14. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/LICENSE +0 -0
  15. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/README.md +0 -0
  16. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/docs/api.md +0 -0
  17. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/docs/conf.py +0 -0
  18. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/docs/index.md +0 -0
  19. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/docs/package-lock.json +0 -0
  20. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/docs/package.json +0 -0
  21. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/docs/requirements.txt +0 -0
  22. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/__init__.py +0 -0
  23. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/asgi_app.py +0 -0
  24. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/contract/plugin-api.d.ts +0 -0
  25. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/contract.py +0 -0
  26. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/job_context.py +0 -0
  27. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/py.typed +0 -0
  28. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/__init__.py +0 -0
  29. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/alias_override_ui.py +0 -0
  30. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/alias_ui.py +0 -0
  31. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/aliases.py +0 -0
  32. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/config_store.py +0 -0
  33. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/config_ui.py +0 -0
  34. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/data_source_ui.py +0 -0
  35. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/generic_job.py +0 -0
  36. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/images.py +0 -0
  37. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/job_tracker.py +0 -0
  38. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/labels.py +0 -0
  39. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/modality.py +0 -0
  40. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/model_storage.py +0 -0
  41. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/naming.py +0 -0
  42. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/ui_inject.py +0 -0
  43. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/shared/url_utils.py +0 -0
  44. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/src/tlc_plugin_sdk/worker.py +0 -0
  45. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/tests/test_import_light.py +0 -0
  46. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/tests/test_job_context.py +0 -0
  47. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/tests/test_js_contract_surface.py +0 -0
  48. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/tests/test_url_utils.py +0 -0
  49. {3lc_compute_plugin_sdk-0.3.1 → 3lc_compute_plugin_sdk-0.3.2}/tests/test_worker_gpu_reclaim.py +0 -0
@@ -1,10 +1,11 @@
1
1
  name: docs
2
2
 
3
3
  # Build the Sphinx docs and publish to GitHub Pages.
4
- # Manual-only by choice: run from the Actions tab (or `gh workflow run docs`)
5
- # when the docs should go out — pushes to main do NOT redeploy.
6
- # One-time setup: repo Settings -> Pages -> Source = "GitHub Actions".
4
+ # Deploys automatically on every release tag, or manually from the Actions tab
5
+ # (`gh workflow run docs`). One-time setup: repo Settings -> Pages -> Source = "GitHub Actions".
7
6
  on:
7
+ push:
8
+ tags: ["v*"]
8
9
  workflow_dispatch:
9
10
 
10
11
  # Least-privilege token for the Pages deploy.
@@ -0,0 +1,434 @@
1
+ # 1.0 readiness report — 3lc-compute-plugin-sdk
2
+
3
+ *Audit of SDK `main` @ `576bcd5` (0.2.0, includes JS_CONTRACT 0.2 / PR #8), host `3lc-compute` @ `bbf2aed`,
4
+ Hub frontend, and all 9 first-party plugins (exporter, importer, merger, splitter, image-metrics,
5
+ table-statistics, timm, sam3, yolo). Line numbers reference those revisions. Uncommitted working doc.*
6
+
7
+ **Framing** (per the pre-1.0 window): all consumers are in-house, so breaking is cheap *now* —
8
+ grep-and-fix across our own repos, coordinated via editable overlays and the frontend contract test.
9
+ Every breaking proposal below states who migrates and roughly how.
10
+
11
+ ---
12
+
13
+ ## The shape of the problem (executive summary)
14
+
15
+ 1. **The contract that's written down is healthy; the contract that's *lived* is bigger and undeclared.**
16
+ The `PluginApi` top level matches the d.ts exactly (the conformance test passes), but the surface
17
+ plugins actually depend on includes `openTablePicker` (7 of 8 UI plugins), 13 SDK-injected `_tlc*`
18
+ globals (6 of 8 plugins), `CancelJob`, bare `showToast`, `cssVar`, `plugin-common.css` classes, and
19
+ an `allowErrorStatus` fetch option the host itself uses 13×. None of that is in the d.ts. 1.0 should
20
+ be the moment the declared contract and the lived contract become the same thing.
21
+ 2. **The job-reporting API is fought by its heaviest users.** `ctx.result(run_url=)` is called by only
22
+ 2 of 7 job plugins — all three GPU trainers smuggle their result URL through `ctx.emit`/`ctx.metric`
23
+ instead; failure reporting is hand-rolled (`ctx.emit("job_failed", ...)` + `raise`) in duplicate;
24
+ the failure message rides `PluginJobUpdate.subtitle`. These are the exact warts that freeze at 1.0.
25
+ 3. **The version story has three axes and one consumer.** `PY_CONTRACT`/`JS_CONTRACT` are read by
26
+ nobody (host compares `sdk_version` only; zero plugin references); the manifest `sdk_version` the
27
+ SDK docstring promises is read by nobody; and `PLUGIN_API.contractVersion` is `''` in every
28
+ production frontend container. Consolidate to one axis and make it actually reach production.
29
+ 4. **Live pin defect, before anything else:** the host and the plugins umbrella pin
30
+ `3lc-compute-plugin-sdk[shared]>=0.1.0,<0.2.0` — which *excludes the current 0.2.0 release* — so
31
+ three shipped 0.2 features (host-mode GPU reclaim, `/reclaim`, `/health` skew detection) are inert
32
+ in the resolved lock (host `pyproject.toml:24`, acknowledged at `job_manager.py:59-71`).
33
+ 5. **Ceremony is measurable.** 8/9 plugins repeat the same `_ui_cache` + `Path(__file__)` +
34
+ `read_text` block; 5/9 implement `compute()` as a dead stub; script injection exists in **four**
35
+ idioms (two of them the exact footgun `ui_inject.py` was written to eliminate, one injection dead
36
+ on both ends); the config-CRUD route quartet is copy-pasted 4×; yolo reimplements
37
+ `PluginConfigStore` wholesale.
38
+
39
+ ---
40
+
41
+ ## A. Break at 1.0 (ranked by leverage)
42
+
43
+ ### A1. Reshape job *reporting*: `result`, failure, and the error field
44
+
45
+ - **What/where:** `src/tlc_plugin_sdk/job_context.py:75-87` (`result(*, run_url)`),
46
+ `contract/plugin-api.d.ts` `PluginJobUpdate` (failure message documented as riding `subtitle`),
47
+ host translation `3lc-compute/src/tlc_compute/plugins/job_manager.py:461-479`.
48
+ - **Why it hurts:** the name `run_url` is wrong for half the producers (importers/exporters/mergers
49
+ produce *tables*), and the evidence says the API lost its users: only merger (`__init__.py:147`) and
50
+ importer (`:1553`) call `ctx.result`; timm ships the URL via `ctx.emit("job_completed", {..., "run_url"})`
51
+ (`timm/__init__.py:258-261`), yolo likewise (`:299-308`), and sam3 abuses `ctx.metric("run", str(run.url))`
52
+ (`:393-394`) — so the generic panel's "open result" link works for 2 of 7 job plugins. Failure has no
53
+ first-class API at all: timm `:266-269` and yolo `:311-313` both hand-emit `job_failed` + `raise`, and
54
+ the frontend reads the failure message out of `subtitle`. All of this freezes at 1.0 if untouched.
55
+ - **Proposed 1.0 shape:**
56
+ - `ctx.result(url: str, *, kind: str = "run")` (or just `url=`); keep emitting `run_url` on the wire
57
+ *or* rename the wire field to `result_url` in the same coordinated set.
58
+ - Add `error` to the generic job record/`PluginJobUpdate` (host sets it from the terminal `error`
59
+ event); keep `subtitle` as display text. Optionally add `ctx.fail(message)` sugar (equivalent to
60
+ raising, for validation paths) — that part is additive.
61
+ - Document `percent=-1` (indeterminate) in `JobContext.progress` itself — today it's only in the
62
+ guide (`docs/plugin-guide.md:530`), and importer relies on it (`:1578,1621`).
63
+ - **Additive or breaking:** breaking (kwarg rename + wire-field addition/rename).
64
+ - **Who migrates:** SDK (`job_context.py`, d.ts `PluginJobUpdate`); host (`job_manager.py:473-474`,
65
+ `_Job.to_generic` `:109-122`); frontend (queue-panel/cancel-job read of `run_url`/`subtitle`);
66
+ plugins: merger + importer one line each, then timm/yolo/sam3 *adopt* `ctx.result` and delete their
67
+ workarounds (a net simplification, not just a rename).
68
+ - **Effort:** M.
69
+
70
+ ### A2. Collapse the three version axes to one, and make it reach production
71
+
72
+ - **What/where:** `src/tlc_plugin_sdk/__init__.py:39-56` (`PY_CONTRACT`, `JS_CONTRACT`),
73
+ `asgi_app.py:52-59` (`/health` reports all three), frontend `app.py:22-28` + `pyproject.toml:17-34`.
74
+ - **Why it hurts:** `PY_CONTRACT`/`JS_CONTRACT` have **zero consumers** — the host's skew check
75
+ compares only `sdk_version` (`3lc-compute/plugins/versioning.py:128-164`), no plugin reads them, and
76
+ the frontend's use of `JS_CONTRACT` is broken in production: the SDK is a dev-group-only dependency
77
+ (`frontend/pyproject.toml:33`), the Docker image installs only `[project.dependencies]`, so
78
+ **`PLUGIN_API.contractVersion === ''` in every shipped container** and all documented 0.2
79
+ feature-detection (`location`, `resolveLocation?`, `getLocations?`) reports "pre-0.1 host". Three
80
+ axes is bookkeeping we pay on every release for a distinction nothing exercises. Also: the SDK
81
+ docstring's claim that plugins declare `sdk_version` in their manifest (`__init__.py:32-33`) is
82
+ fiction — no manifest has it, no host code reads it.
83
+ - **Proposed 1.0 shape:** `SDK_CONTRACT_VERSION` is the one version. Delete `PY_CONTRACT`/`JS_CONTRACT`
84
+ exports; the d.ts header states the package version it shipped in. `/health` reports `sdk_version`
85
+ only. The frontend derives `contractVersion` from the *compute service* (it already calls
86
+ `GET /api/plugins/` and `/health`) instead of a locally-installed Python package — that fixes the
87
+ production hole structurally rather than by promoting the SDK to a frontend runtime dep. Fix or
88
+ delete the `sdk_version`-manifest docstring claim (see A6).
89
+ - **Additive or breaking:** breaking (removes two public constants and two `/health` fields).
90
+ - **Who migrates:** SDK (`__init__.py`, `asgi_app.py`, d.ts header, guide's "Two version axes"
91
+ section); frontend (`app.py`, `base.html:48`, `plugin-loader.js:210` → read from compute);
92
+ host: nothing (never consumed them). Plugins: nothing.
93
+ - **Effort:** M (the frontend sourcing change is the real work).
94
+
95
+ ### A3. Bring the escaped browser surface into the contract; kill the bare globals
96
+
97
+ - **What/where:** frontend `static/js/table-picker.js:62` (`openTablePicker`, used by **7 of 8** UI
98
+ plugins), `cancel-job.js:29` (`CancelJob`, yolo+timm), `main.js:202/170` (bare `showToast`, `cssVar`);
99
+ SDK-injected `_tlc*` globals — 13 functions across `shared/alias_ui.py`, `alias_override_ui.py`,
100
+ `config_ui.py`, used by 6 of 8 plugins, none declared in the d.ts.
101
+ - **Why it hurts:** this is contract-shaped surface with no contract: no d.ts entry, no conformance
102
+ gate, no stability tier. `openTablePicker(targetInputId)` additionally couples via a global DOM id
103
+ (`table-picker.js:308` reaches out of the plugin container), and the host fires both `input` *and*
104
+ `change` events because five named plugins disagree on which to listen for (`table-picker.js:308-317`).
105
+ The underscore prefix on `_tlc*` says "private" while the usage pattern is "public API".
106
+ - **Proposed 1.0 shape:**
107
+ - `PLUGIN_API.pickers.table(options) → Promise<string|null>` (promise-returning, no DOM-id
108
+ coupling; frontend wraps the existing picker). There is no run picker today — declare only what
109
+ exists, add `pickers.run` later additively.
110
+ - `PLUGIN_API.jobs.cancelDialog(jobId, opts)` or declare `CancelJob` as-is in the d.ts; plugins
111
+ already use `PLUGIN_API.showToast`, so delete the bare-global usage (importer ×3).
112
+ - Rename the injected `_tlc*` families under one declared namespace (e.g. `window.TlcPluginUi.alias.*`,
113
+ `.aliasOverride.*`, `.configBar(...)`) with d.ts declarations; ship thin deprecated aliases during
114
+ the migration window, drop the bare `_tlc*` names at 1.0.
115
+ - **Additive or breaking:** additive to introduce, breaking to remove the old globals at 1.0 (that
116
+ removal is the point — otherwise the undeclared surface stays load-bearing forever).
117
+ - **Who migrates:** frontend (implement `pickers`, declare members, then delete the legacy event
118
+ double-fire once all plugins use the promise API); SDK (rename injected JS + d.ts declarations);
119
+ plugins: mechanical sweep — `openTablePicker` call sites in 7 plugins, `_tlc*` call sites in 5.
120
+ - **Effort:** M–L (mechanical but wide; the contract-test extension in C3 keeps it honest).
121
+
122
+ ### A4. Make the SDK own the manifest schema — and fix the doc/code lies about it
123
+
124
+ - **What/where:** manifest parsing lives host-side (`3lc-compute/plugins/manifest.py:140-248`) with
125
+ no schema, while the SDK documents it (`docs/plugin-guide.md:104-157`). Confirmed divergences:
126
+ - `isolation` default: guide says `venv` is the default (`plugin-guide.md:130`); host code defaults
127
+ to **`host`** (`registry.py:392`) — the opposite, and the dangerous direction (in-process import).
128
+ - `socketio_namespace`: guide says declare-to-override (`:134-137`); host **hardcodes** `f"/{pid}"`
129
+ and never reads the key (`manifest.py:219-221`).
130
+ - `provision_extra`: guide says REQUIRED for venv (`:133,140`); host treats it as optional and
131
+ silently falls back to in-place resolution (`worker_spec.py:351-397`).
132
+ - Undocumented keys the host reads: `runtime.auth_exempt_paths`, `runtime.venv_python`,
133
+ `runtime.python`, `runtime.training`, `ui.min_input_count`, `ui.action_param_names`,
134
+ `ui.quick_action_label/description` (`manifest.py:191-216`).
135
+ - Documented keys nothing reads: `sdk_version`, `ui.fragment`, `display_mode = "hidden"` (no host
136
+ branch on it).
137
+ - No `max_input_count`: image-metrics encodes "1–10 tables" as `input_types = ["table"] × 10`
138
+ (`tlc_plugin_image_metrics/plugin.toml:13`).
139
+ - No reserved-id list: `id = "jobs"` (or `manifest`, `by-resource`, `install`, …) passes the id
140
+ pattern (`identity.py:52`) and is then unroutable under `/api/plugins`.
141
+ - A TOML parse error makes the plugin **vanish silently** (`manifest.py:115-121`) — contradicting
142
+ the stated design goal of "listed greyed-out, never vanishing".
143
+ - `contract.py:8-9` says the manifest is "`[tool.tlc-compute]` in plugin.toml" — all 9 real
144
+ manifests are bare top-level `plugin.toml`; the `[tool.tlc-compute]` form only applies to
145
+ `pyproject.toml`.
146
+ - **Why it hurts:** the manifest *is* contract surface — every key an author writes is a promise —
147
+ and right now the source of truth is a host file the SDK repo can't see, with drift in both
148
+ directions and no validation (string `priority` takes down `GET /api/plugins` wholesale,
149
+ `registry.py:725`).
150
+ - **Proposed 1.0 shape:** a typed `tlc_plugin_sdk.manifest` module (dataclass + `parse()` +
151
+ validation errors) that the **host imports** — SDK is already the root of the dependency graph, so
152
+ this is the natural home. In the same move: require `isolation` explicitly (all 9 manifests already
153
+ declare it — zero real migration); either implement `socketio_namespace` override or delete it from
154
+ the schema/docs (recommend delete — nothing has ever overridden it); make `provision_extra`
155
+ actually required for venv (validation error); add `max_input_count`; reserve the literal path
156
+ segments (`jobs`, `manifest`, `by-resource`, `venv`, …) in id validation; document the rest.
157
+ Also add `/venv` to the reserved-route list in `contract.py:105` (host owns
158
+ `GET /api/plugins/{id}/venv`; a plugin's `@get("/venv")` is silently shadowed today).
159
+ - **Additive or breaking:** breaking (required `isolation`, dropped dead keys, host adopts SDK parser).
160
+ - **Who migrates:** host (manifest.py becomes a consumer of the SDK schema — the big piece);
161
+ plugins: none in practice; docs: guide's manifest section becomes generated-from/checked-against
162
+ the schema.
163
+ - **Effort:** L (mostly host-side; the SDK module itself is S–M).
164
+
165
+ ### A5. Prune and re-tier `shared.*` (the graduation question)
166
+
167
+ - **What/where & evidence (usage census across all 9 plugins + host):**
168
+ - `shared/modality.py` (551 lines — the largest shared module): plugins use exactly **one** of its
169
+ five public functions (`detect_modality_from_table`, 5 plugins). `detect_modality_from_url` has
170
+ **zero references anywhere**; `detect_modality_from_schema` and `classify_metrics_columns` are
171
+ internal/host-only. It is 551 lines of heuristics (7-step fallback chains, name conventions)
172
+ published as forever-contract.
173
+ - `shared/labels.py`: three plugins use one distinct function each, no overlap;
174
+ `candidate_label_paths`/`find_label_path`/`get_label_map` have no external callers.
175
+ - `shared/generic_job.epoch_progress`: 2 users (timm, yolo) — and **both** also maintain a private
176
+ `_timing` dict + hand-rolled elapsed/avg/ETA computation + a local `avg_epoch_s → avg_step_s`
177
+ remap (`yolo/__init__.py:184-191`, `timm/__init__.py:144-154`) because the helper only formats,
178
+ it doesn't *track*. The abstraction is at the wrong altitude.
179
+ - `shared/url_utils.normalize_url`: adoption ~25% (29 bypassing `tlc.Table.from_url` calls vs ~9
180
+ using it), and its heuristics are platform folklore — `url.startswith(("Users/", "home/"))`
181
+ re-adds a lost leading slash (`url_utils.py:37-39`), macOS/Linux-specific, silently wrong
182
+ elsewhere.
183
+ - `shared/model_storage.py`: 2 users (timm, yolo); `on_status: Any` where it means
184
+ `Callable[[str], None]`; contains an `except AttributeError` probe for a `tlc.Url` method.
185
+ - `shared/config_store.py`: healthy (3 users + yolo's clone proves demand) but leans on
186
+ `# type: ignore[attr-defined]` ×5 for the id/created/last_run envelope — the repo's own
187
+ CLAUDE.md bans that.
188
+ - **Proposed 1.0 shape:**
189
+ - **Graduate (stable core):** `ui_inject`, `job_tracker`, `config_store` (with a typed envelope —
190
+ a `PluginConfigBase` dataclass or Protocol killing the `type: ignore`s), `aliases`, `images`,
191
+ `labels` (public trio only), `naming`.
192
+ - **Trim:** delete `detect_modality_from_url` (zero users); underscore
193
+ `detect_modality_from_schema`, `candidate_label_paths`, `find_label_path` (internal helpers).
194
+ - **Mark experimental:** `modality` detection heuristics — either a `shared.experimental` namespace
195
+ or an explicit "best-effort, may change in minors" tier in the docstring + docs, mirroring the
196
+ `libs` tier precedent. Freezing 551 lines of heuristics at 1.0 is the single biggest frozen-wart
197
+ risk in the package.
198
+ - **Replace:** `epoch_progress` with an `EpochTimer` that owns the timing dict + ETA math both
199
+ trainers reimplement (additive add, breaking delete of `epoch_progress` — 2 trivial migrations).
200
+ `normalize_url` → either push proper resolution into `tlc` core or reduce it to the defensible
201
+ subset (absolute-ize + project-root resolution) and delete the "Users/" folklore.
202
+ - **Tighten:** `model_storage` signatures (`on_status: Callable[[str], None] | None`).
203
+ - **The `[shared]` extra:** keep the split. The pyproject "direction" note (`pyproject.toml:43`)
204
+ says `3lc` likely returns to base — but the frontend is a real bare-SDK consumer (it installs the
205
+ SDK purely for the d.ts). Recommend deleting that direction note and declaring the split
206
+ permanent at 1.0.
207
+ - **Additive or breaking:** mixed; the deletions/underscorings are breaking.
208
+ - **Who migrates:** essentially nobody for the deletions (zero external users, verified); timm+yolo
209
+ for `epoch_progress`; call-site sweep for `normalize_url` semantics (5 plugins, host insights
210
+ plugins).
211
+ - **Effort:** M.
212
+
213
+ ### A6. Fix `contractVersion`'s meaning and the d.ts's own identity strings
214
+
215
+ - **What/where:** the d.ts references the **pre-rename distribution name** `3lc-plugin-sdk` in three
216
+ places (`plugin-api.d.ts:26, 353, 416`); the guide (`plugin-guide.md:313`) and `docs/api.md:32` say
217
+ `/// <reference types="3lc-compute-plugin-sdk/contract/plugin-api" />` while the d.ts header
218
+ (`:24-27`) explains at length why it must be the *import* name `tlc_plugin_sdk`. The guide's
219
+ version table (`plugin-guide.md:785-787`) still says JS_CONTRACT 0.1 / package 0.1.0. README says
220
+ "0.1 is the released contract line" and that the base deps include `3lc` (`README.md:46,52`) —
221
+ both wrong.
222
+ - **Why it hurts:** the contract document contradicting itself about its own name and version is
223
+ exactly the kind of thing a first external plugin author trips on day one.
224
+ - **Proposed 1.0 shape:** one pass fixing every self-reference; if A2 lands, the "Two version axes"
225
+ section collapses to one paragraph. Decide the `<reference>` path question once (test both forms in
226
+ an editor, document the winner, delete the loser).
227
+ - **Additive or breaking:** docs-only (bundled here because it must land *with* A2's reshape).
228
+ - **Effort:** S.
229
+
230
+ ---
231
+
232
+ ## B. Additive anytime (ranked)
233
+
234
+ ### B1. Auto-inject the SDK client scripts at serve time (kill the injection ceremony)
235
+
236
+ - **What/where:** every job-using plugin must remember
237
+ `inject_scripts(raw, job_tracker_script())`. Reality check across 9 plugins: **four different
238
+ idioms** — `inject_scripts` (4 plugins); the literal `raw.replace("<script>", ...)` footgun that
239
+ `ui_inject.py:16-22` was written to eliminate (sam3 `__init__.py:50-55`); a brittle 3-line-exact
240
+ source-string match (importer `:1691-1694`); wholesale script-block prepending (merger `:104`,
241
+ splitter `:157`). Plus one **dead** injection: exporter injects `alias_override_ui_script()` into a
242
+ `ui.html` that never references any alias function (`exporter/__init__.py:381-386`).
243
+ - **Proposal:** `build_plugin_app`'s `/ui` handler (`asgi_app.py:64-66`) — and the host's in-process
244
+ ui path — injects `JOB_TRACKER_JS` automatically. The client is already idempotent
245
+ (`if (window.PluginJobs) return;`, `job_tracker.py:33`), so existing plugins keep working and can
246
+ delete their injection lines at leisure. Alias/config UI injection becomes a manifest opt-in
247
+ (`[ui] inject = ["alias", "alias_override", "config_bar"]`) or stays manual — either way documented.
248
+ - **Effort:** S–M. Migration: none required; cleanup sweep optional.
249
+
250
+ ### B2. De-abstract `compute()`
251
+
252
+ - **What/where:** `contract.py:56-59` makes `compute()` abstract; 5 of 9 plugins implement it as a
253
+ dead stub (timm `:56` returns `{"status": "Use /api/plugins/timm/* endpoints"}`, etc.). The
254
+ `/compute` route also injects a magic `params.setdefault("url", "")` (`asgi_app.py:71`).
255
+ - **Proposal:** give it a default returning `{"error": "<id> does not implement compute()"}` —
256
+ mirroring `run_job`'s default — so `get_ui_fragment` is the only abstract. Drop the magic `url`
257
+ defaulting (let plugins read their own params). Deleting five stubs is optional cleanup.
258
+ - **Effort:** S. (The `url` defaulting change is technically behavior-breaking for any plugin relying
259
+ on the key existing — grep says none do.)
260
+
261
+ ### B3. Ship the missing batteries the plugins keep hand-rolling
262
+
263
+ Evidence-ranked by duplication count:
264
+
265
+ | Gap | Duplication evidence | Proposal |
266
+ |---|---|---|
267
+ | HTML-escape helper in JS | identical `_esc` in **8/8** ui.html files | ship in the injected client (`TlcPluginUi.esc`) and declare it |
268
+ | Plugin-scoped fetch | 8/8 re-derive `getConfig('compute_service_url') + '/api/plugins/<id>/...'`; timm/yolo/sam3/image-metrics each wrap it 2–3× with per-plugin timeouts; sam3 hardcodes a `localhost:5020` fallback (`sam3/ui.html:296`) | `PLUGIN_API.pluginFetch(path, opts)` — pre-scoped to the mounted plugin's route root, with a venv-aware default timeout (timm's comment at `ui.html:474-477` documents why 10s is wrong for cold-start workers) |
269
+ | Config CRUD routes | the same `GET/POST /configs`, `GET /configs/{id}`, `POST /configs/{id}/delete` quartet 4× (image_metrics, timm, sam3, yolo), with **three different** dataclass↔dict marshalling answers | `shared.config_routes.make_config_routes(store) -> list[BaseRouteHandler]` — pairs with the graduated `config_store`; also removes yolo's incentive to keep its `ProjectStore` clone (97 lines) |
270
+ | Alias-override lifecycle | identical `apply_alias_overrides → try → finally: restore_aliases` 4×, with **three different** param keys (`_alias_overrides` popped / gotten, `alias_overrides`) | `with alias_overrides(params) as ...:` context manager + one blessed param key |
271
+ | Duration/ETA/log-pane UI | duration formatter 4×, timing-strip renderer 3×, log-pane appender 4×, config-picker-with-localStorage ~100 lines × 3 (only image_metrics uses `_tlcPluginConfig`) | fold into the declared injected client (per A3's namespace) |
272
+ | Table identity | `getattr(table, "project_name", "") or ""` ×6 across timm/yolo/image_metrics | tiny `shared.tables.table_identity(table)` helper (or a `tlc` core ask) |
273
+ | Task detection | `timm/task_detection.py` ≈ `yolo/task_detection.py` (same 60 lines, different tail) | `shared.modality.detect_task(table, mapping)` |
274
+
275
+ - **Effort:** M total; each row is S. All additive.
276
+
277
+ ### B4. `PluginJobs` v2 — close the gap that makes GPU plugins hand-roll sockets
278
+
279
+ - **What/where:** all four plugins that need custom events (`timm`, `yolo`, `sam3`, `image_metrics`)
280
+ bypass `PluginJobs` entirely — hand-rolled `io(...)` connections with a copy-pasted options object
281
+ (`timm/ui.html:511-522`, `yolo:330-350`, `image_metrics:614-620`; sam3 connects to the **root**
282
+ namespace with no namespace at all, `sam3/ui.html:792-794`) and hand-rolled `POST .../run` +
283
+ `POST .../jobs/{id}/cancel`. The Python side achieved "a plugin never opens its own SocketIO
284
+ connection" (`job_context.py:97`); the JS side did not.
285
+ - **Proposal:** extend `PluginJobs.run(pluginId, params, handlers)` with `handlers.on = {eventName: fn}`
286
+ (custom `ctx.emit` events on the same managed socket, subscribed before `/run` fires — the ordering
287
+ footgun `PluginJobs.on` currently documents away). Add reconnection options matching what the
288
+ plugins standardized on. Then the four hand-rolled stacks collapse onto the client.
289
+ - Also: stop plugins re-stamping `job_id` into every `ctx.emit` payload (15 occurrences) — the sink
290
+ already `setdefault`s it (`job_context.py:115`); document that payloads arrive with `job_id`.
291
+ - **Effort:** M. Additive.
292
+
293
+ ### B5. d.ts truth pass (declare what exists, decide what doesn't)
294
+
295
+ - Add `allowErrorStatus` to `PluginFetchOptions` (implemented at `api-client.js:172-183`, used 13×
296
+ by the host's own pages) — and note `authFetch`'s auth-blocking and Response-destroying error
297
+ behavior (`api-client.js:132-140, 182-194`).
298
+ - Complete `TlcLocationApi` (`rowsSpanLocations`, `shortLabel` — `location.js:38-63`) or explicitly
299
+ tier them out; reconcile ambient `TlcApi` (declares 6 members, implementation exports 20, and the
300
+ d.ts doc-comment references `encodeObjectUrl`, which it never declares).
301
+ - `libs.cytoscape` is **unconditionally `null`** for plugins — the vendor file is loaded only on
302
+ `project_detail.html:15`, never on `plugin_host.html`. It's best-effort tier, so: either load it on
303
+ the plugin page or delete the member at 1.0. Also decide `TlcPptx` (a full deck-builder global on
304
+ the plugin page with no tier at all) — declare best-effort or hide.
305
+ - Declare the fragment-shape contract prose in the guide: `.plugin-hero h2` rewriting
306
+ (`plugin-loader.js:244-254`), `[data-provisioning]` lifecycle (`:267-301`), and `plugin-common.css`
307
+ class stability.
308
+ - **Effort:** S–M. Additive.
309
+
310
+ ### B6. Export the job-runner core to de-duplicate host↔worker terminal logic
311
+
312
+ - **What/where:** the terminal-event synthesis (`done/cancelled/error` + message format), GPU-reclaim
313
+ ordering, and the `job_update` reserved-name check exist **twice**: SDK `worker.py:158-171` /
314
+ `job_context.py:108-110` and host `job_manager.py:262-274, 83, 436-442` — byte-similar
315
+ reimplementations that must drift-proof by hand.
316
+ - **Proposal:** export `run_job_and_stream(plugin, job_id, params, state_dir, sink, cancel_event)`
317
+ from the SDK (the `_Job._run` body, made public); host mode calls it too. One implementation of the
318
+ terminal contract.
319
+ - **Effort:** M (host adopts). Additive.
320
+
321
+ ### B7. Docs drift batch (everything not already inside A6)
322
+
323
+ - README: base-deps claim (`README.md:46-47` says `3lc` is base), Status section ("0.1 line").
324
+ - `contract.py:8-9` manifest-location claim; delete the orphaned `_ICON_SVG` (`contract.py:32` —
325
+ guide says "no `_ICON_SVG` import anymore", nothing imports it).
326
+ - `__init__.py:32-33` `sdk_version` manifest claim (per A2/A4 decision).
327
+ - Guide: document undocumented-but-real manifest keys (A4 list) in the interim, fix the
328
+ `min_input_count = 0` falsy-default footgun host-side (`manifest.py:210` — explicit 0 is silently
329
+ replaced), correct the isolation-default row until A4 decides it.
330
+ - `frontend/API.md:294-302` stale "add location in the same PR" instruction (already done).
331
+ - **Effort:** S.
332
+
333
+ ---
334
+
335
+ ## C. Process & CI
336
+
337
+ ### C1. Fix the live pins — today, independent of 1.0
338
+
339
+ - Host + plugins umbrella: `3lc-compute-plugin-sdk[shared]>=0.1.0,<0.2.0` excludes 0.2.0
340
+ (`3lc-compute/pyproject.toml:24`, `3lc-compute-plugins/pyproject.toml:23`); resolved lock is 0.1.1,
341
+ so host-mode GPU reclaim, worker `/reclaim`, and `/health` skew-detection are all dead in practice.
342
+ Bump to `>=0.2.0,<0.3.0`, re-lock, verify the contract test.
343
+ - Frontend: decide the production `contractVersion` story (A2's compute-derived value is the fix; the
344
+ dev-group-only SDK dep is fine for the d.ts + test).
345
+ - **Effort:** S. Do this first — it's also the dry run for the 1.0 cross-repo bump.
346
+
347
+ ### C2. Mechanical additive-only enforcement (the known wishlist item — confirmed absent)
348
+
349
+ - CI job in this repo that diffs, against the last release tag: `tlc_plugin_sdk.__all__` + public
350
+ module symbols (a simple import-and-introspect script), the d.ts interface member sets (extend the
351
+ frontend's regex parser or use a real TS parser), and — once A4 lands — the manifest schema fields.
352
+ Removals/renames fail unless the version bump is major. This is what makes "additive-only" and the
353
+ post-1.0 freeze a property of the repo instead of a promise in the README.
354
+ - **Effort:** M.
355
+
356
+ ### C3. Deepen the frontend conformance test
357
+
358
+ - `frontend/test_plugin_contract.py` diffs **top-level `PluginApi` names only**. Confirmed blind
359
+ spots: nested `libs`/`context` shapes, `getConfig` key set, every referenced interface
360
+ (`TlcData`, `TlcLocationApi`, `TlcApi`, `PluginFetchOptions`, `PluginJobsApi`), signatures, and a
361
+ second `window.PLUGIN_API` assignment. Dropping `libs.io` — the one STABLE lib — would pass today.
362
+ Extend to nested object literals and the `getConfig` key union at minimum; `PluginJobsApi` needs a
363
+ gate on the SDK side (its implementation and declaration live in the same wheel — a unit test
364
+ comparing `JOB_TRACKER_JS`'s exported names to the d.ts is cheap).
365
+ - **Effort:** M.
366
+
367
+ ### C4. Test the contract itself in this repo
368
+
369
+ Current suite: 2 files (import-light + GPU reclaim), both good, neither about the contract. Add:
370
+ - `JobContext` event golden-shapes test (each method → exact wire dict; `job_id` stamping; the
371
+ reserved-name rejection) — this is the schema the host, frontend, and plugins all parse.
372
+ - Worker NDJSON protocol test (start → events → terminal; cancel; the `error` message format) —
373
+ doubles as the conformance suite the host can run against its in-process runner (B6).
374
+ - `inject_scripts` edge cases; `config_store` round-trip/migration; manifest schema round-trip
375
+ (post-A4).
376
+ - CI: add a second job with `--extra shared` so mypy actually checks `shared.*` against a real `tlc`
377
+ (today `tlc`/`PIL`/`torch` are `Any` in CI, which is how `on_status: Any` survives --strict).
378
+ - **Effort:** M.
379
+
380
+ ### C5. Write the release-train checklist
381
+
382
+ - The 0.2 pin miss (C1) happened because the bump dance spans four repos with no checklist. One page
383
+ in this repo: bump order (SDK tag → host pin+lock → plugins pin → frontend dev pin), the contract
384
+ test loop, and the editable-overlay verification recipe.
385
+ - **Effort:** S.
386
+
387
+ ### C6. State the plugin trust model explicitly
388
+
389
+ - Fragments execute with **full host origin** — `innerHTML` + script re-execution
390
+ (`plugin-loader.js:198, 257-261`), no iframe/shadow-DOM/CSP anywhere, `TlcAuth` (raw JWT) reachable.
391
+ With a catalog/install flow shipping (guide's "Publishing via a Catalog"), 1.0 must say out loud:
392
+ *installing a plugin is executing trusted code in the Hub's origin* — in the guide and the catalog
393
+ docs. Actual sandboxing is a large frontend project; the honest documented stance is the 1.0
394
+ deliverable, sandboxing a post-1.0 decision.
395
+ - **Effort:** S (doc) / L (sandboxing, out of scope).
396
+
397
+ ---
398
+
399
+ ## Suggested one-week sequencing
400
+
401
+ Cross-repo sets are ordered so the contract test + editable overlays keep SDK, host, and frontend
402
+ green at every step. Breaking work lands in two coordinated waves, not a trickle.
403
+
404
+ **Day 1 — make the current state true (no breaks).**
405
+ C1 pin fixes across host + plugins umbrella (re-lock, contract test). B7 + A6 docs/d.ts truth batch.
406
+ B5 d.ts truth pass. Start C2 freeze-check script (it gates the rest of the week).
407
+
408
+ **Day 2–3 — breaking wave 1: the reporting/version core (SDK-led).**
409
+ A1 (`ctx.result`/error field) + A2 (version-axis collapse) + A5 prunes/underscorings + B2
410
+ (de-abstract `compute`) land in the SDK as `1.0.0rc1`. Same-day coordinated PRs: host
411
+ (`job_manager` translation + pin `>=1.0.0rc1`), frontend (error field in the panel,
412
+ compute-derived `contractVersion`), plugins (merger/importer `result` rename; timm/yolo/sam3 adopt
413
+ `ctx.result` + delete `job_failed` hand-rolls). Overlay-verify a GPU job end-to-end.
414
+
415
+ **Day 3–4 — breaking wave 2: the browser surface (frontend-led).**
416
+ A3: frontend implements `PLUGIN_API.pickers.table` + declares CancelJob/toast surface; SDK renames
417
+ `_tlc*` under the declared namespace (aliases kept for the sweep); B1 auto-injection + B4
418
+ `PluginJobs` v2 ship alongside. Then the mechanical plugin sweep (7 plugins × pickers, 5 × `_tlc*`,
419
+ 4 × hand-rolled sockets → `PluginJobs`), deleting the four injection idioms and the dead exporter
420
+ injection as you pass through.
421
+
422
+ **Day 4–5 — the manifest + batteries.**
423
+ A4 `tlc_plugin_sdk.manifest` module; host adopts it (the week's biggest single PR — can trail into
424
+ the next week without blocking the tag if rc-gated). B3 config-route factory + alias context manager
425
+ + `pluginFetch`; delete yolo's store clone and the CRUD quartets in the same sweep.
426
+
427
+ **Day 5 — lock it.**
428
+ C3 contract-test deepening + C4 golden-shape tests written against the *new* surface (cheaper than
429
+ writing them twice). C2 freeze-check turned on against `v1.0.0`. C5 checklist + C6 trust-model
430
+ paragraph. Tag `1.0.0`.
431
+
432
+ **Explicitly deferred (fine post-1.0, all additive):** run picker, sandboxing, `modality`
433
+ graduation into `tlc` core (the porting notes in `shared/images.py:13-37` are the plan of record),
434
+ B6 job-runner unification (safe any time).
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ _Nothing yet._
11
+
12
+ ## [0.3.2] - 2026-08-31
13
+
14
+ ### Fixed
15
+ - Data-source browse fields with a comma-separated `accept` (e.g. `*.yaml,*.yml`, `*.pt,*.pth`)
16
+ now list matching files; previously they showed none. `/browse` was matching the whole
17
+ `accept` string as one literal glob pattern instead of any pattern in the list.
18
+ ([#28](https://github.com/3lc-ai/3lc-compute-plugin-sdk/pull/28))
19
+
10
20
  ## [0.3.1] - 2026-08-27
11
21
 
12
22
  A patch on the 0.3 line, additive and pin-compatible (`>=0.3.0,<0.4.0` resolves it). The SDK now
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 3lc-compute-plugin-sdk
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Public Python SDK for building 3LC compute-service plugins.
5
5
  Project-URL: Homepage, https://github.com/3lc-ai/3lc-compute-plugin-sdk
6
6
  Project-URL: Repository, https://github.com/3lc-ai/3lc-compute-plugin-sdk
@@ -642,143 +642,194 @@ needs to render a good, navigation-proof view.
642
642
 
643
643
  ---
644
644
 
645
- ## CSS Conventions
645
+ ## Styling & UI Conventions
646
646
 
647
- Plugin UI fragments inherit all CSS from `main.css` (via `base.html`) and `plugin-common.css` (via `plugin_host.html`). Use these shared patterns:
647
+ A plugin's `ui.html` gets the host's entire design system for free — and the host counts on it using that system rather than reinventing it. This section is the class catalog and the rules that keep every plugin looking like one product, in every theme.
648
648
 
649
- | Class | Purpose |
650
- |---|---|
651
- | `.plugin-page` | Max-width 1200px container |
652
- | `.plugin-page-narrow` | Max-width 700px container |
653
- | `.card` | Standard card with shadow |
654
- | `.btn`, `.btn-sm`, `.btn-primary` | Buttons |
655
- | `.plugin-hero` | Hero section with accent background |
656
- | `.plugin-form-grid` | 2-column responsive form layout |
657
- | `.plugin-form-grid-3` | 3-column responsive form layout |
658
- | `.plugin-param-group` | Titled section within a card |
659
- | `.plugin-action-bar` | Submit/cancel row with spinner |
660
- | `.plugin-metric-card` | Metric display card |
661
- | `.plugin-progress-wrap` + `.plugin-progress-bar` | Progress bar |
662
- | `.plugin-log-area` | Monospace log output |
663
- | `.plugin-two-col` | Sidebar + main layout |
664
- | `.plugin-three-col` | Sidebar + main + preview layout |
665
- | `.plugin-config-item` | Selectable list item |
666
-
667
- Use CSS variables for colors — never hardcode:
649
+ ### Why the shared classes are available (no import, no build step)
650
+
651
+ The frontend mounts a plugin by injecting its fragment straight into the host page — `containerEl.innerHTML = fragment` (see [How a fragment reaches the browser](#how-a-fragment-reaches-the-browser)). There is **no iframe and no shadow DOM**, so the fragment lands inside the host document and inherits its two stylesheets:
652
+
653
+ - `main.css` — the base design system (forms, buttons, cards, spinners, theme tokens)
654
+ - `plugin-common.css` — the plugin page scaffolding (hero, workflow, metric cards, viewer)
655
+
656
+ Write `class="btn btn-primary"` or `class="form-control"` and you get the fully themed, dark-mode-correct component with zero CSS of your own. The flip side: your fragment shares the global namespace. Prefix any bespoke class with your plugin id (`.myplugin-…`) and never restyle a shared class globally — you'd repaint every other plugin.
657
+
658
+ > **Only `main.css` and `plugin-common.css` are inherited.** Styles defined inside a host *template* — for example the `.pbadge` badges in `settings/plugins.html` — live in that page's own `<style>` block and are **not** available to a mounted fragment. Don't reach for them.
659
+
660
+ ### Theme tokens — never hardcode a color
661
+
662
+ Every color, in both light and dark mode, comes from a CSS variable. Hardcoding a hex value produces a control that looks wrong the moment the user switches theme.
668
663
 
669
664
  ```css
670
- color: var(--text); /* Primary text */
671
- color: var(--text-muted); /* Secondary text */
672
- background: var(--bg); /* Background */
673
- background: var(--bg-card); /* Card background */
674
- border-color: var(--border); /* Border */
675
- color: var(--accent); /* Accent color (#2a4a61) */
665
+ color: var(--text); /* primary text */
666
+ color: var(--text-secondary); /* secondary text */
667
+ color: var(--text-muted); /* de-emphasized / helper text */
668
+ color: var(--accent); /* accent (light mode #2a4a61) */
669
+ background: var(--bg); /* recessed page ground */
670
+ background: var(--bg-card); /* ELEVATED surface (raised cards) */
671
+ border-color: var(--border);
672
+ border-color: var(--border-light);
676
673
  ```
677
674
 
678
- ---
675
+ `--bg` and `--bg-card` are the pair that matters most: `--bg-card` is an *elevated* surface, `--bg` the *recessed* page ground. In dark mode they are visibly different depths, and swapping them inverts every affordance on the page (see **Cards**, below). Also available: `--accent-light` (tinted wash), `--error` / `--danger`, and the `--badge-<color>-bg` / `--badge-<color>-text` pairs. Never use the legacy teal `#5a9aad` — `var(--accent)` is the accent.
679
676
 
680
- ## Existing Plugins Reference
677
+ ### Forms
681
678
 
682
- | Plugin ID | display_mode | Section | GPU | Description |
683
- |---|---|---|---|---|
684
- | `importer` | sidebar | Data Ops | — | Import data (YOLO, COCO, Folder, CSV, Unlabeled) |
685
- | `exporter` | sidebar | Data Ops | — | Export tables to CSV, XLSX, YOLO, COCO |
686
- | `splitter` | sidebar | Data Ops | — | Split tables into train/val/test sets |
687
- | `merger` | sidebar | Data Ops | — | Merge 2 tables by column join |
688
- | `image-metrics` | sidebar | Data Ops | GPU | Image quality metrics (brightness, sharpness, noise, etc.) |
689
- | `yolo` | sidebar | AI Tools | GPU | Ultralytics YOLO training + metrics collection |
690
- | `sam3` | sidebar | AI Tools | GPU | Auto-labeling with SAM3/GroundingDINO |
691
- | `timm` | sidebar | AI Tools | GPU | Image classification with timm models |
692
- | `table-statistics` | hidden | Analysis | — | Per-column stats & image thumbnails (API-only) |
693
- | `run-insights` | action | Analysis | — | Run statistics, health scores, per-class metrics |
694
- | `table-insights` | action | Analysis | — | GT-only data quality analysis (bbox sizes, balance, etc.) |
679
+ Wrap each field in `.form-group`; label it with `.form-label`; use `.form-control` for text inputs and `<textarea>`, `.form-select` for `<select>`; add `.form-help` for a line of helper text. For multi-field layouts, `.plugin-form-grid` (2-col) and `.plugin-form-grid-3` (3-col) are responsive grids.
695
680
 
696
- ---
681
+ ```html
682
+ <div class="form-group">
683
+ <label class="form-label required">Dataset name</label>
684
+ <input type="text" class="form-control" required placeholder="my-dataset">
685
+ <div class="form-help">Lowercase, no spaces.</div>
686
+ </div>
687
+ ```
688
+
689
+ - `.form-label.required` appends a red `*`.
690
+ - Placeholders render dimmed + italic automatically.
691
+ - **Required-field cue — worth adopting:** put both `required` **and** a `placeholder` on a required text field. While the field is empty (its placeholder still showing) the host paints it with a muted red wash (`:required:placeholder-shown`), which clears the instant the user types. A free "you still need to fill this in" signal — no JS.
692
+
693
+ ### Buttons
694
+
695
+ Use `.btn` plus one variant. Never define your own button styles.
697
696
 
698
- ## UI Consistency Guide
697
+ | Class | Use |
698
+ |---|---|
699
+ | `.btn` | base — required on every button |
700
+ | `.btn-primary` | the one main action (Run, Import) |
701
+ | `.btn-secondary` | secondary action |
702
+ | `.btn-ghost` | low-emphasis / borderless |
703
+ | `.btn-danger` | destructive |
704
+ | `.btn-plugin` | accent-styled plugin action |
705
+ | `.btn-sm` / `.btn-lg` | size modifiers, combine with the above |
706
+
707
+ ### Page scaffolding (`plugin-common.css`)
699
708
 
700
- All plugin UIs should look like they belong to the same product. Follow these patterns
701
- from the existing plugins (SAM3, YOLO, timm are the reference implementations).
709
+ | Class | Purpose |
710
+ |---|---|
711
+ | `.plugin-page` | max-width page container — wrap the whole fragment in it |
712
+ | `.plugin-hero` | intro banner (accent-tinted); put `<h2>` + `<p>` inside |
713
+ | `.plugin-hero-badge` | informational "what this does" tile — flat, recessed, **non-interactive** |
714
+ | `.plugin-workflow` / `.plugin-workflow-step` | numbered step strip (`.num` child is the circle) |
715
+ | `.plugin-param-group` / `.plugin-param-group-label` | titled section inside a card |
716
+ | `.plugin-form-grid` / `.plugin-form-grid-3` | 2- and 3-column responsive form layouts |
717
+ | `.plugin-action-bar` | submit/cancel row |
718
+ | `.plugin-config-item` | selectable list item |
719
+ | `.plugin-metric-card` (+ `-label` / `-value`) | scalar metric tile |
720
+ | `.plugin-progress-wrap` + `.plugin-progress-bar` | progress bar |
721
+ | `.plugin-log-area` | monospace log output |
722
+ | `.plugin-two-col` / `.plugin-three-col` | sidebar + main (+ preview) layouts |
723
+ | `.plugin-viewer-card` (+ `-header` / `-toolbar` / `-viewport` / `-footer`) | image viewer shell |
724
+ | `.spinner` / `.spinner-lg` | loading spinner |
702
725
 
703
- ### Page Structure
726
+ ### Cards: interactive vs. informational — the one distinction to get right
704
727
 
705
- Every sidebar plugin UI should follow this structure:
728
+ Two kinds of tile look superficially similar but must read differently:
729
+
730
+ - **Interactive / selectable** — something the user clicks or picks (a config item, a selectable tile). These sit on the **raised** surface: `--bg-card` + a drop shadow, with a `.selected` / `.active` state. The generic `.card` / `.card-header` / `.card-title` / `.card-body` panel is the standard raised container for config forms.
731
+ - **Informational** — pure display, not clickable (the hero "what this does" badges). These stay **flat and recessed**: use `.plugin-hero-badge`, which the host styles on `--bg` with **no shadow**. That flatness is exactly what marks it as "not a button".
732
+
733
+ Get these backwards and a static badge invites a click it won't answer. The mistake is nearly invisible in light mode and obvious in dark mode, where `--bg-card` is a distinctly elevated surface — so **verify your plugin in dark mode.**
734
+
735
+ > **Don't inline surface styles.** `.plugin-hero-badge` carries its whole look, so write `class="plugin-hero-badge"` and nothing else. Several plugins once inlined `style="background:var(--bg-card);box-shadow:…"` on it, and because inline styles outrank the host stylesheet, those badges kept the raised, clickable look the class was written to remove — the exact dark-mode bug above. Keep background, elevation, and color out of your `style=` attributes and let the class do the work. (`.plugin-hero-badge-card` is a deprecated alias kept only for backward compatibility — prefer `.plugin-hero-badge` alone.)
736
+
737
+ ### Page structure
738
+
739
+ A sidebar plugin UI follows this shape:
706
740
 
707
741
  ```html
708
- <style>/* Plugin-specific styles only — use shared classes for everything else */</style>
742
+ <style>
743
+ /* Plugin-specific rules only — layout for your own elements. Everything
744
+ visual (surface, color, elevation) comes from the shared classes. */
745
+ .myplugin-badges { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
746
+ </style>
709
747
 
710
- <div class="plugin-page-narrow"> <!-- Centered 700px container -->
711
- <div class="plugin-hero"> <!-- Accent-tinted header -->
748
+ <div class="plugin-page">
749
+ <div class="plugin-hero">
712
750
  <h2>🎯 Plugin Name</h2>
713
751
  <p>Short description of what this plugin does.</p>
714
752
 
715
- <!-- Feature badges — 3 items in a grid -->
716
- <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:12px">
717
- <div class="plugin-hero-badge">Feature 1</div>
718
- <div class="plugin-hero-badge">Feature 2</div>
719
- <div class="plugin-hero-badge">Feature 3</div>
753
+ <!-- Informational badges — flat, non-interactive -->
754
+ <div class="myplugin-badges">
755
+ <div class="plugin-hero-badge">Fast</div>
756
+ <div class="plugin-hero-badge">No config</div>
757
+ <div class="plugin-hero-badge">Exports CSV</div>
720
758
  </div>
721
759
 
722
- <!-- Workflow steps (optional) -->
760
+ <!-- Numbered workflow (optional) -->
723
761
  <div class="plugin-workflow">
724
- <span class="plugin-workflow-step active">1. Configure</span>
725
- <span class="plugin-workflow-arrow">→</span>
726
- <span class="plugin-workflow-step">2. Run</span>
727
- <span class="plugin-workflow-arrow">→</span>
728
- <span class="plugin-workflow-step">3. Results</span>
729
- </div>
730
- </div>
731
-
732
- <!-- Config bar (for plugins with saved configs) -->
733
- <div class="card" style="margin-bottom:12px;padding:10px 14px">
734
- <div style="display:flex;align-items:center;gap:8px">
735
- <select id="config-select">...</select>
736
- <button class="btn btn-sm">Load</button>
737
- <button class="btn btn-sm btn-primary">Save</button>
762
+ <div class="plugin-workflow-step"><span class="num">1</span> Configure</div>
763
+ <div class="plugin-workflow-step"><span class="num">2</span> Run</div>
764
+ <div class="plugin-workflow-step"><span class="num">3</span> Results</div>
738
765
  </div>
739
766
  </div>
740
767
 
741
768
  <!-- Main form -->
742
- <div class="card" style="padding:16px">
743
- <div class="plugin-param-group">
744
- <div class="plugin-param-group-title">Section Title</div>
745
- <div class="plugin-form-grid">
746
- <!-- Form fields -->
769
+ <div class="card">
770
+ <div class="card-header"><span class="card-title">Configuration</span></div>
771
+ <div class="card-body">
772
+ <div class="plugin-param-group">
773
+ <div class="plugin-param-group-label">Source</div>
774
+ <div class="form-group">
775
+ <label class="form-label required">Table name</label>
776
+ <input id="myplugin-name" type="text" class="form-control" required placeholder="my-table">
777
+ <div class="form-help">The table to analyze.</div>
778
+ </div>
779
+ </div>
780
+ <div class="plugin-action-bar">
781
+ <button id="myplugin-run" class="btn btn-primary">Run</button>
782
+ <span class="spinner" style="display:none"></span>
747
783
  </div>
748
- </div>
749
-
750
- <div class="plugin-action-bar">
751
- <button class="btn btn-primary">Run</button>
752
- <span class="spinner" style="display:none"></span>
753
784
  </div>
754
785
  </div>
755
786
 
756
787
  <!-- Results area (appears after execution) -->
757
- <div id="results" class="card" style="display:none;padding:16px;margin-top:12px">
758
- </div>
788
+ <div id="myplugin-results" class="card" style="display:none"></div>
759
789
  </div>
760
790
  ```
761
791
 
762
- ### Visual Consistency Rules
792
+ ### Visual rules
793
+
794
+ 1. **Container** — wrap the fragment in `.plugin-page`.
795
+ 2. **Hero** — `.plugin-hero` with icon + title + description, optionally three `.plugin-hero-badge` tiles.
796
+ 3. **Accent** — `var(--accent)`; never the legacy teal `#5a9aad`.
797
+ 4. **Cards** — group content in `.card`; raised for interactive, flat `.plugin-hero-badge` for informational.
798
+ 5. **Forms** — `.form-group` + `.form-control` / `.form-select`, laid out with `.plugin-form-grid` where useful.
799
+ 6. **Buttons** — `.btn` + one variant; the primary action gets `.btn-primary`.
800
+ 7. **Spinners** — `<span class="spinner"></span>` (defined in `main.css`).
801
+ 8. **Toasts** — `PLUGIN_API.showToast(msg, 'success'|'error'|'info')` for feedback.
802
+ 9. **Dark mode** — must work; only `var(--*)` colors, and confirm the raised/flat card distinction actually reads.
803
+
804
+ ### What NOT to do
805
+
806
+ - Don't hardcode colors — always a `var(--…)` token.
807
+ - Don't inline surface/elevation styles (`background`, `box-shadow`) on shared classes — let the class do it.
808
+ - Don't define custom button, input, or card styles — the shared classes exist.
809
+ - Don't restyle a shared class globally, and prefix your own classes with the plugin id.
810
+ - Don't reach for host-template-only classes like `.pbadge` — they aren't inherited.
811
+ - Don't create custom modal/dialog implementations — use `.card` with show/hide.
812
+ - Don't use `position: fixed` — it breaks the plugin container.
813
+ - Don't add font sizes below 10px or custom scrollbars.
814
+ - Don't skip a dark-mode check.
763
815
 
764
- 1. **Container**: Use `.plugin-page-narrow` (700px) for form-based plugins, `.plugin-page` (1200px) for data-heavy plugins like run-insights.
765
- 2. **Hero section**: Always include `.plugin-hero` with icon + title + description + 3 feature badges.
766
- 3. **Color**: Use `var(--accent)` (#2a4a61) as the accent. Never use teal (#5a9aad).
767
- 4. **Cards**: Wrap all content sections in `.card`. Use `margin-bottom:12px` between cards.
768
- 5. **Forms**: Use `.plugin-form-grid` (2-col) or `.plugin-form-grid-3` (3-col) for form layouts.
769
- 6. **Buttons**: Primary actions get `.btn-primary`. Secondary actions get plain `.btn`.
770
- 7. **Spinners**: Use `<span class="spinner"></span>` (defined in main.css).
771
- 8. **Toasts**: Use `PLUGIN_API.showToast(msg, 'success'|'error'|'info')` for feedback.
772
- 9. **Config bar**: Plugins with saved configurations (YOLO, SAM3, timm) should have a config select/save/delete bar below the hero.
773
- 10. **Dark mode**: All CSS must work in dark mode. Use `var(--text)`, `var(--bg)`, `var(--border)`, etc. Never hardcode colors.
816
+ ---
774
817
 
775
- ### What NOT to Do
818
+ ## Existing Plugins Reference
776
819
 
777
- - Don't define custom button styles — use `.btn`, `.btn-sm`, `.btn-primary`.
778
- - Don't use inline colors — always use CSS variables.
779
- - Don't create custom modal/dialog implementations — use `.card` with show/hide.
780
- - Don't add custom scrollbar styles or font sizes below 10px.
781
- - Don't use `position: fixed` — it breaks the plugin container layout.
820
+ | Plugin ID | display_mode | Section | GPU | Description |
821
+ |---|---|---|---|---|
822
+ | `importer` | sidebar | Data Ops | — | Import data (YOLO, COCO, Folder, CSV, Unlabeled) |
823
+ | `exporter` | sidebar | Data Ops | — | Export tables to CSV, XLSX, YOLO, COCO |
824
+ | `splitter` | sidebar | Data Ops | — | Split tables into train/val/test sets |
825
+ | `merger` | sidebar | Data Ops | — | Merge 2 tables by column join |
826
+ | `image-metrics` | sidebar | Data Ops | GPU | Image quality metrics (brightness, sharpness, noise, etc.) |
827
+ | `yolo` | sidebar | AI Tools | GPU | Ultralytics YOLO training + metrics collection |
828
+ | `sam3` | sidebar | AI Tools | GPU | Auto-labeling with SAM3/GroundingDINO |
829
+ | `timm` | sidebar | AI Tools | GPU | Image classification with timm models |
830
+ | `table-statistics` | hidden | Analysis | — | Per-column stats & image thumbnails (API-only) |
831
+ | `run-insights` | action | Analysis | — | Run statistics, health scores, per-class metrics |
832
+ | `table-insights` | action | Analysis | — | GT-only data quality analysis (bbox sizes, balance, etc.) |
782
833
 
783
834
  ---
784
835
 
@@ -964,7 +1015,7 @@ If your entry doesn't show up after that, check that the tester's compute-servic
964
1015
  - [ ] Job progress follows the generic schema (no plugin-specific fields in frontend)
965
1016
  - [ ] If GPU-bound: `requires_gpu = true` in `[runtime]`; long work is `run_job(ctx)` — never grab a queue
966
1017
  - [ ] If creating tables from images: registers URL aliases via `tlc_plugin_sdk/shared/aliases.py` + `tlc_plugin_sdk/shared/alias_ui.py` (inject with `inject_scripts()`)
967
- - [ ] UI follows the page structure from "UI Consistency Guide" above
1018
+ - [ ] UI follows the page structure and card conventions from "Styling & UI Conventions" above
968
1019
  - [ ] Hero section with icon, title, description, and 3 feature badges
969
1020
  - [ ] Config bar if plugin has saved configurations
970
1021
  - [ ] Dark mode works correctly (no hardcoded colors)
@@ -7,7 +7,7 @@ name = "3lc-compute-plugin-sdk"
7
7
  # The plugin-contract (ABI) version. This IS tlc_plugin_sdk.SDK_CONTRACT_VERSION
8
8
  # (read back via importlib.metadata) — one source of truth. SemVer MAJOR.MINOR.PATCH;
9
9
  # the 0.x line is additive-only (see README → Status).
10
- version = "0.3.1"
10
+ version = "0.3.2"
11
11
  description = "Public Python SDK for building 3LC compute-service plugins."
12
12
  requires-python = ">=3.10"
13
13
  readme = "README.md"
@@ -176,7 +176,9 @@ def data_source_route_handlers() -> list[BaseRouteHandler]:
176
176
 
177
177
  Query params:
178
178
  path: Directory to list. Empty or ``~`` opens the first allowed root.
179
- glob: Optional glob pattern to filter files (e.g. ``*.yaml``).
179
+ glob: Optional glob pattern(s) to filter files, comma-separated for more than
180
+ one (e.g. ``*.yaml`` or ``*.yaml,*.yml`` — matches the widget's ``accept``
181
+ config verbatim). A file is kept if it matches *any* pattern in the list.
180
182
  show_hidden: Whether to include dotfiles (default ``false``).
181
183
  purpose: ``input`` (default) or ``output``. In ``output`` mode the response
182
184
  additionally reports writability (see below); ``input`` mode skips that
@@ -196,7 +198,9 @@ def data_source_route_handlers() -> list[BaseRouteHandler]:
196
198
 
197
199
  roots = allowed_browse_roots()
198
200
  raw_path = request.query_params.get("path", "").strip()
199
- glob_pattern = request.query_params.get("glob", "")
201
+ # Comma-separated, matching the widget's "accept" config (e.g. "*.yaml,*.yml") —
202
+ # a bare fnmatch against the joined string would only match a literal comma.
203
+ glob_patterns = [g.strip() for g in request.query_params.get("glob", "").split(",") if g.strip()]
200
204
  show_hidden = request.query_params.get("show_hidden", "false").lower() == "true"
201
205
  purpose = request.query_params.get("purpose", "input")
202
206
  want_writable = purpose == "output"
@@ -224,7 +228,11 @@ def data_source_route_handlers() -> list[BaseRouteHandler]:
224
228
  for entry in sorted(os.scandir(expanded), key=lambda e: (not e.is_dir(), e.name.lower())):
225
229
  if not show_hidden and entry.name.startswith("."):
226
230
  continue
227
- if glob_pattern and not entry.is_dir() and not fnmatch.fnmatch(entry.name, glob_pattern):
231
+ if (
232
+ glob_patterns
233
+ and not entry.is_dir()
234
+ and not any(fnmatch.fnmatch(entry.name, pattern) for pattern in glob_patterns)
235
+ ):
228
236
  continue
229
237
  try:
230
238
  stat = entry.stat()
@@ -100,6 +100,14 @@ class TestBrowseConfinement:
100
100
  body = _browse(client, path=str(root / "sub"), glob="*.yaml")
101
101
  assert [e["name"] for e in body["entries"]] == ["table.yaml"]
102
102
 
103
+ def test_glob_accepts_comma_separated_patterns(self, client: TestClient[Litestar], root: Path) -> None:
104
+ # Matches the widget's "accept" config verbatim (e.g. "*.yaml,*.yml") — a file
105
+ # is kept if it matches ANY pattern in the list, not the joined string literally.
106
+ body = _browse(client, path=str(root / "sub"), glob="*.yaml,*.yml")
107
+ assert [e["name"] for e in body["entries"]] == ["table.yaml"]
108
+ body = _browse(client, path=str(root), glob="*.csv,*.json")
109
+ assert [e["name"] for e in body["entries"]] == ["sub", "data.csv"]
110
+
103
111
 
104
112
  class TestBrowseAccessibility:
105
113
  """Each dir entry is tagged with whether it can actually be opened for listing."""
@@ -84,7 +84,7 @@ pandas = [
84
84
 
85
85
  [[package]]
86
86
  name = "3lc-compute-plugin-sdk"
87
- version = "0.3.1"
87
+ version = "0.3.2"
88
88
  source = { editable = "." }
89
89
  dependencies = [
90
90
  { name = "3lc", extra = ["pandas"] },