Skip to content

Converge the empty-state defaults onto DataEmptyState — four packages now answer "is it broken or is it young" four different ways, and two rulings just fixed two of them locally #7132

Description

@os-warren

Filed by the domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM) as the deliberately deferred convergence half of two maintainer rulings that landed within ten minutes of each other:

Both rest on the same principle — objectstack#13848: apps are simplified business implementations; uniform behaviour belongs to the platform, and per-widget/per-section authored copy is the per-app tax being ruled against.

⭐ Why this card exists rather than having been done inside one of those PRs

DataEmptyState (packages/components/src/custom/view-states.tsx) is already consumed by four packages: plugin-list, plugin-kanban, plugin-dashboard and plugin-detail. Its title default is literally 'No data'.

⇒ Changing its defaults would have been a smaller diff that repaired both rulings at once, and #7124's implementer identified exactly that and declined it on purpose, consuming the primitive unchanged and recording the reason in the new component's file header.

That was the correct call, and it is the reason this card is filed rather than lost: the two rulings were dispatched in parallel to two agents who could not see each other's reasoning, and both dispatch orders explicitly forbade designing a cross-surface abstraction, requiring stop-and-report instead. ⚠️ A shared abstraction designed simultaneously by two agents is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant.

Both local fixes have now landed. The precondition for doing it properly, once, is satisfied.

The measured spread — four packages, four answers

