Skip to content

UI Conventions Decisions

Distilled from 14 plans (10 plans had no DECISIONS.md or no real tradeoffs). 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