beadcyte 0.4.0

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
@@ -0,0 +1,270 @@
1
+ /* styles-ported.css — named styles ported from well-known editor palettes,
2
+ * plus the High Contrast original. Loaded after styles-alt.css (bp-kkf).
3
+ *
4
+ * Every block redefines the same tokens the components already read, in the
5
+ * same light-dark(oklch()) form the audit can parse, and declares the whole
6
+ * encoding set deliberately — the rule styles-alt.css explains.
7
+ *
8
+ * The palette VALUES come from each theme's own published files, with these
9
+ * departures, applied uniformly and recorded in docs/theming.md:
10
+ * - a panel ladder derived from the theme's background by lightness only;
11
+ * - text lifted to 7:1, dim to 5:1, accent to 4.5:1 against --c-bg where
12
+ * the theme's own colour fell short;
13
+ * - the lightness of every colour-only encoding token placed by
14
+ * scripts/place-encoding-lightness.mjs so each pair clears the audit's
15
+ * OKLab and colour-vision floors as tokens and as painted bars.
16
+ * Do not change a ported value by hand and keep the theme's name on it:
17
+ * re-run the placement, or record the change here and in the notices.
18
+ *
19
+ * Attribution lives in THIRD_PARTY_NOTICES.md at the repo root, which is
20
+ * what a redistributor will look for and what survives minification. The
21
+ * one-line summary per theme sits above its block.
22
+ */
23
+
24
+ /* Catppuccin. Mocha (dark) and Latte (light) — github.com/catppuccin/palette, MIT, Copyright (c) 2021 Catppuccin.
25
+ Latte's text is one step darker than published (L 39.5 vs 43.5) so dim
26
+ can clear both its 5:1 floor and the ink-distinctness floor against it;
27
+ Latte's epic ink and both flavours' dim-bright moved for the same reason. */
28
+ :root[data-style="catppuccin"],
29
+ [data-preview="catppuccin"] {
30
+ --c-shipped: light-dark(oklch(65.0% 0.177 140.4), oklch(71.0% 0.109 142.7)); /* #40a02b | #a6e3a1 */
31
+ --c-in-progress: light-dark(oklch(56.3% 0.226 262.1), oklch(80.4% 0.099 259.9)); /* #1e66f5 | #89b4fa */
32
+ --c-future: light-dark(oklch(41.5% 0.228 297.0), oklch(72.7% 0.154 304.8)); /* #8a3bf1 | #c190f8 */
33
+ --c-backlog: light-dark(oklch(41.5% 0.228 297.0), oklch(72.7% 0.154 304.8)); /* #8a3bf1 | #c190f8 */
34
+ --c-closed-noship: light-dark(oklch(57.4% 0.027 278.1), oklch(61.8% 0.037 274.7)); /* #828597 | #8e94ad */
35
+ --c-today: light-dark(oklch(54.9% 0.216 19.8), oklch(77.2% 0.130 2.8)); /* #d20f39 | #f38ba8 */
36
+ --c-danger: light-dark(oklch(58.2% 0.216 19.8), oklch(75.7% 0.130 2.8)); /* #d20f39 | #f38ba8 */
37
+ --c-bg: light-dark(oklch(95.8% 0.006 264.5), oklch(24.3% 0.030 283.9)); /* #eff1f5 | #1e1e2e */
38
+ --c-panel: light-dark(oklch(93.3% 0.009 264.5), oklch(32.4% 0.032 282.0)); /* #e6e9ef | #313244 */
39
+ --c-panel-2: light-dark(oklch(90.6% 0.012 264.5), oklch(40.4% 0.032 280.2)); /* #dce0e8 | #45475a */
40
+ --c-border: light-dark(oklch(85.7% 0.014 268.5), oklch(40.4% 0.032 280.2)); /* #ccd0da | #45475a */
41
+ --c-border-strong: light-dark(oklch(75.8% 0.020 273.2), oklch(47.7% 0.034 278.6)); /* #acb0be | #585b70 */
42
+ --c-text: light-dark(oklch(39.5% 0.043 279.3), oklch(87.9% 0.043 272.3)); /* #41445d | #cdd6f4 */
43
+ --c-dim: light-dark(oklch(51.1% 0.030 278.7), oklch(72.6% 0.040 273.9)); /* #626578 | #9ea5c0 */
44
+ --c-dim-bright: light-dark(oklch(41.2% 0.038 279.3), oklch(85.7% 0.040 272.9)); /* #464960 | #c7cfeb */
45
+ --c-phase-worked: light-dark(oklch(57.6% 0.177 140.4), oklch(71.3% 0.109 142.7)); /* #40a02b | #a6e3a1 */
46
+ --c-phase-review: light-dark(oklch(49.1% 0.226 262.1), oklch(78.5% 0.109 259.9)); /* #1e66f5 | #89b4fa */
47
+ --c-phase-waiting: light-dark(oklch(74.0% 0.149 67.8), oklch(91.9% 0.070 86.5)); /* #ef9d33 | #f9e2af */
48
+ --c-phase-other: light-dark(oklch(77.7% 0.014 268.5), oklch(45.0% 0.032 280.2)); /* #ccd0da | #45475a */
49
+ --c-accent: light-dark(oklch(55.5% 0.250 297.0), oklch(78.7% 0.119 304.8)); /* #8839ef | #cba6f7 */
50
+ --c-epic: light-dark(oklch(54.6% 0.174 338.4), oklch(96.0% 0.028 336.3)); /* #ad3d92 | #ffebf9 */
51
+ }
52
+
53
+ /* Dracula. Dracula (dark) and its official light companion Alucard — github.com/dracula/dracula-theme, MIT, Copyright (c) 2023 Dracula Theme. */
54
+ :root[data-style="dracula"],
55
+ [data-preview="dracula"] {
56
+ --c-shipped: light-dark(oklch(44.1% 0.151 141.9), oklch(92.1% 0.142 148.0)); /* #14710a | #50fa7b */
57
+ --c-in-progress: light-dark(oklch(54.5% 0.106 236.2), oklch(82.2% 0.093 212.8)); /* #036a96 | #8be9fd */
58
+ --c-future: light-dark(oklch(35.5% 0.188 287.1), oklch(75.5% 0.151 301.9)); /* #644ac9 | #bc8bff */
59
+ --c-backlog: light-dark(oklch(35.5% 0.188 287.1), oklch(75.5% 0.151 301.9)); /* #644ac9 | #bc8bff */
60
+ --c-closed-noship: light-dark(oklch(53.8% 0.041 97.1), oklch(62.6% 0.080 270.1)); /* #6c664b | #9aace2 */
61
+ --c-today: light-dark(oklch(70.3% 0.184 30.1), oklch(68.1% 0.206 24.4)); /* #cb3a2a | #ff5555 */
62
+ --c-danger: light-dark(oklch(56.3% 0.184 30.1), oklch(68.3% 0.204 24.4)); /* #cb3a2a | #ff5555 */
63
+ --c-bg: light-dark(oklch(98.7% 0.021 95.3), oklch(28.8% 0.022 277.5)); /* #fffbeb | #282a36 */
64
+ --c-panel: light-dark(oklch(96.2% 0.021 95.3), oklch(33.3% 0.022 277.5)); /* #f7f3e3 | #333542 */
65
+ --c-panel-2: light-dark(oklch(93.2% 0.021 95.3), oklch(40.3% 0.032 277.8)); /* #ede9d9 | #44475a */
66
+ --c-border: light-dark(oklch(88.7% 0.021 95.3), oklch(37.8% 0.032 277.8)); /* #dedaca | #3d4053 */
67
+ --c-border-strong: light-dark(oklch(72.0% 0.041 97.1), oklch(40.3% 0.032 277.8)); /* #aba588 | #44475a */
68
+ --c-text: light-dark(oklch(23.9% 0.000 0.0), oklch(97.7% 0.008 106.5)); /* #1f1f1f | #f8f8f2 */
69
+ --c-dim: light-dark(oklch(50.8% 0.041 97.1), oklch(69.0% 0.080 270.1)); /* #6c664b | #8899ce */
70
+ --c-dim-bright: light-dark(oklch(36.0% 0.041 97.1), oklch(80.0% 0.080 270.1)); /* #433d24 | #a9bcf2 */
71
+ --c-phase-worked: light-dark(oklch(52.9% 0.155 141.9), oklch(84.9% 0.220 148.0)); /* #338a2b | #50fa7b */
72
+ --c-phase-review: light-dark(oklch(60.0% 0.106 236.2), oklch(91.1% 0.073 212.8)); /* #267daa | #8be9fd */
73
+ --c-phase-waiting: light-dark(oklch(72.7% 0.131 48.5), oklch(91.4% 0.060 66.6)); /* #ed905d | #ffb86c */
74
+ --c-phase-other: light-dark(oklch(74.7% 0.021 95.3), oklch(45.0% 0.032 277.8)); /* #cac6b7 | #44475a */
75
+ --c-accent: light-dark(oklch(50.9% 0.188 287.1), oklch(74.2% 0.149 301.9)); /* #644ac9 | #bd93f9 */
76
+ --c-epic: light-dark(oklch(46.8% 0.177 5.0), oklch(75.5% 0.183 346.8)); /* #a3144d | #ff79c6 */
77
+ }
78
+
79
+ /* GitHub. Primer light and dark from @primer/primitives 7.11.13 — github.com/primer/primitives, MIT, Copyright (c) 2018 GitHub Inc. */
80
+ :root[data-style="github"],
81
+ [data-preview="github"] {
82
+ --c-shipped: light-dark(oklch(43.6% 0.128 148.0), oklch(75.3% 0.181 145.6)); /* #1a7f37 | #3fb950 */
83
+ --c-in-progress: light-dark(oklch(66.0% 0.181 257.5), oklch(61.6% 0.193 258.3)); /* #0969da | #2f81f7 */
84
+ --c-future: light-dark(oklch(47.3% 0.207 295.0), oklch(78.7% 0.125 298.1)); /* #814fde | #a371f7 */
85
+ --c-backlog: light-dark(oklch(47.3% 0.207 295.0), oklch(78.7% 0.125 298.1)); /* #814fde | #a371f7 */
86
+ --c-closed-noship: light-dark(oklch(62.0% 0.018 250.9), oklch(59.1% 0.020 256.3)); /* #7e8791 | #6d7580 */
87
+ --c-today: light-dark(oklch(65.0% 0.205 24.5), oklch(65.4% 0.205 27.0)); /* #cf222e | #f85149 */
88
+ --c-danger: light-dark(oklch(55.2% 0.205 24.5), oklch(66.6% 0.205 27.0)); /* #cf222e | #f85149 */
89
+ --c-bg: light-dark(oklch(100.0% 0.000 0.0), oklch(17.6% 0.014 258.4)); /* #ffffff | #0d1117 */
90
+ --c-panel: light-dark(oklch(97.8% 0.003 247.9), oklch(22.0% 0.016 256.8)); /* #f6f8fa | #161b22 */
91
+ --c-panel-2: light-dark(oklch(94.7% 0.007 247.9), oklch(26.7% 0.015 256.8)); /* #eaeef2 | #21262d */
92
+ --c-border: light-dark(oklch(87.6% 0.012 248.0), oklch(33.0% 0.015 252.3)); /* #d0d7de | #30363d */
93
+ --c-border-strong: light-dark(oklch(77.8% 0.016 248.0), oklch(42.5% 0.017 254.7)); /* #afb8c1 | #484f58 */
94
+ --c-text: light-dark(oklch(25.4% 0.011 254.0), oklch(94.3% 0.011 243.7)); /* #1f2328 | #e6edf3 */
95
+ --c-dim: light-dark(oklch(53.1% 0.017 251.3), oklch(61.4% 0.019 256.3)); /* #656d76 | #7d8590 */
96
+ --c-dim-bright: light-dark(oklch(40.5% 0.018 251.4), oklch(78.5% 0.017 250.9)); /* #424a53 | #b1bac4 */
97
+ --c-phase-worked: light-dark(oklch(50.0% 0.140 148.0), oklch(62.2% 0.181 145.6)); /* #1a7f37 | #3fb950 */
98
+ --c-phase-review: light-dark(oklch(54.9% 0.191 257.5), oklch(69.1% 0.163 258.3)); /* #0969da | #2f81f7 */
99
+ --c-phase-waiting: light-dark(oklch(74.0% 0.117 75.0), oklch(77.5% 0.140 79.9)); /* #dca656 | #d29922 */
100
+ --c-phase-other: light-dark(oklch(79.6% 0.012 248.0), oklch(41.0% 0.015 252.3)); /* #d0d7de | #30363d */
101
+ --c-accent: light-dark(oklch(54.0% 0.191 257.5), oklch(61.8% 0.193 258.3)); /* #0969da | #2f81f7 */
102
+ --c-epic: light-dark(oklch(56.3% 0.207 295.0), oklch(66.1% 0.193 298.1)); /* #8250df | #a371f7 */
103
+ }
104
+
105
+ /* Gruvbox. Dark and light — github.com/morhetz/gruvbox, MIT as declared in its package.json (the repo ships no LICENSE file), Pavel Pertsev.
106
+ Dark text is fg0 rather than fg1, the bar hues carry chroma x1.5, and
107
+ lightness was placed with --span=0.34: Gruvbox's warm greys and yellow-
108
+ leaning greens sit along the axis dichromacy removes, and this is the
109
+ least change that clears the colour-vision floor (6.6, the thinnest
110
+ margin in the set). */
111
+ :root[data-style="gruvbox"],
112
+ [data-preview="gruvbox"] {
113
+ --c-shipped: light-dark(oklch(35.3% 0.080 106.5), oklch(80.2% 0.176 110.8)); /* #79740e | #b8bb26 */
114
+ --c-in-progress: light-dark(oklch(69.4% 0.122 215.8), oklch(85.9% 0.095 169.8)); /* #076678 | #83a598 */
115
+ --c-future: light-dark(oklch(32.0% 0.144 344.3), oklch(78.5% 0.120 2.2)); /* #8f3f71 | #f883a6 */
116
+ --c-backlog: light-dark(oklch(32.0% 0.144 344.3), oklch(78.5% 0.120 2.2)); /* #8f3f71 | #f883a6 */
117
+ --c-closed-noship: light-dark(oklch(50.9% 0.023 62.6), oklch(65.0% 0.029 67.3)); /* #493d33 | #8f8071 */
118
+ --c-today: light-dark(oklch(59.9% 0.245 28.3), oklch(65.9% 0.226 30.4)); /* #9d0006 | #fb4934 */
119
+ --c-danger: light-dark(oklch(49.8% 0.206 28.3), oklch(66.0% 0.226 30.4)); /* #9d0006 | #fb4934 */
120
+ --c-bg: light-dark(oklch(95.6% 0.055 96.2), oklch(27.7% 0.000 0.0)); /* #fbf1c7 | #282828 */
121
+ --c-panel: light-dark(oklch(92.2% 0.055 92.5), oklch(34.4% 0.007 48.5)); /* #f2e5bc | #3c3836 */
122
+ --c-panel-2: light-dark(oklch(89.4% 0.057 89.2), oklch(41.1% 0.012 51.9)); /* #ebdbb2 | #504945 */
123
+ --c-border: light-dark(oklch(82.5% 0.051 85.1), oklch(41.1% 0.012 51.9)); /* #d5c4a1 | #504945 */
124
+ --c-border-strong: light-dark(oklch(69.0% 0.035 76.3), oklch(48.2% 0.018 61.0)); /* #a89984 | #665c54 */
125
+ --c-text: light-dark(oklch(34.4% 0.007 48.5), oklch(95.6% 0.055 96.2)); /* #3c3836 | #fbf1c7 */
126
+ --c-dim: light-dark(oklch(51.0% 0.023 62.6), oklch(67.9% 0.029 67.3)); /* #706359 | #a59585 */
127
+ --c-dim-bright: light-dark(oklch(41.1% 0.012 51.9), oklch(77.6% 0.041 82.3)); /* #504945 | #c3b499 */
128
+ --c-phase-worked: light-dark(oklch(54.7% 0.117 106.5), oklch(67.8% 0.148 110.8)); /* #79740e | #b8bb26 */
129
+ --c-phase-review: light-dark(oklch(47.0% 0.083 215.8), oklch(69.2% 0.095 169.8)); /* #076678 | #83a598 */
130
+ --c-phase-waiting: light-dark(oklch(72.3% 0.151 77.7), oklch(77.6% 0.159 83.0)); /* #d79921 | #fabd2f */
131
+ --c-phase-other: light-dark(oklch(74.0% 0.051 85.1), oklch(45.0% 0.012 51.9)); /* #d5c4a1 | #504945 */
132
+ --c-accent: light-dark(oklch(54.3% 0.118 70.7), oklch(83.2% 0.159 83.0)); /* #9a6100 | #fabd2f */
133
+ --c-epic: light-dark(oklch(48.9% 0.124 344.3), oklch(70.5% 0.098 2.2)); /* #8f3f71 | #d3869b */
134
+ }
135
+
136
+ /* High Contrast. ORIGINAL. Pure white ground with black ink in light mode, pure black with white in dark; achromatic chrome; saturated, well-separated data hues. Text 21:1 both ways. */
137
+ :root[data-style="high-contrast"],
138
+ [data-preview="high-contrast"] {
139
+ --c-shipped: light-dark(oklch(29.6% 0.085 150.7), oklch(84.5% 0.199 152.8)); /* #00662e | #3ff08a */
140
+ --c-in-progress: light-dark(oklch(53.1% 0.187 260.8), oklch(88.9% 0.064 235.0)); /* #0b4fbf | #4fc3ff */
141
+ --c-future: light-dark(oklch(43.8% 0.203 322.2), oklch(65.8% 0.172 307.4)); /* #8c1b9c | #c88afe */
142
+ --c-backlog: light-dark(oklch(43.8% 0.203 322.2), oklch(65.8% 0.172 307.4)); /* #8c1b9c | #c88afe */
143
+ --c-closed-noship: light-dark(oklch(46.1% 0.000 0.0), oklch(71.8% 0.000 0.0)); /* #585858 | #989898 */
144
+ --c-today: light-dark(oklch(50.8% 0.197 27.3), oklch(68.8% 0.202 26.6)); /* #b3000f | #ff5a52 */
145
+ --c-danger: light-dark(oklch(48.1% 0.197 27.3), oklch(68.7% 0.202 26.6)); /* #b3000f | #ff5a52 */
146
+ --c-bg: light-dark(oklch(100.0% 0.000 0.0), oklch(0.0% 0.000 0.0)); /* #ffffff | #000000 */
147
+ --c-panel: light-dark(oklch(96.7% 0.000 0.0), oklch(15.9% 0.000 0.0)); /* #f4f4f4 | #0d0d0d */
148
+ --c-panel-2: light-dark(oklch(92.5% 0.000 0.0), oklch(22.6% 0.000 0.0)); /* #e6e6e6 | #1c1c1c */
149
+ --c-border: light-dark(oklch(69.3% 0.000 0.0), oklch(45.3% 0.000 0.0)); /* #9c9c9c | #565656 */
150
+ --c-border-strong: light-dark(oklch(0.0% 0.000 0.0), oklch(75.7% 0.000 0.0)); /* #000000 | #b0b0b0 */
151
+ --c-text: light-dark(oklch(0.0% 0.000 0.0), oklch(100.0% 0.000 0.0)); /* #000000 | #ffffff */
152
+ --c-dim: light-dark(oklch(43.9% 0.000 0.0), oklch(73.2% 0.000 0.0)); /* #525252 | #a8a8a8 */
153
+ --c-dim-bright: light-dark(oklch(22.6% 0.000 0.0), oklch(90.1% 0.000 0.0)); /* #1c1c1c | #dedede */
154
+ --c-phase-worked: light-dark(oklch(54.3% 0.121 150.7), oklch(89.6% 0.185 152.8)); /* #419459 | #3ff08a */
155
+ --c-phase-review: light-dark(oklch(59.7% 0.187 260.8), oklch(77.5% 0.134 235.0)); /* #3274e7 | #4fc3ff */
156
+ --c-phase-waiting: light-dark(oklch(74.0% 0.099 69.1), oklch(84.6% 0.148 81.7)); /* #e1ac6f | #ffc44d */
157
+ --c-phase-other: light-dark(oklch(75.6% 0.000 0.0), oklch(40.1% 0.000 0.0)); /* #c9c9c9 | #333333 */
158
+ --c-accent: light-dark(oklch(46.8% 0.203 322.2), oklch(74.3% 0.172 307.4)); /* #8b1a9b | #c98bff */
159
+ --c-epic: light-dark(oklch(47.6% 0.194 357.0), oklch(78.9% 0.172 338.1)); /* #a8005e | #ff8be0 */
160
+ }
161
+
162
+ /* Nord. github.com/nordtheme/nord, MIT, Copyright (c) 2016-present Sven Greb. Chroma of the bar-kind hues is scaled 1.6–2.3x: Nord's whole palette sits under C 0.09, and no 35% wash of that survives the audit.
163
+ Dark dim and epic moved (epic chroma raised) so the label inks stay
164
+ apart; light dim-bright darkened one step for the type chips. */
165
+ :root[data-style="nord"],
166
+ [data-preview="nord"] {
167
+ --c-shipped: light-dark(oklch(39.9% 0.113 131.1), oklch(84.0% 0.135 131.1)); /* #4c7811 | #abdd7d */
168
+ --c-in-progress: light-dark(oklch(60.0% 0.124 254.0), oklch(62.1% 0.130 248.7)); /* #165394 | #3e8bd0 */
169
+ --c-future: light-dark(oklch(38.6% 0.112 332.7), oklch(90.2% 0.078 332.7)); /* #85467c | #f196e3 */
170
+ --c-backlog: light-dark(oklch(38.6% 0.112 332.7), oklch(90.2% 0.078 332.7)); /* #85467c | #f196e3 */
171
+ --c-closed-noship: light-dark(oklch(52.3% 0.035 264.1), oklch(74.0% 0.035 264.1)); /* #3b4558 | #96a2b8 */
172
+ --c-today: light-dark(oklch(54.0% 0.169 15.3), oklch(65.9% 0.193 15.3)); /* #ae2942 | #f0526a */
173
+ --c-danger: light-dark(oklch(50.0% 0.169 15.3), oklch(65.9% 0.193 15.3)); /* #ae2942 | #f0526a */
174
+ --c-bg: light-dark(oklch(95.1% 0.007 260.7), oklch(32.4% 0.023 264.2)); /* #eceff4 | #2e3440 */
175
+ --c-panel: light-dark(oklch(93.3% 0.010 261.8), oklch(37.9% 0.029 266.5)); /* #e5e9f0 | #3b4252 */
176
+ --c-panel-2: light-dark(oklch(89.9% 0.016 262.7), oklch(41.6% 0.032 264.1)); /* #d8dee9 | #434c5e */
177
+ --c-border: light-dark(oklch(84.9% 0.016 262.7), oklch(41.6% 0.032 264.1)); /* #c8ced9 | #434c5e */
178
+ --c-border-strong: light-dark(oklch(72.0% 0.035 264.1), oklch(45.2% 0.035 264.1)); /* #9aa5bb | #4c566a */
179
+ --c-text: light-dark(oklch(32.4% 0.023 264.2), oklch(95.1% 0.007 260.7)); /* #2e3440 | #eceff4 */
180
+ --c-dim: light-dark(oklch(45.2% 0.035 264.1), oklch(72.2% 0.035 264.1)); /* #4c566a | #9aa6bc */
181
+ --c-dim-bright: light-dark(oklch(35.9% 0.029 266.5), oklch(89.9% 0.016 262.7)); /* #363d4c | #d8dee9 */
182
+ --c-phase-worked: light-dark(oklch(56.7% 0.135 131.1), oklch(75.5% 0.135 131.1)); /* #578422 | #9fcf70 */
183
+ --c-phase-review: light-dark(oklch(49.8% 0.116 254.0), oklch(65.9% 0.130 248.7)); /* #3a70b0 | #4b97de */
184
+ --c-phase-waiting: light-dark(oklch(74.0% 0.143 84.1), oklch(88.2% 0.125 84.1)); /* #dba82e | #fdc855 */
185
+ --c-phase-other: light-dark(oklch(81.9% 0.016 262.7), oklch(45.0% 0.032 264.1)); /* #d8dee9 | #434c5e */
186
+ --c-accent: light-dark(oklch(48.0% 0.116 254.0), oklch(77.5% 0.062 217.5)); /* #295f9d | #88c0d0 */
187
+ --c-epic: light-dark(oklch(50.0% 0.112 332.7), oklch(72.2% 0.105 332.7)); /* #88497f | #cd8cc2 */
188
+ }
189
+
190
+ /* One. Atom One Dark and One Light — github.com/atom/one-dark-syntax and one-light-syntax, MIT, Copyright (c) 2016 GitHub Inc. */
191
+ :root[data-style="one"],
192
+ [data-preview="one"] {
193
+ --c-shipped: light-dark(oklch(71.1% 0.142 143.5), oklch(76.8% 0.110 133.0)); /* #50a14f | #98c379 */
194
+ --c-in-progress: light-dark(oklch(60.2% 0.193 263.2), oklch(85.5% 0.077 245.3)); /* #4078f2 | #61afef */
195
+ --c-future: light-dark(oklch(52.0% 0.211 328.8), oklch(72.9% 0.164 318.2)); /* #a524a3 | #d283e9 */
196
+ --c-backlog: light-dark(oklch(52.0% 0.211 328.8), oklch(72.9% 0.164 318.2)); /* #a524a3 | #d283e9 */
197
+ --c-closed-noship: light-dark(oklch(62.0% 0.011 273.3), oklch(61.7% 0.022 262.9)); /* #84868d | #7c8391 */
198
+ --c-today: light-dark(oklch(61.5% 0.179 28.3), oklch(56.8% 0.145 17.0)); /* #e45649 | #e06c75 */
199
+ --c-danger: light-dark(oklch(54.2% 0.208 15.9), oklch(67.2% 0.145 17.0)); /* #ca1243 | #e06c75 */
200
+ --c-bg: light-dark(oklch(98.5% 0.000 0.0), oklch(29.3% 0.016 264.3)); /* #fafafa | #282c34 */
201
+ --c-panel: light-dark(oklch(95.5% 0.001 286.4), oklch(32.9% 0.019 268.1)); /* #f0f0f1 | #31353f */
202
+ --c-panel-2: light-dark(oklch(92.2% 0.001 286.4), oklch(37.5% 0.022 264.3)); /* #e5e5e6 | #3b414d */
203
+ --c-border: light-dark(oklch(89.2% 0.001 286.4), oklch(37.5% 0.022 264.3)); /* #dbdbdc | #3b414d */
204
+ --c-border-strong: light-dark(oklch(77.7% 0.003 286.3), oklch(43.9% 0.029 267.6)); /* #b6b6b8 | #4b5263 */
205
+ --c-text: light-dark(oklch(35.0% 0.014 274.5), oklch(78.2% 0.020 263.0)); /* #383a42 | #b1b8c5 */
206
+ --c-dim: light-dark(oklch(53.2% 0.018 273.6), oklch(68.9% 0.023 264.4)); /* #696c77 | #949baa */
207
+ --c-dim-bright: light-dark(oklch(44.0% 0.018 273.5), oklch(85.0% 0.015 260.7)); /* #4f525d | #c8ced8 */
208
+ --c-phase-worked: light-dark(oklch(56.4% 0.142 143.5), oklch(84.6% 0.110 133.0)); /* #50a14f | #98c379 */
209
+ --c-phase-review: light-dark(oklch(61.6% 0.193 263.2), oklch(73.0% 0.121 245.3)); /* #4078f2 | #61afef */
210
+ --c-phase-waiting: light-dark(oklch(74.0% 0.138 76.2), oklch(75.8% 0.097 82.3)); /* #e3a43b | #e5c07b */
211
+ --c-phase-other: light-dark(oklch(81.2% 0.001 286.4), oklch(45.0% 0.022 264.3)); /* #dbdbdc | #3b414d */
212
+ --c-accent: light-dark(oklch(56.2% 0.193 263.2), oklch(73.0% 0.121 245.3)); /* #356be4 | #61afef */
213
+ --c-epic: light-dark(oklch(52.4% 0.211 328.8), oklch(69.4% 0.164 318.2)); /* #a626a4 | #c678dd */
214
+ }
215
+
216
+ /* Solarized. Dark and light — github.com/altercation/solarized, MIT, Copyright (c) 2011 Ethan Schoonover. Dark ink is base2 rather than base1 for a 12:1 rather than 5.6:1 text contrast; future is Solarized magenta, not its violet, which sat inside the audit floor from its blue.
217
+ dim-bright and the violet epic ink moved in lightness so the type-chip
218
+ inks stay apart: Solarized's base0/base1 greys are 0.03 OKLab apart. */
219
+ :root[data-style="solarized"],
220
+ [data-preview="solarized"] {
221
+ --c-shipped: light-dark(oklch(67.7% 0.151 118.6), oklch(71.1% 0.151 118.6)); /* #859900 | #859900 */
222
+ --c-in-progress: light-dark(oklch(61.6% 0.139 244.9), oklch(67.6% 0.139 244.9)); /* #268bd2 | #268bd2 */
223
+ --c-future: light-dark(oklch(43.2% 0.180 355.9), oklch(82.6% 0.108 355.9)); /* #d23581 | #ff7bb2 */
224
+ --c-backlog: light-dark(oklch(43.2% 0.180 355.9), oklch(82.6% 0.108 355.9)); /* #d23581 | #ff7bb2 */
225
+ --c-closed-noship: light-dark(oklch(53.4% 0.016 196.8), oklch(63.9% 0.028 219.1)); /* #7c8989 | #6b8289 */
226
+ --c-today: light-dark(oklch(58.2% 0.206 27.1), oklch(58.6% 0.206 27.1)); /* #dc322f | #dc322f */
227
+ --c-danger: light-dark(oklch(58.6% 0.206 27.1), oklch(58.6% 0.206 27.1)); /* #dc322f | #dc322f */
228
+ --c-bg: light-dark(oklch(97.4% 0.026 90.1), oklch(26.7% 0.049 219.8)); /* #fdf6e3 | #002b36 */
229
+ --c-panel: light-dark(oklch(93.1% 0.026 92.4), oklch(30.9% 0.052 219.7)); /* #eee8d5 | #073642 */
230
+ --c-panel-2: light-dark(oklch(88.6% 0.026 92.4), oklch(36.4% 0.052 219.7)); /* #dfd9c6 | #194551 */
231
+ --c-border: light-dark(oklch(87.1% 0.026 92.4), oklch(35.9% 0.052 219.7)); /* #dad4c2 | #184350 */
232
+ --c-border-strong: light-dark(oklch(69.8% 0.016 196.8), oklch(52.3% 0.028 219.1)); /* #93a1a1 | #586e75 */
233
+ --c-text: light-dark(oklch(26.7% 0.049 219.8), oklch(93.1% 0.026 92.4)); /* #002b36 | #eee8d5 */
234
+ --c-dim: light-dark(oklch(51.8% 0.029 221.9), oklch(66.9% 0.020 205.3)); /* #566c74 | #87999b */
235
+ --c-dim-bright: light-dark(oklch(42.3% 0.028 219.1), oklch(77.8% 0.016 196.8)); /* #3d5258 | #acbaba */
236
+ --c-phase-worked: light-dark(oklch(62.2% 0.147 118.6), oklch(64.3% 0.151 118.6)); /* #859900 | #859900 */
237
+ --c-phase-review: light-dark(oklch(61.5% 0.139 244.9), oklch(61.5% 0.139 244.9)); /* #268bd2 | #268bd2 */
238
+ --c-phase-waiting: light-dark(oklch(74.0% 0.134 85.7), oklch(80.1% 0.134 85.7)); /* #d7aa3c | #e4b74b */
239
+ --c-phase-other: light-dark(oklch(85.1% 0.026 92.4), oklch(45.0% 0.052 219.7)); /* #eee8d5 | #234e5b */
240
+ --c-accent: light-dark(oklch(54.5% 0.137 244.9), oklch(64.0% 0.139 244.9)); /* #0075ba | #3093da */
241
+ --c-epic: light-dark(oklch(55.2% 0.126 279.1), oklch(67.2% 0.126 279.1)); /* #6468ba | #868ce2 */
242
+ }
243
+
244
+ /* Tokyo Night. Night and Light — github.com/enkia/tokyo-night-vscode-theme, MIT, Copyright (c) 2018-present Enkia. In-progress is its blue, not its cyan, which collided with the text colour in the WIP bullet.
245
+ dim-bright moved one step in both modes and the dark epic ink gained
246
+ chroma so the type-chip inks stay apart. */
247
+ :root[data-style="tokyonight"],
248
+ [data-preview="tokyonight"] {
249
+ --c-shipped: light-dark(oklch(41.8% 0.116 132.7), oklch(88.3% 0.139 130.1)); /* #385f0d | #9ece6a */
250
+ --c-in-progress: light-dark(oklch(49.8% 0.091 222.1), oklch(56.1% 0.132 264.2)); /* #006c86 | #7aa2f7 */
251
+ --c-future: light-dark(oklch(49.1% 0.180 302.0), oklch(82.7% 0.100 299.5)); /* #753cb3 | #b07ffe */
252
+ --c-backlog: light-dark(oklch(49.1% 0.180 302.0), oklch(82.7% 0.100 299.5)); /* #753cb3 | #b07ffe */
253
+ --c-closed-noship: light-dark(oklch(60.8% 0.014 271.2), oklch(68.5% 0.059 274.3)); /* #80838c | #727ba1 */
254
+ --c-today: light-dark(oklch(48.0% 0.100 9.5), oklch(80.3% 0.115 10.3)); /* #8c4351 | #f7768e */
255
+ --c-danger: light-dark(oklch(47.9% 0.100 9.5), oklch(72.2% 0.159 10.3)); /* #8c4351 | #f7768e */
256
+ --c-bg: light-dark(oklch(92.9% 0.008 278.6), oklch(22.6% 0.021 280.5)); /* #e6e7ed | #1a1b26 */
257
+ --c-panel: light-dark(oklch(89.5% 0.010 273.3), oklch(28.2% 0.036 274.7)); /* #dadce3 | #24283b */
258
+ --c-panel-2: light-dark(oklch(86.2% 0.013 276.1), oklch(32.9% 0.046 276.8)); /* #cfd1da | #2f334d */
259
+ --c-border: light-dark(oklch(81.5% 0.007 277.1), oklch(30.6% 0.037 273.2)); /* #c1c2c7 | #292e42 */
260
+ --c-border-strong: light-dark(oklch(70.7% 0.016 273.8), oklch(38.7% 0.054 273.9)); /* #9da0ab | #3b4261 */
261
+ --c-text: light-dark(oklch(35.9% 0.051 273.2), oklch(84.6% 0.061 274.8)); /* #343b58 | #c0caf5 */
262
+ --c-dim: light-dark(oklch(49.0% 0.022 279.2), oklch(64.2% 0.059 274.3)); /* #5d5f6d | #818bb1 */
263
+ --c-dim-bright: light-dark(oklch(38.1% 0.035 276.4), oklch(74.8% 0.061 273.1)); /* #3d4155 | #a0abd5 */
264
+ --c-phase-worked: light-dark(oklch(55.0% 0.116 132.7), oklch(71.7% 0.139 130.1)); /* #598236 | #9ece6a */
265
+ --c-phase-review: light-dark(oklch(59.8% 0.091 222.1), oklch(68.0% 0.132 264.2)); /* #26809b | #7aa2f7 */
266
+ --c-phase-waiting: light-dark(oklch(74.0% 0.104 71.0), oklch(84.6% 0.106 75.4)); /* #dba664 | #e0af68 */
267
+ --c-phase-other: light-dark(oklch(74.0% 0.007 277.1), oklch(38.6% 0.037 273.2)); /* #c1c2c7 | #292e42 */
268
+ --c-accent: light-dark(oklch(47.6% 0.140 260.2), oklch(71.9% 0.132 264.2)); /* #2959aa | #7aa2f7 */
269
+ --c-epic: light-dark(oklch(51.1% 0.180 302.0), oklch(75.1% 0.150 299.5)); /* #7b43ba | #bd97ff */
270
+ }
@@ -0,0 +1,362 @@
1
+ // subgraph.ts — layout for the drawer's dependency graphs.
2
+ //
3
+ // Two pictures share one renderer (BeadSubGraph.vue):
4
+ //
5
+ // - a bead's NEIGHBOURHOOD: its transitive blockers and dependents out to a
6
+ // hop cap, one column per hop, the bead itself in the middle;
7
+ // - an epic's CHILDREN: every bead whose parent is the epic, one column per
8
+ // dependency depth within that set. An epic carries parent/child edges,
9
+ // not blocks edges, so its own neighbourhood is almost always the epic
10
+ // alone — the structure worth seeing is which children block which
11
+ // (bp-67g.59).
12
+ //
13
+ // Pure, so both layouts can be asserted from node --test. Coordinates are
14
+ // SVG user units; the renderer paints what comes back and nothing more.
15
+
16
+ import type { Bead } from "./store";
17
+
18
+ export type NodeRole = "root" | "upstream" | "downstream" | "member";
19
+
20
+ export interface GraphNode {
21
+ id: string;
22
+ bead: Bead;
23
+ /**
24
+ * Neighbourhood: signed hop distance from the root, negative upstream.
25
+ * Children: depth — the longest chain of blockers above the node within
26
+ * the child set, so a node always sits right of everything that blocks it.
27
+ */
28
+ hop: number;
29
+ role: NodeRole;
30
+ x: number;
31
+ y: number;
32
+ }
33
+
34
+ export interface GraphEdge {
35
+ fromId: string;
36
+ toId: string;
37
+ x1: number;
38
+ y1: number;
39
+ x2: number;
40
+ y2: number;
41
+ }
42
+
43
+ export interface GraphLayout {
44
+ nodes: GraphNode[];
45
+ edges: GraphEdge[];
46
+ width: number;
47
+ height: number;
48
+ }
49
+
50
+ export interface GraphGeometry {
51
+ nodeW: number;
52
+ nodeH: number;
53
+ colGap: number;
54
+ rowGap: number;
55
+ }
56
+
57
+ export const GEOMETRY: GraphGeometry = {
58
+ nodeW: 130,
59
+ nodeH: 42,
60
+ colGap: 40,
61
+ rowGap: 10,
62
+ };
63
+
64
+ export const DEFAULT_MAX_HOPS = 4;
65
+
66
+ export interface ChildrenLayoutOptions {
67
+ /**
68
+ * A child to mark as the focal node — the bead whose drawer is open when
69
+ * the epic-wide graph is shown from a child (bp-67g.60). It keeps the
70
+ * root role and so the root styling, so a zoom-out reads as a zoom-out
71
+ * rather than a change of subject.
72
+ */
73
+ focalId?: string;
74
+ /**
75
+ * How many isolated children sit side by side. One — a plain list — suits
76
+ * the drawer, which scrolls vertically for free and horizontally only
77
+ * inside the graph. A full-window view passes what fits its width, so
78
+ * forty unconnected children are a short grid rather than a long column.
79
+ */
80
+ isolatedColumns?: number;
81
+ }
82
+
83
+ interface Adjacency {
84
+ /** blocker → the beads it blocks */
85
+ outgoing: Map<string, string[]>;
86
+ /** bead → its blockers */
87
+ incoming: Map<string, string[]>;
88
+ }
89
+
90
+ /** blocks edges among `beads`; an edge whose far end is not in the set is dropped. */
91
+ function blocksAdjacency(beads: readonly Bead[]): Adjacency {
92
+ const ids = new Set(beads.map((b) => b.id));
93
+ const outgoing = new Map<string, string[]>();
94
+ const incoming = new Map<string, string[]>();
95
+ for (const b of beads) {
96
+ for (const d of b.dependencies ?? []) {
97
+ if (d.type !== "blocks") continue;
98
+ if (!ids.has(d.depends_on_id)) continue;
99
+ push(outgoing, d.depends_on_id, b.id);
100
+ push(incoming, b.id, d.depends_on_id);
101
+ }
102
+ }
103
+ return { outgoing, incoming };
104
+ }
105
+
106
+ function push<K>(m: Map<K, string[]>, k: K, v: string): void {
107
+ const arr = m.get(k);
108
+ if (arr) arr.push(v);
109
+ else m.set(k, [v]);
110
+ }
111
+
112
+ const colX = (col: number, geo: GraphGeometry): number =>
113
+ col * (geo.nodeW + geo.colGap);
114
+
115
+ const widthFor = (cols: number, geo: GraphGeometry): number =>
116
+ cols <= 0 ? 0 : (cols - 1) * (geo.nodeW + geo.colGap) + geo.nodeW;
117
+
118
+ /**
119
+ * Edges between laid-out nodes only. An edge that fans beyond the visible
120
+ * set is skipped rather than drawn dangling. Blockers always sit left of
121
+ * what they block in both layouts, so every edge runs left to right.
122
+ */
123
+ function edgesBetween(
124
+ nodes: readonly GraphNode[],
125
+ adj: Adjacency,
126
+ geo: GraphGeometry,
127
+ ): GraphEdge[] {
128
+ const byId = new Map(nodes.map((n) => [n.id, n] as const));
129
+ const edges: GraphEdge[] = [];
130
+ for (const from of nodes) {
131
+ for (const toId of adj.outgoing.get(from.id) ?? []) {
132
+ const to = byId.get(toId);
133
+ if (!to) continue;
134
+ edges.push({
135
+ fromId: from.id,
136
+ toId,
137
+ x1: from.x + geo.nodeW,
138
+ y1: from.y + geo.nodeH / 2,
139
+ x2: to.x,
140
+ y2: to.y + geo.nodeH / 2,
141
+ });
142
+ }
143
+ }
144
+ return edges;
145
+ }
146
+
147
+ /**
148
+ * The focal bead's transitive blockers (leftward) and dependents
149
+ * (rightward), each side capped at `maxHops`. Columns are hop distance;
150
+ * a bead reachable by more than one path keeps the hop furthest from the
151
+ * root on its side, so it lands beyond everything on the path to it.
152
+ */
153
+ export function layoutNeighbourhood(
154
+ root: Bead,
155
+ beads: readonly Bead[],
156
+ maxHops: number = DEFAULT_MAX_HOPS,
157
+ geo: GraphGeometry = GEOMETRY,
158
+ ): GraphLayout {
159
+ const byId = new Map(beads.map((b) => [b.id, b] as const));
160
+ if (!byId.has(root.id)) byId.set(root.id, root);
161
+ const adj = blocksAdjacency([...byId.values()]);
162
+
163
+ const hopOf = new Map<string, number>();
164
+ hopOf.set(root.id, 0);
165
+ const upQueue: Array<[string, number]> = [[root.id, 0]];
166
+ while (upQueue.length) {
167
+ const [id, hop] = upQueue.shift()!;
168
+ if (hop <= -maxHops) continue;
169
+ for (const prev of adj.incoming.get(id) ?? []) {
170
+ const nextHop = hop - 1;
171
+ if (hopOf.has(prev) && hopOf.get(prev)! <= nextHop) continue;
172
+ hopOf.set(prev, nextHop);
173
+ upQueue.push([prev, nextHop]);
174
+ }
175
+ }
176
+ const downQueue: Array<[string, number]> = [[root.id, 0]];
177
+ while (downQueue.length) {
178
+ const [id, hop] = downQueue.shift()!;
179
+ if (hop >= maxHops) continue;
180
+ for (const next of adj.outgoing.get(id) ?? []) {
181
+ const nextHop = hop + 1;
182
+ if (hopOf.has(next) && hopOf.get(next)! >= nextHop) continue;
183
+ hopOf.set(next, nextHop);
184
+ downQueue.push([next, nextHop]);
185
+ }
186
+ }
187
+
188
+ const byHop = new Map<number, string[]>();
189
+ for (const [id, hop] of hopOf) push(byHop, hop, id);
190
+ const hopKeys = [...byHop.keys()].sort((a, b) => a - b);
191
+ const minHop = hopKeys[0] ?? 0;
192
+ const maxHop = hopKeys[hopKeys.length - 1] ?? 0;
193
+
194
+ const maxRowsInCol = Math.max(
195
+ ...hopKeys.map((h) => byHop.get(h)?.length ?? 0),
196
+ 1,
197
+ );
198
+ const height = maxRowsInCol * (geo.nodeH + geo.rowGap) + geo.rowGap;
199
+
200
+ const nodes: GraphNode[] = [];
201
+ for (const hop of hopKeys) {
202
+ const items = (byHop.get(hop) ?? []).slice().sort();
203
+ const totalH = items.length * (geo.nodeH + geo.rowGap);
204
+ const startY = (height - totalH) / 2;
205
+ items.forEach((id, i) => {
206
+ const bead = byId.get(id);
207
+ if (!bead) return;
208
+ nodes.push({
209
+ id,
210
+ bead,
211
+ hop,
212
+ role: hop === 0 ? "root" : hop < 0 ? "upstream" : "downstream",
213
+ x: colX(hop - minHop, geo),
214
+ y: startY + i * (geo.nodeH + geo.rowGap),
215
+ });
216
+ });
217
+ }
218
+
219
+ return {
220
+ nodes,
221
+ edges: edgesBetween(nodes, adj, geo),
222
+ width: widthFor(maxHop - minHop + 1, geo),
223
+ height,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Every child of `epic`, laid out by dependency depth within the child set.
229
+ *
230
+ * There is no single root here: the children are a forest of chains plus
231
+ * isolated beads. Depth is the longest chain of in-set blockers above a
232
+ * node, so an edge never runs backwards. Connected components are stacked
233
+ * as separate bands — chains first, largest first — so two unrelated chains
234
+ * never interleave by depth alone. The isolated children collect below the
235
+ * chains, in id order, `isolatedColumns` to a row: unconnected is what they
236
+ * are, and with no edges among them a column carries no depth.
237
+ *
238
+ * Edges to beads outside the epic are not drawn: the picture is the epic's
239
+ * internal structure, and a child's outside blockers are one click away in
240
+ * its own neighbourhood.
241
+ */
242
+ export function layoutChildren(
243
+ epic: Bead,
244
+ beads: readonly Bead[],
245
+ { isolatedColumns = 1, focalId }: ChildrenLayoutOptions = {},
246
+ geo: GraphGeometry = GEOMETRY,
247
+ ): GraphLayout {
248
+ const members = beads
249
+ .filter((b) => b.parent === epic.id)
250
+ .sort((a, b) => a.id.localeCompare(b.id));
251
+ if (!members.length) return { nodes: [], edges: [], width: 0, height: 0 };
252
+ const adj = blocksAdjacency(members);
253
+ const roleOf = (id: string): NodeRole => (id === focalId ? "root" : "member");
254
+
255
+ // Depth by memoised walk up the blockers. bd will record a dependency
256
+ // cycle if asked to, so a back edge is met by contributing nothing: the
257
+ // walk terminates and every member still lands in a column.
258
+ const depth = new Map<string, number>();
259
+ const visiting = new Set<string>();
260
+ const depthOf = (id: string): number => {
261
+ const known = depth.get(id);
262
+ if (known !== undefined) return known;
263
+ if (visiting.has(id)) return -1;
264
+ visiting.add(id);
265
+ let d = 0;
266
+ for (const blocker of adj.incoming.get(id) ?? []) {
267
+ d = Math.max(d, depthOf(blocker) + 1);
268
+ }
269
+ visiting.delete(id);
270
+ depth.set(id, d);
271
+ return d;
272
+ };
273
+ for (const m of members) depthOf(m.id);
274
+
275
+ // Connected components by union-find over the in-set edges.
276
+ const parent = new Map(members.map((m) => [m.id, m.id] as const));
277
+ const find = (x: string): string => {
278
+ while (parent.get(x) !== x) x = parent.get(x)!;
279
+ return x;
280
+ };
281
+ for (const [from, tos] of adj.outgoing) {
282
+ for (const to of tos) {
283
+ const a = find(from);
284
+ const b = find(to);
285
+ if (a !== b) parent.set(a, b);
286
+ }
287
+ }
288
+ const groups = new Map<string, Bead[]>();
289
+ for (const m of members) {
290
+ const key = find(m.id);
291
+ const g = groups.get(key);
292
+ if (g) g.push(m);
293
+ else groups.set(key, [m]);
294
+ }
295
+ // Members are id-sorted, so a component's first entry is its lowest id
296
+ // and the order is deterministic for equal sizes.
297
+ const components = [...groups.values()].sort(
298
+ (a, b) => b.length - a.length || a[0].id.localeCompare(b[0].id),
299
+ );
300
+ const chains = components.filter((c) => c.length > 1);
301
+ const isolated = components.filter((c) => c.length === 1).map((c) => c[0]);
302
+
303
+ const nodes: GraphNode[] = [];
304
+ const step = geo.nodeH + geo.rowGap;
305
+ let y = geo.rowGap;
306
+ let bandEnd = y;
307
+ let cols = 0;
308
+ chains.forEach((comp) => {
309
+ const byDepth = new Map<number, Bead[]>();
310
+ for (const b of comp) {
311
+ const d = depth.get(b.id)!;
312
+ const arr = byDepth.get(d);
313
+ if (arr) arr.push(b);
314
+ else byDepth.set(d, [b]);
315
+ }
316
+ const depths = [...byDepth.keys()].sort((a, b) => a - b);
317
+ const rows = Math.max(...depths.map((d) => byDepth.get(d)!.length));
318
+ const bandH = rows * step - geo.rowGap;
319
+ for (const d of depths) {
320
+ const items = byDepth.get(d)!;
321
+ const colH = items.length * step - geo.rowGap;
322
+ const startY = y + (bandH - colH) / 2;
323
+ items.forEach((b, i) => {
324
+ nodes.push({
325
+ id: b.id,
326
+ bead: b,
327
+ hop: d,
328
+ role: roleOf(b.id),
329
+ x: colX(d, geo),
330
+ y: startY + i * step,
331
+ });
332
+ });
333
+ }
334
+ cols = Math.max(cols, depths[depths.length - 1] + 1);
335
+ bandEnd = y + bandH;
336
+ y = bandEnd + geo.rowGap * 2; // a chain gets air around it
337
+ });
338
+
339
+ // The unconnected tail, as a grid under the chains.
340
+ const gridCols = Math.max(1, Math.floor(isolatedColumns));
341
+ isolated.forEach((b, i) => {
342
+ const col = i % gridCols;
343
+ const row = Math.floor(i / gridCols);
344
+ nodes.push({
345
+ id: b.id,
346
+ bead: b,
347
+ hop: 0,
348
+ role: roleOf(b.id),
349
+ x: colX(col, geo),
350
+ y: y + row * step,
351
+ });
352
+ bandEnd = y + row * step + geo.nodeH;
353
+ });
354
+ cols = Math.max(cols, Math.min(gridCols, isolated.length));
355
+
356
+ return {
357
+ nodes,
358
+ edges: edgesBetween(nodes, adj, geo),
359
+ width: widthFor(cols, geo),
360
+ height: bandEnd + geo.rowGap,
361
+ };
362
+ }