create-pmos 0.13.0 → 0.15.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.
- package/README.md +39 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/focus.test.js +650 -0
- package/workspace/handoff.test.js +443 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/drive-guard.js +98 -0
- package/workspace/lib/drive-plan.js +97 -0
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +127 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package.json +4 -2
- package/workspace/profiles.test.js +362 -0
- package/workspace/public/app.js +2318 -208
- package/workspace/public/index.html +158 -28
- package/workspace/public/styles.css +865 -51
- package/workspace/repo-scan.test.js +327 -0
- package/workspace/server.js +914 -23
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The handoff links model (workspace-jira-handoff · PRD R1/R4/R5/R6/R7).
|
|
4
|
+
*
|
|
5
|
+
* A real PMOS workflow can end outside PMOS: the PM owns intake through spec, and the build happens
|
|
6
|
+
* on another team's board. This module turns what an initiative RECORD declares about that handoff
|
|
7
|
+
* into a model the panel can render. It reads the record's frontmatter and nothing else, because
|
|
8
|
+
* there is nothing else to read.
|
|
9
|
+
*
|
|
10
|
+
* THE DISTINCTION THE WHOLE MODULE RESTS ON:
|
|
11
|
+
* A DECLARATION is something the PM typed into `planning/initiatives/<id>.md`.
|
|
12
|
+
* A READING is something PMOS went and looked at.
|
|
13
|
+
* This module returns declarations, labels them as declarations, and returns no readings at all.
|
|
14
|
+
*
|
|
15
|
+
* Even the epic key is a declaration. PMOS does not know that `ATL-482` exists, that it is an epic,
|
|
16
|
+
* that it belongs to this initiative, or that anyone has touched it. It knows that a human wrote
|
|
17
|
+
* that string in a file in this repository. Every rule below follows from taking that seriously.
|
|
18
|
+
*
|
|
19
|
+
* WHY THERE IS NO STORY COUNT AND NO SYNC TIME, AND WHY THAT IS NOT A "NOT YET".
|
|
20
|
+
* There is no Jira client, no Confluence client, no credential store, no host allow-list and no
|
|
21
|
+
* outbound call anywhere in the workspace. That absence is a RULING, not a gap:
|
|
22
|
+
* `planning/prd/jira-sync-prd.md` records that a PMOS-side Jira REST client and a stored credential
|
|
23
|
+
* were built in a first draft and then REJECTED by the PM at build review, under D01 (orchestrate,
|
|
24
|
+
* don't execute) and D51 (no standing credential). So `3 of 7 stories done` here would not be an
|
|
25
|
+
* optimistic estimate or a stale cache — it would be a fabricated reading of a system PMOS cannot
|
|
26
|
+
* see, wearing the same visual grammar the panel uses for facts it actually derived. A PM who
|
|
27
|
+
* believes PMOS is watching Jira stops watching Jira, and that is the failure this module exists to
|
|
28
|
+
* make impossible.
|
|
29
|
+
*
|
|
30
|
+
* HONEST BY CONSTRUCTION, NOT BY DISCIPLINE. The defence is structural, so that it survives an
|
|
31
|
+
* author who has not read this comment. handoff.test.js asserts each of these as a property:
|
|
32
|
+
*
|
|
33
|
+
* · this file `require`s NOTHING — no fs, no net, no clock. There is no path by which an external
|
|
34
|
+
* reading could enter it, and no I/O to mock, stub or accidentally re-enable;
|
|
35
|
+
* · the model contains NO NUMBER anywhere — every value is a string, a boolean, null, or an array
|
|
36
|
+
* of those. A story count, an issue tally and a progress percentage have no field to live in;
|
|
37
|
+
* · the field sets are closed. A link is exactly LINK_FIELDS, the result is exactly RESULT_FIELDS,
|
|
38
|
+
* and the test compares those key lists for equality — so `synced_at` cannot be added quietly;
|
|
39
|
+
* · `connected` is the literal `false`, and the whole result is deep-frozen, so a consumer can
|
|
40
|
+
* neither flip it nor bolt a fabricated field onto it after the fact;
|
|
41
|
+
* · every string this module emits is either copied VERBATIM out of the record or is fixed prose
|
|
42
|
+
* enumerated in the test — so a value that is computed, estimated, or formatted relative to now
|
|
43
|
+
* fails the moment it is invented, whatever it is and however plausible it looks.
|
|
44
|
+
*
|
|
45
|
+
* The not-connected state is not decoration on this feature — it IS the feature. The failure it
|
|
46
|
+
* prevents is a screen on which every individual sentence is true and the composition still reads
|
|
47
|
+
* as a live connection.
|
|
48
|
+
*
|
|
49
|
+
* WHAT THIS MODULE DOES NOT DO — deliberately, and the renderer must:
|
|
50
|
+
* · it does not escape. Values come straight from frontmatter and have NOT passed through `esc()`
|
|
51
|
+
* (unlike `mdLink`, which is safe only because `inline()` escaped the whole line first). They
|
|
52
|
+
* are returned verbatim so the whitelist property test can compare them against the record file
|
|
53
|
+
* byte for byte; the renderer MUST escape at every interpolation site, including the `href`;
|
|
54
|
+
* · it does not compose the R8 ladder annotation or the R10 sessions line. It returns the pieces
|
|
55
|
+
* (`to`, `at`, `systems`) and leaves the sentence to the surface that shows it.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
// ── the record fields (PRD R1) ──────────────────────────────────────────────────────────────────
|
|
59
|
+
// All five optional, all flat, all single-line — `parseLeadingFrontmatter` (lib/file-mode-parse.js)
|
|
60
|
+
// admits `^[a-z_]+: value$` and nothing else. A YAML list under a `links:` key does not partially
|
|
61
|
+
// parse: it TERMINATES the frontmatter block at that line and silently drops every field below it,
|
|
62
|
+
// including `lane` and `parent_kr`. That is why there is no list form here and never will be; the
|
|
63
|
+
// truncation is pinned by a test in handoff.test.js so the next author who reaches for a list finds
|
|
64
|
+
// a failing test explaining it rather than an initiative that reads as unanchored.
|
|
65
|
+
const HANDOFF_KEYS = ['flow', 'handoff_to', 'handoff_epic', 'handoff_page', 'handoff_at'];
|
|
66
|
+
|
|
67
|
+
// The two keys that name something in another system. Iteration order here — not frontmatter order —
|
|
68
|
+
// fixes the order of `links`, so the section renders the same way whatever order the PM typed.
|
|
69
|
+
const REFERENCE_KEYS = ['handoff_epic', 'handoff_page'];
|
|
70
|
+
const KIND = { handoff_epic: 'jira', handoff_page: 'confluence' };
|
|
71
|
+
const SYSTEM = { handoff_epic: 'Jira', handoff_page: 'Confluence' };
|
|
72
|
+
|
|
73
|
+
// The closed field sets. These exist to be asserted against: a new field is a test failure, which is
|
|
74
|
+
// the point — "no story count" has to be a property of the shape, not a habit of the author.
|
|
75
|
+
const LINK_FIELDS = ['kind', 'id', 'declared', 'state', 'why', 'state_text', 'href', 'inert_why'];
|
|
76
|
+
const PROBLEM_FIELDS = ['id', 'declared', 'why'];
|
|
77
|
+
const RESULT_FIELDS = ['connected', 'handoff', 'flow', 'to', 'at', 'systems', 'links', 'problems', 'empty'];
|
|
78
|
+
|
|
79
|
+
// The one state a link can be in. There is no second value and no code path toward one.
|
|
80
|
+
const NOT_CONNECTED = 'not-connected';
|
|
81
|
+
|
|
82
|
+
// R5 — why a bare reference is inert. PMOS was handed a key, not a location; guessing the location
|
|
83
|
+
// (a base URL, an issue-browse path) would be PMOS inventing the one thing it was not told.
|
|
84
|
+
const INERT_WHY = 'PMOS was given a reference, not a URL, and will not construct one it was not given';
|
|
85
|
+
|
|
86
|
+
// R4 — the state, per row and per system. `why` is the short clause; `state_text` is the full
|
|
87
|
+
// sentence for the row's accessible name. BOTH are per-link on purpose: a single banner at the top
|
|
88
|
+
// of the section is the FAIL shape the PRD names, because a PM scanning a row sees the row.
|
|
89
|
+
function whyFor(system) { return `no ${system} credential is configured`; }
|
|
90
|
+
function stateTextFor(system) {
|
|
91
|
+
return `not connected — ${whyFor(system)}; this is what the record declares, not a reading`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── validation ──────────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
// This surface has no read that can fail, so a malformed declaration is the only failure it CAN
|
|
96
|
+
// have — which makes it the only thing that may render red, and it must (R7).
|
|
97
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
98
|
+
const ISO_DATE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
|
|
99
|
+
const SINGLE_TOKEN = /^\S+$/;
|
|
100
|
+
// `flow` names the file `planning/workflows/<flow>.md`, so it is a filename and is checked as one.
|
|
101
|
+
// A value carrying `/` or a leading `.` would send the "open the template" affordance somewhere the
|
|
102
|
+
// PM did not point it.
|
|
103
|
+
const TEMPLATE_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
104
|
+
// R5's allow-list, deliberately STRICTER than `mdLink`'s (`app.js:2999`), which also admits
|
|
105
|
+
// protocol-relative `//host` and `mailto:`. Neither belongs on a handoff reference: `//evil` inherits
|
|
106
|
+
// whatever scheme the page happens to be on, and a mail address is not an epic. A host character is
|
|
107
|
+
// required after the slashes, so a bare `https://` is refused rather than rendered as a dead link.
|
|
108
|
+
const ABSOLUTE_URL = /^https?:\/\/[^\s/?#]+/i;
|
|
109
|
+
|
|
110
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
111
|
+
|
|
112
|
+
// Names a JS type for a problem message. Used ONLY when the declared value is not text at all, in
|
|
113
|
+
// which case there is no "value verbatim" to print and naming what arrived is the honest substitute.
|
|
114
|
+
function typeName(v) {
|
|
115
|
+
if (v === null) return 'null';
|
|
116
|
+
if (Array.isArray(v)) return 'an array';
|
|
117
|
+
return typeof v === 'object' ? 'an object' : `a ${typeof v}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Per-key shape rules. Each returns a reason string when the value is malformed, else null.
|
|
121
|
+
// NOTE: a reason NEVER embeds the declared value — the `declared` field prints it verbatim, and
|
|
122
|
+
// keeping the prose value-free is what lets the whitelist property test enumerate the fixed
|
|
123
|
+
// vocabulary this module can emit.
|
|
124
|
+
const CHECK = {
|
|
125
|
+
flow: (v) => (TEMPLATE_NAME.test(v) ? null
|
|
126
|
+
: '`flow:` names a template file under planning/workflows/ — lower-case letters, digits, dot, underscore and hyphen only. This value cannot name a file, so there is nothing to open'),
|
|
127
|
+
// `handoff_to` is "who received it, in plain language" (R1), so whitespace is EXPECTED here and
|
|
128
|
+
// the token rule below must not be applied to it. The asymmetry is deliberate, not an oversight.
|
|
129
|
+
handoff_to: () => null,
|
|
130
|
+
handoff_epic: (v) => (SINGLE_TOKEN.test(v) ? null
|
|
131
|
+
: '`handoff_epic:` holds a reference or an absolute URL — a single token. This value contains whitespace, so it is prose in a field meant for an identifier, and PMOS will not dress prose as a reference'),
|
|
132
|
+
handoff_page: (v) => (SINGLE_TOKEN.test(v) ? null
|
|
133
|
+
: '`handoff_page:` holds a reference or an absolute URL — a single token. This value contains whitespace, so it is prose in a field meant for an identifier, and PMOS will not dress prose as a reference'),
|
|
134
|
+
handoff_at: (v) => (ISO_DATE.test(v) ? null
|
|
135
|
+
: '`handoff_at:` is the ISO date the PM declared the handoff (YYYY-MM-DD). This value is not one, and PMOS will not guess what date was meant'),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/* Read ONE declared key. Returns { present, value, why }:
|
|
139
|
+
* present:false — the key is not in the record. Nothing is claimed about it.
|
|
140
|
+
* why:null — a well-formed declaration; `value` is the verbatim (trimmed) string.
|
|
141
|
+
* why:<reason> — malformed; `value` is the offending string verbatim, or null when the
|
|
142
|
+
* declared value was never text.
|
|
143
|
+
* ABSENT and MALFORMED are answered separately because they are different facts: one is an
|
|
144
|
+
* initiative that was not handed off, the other is a record with a mistake in it, and rendering the
|
|
145
|
+
* second as the first is how a typo becomes silence.
|
|
146
|
+
*/
|
|
147
|
+
function readDeclared(fm, key) {
|
|
148
|
+
if (!has(fm, key)) return { present: false, value: null, why: null };
|
|
149
|
+
const raw = fm[key];
|
|
150
|
+
if (typeof raw !== 'string') {
|
|
151
|
+
return { present: true, value: null,
|
|
152
|
+
why: `\`${key}:\` is ${typeName(raw)}, not the single-line string the record format admits — PMOS prints what the record declares as text, and there is no text here to print` };
|
|
153
|
+
}
|
|
154
|
+
const v = raw.trim();
|
|
155
|
+
if (!v) {
|
|
156
|
+
return { present: true, value: null,
|
|
157
|
+
why: `\`${key}:\` is present but empty — the key is declared and no value is, so there is nothing to state` };
|
|
158
|
+
}
|
|
159
|
+
if (CONTROL_CHARS.test(v)) {
|
|
160
|
+
return { present: true, value: null,
|
|
161
|
+
why: `\`${key}:\` contains a control character — refused rather than rendered, because what it would print is not what the record shows` };
|
|
162
|
+
}
|
|
163
|
+
return { present: true, value: v, why: CHECK[key](v) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Freezing is part of the contract, not tidiness: a frozen result cannot acquire a `synced_at` or a
|
|
167
|
+
// `story_count` downstream, and `connected` cannot be flipped by a consumer that would rather it
|
|
168
|
+
// were true.
|
|
169
|
+
function deepFreeze(o) {
|
|
170
|
+
if (o && typeof o === 'object' && !Object.isFrozen(o)) {
|
|
171
|
+
Object.freeze(o);
|
|
172
|
+
for (const k of Object.keys(o)) deepFreeze(o[k]);
|
|
173
|
+
}
|
|
174
|
+
return o;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* readLinks(record) — the handoff model for ONE initiative.
|
|
178
|
+
*
|
|
179
|
+
* `record` is the initiative's parsed leading frontmatter: the plain object
|
|
180
|
+
* `parseLeadingFrontmatter(text)` returns (lib/file-mode-parse.js). Not a path, not the file text —
|
|
181
|
+
* this module does no I/O by design, and a caller that hands it text gets a stated problem rather
|
|
182
|
+
* than a silent empty, because "no handoff declared" and "you gave me the wrong thing" are
|
|
183
|
+
* different facts.
|
|
184
|
+
*
|
|
185
|
+
* Returns a deep-frozen object with exactly RESULT_FIELDS:
|
|
186
|
+
*
|
|
187
|
+
* connected false — ALWAYS, a literal. There is no argument, no record field and no code path
|
|
188
|
+
* that produces any other value.
|
|
189
|
+
* handoff boolean — the record declares `flow:` or any `handoff_*` key. False for every
|
|
190
|
+
* initiative that was not handed off, so the panel is unchanged for them.
|
|
191
|
+
* flow string|null — the verbatim `flow:` value, or null (absent or malformed).
|
|
192
|
+
* to string|null — the verbatim `handoff_to:` value.
|
|
193
|
+
* at string|null — the verbatim `handoff_at:` value, only when it is an ISO date.
|
|
194
|
+
* systems string[] — 'Jira' and/or 'Confluence', from which reference keys are DECLARED
|
|
195
|
+
* (including malformed ones — a broken epic still names what PMOS cannot read).
|
|
196
|
+
* links link[] — one per well-formed reference, in REFERENCE_KEYS order.
|
|
197
|
+
* problems problem[] — one per malformed declaration (R7). Render red, value verbatim.
|
|
198
|
+
* empty boolean — a handoff is declared and there is nothing to link and nothing wrong: the
|
|
199
|
+
* verified empty of R6. Absence is STATED, never implied by a missing section.
|
|
200
|
+
*
|
|
201
|
+
* A link is exactly LINK_FIELDS:
|
|
202
|
+
* kind 'jira' | 'confluence' — which system the reference points AT. A kind marker, never a
|
|
203
|
+
* claim about it, and never to be styled like the panel's derived `d2-pill`.
|
|
204
|
+
* id 'handoff_epic' | 'handoff_page' — the RECORD KEY this row came from. This is the
|
|
205
|
+
* row's identity for rendering; it is NOT the epic's identifier. The thing the
|
|
206
|
+
* PM typed is `declared`.
|
|
207
|
+
* declared string — the reference, verbatim and UNESCAPED. The renderer escapes.
|
|
208
|
+
* state 'not-connected' — always, for every link, for every record.
|
|
209
|
+
* why string — the short clause, per system ('no Jira credential is configured').
|
|
210
|
+
* state_text string — the full R4 sentence for the row's accessible name.
|
|
211
|
+
* href string|null — the value IFF it is an absolute http(s) URL (R5); else null. Byte-
|
|
212
|
+
* identical to `declared` when set — never normalised, never constructed.
|
|
213
|
+
* inert_why string|null — why this row is not a link, when href is null.
|
|
214
|
+
*
|
|
215
|
+
* A problem is exactly PROBLEM_FIELDS: { id, declared, why } — the record key (or 'record' for a
|
|
216
|
+
* caller that passed the wrong shape), the offending value verbatim (null when it was never text),
|
|
217
|
+
* and the reason, which never embeds the value.
|
|
218
|
+
*/
|
|
219
|
+
function readLinks(record) {
|
|
220
|
+
const problems = [];
|
|
221
|
+
const links = [];
|
|
222
|
+
let fm = {};
|
|
223
|
+
|
|
224
|
+
if (record !== null && record !== undefined) {
|
|
225
|
+
if (typeof record !== 'object' || Array.isArray(record)) {
|
|
226
|
+
problems.push({ id: 'record', declared: null,
|
|
227
|
+
why: `readLinks was given ${typeName(record)}, not the parsed frontmatter object it reads — no handoff is being claimed for this record, and that is a fault rather than an absence` });
|
|
228
|
+
} else {
|
|
229
|
+
fm = record;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const declared = HANDOFF_KEYS.filter((k) => has(fm, k));
|
|
234
|
+
const value = {};
|
|
235
|
+
for (const key of HANDOFF_KEYS) {
|
|
236
|
+
const r = readDeclared(fm, key);
|
|
237
|
+
if (!r.present) continue;
|
|
238
|
+
if (r.why) { problems.push({ id: key, declared: r.value, why: r.why }); continue; }
|
|
239
|
+
value[key] = r.value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const key of REFERENCE_KEYS) {
|
|
243
|
+
const v = value[key];
|
|
244
|
+
if (v === undefined) continue;
|
|
245
|
+
const isUrl = ABSOLUTE_URL.test(v);
|
|
246
|
+
links.push({
|
|
247
|
+
kind: KIND[key],
|
|
248
|
+
id: key,
|
|
249
|
+
declared: v,
|
|
250
|
+
state: NOT_CONNECTED,
|
|
251
|
+
why: whyFor(SYSTEM[key]),
|
|
252
|
+
state_text: stateTextFor(SYSTEM[key]),
|
|
253
|
+
href: isUrl ? v : null,
|
|
254
|
+
inert_why: isUrl ? null : INERT_WHY,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return deepFreeze({
|
|
259
|
+
connected: false,
|
|
260
|
+
handoff: declared.length > 0,
|
|
261
|
+
flow: value.flow === undefined ? null : value.flow,
|
|
262
|
+
to: value.handoff_to === undefined ? null : value.handoff_to,
|
|
263
|
+
at: value.handoff_at === undefined ? null : value.handoff_at,
|
|
264
|
+
systems: REFERENCE_KEYS.filter((k) => declared.indexOf(k) !== -1).map((k) => SYSTEM[k]),
|
|
265
|
+
links,
|
|
266
|
+
problems,
|
|
267
|
+
// A `flow:` with no references is a VERIFIED EMPTY, in the panel's existing vocabulary — not a
|
|
268
|
+
// hidden section. A malformed declaration is not empty; it is red, and red wins.
|
|
269
|
+
empty: declared.length > 0 && links.length === 0 && problems.length === 0,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = deepFreeze({
|
|
274
|
+
readLinks,
|
|
275
|
+
HANDOFF_KEYS, REFERENCE_KEYS, LINK_FIELDS, PROBLEM_FIELDS, RESULT_FIELDS,
|
|
276
|
+
NOT_CONNECTED, INERT_WHY,
|
|
277
|
+
// Exported so the renderer states EXACTLY what this module states — a surface that rewords the
|
|
278
|
+
// not-connected sentence is a surface that can soften it.
|
|
279
|
+
whyFor, stateTextFor,
|
|
280
|
+
});
|
package/workspace/lib/layout.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* so a layout the client can produce and a layout the server will store can never disagree.
|
|
7
7
|
*
|
|
8
8
|
* A layout is a NAMED STATE TUPLE. The grid template is COMPUTED from it (gridFor) rather than
|
|
9
|
-
* hand-written per layout, because
|
|
9
|
+
* hand-written per layout, because that is exactly where a
|
|
10
10
|
* hand-written template drifts: a column count that no longer matches its area row produces an
|
|
11
11
|
* unusable window, and the PM's live agent sessions are inside it.
|
|
12
12
|
*
|
|
@@ -24,24 +24,41 @@
|
|
|
24
24
|
else root.PMOSLayout = api;
|
|
25
25
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
26
26
|
|
|
27
|
+
/* workspace-viewer-column — `reader` is RETIRED from the tuple.
|
|
28
|
+
*
|
|
29
|
+
* It used to hold four positions and decided where a document rendered. Documents, images and
|
|
30
|
+
* the summoned surfaces now open as tabs in the terminal column, so there is no longer a pane to
|
|
31
|
+
* position: `#paneDoc`, `#railSplit` and `libSplit` went with it, and `.rail` is the detail panel
|
|
32
|
+
* alone. Keeping the slot and merely emptying it was the alternative and is the worse one — it
|
|
33
|
+
* would leave document-shaped content two possible homes and no rule for which.
|
|
34
|
+
*
|
|
35
|
+
* What survives is the DETAIL PANEL's column, which still needs a width and keeps its drag
|
|
36
|
+
* handle — `readW` is renamed `panelW` and old values fall back to it, since it sized the same
|
|
37
|
+
* column. What does not survive is its POSITION: the panel now always sits between the structure
|
|
38
|
+
* column and the terminal, because the four positions existed to place a document.
|
|
39
|
+
*
|
|
40
|
+
* So the tuple is `{side, split, sideW, panelW, folds}` rather than seven fields.
|
|
41
|
+
*/
|
|
27
42
|
const SIDE = ['left', 'right', 'off'];
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
const SPLIT = ['off', 'on'];
|
|
44
|
+
// `doc` retired with the pane it folded. `sess` was already retired (R13) and is likewise ignored
|
|
45
|
+
// rather than migrated — a stored value for either resolves harmlessly through sanitize.
|
|
46
|
+
const FOLD_KEYS = ['ws', 'lib'];
|
|
47
|
+
const RETIRED_FOLD_KEYS = ['sess', 'doc'];
|
|
31
48
|
|
|
32
49
|
const SIDE_MIN = 160, SIDE_MAX = 520;
|
|
33
|
-
const
|
|
34
|
-
const LIB_MIN = 15, LIB_MAX = 80; // the library/document divider, percent
|
|
35
|
-
const BOTTOM_H = 300; // the reader's height when docked bottom
|
|
36
|
-
const LIB_DEFAULT = 46;
|
|
50
|
+
const PANEL_MIN = 280, PANEL_MAX = 1000; // the detail panel's width, still draggable
|
|
37
51
|
|
|
38
|
-
// `review` is
|
|
39
|
-
//
|
|
52
|
+
// `review` is the DEFAULT — the arrangement a PM who never opens the layout control sees.
|
|
53
|
+
// With the reader retired, the four stock layouts differ in the two axes that remain: whether the
|
|
54
|
+
// structure column is present, which side it sits on, and whether the terminal is split. That is
|
|
55
|
+
// fewer axes than before and they are kept genuinely distinct rather than padded back out to four
|
|
56
|
+
// by inventing differences the PM would not feel.
|
|
40
57
|
const STOCK = [
|
|
41
|
-
{ id: 'focus', name: 'focus', key: 1, side: 'off',
|
|
42
|
-
{ id: 'read', name: 'read', key: 2, side: 'left',
|
|
43
|
-
{ id: 'compare', name: 'compare', key: 3, side: 'left',
|
|
44
|
-
{ id: 'review', name: 'review', key: 4, side: 'left',
|
|
58
|
+
{ id: 'focus', name: 'focus', key: 1, side: 'off', split: 'off', sideW: 262, panelW: 460 },
|
|
59
|
+
{ id: 'read', name: 'read', key: 2, side: 'left', split: 'off', sideW: 214, panelW: 760 },
|
|
60
|
+
{ id: 'compare', name: 'compare', key: 3, side: 'left', split: 'on', sideW: 214, panelW: 420 },
|
|
61
|
+
{ id: 'review', name: 'review', key: 4, side: 'left', split: 'off', sideW: 280, panelW: 560 },
|
|
45
62
|
];
|
|
46
63
|
const DEFAULT_ID = 'review';
|
|
47
64
|
const STOCK_IDS = STOCK.map((l) => l.id);
|
|
@@ -54,7 +71,7 @@
|
|
|
54
71
|
return Number.isFinite(n) ? n : fallback;
|
|
55
72
|
};
|
|
56
73
|
|
|
57
|
-
function defaultFolds() { return { ws: true,
|
|
74
|
+
function defaultFolds() { return { ws: true, lib: true }; }
|
|
58
75
|
function folds(v) {
|
|
59
76
|
const out = defaultFolds();
|
|
60
77
|
if (v && typeof v === 'object') for (const k of FOLD_KEYS) if (typeof v[k] === 'boolean') out[k] = v[k];
|
|
@@ -84,15 +101,19 @@
|
|
|
84
101
|
name: stock ? base.name : name, // stock names are not editable (R5)
|
|
85
102
|
key: stock ? base.key : null, // ⌃1–⌃4 belong to the stock four
|
|
86
103
|
side: oneOf(r.side, SIDE, base ? base.side : 'left'),
|
|
87
|
-
reader: oneOf(r.reader, READER, base ? base.reader : 'right'),
|
|
88
104
|
split: oneOf(r.split, SPLIT, base ? base.split : 'off'),
|
|
89
105
|
sideW: Math.round(clamp(num(r.sideW, base ? base.sideW : 262), SIDE_MIN, SIDE_MAX)),
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
// The detail panel keeps a width and keeps its drag handle. A layout stored before this
|
|
107
|
+
// change has no `panelW`, but it does have the `readW` that used to size the same column —
|
|
108
|
+
// so that is what it falls back to, and the PM's tuning survives the rename instead of
|
|
109
|
+
// snapping to a default. This is the one legacy field genuinely worth carrying.
|
|
110
|
+
panelW: Math.round(clamp(num(r.panelW, num(r.readW, base ? base.panelW : 560)), PANEL_MIN, PANEL_MAX)),
|
|
111
|
+
// `reader`, `readW` and `libSplit` are DROPPED rather than carried. Every layout persisted
|
|
112
|
+
// before workspace-viewer-column still holds all three, and they arrive here on every boot in
|
|
113
|
+
// a live workspace — so the contract that matters is that reading them is harmless, which it
|
|
114
|
+
// is: unknown keys on `r` are simply not copied out. Dropping is deliberate and not silent —
|
|
115
|
+
// a value for a pane that no longer exists cannot be honoured, and carrying it forward would
|
|
116
|
+
// let a later reader resurrect a pane the app can no longer render.
|
|
96
117
|
folds: folds(r.folds),
|
|
97
118
|
lastUsedAt: Math.max(0, Math.round(num(r.lastUsedAt, 0))),
|
|
98
119
|
};
|
|
@@ -130,19 +151,17 @@
|
|
|
130
151
|
/* The computed grid. Returns {columns, rows, areas} as CSS strings.
|
|
131
152
|
INVARIANT: every area row names exactly as many cells as `columns` has tracks. */
|
|
132
153
|
function gridFor(l) {
|
|
133
|
-
const S = l.sideW + 'px',
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
154
|
+
const S = l.sideW + 'px', P = l.panelW + 'px';
|
|
155
|
+
// The detail panel is no longer independently positionable: it always sits between the
|
|
156
|
+
// structure column and the terminal, because that is the order the work flows in — pick the
|
|
157
|
+
// initiative, read its state, act on it. The four positions it used to have existed to place a
|
|
158
|
+
// DOCUMENT, and documents are tabs in the terminal column now. Hiding the panel is the column
|
|
159
|
+
// collapse (a 36px labelled rail), which is client state rather than part of the arrangement.
|
|
140
160
|
const cols = [], names = [];
|
|
141
|
-
if (l.side === 'left')
|
|
142
|
-
|
|
161
|
+
if (l.side === 'left') { cols.push(S); names.push('side'); }
|
|
162
|
+
cols.push(P); names.push('read');
|
|
143
163
|
cols.push('minmax(0,1fr)'); names.push('term');
|
|
144
|
-
if (l.
|
|
145
|
-
if (l.side === 'right') { cols.push(S); names.push('side'); }
|
|
164
|
+
if (l.side === 'right') { cols.push(S); names.push('side'); }
|
|
146
165
|
return { columns: cols.join(' '), rows: 'minmax(0,1fr)', areas: '"' + names.join(' ') + '"' };
|
|
147
166
|
}
|
|
148
167
|
|
|
@@ -156,28 +175,31 @@
|
|
|
156
175
|
The property item 4 actually names — the control that un-hides a pane is itself always
|
|
157
176
|
reachable — is a fact about the DOM (the chip lives in `.bar`, a grid sibling of `#bodyGrid`,
|
|
158
177
|
and the position controls in `.lay-pos` can always restore a pane), and it is asserted where
|
|
159
|
-
it can genuinely fail: ui-test.js walks
|
|
178
|
+
it can genuinely fail: ui-test.js walks every remaining arrangement in a real engine. */
|
|
160
179
|
function hasEscape(l) {
|
|
161
180
|
return gridFor(l).areas.indexOf('term') !== -1;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
183
|
/* R8 — carry the pre-feature client keys into this workspace's `review` so nobody's tuning is
|
|
165
|
-
lost on upgrade.
|
|
184
|
+
lost on upgrade. It read five localStorage values the shipped app wrote:
|
|
166
185
|
rail(on|off) railw(<n>px) libsplit(<n>%) fold-lib(on|off) fold-doc(on|off).
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
186
|
+
|
|
187
|
+
THREE OF THE FIVE NO LONGER HAVE ANYWHERE TO GO, and that is stated rather than quietly
|
|
188
|
+
dropped: `rail: off` positioned a pane that is now always a column (hiding it is the column
|
|
189
|
+
collapse, which is client state and not part of an arrangement); `libsplit` divided a rail that
|
|
190
|
+
no longer has two halves; `fold-doc` folded `#paneDoc`, which no longer exists. Carrying any of
|
|
191
|
+
them would mean writing a value no reader can honour.
|
|
192
|
+
|
|
193
|
+
`railw` still has a real destination — it sized the same column `panelW` now sizes — so it is
|
|
194
|
+
the one width that survives, and `fold-lib` still folds the pane it always folded. A PM
|
|
195
|
+
upgrading from the pre-feature build keeps their column width and their fold; they lose a
|
|
196
|
+
reader position for a reader that is gone. */
|
|
170
197
|
function migrateLegacy(legacy) {
|
|
171
198
|
const g = (k) => (legacy && typeof legacy === 'object' ? legacy[k] : null);
|
|
172
199
|
const review = stockLayout(DEFAULT_ID);
|
|
173
|
-
const railOff = g('rail') === 'off';
|
|
174
|
-
if (railOff) review.reader = 'off';
|
|
175
200
|
const w = num(String(g('railw') || '').replace('px', ''), null);
|
|
176
|
-
if (w !== null) review.
|
|
177
|
-
const ls = num(String(g('libsplit') || '').replace('%', ''), null);
|
|
178
|
-
if (ls !== null) review.libSplit = Math.round(clamp(ls, LIB_MIN, LIB_MAX) * 10) / 10;
|
|
201
|
+
if (w !== null) review.panelW = Math.round(clamp(w, PANEL_MIN, PANEL_MAX));
|
|
179
202
|
if (g('fold-lib') === 'off') review.folds.lib = false;
|
|
180
|
-
if (g('fold-doc') === 'off') review.folds.doc = false;
|
|
181
203
|
const layouts = stockLayouts().map((l) => (l.id === DEFAULT_ID ? review : l));
|
|
182
204
|
return { layouts, currentId: DEFAULT_ID };
|
|
183
205
|
}
|
|
@@ -198,8 +220,8 @@
|
|
|
198
220
|
}
|
|
199
221
|
|
|
200
222
|
return {
|
|
201
|
-
SIDE,
|
|
202
|
-
SIDE_MIN, SIDE_MAX,
|
|
223
|
+
SIDE, SPLIT, FOLD_KEYS, RETIRED_FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
|
|
224
|
+
SIDE_MIN, SIDE_MAX, PANEL_MIN, PANEL_MAX,
|
|
203
225
|
isStock, stockLayout, stockLayouts, layout, sanitize, gridFor, hasEscape, migrateLegacy,
|
|
204
226
|
loadAction,
|
|
205
227
|
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/* mobile-nav — the phone surface's navigation reducer (workspace-mobile-surface).
|
|
2
|
+
*
|
|
3
|
+
* One source of truth for "which screen am I on", used on BOTH sides:
|
|
4
|
+
* - the browser loads it as /lib/mobile-nav.js before app.js (window.PMOSMobileNav)
|
|
5
|
+
* - test.js requires it, so the whole domain is walkable in node without a browser
|
|
6
|
+
* exactly as lib/layout.js is. It was written inline in app.js and moved here because the rubric
|
|
7
|
+
* asks for a pure module and because a reducer that cannot be unit-tested is one whose exclusivity
|
|
8
|
+
* guarantee is only ever checked by eye.
|
|
9
|
+
*
|
|
10
|
+
* DOM-FREE ON PURPOSE. Every function takes a nav state and returns a new one; none touches an
|
|
11
|
+
* element, a media query, or the app's `state`. That is what makes the four properties below
|
|
12
|
+
* decidable without a browser:
|
|
13
|
+
* P1 exactly one column is shown for any screen — `column` is a function of `kind` alone
|
|
14
|
+
* P2 every MODES key has a phone path — the map is iterated, never a literal list
|
|
15
|
+
* P3 back never exits the app and never unwinds into another destination
|
|
16
|
+
* P4 choosing a destination resets it to its root
|
|
17
|
+
*
|
|
18
|
+
* The screen model:
|
|
19
|
+
* { dest, kind, mode, id, dk, title, column }
|
|
20
|
+
* dest one of DESTS — which destination this screen belongs to
|
|
21
|
+
* kind 'list' | 'detail' | 'terminal' | 'doc' | 'more'
|
|
22
|
+
* mode a MODES key, for kind 'list'
|
|
23
|
+
* id the detail id / session id / viewer id
|
|
24
|
+
* dk the DETAIL kind, for kind 'detail'
|
|
25
|
+
* title only for 'terminal' and 'doc', where the name is not derivable
|
|
26
|
+
* column 'side' | 'term' | 'rail' — determined by `kind` and by nothing else
|
|
27
|
+
*
|
|
28
|
+
* `column` is the whole of the exclusivity guarantee: the stylesheet shows exactly one of the three
|
|
29
|
+
* column nodes per value, so P1 is a property of the CSS rather than something this reducer has to
|
|
30
|
+
* remember to enforce. The columns are never re-parented and never rebuilt, which is what makes the
|
|
31
|
+
* xterm instance and its PTY socket survive a push, a pop and a destination switch structurally.
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
(function (root, factory) {
|
|
35
|
+
const api = factory();
|
|
36
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
37
|
+
else root.PMOSMobileNav = api;
|
|
38
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
39
|
+
|
|
40
|
+
// What a LIST screen is called, keyed by mode. This mirrors `MODES[].title` in app.js, and the
|
|
41
|
+
// mirror is deliberate rather than lazy: `titleOf` used to read app.js's `MODE_BY_K` directly,
|
|
42
|
+
// which works in a browser (one global scope) and throws `ReferenceError` the moment the module is
|
|
43
|
+
// required in node — for `kind:'list'`, the root of four of the five destinations. Nothing was red,
|
|
44
|
+
// because nothing called it outside a browser; the header two lines below claimed the whole domain
|
|
45
|
+
// was walkable in node, and it was not. A module that reaches back into its caller for a lookup
|
|
46
|
+
// table is not pure, and the fix for that is to hold the table, not to keep reaching.
|
|
47
|
+
// The drift risk a mirror creates is paid for in the suite: MOBNAV-P6 iterates app.js's own MODES
|
|
48
|
+
// against this map, so a mode added or retitled there and forgotten here fails rather than
|
|
49
|
+
// silently renders a key where a title belongs.
|
|
50
|
+
const MODE_TITLE = {
|
|
51
|
+
product: 'initiatives', scheduled: 'scheduled tasks', workflow: 'workflows', skills: 'skills',
|
|
52
|
+
knowledge: 'knowledge', agent: 'agents', memory: 'memory',
|
|
53
|
+
};
|
|
54
|
+
// What a DETAIL screen is called. It moved here with the reducer, for the same reason.
|
|
55
|
+
const DETAIL_TITLE = {
|
|
56
|
+
initiative: 'initiative', repo: 'repository', flow: 'workflow', sched: 'scheduled task',
|
|
57
|
+
skill: 'skill', know: 'knowledge', agent: 'agent', mem: 'memory',
|
|
58
|
+
ingest: 'ingest proposal', profiles: 'profiles', connections: 'connections',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const MOBILE_DESTS = ['board', 'scheduled', 'agents', 'memory', 'more'];
|
|
62
|
+
// Every MODES key has an entry. The suite ITERATES `MODES` against this map rather than a literal
|
|
63
|
+
// list, so adding a mode without giving it a phone path fails the suite instead of going silently
|
|
64
|
+
// unreachable — which is the failure P2 exists to catch.
|
|
65
|
+
const MOBILE_DEST = { product: 'board', scheduled: 'scheduled', agent: 'agents', memory: 'memory',
|
|
66
|
+
workflow: 'more', skills: 'more', knowledge: 'more' };
|
|
67
|
+
const MOBILE_ROOT = {
|
|
68
|
+
board: { kind: 'list', mode: 'product' },
|
|
69
|
+
scheduled: { kind: 'list', mode: 'scheduled' },
|
|
70
|
+
agents: { kind: 'list', mode: 'agent' },
|
|
71
|
+
memory: { kind: 'list', mode: 'memory' },
|
|
72
|
+
more: { kind: 'more', mode: null },
|
|
73
|
+
};
|
|
74
|
+
// kind → column. There is no second input, and no screen may carry a column its kind does not give.
|
|
75
|
+
const MOBILE_COLUMN = { list: 'side', more: 'side', detail: 'rail', terminal: 'term', doc: 'term' };
|
|
76
|
+
function mobColumnOf(kind) { return MOBILE_COLUMN[kind] || 'side'; }
|
|
77
|
+
function mobDestForMode(k) { return MOBILE_DEST[k] || null; }
|
|
78
|
+
// Complete a partial screen. The column is derived, never passed in, so a caller cannot put a
|
|
79
|
+
// detail on the terminal column by writing it down.
|
|
80
|
+
function mobScreen(s) {
|
|
81
|
+
const kind = (s && s.kind) || 'list';
|
|
82
|
+
return { dest: (s && s.dest) || 'board', kind, mode: (s && s.mode) || null,
|
|
83
|
+
id: (s && s.id) || null, dk: (s && s.dk) || null, title: (s && s.title) || null,
|
|
84
|
+
column: mobColumnOf(kind) };
|
|
85
|
+
}
|
|
86
|
+
function mobRootOf(dest) {
|
|
87
|
+
const r = MOBILE_ROOT[dest] || MOBILE_ROOT.board;
|
|
88
|
+
return mobScreen({ dest: MOBILE_ROOT[dest] ? dest : 'board', kind: r.kind, mode: r.mode });
|
|
89
|
+
}
|
|
90
|
+
/* The title map. `list` and `more` derive; `detail` reads DETAIL_TITLE (declared far below — this
|
|
91
|
+
* is only ever called at runtime, long after that const has evaluated); `terminal` and `doc` carry
|
|
92
|
+
* a title because a session's name and a document's name are not derivable from a screen. */
|
|
93
|
+
function mobTitleOf(s) {
|
|
94
|
+
if (!s) return '';
|
|
95
|
+
if (s.kind === 'list') return MODE_TITLE[s.mode] || String(s.mode || '');
|
|
96
|
+
if (s.kind === 'more') return 'more';
|
|
97
|
+
if (s.kind === 'detail') return DETAIL_TITLE[s.dk] || 'detail';
|
|
98
|
+
return s.title || String(s.id || '');
|
|
99
|
+
}
|
|
100
|
+
function mobNavInit(dest) { return { dest: MOBILE_ROOT[dest] ? dest : 'board', stack: [] }; }
|
|
101
|
+
function mobPush(nav, screen) {
|
|
102
|
+
const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
|
|
103
|
+
return { dest: n.dest, stack: n.stack.concat([mobScreen(Object.assign({ dest: n.dest }, screen))]) };
|
|
104
|
+
}
|
|
105
|
+
// Removes EXACTLY one. Back from a destination root is a no-op — it never exits the app and never
|
|
106
|
+
// unwinds into another destination, which is the second half of P3.
|
|
107
|
+
function mobPop(nav) {
|
|
108
|
+
const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
|
|
109
|
+
return n.stack.length ? { dest: n.dest, stack: n.stack.slice(0, -1) } : { dest: n.dest, stack: [] };
|
|
110
|
+
}
|
|
111
|
+
// Choosing a destination RESETS it to its root with an empty stack — the prototype's {tab, stack:[]}.
|
|
112
|
+
function mobSwitchDest(nav, dest) { return mobNavInit(dest); }
|
|
113
|
+
function mobCurrentOf(nav) {
|
|
114
|
+
const n = nav && MOBILE_ROOT[nav.dest] ? nav : mobNavInit('board');
|
|
115
|
+
return n.stack.length ? n.stack[n.stack.length - 1] : mobRootOf(n.dest);
|
|
116
|
+
}
|
|
117
|
+
/* R11 — the legacy key is read ONCE, for migration, and never written again. Every value the old
|
|
118
|
+
* three-way switcher could hold, plus anything unrecognised and nothing at all, lands on `board`:
|
|
119
|
+
* the destination root is the only screen that is safe to draw without re-reading anything. */
|
|
120
|
+
function mobMigrateView(v) { return MOBILE_ROOT[v] ? v : 'board'; }
|
|
121
|
+
|
|
122
|
+
return { push: mobPush, pop: mobPop, switchDest: mobSwitchDest, init: mobNavInit,
|
|
123
|
+
current: mobCurrentOf, titleOf: mobTitleOf, columnOf: mobColumnOf, destForMode: mobDestForMode,
|
|
124
|
+
MODE_TITLE,
|
|
125
|
+
migrateView: mobMigrateView, screen: mobScreen, root: mobRootOf,
|
|
126
|
+
DESTS: MOBILE_DESTS, DEST_FOR_MODE: MOBILE_DEST, ROOTS: MOBILE_ROOT, DETAIL_TITLE };
|
|
127
|
+
});
|