Skip to content

feat(ui): member pickers, a real request form, and vault creation from the rail - #667

Merged
SudoThijn merged 20 commits into
developmentfrom
feature/changes
Sep 9, 2026
Merged

feat(ui): member pickers, a real request form, and vault creation from the rail#667
SudoThijn merged 20 commits into
developmentfrom
feature/changes

Conversation

@SudoThijn

Copy link
Copy Markdown
Contributor

Frontend work on three dialogs and the left rail, plus a handful of smaller fixes carried on the same branch.

Team folder sharing

  • Members are picked from a list instead of typed by id. Users come from Nextcloud's sharee search narrowed by the batch shareability probe (feat(sharing): batch recipient-certificate lookup for share dialogs #656), so only people who can actually receive a secret are offered; groups come from the provisioning API, searched as you type since both directories page.
  • Switching member type clears the id, so a user id can no longer be submitted as a group.
  • The add-member row fits on one line again.

Ask someone for a credential

  • Name and folder were bare inline elements that ran onto one line; the whole dialog is a single labelled column now.
  • "Create request" is gated on its required inputs. It used to be clickable with nothing filled in, and the empty-field selection reached the server as a 400.
  • Requested fields use NcCheckboxRadioSwitch: the hand-rolled rows were several times too tall with labels off the box. Custom fields can be removed again.
  • Opening the dialog from a vault or folder files the request there.
  • The fill link handed to the requester is absolute; generateUrl silently ignored the { absolute: true } it was being passed, so it was a bare path.

Vault rail

  • A vault's ⋮ menu can create a folder inside it.
  • "New vault" is a row at the end of the group. The Vaults caption is no longer hidden when the tree is empty, which left a new user with no way to create their first vault from the rail at all.

Also

  • The browser-extension settings section is commented out until the extension ships.
  • Bulk delete/move/share/team-folder dialogs report once they finish.
  • Breadcrumb Home crumb has a name; secrets actions bar corners squared off; the dashboard approval queue links to the applications page.

SudoThijn and others added 18 commits September 7, 2026 14:35
Opening any vault flooded the console with one Vue warning per crumb per
render:

  [Vue warn]: Invalid prop: type check failed for prop "name".
  Expected String with value "undefined", got Undefined

NcBreadcrumb declares `name` as a required String, and the trail's first
crumb is icon-only -- `{ icon: 'Home', to: ... }`, the shape CnBreadcrumbs'
own docs advertise for a Home crumb -- so `crumb.label` reached it as
undefined. The trail is rendered only inside a folder, which is why the
root page was quiet and every vault was not.

The crumb now carries `label: t('keepiq', 'Vaults')`. Nothing renders it:
NcBreadcrumb prints `name` only for crumbs without an icon slot, so the
crumb looks exactly as before. The word matches the nav item pointing at
the same route, and the key is already translated across l10n/, so this
adds no string.

CnBreadcrumbs stops forwarding undefined on its own side too, but keepiq
builds the published @conduction/nextcloud-vue dist, so that fix reaches
this app only on the next release -- the label is what silences it now.

Not addressed here: the Home crumb's button has no accessible name at all.
NcBreadcrumb sets aria-label only when its `icon` prop is used, not the
`#icon` slot CnBreadcrumbs renders through.
With the page title hidden in keepiq, the actions bar is the page's TOP
element: `.cn-index-page` gives up its remaining 4px of top padding, and
the bar's top corners go square so the bar meets the page edge as a seam
instead of showing the page background in two notches.

The bottom corners keep the container-large radius, so the shorthand is
split into logical longhands rather than losing the token and its
fallbacks for older server generations.
The "Applications awaiting approval" widget listed the pending registrations
and then left an admin with nowhere to go: its configured "View all" footer
is dead. `limit` never reaches the widget on @conduction/nextcloud-vue
2.36.3 -- the object-table host adapter destructures it out to fold it into
`source`, and an endpointSource table has no `source` -- so every row
renders, the footer's total-greater-than-shown condition can never hold, and
`viewAllRoute` is unreachable no matter how many applications are waiting.

`rowRoute: "ApplicationRegister"` gives the card its navigation back: any
row opens /applications, the page where the queue is handled. A route NAME,
so the widget router-pushes it -- the vault master key is memory-only, and a
full page load would re-lock the vault and land on /lock, which is the trap
this manifest's tile note already documents for linkType "app".

Deliberately NOT ApplicationDetail (/applications/:id, which does exist):
the queue's job is to get an admin to the page that handles the whole queue,
not to one application. The cost is one dev-only console line per click --
the widget's rowRoute always pushes `params: {id}` and vue-router 4.6.4
discards a param this path has no segment for (measured: it resolves to
/applications and warns "Discarded invalid param(s) id"). Production builds
strip the warning.

The widget's `_note` is corrected with it. It claimed "No rowRoute:
applications have no detail page", and ApplicationDetail has existed for
some time; the note now records why the row still goes to the index, and
what makes the View-all footer dead rather than merely unconfigured.

The upstream adapter fix is committed in nextcloud-vue, so the footer starts
appearing at a fourth pending application once keepiq bumps past 2.36.3.
Until then rowRoute carries this on its own.
Reported from testing, while clearing the development seed: deleting several
secrets ran fine and then ended on a screen that was part result, part
command -- with a live "Delete 0 secrets" button on it.

Everything in the dialog counted off the SELECTION, and the host reloads the
list when the run completes (SecretList.onBulkDone), after which the
reconciled selection is empty. So a finished run left a warning about
deleting 0 secrets, a destructive button offering to delete 0 secrets, and
the per-item report, all at once.

The dialog now has two phases and the phase decides the chrome. While it
asks, nothing changes. Once the run has finished the warning and the typed
confirmation are gone, the destructive button is REMOVED rather than
disabled, Close becomes the primary action, and the title turns into an
outcome: "Deleted {ok} of {total} secrets", counted off the report so it
stays true when the selection behind it is empty and when part of the run
failed or was cancelled.

The phase is an instance flag, not a read of the store. The report outlives
the dialog -- it is cleared with the selection, not on close -- so deriving
"finished" from it would have opened a fresh dialog straight into a result
state. The same flag now gates BulkRunPanel, which fixes a second, quieter
version of the same confusion: reopening the dialog showed the PREVIOUS
run's report table before anything had been asked for.

A failed run keeps its report and its Retry button; only the affordances
that would act on the emptied selection go away.

l10n: one new key, translated into all 36 required locales rather than left
to fall back to English, and the .js catalogues regenerated from the .json
(npm run l10n:build). Each translation follows the phrasing that locale
already uses -- the noun from its own "Delete {count} secrets", the "X of Y"
construction from its "Page {page} of {total}" / "Used {used} of {limit}".

The three sibling bulk dialogs (move / share / team folder) share the
pattern and are fixed in the commit after this one.
The same defect the delete dialog was just fixed for, in the three dialogs
beside it. Everything counted off the SELECTION, and the host reloads the
list when a run completes, which empties the reconciled selection -- so a
finished run left a title counting 0 secrets, a destination picker, and a
live primary button.

Quieter than delete only because their run buttons carry static labels
("Move", "Share", "Add to team folder") rather than a count. Worse in one
respect: pressing the button again ran the runner over an empty id list,
which RESET the report to empty -- the record of what had just happened was
one stray click away from being wiped.

Each now has the two phases delete has. While it asks, nothing changes. Once
the run has finished the input is gone, the run button is REMOVED rather than
disabled, Close becomes primary, and the title turns into an outcome counted
off the report: "Moved/Shared {ok} of {total} secrets", "Added {ok} of
{total} secrets to the team folder". The same instance flag gates
BulkRunPanel, so reopening a dialog no longer shows the previous run's table
before anything has been asked for.

