@starlove/orbit 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -76,6 +76,7 @@ input[type="file"] {
76
76
  --orbit-add-border: rgba(29, 37, 40, 0.12);
77
77
  --orbit-phantom-border: rgba(29, 37, 40, 0.14);
78
78
  --orbit-phantom-bg: rgba(29, 37, 40, 0.03);
79
+ --kanban-column-width: 22rem;
79
80
  --scrollbar-thumb: rgba(var(--accent-rgb),0.45);
80
81
  --scrollbar-thumb-hover: rgba(var(--accent-rgb),0.65);
81
82
  --scrollbar-track: transparent;
@@ -100,6 +101,12 @@ input[type="file"] {
100
101
  --select-chevron-size: 12px;
101
102
  }
102
103
 
104
+ @media (max-width: 720px) {
105
+ :root {
106
+ --kanban-column-width: 18rem;
107
+ }
108
+ }
109
+
103
110
  * {
104
111
  box-sizing: border-box;
105
112
  }
@@ -529,6 +536,12 @@ h1 {
529
536
  scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
530
537
  }
531
538
 
539
+ #boardFlyout {
540
+ min-width: 20.25rem;
541
+ max-width: min(30rem, calc(100vw - 1.5rem));
542
+ max-height: min(36rem, calc(100vh - 4rem));
543
+ }
544
+
532
545
  .menu-flyout--align-end {
533
546
  right: 0;
534
547
  left: auto;
@@ -1109,7 +1122,7 @@ h1 {
1109
1122
  /* Horizontal scroll: styled thumb/track (Firefox + WebKit). */
1110
1123
  .kanban {
1111
1124
  display: grid;
1112
- grid-auto-columns: minmax(17rem, 1fr);
1125
+ grid-auto-columns: minmax(var(--kanban-column-width), 1fr);
1113
1126
  grid-auto-flow: column;
1114
1127
  grid-template-rows: minmax(0, 1fr);
1115
1128
  flex: 1 1 auto;
@@ -1704,8 +1717,8 @@ h1 {
1704
1717
  /* Epic-aware grouping inside a column.
1705
1718
  `.epic-children` is a flex item nested in `.column` (which is itself flex-column,
1706
1719
  gap 0.7rem). Right-aligning + narrowing it makes its cards read as indented
1707
- children of the epic card or `.epic-mini-header` directly above. The negative
1708
- margin-top tightens the visual link by halving the column gap. */
1720
+ children of the epic card or full-width `.epic-mini-header` directly above.
1721
+ The negative margin-top tightens the visual link by halving the column gap. */
1709
1722
  .epic-children {
1710
1723
  display: flex;
1711
1724
  flex-direction: column;
@@ -1744,15 +1757,14 @@ h1 {
1744
1757
  0 8px 20px rgba(0, 0, 0, 0.25);
1745
1758
  }
1746
1759
 
1747
- /* Thin "header" that stands in for the epic when its children sit in a different
1748
- column. Visually pairs with the `.epic-children` block right below it: same
1749
- width and right-alignment, slim purple bar shape. */
1760
+ /* Thin full-width "header" that stands in for the epic when its children sit in
1761
+ a different column. Children remain narrowed underneath so the hierarchy is
1762
+ visible without cutting the lane width at the epic anchor. */
1750
1763
  .epic-mini-header {
1751
1764
  display: flex;
1752
1765
  align-items: center;
1753
1766
  gap: 0.4rem;
1754
- width: 92%;
1755
- align-self: flex-end;
1767
+ width: 100%;
1756
1768
  padding: 0.45rem 0.6rem;
1757
1769
  border-radius: 10px 10px 4px 4px;
1758
1770
  background: rgba(var(--purple-rgb), 0.10);
@@ -2144,7 +2156,13 @@ h1 {
2144
2156
  background: var(--drawer-bg);
2145
2157
  box-shadow: 0 24px 60px rgba(12, 18, 26, 0.28);
2146
2158
  transform: translateX(calc(100% + 1.5rem));
2147
- transition: transform 320ms cubic-bezier(0.4, 0, 0.2, 1);
2159
+ transition:
2160
+ transform 320ms cubic-bezier(0.4, 0, 0.2, 1),
2161
+ width 180ms ease;
2162
+ }
2163
+
2164
+ .drawer.is-wide {
2165
+ width: min(58rem, calc(100vw - 1rem));
2148
2166
  }
2149
2167
 
2150
2168
  .drawer.is-open {
@@ -2286,8 +2304,8 @@ h1 {
2286
2304
  border-color: var(--accent-border);
2287
2305
  }
2288
2306
 
2289
- .drawer-close {
2290
- grid-area: close;
2307
+ .drawer-close,
2308
+ .drawer-archive {
2291
2309
  display: inline-grid;
2292
2310
  place-items: center;
2293
2311
  width: 2rem;
@@ -2297,10 +2315,14 @@ h1 {
2297
2315
  border-radius: 10px;
2298
2316
  background: var(--surface);
2299
2317
  color: var(--ink);
2318
+ cursor: url('cursors/live_link.cur'), pointer;
2319
+ }
2320
+
2321
+ .drawer-close {
2322
+ grid-area: close;
2300
2323
  font-size: 1.35rem;
2301
2324
  font-weight: 400;
2302
2325
  line-height: 1;
2303
- cursor: url('cursors/live_link.cur'), pointer;
2304
2326
  }
2305
2327
 
2306
2328
  .drawer-close:hover {
@@ -2310,16 +2332,6 @@ h1 {
2310
2332
 
2311
2333
  .drawer-archive {
2312
2334
  grid-area: archive;
2313
- display: inline-grid;
2314
- place-items: center;
2315
- width: 2rem;
2316
- height: 2rem;
2317
- padding: 0;
2318
- border: 1px solid var(--line);
2319
- border-radius: 10px;
2320
- background: var(--surface);
2321
- color: var(--ink);
2322
- cursor: url('cursors/live_link.cur'), pointer;
2323
2335
  }
2324
2336
 
2325
2337
  .drawer-archive:hover {
@@ -2463,6 +2475,95 @@ h1 {
2463
2475
  white-space: pre-wrap;
2464
2476
  }
2465
2477
 
2478
+ .markdown-body {
2479
+ white-space: normal;
2480
+ overflow-wrap: anywhere;
2481
+ }
2482
+
2483
+ .markdown-body p,
2484
+ .markdown-body ul,
2485
+ .markdown-body ol,
2486
+ .markdown-body pre {
2487
+ margin: 0;
2488
+ }
2489
+
2490
+ .markdown-body p + p,
2491
+ .markdown-body p + ul,
2492
+ .markdown-body p + ol,
2493
+ .markdown-body p + pre,
2494
+ .markdown-body ul + p,
2495
+ .markdown-body ul + ol,
2496
+ .markdown-body ul + pre,
2497
+ .markdown-body ol + p,
2498
+ .markdown-body ol + ul,
2499
+ .markdown-body ol + pre,
2500
+ .markdown-body pre + p,
2501
+ .markdown-body pre + ul,
2502
+ .markdown-body pre + ol {
2503
+ margin-top: 0.55rem;
2504
+ }
2505
+
2506
+ .markdown-body ul,
2507
+ .markdown-body ol {
2508
+ padding-left: 1.25rem;
2509
+ }
2510
+
2511
+ .markdown-body li + li {
2512
+ margin-top: 0.2rem;
2513
+ }
2514
+
2515
+ .markdown-body code {
2516
+ border: 1px solid rgba(var(--blue-rgb), 0.18);
2517
+ border-radius: 0.35rem;
2518
+ padding: 0.05rem 0.25rem;
2519
+ color: var(--ink);
2520
+ background: rgba(var(--blue-rgb), 0.08);
2521
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
2522
+ font-size: 0.9em;
2523
+ }
2524
+
2525
+ .markdown-body pre {
2526
+ padding: 0.65rem 0.75rem;
2527
+ overflow: auto;
2528
+ border: 1px solid var(--line);
2529
+ border-radius: 10px;
2530
+ background: var(--surface-card);
2531
+ }
2532
+
2533
+ .markdown-body pre code {
2534
+ display: block;
2535
+ border: 0;
2536
+ padding: 0;
2537
+ background: transparent;
2538
+ white-space: pre;
2539
+ }
2540
+
2541
+ .markdown-body a {
2542
+ color: var(--blue);
2543
+ font-weight: 700;
2544
+ text-decoration: underline;
2545
+ text-decoration-thickness: 0.08em;
2546
+ text-underline-offset: 0.15em;
2547
+ }
2548
+
2549
+ .card-description {
2550
+ margin-top: 0.45rem;
2551
+ color: var(--muted);
2552
+ font-size: 0.82rem;
2553
+ line-height: 1.45;
2554
+ }
2555
+
2556
+ .card .markdown-body p {
2557
+ display: block;
2558
+ overflow: visible;
2559
+ -webkit-line-clamp: unset;
2560
+ -webkit-box-orient: initial;
2561
+ }
2562
+
2563
+ .card .markdown-body pre {
2564
+ max-height: 8rem;
2565
+ }
2566
+
2466
2567
  /* The detail pane's lede description is body copy, not a card excerpt — give
2467
2568
  it more presence than the universal muted .description rule. */
2468
2569
  .detail-head > .description {
@@ -3194,6 +3295,157 @@ details.section > summary .mcp-title-row {
3194
3295
  margin-bottom: 0.85rem;
3195
3296
  }
3196
3297
 
3298
+ .appearance-theme-section {
3299
+ margin-top: 0.4rem;
3300
+ }
3301
+
3302
+ .appearance-theme-section + .section:has(> .orbit-check:first-child) {
3303
+ margin-top: 1.25rem;
3304
+ }
3305
+
3306
+ .appearance-theme-field {
3307
+ display: grid;
3308
+ gap: 0.55rem;
3309
+ min-width: 0;
3310
+ margin: 0;
3311
+ padding: 0;
3312
+ border: 0;
3313
+ }
3314
+
3315
+ .appearance-theme-field legend {
3316
+ margin-bottom: 0.55rem;
3317
+ color: var(--ink);
3318
+ font-size: 0.88rem;
3319
+ font-weight: 800;
3320
+ }
3321
+
3322
+ .appearance-theme-options {
3323
+ display: grid;
3324
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3325
+ gap: 0.5rem;
3326
+ }
3327
+
3328
+ .appearance-theme-option {
3329
+ position: relative;
3330
+ display: inline-flex;
3331
+ min-width: 0;
3332
+ min-height: 2.5rem;
3333
+ align-items: center;
3334
+ justify-content: center;
3335
+ gap: 0.45rem;
3336
+ padding: 0.5rem 0.72rem;
3337
+ border: 1px solid var(--line);
3338
+ border-radius: 10px;
3339
+ background: var(--surface-dim);
3340
+ color: var(--ink);
3341
+ font-size: 0.86rem;
3342
+ font-weight: 700;
3343
+ cursor: url('cursors/live_link.cur'), pointer;
3344
+ transition:
3345
+ border-color 160ms ease,
3346
+ background 160ms ease,
3347
+ box-shadow 160ms ease,
3348
+ color 160ms ease;
3349
+ }
3350
+
3351
+ .appearance-theme-option:hover {
3352
+ border-color: var(--accent-border);
3353
+ background: var(--accent-hover);
3354
+ }
3355
+
3356
+ .appearance-theme-option input[type="radio"] {
3357
+ position: absolute;
3358
+ width: 1px;
3359
+ height: 1px;
3360
+ padding: 0;
3361
+ margin: -1px;
3362
+ overflow: hidden;
3363
+ clip: rect(0, 0, 0, 0);
3364
+ white-space: nowrap;
3365
+ border: 0;
3366
+ }
3367
+
3368
+ .appearance-theme-option-mark {
3369
+ display: grid;
3370
+ width: 1.35rem;
3371
+ height: 1.35rem;
3372
+ flex: 0 0 auto;
3373
+ place-items: center;
3374
+ border: 1px solid var(--accent-border);
3375
+ border-radius: 999px;
3376
+ background: var(--surface-input);
3377
+ transition:
3378
+ background 160ms ease,
3379
+ border-color 160ms ease,
3380
+ box-shadow 160ms ease;
3381
+ }
3382
+
3383
+ .appearance-theme-option-icon {
3384
+ width: 0.9rem;
3385
+ height: 0.9rem;
3386
+ }
3387
+
3388
+ .appearance-theme-option-label {
3389
+ min-width: 0;
3390
+ overflow: hidden;
3391
+ text-overflow: ellipsis;
3392
+ white-space: nowrap;
3393
+ }
3394
+
3395
+ .appearance-theme-option:has(input[type="radio"]:checked) {
3396
+ border-color: var(--accent);
3397
+ background:
3398
+ linear-gradient(135deg, rgba(var(--accent-rgb),0.12), rgba(var(--blue-rgb),0.08)),
3399
+ var(--surface-dim);
3400
+ box-shadow:
3401
+ inset 0 0 0 1px rgba(var(--accent-rgb),0.18),
3402
+ 0 4px 10px rgba(var(--accent-rgb),0.12);
3403
+ }
3404
+
3405
+ .appearance-theme-option:has(input[type="radio"]:checked) .appearance-theme-option-mark {
3406
+ border-color: var(--accent);
3407
+ color: #fff;
3408
+ background:
3409
+ linear-gradient(135deg, rgba(var(--accent-rgb),0.95), rgba(var(--blue-rgb),0.95));
3410
+ }
3411
+
3412
+ .appearance-theme-option input[type="radio"]:focus-visible ~ .appearance-theme-option-mark {
3413
+ box-shadow: var(--accent-ring);
3414
+ }
3415
+
3416
+ [data-theme="dark"] .appearance-theme-option {
3417
+ background: rgba(15, 25, 50, 0.55);
3418
+ border-color: rgba(var(--starlight-rgb),0.16);
3419
+ }
3420
+
3421
+ [data-theme="dark"] .appearance-theme-option:hover {
3422
+ border-color: rgba(var(--starlight-rgb),0.4);
3423
+ background: rgba(var(--starlight-rgb),0.08);
3424
+ }
3425
+
3426
+ [data-theme="dark"] .appearance-theme-option-mark {
3427
+ border-color: rgba(var(--starlight-rgb),0.35);
3428
+ background:
3429
+ linear-gradient(135deg, rgba(var(--starlight-rgb),0.06), rgba(var(--cosmos-rgb),0) 65%),
3430
+ rgba(15, 25, 50, 0.7);
3431
+ }
3432
+
3433
+ [data-theme="dark"] .appearance-theme-option:has(input[type="radio"]:checked) {
3434
+ border-color: rgba(var(--starlight-rgb),0.55);
3435
+ background:
3436
+ linear-gradient(135deg, rgba(var(--starlight-rgb),0.13), rgba(var(--violet-rgb),0.08)),
3437
+ rgba(15, 25, 50, 0.65);
3438
+ box-shadow:
3439
+ inset 0 0 0 1px rgba(var(--starlight-rgb),0.18),
3440
+ 0 4px 14px rgba(var(--starlight-rgb),0.16);
3441
+ }
3442
+
3443
+ [data-theme="dark"] .appearance-theme-option:has(input[type="radio"]:checked) .appearance-theme-option-mark {
3444
+ border-color: rgba(var(--starlight-rgb),0.7);
3445
+ color: #0a1024;
3446
+ background: linear-gradient(135deg, #d6e6ff, #a8c0ff);
3447
+ }
3448
+
3197
3449
  /* Lanes tab opens directly with the checkbox — drop the default .section
3198
3450
  top margin so it sits flush under the drawer tabs. */
3199
3451
  .section:has(> .orbit-check:first-child) {
@@ -4828,6 +5080,27 @@ button:disabled,
4828
5080
  }
4829
5081
  }
4830
5082
 
5083
+ [data-reduced-motion="reduce"] *,
5084
+ [data-reduced-motion="reduce"] *::before,
5085
+ [data-reduced-motion="reduce"] *::after {
5086
+ scroll-behavior: auto !important;
5087
+ transition-duration: 0.01ms !important;
5088
+ animation-duration: 0.01ms !important;
5089
+ animation-iteration-count: 1 !important;
5090
+ }
5091
+
5092
+ [data-reduced-motion="reduce"][data-theme="dark"] .starfield,
5093
+ [data-reduced-motion="reduce"][data-theme="dark"] .milky-way,
5094
+ [data-reduced-motion="reduce"][data-theme="dark"] .grain::before,
5095
+ [data-reduced-motion="reduce"][data-theme="dark"] .grain::after,
5096
+ [data-reduced-motion="reduce"][data-theme="dark"] .signature-star {
5097
+ animation: none;
5098
+ }
5099
+
5100
+ [data-reduced-motion="reduce"][data-theme="dark"] .meteor {
5101
+ display: none;
5102
+ }
5103
+
4831
5104
  [data-theme="dark"] {
4832
5105
  /* Tells the browser to render native widgets (dropdowns, scrollbars,
4833
5106
  date pickers, etc.) in their dark variant so option popups match. */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starlove/orbit",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "edition": "full",
5
- "builtAt": "2026-05-20T07:25:07.448Z"
5
+ "builtAt": "2026-05-23T20:12:10.627Z"
6
6
  }
@@ -4,6 +4,8 @@ The board is intentionally agent-first. Agents should not scrape the UI. They sh
4
4
 
5
5
  If an MCP client is available, use the MCP server instead of rebuilding these HTTP calls yourself. The MCP tools map directly to the same core operations.
6
6
 
7
+ Never edit `.orbit/board.db` directly to create or mutate cards. The SQLite file is Orbit's storage detail; use the HTTP API or MCP tools so validation, events, search indexing, backups, and cross-process locking all run.
8
+
7
9
  ## Core Flow
8
10
 
9
11
  1. `POST /api/agent/claim-next`
@@ -298,6 +300,8 @@ Over MCP these are exposed as `board_archive_ticket`, `board_restore_ticket`, `b
298
300
 
299
301
  Ticket types are `epic`, `feature`, `task`, and `bug`.
300
302
 
303
+ MCP equivalent: `board_create_ticket`. Use it for new cards instead of modifying `.orbit/board.db`.
304
+
301
305
  Create the epic/story parent first:
302
306
 
303
307
  ```http
package/docs/MCP_SETUP.md CHANGED
@@ -29,7 +29,9 @@ The checked-in example file is not read automatically by Starscape Orbit. It exi
29
29
  3. Replace the path to `src/mcp-server.js` with the absolute path on your machine.
30
30
  4. Restart the MCP client.
31
31
 
32
- The MCP server finds a board from an explicit project root first: pass `orbit mcp --cwd <repo>` or set `PROJECT_ROOT=<repo>` in persistent MCP configs. If neither is set, it falls back to the process cwd and walks upward to the first ancestor with `.orbit/board.db`. Prefer explicit roots for long-lived agent configs so launching the agent from another folder does not attach the wrong board. **`AGENTS.md` / `SKILL-ORBIT.md`** are unrelated to MCP transport: agents open them via the workspace file tree, and `orbit init` keeps a terse Orbit pointer section in `AGENTS.md`.
32
+ By default MCP runs in local mode. It finds a board from an explicit project root first: pass `orbit mcp --cwd <repo>` or set `PROJECT_ROOT=<repo>` in persistent MCP configs. If neither is set, it falls back to the process cwd and walks upward to the first ancestor with `.orbit/board.db`. Prefer explicit roots for long-lived agent configs so launching the agent from another folder does not attach the wrong board.
33
+
34
+ For a shared/deployed board, run MCP in remote mode instead: set `ORBIT_MODE=remote`, `ORBIT_API_URL=<server origin>`, and usually `ORBIT_DEFAULT_BOARD=<slug-or-id>`. Remote mode calls the HTTP API and does not open or auto-create local `.orbit` databases; it fails at startup if `ORBIT_API_URL` is missing. **`AGENTS.md` / `SKILL-ORBIT.md`** are unrelated to MCP transport: agents open them via the workspace file tree, and `orbit init` keeps a terse Orbit pointer section in `AGENTS.md`.
33
35
 
34
36
  Typical use:
35
37
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@starlove/orbit",
3
- "version": "1.0.6",
4
- "description": "Starscape Orbit — SQLite-backed, agent-first task board (Starscape suite).",
3
+ "version": "1.0.7",
4
+ "description": "Starscape Orbit — zero-dependency, pretty, human-ai-first kanban system.",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
7
7
  "packageManager": "pnpm@10.20.0",
package/public/js/api.js CHANGED
@@ -9,7 +9,11 @@ import { request } from "./transport.js";
9
9
 
10
10
  /** Bootstrap for the currently selected board (server opens the right DB). */
11
11
  export function bootstrapPath() {
12
- const q = state.boardId ? `?board_id=${encodeURIComponent(state.boardId)}` : "";
12
+ const q = state.boardId
13
+ ? `?board_id=${encodeURIComponent(state.boardId)}`
14
+ : state.boardSlug
15
+ ? `?board=${encodeURIComponent(state.boardSlug)}`
16
+ : "";
13
17
  return `/api/bootstrap${q}`;
14
18
  }
15
19
 
@@ -50,6 +50,7 @@ export function closeDrawer() {
50
50
  }
51
51
  if (subtitleEl) { subtitleEl.innerHTML = ""; subtitleEl.hidden = true; }
52
52
  if (tabsEl) { tabsEl.innerHTML = ""; tabsEl.hidden = true; }
53
+ drawer.classList.remove("is-wide");
53
54
  };
54
55
 
55
56
  if (!wasOpen) {
@@ -75,6 +76,7 @@ export function closeDrawer() {
75
76
  */
76
77
  export function renderDrawerShell({ eyebrow, title, titleAttrs, subtitleHtml, tabs, activeTab, onTabSelect, body, mode }) {
77
78
  drawer.classList.toggle("is-settings", mode === "settings");
79
+ drawer.classList.toggle("is-wide", mode === "ticket" || mode === "settings");
78
80
  const eyebrowEl = document.getElementById("drawerEyebrow");
79
81
  const titleEl = document.getElementById("drawerTitle");
80
82
  const subtitleEl = document.getElementById("drawerSubtitle");
@@ -10,6 +10,148 @@ export function escapeHtml(value) {
10
10
  .replaceAll("'", "&#039;");
11
11
  }
12
12
 
13
+ const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
14
+
15
+ function sanitizeMarkdownUrl(value) {
16
+ const href = String(value || "").trim();
17
+ if (!href) return "";
18
+
19
+ // Collapse control characters/whitespace before checking the scheme so inputs
20
+ // like `java\nscript:` or `jav\tascript:` cannot bypass the blocklist.
21
+ const compact = href.replace(/[\u0000-\u001f\u007f\s]+/g, "").toLowerCase();
22
+ if (/^(?:javascript|data|vbscript):/.test(compact)) return "";
23
+
24
+ const scheme = compact.match(/^([a-z][a-z0-9+.-]*):/);
25
+ if (scheme && !SAFE_LINK_PROTOCOLS.has(`${scheme[1]}:`)) return "";
26
+
27
+ return href;
28
+ }
29
+
30
+ function renderEmphasis(text) {
31
+ let html = escapeHtml(text);
32
+ html = html.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>");
33
+ html = html.replace(/(\*|_)(?=\S)([^*_]*?\S)\1/g, "<em>$2</em>");
34
+ return html;
35
+ }
36
+
37
+ function renderLinksAndEmphasis(text) {
38
+ const linkPattern = /\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
39
+ let html = "";
40
+ let lastIndex = 0;
41
+ let match;
42
+
43
+ while ((match = linkPattern.exec(text))) {
44
+ html += renderEmphasis(text.slice(lastIndex, match.index));
45
+
46
+ const labelHtml = renderEmphasis(match[1]);
47
+ const href = sanitizeMarkdownUrl(match[2]);
48
+ html += href
49
+ ? `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${labelHtml}</a>`
50
+ : labelHtml;
51
+
52
+ lastIndex = match.index + match[0].length;
53
+ }
54
+
55
+ html += renderEmphasis(text.slice(lastIndex));
56
+ return html;
57
+ }
58
+
59
+ function renderInlineMarkdown(text) {
60
+ const source = String(text ?? "");
61
+ let html = "";
62
+ let cursor = 0;
63
+
64
+ while (cursor < source.length) {
65
+ const codeStart = source.indexOf("`", cursor);
66
+ if (codeStart === -1) {
67
+ html += renderLinksAndEmphasis(source.slice(cursor));
68
+ break;
69
+ }
70
+
71
+ const codeEnd = source.indexOf("`", codeStart + 1);
72
+ if (codeEnd === -1) {
73
+ html += renderLinksAndEmphasis(source.slice(cursor));
74
+ break;
75
+ }
76
+
77
+ html += renderLinksAndEmphasis(source.slice(cursor, codeStart));
78
+ html += `<code>${escapeHtml(source.slice(codeStart + 1, codeEnd))}</code>`;
79
+ cursor = codeEnd + 1;
80
+ }
81
+
82
+ return html;
83
+ }
84
+
85
+ function renderParagraph(lines) {
86
+ return `<p>${renderInlineMarkdown(lines.join("\n")).replaceAll("\n", "<br>")}</p>`;
87
+ }
88
+
89
+ export function renderMarkdown(value) {
90
+ const source = String(value ?? "").replace(/\r\n?/g, "\n");
91
+ if (!source.trim()) return "";
92
+
93
+ const blocks = [];
94
+ const lines = source.split("\n");
95
+ let paragraph = [];
96
+ let list = null;
97
+
98
+ const flushParagraph = () => {
99
+ if (!paragraph.length) return;
100
+ blocks.push(renderParagraph(paragraph));
101
+ paragraph = [];
102
+ };
103
+
104
+ const flushList = () => {
105
+ if (!list) return;
106
+ blocks.push(`<${list.type}>${list.items.map((item) => `<li>${item}</li>`).join("")}</${list.type}>`);
107
+ list = null;
108
+ };
109
+
110
+ for (let i = 0; i < lines.length; i += 1) {
111
+ const line = lines[i];
112
+
113
+ if (/^\s*```/.test(line)) {
114
+ flushParagraph();
115
+ flushList();
116
+ const codeLines = [];
117
+ i += 1;
118
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
119
+ codeLines.push(lines[i]);
120
+ i += 1;
121
+ }
122
+ blocks.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
123
+ continue;
124
+ }
125
+
126
+ if (!line.trim()) {
127
+ flushParagraph();
128
+ flushList();
129
+ continue;
130
+ }
131
+
132
+ const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
133
+ const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
134
+ if (ordered || unordered) {
135
+ flushParagraph();
136
+ const type = ordered ? "ol" : "ul";
137
+ if (!list || list.type !== type) {
138
+ flushList();
139
+ list = { type, items: [] };
140
+ }
141
+ list.items.push(renderInlineMarkdown((ordered || unordered)[1].trim()).replaceAll("\n", "<br>"));
142
+ continue;
143
+ }
144
+
145
+ flushList();
146
+ paragraph.push(line.trim());
147
+ }
148
+
149
+ flushParagraph();
150
+ flushList();
151
+
152
+ return blocks.join("");
153
+ }
154
+
13
155
  export function formatDate(value) {
14
156
  if (!value) return "";
15
157
  return new Intl.DateTimeFormat(undefined, {
@@ -12,7 +12,8 @@ import {
12
12
  typeLabel,
13
13
  priorityLabel,
14
14
  priorityKeyFor,
15
- renderLabels
15
+ renderLabels,
16
+ renderMarkdown
16
17
  } from "./format.js";
17
18
  import { api, withBoardQuery } from "./api.js";
18
19
  import { navigate } from "./router.js";
@@ -70,6 +71,11 @@ export function renderBoard() {
70
71
  // follow-up click so dropping a card does not also open the detail drawer.
71
72
  let suppressClickAfterDrag = false;
72
73
  card.addEventListener("click", async (event) => {
74
+ const clickTarget = event.target instanceof Element ? event.target : event.target?.parentElement;
75
+ if (clickTarget?.closest("a")) {
76
+ event.stopPropagation();
77
+ return;
78
+ }
73
79
  if (suppressClickAfterDrag) {
74
80
  suppressClickAfterDrag = false;
75
81
  event.preventDefault();
@@ -336,7 +342,8 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
336
342
  const priorityKey = priorityKeyFor(ticket.priority);
337
343
  const typeText = typeLabel(ticket.type).toUpperCase();
338
344
  const isExpanded = state.expandedCardIds.has(ticket.id);
339
- const hasExpandable = (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
345
+ const hasDescription = Boolean(String(ticket.description || "").trim());
346
+ const hasExpandable = hasDescription || (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
340
347
 
341
348
  const triggerInner = `
342
349
  <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
@@ -368,6 +375,7 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
368
375
 
369
376
  ${hasExpandable ? `
370
377
  <div class="card-expandable">
378
+ ${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
371
379
  ${renderLabels(ticket.labels)}
372
380
  ${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
373
381
  ${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}