surface what an empty result renders state
plugin-detail record sections heading + labels + em-dash placeholders, heuristic-owned ✅ just ruled and fixed (#7064)
plugin-dashboard dataset/table/pivot widgets WidgetEmptyState — role="status", title + explanation, names the data source ✅ just ruled and fixed (#7063)
plugin-list, plugin-kanban DataEmptyState defaults — terse, unexamined against either ruling ⚠️ unmeasured
plugin-charts ObjectChart ⛔ no empty branch at all — a bare chart frame 🟡 filed as #7130

⇒ The two rulings did not establish a platform-wide answer; they established it on two surfaces. This card is about whether the other two should follow, and whether the shared primitive should carry the answer.

⛔ What this card must NOT do

Suggested first step — measurement, not refactor

Render all four surfaces' empty states side by side and judge them against the two rulings' own stated properties:

  1. distinguishable from a load failure at a glance (role="status" vs role="alert" is the structural test fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets #7124 used);
  2. self-describing without authored copy.

Then answer: does the shared primitive carry those properties, or does each surface legitimately differ? ⭐ "They legitimately differ, here is the evidence" is a fully valid outcome — close it measured-and-declined.

Sequencing

⚠️ packages/components is consumed by all four packages, so a defaults change there is broad-blast. Check the packages/app-shell PR backlog before starting.

Refs: #7063 · #7064 · PR #7123 · PR #7124 · #7130 (ObjectChart) · #7125 (dead keys from #7124) · objectstack#13848 · hotcrm#1212 · hotcrm#1247.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 1, 2026
  2. self-assigned this
    on Sep 1, 2026
  3. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    🔒 Claimed — domain:ui execution seat (objectui)

    • Session: session_012wwHa4aaFybxXrfmfHioDM · Branch: claude/issue-7132-empty-state-convergence
    • Build tier: opus (⚠️ Fable 5 quota exhausted; the exemption lowers the build tier only)

    ✅ Unblocked — and by the mechanism this card was written to protect

    This card exists because #7124's implementer identified changing DataEmptyState's defaults as a smaller fix spanning both rulings, and declined it, because two agents designing one cross-surface abstraction in parallel is worse than two honest local fixes. Both local fixes have since landed (eeb6c2f2b, 220c18d05).

    ⭐ The fence held a second time, unprompted. #7130 (ObjectChart) was dispatched with the same instruction — if the answer is "promote the seam to a shared package", stop and report here. Its implementer measured four package-boundary options and reported promoted_to_7132: false, taking the one that changes nothing structural: consume DataEmptyState, which was already shared and already a declared dependency. ⇒ Nothing has been pre-decided for this card by either sibling.

    ⚠️ It is now FIVE call sites, not four

    PR #7139 (#7130) is in flight and adds ObjectChart as a fifth consumer of DataEmptyState, deliberately so that it "collapses with the other four when #7132 converges the defaults." ⛔ Do not treat that as a decision already taken — it is a call site placed to be convergeable, not an argument that convergence is right.

    Measure on main, and account for #7139 landing during the work. ⛔ Do not edit packages/plugin-charts — that is its surface.

    The fences from the card, restated as binding

    ⚠️ One datum that already argues against naive hoisting: #7124 measured that metric/KPI widgets deliberately render 0 over an empty dataset rather than an empty state, because a KPI reading "no data" reads as broken. DatasetWidget.tsx:726-728 says so in the code. At least one consumer family legitimately wants different behaviour. Whether that generalises is what you are measuring.


    Generated by Claude Code

  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 7132,
      "status": "done",
      "branch": "claude/issue-7132-empty-state-convergence",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7144",
      "premise_still_valid": true,
      "summary": "MEASURED FIRST, then converged. Rendered all surfaces and read role/aria-live/data-slot/text-node-count/text directly at 2b3964aff. Result: DataEmptyState carried NO role while its own siblings in the same file did (DataLoadingState=status, DataErrorState=alert). Three surfaces had each hand-typed role=status at their own call site (kanban, dashboard #7063, ObjectChart PR #7139) while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently had none. So they do NOT legitimately differ: it is one missing platform default bought retail three times, the objectstack#13848 tax at package level. Gave DataEmptyState a role=status DEFAULT (spread from props, so call sites keep the last word). Property 2 (self-describing without authored copy) is measured-and-DECLINED: every shipped surface already supplies its own copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component with no translation hook. The measurement also found a real defect: plugin-list renders its LOAD FAILURE through DataEmptyState, so a 403 and an empty list were the same node shape, and a naive default would have announced an outage as a routine status. That call site now declares role=alert. Both ruled surfaces are structurally inert: #7064's fix lives in record-details.tsx which does not consume the primitive at all, and #7063 passes role=status explicitly.",
      "tests": "ABLATION, direction predicted before running: removing the role=status default reddens the two DEFAULT arms plus the two ListView arms naming status, leaving the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green. Mutation proven on disk BEFORE the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty. Result: 'Tests 4 failed | 4 passed (8)' -- exactly the four predicted arms, passing count asserted so a collapsed suite could not masquerade as the red. Restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEAD AND git diff --cached both empty. No rebuild needed for the ablation and that is verified, not assumed: root vitest.config.mts aliases every @object-ui/* specifier to packages/*/src, so a src mutation is what the run loads. Suites at final commit ff43f39f7: two new pins 'Test Files 2 passed (2) / Tests 8 passed (8)'; five DIRECT consumer packages 'Test Files 504 passed (504) / Tests 4780 passed (4780)'; eight INDIRECT packages (app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-view) 'Test Files 1003 passed (1003) / Tests 10939 passed | 1 skipped (10940)'. RECORDER CONTROL: the measurement harness asserted its own row count, that no row was ELEMENT-NOT-FOUND, and that the bare primitive read exactly 'No data' -- it fired on the first run and caught my own miscount rather than printing a plausible table; console output turned out to be swallowed by the vitest config, so the recorder was switched to a file with a read-back assertion.",
      "mcp_calls": "11",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #7142: ActivityTimeline's empty title is a hardcoded English literal (title=\"No activity recorded\") while its sibling RecordActivityTimeline uses t('detail.noActivity') -- stays English in all ten packs",
        "filed as #7143: [finding] ListView renders its load FAILURE through DataEmptyState while DataErrorState sits unused beside it; #7144 fixes the role collision but not the structural misuse, which needs its own visual review because DataErrorState hardcodes its icon and has no icon/showIcon props"
      ],
      "consumer_set_rederived_with_control": "YES, and the PM count was wrong. SIX call sites in FOUR packages on main, not four: plugin-list has TWO (empty at ListView.tsx:3675 AND the load-error panel at :3584), plugin-detail has TWO (ActivityTimeline.tsx:138, RecordActivityTimeline.tsx:415), plus plugin-kanban KanbanImpl.tsx:645 and packages/components' OWN ui:empty schema renderer at renderers/feedback/empty.tsx:36 -- which the card did not list at all. plugin-dashboard reaches it via WidgetEmptyState.tsx:101. PR #7139 adds ObjectChart as a seventh. The alias export EmptyState has ZERO consumers -- a real zero, control git grep -nw hits the alias line itself at view-states.tsx:199. OUTSIDE packages/*: zero code consumers, control 238 files outside packages/* reference @object-ui/components so the channel reads.",
      "other_shared_primitives_found": "PM ASSUMPTION FALSIFIED. DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx holds a second independent family: Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent, EmptyValue. Also BuiltinSelectEmptyState (renderers/form/form.tsx:3337) and CommandEmpty (ui/command.tsx:113). None is a view-level data empty state and none is touched, but 'the only shared primitive' was not true. Control: the same regex hits 3 'function Data*' in view-states.tsx.",
      "per_surface": [
        {"surface": "DataEmptyState bare default (the primitive)", "what_empty_renders": "inbox glyph + h3 'No data', 1 text node", "role_attr": "NONE (before) / status (after)", "self_describing": false, "distinguishable": false},
        {"surface": "plugin-list ListView EMPTY", "what_empty_renders": "'Nothing here yet' + 'Create your first record to get started.' + Add-record button, 2 text nodes", "role_attr": "NONE (before) / status (after)", "self_describing": true, "distinguishable": false},
        {"surface": "plugin-list ListView LOAD ERROR (403)", "what_empty_renders": "'You don't have access' + permission explanation + Retry, 3 text nodes -- rendered through DataEmptyState, not DataErrorState", "role_attr": "NONE (before) / alert (after)", "self_describing": true, "distinguishable": false},
        {"surface": "plugin-detail ActivityTimeline", "what_empty_renders": "'No activity recorded', 1 text node, hardcoded English", "role_attr": "NONE (before) / status (after)", "self_describing": false, "distinguishable": false},
        {"surface": "plugin-detail RecordActivityTimeline", "what_empty_renders": "t('detail.noActivity') or authored emptyLabel", "role_attr": "NONE (before) / status (after)", "self_describing": true, "distinguishable": false},
        {"surface": "packages/components ui:empty schema renderer", "what_empty_renders": "authored title/description, defaulting to 'No data'", "role_attr": "NONE (before) / status (after)", "self_describing": false, "distinguishable": false},
        {"surface": "plugin-dashboard WidgetEmptyState (#7063, RULED)", "what_empty_renders": "'No data yet' + 'This widget loaded successfully and its query returned no records yet.' + 'Source: work_order', 4 text nodes", "role_attr": "status, typed explicitly at the call site -- UNCHANGED by this PR", "self_describing": true, "distinguishable": true},
        {"surface": "plugin-kanban empty board", "what_empty_renders": "'No cards' + '2 columns', 2 text nodes", "role_attr": "status + aria-live=polite, typed explicitly -- UNCHANGED by this PR", "self_describing": true, "distinguishable": true},
        {"surface": "plugin-charts ObjectChart (PR #7139, in flight)", "what_empty_renders": "'No data yet' + explanation", "role_attr": "status, typed explicitly -- UNCHANGED by this PR", "self_describing": true, "distinguishable": true},
        {"surface": "plugin-detail record SECTIONS (#7064, RULED)", "what_empty_renders": "heading + labels + em-dash placeholders, heuristic-owned in record-details.tsx", "role_attr": "n/a -- does NOT consume DataEmptyState, so unreachable from this change", "self_describing": true, "distinguishable": true}
      ],
      "do_they_legitimately_differ": false,
      "verdict": "CONVERGENCE WARRANTED for property 1 (distinguishable from a load failure), DECLINED for property 2 (self-describing copy). The surfaces were not legitimately differing on the structural test: the primitive's own siblings in the same file both declare a role and it alone declared none, so three surfaces hand-copied the same line and three did not get it. Naive hoisting was nonetheless NOT safe -- plugin-list renders its load error through the empty primitive, so a bare role=status default would have announced a 403 as a routine status. The correct convergence is therefore two lines, not one: the default on the primitive, plus the misusing call site declaring role=alert.",
      "blast_radius_measured": "packages/components is broad-blast so the whole consumer graph was run, not sampled. DIRECT (components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboard): 504 test files / 4780 tests, all passed, exit 0. INDIRECT (app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-view -- the packages holding all 27 out-of-package test files that touch empty-state DOM or roles): 1003 test files / 10939 passed + 1 skipped, exit 0. Combined 1507 test files, zero failures. Snapshot exposure zero: no snapshot contains a DataEmptyState render, control 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads.",
      "kpi_family_generalises": false,
      "authoring_surface_widened": false,
      "spec_key_needed": false,
      "files_changed": [
        "packages/components/src/custom/view-states.tsx",
        "packages/plugin-list/src/ListView.tsx",
        "packages/components/src/__tests__/data-empty-state-role-7132.test.tsx",
        "packages/plugin-list/src/__tests__/ListView.emptyVsErrorRole-7132.test.tsx",
        ".changeset/7132-empty-state-role-default.md"
      ],
      "pins_added": [
        "packages/components/src/__tests__/data-empty-state-role-7132.test.tsx -- 5 arms: the default; the default alongside real call-site props; an override to alert; the ruled surfaces' explicit role=status override; the sibling contrast (error=alert, loading=status)",
        "packages/plugin-list/src/__tests__/ListView.emptyVsErrorRole-7132.test.tsx -- 3 arms: empty branch is status; error branch is alert; both branches asserted BY EXACT VALUE rather than by inequality, because on main both were null and a partial fix leaving empty at null would still satisfy null !== 'alert' and pass a mere-difference assertion"
      ],
      "pin_ablation": "PROVEN ABLE TO FAIL. Predicted before running, then observed exactly: 'Tests 4 failed | 4 passed (8)'. Red: the two DEFAULT arms and the two ListView arms naming status. Green (negative controls, correctly unmoved): both OVERRIDE arms, the SIBLING CONTRAST arm, the ERROR arm -- which is itself the proof that the two already-ruled surfaces are inert under this change, since they pass role explicitly. Mutation proven on disk before the run (blob 294d845a to 9e24fccf, guard-line count 2 to 1); restore proven by state (blob back, guard-lines back to 2, git diff HEAD and git diff --cached both empty). Ablation script carried a trap ... EXIT INT TERM with an absolute REPO_ROOT path.",
      "changeset_gate_verdict_verbatim": "STAGED FIRST so the population is non-empty. check-changeset-presence exit 0: 'Compared the working tree with 2b3964aff (merge-base with origin/main): 5 file(s) changed, 4 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 1 changeset(s) added.' then 'OK 4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/7132-empty-state-role-default.md.' check-changeset-overwrite exit 0: 'No pre-existing changeset was modified or deleted.' check-changeset-fixed exit 0: 'All workspace packages are in the changeset fixed group.' check-changeset-no-major exit 0: 'No changeset declares a major bump.'",
      "gates": [
        {"name": "vitest run (two new pins)", "exit": 0, "verdict": "Test Files 2 passed (2) / Tests 8 passed (8)", "raw": "at final commit ff43f39f7"},
        {"name": "vitest run (5 direct consumer packages)", "exit": 0, "verdict": "Test Files 504 passed (504) / Tests 4780 passed (4780)", "raw": "components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboard"},
        {"name": "vitest run (8 indirect packages)", "exit": 0, "verdict": "Test Files 1003 passed (1003) / Tests 10939 passed | 1 skipped (10940)", "raw": "app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-view"},
        {"name": "tsc --noEmit + tsc -p tsconfig.test.json (components)", "exit": 0, "verdict": "clean, both passes", "raw": "NOT MEASURED on the first attempt -- Cannot find module @object-ui/core, unbuilt closure; re-run after turbo run build --filter=@object-ui/plugin-list^... which reported 12 successful, 12 total"},
        {"name": "tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)", "exit": 0, "verdict": "clean, both passes", "raw": "same closure build"},
        {"name": "tsc --listFiles coverage control", "exit": 0, "verdict": "components pin 1 of 1895 checked files; plugin-list pin 1 of 1625; edited sources 1 of 1367 and 1 of 1334; control -- src pass contains 0 .test.tsx", "raw": "packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx, so a clean typecheck alone would have said NOTHING about the new pins; the second tsconfig.test.json pass is what covers them"},
        {"name": "eslint (plain form, 4 changed files)", "exit": 0, "verdict": "184 problems (0 errors, 184 warnings)", "raw": "all pre-existing no-explicit-any in ListView.tsx; the new pin contributes 2, matching ListView.loadErrorKind.test.tsx's own profile at the identical construct"},
        {"name": "check-control-bytes", "exit": 0, "verdict": "OK (scanned 5917 tracked text file(s); skipped 85 binary)", "raw": ""},
        {"name": "check-i18n-call-site-keys", "exit": 0, "verdict": "every in-scope call-site key resolves against the en pack (2845 keys)", "raw": "no new i18n key added by this PR"},
        {"name": "check-i18n-en-drift", "exit": 0, "verdict": "No en value changed in this range.", "raw": ""},
        {"name": "check-phantom-dependencies", "exit": 0, "verdict": "Every in-scope import is declared by the package that publishes it.", "raw": "scanned 40 released packages, 3681 source files, 18510 specifiers"},
        {"name": "repo-wide pnpm lint + full check:* farm", "exit": null, "verdict": "DECLARED NARROWING -- not run locally, left to CI which runs the farm regardless", "raw": ""}
      ],
      "controls_used": [
        "alias census: git grep -nw EmptyState returned zero consumers; re-taken because the first attempt's grep -v filter had removed the very line that was the control; the control then hit at view-states.tsx:199, making the zero a real reading",
        "outside-packages census: zero code consumers, control 238 files outside packages/* reference @object-ui/components",
        "other-primitives sweep: control confirmed the regex hits 3 'function Data*' in view-states.tsx; filename sweep control 442 tracked files under packages/components/src",
        "snapshot exposure: 0 data-empty-state hits, control 10 data-slot hits in snapshot.test.tsx.snap",
        "indirect-exposure scoping: 27 test files outside the 5 direct packages touch empty-state DOM or roles, control 13 hits inside those 5 packages proves the grep reads",
        "recorder self-control: row count, no ELEMENT-NOT-FOUND row, and bare primitive text === 'No data'; it FIRED on run 1 and caught a miscount",
        "recorder file read-back: after console output proved to be swallowed by the vitest config, the harness wrote to a file and asserted the file contained the primitive row",
        "typecheck coverage control: 0 .test.tsx in the src pass, confirming the exclude is real and the second pass is what covers the pins"
      ],
      "assumptions_falsified": [
        "FALSE: 'it is five call sites, not four'. It is SIX on main in four packages, seven with #7139. plugin-list has two (empty AND error) and plugin-detail has two, and packages/components' own ui:empty renderer is a consumer the card never listed.",
        "FALSE: 'DataEmptyState is the only shared empty-state primitive'. custom/empty.tsx holds a whole second family (Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent/EmptyValue), plus BuiltinSelectEmptyState and CommandEmpty.",
        "FALSE: 'all consumers pass compatible props'. plugin-list's load-ERROR panel passes error copy, destructive icons and a retry action to a component named DataEmptyState -- structurally different use, and the reason a naive default would have been actively wrong.",
        "TRUE (confirmed): the four-or-five packages are the whole consumer set in the sense that nothing outside packages/* consumes it.",
        "TRUE (confirmed) but does NOT generalise: the KPI carve-out is real and is a branch gate (rows.length === 0 && !isMetric) on whether an empty state renders AT ALL, upstream of the primitive. It answers a different question than this card's, so it neither blocks nor supports the structural convergence."
      ],
      "open_questions_note": "none -- no decision was needed; the measurement settled it in both directions without touching a ruled surface or widening an authoring surface"
    }

    ⚠️ For the PM arming landing: PR #7144 is draft, base has moved (mergeable_state: behind, base now e8e4c4df5 vs the 2b3964aff this branch measured and forked from). CI was not waited on per the dispatch contract. Ready was not flipped and auto-merge was not enabled.


    Generated by Claude Code

  5. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    🟢 Landed — d1842abcf

    PR #7144 merged; main advanced. Fixes closed this card.

    Verified by content with a differential control

    probe pre (dd35800af) post (d1842abcf)
    view-states.tsx — code role= sites 2 3
    ListView.tsx — role="alert" 1 2
    CONTROL — Data* components in view-states.tsx 3 3 ✅
    CONTROL — DataEmptyState in ListView.tsx 3 4 ✅

    The three new code sites are :31 (DataLoadingState, pre-existing), :130 (the new DataEmptyState default) and :195 (DataErrorState, pre-existing) — so the file went from two of three siblings declaring a role to all three.

    ⚠️ My own probe needed correcting first. A line-count grep returned 6, not the 3 I predicted, and I nearly recorded that as a discrepancy. Three of those six are inside the doc comment this PR added (:90, :106, :109) — the instrument was counting prose alongside implementation on a file the change deliberately documented. My prediction was right about the code; my measurement of it was not. Splitting code from comment is what made the reading true.

    Why this landed as-is

    Property 2 came back measured-and-DECLINED, and that is a full pass. Self-describing default copy would be a change reaching every consumer plus an i18n project in a component with no translation hook, and every shipped surface already supplies its own copy. A card that converges one property and explicitly declines the other has answered its question.

    A naive hoist would have been actively harmful. plugin-list renders its load failure through DataEmptyState, so a bare role="status" default would have announced a 403 as a routine status update. The correct convergence is two lines, not one — the default plus role="alert" at that call site. That is invisible to a source read: view-states.tsx shows one component missing one attribute and looks like a one-line fix. Only rendering the surfaces revealed that one of them was doing the other's job.

    My briefed consumer count was wrong and I verified the correction myself: I said four call sites; it is six in four packages, seven with objectui#7139. ListView.tsx alone has two, and packages/components' own ui:empty renderer was never listed on the card.

    Three of my assumptions were falsified and reported rather than absorbed — that DataEmptyState was the only shared empty-state primitive (custom/empty.tsx holds a whole second family), that all consumers pass compatible props, and the consumer count above. The KPI carve-out was confirmed but explicitly non-generalising: it is a branch gate on whether an empty state renders at all, upstream of this component.

    Follow-up now unblocked

    objectui#7143 — ListView renders its load failure through DataEmptyState while DataErrorState sits unused. This PR fixed the accessibility collision, not the structural one. Its file collision with this PR has cleared; its Clause ② fork has not.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions