@nanocollective/roster 0.1.0-alpha.15 → 0.1.0-alpha.16
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/package.json +1 -1
- package/templates/portal/css/markdown.css +19 -0
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/views/org.js +35 -26
- package/templates/portal/js/views/orgedit.js +213 -0
- package/templates/portal/js/views/prompt.js +4 -1
- package/templates/portal/js/views/setup.js +15 -153
package/package.json
CHANGED
|
@@ -75,3 +75,22 @@ pre.yaml{color:var(--ink)}
|
|
|
75
75
|
.md img{display:block;max-width:100%;height:auto;margin:14px 0;
|
|
76
76
|
border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--shadow)}
|
|
77
77
|
.md img+em,.md p>img+em{display:block}
|
|
78
|
+
|
|
79
|
+
/* ---------- highlighted editors (mdedit.js) ---------- */
|
|
80
|
+
|
|
81
|
+
.hled{position:relative}
|
|
82
|
+
.hled .hlback{position:absolute;inset:0;margin:0;pointer-events:none;overflow:hidden;
|
|
83
|
+
white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;color:var(--ink);
|
|
84
|
+
border-color:transparent !important;background:transparent !important}
|
|
85
|
+
.hled .hlfront{position:relative;color:transparent;background:transparent;caret-color:var(--ink);
|
|
86
|
+
-webkit-text-fill-color:transparent}
|
|
87
|
+
.hled .hlfront::selection{background:var(--accent-soft)}
|
|
88
|
+
.mdh{color:var(--ruled);font-weight:700}
|
|
89
|
+
.mdb{font-weight:700}
|
|
90
|
+
.mdi{font-style:italic}
|
|
91
|
+
.mdc,.mdcode,.mdfence{color:var(--derived)}
|
|
92
|
+
.mdq{color:var(--ink-dim);font-style:italic}
|
|
93
|
+
.mdl{color:var(--accent)}
|
|
94
|
+
.mdk{color:var(--accent)}
|
|
95
|
+
.mdu{color:var(--ink-faint)}
|
|
96
|
+
.mdt{color:var(--ruled)}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Colour behind a textarea, for the files people edit here.
|
|
2
|
+
*
|
|
3
|
+
* The textarea stays the thing you type into, so undo, selection, spellcheck and ⌘S all work
|
|
4
|
+
* as they always did. Its text is made transparent and a <pre> with the same box sits behind
|
|
5
|
+
* it, redrawn on every keystroke. The editors grow to fit rather than scroll, which is what
|
|
6
|
+
* keeps the two lined up without syncing a scroll position.
|
|
7
|
+
*
|
|
8
|
+
* Markdown here is a per-line tokeniser, like yaml.js: enough to read by, and anything it does
|
|
9
|
+
* not recognise is plain text rather than an error.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { el, esc } from "./dom.js";
|
|
13
|
+
import { yamlHTML } from "./yaml.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a textarea so its contents are highlighted. Returns the wrapper, which is what goes on
|
|
17
|
+
* the page in place of the textarea.
|
|
18
|
+
* @param {"md"|"yaml"} lang
|
|
19
|
+
*/
|
|
20
|
+
export function highlighted(ta, lang = "md") {
|
|
21
|
+
const wrap = el("div", { className: "hled" });
|
|
22
|
+
const back = el("pre", { className: "hlback" });
|
|
23
|
+
back.setAttribute?.("aria-hidden", "true");
|
|
24
|
+
ta.classList?.add("hlfront");
|
|
25
|
+
wrap.append(back, ta);
|
|
26
|
+
/* The colours only line up if both boxes set text identically, and the textarea's box comes
|
|
27
|
+
from whichever class it already had, so it is read rather than restated in CSS. */
|
|
28
|
+
const match = () => {
|
|
29
|
+
if (typeof getComputedStyle !== "function" || !ta.isConnected) return;
|
|
30
|
+
const cs = getComputedStyle(ta);
|
|
31
|
+
for (const k of ["font", "lineHeight", "letterSpacing", "tabSize", "padding", "borderWidth", "borderStyle", "borderRadius"]) {
|
|
32
|
+
back.style[k] = cs[k];
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const paint = () => {
|
|
36
|
+
match();
|
|
37
|
+
// A trailing newline in a textarea is a line; in a <pre> it is not, without something after it.
|
|
38
|
+
back.innerHTML = (lang === "yaml" ? yamlHTML(ta.value) : markdownHTML(ta.value)) + "\n ";
|
|
39
|
+
};
|
|
40
|
+
ta.addEventListener("input", paint);
|
|
41
|
+
paint();
|
|
42
|
+
// Setting .value from code fires no input event, so the owner can ask for a repaint.
|
|
43
|
+
wrap.repaint = paint;
|
|
44
|
+
return wrap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Escaped, highlighted HTML for markdown source. */
|
|
48
|
+
export function markdownHTML(text) {
|
|
49
|
+
let fenced = false;
|
|
50
|
+
return String(text ?? "")
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => {
|
|
53
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
54
|
+
fenced = !fenced;
|
|
55
|
+
return span("mdfence", line);
|
|
56
|
+
}
|
|
57
|
+
if (fenced) return span("mdcode", line);
|
|
58
|
+
if (/^#{1,6}\s/.test(line)) return span("mdh", line);
|
|
59
|
+
if (/^\s*>/.test(line)) return span("mdq", line);
|
|
60
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) return span("mdfence", line);
|
|
61
|
+
const list = /^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/.exec(line);
|
|
62
|
+
if (list) return esc(list[1]) + span("mdl", list[2]) + esc(list[3]) + inline(list[4]);
|
|
63
|
+
return inline(line);
|
|
64
|
+
})
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const span = (cls, text) => '<span class="' + cls + '">' + esc(text) + "</span>";
|
|
69
|
+
|
|
70
|
+
/** Inline marks, on one line. Code spans first, so nothing inside one is read as markup. */
|
|
71
|
+
function inline(line) {
|
|
72
|
+
return line
|
|
73
|
+
.split(/(`[^`]+`)/)
|
|
74
|
+
.map((part, i) => (i % 2 ? span("mdc", part) : marks(part)))
|
|
75
|
+
.join("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function marks(text) {
|
|
79
|
+
return esc(text)
|
|
80
|
+
.replace(/\{\{[^}]*\}\}/g, (m) => '<span class="mdt">' + m + "</span>")
|
|
81
|
+
.replace(/(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, '<span class="mdb">$1$2$1</span>')
|
|
82
|
+
.replace(/(^|[^*\w])([*_])(?=\S)([^*_]+?)(?<=\S)\2(?![*\w])/g, '$1<span class="mdi">$2$3$2</span>')
|
|
83
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<span class="mdk">[$1]</span><span class="mdu">($2)</span>');
|
|
84
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* wrong. The server validates it before writing.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { highlighted } from "../mdedit.js";
|
|
17
18
|
import { getFile, getOrgLayer, post } from "../api.js";
|
|
18
19
|
import { askYes } from "../dialog.js";
|
|
19
20
|
import { el, esc, grow, kb, skeleton } from "../dom.js";
|
|
@@ -21,31 +22,27 @@ import { icon } from "../icons.js";
|
|
|
21
22
|
import { mdlite } from "../md.js";
|
|
22
23
|
import { humansOf, S, writeHash } from "../state.js";
|
|
23
24
|
import { yamlPre } from "../yaml.js";
|
|
25
|
+
import { businessForm, fileModes, prioritiesForm } from "./orgedit.js";
|
|
24
26
|
import { repoPicker } from "./repos.js";
|
|
25
27
|
|
|
26
28
|
/* What the files roster itself ships are for, in one line each. A filename says nothing about
|
|
27
29
|
which one to open; anything a tenant has added of its own falls back to its first heading. */
|
|
28
30
|
const KNOWN = {
|
|
29
|
-
"org.yaml": "
|
|
30
|
-
"org/business.md":
|
|
31
|
-
|
|
32
|
-
"org/
|
|
33
|
-
|
|
34
|
-
"org/
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"How everything they write reads. Change it once, and every surface changes on the next run.",
|
|
40
|
-
"prompts/daily.md": "The whole of a scheduled run, before the fragments are pulled into it.",
|
|
41
|
-
"prompts/mention.md": "What they are sent when you @-mention them.",
|
|
42
|
-
"prompts/_identity.md": "Who they post as, and what wakes you.",
|
|
43
|
-
"prompts/_paths.md": "Where everything is in the runner's checkout.",
|
|
31
|
+
"org.yaml": "The people, repos, defaults and coding agent.",
|
|
32
|
+
"org/business.md": "What the business is. Every staff member reads it.",
|
|
33
|
+
"org/priorities.md": "This month's priorities, in order, and what's out of scope.",
|
|
34
|
+
"org/operating.md": "What staff do on their own, what they ask about, and how a run starts and ends.",
|
|
35
|
+
"org/guardrails.md": "Rules every staff member must follow.",
|
|
36
|
+
"org/voice.md": "How staff write.",
|
|
37
|
+
"prompts/daily.md": "The instructions for a scheduled run.",
|
|
38
|
+
"prompts/mention.md": "The instructions when you @-mention someone.",
|
|
39
|
+
"prompts/_identity.md": "Who staff post as.",
|
|
40
|
+
"prompts/_paths.md": "Where files are during a run.",
|
|
44
41
|
};
|
|
45
42
|
|
|
46
43
|
const GROUPS = [
|
|
47
|
-
["org", "
|
|
48
|
-
["prompts", "
|
|
44
|
+
["org", "Org files", "Every staff member reads these."],
|
|
45
|
+
["prompts", "Prompts", "Roster's run instructions. Upgrades may change them."],
|
|
49
46
|
];
|
|
50
47
|
|
|
51
48
|
export function viewOrg(m) {
|
|
@@ -55,8 +52,7 @@ export function viewOrg(m) {
|
|
|
55
52
|
el("p", {
|
|
56
53
|
className: "sub",
|
|
57
54
|
textContent:
|
|
58
|
-
"
|
|
59
|
-
org.staff.length + " of them on their next run.",
|
|
55
|
+
"Files every staff member reads. Changes apply from their next run.",
|
|
60
56
|
}),
|
|
61
57
|
);
|
|
62
58
|
|
|
@@ -172,17 +168,28 @@ export function viewOrg(m) {
|
|
|
172
168
|
head.append(el("span", { className: "meta", textContent: full(path) + " · " + kb(text.length) }));
|
|
173
169
|
/* The edit control used to be a word in a row of grey text, which is why "there is no easy
|
|
174
170
|
way to edit the org docs" was a fair thing to say about a screen that could edit them. */
|
|
175
|
-
const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
|
|
176
|
-
edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
|
|
177
|
-
head.append(edit);
|
|
178
171
|
const file = S.orgFiles.find((f) => f.path === path) ?? { path };
|
|
179
|
-
viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
|
|
180
172
|
|
|
181
173
|
if (path.endsWith(".yaml") || path.endsWith(".yml")) {
|
|
182
|
-
|
|
174
|
+
const edit = el("button", { className: "ghbtn primary editbtn", onclick: () => editor(path, text) });
|
|
175
|
+
edit.append(icon("edit", "ic"), el("span", { textContent: "Edit" }));
|
|
176
|
+
head.append(edit);
|
|
177
|
+
viewer.append(head, el("p", { className: "factsub", textContent: why(file) }), yamlPre(text));
|
|
183
178
|
return;
|
|
184
179
|
}
|
|
185
|
-
|
|
180
|
+
|
|
181
|
+
/* Prose files open on Read, with the ways to change them as tabs beside it: the same ones
|
|
182
|
+
Getting started offers, because a file is no easier to write after setup than during it. */
|
|
183
|
+
viewer.append(head, el("p", { className: "factsub", textContent: why(file) }));
|
|
184
|
+
const read = { label: "Read", node: el("div", { className: "md doc", innerHTML: mdlite(text) }) };
|
|
185
|
+
const o = { path: full(path), name: S.data.name, text, before: [read], onSaved: () => show(path) };
|
|
186
|
+
viewer.append(
|
|
187
|
+
path === "org/business.md"
|
|
188
|
+
? businessForm(o)
|
|
189
|
+
: path === "org/priorities.md"
|
|
190
|
+
? prioritiesForm(o)
|
|
191
|
+
: fileModes(o),
|
|
192
|
+
);
|
|
186
193
|
}
|
|
187
194
|
|
|
188
195
|
function editor(path, text) {
|
|
@@ -198,8 +205,10 @@ export function viewOrg(m) {
|
|
|
198
205
|
viewer.append(bar);
|
|
199
206
|
|
|
200
207
|
const ta = el("textarea", { value: text, className: "editor" });
|
|
201
|
-
|
|
208
|
+
const hl = highlighted(ta, isYaml ? "yaml" : "md");
|
|
209
|
+
viewer.append(hl);
|
|
202
210
|
grow(ta);
|
|
211
|
+
hl.repaint();
|
|
203
212
|
ta.addEventListener("input", () => grow(ta));
|
|
204
213
|
// ⌘S is what a person's hands do in a text box. Without it, saving means finding a button
|
|
205
214
|
// above a screenful of textarea you have just scrolled past.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/* Ways to write the two org files a person has to write, shared by Getting started and the Org
|
|
2
|
+
* screen: plain questions, an AI interview, or the file itself. Each is one tab of a switcher,
|
|
3
|
+
* so the choice is visible rather than folded under a Save button. */
|
|
4
|
+
|
|
5
|
+
import { fileUrl, saveFile } from "../api.js";
|
|
6
|
+
import { el, grow } from "../dom.js";
|
|
7
|
+
import { highlighted } from "../mdedit.js";
|
|
8
|
+
import { paste } from "./paste.js";
|
|
9
|
+
|
|
10
|
+
const BUSINESS_QUESTIONS = [
|
|
11
|
+
["line", "What does it do, in one line?", "What this business does, in one line", true],
|
|
12
|
+
["who", "Who is it for?", "Who the customers are, specifically", true],
|
|
13
|
+
["key", "What does everything depend on?", "The one fact everything else follows from", false],
|
|
14
|
+
["true", "What's already true? What's built, measured or tried?", "What is already true", false],
|
|
15
|
+
["not", "What should staff never decide on their own?", "What is not ours to decide", false],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const val = (input) => String(input.value ?? "").trim();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* org/business.md as five questions. With `text`, the answers already in the file fill the
|
|
22
|
+
* fields, so changing one answer does not mean retyping the other four.
|
|
23
|
+
* @param {{path: string, name: string, text?: string, onSaved: () => void, before?: object[]}} o
|
|
24
|
+
* `before` puts extra tabs ahead of the three, which is how the Org screen adds Read.
|
|
25
|
+
*/
|
|
26
|
+
export function businessForm(o) {
|
|
27
|
+
const form = el("div");
|
|
28
|
+
const known = sections(o.text ?? "");
|
|
29
|
+
const fields = BUSINESS_QUESTIONS.map(([id, label, heading, required]) => {
|
|
30
|
+
const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
|
|
31
|
+
const had = known.get(heading.toLowerCase());
|
|
32
|
+
if (had) input.value = had.replace(/^>\s?/gm, "");
|
|
33
|
+
return { id, input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: label + (required ? "" : " (optional)") }), input]) };
|
|
34
|
+
});
|
|
35
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
36
|
+
const out = el("div");
|
|
37
|
+
form.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
|
|
38
|
+
|
|
39
|
+
save.onclick = async () => {
|
|
40
|
+
const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !val(f.input));
|
|
41
|
+
if (missing) {
|
|
42
|
+
missing.input.focus?.();
|
|
43
|
+
out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const text =
|
|
47
|
+
`# What ${o.name} is\n\n` +
|
|
48
|
+
fields
|
|
49
|
+
.map((f, i) => [BUSINESS_QUESTIONS[i][2], val(f.input)])
|
|
50
|
+
.filter(([, v]) => v)
|
|
51
|
+
.map(([h, v]) => `## ${h}\n\n${v}\n`)
|
|
52
|
+
.join("\n");
|
|
53
|
+
await saveAndSay(save, out, o.path, text, "portal: write org/business.md", o.onSaved);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return modes([
|
|
57
|
+
...(o.before ?? []),
|
|
58
|
+
{ label: "Answer questions", node: form },
|
|
59
|
+
{ label: "Let an AI interview you", node: paste({ kind: "discover", title: "", onSaved: o.onSaved }) },
|
|
60
|
+
rawMode(o.path, o.onSaved),
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** org/priorities.md as three lines and an out-of-scope list, filled from the file if it has them. */
|
|
65
|
+
export function prioritiesForm(o) {
|
|
66
|
+
const form = el("div");
|
|
67
|
+
const { items, outs } = parsePriorities(o.text ?? "");
|
|
68
|
+
const ranks = [1, 2, 3].map((i) => {
|
|
69
|
+
const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
|
|
70
|
+
if (items[i - 1]) input.value = items[i - 1];
|
|
71
|
+
return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
|
|
72
|
+
});
|
|
73
|
+
const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
|
|
74
|
+
if (outs.length) scope.value = outs.join("\n");
|
|
75
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
76
|
+
const out = el("div");
|
|
77
|
+
form.append(
|
|
78
|
+
...ranks.map((r) => r.wrap),
|
|
79
|
+
el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
|
|
80
|
+
el("div", { className: "row" }, [save]),
|
|
81
|
+
out,
|
|
82
|
+
);
|
|
83
|
+
save.onclick = async () => {
|
|
84
|
+
const list = ranks.map((r) => val(r.input)).filter(Boolean);
|
|
85
|
+
if (!list.length) {
|
|
86
|
+
ranks[0].input.focus?.();
|
|
87
|
+
out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const scoped = val(scope).split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
|
|
91
|
+
const text =
|
|
92
|
+
"## What matters this month\n\n### Priorities, in order\n\n" +
|
|
93
|
+
list.map((t, i) => `${i + 1}. ${t}`).join("\n") +
|
|
94
|
+
"\n" +
|
|
95
|
+
(scoped.length ? "\n### Out of scope this month\n\n" + scoped.map((t) => `- ${t}`).join("\n") + "\n" : "");
|
|
96
|
+
await saveAndSay(save, out, o.path, text, "portal: write org/priorities.md", o.onSaved);
|
|
97
|
+
};
|
|
98
|
+
return modes([...(o.before ?? []), { label: "Fill in", node: form }, rawMode(o.path, o.onSaved)]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Any org file as highlighted text, with Read ahead of it on the Org screen. */
|
|
102
|
+
export function fileModes(o) {
|
|
103
|
+
return modes([...(o.before ?? []), rawMode(o.path, o.onSaved, o.lang)]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The file itself, for editing what is already there rather than starting again. */
|
|
107
|
+
export function rawMode(path, onSaved, lang = "md") {
|
|
108
|
+
const node = el("div");
|
|
109
|
+
const ta = el("textarea", { className: "pastebox" });
|
|
110
|
+
ta.rows = 12;
|
|
111
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
112
|
+
const out = el("div");
|
|
113
|
+
const hl = highlighted(ta, lang);
|
|
114
|
+
node.append(hl, el("div", { className: "row" }, [save]), out);
|
|
115
|
+
ta.addEventListener("input", () => grow(ta));
|
|
116
|
+
const commit = () =>
|
|
117
|
+
saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
|
|
118
|
+
save.onclick = commit;
|
|
119
|
+
// ⌘S is what a person's hands do in a text box.
|
|
120
|
+
ta.addEventListener("keydown", (e) => {
|
|
121
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
commit();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// Read when first shown, so it shows the file as it is then, not as it was at page load.
|
|
127
|
+
const load = () =>
|
|
128
|
+
// Not getFile: an absent file answers 404 with a message, and that is not what to edit.
|
|
129
|
+
fetch(fileUrl(path), { cache: "no-store" })
|
|
130
|
+
.then((r) => (r.ok ? r.text() : ""))
|
|
131
|
+
.then((text) => {
|
|
132
|
+
ta.value = text;
|
|
133
|
+
grow(ta);
|
|
134
|
+
hl.repaint();
|
|
135
|
+
});
|
|
136
|
+
return { label: "Edit the file", node, onShow: load };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Ways to do one thing, as a switcher across the top: one shown at a time. */
|
|
140
|
+
export function modes(list) {
|
|
141
|
+
const box = el("div");
|
|
142
|
+
const bar = el("div", { className: "modes" });
|
|
143
|
+
const panes = el("div");
|
|
144
|
+
const buttons = list.map((m, i) => {
|
|
145
|
+
const b = el("button", { className: "mode", textContent: m.label });
|
|
146
|
+
b.onclick = () => show(i);
|
|
147
|
+
return b;
|
|
148
|
+
});
|
|
149
|
+
bar.append(...buttons);
|
|
150
|
+
box.append(bar, panes);
|
|
151
|
+
function show(i) {
|
|
152
|
+
buttons.forEach((b, j) => b.classList.toggle("on", i === j));
|
|
153
|
+
panes.replaceChildren(list[i].node);
|
|
154
|
+
list[i].onShow?.();
|
|
155
|
+
}
|
|
156
|
+
show(0);
|
|
157
|
+
return box;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function saveAndSay(btn, out, path, text, message, onSaved) {
|
|
161
|
+
btn.disabled = true;
|
|
162
|
+
const label = btn.textContent;
|
|
163
|
+
btn.textContent = "Saving…";
|
|
164
|
+
try {
|
|
165
|
+
const r = await saveFile(path, text, message);
|
|
166
|
+
out.replaceChildren(
|
|
167
|
+
el("p", {
|
|
168
|
+
className: r.pushed ? "sub" : "err",
|
|
169
|
+
textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
onSaved?.();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
175
|
+
} finally {
|
|
176
|
+
btn.disabled = false;
|
|
177
|
+
btn.textContent = label;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `## Heading` → the text under it, by lower-cased heading. */
|
|
182
|
+
function sections(text) {
|
|
183
|
+
const out = new Map();
|
|
184
|
+
let key = null;
|
|
185
|
+
let buf = [];
|
|
186
|
+
const flush = () => key && out.set(key, buf.join("\n").trim());
|
|
187
|
+
for (const line of text.split("\n")) {
|
|
188
|
+
const m = /^##\s+(.*)$/.exec(line);
|
|
189
|
+
if (m) {
|
|
190
|
+
flush();
|
|
191
|
+
key = m[1].trim().toLowerCase();
|
|
192
|
+
buf = [];
|
|
193
|
+
} else if (key) buf.push(line);
|
|
194
|
+
}
|
|
195
|
+
flush();
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parsePriorities(text) {
|
|
200
|
+
const items = [];
|
|
201
|
+
const outs = [];
|
|
202
|
+
let where = "";
|
|
203
|
+
for (const line of text.split("\n")) {
|
|
204
|
+
if (/^#+\s.*priorit/i.test(line)) where = "p";
|
|
205
|
+
else if (/^#+\s.*out of scope/i.test(line)) where = "o";
|
|
206
|
+
else if (/^#/.test(line)) where = "";
|
|
207
|
+
const n = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
208
|
+
const b = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
209
|
+
if (where === "p" && n) items.push(n[1].trim());
|
|
210
|
+
if (where === "o" && b) outs.push(b[1].trim());
|
|
211
|
+
}
|
|
212
|
+
return { items, outs };
|
|
213
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* changing a byte of the composed text, and nothing said so before.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { highlighted } from "../mdedit.js";
|
|
12
13
|
import { getFile, post } from "../api.js";
|
|
13
14
|
import { askText, askYes } from "../dialog.js";
|
|
14
15
|
import { el, esc, grow, kb, toClipboard } from "../dom.js";
|
|
@@ -351,8 +352,10 @@ export function viewPrompt(m) {
|
|
|
351
352
|
);
|
|
352
353
|
|
|
353
354
|
const ta = el("textarea", { value: text, className: "editor" });
|
|
354
|
-
|
|
355
|
+
const hl = highlighted(ta, "md");
|
|
356
|
+
viewer.append(hl);
|
|
355
357
|
grow(ta);
|
|
358
|
+
hl.repaint();
|
|
356
359
|
ta.addEventListener("input", () => grow(ta));
|
|
357
360
|
|
|
358
361
|
const status = el("span", { className: "meta" });
|
|
@@ -8,22 +8,20 @@
|
|
|
8
8
|
import {
|
|
9
9
|
checkOrg,
|
|
10
10
|
createTenant,
|
|
11
|
-
fileUrl,
|
|
12
11
|
getAccess,
|
|
13
12
|
getOrg,
|
|
14
13
|
getSetup,
|
|
15
14
|
joinOrg,
|
|
16
15
|
planTenant,
|
|
17
|
-
saveFile,
|
|
18
16
|
setAccess,
|
|
19
17
|
} from "../api.js";
|
|
20
|
-
import { $, el, esc,
|
|
18
|
+
import { $, el, esc, toClipboard } from "../dom.js";
|
|
21
19
|
import { go } from "../router.js";
|
|
22
20
|
import { S as App } from "../state.js";
|
|
23
21
|
import { checklist } from "./checklist.js";
|
|
22
|
+
import { businessForm, prioritiesForm } from "./orgedit.js";
|
|
24
23
|
import { credentialPanel } from "./credential.js";
|
|
25
24
|
import { repoPicker } from "./repos.js";
|
|
26
|
-
import { paste } from "./paste.js";
|
|
27
25
|
|
|
28
26
|
let S = null; // /api/setup/status, refreshed after anything that changes the world
|
|
29
27
|
|
|
@@ -394,13 +392,24 @@ function afterCreate() {
|
|
|
394
392
|
|
|
395
393
|
const business = todo(++n, "Describe the business", !left.includes("business"));
|
|
396
394
|
business.hint("A few short answers. Every staff member reads them before they start work.");
|
|
397
|
-
business.body.append(
|
|
395
|
+
business.body.append(
|
|
396
|
+
businessForm({
|
|
397
|
+
path: opsName() + "/org/business.md",
|
|
398
|
+
name: App.data?.name ?? S.tenant.name ?? S.tenant.org,
|
|
399
|
+
onSaved: () => (business.setDone(true), recheck()),
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
398
402
|
fileSteps.set("business", business.setDone);
|
|
399
403
|
list.append(business.card);
|
|
400
404
|
|
|
401
405
|
const priorities = todo(++n, "Set this month's priorities", !left.includes("priorities"));
|
|
402
406
|
priorities.hint("Up to three, in order. Staff pick work that serves them.");
|
|
403
|
-
priorities.body.append(
|
|
407
|
+
priorities.body.append(
|
|
408
|
+
prioritiesForm({
|
|
409
|
+
path: opsName() + "/org/priorities.md",
|
|
410
|
+
onSaved: () => (priorities.setDone(true), recheck()),
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
404
413
|
fileSteps.set("priorities", priorities.setDone);
|
|
405
414
|
list.append(priorities.card);
|
|
406
415
|
|
|
@@ -482,158 +491,11 @@ function todo(n, title, done) {
|
|
|
482
491
|
* quicker to type than to explain to a model. So the file opens as it is on disk, stub and
|
|
483
492
|
* all, and saving it is the same commit-and-push as every other editor here.
|
|
484
493
|
*/
|
|
485
|
-
const BUSINESS_QUESTIONS = [
|
|
486
|
-
["line", "What does it do, in one line?", "What this business does, in one line", true],
|
|
487
|
-
["who", "Who is it for?", "Who the customers are, specifically", true],
|
|
488
|
-
["key", "What does everything depend on?", "The one fact everything else follows from", false],
|
|
489
|
-
["true", "What's already true? What's built, measured or tried?", "What is already true", false],
|
|
490
|
-
["not", "What should staff never decide on their own?", "What is not ours to decide", false],
|
|
491
|
-
];
|
|
492
|
-
|
|
493
|
-
/** The business, as five questions in plain fields, written out as org/business.md. */
|
|
494
|
-
function businessForm(onSaved) {
|
|
495
|
-
const form = el("div");
|
|
496
|
-
const path = opsName() + "/org/business.md";
|
|
497
|
-
const fields = BUSINESS_QUESTIONS.map(([id, label, , required]) => {
|
|
498
|
-
const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
|
|
499
|
-
const wrap = el("label", { className: "qfield" }, [
|
|
500
|
-
el("span", { textContent: label + (required ? "" : " (optional)") }),
|
|
501
|
-
input,
|
|
502
|
-
]);
|
|
503
|
-
return { id, input, wrap };
|
|
504
|
-
});
|
|
505
|
-
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
506
|
-
const out = el("div");
|
|
507
|
-
form.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
|
|
508
494
|
|
|
509
|
-
save.onclick = async () => {
|
|
510
|
-
const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !String(f.input.value ?? "").trim());
|
|
511
|
-
if (missing) {
|
|
512
|
-
missing.input.focus();
|
|
513
|
-
out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
const name = App.data?.name ?? S.tenant.name ?? S.tenant.org;
|
|
517
|
-
const text =
|
|
518
|
-
`# What ${name} is\n\n` +
|
|
519
|
-
fields
|
|
520
|
-
.map((f, i) => [BUSINESS_QUESTIONS[i][2], String(f.input.value ?? "").trim()])
|
|
521
|
-
.filter(([, v]) => v)
|
|
522
|
-
.map(([h, v]) => `## ${h}\n\n${v}\n`)
|
|
523
|
-
.join("\n");
|
|
524
|
-
await saveAndSay(save, out, path, text, "portal: write org/business.md", onSaved);
|
|
525
|
-
};
|
|
526
495
|
|
|
527
|
-
return modes([
|
|
528
|
-
{ label: "Answer questions", node: form },
|
|
529
|
-
{
|
|
530
|
-
label: "Let an AI interview you",
|
|
531
|
-
node: paste({ kind: "discover", title: "", onSaved: () => (onSaved(), recheck()) }),
|
|
532
|
-
},
|
|
533
|
-
rawMode(path, onSaved),
|
|
534
|
-
]);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/** This month's priorities, as three lines and an out-of-scope list. */
|
|
538
|
-
function prioritiesForm(onSaved) {
|
|
539
|
-
const form = el("div");
|
|
540
|
-
const path = opsName() + "/org/priorities.md";
|
|
541
|
-
const ranks = [1, 2, 3].map((i) => {
|
|
542
|
-
const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
|
|
543
|
-
return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
|
|
544
|
-
});
|
|
545
|
-
const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
|
|
546
|
-
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
547
|
-
const out = el("div");
|
|
548
|
-
form.append(
|
|
549
|
-
...ranks.map((r) => r.wrap),
|
|
550
|
-
el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
|
|
551
|
-
el("div", { className: "row" }, [save]),
|
|
552
|
-
out,
|
|
553
|
-
);
|
|
554
|
-
save.onclick = async () => {
|
|
555
|
-
const items = ranks.map((r) => String(r.input.value ?? "").trim()).filter(Boolean);
|
|
556
|
-
if (!items.length) {
|
|
557
|
-
ranks[0].input.focus();
|
|
558
|
-
out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
const outs = String(scope.value ?? "").split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
|
|
562
|
-
const text =
|
|
563
|
-
"## What matters this month\n\n### Priorities, in order\n\n" +
|
|
564
|
-
items.map((t, i) => `${i + 1}. ${t}`).join("\n") +
|
|
565
|
-
"\n" +
|
|
566
|
-
(outs.length ? "\n### Out of scope this month\n\n" + outs.map((t) => `- ${t}`).join("\n") + "\n" : "");
|
|
567
|
-
await saveAndSay(save, out, path, text, "portal: write org/priorities.md", onSaved);
|
|
568
|
-
};
|
|
569
|
-
return modes([{ label: "Fill in", node: form }, rawMode(path, onSaved)]);
|
|
570
|
-
}
|
|
571
496
|
|
|
572
|
-
async function saveAndSay(btn, out, path, text, message, onSaved) {
|
|
573
|
-
btn.disabled = true;
|
|
574
|
-
const label = btn.textContent;
|
|
575
|
-
btn.textContent = "Saving…";
|
|
576
|
-
try {
|
|
577
|
-
const r = await saveFile(path, text, message);
|
|
578
|
-
out.replaceChildren(
|
|
579
|
-
el("p", {
|
|
580
|
-
className: r.pushed ? "sub" : "err",
|
|
581
|
-
textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
|
|
582
|
-
}),
|
|
583
|
-
);
|
|
584
|
-
onSaved();
|
|
585
|
-
recheck();
|
|
586
|
-
} catch (err) {
|
|
587
|
-
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
588
|
-
} finally {
|
|
589
|
-
btn.disabled = false;
|
|
590
|
-
btn.textContent = label;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
497
|
|
|
594
|
-
/** The file itself, for editing what is already there rather than starting again. */
|
|
595
|
-
function rawMode(path, onSaved) {
|
|
596
|
-
const node = el("div");
|
|
597
|
-
const ta = el("textarea", { className: "pastebox" });
|
|
598
|
-
ta.rows = 12;
|
|
599
|
-
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
600
|
-
const out = el("div");
|
|
601
|
-
node.append(ta, el("div", { className: "row" }, [save]), out);
|
|
602
|
-
ta.addEventListener("input", () => grow(ta));
|
|
603
|
-
save.onclick = () =>
|
|
604
|
-
saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
|
|
605
|
-
// Read when first shown, so it shows the file as it is then, not as it was at page load.
|
|
606
|
-
const load = () =>
|
|
607
|
-
// Not getFile: an absent file answers 404 with a message, and that is not what to edit.
|
|
608
|
-
fetch(fileUrl(path), { cache: "no-store" })
|
|
609
|
-
.then((r) => (r.ok ? r.text() : ""))
|
|
610
|
-
.then((text) => {
|
|
611
|
-
ta.value = text;
|
|
612
|
-
grow(ta);
|
|
613
|
-
});
|
|
614
|
-
return { label: "Edit the file", node, onShow: load };
|
|
615
|
-
}
|
|
616
498
|
|
|
617
|
-
/** Ways to do one step, as a switcher across the top: one shown at a time. */
|
|
618
|
-
function modes(list) {
|
|
619
|
-
const box = el("div");
|
|
620
|
-
const bar = el("div", { className: "modes" });
|
|
621
|
-
const panes = el("div");
|
|
622
|
-
const buttons = list.map((m, i) => {
|
|
623
|
-
const b = el("button", { className: "mode", textContent: m.label });
|
|
624
|
-
b.onclick = () => show(i);
|
|
625
|
-
return b;
|
|
626
|
-
});
|
|
627
|
-
bar.append(...buttons);
|
|
628
|
-
box.append(bar, panes);
|
|
629
|
-
function show(i) {
|
|
630
|
-
buttons.forEach((b, j) => b.classList.toggle("on", i === j));
|
|
631
|
-
panes.replaceChildren(list[i].node);
|
|
632
|
-
list[i].onShow?.();
|
|
633
|
-
}
|
|
634
|
-
show(0);
|
|
635
|
-
return box;
|
|
636
|
-
}
|
|
637
499
|
|
|
638
500
|
/** The ops repo's directory name, which is what every workspace-relative path starts with. */
|
|
639
501
|
function opsName() {
|