Two details that are not copy-paste from delete:

  • The team-folder run is two steps -- the chunked move, then the
    membership fan-out -- so its switch waits for `fanOut.running` as well.
    Reporting while members were still receiving copies would have been a
    new version of the same lie.

  • Share resolves the recipient's certificate BEFORE the runner starts and
    returns early when the recipient has no active suite. The flag is set
    only after that resolves: such a dialog never ran, so it must keep
    asking with the reason on screen.

Tests: tests/dialogs/BulkPhaseDialogs.spec.js runs the asking phase, the
switch to reporting, and the fresh-open case over all three dialogs
(describe.each), plus the refused-recipient path for share. Verified they
fail without the change.

l10n: three new keys, translated into all 36 required locales and the .js
catalogues regenerated, each following the verb and "X of Y" construction
that locale already uses.
The extension does not exist yet, so the section promised a feature no
one can install. Commented out rather than deleted: markup, the steps
style rule and securitySettingsUrl stay in place so restoring it is a
matter of uncommenting.

While in there, two fixes to the section itself. The description dropped
its implementation detail — that the extension decrypts locally and the
server ships only encrypted blobs — since a user wants to know their
secrets are safe, not how. And the steps list gained a logical inline
padding, because the server CSS zeroes list padding, which left the
markers hanging outside the settings section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one of these was reachable from the same dialog, so they land
together.

The form. Name and folder were four bare inline elements in a div with no
layout, so both labels and both controls ran onto one line and read as a
single sentence. They are NcTextField and NcSelect now, stacked in one
labelled column. "No folder" became the picker's placeholder rather than
an option, because NcSelect filters an empty-string model value out of
its selection and an option valued '' renders as though nothing were
picked. The placeholder explanation moved to a note at the top: as the
last child of the name/folder block it read as a footnote to the folder
picker rather than the premise of the dialog, and it no longer claims
something happened "now", which implied opening the dialog had already
created something.

Required fields. A blank dialog offered an enabled "Create request". The
name was only checked past the button, in a pane below the fold, and the
field selection was not checked at all — so the POST went out and the
endpoint answered 400 "requestedFields cannot be empty". The button is
gated on both now, with the reason shown next to the missing input.

The checkboxes. The server's input styling applies to checkboxes too, so
each hand-rolled row came out a clickable-area tall with padding and
margins on top of that, and the label sat on the text baseline instead of
level with the box. NcCheckboxRadioSwitch, as everywhere else in the app.
The bordered fieldset stays: the group holds the list AND the
add-a-field row, and the border is what says where it starts and stops.

Deleting a custom field. Unticking one only declines to ask for it, and
the name stayed in the list by design, which left a typo with no way out
of the dialog but cancelling it. Names typed here now carry a delete
button; the built-ins and the secret's own members do not, since they
exist whether or not this request asks for them.

The fill link. fillLinkFor passed `{ absolute: true }` to generateUrl,
which reads like it says so, but @nextcloud/router has no such option and
silently ignored it — the requester was handed a bare path to paste into
a mail. The origin is prepended explicitly, matching the absolute form
the machine API already returns for the same token.

New source strings carry all 36 locale translations, derived from the
long-form entries already in each file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening "Ask someone for a credential" from inside a vault or folder
already says where the credential belongs, so the picker started empty
and made the requester name the place they were standing in — and a
mis-set destination is only noticed once the filled secret turns up
somewhere else.

SecretList passes the browsed folder in and the dialog starts on it,
including after a close: reopening from the same place must offer the
same destination again, not reset to unfiled.

Unfiled stays available, and stays meaningful — a null folderId is the
list's whole-vault query, so a request filed nowhere is still one the
requester can find.

Terminology, while here: "folder" is the word for the picker at every
level (team decision) — the first level under the root is a vault and
everything below it is a folder — so the labels stay as they are and the
rail keeps its "Vaults" caption for the level that is one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were only reachable from the secret list, which is the wrong place
to look for them: the rail is where vaults live.

