Appearance
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
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