Skip to content

UI Conventions Decisions ​

Each entry is a real fork in the road. Implementation details that aren't a tradeoff are NOT here.


Combobox UX — fix focus in the existing MultiSelect, don't fork new components ​

  • Chose: Re-focus the search input after each selection in the shared MultiSelect.
  • Rejected: New ComboboxSelect and ComboboxMultiSelect components.
  • Why: The "sluggish" feel was a focus-management bug, not an architecture problem; one shared fix benefits every consumer instead of duplicating most of the logic.
  • Source: KD-0163

Empty search results — fix in shared selects, not per usage ​

  • Chose: Default "Geen resultaten gevonden" rendering in SearchableSelect and MultiSelect.
  • Rejected: Use the existing #noResult slot only at the one calling site (TeamForm).
  • Why: Slot was unused everywhere — adding a sensible default lifts every site at once.
  • Source: KD-0163

Form select width cap — at the shared SelectContainer, not per form ​

  • Chose: Apply sm:max-w-125 (500px) globally inside SelectContainer, with a runtime exclusion for selects rendered inside <dialog> ancestors.
  • Rejected: Cap per form; OR add a noMaxWidth opt-out prop.
  • Why: One change covers all current and future selects; the modal escape hatch via closest('dialog') keeps form-side callers blissfully unaware. Form selects show short labels — text inputs stay wide because they hold free-form content.
  • Source: KD-0274

Mobile layout — single source of truth via CSS custom properties ​

  • Chose: CSS custom properties on :root via UnoCSS preflights; layout dimensions consumed via :style bindings in App.vue.
  • Rejected: TypeScript constants exported from a shared module, OR both combined.
  • Why: TS constants can't be consumed in CSS; :style bindings beat any UnoCSS class on specificity, eliminating the cascade order bug that kept causing pb-4 to override lt-md:pb-16.
  • Source: KD-0304

Mobile content padding lives on <main> in App.vue, not in layout components ​

  • Chose: Apply mobile top/bottom padding once at the app shell.
  • Rejected: Per-layout fix in SharedDomainLayout and ProjectLayout.
  • Why: Avoids duplicating the padding rule in every new layout; the layout component shouldn't have to know about the mobile tab bar.
  • Source: KD-0304

Zebra-stripe rows globally, not via opt-in prop ​

  • Chose: Single CSS rule in TableBody.vue covers all tables.
  • Rejected: striped prop on TableBody.vue requiring per-table opt-in.
  • Why: Every table benefits from improved readability; the one exception (notifications) already overrides row backgrounds inline.
  • Source: KD-0402