A vault's "⋮" menu gains "New folder", first in the menu because it is
the only entry that adds something rather than editing or removing the
vault it was opened on. Nested folder rows deliberately get nothing —
they have no menu at all — so the rail cannot offer a folder as a
parent, matching FolderCreateDialog's rule that a folder is only ever
created inside a vault.

"New vault" is a plain row at the end of the vault group rather than a
caption action: a menu you have to open first is what made this look
missing. The Vaults caption is no longer gated on the tree being
non-empty, which was the worse half of the gap — a user with no vaults
yet saw no caption, no tree, and no way to create the first one.

Both entry points open the same FolderCreateDialog and differ only in
the parentId they pass, which is what fixes the level at open time.

No new translation keys: "New vault" and "New folder" are the dialog's
own titles, already in every locale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Typing a user id by hand is how you add a member who cannot receive one:
holding an ACTIVE encryption suite is what makes someone shareable at all
(no suite, no public key to encrypt a copy for), and nothing in the field
said so. A typo produced a membership whose copies the reconcile pass
then reported as permanently missing.

encryptionSuite.fetchSuiteOwners() reads the shareable users off GET
/suites: active suites, owner type user, distinct, minus the current user,
who is not a candidate for their own team folder. The dialog offers a
picker when that list has anyone in it and keeps the free-text field when
it does not, so nobody is left with a dropdown that cannot be opened.

Which means it is EMPTY today, on purpose: the endpoint answers with the
caller's own suites only (EncryptionSuiteController::index passes the
session user to getSuitesByOwner), so every row is the current user and
they are dropped. The day that endpoint can report other owners, the same
code returns them and the picker appears with no further change.

That is deliberately expressed as "who is in the response" rather than a
capability flag or a query parameter: nothing to agree with the backend up
front, and nothing to remove afterwards.

Groups are still free text. The suites table knows users; a group
directory would have to come from somewhere else (the sharee API, or the
provisioning API for an admin), which is a decision of its own rather
than a detail of this one.

Also: switching the member type now clears the id. Carrying it across
offered to add "bob" as a group — accepted by the field, refused by the
server.

No new translation keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groups are the server's data, not keepiq's — a team folder stores a gid
and who is in it is the server's business — so the candidates come from
the provisioning API: GET /ocs/v2.php/cloud/groups, which any logged-in
user may call (GroupsController::getGroups is NoAdminRequired) and which
answers a plain list of gids.

So the group half of the member picker works today, unlike the user half:
a user must hold an active encryption suite before a secret can be
encrypted to them, and that list still waits on /suites learning to
answer for more than the caller. A group carries no key of its own — its
members are resolved and key-checked when the fan-out runs — so there is
nothing to filter it by.

The endpoint pages, so the picker searches rather than pretending one
fetch is the whole directory: typing refetches on a 300ms debounce, which
is also the answer to "why is my group not in the list" on an instance
with more groups than one page. Users need none of that; their candidates
are a local list the picker already filters itself.

Two OCS details that each fail as "no groups" rather than as an error, and
so are pinned by tests: the OCS-APIRequest header (without it the route
answers 401 whatever the session says) and format=json (without it, XML),
plus reading the list out of the ocs.data envelope rather than off the
response body.

No new translation keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NcSelect ships min-width: 260px, so two of them could not share a 600px
dialog with a button: the pickers took roughly half the width each and
the Add button wrapped onto its own line underneath. The row sizes them
itself now — 10rem for the type, the rest to the member field, natural
width for the button — since neither picker holds anything long, just
"User"/"Group" and an id.

10rem rather than less because NcSelect passes inputLabel to its search
field instead of an external label, so the floating "Member type" has to
fit inside the control.

The row still wraps: under ~512px the dialog goes full width and three
across does not fit. What is gone is the wrap on a desktop dialog.

