Appearance
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
ComboboxSelectandComboboxMultiSelectcomponents. - 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
SearchableSelectandMultiSelect. - Rejected: Use the existing
#noResultslot 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 insideSelectContainer, with a runtime exclusion for selects rendered inside<dialog>ancestors. - Rejected: Cap per form; OR add a
noMaxWidthopt-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
:rootvia UnoCSS preflights; layout dimensions consumed via:stylebindings inApp.vue. - Rejected: TypeScript constants exported from a shared module, OR both combined.
- Why: TS constants can't be consumed in CSS;
:stylebindings beat any UnoCSS class on specificity, eliminating the cascade order bug that kept causingpb-4to overridelt-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
SharedDomainLayoutandProjectLayout. - 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.vuecovers all tables. - Rejected:
stripedprop onTableBody.vuerequiring 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 (
#181820dark,#f8f6f3light), applied to:nth-child(even). - Rejected: Reuse
bg-kendo-surfaceto match the epic-board precedent. - Why:
--surfacecollides with--surface-headerin dark mode (rows blend into header) and with--surface-raisedin 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-heighton the sharedTextAreaField. - 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 letsEdit.vueupdate it via a deep watch. - Rejected: Keep
() => booleangetter and bind a closure over alet snapshotvariable; OR accept both shapes. - Why:
inject()-based router context only works synchronously during setup before anyawait. The ref-based form lets the guard register synchronously while still computing the snapshot afteruseIssueFromSlug()resolves. - Source: KD-0444
Navigation guard — use routerService.registerBeforeRouteMiddleware, not onBeforeRouteLeave
- Chose: Composable accepts a
registercallback; the page wires the project's middleware registrar. - Rejected: Call
onBeforeRouteLeavefrom 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), soonBeforeRouteLeavesilently no-ops everywhere. ImportingrouterServicedirectly 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
ChangelogEntrymodel 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
Changelog navigation — external link to kendo.dev/changelog, not in-app route
- Chose: Sidebar link opens VitePress changelog in a new tab.
- Rejected: In-app
/changelogVue 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
SidebarPilltaking alabel: stringfor neutral text pills. - Rejected: Add a
variant="pill"prop toSidebarBadge(already used for unread counts). - Why:
SidebarBadgeis hard-coded for numericcountwithbg-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
contentas optional fallback alongside slot ("soft deprecation"). - Why: Soft deprecation has no enforcement and loses
vue-tscas 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
labelprop toforId. - Rejected:
htmlFor(React convention); ORfor(exact HTML attribute). - Why:
foris a JavaScript reserved word and can't be destructured;htmlForis React drift in a Vue codebase.forIdreads 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-tscruns 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
generateStorypolicy to admin-only. - Why:
generateStoryalso 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
createNestedDomainChildrento acceptmetaOverrides; OR post-patch generated route meta. - Why: Feature Planner has no CRUD, so the factory provides no real benefit; a one-off
minRoledoesn'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:UPDATEandTenantAiKeys:CREATE; bare/settingsuses a smart redirect middleware that runs beforeauthMiddleware. - Rejected: Gate on
AppSettings:READandTenantAiKeys: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.tsandsettingsTabs.tsalongside the existing users files; pages stay inusers/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
shallowRefstack with a singlewatchEffectreading the top getter;onUnmountedpops the entry. - Rejected: Each
useTitlecall setsdocument.titledirectly and restores the previous value on unmount. - Why: Two independent
watchEffects both writingdocument.titlerace 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-levelTenantTitlefallback 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/editshows the same[KD-XXXX] <title> — <project> — Kendotab 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
getStartOfWeekwith the shared one (which also zeroes time-of-day viagetStartOfDay), even though the private version preserved the input's time. - Rejected: Add a
preserveTimeflag / 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) andDividerDashed.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.permissionmiddleware 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/makeEpicStoreconstruct 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.tsafterrouterServiceis constructed; only static-stringmeta.titlestays 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 readsrouterServicewhilerouter/index.tswas 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 existingbreakpoint.ts/ pagination convention). - Rejected: Bind a class conditionally from the existing
isTouchDeviceref; OR a custom(pointer: coarse)-alone variant; OR bumpmin-heightunconditionally 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: finedevice); 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-11toButton.vueitself 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-11directly 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
matchMediamocking that asserts computed height. - Why: JSDOM runs no CSS layout (
getBoundingClientRect()is zero everywhere) andmatchMediamocks 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.vueand 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
*Legacyrename 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
useIssueFilterBarfor 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
useIssueFilterBarto 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?sinceAPI fetch) and view toggles likeGranularityFilterin the bar's#action/#leadingslots, 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'](withinheritAttrs: 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 onaria-label. - Why: For icon-only affordances the
aria-labeland 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;focusinflipsopensynchronously 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 witharia-hiddentoggling when closed. - Why: Always-in-DOM means 56+ persistent hidden nodes app-wide plus
aria-hiddenplumbing 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
:hoverflickers, 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
variantprop. - 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
arrowmiddleware; 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, leavingsm/md/lg/xl/2xlat preset defaults. - Rejected: Redefine
lgto 1100px; OR replace defaults with epic-named tiers (mobile/tablet/desktop). - Why: Shifting
lg's threshold silently invalidates sibling migrations that treatlg:as 1024px, and renaming forces a ~50-file migration plus breaks the shared mental model thatmd: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 runtimebreakpoint.tsservice anduno.config.mtsboth import from it. - Rejected: Co-locate the constants inside the
breakpoint.tsservice 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
windowrisks 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
@keydownhandler on the<form>wrappingRichTextAreato 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.entermodifiers. - Why: ProseMirror doesn't
stopPropagationon 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 (matchingFilterBar) 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
onBeforeUnmountfallback to catch the tab strip unmountingCommentSection. - Why: Vue unmount isn't cancelable, so
onBeforeUnmountcan'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 separateLinkPanel.vuecomponent. - Why:
prompt()is a browser-native dialog inconsistent with app UX, a floating popover would need a new primitive (Dropdown.vueowns 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):/iblocklist as a "first line of defence" (shipped first, then removed on review). - Why: TipTap's
setLinkalready 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.vuewith a documentedeslint-disable max-lines. - Rejected: Extract a composable purely to get under the
max-linescap. - 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 realLinkPanel.vuecomponent with explicit emits, so an honest suppression beats a fake abstraction until that extraction is warranted. - Source: KD-0851
Editor link mark stays inclusive (default), consistent with other marks
- Chose: Leave
autolinkat its default so the link mark isinclusive: true; typing at the end of a link extends it, and→moves the cursor past the mark. - Rejected:
autolink: false, making the markinclusive: falseso 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
Editor toolbar — link and image share one "insert content" group
- 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')inProjectController::index()as the single source of truth for every API consumer. - Why:
retrieveAllstores records in an object keyed by numeric id andgetAllreturnsObject.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
defaultSortKeyto the shared composable sosortKeystarts populated instead ofnull. - 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.vueto 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 projectaccesspolicy and serve itcache.headers:private. - Rejected: Copy
ProfilePictureController'spubliccache 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
isSavingprop +saveemit with anonSavecallable prop so the form wraps its own submit handler inusePendingGuard. - 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, matchingLaneAddForm.vue. - Source: KD-0946
Wiring a guard doesn't include deleting the call site's try/catch
- Chose: Leave the parent's
try/catch+dangerToastin place even though it now runs as the guarded body. - Rejected: Strip it per the
frontend/CLAUDE.mdrule 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 (theTokenManagement.vueshape) 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-storeevery domain uses hard-codesawait httpService.deleteRequest(...)thendeleteById(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
headingActionshows 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/#entriesslots, 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 theimport/firsteslint-disable), leaving children globally mocked. - Rejected: Delete the entries from the global
tests/mocks/lists/shared.ts; OR override viaconfig.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.stubsonly reverses VTU's auto-stubbing of a child by name, it cannot undo a module-levelvi.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. aButtonmock withprops: ['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 assertlabel({id: 50})returns'50'. - Rejected: Have the child mock's template invoke the callback so rendering covers it.
- Why:
shallowMountstubs 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 (defineModelemits 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
.vuefile, reserving line-level/* v8 ignore next -- reason */for genuinely unreachable lines. - Rejected: Keep the whole-file entries in
excludeCoverageForWorkspaceand 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
.vuefiles; leaveconstants.ts,tabs.ts,index.ts,route.ts,store.ts, andtranslations.tsexcluded. - 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-themeand 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
storageevent 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'sthemekey 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
Sidebar projects — one flat list, not per-team groups
- 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
Sidebar — admins see every tenant project
- Chose:
isAdmin || canAccessAllProjectsfills 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
Sidebar — no access-reason text
- 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_idsordered bypinned_at, plus a newrecently_visited_project_idslist on the profile. - Rejected: Expose
last_visited_at/pinned_atper 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
Sidebar recency — no mid-session live reorder
- 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.enteron 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:
usePendingIdGuardonly 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 instantundefined. - Source: KD-1079
Colour picker — a ColorSelect wrapper, not slot markup at every call site
- Chose:
ColorSelectbakes in options, option chips, and the trigger swatch; call sites arev-modelonly. - Rejected: Forward the
optionslot onSingleSelectand 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
::beforedot 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
markdownand derivehtmlin a computed keyed on the source; delete thehtmlprop. - Rejected: A
computedat each call site, or keephtmlalongsidemarkdown. - 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 byshared/. - 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-primarywhen 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-eventsbridge 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
setTimeoutsites 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 pageinert. - Rejected: A
<dialog showModal()>(or a fixedz-indexbanner). - 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 routemeta.heading. - Rejected: A
PageHeadingcomponent 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
headingstring onRouteMeta. - Rejected: Reuse
meta.title/titleResolvers. - Why: The tab reads
Kendo.dev — Boardand[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>(includingv-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:
projectSectionsholds vocabulary; each gated route module exports the gate object its own record spreads. - Rejected: Parse
meta.feature/meta.permissionsoff 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 —
ProjectLayoutloads 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-autoso 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
Sidebar — drop the flat Projects group and collapsibility
- Chose: Delete
useSidebarProjectsand thecollapsiblearm 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
Breadcrumbs — follow the route, not the remembered project
- 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 / projectsclaims 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.vuerenders the sr-onlyh1ungated by width or chrome. - Rejected: Last crumb as a visible
h1, or the heading living insideBreadcrumbStrip. - Why: The strip unmounts below
md, so anh1inside it vanished on every mobile viewport;AuthContainerwas 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>, aProjectBottomBarfed by the same section source, or restylingMenuTabsas the mobile bar. - Why: A skip-link must land above the crumbs; a second nav at a second mount point is what made the
h1vanish on mobile and made the mobile half invisible to tests pinned atisMobile: false. - Source: KD-1264
Breadcrumbs — no sprint crumb
- 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 showsSprint: <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:
⌘Konly, 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 contenteditableDIV. - Source: KD-1265
Palette search — opt-in mode on GET /api/issues/search
- Chose: A
paletteflag 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-dialogfor this one surface, or adding backdrop-click tocreateModal. - Why:
fs-dialogprevents Escape (AC4 would need a hand-rolled handler) and writesbody.overflowYoff 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
Btypes "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
tabindexonto 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
useDomainLayoutdie with the shared wrapper. - Rejected: Keep a shared frame parameterised by
routerViewfor 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 viaisSectionAllowed, 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
titleas a name. - Why:
title-prose-blacklist.spec.tsexists becausetitleis unreachable without a mouse; two gates cannot disagree about whethertitlenames a control. - Source: KD-1335
role="button" on a non-button always offends
- Chose: No exemption for a
divthat already hastabindex,aria-label, and a keydown handler. - Rejected: Allow a "keyboard-complete" hand-roll.
- Why: Completeness is not decidable from markup —
@keydownsays 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
:rolecontributes 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-domto 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
@clickplus 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: stringon the component, matching IconButton. - Rejected: An optional prop with
$attrsfallthrough. - Why: The gate reads ToggleButton's own template, so a name that only exists on the consumer is invisible, and
$attrsis invisible too. - Source: KD-1339
Icon-only raw buttons — add aria-label, keep <button> and title
- Chose: Name the existing button; leave
titlein 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
Sidebar shortcuts — G then the letter, not a modifier per letter
- 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/⌘Nand ignorespreventDefault, 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 staysaria-hidden. - Rejected:
aria-keyshortcuts="G B", or no announcement. - Why:
aria-keyshortcutstreats a space as alternative shortcuts, so"G B"announces two independent keys — a user told the shortcut isBpressesBand nothing happens. - Source: KD-1362
Section chord with no active project — silent no-op
- Chose:
G Bmatches nothing when there is no active project. - Rejected: Fall back to All Projects, or toast why nothing happened.
- Why:
G Bmeaning 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
undefinedwithout touching loading; only the latest success or reject clears it. - Rejected:
finally { loading = false }on every call, or an in-flight counter. - Why:
finallystill runs afterreturn, 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.cssimported from tenant, central, andmcp/todaymain.ts. - Rejected: Leave the rule in
kendo.css(or import that whole sheet into the widget). - Why:
kendo.cssis 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 othersrc/file may contain the query. - Rejected: Allow per-component overrides.
- Why: The global
!importantduration already zeroes transitions, and a second copy is how the widget was left out. - Source: KD-1402