Dedicated --surface-row-alt token, not reuse of --surface ​

  • Chose: New CSS custom property tuned for both themes (#181820 dark, #f8f6f3 light), applied to :nth-child(even).
  • Rejected: Reuse bg-kendo-surface to match the epic-board precedent.
  • Why: --surface collides with --surface-header in dark mode (rows blend into header) and with --surface-raised in light mode (zero contrast). No existing token hits the right contrast in both themes.
  • Source: KD-0402

Textarea standardisation — fixed minimum height, not autogrow ​

  • Chose: 6-row default min-height on the shared TextAreaField.
  • Rejected: JS-driven autogrow with resize observers.
  • Why: The complaint was "fields too compact" — a generous default solves that without runtime cost or layout shifts.
  • Source: KD-0438

Don't unify TextAreaField and RichTextArea visual styling ​

  • Chose: Keep this ticket scoped to plain TextAreaField.
  • Rejected: Align line-height, min-height, and placeholder positioning across both primitives.
  • Why: No major design system (Atlassian, Radix, Chakra, Primer, shadcn, Headless UI, Ark) unifies these — and <p> margin inside Tiptap, two different placeholder mechanisms, and browser baseline differences are non-trivial. Defer until product priority justifies it.
  • Source: KD-0438

Unsaved-changes guard — Ref<boolean>, not getter function ​

  • Chose: Composable accepts Ref<boolean> and lets Edit.vue update it via a deep watch.
  • Rejected: Keep () => boolean getter and bind a closure over a let snapshot variable; OR accept both shapes.
  • Why: inject()-based router context only works synchronously during setup before any await. The ref-based form lets the guard register synchronously while still computing the snapshot after useIssueFromSlug() resolves.
  • Source: KD-0444
  • Chose: Composable accepts a register callback; the page wires the project's middleware registrar.
  • Rejected: Call onBeforeRouteLeave from inside the composable; OR install Vue Router as a Vue plugin.
  • Why: Kendo doesn't install Vue Router as a plugin (the view layer is a custom createRouterView), so onBeforeRouteLeave silently no-ops everywhere. Importing routerService directly from a @shared/ composable would cross domain boundaries.
  • Source: KD-0444

Changelog content — Markdown in site/changelog/, no DB ​

  • Chose: Plain markdown files in the VitePress site as the single source of truth.
  • Rejected: DB-backed ChangelogEntry model with a CRUD stack; OR hybrid markdown+sync command.
  • Why: Changelog has no per-tenant customization, no role permissions beyond "admin writes entries", and no behavior the static site doesn't already handle. ~10× the code for zero functional gain.
  • Source: KD-0471

No per-user "seen" state — stateless freshness signal ​

  • Chose: Single global fact (latest entry's date) drives a 4-day decaying [new] pill identical for every user.
  • Rejected: users.changelog_last_seen_at + MarkChangelogSeenAction; OR localStorage per browser.
  • Why: Backend state would be backend work for a frontend-only feature; localStorage gives bad cross-device UX. A user who saw the entry sees "new" for up to 4 days — acceptable for a low-cadence changelog.
  • Source: KD-0471
  • Chose: Sidebar link opens VitePress changelog in a new tab.
  • Rejected: In-app /changelog Vue route bundling markdown at build time.
  • Why: Avoids duplicating VitePress's markdown rendering, syntax highlighting, breadcrumbs, SEO. App's responsibility shrinks to one sidebar link.
  • Source: KD-0471

New SidebarPill component, not overloaded SidebarBadge ​

  • Chose: Single-purpose SidebarPill taking a label: string for neutral text pills.
  • Rejected: Add a variant="pill" prop to SidebarBadge (already used for unread counts).
  • Why: SidebarBadge is hard-coded for numeric count with bg-kendo-red (high urgency). Overloading it for neutral text fights the component's existing semantics.
  • Source: KD-0471

SimpleBadge — drop the content prop, migrate all callsites in one PR ​

  • Chose: Convert content to default slot, atomic migration across 22 callsites + 13 test files.
  • Rejected: Keep content as optional fallback alongside slot ("soft deprecation").
  • Why: Soft deprecation has no enforcement and loses vue-tsc as the safety net — missed callsites silently keep working via the prop. Atomic migration uses the type checker as a forcing function.
  • Source: KD-0618

InputLabel prop rename — forId, not htmlFor or for ​

  • Chose: Rename misnamed label prop to forId.
  • Rejected: htmlFor (React convention); OR for (exact HTML attribute).
  • Why: for is a JavaScript reserved word and can't be destructured; htmlFor is React drift in a Vue codebase. forId reads as "the id this label is for" and stays a legal identifier.
  • Source: KD-0619

Single atomic commit for shared-component renames ​

  • Chose: Rename prop + sweep all 104 callsites + spec updates in one commit.
  • Rejected: Two commits (component, then callsites) with a deprecation alias bridging them.
  • Why: vue-tsc runs across the whole frontend project, so any miss is red CI; deprecation aliases for one commit are pure noise for a mechanical rename.
  • Source: KD-0619

Feature Planner permission — new accessFeaturePlanner() policy method ​

  • Chose: Surgical new policy method, admin-only.
  • Rejected: Tighten the shared generateStory policy to admin-only.
  • Why: generateStory also gates per-issue AI story generation, which must stay Member+. Tightening it would break unrelated functionality.
  • Source: KD-0185

Feature Planner route — manual config, don't extend the route factory ​

  • Chose: Direct createStandardRouteConfig({minRole: ADMIN}) for the single overview page.
  • Rejected: Extend createNestedDomainChildren to accept metaOverrides; OR post-patch generated route meta.
  • Why: Feature Planner has no CRUD, so the factory provides no real benefit; a one-off minRole doesn't justify changing shared infrastructure for a single use case.
  • Source: KD-0185

Cherry-pick scope when umbrella issues bundle vague + shipped + concrete items ​

  • Chose: Implement only items with binary pass/fail acceptance; document out-of-scope items with reasons.
  • Rejected: Implement all 13 items; OR close as superseded.
  • Why: Contradictory taste-driven items (status-display feedback #35 vs #61 vs #62) and items already shipped via separate tickets force rework or guesswork. A focused PR is reviewable.
  • Source: KD-0364

Settings nav gating — action permissions, not READ ​

  • Chose: Sidebar/tab gates use AppSettings:UPDATE and TenantAiKeys:CREATE; bare /settings uses a smart redirect middleware that runs before authMiddleware.
  • Rejected: Gate on AppSettings:READ and TenantAiKeys:READ.
  • Why: READ-gating creates dead-click cases — the seeded Member role has READ but not UPDATE, so they'd see the entry, navigate, and get bounced by the route guard. The sidebar should expose only what the user can actually act on.
  • Source: KD-0367

Settings folder structure — stay in domains/users/ ​

  • Chose: Add settingsRoute.ts and settingsTabs.ts alongside the existing users files; pages stay in users/pages/.
  • Rejected: New domains/settings/ vertical slice with re-export shells.
  • Why: Empty re-export pages double file count for zero behavioral change and risk cross-domain import violations. The users/ folder already houses tenant-admin tooling.
  • Source: KD-0367

useTitle — stack-based composable, not restore-on-unmount ​

  • Chose: Module-level shallowRef stack with a single watchEffect reading the top getter; onUnmounted pops the entry.
  • Rejected: Each useTitle call sets document.title directly and restores the previous value on unmount.
  • Why: Two independent watchEffects both writing document.title race when reactive deps resolve at different times (e.g. tenant name resolving after login overwrites a page-level title). The stack keeps only the top getter active, so a base-level TenantTitle fallback can sit underneath every page-level title without interference, and unmounting cleanly falls back to the next entry.
  • Source: KD-0506

Tab-title scope — sub-URLs inherit, action routes don't get their own title ​

  • Chose: /issues/:id/edit shows the same [KD-XXXX] <title> — <project> — Kendo tab title as the issue show page.
  • Rejected: Distinct titles per route, e.g. Edit — [KD-XXXX] ... for the edit sub-route.
  • Why: The browser tab identifies where the user is, not the action they're performing. Edit/view modes on the same resource share a location; stable titles avoid leaking implementation details (view vs edit route) into the browser chrome.
  • Source: KD-0506

Adopting a shared helper — accept benign behaviour divergence over flag-bloat ​

  • Chose: Replace timeline-grid's private getStartOfWeek with the shared one (which also zeroes time-of-day via getStartOfDay), even though the private version preserved the input's time.
  • Rejected: Add a preserveTime flag / overload to the shared helper so timeline-grid's exact prior behaviour stays intact.
  • Why: Both inputs to timeline-grid's column-math pass through the same helper, so any extra normalisation cancels on both sides — there's no observable change. Adding a parameter to satisfy a non-requirement widens the shared API for no caller that actually needs it.
  • Source: KD-0616

Divider primitives — two zero-prop components, not one with a variant prop ​

  • Chose: Separate Divider.vue (solid) and DividerDashed.vue (dashed) components, each a single <div b-t="..." /> with no <script> block.
  • Rejected: Single <Divider variant="solid|dashed" /> component with a prop enum.
  • Why: Each file is dead-obvious to read at the callsite — no enum to memorize, no prop-value typo class. Margins fall through via UnoCSS attributify, so the components stay zero-prop.
  • Source: KD-0620

Tab title — declared in route meta, not via per-component useTitle() calls ​

  • Chose: Declare meta.title (static string or resolver) on route config; one route-middleware reads it once per navigation.
  • Rejected: Keep the KD-0506 useTitle() composable calls scattered across each page component.
  • Why: Follows the established meta.feature / meta.permission middleware pattern — one place to declare, one place to read, no per-page author having to remember to call it.
  • Source: KD-0681

Tab title — resolve only identifier-shaped URL data, never read stores ​

  • Chose: Title resolvers use only URL params (slug/key); description-shaped prose (issue title, epic name) is permanently omitted, and a numeric DB id falls back to a generic label like Edit epic.
  • Rejected: Read issue/epic stores from inside the resolver to "catch up" the title once data hydrates (via the resolver or a per-page watch({ once: true })).
  • Why: The non-memoized makeIssueStoreForProject / makeEpicStore construct a fresh store and leak Echo WebSocket listeners on every call while always returning empty data — the catch-up path was unreachable code carrying a real leak; the rule "format what the URL carries, don't reach into stores" also avoids re-scattering title logic per page.
  • Source: KD-0681

Store-reading title resolvers live in a titleResolvers.ts registry, not inline on route meta ​

  • Chose: Keep resolver closures that import domain stores in a single registry keyed by route name, imported from main.ts after routerService is constructed; only static-string meta.title stays on the route.
  • Rejected: Inline resolver closures directly on each route's meta.title (the simpler co-located shape).
  • Why: Inlining them made route files import getProjectById, which transitively pulled the issues store → echo → auth, and auth reads routerService while router/index.ts was still importing those same route files — an ESM temporal-dead-zone cycle that crashed the app on load; the name-keyed registry breaks the cycle and stays one grep from the route.
  • Source: KD-0681

Touch targets — CSS media-touch: variant, not a JS isTouchDevice ref ​

  • Chose: Gate the ≥44px sizing with UnoCSS's built-in media-touch: variant (the stacked (hover: none) and (pointer: coarse) predicate, matching kendo's existing breakpoint.ts / pagination convention).
  • Rejected: Bind a class conditionally from the existing isTouchDevice ref; OR a custom (pointer: coarse)-alone variant; OR bump min-height unconditionally for every device.
  • Why: The CSS variant adds zero JS glue, no first-paint flash, and is snapshot-safe on desktop by construction (it never matches a hover: hover / pointer: fine device); the JS ref is set once at startup and goes stale on hybrid devices, and an unconditional bump reflows 38+ desktop call-sites.
  • Source: KD-0721

Touch-target clamp on the shared Button, not across its 38 call-sites ​

  • Chose: Add media-touch:min-h-11 to Button.vue itself so the clamp wins regardless of call-site padding.
  • Rejected: Walk all 38 call-sites and bump py-* padding wherever the total fell below 44px.
  • Why: One edit lifts every call-site and every future one automatically; the per-site sweep is noisy, risks missing sites, and forces future authors to remember the convention — and overriding the clamp would require a call-site to actively set a fixed height, which surfaces in review.
  • Source: KD-0721

Touch checkbox — enlarge the native input, no tap-target wrapper ​

  • Chose: Apply media-touch:w-11 media-touch:h-11 directly to the bare <input type="checkbox">.
  • Rejected: Wrap the small native glyph in a 44×44 clickable label/div.
  • Why: The large native glyph is the WCAG-recommended touch look and matches mobile OS rendering, while keeping the migration tokens-on-the-input — a wrapper changes the component's DOM shape and risks reflow at every call-site that places its own adjacent label.
  • Source: KD-0721

Media-query-gated CSS — guard with a grep-based arch test, not JSDOM assertions ​

  • Chose: An architecture test that greps each in-scope file for the required media-touch:(h|min-h|w)- token (modelled on the existing loading-text blacklist test).
  • Rejected: Per-component unit tests asserting class presence; OR JSDOM matchMedia mocking that asserts computed height.
  • Why: JSDOM runs no CSS layout (getBoundingClientRect() is zero everywhere) and matchMedia mocks don't reach UnoCSS-emitted CSS, so real-size assertions need Playwright; the grep test catches the realistic regression (a future PR drops the token) in ~30 lines.
  • Source: KD-0721

Multi-consumer component swap — rename incumbent to *Legacy, migrate page-by-page ​

  • Chose: Promote the new bar to the canonical name, rename the old one to FilterBarLegacy.vue, and point each of 10 consumer pages at the legacy target until its own migration PR.
  • Rejected: Delete the existing FilterBar.vue and replace it in one PR.
  • Why: All 10 pages imported the incumbent, so a single delete-and-replace was incoherent and too large to review; the *Legacy rename keeps every page working untouched and lets each migrate in a separate, revertible, low-blast-radius PR.
  • Source: KD-0515

Filter bar plumbing — shared composable for the common domain, inline descriptors for one-off kinds ​

  • Chose: Extract useIssueFilterBar for the repeated issue-domain kinds (used by 5 pages), but inline a single descriptor + handler trio on pages with one bespoke kind (Notifications' Type, EpicBoard's Status, Users' Role via a thin domain shim).
  • Rejected: Inline the descriptor array on every page; OR coerce useIssueFilterBar to be generic enough to cover non-issue kinds.
  • Why: The issue-domain shape repeats across many pages so a composable removes real duplication, but a single-kind page never grows past ~50 LoC of wiring and its selection type differs (e.g. EpicStatusEnumValue[], string role slugs) — generic-ifying the composable for one consumer is premature abstraction.
  • Source: KD-0515

Filter bar — keep fetch-scope and view-toggle controls out of the descriptor model ​

  • Chose: Render DateRangeDropdown (controls the ?since API fetch) and view toggles like GranularityFilter in the bar's #action / #leading slots, plus tri-state selectors (Read All/Unread/Read) outside the descriptor list.
  • Rejected: Model every bar control — date range, granularity, tri-state read — as a filter descriptor/chip.
  • Why: The descriptor model is multi-select client-side filtering; a date range changes the server fetch scope, granularity changes how the timeline renders, and a tri-state control isn't multi-select — forcing them into descriptors distorts both the UX and the data flow.
  • Source: KD-0515

Tooltip affordance — bundle into button components via $attrs['aria-label'], not external wrapper ​

  • Chose: Button components (IconButton, SidebarLink, etc.) wrap themselves in a Tooltip whose label is read from $attrs['aria-label'] (with inheritAttrs: false).
  • Rejected: External <Tooltip label="X"> wrapper at every call site (the originally shipped approach), which duplicated the label string once on the wrapper and once on aria-label.
  • Why: For icon-only affordances the aria-label and tooltip text are always the same string, so deriving one from the other removes 40+ duplicate strings, kills label drift, and costs existing call sites zero migration. External wrappers stay only for non-button elements (<a>, <div role="button">) and dynamic/conditional labels.
  • Source: KD-0479

Tooltip node renders only when open, relying on synchronous focusin → open ​

  • Chose: v-if="open" on the tooltip node; focusin flips open synchronously so Vue flushes the node into the DOM before the screen reader walks the tree.
  • Rejected: Keep the tooltip always in the DOM, hidden via v-show/CSS with aria-hidden toggling when closed.
  • Why: Always-in-DOM means 56+ persistent hidden nodes app-wide plus aria-hidden plumbing to prevent premature SR reads; render-on-open keeps the DOM clean and the synchronous focus path makes the description available in time. Reversible to always-in-DOM with a contained change if a specific SR/browser combo regresses.
  • Source: KD-0479

Tooltips are hover/focus only — no touch behaviour ​

  • Chose: Listen only for mouseenter/mouseleave/focusin/focusout/keydown.escape; rely on icon legibility on touch devices.
  • Rejected: Long-press to reveal; OR tap-to-reveal-then-tap-to-activate.
  • Why: Touch has no hover, mobile Safari's simulated :hover flickers, long-press collides with native context menus, and tap-to-reveal breaks every other tap pattern in the app — matching Linear/GitHub/Radix, which all suppress tooltips on touch.
  • Source: KD-0479

Tooltip — single neutral variant, no variant prop ​

  • Chose: One fixed app-wide colour pair (WCAG ≥ 4.5:1) for icon-button affordances; no variant prop.
  • Rejected: A default + destructive variant; OR Emmie's 8 colour variants (warning/success/danger/info/etc.).
  • Why: Kendo tooltips are name-hints, not form-field status messages — the button's own colour and icon already convey destructiveness, so the tooltip names the action rather than urging it; variants would re-import an Emmie use case the issue explicitly scoped out.
  • Source: KD-0479

Tooltip — no arrow ​

  • Chose: Drop floating-ui's arrow middleware; an 8px offset (offset(8)) plus proximity conveys the trigger relationship.
  • Rejected: Render a rotated-square arrow element behind the card (Emmie's literal shape).
  • Why: Emmie uses arrows because its tooltips double as form popovers; Kendo's are name-hints only, so the arrow is overkill — dropping it matches Linear/Radix/GitHub defaults and removes an extra DOM node and positioning math across 56+ wrapped sites.
  • Source: KD-0479

Tooltip timing — 400ms hover delay, 0ms focus delay ​

  • Chose: A single setTimeout-gated ~400ms open delay on hover, bypassed entirely on the focus path (instant on tab-in).
  • Rejected: No delay (Emmie's immediate-on-hover); OR a configurable per-call-site delay prop.
  • Why: Immediate hover flickers when the cursor sweeps a row of icons, while a keyboard user who tabbed to an element has already committed and needs the label instantly — matching GitHub/macOS/Radix; a delay prop would defeat the app-wide consistency that is the point.
  • Source: KD-0479

Breakpoints — add a new narrow keyword, don't redefine md/lg ​

  • Chose: Additively override only theme.breakpoints.narrow = 1100px, leaving sm/md/lg/xl/2xl at preset defaults.
  • Rejected: Redefine lg to 1100px; OR replace defaults with epic-named tiers (mobile/tablet/desktop).
  • Why: Shifting lg's threshold silently invalidates sibling migrations that treat lg: as 1024px, and renaming forces a ~50-file migration plus breaks the shared mental model that md: means "medium"; an additive keyword has zero blast radius outside itself.
  • Source: KD-0726

Breakpoint constants live in a Vue-free shared/constants/ module ​

  • Chose: Put scalar breakpoint constants in shared/constants/breakpoints.ts; the runtime breakpoint.ts service and uno.config.mts both import from it.
  • Rejected: Co-locate the constants inside the breakpoint.ts service and re-export.
  • Why: UnoCSS config imports the values at build time from a Node context, so pulling them through a Vue service that references window risks SSR/build breakage — pure constants keep the build path Vue-free.
  • Source: KD-0726

Breakpoints — compose UnoCSS inline from scalars, no intermediate map ​

  • Chose: Export only scalar constants and build the override inline ({...theme.breakpoints, narrow: ${NARROW_BREAKPOINT}px}).
  • Rejected: A typed BREAKPOINTS = {narrow: '1100px'} map (which was shipped first, then reversed on PR review).
  • Why: A one-key map consumed in exactly one place over-promised with its name and added indirection; adding a future tier costs the same two-file edit (one inline key + one scalar) without the wrapper.
  • Source: KD-0726

Editor keyboard shortcuts — listen on the enclosing <form>, don't extend the editor API ​

  • Chose: Attach a native @keydown handler on the <form> wrapping RichTextArea to catch Cmd/Ctrl+Enter, using a hand-written (metaKey || ctrlKey) predicate.
  • Rejected: Extend RichTextArea (tiptap/ProseMirror) with a new keydown emit; OR use Vue's @keydown.meta.enter/.ctrl.enter modifiers.
  • Why: ProseMirror doesn't stopPropagation on keydown, so the enclosing form already receives editor-originated key events — the cheapest hook needs no new editor API; Vue's modifiers are AND-combined and can't express "meta OR ctrl" cross-platform, so a predicate (matching FilterBar) is required.
  • Source: KD-0824

Unsaved-draft guard covers route + browser-close, not in-page tab switches ​

  • Chose: Guard route navigation and beforeunload; document the comment-draft loss on same-route tab switch as a follow-up rather than guard it.
  • Rejected: Add an onBeforeUnmount fallback to catch the tab strip unmounting CommentSection.
  • Why: Vue unmount isn't cancelable, so onBeforeUnmount can't host an async confirm modal to block it — the only real fix is intercepting the tab change higher up and lifting draft state into the page, which is scope creep beyond the "navigating away" criteria.
  • Source: KD-0844

Editor link entry — inline conditional panel below the toolbar ​

  • Chose: Render link-URL entry as an inline panel conditionally shown below the RichTextArea toolbar.
  • Rejected: window.prompt(); OR a floating popover near the button; OR a separate LinkPanel.vue component.
  • Why: prompt() is a browser-native dialog inconsistent with app UX, a floating popover would need a new primitive (Dropdown.vue owns its own trigger + chevron and conflicts with the toolbar layout), and a dedicated component adds interface boilerplate for a single consumer with no reuse gain.
  • Source: KD-0851

Editor URL safety — lean on TipTap's isAllowedUri + DOMPurify, drop the redundant blocklist ​

  • Chose: No client-side regex guard in confirmLink; rely on TipTap's built-in protocol allowlist and DOMPurify at render time.
  • Rejected: A /^(javascript|data):/i blocklist as a "first line of defence" (shipped first, then removed on review).
  • Why: TipTap's setLink already runs an allowlist (only http/https/ftp/mailto/tel/etc.) which is strictly stronger than a two-protocol blocklist, and a redundant guard only creates false confidence about which check is load-bearing.
  • Source: KD-0851

Editor toolbar inline state — accept an honest eslint-disable, don't extract a fake composable ​

  • Chose: Keep the link-panel state and helpers inline in RichTextArea.vue with a documented eslint-disable max-lines.
  • Rejected: Extract a composable purely to get under the max-lines cap.
  • Why: The composable existed only to dodge a line limit, not for reuse, and useTemplateRef('url-input') coupled it to this exact template — making it untestable in isolation; the only correct boundary is a real LinkPanel.vue component with explicit emits, so an honest suppression beats a fake abstraction until that extraction is warranted.
  • Source: KD-0851
  • Chose: Leave autolink at its default so the link mark is inclusive: true; typing at the end of a link extends it, and → moves the cursor past the mark.
  • Rejected: autolink: false, making the mark inclusive: false so trailing typing doesn't extend the link.
  • Why: Every other toolbar mark (bold, italic, lists) is inclusive, so a non-inclusive link is an inconsistent "invisible cursor mode" — the explicit way to remove formatting is the toolbar button, and the → escape is standard ProseMirror behaviour, not a special case.
  • Source: KD-0851
  • Chose: Put the link and image actions in a single toolbar group with one separator.
  • Rejected: Separate groups (and therefore a separator) for link vs image.
  • Why: Both are "insert rich content" actions distinct from the text-formatting group; splitting them implies a semantic distinction that doesn't exist and adds a needless separator.
  • Source: KD-0851

Alphabetical project lists — sort on the frontend, not with a backend orderBy ​

  • Chose: Sort in the two consuming surfaces (each sidebar group and the overview table); the index query is untouched.
  • Rejected: ->orderBy('name') in ProjectController::index() as the single source of truth for every API consumer.
  • Why: retrieveAll stores records in an object keyed by numeric id and getAll returns Object.values(...), and integer-like keys iterate in ascending numeric order per the ECMAScript spec — so the SPA always renders id-order and a backend sort is a silent no-op.
  • Source: KD-0927

Default table sort belongs in useTableSort, not in a pre-sorted source array ​

  • Chose: Add an optional defaultSortKey to the shared composable so sortKey starts populated instead of null.
  • Rejected: Pre-sort the filtered source by name at the one call site, leaving the shared composable alone.
  • Why: Pre-sorting renders sorted data with no active-sort arrow on the header — the UI lies about its own state; initialising the composable keeps the indicator and the data telling the same story, and every other table can adopt it.
  • Source: KD-0927

Project avatars — new ProjectAvatar.vue, don't generalize ProfilePicture.vue ​

  • Chose: A sibling component taking {project, size}, accepting some render-logic overlap with the user component.
  • Rejected: Widen ProfilePicture.vue to cover both entity types.
  • Why: ProfilePicture's props are name-shaped (firstName/lastName, initials from first[0]+last[0]) and don't map onto a project; generalizing churns a shipped user feature and its specs for an out-of-issue benefit.
  • Source: KD-0927

Authz-gated image route — access policy + private cache, diverging from profile pictures ​

  • Chose: Gate GET {project}/avatar/{variant} on the project access policy and serve it cache.headers:private.
  • Rejected: Copy ProfilePictureController's public cache with no per-resource authz.
  • Why: Project visibility is restricted by accessibleBy, and access-controlled bytes must never land in a shared cache; <img> requests carry Sanctum cookies same-origin, so gated serving needs no extra plumbing.
  • Source: KD-0927

Avatar fallback — deterministic colour tile from the record id, no stored generated state ​

  • Chose: Two states only — an uploaded image, or a muted tile derived from hashColor(project.id).
  • Rejected: A third "generated identicon" state picked from a candidate grid (planned, backend shipped, then stripped mid-issue).
  • Why: A colour derived from the id is a pure frontend function needing zero backend surface, whereas the identicon state cost a column, route, Action, DTO, Request, and a generator — a whole stack existing only to persist a choice with marginal user value.
  • Source: KD-0927

Pending guard lives in the form that owns <form @submit>, not the parent that owns the request ​

  • Chose: Replace the isSaving prop + save emit with an onSave callable prop so the form wraps its own submit handler in usePendingGuard.
  • Rejected: Guard in the parent and mark each form // pending-guard: N/A — guard lives in parent.
  • Why: guard() takes () => Promise<T>, so a parent handler that takes a payload needs a closure just to be wrappable; keeping the guard where the submit is satisfies the arch test structurally rather than with an exemption marker, matching LaneAddForm.vue.
  • Source: KD-0946

Wiring a guard doesn't include deleting the call site's try/catch ​

  • Chose: Leave the parent's try/catch + dangerToast in place even though it now runs as the guarded body.
  • Rejected: Strip it per the frontend/CLAUDE.md rule that the chokepoint owns error handling.
  • Why: Removing it changes the HTTP-failure UX inside a migration whose scope is re-entry protection, and the same file already has a post-doctrine handler with the identical shape — that cleanup deserves its own decision and review, not a ride-along.
  • Source: KD-0946

Row-level guards use a per-id pending set, not a single shared boolean ​

  • Chose: A ref(new Set<number>()) in the file that owns the mutation, with the pending id threaded to the clicked row via a prop.
  • Rejected: One usePendingGuard() boolean per handler (the TokenManagement.vue shape) reused for list rows.
  • Why: A shared boolean silently no-ops every other row's independent click while one request is in flight and gives the clicked row no feedback — harmless on a short token list, a real regression on comments, issues, and attachments.
  • Source: KD-1016

Re-entry protection is not optimistic UI — keep confirm-then-mutate ​

  • Chose: Disable the control while the request is in flight; the row disappears only once the server confirms.
  • Rejected: Remove the row optimistically and roll back on failure.
  • Why: The shared fs-adapter-store every domain uses hard-codes await httpService.deleteRequest(...) then deleteById(id) with no rollback path, so optimism means either bypassing the adapter per call site (reintroducing the raw mutation it exists to prevent) or a multi-day change to the shared package.
  • Source: KD-1016

New toolbar mark — one more literal Action, no toggleMarkAction factory ​

  • Chose: Add a strike entry to the same flat action array as Bold/Italic, duplicating the established three-line shape.
  • Rejected: Extract a toggleMarkAction(...) factory shared by Bold, Italic, and Strike.
  • Why: The codebase's own headingAction shows it factors an action out only when the action takes a parameter (heading level) — strike takes none, so a factory buys nothing while making the seventh sibling read differently from the other six.
  • Source: KD-1033

Re-enabling a disabled spec — rebuild from a 100%-covered sibling, don't patch the old body ​

  • Chose: Rewrite the disabled select specs from scratch using an already-100%, non-excluded sibling spec as the blueprint.
  • Rejected: Minimally patch the existing test bodies until they pass again.
  • Why: The specs were disabled because their child stubs were propless and template-less and never rendered the #trigger / #entries slots, so the components' template branches were unreachable — patching test-by-test fights that same stub limitation, while a known-100% sibling already encodes the fix.
  • Source: KD-0967

Un-shadow the component under test with per-file vi.unmock, not by editing the global mock list ​

  • Chose: vi.unmock('@shared/…') at the top of each spec (with the import/first eslint-disable), leaving children globally mocked.
  • Rejected: Delete the entries from the global tests/mocks/lists/shared.ts; OR override via config.global.stubs.
  • Why: Those global mocks exist so every other spec mounting a consuming parent gets a light stub — deleting them has app-wide blast radius; and config.global.stubs only reverses VTU's auto-stubbing of a child by name, it cannot undo a module-level vi.mock.
  • Source: KD-0969

Children stay mocked — functional mocks exposing exactly the contract the parent needs ​

  • Chose: Inline mocks declaring the specific props/emits/slots the parent forwards or reacts to (e.g. a Button mock with props: ['disabled'] and a <slot/>).
  • Rejected: Unmock the real children so the parent renders a real subtree.
  • Why: Real children drag unrelated components into the workspace's coverage graph and couple a unit spec to child internals, while a functional mock closes the parent's prop-forwarding, emit, and slot branches without touching source.
  • Source: KD-0969

Inline arrow props are covered by invoking them off the stub, not by a mock template that calls them ​

  • Chose: Read the prop and call it from the test — getComponent({name: 'SingleSelect'}).props('label'), then assert label({id: 50}) returns '50'.
  • Rejected: Have the child mock's template invoke the callback so rendering covers it.
  • Why: shallowMount stubs the child and never renders its template, so a template-side call never executes — the uncovered-function report on an inline :label="(opt) => …" is a real gap, not the "0-statement SFC" V8 quirk it resembles (defineModel emits real instrumented code).
  • Source: KD-0969

Restoring a spec — minimal-touch, don't rewrite it to satisfy the style checklist ​

  • Chose: Add the unmock, fix stale assertions, close coverage gaps, and prune only clearly redundant structural tests, checking each removal against the coverage report first.
  • Rejected: Rewrite each spec down to the testing checklist's 3–6 behaviour-test budget.
  • Why: The goal is a green file back under the 100% gate; a dogmatic rewrite risks silently dropping a branch, and a spec that is merely un-ideal still works as a regression net.
  • Source: KD-0969

Coverage debt — pay it down with tests, never with a whole-file exclusion ​

  • Chose: Restore real tests for every coverage-excluded .vue file, reserving line-level /* v8 ignore next -- reason */ for genuinely unreachable lines.
  • Rejected: Keep the whole-file entries in excludeCoverageForWorkspace and merely tidy their comments.
  • Why: A whole-file exclusion lets the entire file rot uncaught, and the merged auth precedent showed nearly every "V8 quirk / slot content / stub gap" label was recoverable with ordinary testing technique — the labels described the specs, not a real limit.
  • Source: KD-0970

Exclusion labels are claims to re-verify, not facts to honour ​

  • Chose: Re-check each excluded file against its stated reason before accepting it — the "top-level await" label was stale (its spec already mounts green via asyncShallowMount) and two files labelled "No tests" had specs on disk.
  • Rejected: Take the exclusion comments at face value and carve out the ones that sounded like hard framework limits.
  • Why: Labels are written when the exclusion is added and never revisited, so they describe an old workaround rather than a current constraint — honouring them leaves an unjustified suppression in place forever.
  • Source: KD-0970

Coverage exclusions stay for declarative modules, not for components ​

  • Chose: Restore the domain's .vue files; leave constants.ts, tabs.ts, index.ts, route.ts, store.ts, and translations.ts excluded.
  • Rejected: Treat every entry in a domain's exclusion block as debt to clear.
  • Why: Route tables, barrels, constants, and translations are data, not behaviour — a test over them asserts a literal back to itself, and the adapter store is excluded by design (ADR-0013); components are where a regression can actually hide.
  • Source: KD-0970

System theme — wrap fs-theme, don't extend the package ​

  • Chose: Layer mode + matchMedia on top of fs-theme's existing isDark / toggleTheme().
  • Rejected: Add 'system' inside @script-development/fs-theme and publish a new version first.
  • Why: Two repos and two release cycles would block the feature; a kendo-side wrapper ships in one PR and matches the existing thin-adapter pattern.
  • Source: KD-1034

Theme control — three list items, not a new dropdown ​

  • Chose: Extend the existing sidebar <li><button> rows with a check mark; the collapsed button cycles light → dark → system.
  • Rejected: A new dropdown/select for the 3-way choice.
  • Why: A new control category in the sidebar footer is the redesign the issue scoped out; the list-item pattern already exists for Profile/Logout.
  • Source: KD-1034

Theme — no cross-tab live sync ​

  • Chose: A mode change in one tab stays local until the other tab reloads.
  • Rejected: A storage event listener that re-applies mode across open tabs.
  • Why: Neither fs-theme nor the old binary toggle ever synced tabs, and no AC requires it; each tab already follows OS changes via its own matchMedia listener.
  • Source: KD-1034

Theme migration — keep an explicit light/dark choice, else default to system ​

  • Chose: Init from themeMode, else from fs-theme's theme key if the user ever toggled, else 'system'.
  • Rejected: Always default to 'system'.
  • Why: Always-system would silently flip a user who picked Dark while their OS is Light.
  • Source: KD-1034

  • Chose: Replace per-team collapsible groups with a single deduped Projects section; keep the collapse mechanism itself.
  • Rejected: Keep per-team groups and show each shared project only under the first team that claims it.
  • Why: First-team-wins makes a project's location depend on array order, and per-team groups cannot render a multi-team project once.
  • Source: KD-1051

  • Chose: isAdmin || canAccessAllProjects fills the Projects list from the full tenant set, capped.
  • Rejected: Keep the sidebar membership-scoped and send admins to All Projects / pin for the rest.
  • Why: Admins already reach non-member projects (KD-1054); a larger pool self-corrects once recency ranks it.
  • Source: KD-1051

  • Chose: Drop the access-reason tooltip and sub-line entirely after seeing them live.
  • Rejected: Keep "Direct Member" / "Via {Team}" / "Full access" on every project row.
  • Why: Too much text in the sidebar, and not something an average user cares about; if the signal is wanted it belongs on the project Information tab.
  • Source: KD-1051

Recency — backend returns ordered ids, not timestamps ​

  • Chose: pinned_project_ids ordered by pinned_at, plus a new recently_visited_project_ids list on the profile.
  • Rejected: Expose last_visited_at / pinned_at per project and sort in the client.
  • Why: Per-user overlay data does not belong on the shared project resource, and the profile already ships pins as an id list.
  • Source: KD-1052

  • Chose: Recency ranking updates on the next profile refetch (login/reload), not on every project request.
  • Rejected: A client HTTP middleware that unshifts the project id on every /projects/{id} response.
  • Why: Bumping on every in-flight response ping-ponged two projects in the sidebar and showed an order the backend's 5-minute throttle would never produce.
  • Source: KD-1052

Visit = GET browsing, not a mutation ​

  • Chose: Record a visit only on GET, so pin/archive/rename from All Projects do not rank a project.
  • Rejected: Count every request under the projects route group, or maintain a whitelist of visit-worthy routes.
  • Why: Mutations are clickable without opening the project; a named whitelist is the maintenance burden a verb rule avoids.
  • Source: KD-1052

Inline edit — Escape cancels; don't add a cancel button ​

  • Chose: Wire Escape to reset() + exit edit on lanes, with no new IconButton.
  • Rejected: Add a matching cancel-X to lanes for visual parity with labels.
  • Why: AC2 is a keyboard behaviour, not a new mouse control; a button nobody asked for is scope creep.
  • Source: KD-1079

Enter-to-commit — text input only, not the colour select ​

  • Chose: Bind @keydown.enter on the name field only.
  • Rejected: Commit the row when Enter is pressed on the closed colour select.
  • Why: The select uses Enter to open and to pick an option; a wrapper handler fires on the same keystroke and would save the stale colour while the dropdown opens.
  • Source: KD-1079

Inline edit — ignore Escape and Enter while the save is in flight ​

  • Chose: Gate both new keybindings on the existing isSaving* pending check.
  • Rejected: Let Escape (or a second Enter) run cancel/toggle while update() is in flight.
  • Why: usePendingIdGuard only blocks re-entry of the same function, so cancel would revert locally while the PATCH still wins, and a held Enter would exit edit via the guard's instant undefined.
  • Source: KD-1079

Colour picker — a ColorSelect wrapper, not slot markup at every call site ​

  • Chose: ColorSelect bakes in options, option chips, and the trigger swatch; call sites are v-model only.
  • Rejected: Forward the option slot on SingleSelect and repeat the chip markup at each picker.
  • Why: Five identical slot blocks plus non-trivial trigger-swatch logic is a deep module, not a shallow wrapper.
  • Source: KD-1081

Closed colour trigger — CSS swatch, not an upstream value slot ​

  • Chose: A ::before dot on .ui-select__value, coloured via an inherited CSS variable; the colour name stays.
  • Rejected: Wait for a trigger/value slot in @script-development/ui-inputs, or a text-only trigger.
  • Why: The atom's value span is a stable CSS hook, so the dot ships without a package change and the name stays for identification and AT.
  • Source: KD-1081

Markdown — DescriptionProse parses; callers pass source, not HTML ​

  • Chose: Take markdown and derive html in a computed keyed on the source; delete the html prop.
  • Rejected: A computed at each call site, or keep html alongside markdown.
  • Why: A template expression re-parses whenever anything the parent reads changes, and a raw-HTML prop makes sanitization a convention every caller can forget.
  • Source: KD-1103

Filter / shortcut — disable, don't remap ​

  • Chose: A single on/off toggle.
  • Rejected: Let the user pick a different trigger key.
  • Why: Remapping needs a key picker and collision checks, and the issue scoped a remapping system out; the title's "or remap" was leftover wording.
  • Source: KD-1124

Filter-shortcut toggle — inside FiltersPopover, not a settings page ​

  • Chose: A footer checkbox in FiltersPopover, owned by shared/.
  • Rejected: Thread a prop through every FilterBar call site to a Profile settings section.
  • Why: Zero call-site changes, and UserSettings is a server-persisted page that would misrepresent a client-local preference.
  • Source: KD-1124

Low-stakes popover setting — CheckboxField, not ToggleButton ​

  • Chose: A native checkbox, still accent-primary when on.
  • Rejected: The red 20×36 ToggleButton, or a muted/gray checked state.
  • Why: The red switch reads as a primary action in a dense footer, and no checked-state in kendo is anything but kendo-red — gray means disabled, not "on but minor".
  • Source: KD-1124

Tooltip — close-delay so the pointer can reach the card ​

  • Chose: A ~150ms close timer plus hover listeners on the floating card; keyboard and Escape still close immediately.
  • Rejected: An invisible CSS pointer-events bridge across the 8px gap.
  • Why: A geometry bridge breaks across placement values; a timer matches the existing open-delay and is how WCAG 1.4.13 hoverable content actually works.
  • Source: KD-1135

Stale-build reload — confirm at the chokepoint, not at the listeners ​

  • Chose: recoverFromStaleBuild() shows a confirm prompt and reloads only on confirm.
  • Rejected: Patch only the two setTimeout sites the issue cited.
  • Why: The everyday path is router.onError, which calls the helper synchronously and would keep reloading with zero warning.
  • Source: KD-1135

Stale-build prompt — Popover API, not showModal() ​

  • Chose: popover="manual" so the notice enters the top layer without making the page inert.
  • Rejected: A <dialog showModal()> (or a fixed z-index banner).
  • Why: Native dialogs beat any z-index, but showModal() blocks unrelated work; a popover is the only API that stacks above a modal without inerting the page.
  • Source: KD-1135

Page headings — the layout frame injects them, not a per-page component ​

  • Chose: Four wrappers render the <h1> from route meta.heading.
  • Rejected: A PageHeading component on ~45 pages, or promoting FilterBar's <h2>.
  • Why: The wrapper set is small and total; a per-page component can be forgotten, and FilterBar is a context bar covering 23 of 56 pages.
  • Source: KD-1190

Injected page heading is sr-only ​

  • Chose: Screen readers announce it; nothing paints.
  • Rejected: A visible title on 40+ pages.
  • Why: A bare <h1> is 32px with margins and would change every page's appearance, which this issue forbids; a visible title system is a different piece of work.
  • Source: KD-1190

Frame fills heading gaps only ​

  • Chose: A route declares a heading or the page owns an <h1> — never both.
  • Rejected: Demote every existing <h1> and always inject from the frame.
  • Why: Issue and epic pages render author markdown as real <h2>s; demoting the title would make those sections siblings of it, and shifting markdown heading tags would shrink description CSS.
  • Source: KD-1190

Page heading is not the document title ​

  • Chose: A separate heading string on RouteMeta.
  • Rejected: Reuse meta.title / titleResolvers.
  • Why: The tab reads Kendo.dev — Board and [KD-1190] — Kendo.dev; the heading inside that context should read "Board".
  • Source: KD-1190

Heading gate — per route, not per page file ​

  • Chose: Pair each route with its page and assert exactly one of {route heading, page-owned <h1>}.
  • Rejected: Walk page templates for an <h1> (including v-if="heading" on the frame).
  • Why: A frame heading that can render counts for every child, so a template walker reported ten auth pages as covered while they declared no heading at all.
  • Source: KD-1190

Section source — registry owns label/order/key; routes own feature and permission ​

  • Chose: projectSections holds vocabulary; each gated route module exports the gate object its own record spreads.
  • Rejected: Parse meta.feature / meta.permissions off the route tree, or let the registry duplicate those facts.
  • Why: The route tree is [undefined] under the global router mock, so parsing it is untestable; one gate object is a stronger single source than reading a copy out of a record.
  • Source: KD-1262

Sticky project group — session memory first, profile recency as fallback ​

  • Chose: Persist the last project id per user in client storage; fall back to recentlyVisitedProjectIds[0].
  • Rejected: Profile recency alone, or session memory alone.
  • Why: Recency only rewrites every five minutes and the profile copy only refreshes on a profile response, so the list still says A after the user opened B; session-only hides the group on a cold browser.
  • Source: KD-1262

Remembered project — write only an id that resolves ​

  • Chose: Store an id only when it is in activeProjects; delete the clearing watcher.
  • Rejected: Store whatever the URL says, then clear when the list arrives without it.
  • Why: Three review rounds of a clearing watcher never closed the archived-by-URL case — ProjectLayout loads archived projects unscoped, so a bad id reached storage and the clear never saw a value change.
  • Source: KD-1262

Section keycaps — render now, aria-hidden until they bind ​

  • Chose: Show the glyphs; leave shortcuts to the keyboard issue.
  • Rejected: Bind the letters here, or ship SVG icons until shortcuts exist.
  • Why: Binding is the global shortcut layer (input guards, remapping); announcing an unbound key is a lie, so the badge stays hidden from AT.
  • Source: KD-1262

Flyout and user menu — new SidebarPopover, don't extend Dropdown ​

  • Chose: A shared anchored-popover that owns fixed positioning, Escape, focus return, and click-outside.
  • Rejected: Extend Dropdown.vue, or a flyout-only component.
  • Why: The nav is overflow-y-auto so absolute positioning clips; Dropdown has no Escape/focus-return, and changing it would hit every consumer.
  • Source: KD-1263

What's new / Docs — shared user menu, central included ​

  • Chose: Add both items to the shared footer menu.
  • Rejected: A tenant-only slot, or a tenant fork of the footer.
  • Why: A named slot is extra wiring for a distinction the owner does not want, and a fork duplicates theme/logout; central has no existing What's new surface so nothing doubles.
  • Source: KD-1263

  • Chose: Delete useSidebarProjects and the collapsible arm on group labels; PINNED stays, restyle headers to label + rule.
  • Rejected: Keep PROJECTS as mock simplification, and keep the collapse chevron beside the new rule.
  • Why: The mock is literal — only sections + PINNED — and after PROJECTS dies, PINNED is the last collapse consumer.
  • Source: KD-1263

  • Chose: A crumb exists because a route segment does; workspace pages are one crumb.
  • Rejected: Persist a project crumb off-project from activeProject.
  • Why: kendo.dev / projects claims All Projects lives inside one project; a parent the page does not have is worse than a short trail.
  • Source: KD-1264

Page h1 — App.vue, not inside desktop-only chrome ​

  • Chose: The strip is nav only; App.vue renders the sr-only h1 ungated by width or chrome.
  • Rejected: Last crumb as a visible h1, or the heading living inside BreadcrumbStrip.
  • Why: The strip unmounts below md, so an h1 inside it vanished on every mobile viewport; AuthContainer was a second owner of the same heading until it was removed.
  • Source: KD-1264

Breadcrumb strip — inside #main-content, desktop only; no mobile bottom bar ​

  • Chose: Mount v-if="showAppChrome && !isMobile"; mobile reaches sections through the sidebar.
  • Rejected: Above <main>, a ProjectBottomBar fed by the same section source, or restyling MenuTabs as the mobile bar.
  • Why: A skip-link must land above the crumbs; a second nav at a second mount point is what made the h1 vanish on mobile and made the mobile half invisible to tests pinned at isMobile: false.
  • Source: KD-1264

  • Chose: Board reads <project> / board; the strip never reads a page store.
  • Rejected: Prime sprints from the strip, or accept a one-frame-late crumb.
  • Why: The strip mounts outside <Suspense> and the board already shows Sprint: <name> in the FilterBar; a chrome store-read was the only crumb that could be late for a reason the route could not fix.
  • Source: KD-1264

/ opens the palette; FilterBar moves to Cmd+/ ​

  • Chose: Palette takes bare /; filters become ⌘/ / Ctrl+/.
  • Rejected: ⌘K only, or FilterBar wins where it is mounted, or filters as a palette row.
  • Why: One meaning for / app-wide; a modifier needs no input-focus guard and also stops / stealing a keystroke inside TipTap's contenteditable DIV.
  • Source: KD-1265

  • Chose: A palette flag that drops description and ranks exact-key → prefix → title.
  • Rejected: Reuse the endpoint as-is, or a new /api/search.
  • Why: As-is can bury the key you typed under recently-touched description matches; a new endpoint would duplicate scoping and truncation.
  • Source: KD-1265

Palette trigger — a button in the strip; a search icon on mobile ​

  • Chose: Replace the disabled input with a button; add an icon in the mobile header.
  • Rejected: Desktop-only, or a real strip input that hands off to the modal.
  • Why: Touch has no strip, so desktop-only fails "any authenticated page"; two inputs owning one query drop the first character.
  • Source: KD-1265

Palette modal — kendo createModal, not fs-dialog ​

  • Chose: The existing modal service, unchanged.
  • Rejected: @script-development/fs-dialog for this one surface, or adding backdrop-click to createModal.
  • Why: fs-dialog prevents Escape (AC4 would need a hand-rolled handler) and writes body.overflowY off a second stack, so closing a palette over an open modal would unlock scroll behind it.
  • Source: KD-1265

Palette rows — no keycaps ​

  • Chose: Rows are plain labels.
  • Rejected: Render the sidebar letters, or bind those ten letters in this issue.
  • Why: The search input owns every keystroke, so B types "b"; a keycap that points at nothing is the debt the sidebar already declined to ship.
  • Source: KD-1265

Palette a11y — combobox-in-dialog; focus stays on the input ​

  • Chose: role="combobox" + aria-activedescendant; arrows cross groups; Enter activates; Escape closes.
  • Rejected: Roving tabindex onto the rows.
  • Why: Moving DOM focus onto a row would break typing, which is the whole interaction.
  • Source: KD-1265

Page frame — each app owns one; delete the shared DomainLayout ​

  • Chose: Central copies tenant's ~12-line frame; MenuTabs and useDomainLayout die with the shared wrapper.
  • Rejected: Keep a shared frame parameterised by routerView for a tab bar that no longer renders.
  • Why: The prop existed only so MenuTabs could render cross-app links; a parameter whose purpose is gone is not cheaper than twelve duplicated lines.
  • Source: KD-1266

Central pages — no visible title after the tab bar dies ​

  • Chose: Keep the sr-only <h1>; the sidebar's active link names the page.
  • Rejected: Make central's <h1> visible, or port BreadcrumbStrip.
  • Why: A visible title is a visual change on every central page; the strip is out of scope and its resolvers import tenant project APIs.
  • Source: KD-1266

Palette recents — replace PAGES on an empty query only ​

  • Chose: Recents on open; the full page list filters once the user types (including one character).
  • Rejected: Recents instead of PAGES entirely, or an extra RECENT group above an unchanged list.
  • Why: Replacing PAGES entirely means typing "Settings" finds nothing unless it was visited; an extra group makes the open state longer.
  • Source: KD-1290

Palette recents — store the route target, resolve the row live ​

  • Chose: Persist {name, parentId} and rebuild the row against current projects and permissions.
  • Rejected: Snapshot title and meta at visit time.
  • Why: An archived project drops out of activeProjects, a lost permission drops via isSectionAllowed, and a rename shows the new name.
  • Source: KD-1290

Palette recents — exclude the current page; pad to three with defaults ​

  • Chose: Store four so dropping the current page still leaves three; pad with Board, My issues, Projects.
  • Rejected: Show the current page, accept a short group, or fall back to the full list when storage is empty.
  • Why: A row that navigates to where you are is dead weight; a new user would otherwise see an empty or thin group.
  • Source: KD-1290

Icon-only button — no accessible name, no slot, no leftover text; title is not a name ​

  • Chose: Offend when there is no aria-label / aria-labelledby, no <slot>, and no text after stripping child tags.
  • Rejected: Treat title as a name.
  • Why: title-prose-blacklist.spec.ts exists because title is unreachable without a mouse; two gates cannot disagree about whether title names a control.
  • Source: KD-1335

role="button" on a non-button always offends ​

  • Chose: No exemption for a div that already has tabindex, aria-label, and a keydown handler.
  • Rejected: Allow a "keyboard-complete" hand-roll.
  • Why: Completeness is not decidable from markup — @keydown says a handler exists, not that it handles Enter and Space — and a native <button> gets those for free.
  • Source: KD-1335

Role rules — read the attribute's value, including bound ternaries ​

  • Chose: A bound :role contributes every string literal in its expression.
  • Rejected: A regex for the literal role="button" in template text.
  • Why: :role="canAssignSelf ? 'button' : undefined" never matches a literal pattern, so three live sites passed while the gate reported green.
  • Source: KD-1335

A11y gate reader — ship the regex; AST rewrite is KD-1356 ​

  • Chose: Patch the known lexical holes and leave @vue/compiler-dom to the issue that bans raw <button>.
  • Rejected: Rewrite this gate's reader on the Vue compiler in the same PR.
  • Why: Nine findings across three rounds were the same defect class (regex asked a structural question); building the AST reader twice — here and on KD-1356 — is the waste.
  • Source: KD-1335

Composite clickable row — button holds chevron + title; siblings stay outside ​

  • Chose: Outer div is layout; a flex-1 <button> wraps only the disclosure control.
  • Rejected: Row-wide @click plus an inner button with @click.stop.
  • Why: A button must not contain a button, and pointer clicks on badges still toggled the row.
  • Source: KD-1338

Hand-rolled controls — native <button>, not IconButton ​

  • Chose: Each site keeps its existing styling plus a UA reset.
  • Rejected: IconButton at any of the six sites.
  • Why: IconButton wraps a Tooltip and brings its own padding, colour, and focus — restyling controls that already have a look.
  • Source: KD-1338

ToggleButton — required label bound to aria-label ​

  • Chose: label: string on the component, matching IconButton.
  • Rejected: An optional prop with $attrs fallthrough.
  • Why: The gate reads ToggleButton's own template, so a name that only exists on the consumer is invisible, and $attrs is invisible too.
  • Source: KD-1339

Icon-only raw buttons — add aria-label, keep <button> and title ​

  • Chose: Name the existing button; leave title in place.
  • Rejected: Swap them to IconButton now.
  • Why: IconButton would add a Tooltip and its own colour classes at every site; that migration is a separate issue.
  • Source: KD-1339

  • Chose: A GitHub/Linear/Gmail leader chord; other modifiers cancel, Shift/CapsLock do not.
  • Rejected: Ctrl/⌘+letter, or bare letters.
  • Why: The browser swallows ⌘W/⌘T/⌘N and ignores preventDefault, so Workspace, Time Tracking and Inbox could never fire; bare letters spend the single-key namespace the page-action keys need.
  • Source: KD-1362

Chord announcement — aria-description, not aria-keyshortcuts ​

  • Chose: aria-description="Shortcut: G then B"; keycap stays aria-hidden.
  • Rejected: aria-keyshortcuts="G B", or no announcement.
  • Why: aria-keyshortcuts treats a space as alternative shortcuts, so "G B" announces two independent keys — a user told the shortcut is B presses B and nothing happens.
  • Source: KD-1362

Section chord with no active project — silent no-op ​

  • Chose: G B matches nothing when there is no active project.
  • Rejected: Fall back to All Projects, or toast why nothing happened.
  • Why: G B meaning Board on one page and the project list on another is a worse contract than silence, and the sidebar itself renders no section links in that state.
  • Source: KD-1362

Latest-wins — a superseded request must not clear loading ​

  • Chose: Return on undefined without touching loading; only the latest success or reject clears it.
  • Rejected: finally { loading = false } on every call, or an in-flight counter.
  • Why: finally still runs after return, so a stale response painted the dashboard with neither skeleton nor cards; the latest call already owns the flag.
  • Source: KD-1393

Reduced motion — a separate sheet imported by every entry ​

  • Chose: shared/styles/reduced-motion.css imported from tenant, central, and mcp/today main.ts.
  • Rejected: Leave the rule in kendo.css (or import that whole sheet into the widget).
  • Why: kendo.css is 370 lines and only tenant/central import it, so the single-file widget never got the rule.
  • Source: KD-1402

prefers-reduced-motion — one owner ​

  • Chose: After deleting EpicBar's copy, no other src/ file may contain the query.
  • Rejected: Allow per-component overrides.
  • Why: The global !important duration already zeroes transitions, and a second copy is how the widget was left out.
  • Source: KD-1402