While lining them up: NcSelect carries its own bottom margin, so on a
bottom-aligned row its control box sits one grid baseline above the row
edge. The button and the plain-input fallback take the same offset, which
is what actually makes the three bottoms agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uses the batch lookup from #656: POST /api/v1/shares/recipient-certificates
answers, for ids the caller names, which of them hold an active suite and
can therefore receive an encrypted copy.

It PROBES rather than enumerates, deliberately — a certificate is a public
key and safe to hand out, but "who has a keepiq vault" is a membership
fact gated by no sharing permission — so the candidate ids come from
Nextcloud's own sharee search, which is already permission-filtered, and
the probe narrows them. That is two requests per search term, not one, and
no list endpoint is involved.

Which retires the placeholder this replaces: encryptionSuite's
fetchSuiteOwners() read owner ids off GET /suites and waited for that
endpoint to widen its scope. It never will, and now need not.

A user who cannot receive secrets is LISTED AND UNSELECTABLE rather than
filtered out, using the reason the endpoint returns per recipient: "alice
is missing" is a bug report, "alice has no encryption suite" is an answer.
Reusing that existing string, so no new translation keys. An unknown user
and one without a suite report identically by design, so nothing here
treats a reason as proof an account exists.

Both member kinds now search the server per keystroke-burst on one
debounce, and both produce the same option shape, so the picker has a
single template: groups are always selectable (they hold no key of their
own — members are resolved and key-checked when the fan-out runs), users
only with an active suite behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A candidate ruled out by `selectable` came out as a near-white block with
dim grey text, and the not-allowed cursor appeared only on the row's
padding.

