@warren-dean/code-kit 0.7.0 → 0.8.1
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +15 -3
- package/bin/code-kit.mjs +171 -3
- package/hooks/guard-bash.mjs +29 -2
- package/hooks/lib/adapters/context-graph.mjs +18 -19
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/next.mjs +13 -3
- package/hooks/lib/plan.mjs +8 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/loop.mjs +7 -3
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +469 -34
- package/hooks/mod/register.mjs +196 -2
- package/hooks/mod/register.test.ts +456 -25
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +324 -116
- package/package.json +1 -1
- package/skills/init/SKILL.md +3 -3
package/hooks/mod/panes.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// Panes v2 (docs/specs/11-panes.md): the Lanes pane's frame. A counts header, tabs, what needs the
|
|
2
2
|
// person, the tab's body and the keys, as pure functions of what the mod already holds. The Lanes
|
|
3
3
|
// tab's body is view.mjs's lanesPane; the Queue and Usage tabs reuse its sections.
|
|
4
|
+
import { trailRows as trailRowsDrawn, trailSvg } from './trail.mjs';
|
|
5
|
+
import {
|
|
6
|
+
MAP_LEGEND,
|
|
7
|
+
cardOf,
|
|
8
|
+
mapRows as codeRows,
|
|
9
|
+
mapStoryOf,
|
|
10
|
+
mapSvg,
|
|
11
|
+
neighboursOf,
|
|
12
|
+
} from './codemap.mjs';
|
|
4
13
|
import {
|
|
5
14
|
NOT_CODE_KIT,
|
|
6
15
|
STATE_COLOR,
|
|
7
16
|
ago,
|
|
8
17
|
lanesPane,
|
|
9
|
-
loopSection,
|
|
10
18
|
queueSection,
|
|
11
19
|
shownState,
|
|
12
20
|
tokens,
|
|
@@ -15,9 +23,12 @@ import {
|
|
|
15
23
|
|
|
16
24
|
export const TABS = [
|
|
17
25
|
{ id: 'lanes', label: 'Lanes', hotkey: '1' },
|
|
18
|
-
{ id: '
|
|
19
|
-
{ id: '
|
|
20
|
-
{ id: '
|
|
26
|
+
{ id: 'decisions', label: 'Decisions', hotkey: '2' },
|
|
27
|
+
{ id: 'journal', label: 'Journal', hotkey: '3' },
|
|
28
|
+
{ id: 'code', label: 'Code map', hotkey: '4' },
|
|
29
|
+
{ id: 'map', label: 'Trail', hotkey: '5' },
|
|
30
|
+
{ id: 'queue', label: 'Queue', hotkey: '6' },
|
|
31
|
+
{ id: 'usage', label: 'Usage', hotkey: '7' },
|
|
21
32
|
];
|
|
22
33
|
|
|
23
34
|
/** The pane's own state, kept by the mod between draws. */
|
|
@@ -173,6 +184,8 @@ export function lanesFrame(view, els, on) {
|
|
|
173
184
|
children: [
|
|
174
185
|
text('!', { color: 'yellow', bold: true }),
|
|
175
186
|
text(line.text, { bold: true }),
|
|
187
|
+
// DECIDE-2: which story it's for.
|
|
188
|
+
...(line.context ? [text(line.context, { dimColor: true, wrap: 'truncate-end' })] : []),
|
|
176
189
|
...actions.map((a) => {
|
|
177
190
|
const letter = LETTER[a.id] && !used.has(LETTER[a.id]) ? LETTER[a.id] : undefined;
|
|
178
191
|
if (letter) used.add(letter);
|
|
@@ -254,35 +267,48 @@ export function lanesFrame(view, els, on) {
|
|
|
254
267
|
|
|
255
268
|
const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
|
|
256
269
|
const body =
|
|
257
|
-
ui.tab === '
|
|
258
|
-
?
|
|
259
|
-
: ui.tab === '
|
|
260
|
-
?
|
|
261
|
-
: ui.tab === '
|
|
262
|
-
?
|
|
263
|
-
:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
270
|
+
ui.tab === 'decisions'
|
|
271
|
+
? decisionsTab(view.cards ?? [], ui, els, on)
|
|
272
|
+
: ui.tab === 'journal'
|
|
273
|
+
? journalTab(view.journal ?? [], els, view.now)
|
|
274
|
+
: ui.tab === 'code'
|
|
275
|
+
? codeTab(view, els, on)
|
|
276
|
+
: ui.tab === 'map'
|
|
277
|
+
? mapTab(view.status, ui.cell, els, on, view.trail, {
|
|
278
|
+
surface: view.surface,
|
|
279
|
+
columns: view.columns,
|
|
280
|
+
})
|
|
281
|
+
: ui.tab === 'queue'
|
|
282
|
+
? queueSection(loop, text, Box)
|
|
283
|
+
: ui.tab === 'usage'
|
|
284
|
+
? usageSection(view.usage, plan, text, Box)
|
|
285
|
+
: [
|
|
286
|
+
lanesPane(state, els, view.usage, plan, loop, {
|
|
287
|
+
filter: ui.filter,
|
|
288
|
+
search: ui.search,
|
|
289
|
+
selected: ui.selected,
|
|
290
|
+
live: view.live ?? {},
|
|
291
|
+
art: view.art ?? null,
|
|
292
|
+
detail: selected
|
|
293
|
+
? {
|
|
294
|
+
agent: selected.agent ?? null,
|
|
295
|
+
owns: view.paths?.[selected.name] ?? [],
|
|
296
|
+
stories: laneStories(selected, state),
|
|
297
|
+
}
|
|
298
|
+
: null,
|
|
299
|
+
combined: view.combined ?? null,
|
|
300
|
+
}),
|
|
301
|
+
];
|
|
280
302
|
const empty =
|
|
281
|
-
ui.tab === '
|
|
282
|
-
? 'The
|
|
283
|
-
: ui.tab === '
|
|
284
|
-
? '
|
|
285
|
-
:
|
|
303
|
+
ui.tab === 'decisions'
|
|
304
|
+
? 'Nothing needs you. The lead is handling the build.'
|
|
305
|
+
: ui.tab === 'journal'
|
|
306
|
+
? 'Nothing recorded yet: the journal starts with the brief.'
|
|
307
|
+
: ui.tab === 'queue'
|
|
308
|
+
? 'The merge queue is empty.'
|
|
309
|
+
: ui.tab === 'usage'
|
|
310
|
+
? 'No usage measured yet this session.'
|
|
311
|
+
: null;
|
|
286
312
|
|
|
287
313
|
// VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
|
|
288
314
|
const opening = selected ? storyToOpen(selected, state) : null;
|
|
@@ -819,7 +845,14 @@ export function moveCell(requirements, id, step, { rows = false } = {}) {
|
|
|
819
845
|
* The Map tab (TRACE-1 to TRACE-4): a row per spec, a cell per requirement, a legend, and the
|
|
820
846
|
* selected requirement's stories and tests. `status` is `code-kit status --json`'s.
|
|
821
847
|
*/
|
|
822
|
-
export function mapTab(
|
|
848
|
+
export function mapTab(
|
|
849
|
+
status,
|
|
850
|
+
selectedId,
|
|
851
|
+
{ Box, Text, Button, Select, Svg },
|
|
852
|
+
on,
|
|
853
|
+
trail = null,
|
|
854
|
+
{ surface = 'terminal', columns = 100 } = {},
|
|
855
|
+
) {
|
|
823
856
|
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
824
857
|
const requirements = status?.requirements ?? [];
|
|
825
858
|
if (!requirements.length)
|
|
@@ -929,6 +962,28 @@ export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
|
|
|
929
962
|
),
|
|
930
963
|
)
|
|
931
964
|
: [text('No story delivers it yet: a gap in the plan.', { color: 'red' })]),
|
|
965
|
+
// TRAIL-1: the code its stories changed, once the trail is read.
|
|
966
|
+
...(() => {
|
|
967
|
+
const row = trail?.requirements?.find((r) => r.id === selected.id);
|
|
968
|
+
if (!row) return [];
|
|
969
|
+
return row.files.length
|
|
970
|
+
? [
|
|
971
|
+
text(`Code its stories changed (${row.files.length})`, { bold: true }),
|
|
972
|
+
Box({
|
|
973
|
+
key: 'map-files',
|
|
974
|
+
children: [
|
|
975
|
+
text(
|
|
976
|
+
` ${row.files
|
|
977
|
+
.slice(0, 6)
|
|
978
|
+
.map((f) => f.split('/').pop())
|
|
979
|
+
.join(', ')}${row.files.length > 6 ? ', …' : ''}`,
|
|
980
|
+
{ dimColor: true, wrap: 'truncate-end' },
|
|
981
|
+
),
|
|
982
|
+
],
|
|
983
|
+
}),
|
|
984
|
+
]
|
|
985
|
+
: [text('No code changed for it yet.', { dimColor: true })];
|
|
986
|
+
})(),
|
|
932
987
|
...(selected.tests?.length
|
|
933
988
|
? [
|
|
934
989
|
text('Tests that name it', { bold: true }),
|
|
@@ -939,5 +994,385 @@ export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
|
|
|
939
994
|
: [text('No test names it.', { dimColor: true })]),
|
|
940
995
|
],
|
|
941
996
|
});
|
|
942
|
-
|
|
997
|
+
// TRAIL-1: the selected requirement's spec as a trail: each requirement to its stories, the code
|
|
998
|
+
// they changed and the tests that name it, a gap where any link is missing.
|
|
999
|
+
const spec = selected.file;
|
|
1000
|
+
const trailRows = (trail?.requirements ?? []).filter(
|
|
1001
|
+
(r) =>
|
|
1002
|
+
r.spec === spec?.split('/').pop() && requirements.find((q) => q.id === r.id)?.file === spec,
|
|
1003
|
+
);
|
|
1004
|
+
const TONE = {
|
|
1005
|
+
req: {},
|
|
1006
|
+
selected: { bold: true, color: 'cyan' },
|
|
1007
|
+
dim: { dimColor: true },
|
|
1008
|
+
done: { color: 'green' },
|
|
1009
|
+
moving: { color: 'blue' },
|
|
1010
|
+
waiting: { color: 'red' },
|
|
1011
|
+
gap: { color: 'red' },
|
|
1012
|
+
test: { color: 'green' },
|
|
1013
|
+
};
|
|
1014
|
+
const flow =
|
|
1015
|
+
surface && surface !== 'terminal' && Svg
|
|
1016
|
+
? Svg({
|
|
1017
|
+
source: trailSvg(trailRows, { selected: selected.id }),
|
|
1018
|
+
alt: `${label(spec)}'s trail: each requirement to its stories, code and tests`,
|
|
1019
|
+
})
|
|
1020
|
+
: Box({
|
|
1021
|
+
key: 'trail-flow',
|
|
1022
|
+
flexDirection: 'column',
|
|
1023
|
+
children: trailRowsDrawn(trailRows, { selected: selected.id, width: columns }).map(
|
|
1024
|
+
(runs, i) =>
|
|
1025
|
+
Box({
|
|
1026
|
+
key: `trail-${trailRows[i].id}`,
|
|
1027
|
+
flexDirection: 'row',
|
|
1028
|
+
children: runs.map((r) => text(r.text, TONE[r.style] ?? {})),
|
|
1029
|
+
}),
|
|
1030
|
+
),
|
|
1031
|
+
});
|
|
1032
|
+
const trailBox = trailRows.length
|
|
1033
|
+
? [
|
|
1034
|
+
Box({
|
|
1035
|
+
key: 'trail',
|
|
1036
|
+
flexDirection: 'column',
|
|
1037
|
+
children: [text(`Trail · ${label(spec)}`, { bold: true }), flow],
|
|
1038
|
+
}),
|
|
1039
|
+
]
|
|
1040
|
+
: [];
|
|
1041
|
+
return [legend, grid, keys, ...trailBox, detail];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** A tint darkened toward the terminal's ground, for a glowing box's fill: `share` of the tint kept. */
|
|
1045
|
+
const shade = (hex, share) => {
|
|
1046
|
+
const n = Number.parseInt(String(hex).replace('#', ''), 16);
|
|
1047
|
+
if (!Number.isFinite(n)) return undefined;
|
|
1048
|
+
const c = (v) => Math.round(((n >> v) & 0xff) * share + 14 * (1 - share));
|
|
1049
|
+
return `#${[16, 8, 0].map((v) => c(v).toString(16).padStart(2, '0')).join('')}`;
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
/** A code map run's text style: its lane's tint where it glows, a fill inside a glowing box. */
|
|
1053
|
+
function runStyle(r, tints) {
|
|
1054
|
+
const tint = r.lane ? (tints[r.lane] ?? '#5fb3b3') : null;
|
|
1055
|
+
if (tint && (r.style === 'fill-read' || r.style === 'fill-edit'))
|
|
1056
|
+
return {
|
|
1057
|
+
backgroundColor: shade(tint, r.style === 'fill-edit' ? 0.45 : 0.28),
|
|
1058
|
+
color: 'white',
|
|
1059
|
+
...(r.style === 'fill-edit' ? { bold: true } : {}),
|
|
1060
|
+
};
|
|
1061
|
+
if (tint && (r.style === 'reading' || r.style === 'editing'))
|
|
1062
|
+
return { color: tint, bold: r.style === 'editing' };
|
|
1063
|
+
return RUN_STYLE[r.style] ?? {};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** The colour each style of the code map's runs takes, the lane's tint where it glows. */
|
|
1067
|
+
const RUN_STYLE = {
|
|
1068
|
+
dim: { dimColor: true },
|
|
1069
|
+
node: {},
|
|
1070
|
+
selected: { color: 'cyan', bold: true },
|
|
1071
|
+
ok: { color: 'green' },
|
|
1072
|
+
warn: { color: 'yellow' },
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* MAP-1 to MAP-4: the Code map tab: the stories to choose from, the story's files by layer with their
|
|
1077
|
+
* imports (glowing where a lane reads or edits them), and the selected file's card and neighbours.
|
|
1078
|
+
* `view` holds { state, codeMap, ui, glow, art, surface, columns }; `on` holds onMapStory(id),
|
|
1079
|
+
* onMapFile(path), onOpenFile(path).
|
|
1080
|
+
*/
|
|
1081
|
+
export function codeTab(view, { Box, Text, Button, Svg }, on) {
|
|
1082
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
1083
|
+
const { state, ui = DEFAULT_UI, codeMap: map = null, glow = {}, art = null } = view;
|
|
1084
|
+
const storyId = mapStoryOf(state, ui.selected, ui.mapStory);
|
|
1085
|
+
const choices = (state?.stories ?? []).filter(
|
|
1086
|
+
(s) => s.id === storyId || ['in progress', 'review', 'sent back'].includes(s.state),
|
|
1087
|
+
);
|
|
1088
|
+
const picker = Box({
|
|
1089
|
+
key: 'map-stories',
|
|
1090
|
+
flexDirection: 'row',
|
|
1091
|
+
columnGap: 1,
|
|
1092
|
+
flexWrap: 'wrap',
|
|
1093
|
+
children: choices.map((s) =>
|
|
1094
|
+
Button({
|
|
1095
|
+
key: `map-story-${s.id}`,
|
|
1096
|
+
label: `${s.id} ${s.title}`,
|
|
1097
|
+
...(s.id === storyId ? { variant: 'primary' } : { plain: true }),
|
|
1098
|
+
onPress: () => on.onMapStory(s.id),
|
|
1099
|
+
}),
|
|
1100
|
+
),
|
|
1101
|
+
});
|
|
1102
|
+
if (!storyId) return [text('No story to map yet: the plan has none open.', { dimColor: true })];
|
|
1103
|
+
if (!map || map.story?.id !== storyId)
|
|
1104
|
+
return [picker, text(`Reading ${storyId}'s code…`, { dimColor: true })];
|
|
1105
|
+
if (!map.files.length)
|
|
1106
|
+
return [picker, text(`${storyId} hasn't changed any code yet.`, { dimColor: true })];
|
|
1107
|
+
const selected = map.files.some((f) => f.path === ui.mapFile) ? ui.mapFile : map.files[0].path;
|
|
1108
|
+
// The lanes' tints are RGB numbers (the characters' colours); text and SVG take them as hex.
|
|
1109
|
+
const tints = Object.fromEntries(
|
|
1110
|
+
Object.entries(art?.tints ?? {}).map(([lane, rgb]) => [
|
|
1111
|
+
lane,
|
|
1112
|
+
typeof rgb === 'number' ? `#${rgb.toString(16).padStart(6, '0')}` : rgb,
|
|
1113
|
+
]),
|
|
1114
|
+
);
|
|
1115
|
+
const width = Math.max(40, (view.columns ?? 100) - 4);
|
|
1116
|
+
const drawing =
|
|
1117
|
+
view.surface !== 'terminal' && Svg
|
|
1118
|
+
? Svg({
|
|
1119
|
+
source: mapSvg(map, { glow, selected, tints, width }),
|
|
1120
|
+
alt: `${storyId}'s code: ${map.files.length} files by layer`,
|
|
1121
|
+
isInteractive: true,
|
|
1122
|
+
})
|
|
1123
|
+
: Box({
|
|
1124
|
+
key: 'code-map',
|
|
1125
|
+
flexDirection: 'column',
|
|
1126
|
+
children: codeRows(map, { glow, selected, width }).map((runs, y) =>
|
|
1127
|
+
Box({
|
|
1128
|
+
key: `code-row-${y}`,
|
|
1129
|
+
flexDirection: 'row',
|
|
1130
|
+
children: runs.map((r) => text(r.text, runStyle(r, tints))),
|
|
1131
|
+
}),
|
|
1132
|
+
),
|
|
1133
|
+
});
|
|
1134
|
+
const at = map.files.findIndex((f) => f.path === selected);
|
|
1135
|
+
const file = map.files[at];
|
|
1136
|
+
const card = cardOf(file);
|
|
1137
|
+
const near = neighboursOf(map, selected);
|
|
1138
|
+
const short = (p) => p.split('/').pop();
|
|
1139
|
+
const detail = Box({
|
|
1140
|
+
key: 'code-file',
|
|
1141
|
+
flexDirection: 'column',
|
|
1142
|
+
borderStyle: 'round',
|
|
1143
|
+
borderColor: 'gray',
|
|
1144
|
+
paddingX: 1,
|
|
1145
|
+
children: [
|
|
1146
|
+
text(selected, { bold: true }),
|
|
1147
|
+
text(`${file.layer}${file.lane ? ` · ${file.lane} lane` : ''}`, { dimColor: true }),
|
|
1148
|
+
...(card
|
|
1149
|
+
? [
|
|
1150
|
+
text(
|
|
1151
|
+
card.state === 'missing'
|
|
1152
|
+
? 'No why written yet: Context Graph keeps one as a card once it is.'
|
|
1153
|
+
: card.text,
|
|
1154
|
+
card.state === 'current' ? {} : { color: 'yellow' },
|
|
1155
|
+
),
|
|
1156
|
+
...(card.state === 'stale'
|
|
1157
|
+
? [text('The file changed since its why was written.', { dimColor: true })]
|
|
1158
|
+
: []),
|
|
1159
|
+
]
|
|
1160
|
+
: []),
|
|
1161
|
+
...(near.imports.length
|
|
1162
|
+
? [text(`imports ${near.imports.map(short).join(', ')}`, { dimColor: true })]
|
|
1163
|
+
: []),
|
|
1164
|
+
...(near.importers.length
|
|
1165
|
+
? [text(`imported by ${near.importers.map(short).join(', ')}`, { dimColor: true })]
|
|
1166
|
+
: []),
|
|
1167
|
+
],
|
|
1168
|
+
});
|
|
1169
|
+
const keys = Box({
|
|
1170
|
+
key: 'code-keys',
|
|
1171
|
+
flexDirection: 'row',
|
|
1172
|
+
columnGap: 1,
|
|
1173
|
+
children: [
|
|
1174
|
+
Button({
|
|
1175
|
+
key: 'code-next',
|
|
1176
|
+
label: 'Next file',
|
|
1177
|
+
hotkey: 'j',
|
|
1178
|
+
plain: true,
|
|
1179
|
+
onPress: () => on.onMapFile(map.files[(at + 1) % map.files.length].path),
|
|
1180
|
+
}),
|
|
1181
|
+
Button({
|
|
1182
|
+
key: 'code-prev',
|
|
1183
|
+
label: 'Previous',
|
|
1184
|
+
hotkey: 'k',
|
|
1185
|
+
plain: true,
|
|
1186
|
+
onPress: () => on.onMapFile(map.files[(at - 1 + map.files.length) % map.files.length].path),
|
|
1187
|
+
}),
|
|
1188
|
+
...(map.open
|
|
1189
|
+
? [
|
|
1190
|
+
Button({
|
|
1191
|
+
key: 'code-open',
|
|
1192
|
+
label: 'Open in Context Graph',
|
|
1193
|
+
hotkey: 'o',
|
|
1194
|
+
plain: true,
|
|
1195
|
+
onPress: () => on.onOpenFile(selected),
|
|
1196
|
+
}),
|
|
1197
|
+
]
|
|
1198
|
+
: []),
|
|
1199
|
+
],
|
|
1200
|
+
});
|
|
1201
|
+
const legend = Box({
|
|
1202
|
+
key: 'code-legend',
|
|
1203
|
+
flexDirection: 'row',
|
|
1204
|
+
columnGap: 2,
|
|
1205
|
+
flexWrap: 'wrap',
|
|
1206
|
+
children: MAP_LEGEND.map((l) =>
|
|
1207
|
+
text(
|
|
1208
|
+
l.text,
|
|
1209
|
+
l.style === 'glow' ? { color: tints[file.lane] ?? 'cyan' } : (RUN_STYLE[l.style] ?? {}),
|
|
1210
|
+
),
|
|
1211
|
+
),
|
|
1212
|
+
});
|
|
1213
|
+
return [picker, drawing, legend, detail, keys];
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* DECIDE-3: the Decisions tab: each decision that needs the person, the selected one as a card with its
|
|
1218
|
+
* lineage (what they asked for, the story and requirements, what was happening, what's settled, why it
|
|
1219
|
+
* needs them) and its options, each saying what it does. `on`: onDecision(i), onNeed(id, line).
|
|
1220
|
+
*/
|
|
1221
|
+
export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
|
|
1222
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
1223
|
+
if (!cards.length) return [];
|
|
1224
|
+
const at = Math.min(ui.decision ?? 0, cards.length - 1);
|
|
1225
|
+
const card = cards[at];
|
|
1226
|
+
const row = (label, value, style = {}) =>
|
|
1227
|
+
Box({
|
|
1228
|
+
key: `decision-${label}`,
|
|
1229
|
+
flexDirection: 'row',
|
|
1230
|
+
columnGap: 1,
|
|
1231
|
+
children: [
|
|
1232
|
+
text(label.padEnd(15), { dimColor: true }),
|
|
1233
|
+
text(value, { wrap: 'wrap', ...style }),
|
|
1234
|
+
],
|
|
1235
|
+
});
|
|
1236
|
+
const list = Box({
|
|
1237
|
+
key: 'decision-list',
|
|
1238
|
+
flexDirection: 'column',
|
|
1239
|
+
children: cards.map((c, i) =>
|
|
1240
|
+
Button({
|
|
1241
|
+
key: `decision-pick-${i}`,
|
|
1242
|
+
label: `${i === at ? '›' : ' '} ${c.title}`,
|
|
1243
|
+
plain: true,
|
|
1244
|
+
onPress: () => on.onDecision(i),
|
|
1245
|
+
}),
|
|
1246
|
+
),
|
|
1247
|
+
});
|
|
1248
|
+
const detail = Box({
|
|
1249
|
+
key: 'decision-card',
|
|
1250
|
+
flexDirection: 'column',
|
|
1251
|
+
borderStyle: 'round',
|
|
1252
|
+
borderColor: 'yellow',
|
|
1253
|
+
paddingX: 1,
|
|
1254
|
+
rowGap: 0,
|
|
1255
|
+
children: [
|
|
1256
|
+
text(card.title, { bold: true }),
|
|
1257
|
+
...(card.asked ? [row('You asked for', card.asked)] : []),
|
|
1258
|
+
...(card.story
|
|
1259
|
+
? [
|
|
1260
|
+
row(
|
|
1261
|
+
'Story',
|
|
1262
|
+
`${card.story.id} ${card.story.title}${card.story.branch ? ` · ${card.story.branch}` : ''}`,
|
|
1263
|
+
),
|
|
1264
|
+
]
|
|
1265
|
+
: []),
|
|
1266
|
+
...(card.requirements.length
|
|
1267
|
+
? [
|
|
1268
|
+
row(
|
|
1269
|
+
'Requirements',
|
|
1270
|
+
card.requirements.map((r) => `${r.id}${r.title ? ` ${r.title}` : ''}`).join('; '),
|
|
1271
|
+
),
|
|
1272
|
+
]
|
|
1273
|
+
: []),
|
|
1274
|
+
...(card.doing ? [row('Happening', card.doing)] : []),
|
|
1275
|
+
...(card.settled.length
|
|
1276
|
+
? [
|
|
1277
|
+
text('Already settled', { dimColor: true }),
|
|
1278
|
+
...card.settled.map((line, i) =>
|
|
1279
|
+
Box({
|
|
1280
|
+
key: `decision-settled-${i}`,
|
|
1281
|
+
children: [text(` · ${line}`, { wrap: 'wrap' })],
|
|
1282
|
+
}),
|
|
1283
|
+
),
|
|
1284
|
+
]
|
|
1285
|
+
: []),
|
|
1286
|
+
...(card.why ? [row('Why it is yours', card.why, { color: 'yellow' })] : []),
|
|
1287
|
+
text('Your options', { dimColor: true }),
|
|
1288
|
+
...card.options.map((o) =>
|
|
1289
|
+
Box({
|
|
1290
|
+
key: `decision-option-${o.id}`,
|
|
1291
|
+
flexDirection: 'row',
|
|
1292
|
+
columnGap: 2,
|
|
1293
|
+
children: [
|
|
1294
|
+
Button({
|
|
1295
|
+
key: `decide-${o.id}`,
|
|
1296
|
+
label: o.label,
|
|
1297
|
+
...(o.hotkey ? { hotkey: o.hotkey } : {}),
|
|
1298
|
+
variant: 'primary',
|
|
1299
|
+
onPress: () => on.onNeed(o.id, card.line),
|
|
1300
|
+
}),
|
|
1301
|
+
...(o.effect ? [text(o.effect, { dimColor: true, wrap: 'wrap' })] : []),
|
|
1302
|
+
],
|
|
1303
|
+
}),
|
|
1304
|
+
),
|
|
1305
|
+
],
|
|
1306
|
+
});
|
|
1307
|
+
return [
|
|
1308
|
+
text(
|
|
1309
|
+
`${cards.length} decision${cards.length === 1 ? '' : 's'} need${cards.length === 1 ? 's' : ''} you. Everything else is the lead's.`,
|
|
1310
|
+
{ bold: true },
|
|
1311
|
+
),
|
|
1312
|
+
...(cards.length > 1 ? [list] : []),
|
|
1313
|
+
detail,
|
|
1314
|
+
];
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const JOURNAL_GLYPH = {
|
|
1318
|
+
request: '✦',
|
|
1319
|
+
decision: '◆',
|
|
1320
|
+
spec: '▤',
|
|
1321
|
+
setup: '⚙',
|
|
1322
|
+
dispatch: '→',
|
|
1323
|
+
sentBack: '↩',
|
|
1324
|
+
merge: '✓',
|
|
1325
|
+
approval: '✔',
|
|
1326
|
+
milestone: '★',
|
|
1327
|
+
loop: '·',
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* JOURNAL-1: the Journal tab: the build's lineage, newest last, each event with its time and a glyph,
|
|
1332
|
+
* your own approvals and choices marked. The latest 60 are drawn.
|
|
1333
|
+
*/
|
|
1334
|
+
export function journalTab(events, { Box, Text }, now = Date.now()) {
|
|
1335
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
1336
|
+
if (!events.length) return [];
|
|
1337
|
+
const shown = events.slice(-60);
|
|
1338
|
+
const day = (at) => new Date(at).toISOString().slice(0, 10);
|
|
1339
|
+
const time = (at) => new Date(at).toISOString().slice(11, 16);
|
|
1340
|
+
const rows = [];
|
|
1341
|
+
let lastDay = null;
|
|
1342
|
+
shown.forEach((e, i) => {
|
|
1343
|
+
if (day(e.at) !== lastDay) {
|
|
1344
|
+
lastDay = day(e.at);
|
|
1345
|
+
rows.push(
|
|
1346
|
+
Box({ key: `journal-day-${i}`, children: [text(lastDay, { bold: true, dimColor: true })] }),
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
const yours = e.who === 'person' || e.kind === 'request';
|
|
1350
|
+
rows.push(
|
|
1351
|
+
Box({
|
|
1352
|
+
key: `journal-${i}`,
|
|
1353
|
+
flexDirection: 'row',
|
|
1354
|
+
columnGap: 1,
|
|
1355
|
+
children: [
|
|
1356
|
+
text(time(e.at), { dimColor: true }),
|
|
1357
|
+
text(JOURNAL_GLYPH[e.kind] ?? '·', {
|
|
1358
|
+
color: yours
|
|
1359
|
+
? 'yellow'
|
|
1360
|
+
: e.kind === 'merge' || e.kind === 'milestone'
|
|
1361
|
+
? 'green'
|
|
1362
|
+
: e.kind === 'sentBack'
|
|
1363
|
+
? 'magenta'
|
|
1364
|
+
: undefined,
|
|
1365
|
+
}),
|
|
1366
|
+
text(e.text, { wrap: 'wrap', ...(yours ? { color: 'yellow' } : {}) }),
|
|
1367
|
+
],
|
|
1368
|
+
}),
|
|
1369
|
+
);
|
|
1370
|
+
});
|
|
1371
|
+
return [
|
|
1372
|
+
text(
|
|
1373
|
+
`From your request to now: ${events.length} event${events.length === 1 ? '' : 's'}${events.length > shown.length ? `, the latest ${shown.length} shown` : ''}. Yours are in yellow.`,
|
|
1374
|
+
{ dimColor: true },
|
|
1375
|
+
),
|
|
1376
|
+
Box({ key: 'journal', flexDirection: 'column', children: rows }),
|
|
1377
|
+
];
|
|
943
1378
|
}
|