Both come from the teleport. NcSelect appends its menu to <body>
(vue-select's appendToBody, defaulted true), so the menu sits outside the
control and loses every theme variable NcSelect declares on it.
@nextcloud/vue re-declares the menu's own set on the teleported
`.nc-select__dropdown` — background, text, option padding, active and
keyboard-focus colours — but not the disabled ones, so the row fell back
to vue-select's `:root` defaults: #f8f8f8 on rgba(60,60,60,.5).

Themed in assets/app.css because the teleport puts the menu beyond the
reach of any component's scoped styles, and the gap is the same for every
NcSelect in the app that rules options out.

Transparent background, grey text, unchanged everything else: unavailable
is said by the colour and the cursor, not by a block of background. The
option content also inherits the cursor now — over the text the topmost
box is that span, which is why only the padding changed the pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user with no active suite cannot be a member — there is no public key to
encrypt their copies to — so the picker no longer lists them at all.
Listing them greyed out was the previous take; it put an unpickable row in
front of every choice to explain a case most requesters never hit.

Which takes several things with it, all of them only there to render that
row: the option objects (both kinds are plain ids again, so the picker
needs no reduce and no option template), the shareability flag in the
store's state, and the global disabled-option theming in assets/app.css,
since nothing in the app rules select options out any more.

The endpoint's per-recipient `reason` stays unread on purpose. It says
no_active_suite both for a user without a suite and for one that does not
exist — deliberately, so it cannot be used as a user-existence oracle — so
it can tell a caller nothing beyond "not this one".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding the last candidate swapped the control: with nothing left to offer,
the picker was replaced by a free-text field. A control that turns into a
different control underneath you is worse than an empty list, and NcSelect
already says "No results" for that state.

The free-text form is gone rather than hidden, because there was nothing
left for it to do: an id typed by hand is either already in the list or
cannot be a member at all — a user with no active suite has no public key
to encrypt their copies to, and a typo produced exactly the membership
whose copies the reconcile pass then reports as permanently missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prettier's own output, no assertion touched: two single-quoted test names
holding an apostrophe become double-quoted, and one expectation wraps.
They arrived unformatted with 9f5b9ea and fail the format gate for the
whole branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ cf2569e

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 543/543
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development
Hydra gates

Quality workflow — 2026-09-08 14:17 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 6a9ef0e

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 543/543
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development
Hydra gates

Quality workflow — 2026-09-08 14:24 UTC

Download the full PDF report from the workflow artifacts.

gate-16 (spec-coverage) named 12 changed methods with no @SPEC tag, and
gate-46 (spec-anchor-existence) one tag pointing at an anchor that does
not exist. Reproduced locally against origin/development with the gate
package from ConductionNL/.github; all 79 applicable gates pass now.

The missing tags, by what they turned out to be:

- The four bulk dialogs' `finished` and `title`. Both are the report half
  of the two-phase dialog — `title` counts its outcome off the report
  rather than the emptied selection — so both cite the per-item report
  requirement.
- TeamFolderDialog's `onShareFolder`, which had no docblock at all: it is
  the share step every membership then hangs off, so it cites the
  share-a-folder requirement.
- `memberIdLabel`, `candidatesLoading`, the `newMemberType` watcher and
  `beforeUnmount` are excluded with a reason each, naming the method that
  does carry the specified behaviour. A label, a spinner flag, input
  hygiene the server re-checks, and a cleared timer.

The bad anchor was `#requirement-bulk-delete` in the delete dialog's
spec, which the live bulk-actions spec has never had — its requirements
are named by mechanism (the four operations, chunked execution with a
per-item report), not per operation. Pointed at the two it actually
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ ebd2ef1

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 543/543
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development
Hydra gates

Quality workflow — 2026-09-08 14:34 UTC

Download the full PDF report from the workflow artifacts.

Comment thread src/modals/TeamFolderDialog.vue Outdated
Comment thread src/modals/TeamFolderDialog.vue
Comment thread src/store/modules/group.js Outdated
Comment thread src/store/modules/share.js Outdated
Comment thread src/modals/TeamFolderDialog.vue
Comment thread src/App.vue
- the picker offers display names, not raw ids. `searchSharees` carries the
  `label` the sharee search returns, `searchShareableRecipients` joins the
  probe's id-only answer back onto it, and `fetchGroups` maps a gid to
  `{ id, label: gid }` so both directories share one option shape. The
  NcSelect gets `label` + `reduce`; on an LDAP or SSO instance the old list
  of GUIDs was unusable for the thing the picker is for
- a failed lookup is no longer indistinguishable from "nobody matches":
  both stores record `candidatesError` — kept apart from share.js's `error`,
  which belongs to the share list — and the picker renders it as its own
  helper line. NcSelect's `#no-options` slot would have meant
  re-implementing its translated "No results" in keepiq's domain, and only
  shows while the dropdown is open
- out-of-order responses cannot win any more: a monotonic seq per store
  means a superseded search neither writes its list nor clears a flag the
  newer one owns, while still returning its own answer to its own caller
- `onCandidateSearch` ignores the empty term vue-select re-emits when an
  option is picked, which used to refetch page 1 under the user ~300 ms
  after every member added
- the member field's comment no longer claims the server agrees with it.
  The narrowing to the sharee-vouched, suite-holding set IS the point and is
  deliberately narrower than assertMemberAddable, which asks only that the
  user exists and is not the owner
- group.js's header pointed at `encryptionSuite.fetchSuiteOwners`, removed
  in 211ccd3; it now names share.searchShareableRecipients

Not from the review: the edit tripped gate-16 (spec-coverage), which flags
changed methods carrying no @SPEC tag — onUpdateOpen, onRunFanOut, onUnshare
and onGradeChange had none. Tagged, onUpdateOpen excluded with a reason,
every anchor checked against the actual headings.

One new string, translated into all 36 locales; l10n:build re-run.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ e19bd7f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 543/543
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-09-09 11:40 UTC

Download the full PDF report from the workflow artifacts.

@SudoThijn
SudoThijn merged commit 9d8d0ac into development Sep 9, 2026
49 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants