@bongos/core 1.19.1013 → 1.19.1015
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/.bongos-core.json +90 -85
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +4 -1
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
- package/docs/copy-inventory.md +152 -112
- package/docs/copy-registry.json +1001 -304
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet-costs.js +80 -0
- package/modules/hall-ui/public/fleet.html +64 -0
- package/modules/hall-ui/public/fleet.js +45 -0
- package/modules/hall-ui/public/fleet.states.json +14 -0
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +125 -52
- package/modules/hall-ui/public/goal-map.states.json +29 -5
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +15 -0
- package/modules/hall-ui/public/sky-data.js +19 -1
- package/modules/hall-ui/public/sky-interaction.js +14 -7
- package/modules/hall-ui/public/sky-layout.js +2 -1
- package/modules/hall-ui/public/sky-tour.js +10 -6
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +16 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/provisioning/routes/provisioning.js +4 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth.js +8 -0
- package/src/bongos/serve-internal.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +3 -2
- package/tests/fleet_cost_view.mjs +189 -0
- package/tests/goal_map_page.mjs +346 -211
- package/tests/hall_audit.mjs +1 -0
- package/tests/hall_nav.mjs +9 -2
- package/tests/hall_page_gate_map.mjs +5 -2
- package/tests/helpers.mjs +21 -5
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/render_check.mjs +6 -4
- package/modules/hall-ui/public/goal-map-lib.js +0 -349
- package/modules/hall-ui/public/goal-map.css +0 -117
- package/modules/hall-ui/public/goal-map.js +0 -232
- package/tests/goal_map_lib.mjs +0 -214
package/tests/hall_audit.mjs
CHANGED
|
@@ -364,6 +364,7 @@ test('the module doc still records where the 2179 density pass landed', () => {
|
|
|
364
364
|
// and the pack's font links like every hall page (the server injects them).
|
|
365
365
|
const STANDALONE_EXEMPT = {
|
|
366
366
|
'thinking.html': 'the sky (task 1004231, ADR 0331) — the design of record is its own surface, not the hall chrome',
|
|
367
|
+
'goal-map.html': 'the project sky (task 1004232) — the same surface as thinking.html, opened in its whole-project lens',
|
|
367
368
|
};
|
|
368
369
|
|
|
369
370
|
test('every hall page loads style.css and shell.js', () => {
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -258,7 +258,12 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
258
258
|
// task 1003455 (goal 1000089): 'blockers' joins the same group, after Tasks —
|
|
259
259
|
// its subject is the project (what's stuck on the owner), not a person or a
|
|
260
260
|
// craft, and its one data source (GET /blockers) is public like Roadmap's.
|
|
261
|
-
|
|
261
|
+
// task 1004232 (sky part 4, goal 1000095): 'goal-map' — THE PROJECT SKY — joins
|
|
262
|
+
// directly under 'goals'. The goal map that held the URL took no nav slot on
|
|
263
|
+
// purpose (task 1003479, and tests/goal_map_page.mjs pinned it); the owner
|
|
264
|
+
// granted the sky that slot on 2026-09-24 (ADR 0227 §11), under Goals because
|
|
265
|
+
// it is the goals seen as one picture.
|
|
266
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
|
|
262
267
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
263
268
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
264
269
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -287,7 +292,9 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
287
292
|
// 'deploy' (task 1003159, ADR 0293) is the SECOND archon item, beside 'government':
|
|
288
293
|
// its gate is the `core.pin.move` atom, archon-floor and system:true. It sits with
|
|
289
294
|
// the steering surfaces because that is what it is — the owner's third release gate.
|
|
290
|
-
|
|
295
|
+
// 'fleet' (task 1001949) follows it at the metic floor of `provisioning.fleet.manage`:
|
|
296
|
+
// what each hosted project costs is an operator surface, like the rest of this group.
|
|
297
|
+
assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government']);
|
|
291
298
|
});
|
|
292
299
|
|
|
293
300
|
test('Economy lives in Docs and points at the reward-schedule page', () => {
|
|
@@ -48,8 +48,9 @@ const ORACLE = {
|
|
|
48
48
|
'/builders/atlas': 'requireBuilderPage',
|
|
49
49
|
'/builders/modules': 'requireBuilderPage',
|
|
50
50
|
'/builders/goals': 'requireBuilderPage',
|
|
51
|
-
// task 1003479: the goal map
|
|
52
|
-
//
|
|
51
|
+
// task 1003479: the goal map, on the goal board's tier. task 1004232 (sky part 4):
|
|
52
|
+
// the URL now serves THE PROJECT SKY (the same page as /thinking, in its whole-project
|
|
53
|
+
// lens) on the same tier, and holds the nav slot under Goals the map was denied.
|
|
53
54
|
'/builders/goal-map': 'requireBuilderPage',
|
|
54
55
|
'/builders/roadmap': 'requireBuilderPage',
|
|
55
56
|
// Gated at this tier all along, but with no row until task 1003448 walked the
|
|
@@ -103,6 +104,8 @@ const ORACLE = {
|
|
|
103
104
|
// its write on purpose: the preview discloses the exact core version the box serves
|
|
104
105
|
// and how far behind it is, which is a vulnerability window.
|
|
105
106
|
'/builders/deploy': 'requireCorePinPage',
|
|
107
|
+
// The fleet cost page (task 1001949), on the atom its one read checks.
|
|
108
|
+
'/builders/fleet': 'requireFleetPage',
|
|
106
109
|
|
|
107
110
|
// Metic+ via the `project.curate` atom — the exact permission GET /scouting
|
|
108
111
|
// checks, so the page can never drift wider than the API it fronts. Its
|
package/tests/helpers.mjs
CHANGED
|
@@ -759,15 +759,27 @@ export const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
|
759
759
|
// page keeps on `window.OTBSky.S`. Every request the page made is on
|
|
760
760
|
// `requests` with its body, so a filing can be checked byte for byte against
|
|
761
761
|
// what the hall form sends.
|
|
762
|
+
//
|
|
763
|
+
// Two doors on one page (part 4, task 1004232): thinking.html and goal-map.html
|
|
764
|
+
// carry the same markup and the same scripts and differ in the body's
|
|
765
|
+
// `data-sky-lens` (which lens is home). A test names the `page`; each is parsed
|
|
766
|
+
// from its OWN file, so a button lost from either one fails.
|
|
762
767
|
const SKY_FILES = ['idea-objects.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
|
|
763
768
|
const SKY_SRC = SKY_FILES.map((f) => [f, fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8')]);
|
|
764
|
-
const
|
|
765
|
-
|
|
769
|
+
const SKY_PAGES = {};
|
|
770
|
+
function skyPage(page) {
|
|
771
|
+
if (!SKY_PAGES[page]) {
|
|
772
|
+
const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8');
|
|
773
|
+
const m = /<body([^>]*)>([\s\S]*)<\/body>/.exec(html) || [, '', ''];
|
|
774
|
+
SKY_PAGES[page] = { attrs: parseAttrs(m[1]), body: m[2].replace(/<script[\s\S]*?<\/script>/g, '') };
|
|
775
|
+
}
|
|
776
|
+
return SKY_PAGES[page];
|
|
777
|
+
}
|
|
766
778
|
|
|
767
779
|
export function bootSkyPage({
|
|
768
780
|
mine = null, sky = null, versions = null, me = null, template = undefined,
|
|
769
781
|
viewer = { id: 95, permissions: {} }, status = {}, tourSeen = true, storage = {},
|
|
770
|
-
innerWidth = 1280, innerHeight = 800, reduce = true,
|
|
782
|
+
innerWidth = 1280, innerHeight = 800, reduce = true, page = 'thinking',
|
|
771
783
|
} = {}) {
|
|
772
784
|
// ── the DOM ──────────────────────────────────────────────────────────────
|
|
773
785
|
const windowListeners = new Map();
|
|
@@ -826,14 +838,18 @@ export function bootSkyPage({
|
|
|
826
838
|
}
|
|
827
839
|
// innerHTML on any element parses into children, so querySelectorAll('.row')
|
|
828
840
|
// finds what the page just wrote and the page's own listeners get wired.
|
|
841
|
+
const { attrs: bodyAttrs, body: skyBody } = skyPage(page);
|
|
829
842
|
const root = node('body', {});
|
|
843
|
+
// The body's data-* attributes are the page's dataset (data-sky-lens is what
|
|
844
|
+
// sky-data.js reads to know which lens is home).
|
|
845
|
+
for (const [k, v] of Object.entries(bodyAttrs)) if (k.startsWith('data-')) root.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
|
|
830
846
|
const patchInnerHtml = (el) => Object.defineProperty(el, 'innerHTML', {
|
|
831
847
|
get() { return this._html === null ? '' : this._html; },
|
|
832
848
|
set(v) { this._html = String(v); this._value = null; parse(this, this._html); walk(this, (c) => { patchInnerHtml(c); return null; }); },
|
|
833
849
|
configurable: true,
|
|
834
850
|
});
|
|
835
851
|
patchInnerHtml(root);
|
|
836
|
-
parse(root,
|
|
852
|
+
parse(root, skyBody);
|
|
837
853
|
walk(root, (c) => { patchInnerHtml(c); return null; });
|
|
838
854
|
const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
|
|
839
855
|
// '.brand h1' and '.jump' are the two descendant selectors the page uses.
|
|
@@ -893,7 +909,7 @@ export function bootSkyPage({
|
|
|
893
909
|
innerWidth, innerHeight, devicePixelRatio: 1,
|
|
894
910
|
matchMedia: () => ({ matches: reduce }),
|
|
895
911
|
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
|
|
896
|
-
location: { hostname: 'builders.cloudbongos.com', search: '', pathname:
|
|
912
|
+
location: { hostname: 'builders.cloudbongos.com', search: '', pathname: `/${page}`, hash: '', assign: (u) => { out.navigated = u; }, replace: (u) => { out.navigated = u; } },
|
|
897
913
|
addEventListener: (type, fn) => { windowListeners.set(type, fn); },
|
|
898
914
|
requestAnimationFrame: (fn) => { raf = fn; return 1; },
|
|
899
915
|
performance: { now: () => Date.now() },
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '9cc99bb9b7cce948';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
package/tests/render_check.mjs
CHANGED
|
@@ -15,19 +15,21 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
15
15
|
const names = (paths) => statesFilesFor(paths, ROOT).map((f) => path.basename(f));
|
|
16
16
|
|
|
17
17
|
test('a changed page or its own script reaches the states file beside it', () => {
|
|
18
|
-
assert.deepEqual(names(['modules/hall-ui/public/
|
|
18
|
+
assert.deepEqual(names(['modules/hall-ui/public/goals.js']), ['goals.states.json']);
|
|
19
19
|
assert.deepEqual(names(['modules/hall-ui/public/goal-map.html']), ['goal-map.states.json']);
|
|
20
20
|
assert.deepEqual(names(['modules/public-landing/public/projects.html']), ['projects.states.json']);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
test('a shared asset reaches every page in its dir whose HTML names it', () => {
|
|
24
|
-
|
|
24
|
+
// sky-data.js is shared by the two doors of one page (task 1004232): /thinking and /goal-map.
|
|
25
|
+
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
|
|
26
|
+
assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
|
|
25
27
|
assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['projects.states.json']);
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
|
|
29
|
-
assert.deepEqual(names(['modules/hall-ui/public/
|
|
30
|
-
assert.deepEqual(names(['modules/hall-ui/public/
|
|
31
|
+
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js', 'modules/hall-ui/public/work.js']), ['work.states.json', 'goal-map.states.json', 'thinking.states.json']);
|
|
32
|
+
assert.deepEqual(names(['modules/hall-ui/public/sky-data.js', 'modules/hall-ui/public/goal-map.html']), ['goal-map.states.json', 'thinking.states.json'], 'a page reached both ways is listed once, as its own');
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
test('a path that is not a rendered surface, or a page with no states file, reaches nothing', () => {
|
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
// goal-map-lib.js — the goal map's model and layout, pure (task 1003479, goal
|
|
2
|
-
// 1000089, ADR 0227 §6 item 3 and its §10.4 amendment).
|
|
3
|
-
//
|
|
4
|
-
// WHAT THE MAP IS. Goals are the nodes; an edge says "some tasks in goal A wait on
|
|
5
|
-
// tasks in goal B". ADR 0227 measured the real corpus (70 goals, 66 task edges
|
|
6
|
-
// across 33 goal pairs) and changed the shape to build: HALF the goals have no
|
|
7
|
-
// edge at all, and even the connected half is not one network but a large cluster
|
|
8
|
-
// and a scatter of pairs. So this file does three things the picture needs and a
|
|
9
|
-
// force layout would get wrong:
|
|
10
|
-
// 1. it SPLITS the goals into connected and stand-alone (§10.4 item 1) — the
|
|
11
|
-
// stand-alone ones are a list, never dots on a canvas that has nothing to say
|
|
12
|
-
// about them;
|
|
13
|
-
// 2. it finds the CLUSTERS (§10.4 item 2), largest first, so the view reads as
|
|
14
|
-
// "one cluster and a scatter of pairs" and not as a network;
|
|
15
|
-
// 3. it lays each cluster out in LAYERS — a goal sits one column right of the
|
|
16
|
-
// furthest goal it waits on — which is deterministic (same data, same
|
|
17
|
-
// picture, no random seed) and reads as a sequence rather than as a hairball.
|
|
18
|
-
//
|
|
19
|
-
// IT DRAWS NOTHING THE TABLE DOES NOT ALSO SAY. Every fact in the picture —
|
|
20
|
-
// which goals, how many tasks, which category, which goal waits on which — is a
|
|
21
|
-
// row or a cell from tableRows(), and the page ships the table by default, not
|
|
22
|
-
// behind a toggle (ADR 0227 §5). Category is a WORD on every node as well as a
|
|
23
|
-
// colour: the ADR 0192 set is not colour-distinguishable at this many nodes.
|
|
24
|
-
//
|
|
25
|
-
// NEVER VIEW A OR D. There is no function here that takes a task, and no mode
|
|
26
|
-
// that would densify: the only input is the aggregate GET /goals/graph returns,
|
|
27
|
-
// which carries no task identity at all (ADR 0227 §7).
|
|
28
|
-
//
|
|
29
|
-
// Pure — no window, no DOM, no fetch — and Node-require-safe (the blockers-lib.js
|
|
30
|
-
// / goals-lib.js precedent) so tests/goal_map_lib.mjs runs the real layout over
|
|
31
|
-
// the real fixture instead of agreeing with a copy of it.
|
|
32
|
-
(function (root, factory) {
|
|
33
|
-
var mod = factory();
|
|
34
|
-
if (typeof module !== 'undefined' && module.exports) module.exports = mod;
|
|
35
|
-
else root.OTBGoalMapLib = mod;
|
|
36
|
-
}(typeof self !== 'undefined' ? self : this, function () {
|
|
37
|
-
'use strict';
|
|
38
|
-
|
|
39
|
-
// Geometry. A node is wide enough for ~24 characters of title at the page's
|
|
40
|
-
// label size (ADR 0227 measured a 48px pitch as the floor for a label to fit;
|
|
41
|
-
// these are well past it), and the gaps leave room for an edge to be seen.
|
|
42
|
-
var NODE_W = 168;
|
|
43
|
-
// 68, not 60: the extra 8px hold the weight bar (task 1003479 grader follow-up
|
|
44
|
-
// — "node weight = task count" was text-only; the bar makes it a real visual
|
|
45
|
-
// size, not just a number to read).
|
|
46
|
-
var NODE_H = 68;
|
|
47
|
-
var GAP_X = 52;
|
|
48
|
-
var GAP_Y = 14;
|
|
49
|
-
var PAD = 16;
|
|
50
|
-
var BLOCK_GAP = 28;
|
|
51
|
-
var MIN_ROW_W = 760;
|
|
52
|
-
// The categorical colour channel repeats after this many; the WORD on every
|
|
53
|
-
// node is what distinguishes them, so a repeat costs nothing.
|
|
54
|
-
var CATEGORY_COLOURS = 8;
|
|
55
|
-
|
|
56
|
-
function num(id) { return Number(id); }
|
|
57
|
-
function byNumericId(a, b) { return num(a) - num(b); }
|
|
58
|
-
|
|
59
|
-
// A private goal the viewer cannot read arrives with title null. It stays a node
|
|
60
|
-
// (dropping it would falsely isolate its neighbours) and is named as what it is.
|
|
61
|
-
function displayTitle(node) { return node.title ? String(node.title) : 'A private goal'; }
|
|
62
|
-
|
|
63
|
-
function plural(n, one, many) { return n + ' ' + (n === 1 ? one : (many || one + 's')); }
|
|
64
|
-
|
|
65
|
-
// analyse — the graph's structure, with no geometry: nodes by id, who each goal
|
|
66
|
-
// waits on and who needs it, the connected/stand-alone split, and the clusters.
|
|
67
|
-
function analyse(payload) {
|
|
68
|
-
var rawNodes = (payload && Array.isArray(payload.nodes)) ? payload.nodes : [];
|
|
69
|
-
var rawEdges = (payload && Array.isArray(payload.edges)) ? payload.edges : [];
|
|
70
|
-
var byId = {};
|
|
71
|
-
var order = [];
|
|
72
|
-
rawNodes.forEach(function (n) {
|
|
73
|
-
var id = String(n.goal_id);
|
|
74
|
-
if (byId[id]) return;
|
|
75
|
-
byId[id] = Object.assign({}, n, { id: id });
|
|
76
|
-
order.push(id);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
var waits = {};
|
|
80
|
-
var needed = {};
|
|
81
|
-
var edges = [];
|
|
82
|
-
var seen = {};
|
|
83
|
-
rawEdges.forEach(function (e) {
|
|
84
|
-
var from = String(e.from);
|
|
85
|
-
var to = String(e.to);
|
|
86
|
-
if (from === to || !byId[from] || !byId[to] || seen[from + '>' + to]) return;
|
|
87
|
-
seen[from + '>' + to] = true;
|
|
88
|
-
var count = Number(e.count) || 0;
|
|
89
|
-
edges.push({ from: from, to: to, count: count });
|
|
90
|
-
(waits[from] = waits[from] || []).push({ id: to, count: count });
|
|
91
|
-
(needed[to] = needed[to] || []).push({ id: from, count: count });
|
|
92
|
-
});
|
|
93
|
-
function bySize(a, b) { return (b.count - a.count) || byNumericId(a.id, b.id); }
|
|
94
|
-
Object.keys(waits).forEach(function (k) { waits[k].sort(bySize); });
|
|
95
|
-
Object.keys(needed).forEach(function (k) { needed[k].sort(bySize); });
|
|
96
|
-
|
|
97
|
-
// Union-find over the connected goals only.
|
|
98
|
-
var parent = {};
|
|
99
|
-
function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
|
|
100
|
-
function union(a, b) { var ra = find(a); var rb = find(b); if (ra !== rb) parent[rb] = ra; }
|
|
101
|
-
order.forEach(function (id) { if (waits[id] || needed[id]) parent[id] = id; });
|
|
102
|
-
edges.forEach(function (e) { union(e.from, e.to); });
|
|
103
|
-
|
|
104
|
-
var groups = {};
|
|
105
|
-
Object.keys(parent).forEach(function (id) { (groups[find(id)] = groups[find(id)] || []).push(id); });
|
|
106
|
-
var clusters = Object.keys(groups).map(function (k) { return groups[k].sort(byNumericId); });
|
|
107
|
-
clusters.sort(function (a, b) { return (b.length - a.length) || byNumericId(a[0], b[0]); });
|
|
108
|
-
var clusterOf = {};
|
|
109
|
-
clusters.forEach(function (ids, i) {
|
|
110
|
-
ids.forEach(function (id) { clusterOf[id] = { index: i + 1, size: ids.length }; });
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
var isolated = order.filter(function (id) { return !parent.hasOwnProperty(id); }).sort(byNumericId);
|
|
114
|
-
return {
|
|
115
|
-
order: order, byId: byId, edges: edges, waits: waits, needed: needed,
|
|
116
|
-
clusters: clusters, clusterOf: clusterOf, isolated: isolated,
|
|
117
|
-
connected: order.length - isolated.length,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// A goal sits one column right of the furthest goal it waits on. Goal-level
|
|
122
|
-
// dependencies CAN loop (task edges never do, but two goals can each hold a task
|
|
123
|
-
// the other's waits on), so an edge back onto the current path is ignored for
|
|
124
|
-
// layering — it is still drawn, it just does not push anything to the right.
|
|
125
|
-
function layerOf(ids, waits) {
|
|
126
|
-
var depth = {};
|
|
127
|
-
var state = {};
|
|
128
|
-
function visit(id) {
|
|
129
|
-
if (state[id] === 2) return depth[id];
|
|
130
|
-
if (state[id] === 1) return -1;
|
|
131
|
-
state[id] = 1;
|
|
132
|
-
var d = 0;
|
|
133
|
-
(waits[id] || []).forEach(function (dep) {
|
|
134
|
-
var dd = visit(dep.id);
|
|
135
|
-
if (dd >= 0) d = Math.max(d, dd + 1);
|
|
136
|
-
});
|
|
137
|
-
state[id] = 2;
|
|
138
|
-
depth[id] = d;
|
|
139
|
-
return d;
|
|
140
|
-
}
|
|
141
|
-
ids.forEach(visit);
|
|
142
|
-
return depth;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Order the goals inside each column by the average row of their neighbours in
|
|
146
|
-
// the columns already placed, a few alternating sweeps — the standard cheap way
|
|
147
|
-
// to cut crossings, and deterministic (ties fall back to goal id).
|
|
148
|
-
function orderColumns(columns, neighbours) {
|
|
149
|
-
var pos = {};
|
|
150
|
-
function reindex() { columns.forEach(function (col) { col.forEach(function (id, i) { pos[id] = i; }); }); }
|
|
151
|
-
reindex();
|
|
152
|
-
var layerOfId = {};
|
|
153
|
-
columns.forEach(function (col, li) { col.forEach(function (id) { layerOfId[id] = li; }); });
|
|
154
|
-
for (var sweep = 0; sweep < 4; sweep++) {
|
|
155
|
-
var down = sweep % 2 === 0;
|
|
156
|
-
var from = down ? 1 : columns.length - 2;
|
|
157
|
-
var to = down ? columns.length : -1;
|
|
158
|
-
for (var li = from; li !== to; li += down ? 1 : -1) {
|
|
159
|
-
var col = columns[li];
|
|
160
|
-
var key = {};
|
|
161
|
-
col.forEach(function (id) {
|
|
162
|
-
var ns = (neighbours[id] || []).filter(function (n) { return down ? layerOfId[n] < li : layerOfId[n] > li; });
|
|
163
|
-
key[id] = ns.length ? ns.reduce(function (s, n) { return s + pos[n]; }, 0) / ns.length : pos[id];
|
|
164
|
-
});
|
|
165
|
-
col.sort(function (a, b) { return (key[a] - key[b]) || byNumericId(a, b); });
|
|
166
|
-
col.forEach(function (id, i) { pos[id] = i; });
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// layoutCluster — one cluster's goals in a block of its own: { w, h, at: { id: {x, y} } }.
|
|
172
|
-
function layoutCluster(ids, a) {
|
|
173
|
-
var depth = layerOf(ids, a.waits);
|
|
174
|
-
var neighbours = {};
|
|
175
|
-
ids.forEach(function (id) {
|
|
176
|
-
neighbours[id] = (a.waits[id] || []).concat(a.needed[id] || []).map(function (n) { return n.id; });
|
|
177
|
-
});
|
|
178
|
-
var columns = [];
|
|
179
|
-
ids.forEach(function (id) { (columns[depth[id]] = columns[depth[id]] || []).push(id); });
|
|
180
|
-
columns = columns.map(function (c) { return (c || []).sort(byNumericId); });
|
|
181
|
-
orderColumns(columns, neighbours);
|
|
182
|
-
|
|
183
|
-
var rows = columns.reduce(function (m, c) { return Math.max(m, c.length); }, 1);
|
|
184
|
-
var w = columns.length * NODE_W + (columns.length - 1) * GAP_X;
|
|
185
|
-
var h = rows * NODE_H + (rows - 1) * GAP_Y;
|
|
186
|
-
var at = {};
|
|
187
|
-
columns.forEach(function (col, li) {
|
|
188
|
-
var colH = col.length * NODE_H + (col.length - 1) * GAP_Y;
|
|
189
|
-
var top = Math.round((h - colH) / 2);
|
|
190
|
-
col.forEach(function (id, i) { at[id] = { x: li * (NODE_W + GAP_X), y: top + i * (NODE_H + GAP_Y) }; });
|
|
191
|
-
});
|
|
192
|
-
return { w: w, h: h, at: at };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function edgePath(dep, dependent) {
|
|
196
|
-
// Drawn dependency → dependent, left to right: "this had to ship first".
|
|
197
|
-
var x1 = dep.x + NODE_W;
|
|
198
|
-
var y1 = dep.y + NODE_H / 2;
|
|
199
|
-
var x2 = dependent.x;
|
|
200
|
-
var y2 = dependent.y + NODE_H / 2;
|
|
201
|
-
var dx = x2 > x1 ? (x2 - x1) / 2 : 44;
|
|
202
|
-
return 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// layout — every cluster laid out, then packed into rows ("shelves") so the
|
|
206
|
-
// big cluster sits on top and the pairs tile beneath it. Returns the SVG's own
|
|
207
|
-
// size: the page puts the svg in an overflow-x container and NEVER lets the page
|
|
208
|
-
// body scroll sideways (ADR 0227 §5.2).
|
|
209
|
-
function layout(a) {
|
|
210
|
-
var blocks = a.clusters.map(function (ids) { return layoutCluster(ids, a); });
|
|
211
|
-
var rowW = Math.max(MIN_ROW_W, blocks.length ? blocks[0].w : 0);
|
|
212
|
-
var rows = [];
|
|
213
|
-
var cur = { items: [], w: 0, h: 0 };
|
|
214
|
-
blocks.forEach(function (b, i) {
|
|
215
|
-
if (cur.items.length && cur.w + BLOCK_GAP + b.w > rowW) { rows.push(cur); cur = { items: [], w: 0, h: 0 }; }
|
|
216
|
-
cur.items.push({ i: i, x: cur.items.length ? cur.w + BLOCK_GAP : 0 });
|
|
217
|
-
cur.w += (cur.items.length > 1 ? BLOCK_GAP : 0) + b.w;
|
|
218
|
-
cur.h = Math.max(cur.h, b.h);
|
|
219
|
-
});
|
|
220
|
-
if (cur.items.length) rows.push(cur);
|
|
221
|
-
|
|
222
|
-
var nodes = [];
|
|
223
|
-
var abs = {};
|
|
224
|
-
var y = PAD;
|
|
225
|
-
var width = 0;
|
|
226
|
-
rows.forEach(function (row) {
|
|
227
|
-
row.items.forEach(function (it) {
|
|
228
|
-
var b = blocks[it.i];
|
|
229
|
-
Object.keys(b.at).forEach(function (id) {
|
|
230
|
-
var p = { x: PAD + it.x + b.at[id].x, y: y + b.at[id].y };
|
|
231
|
-
abs[id] = p;
|
|
232
|
-
nodes.push({ id: id, x: p.x, y: p.y, w: NODE_W, h: NODE_H });
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
width = Math.max(width, PAD + row.w + PAD);
|
|
236
|
-
y += row.h + BLOCK_GAP;
|
|
237
|
-
});
|
|
238
|
-
var height = rows.length ? y - BLOCK_GAP + PAD : 0;
|
|
239
|
-
|
|
240
|
-
var edges = a.edges.map(function (e) {
|
|
241
|
-
return {
|
|
242
|
-
from: e.from, to: e.to, count: e.count,
|
|
243
|
-
d: edgePath(abs[e.to], abs[e.from]),
|
|
244
|
-
// 1px for a single task edge, up to 3px — weight is a hint, the table has the count.
|
|
245
|
-
width: 1 + Math.min(2, Math.floor(Math.log(Math.max(1, e.count)) / Math.LN2 / 1.5)),
|
|
246
|
-
};
|
|
247
|
-
});
|
|
248
|
-
return { width: width, height: height, nodes: nodes, edges: edges, nodeW: NODE_W, nodeH: NODE_H };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// categories — the legend, and the colour each category wears. Index follows
|
|
252
|
-
// the alphabetical order of the labels PRESENT, so a category keeps its colour
|
|
253
|
-
// from one visit to the next while the set does not change.
|
|
254
|
-
function categories(a) {
|
|
255
|
-
var seen = {};
|
|
256
|
-
a.order.forEach(function (id) {
|
|
257
|
-
var c = a.byId[id].category;
|
|
258
|
-
if (c && c.slug && !seen[c.slug]) seen[c.slug] = { slug: c.slug, label: c.label, count: 0 };
|
|
259
|
-
if (c && c.slug) seen[c.slug].count++;
|
|
260
|
-
});
|
|
261
|
-
var list = Object.keys(seen).map(function (k) { return seen[k]; })
|
|
262
|
-
.sort(function (x, y) { return String(x.label).localeCompare(String(y.label)) || (x.slug < y.slug ? -1 : 1); });
|
|
263
|
-
list.forEach(function (c, i) { c.colour = i % CATEGORY_COLOURS; });
|
|
264
|
-
var uncategorised = a.order.filter(function (id) { return !a.byId[id].category; }).length;
|
|
265
|
-
return { list: list, uncategorised: uncategorised };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function colourOf(node, cats) {
|
|
269
|
-
if (!node.category) return 'none';
|
|
270
|
-
for (var i = 0; i < cats.list.length; i++) if (cats.list[i].slug === node.category.slug) return String(cats.list[i].colour);
|
|
271
|
-
return 'none';
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// weightOf — the node's WEIGHT (ADR 0227 §6 item 3: "node weight = task
|
|
275
|
-
// count"), as a 0–1 fraction of the busiest DRAWN goal's task count. Log-scaled
|
|
276
|
-
// so one 127-task goal does not flatten every other bar to nothing — the same
|
|
277
|
-
// reasoning `edgePath`'s width already uses for edge count. `maxTotal` is the
|
|
278
|
-
// caller's (the drawn set, not the whole corpus) so the scale means something
|
|
279
|
-
// on THIS map; a maxTotal of 0 (every drawn goal has zero tasks) draws no bar
|
|
280
|
-
// rather than dividing by zero into a false full one.
|
|
281
|
-
function weightOf(total, maxTotal) {
|
|
282
|
-
var t = Math.max(0, Number(total) || 0);
|
|
283
|
-
var m = Math.max(0, Number(maxTotal) || 0);
|
|
284
|
-
if (!m || !t) return 0;
|
|
285
|
-
return Math.min(1, Math.log(1 + t) / Math.log(1 + m));
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// tableRows — the primary surface. One row per goal, carrying every fact the
|
|
289
|
-
// picture carries plus the two directions of each dependency BY NAME, because
|
|
290
|
-
// with this many separate clusters the table holds more of the structure than
|
|
291
|
-
// the picture does (§10.4 item 3).
|
|
292
|
-
function tableRows(a) {
|
|
293
|
-
function named(list) {
|
|
294
|
-
return (list || []).map(function (x) { return { id: x.id, title: displayTitle(a.byId[x.id]), count: x.count }; });
|
|
295
|
-
}
|
|
296
|
-
var rows = a.order.map(function (id) {
|
|
297
|
-
var n = a.byId[id];
|
|
298
|
-
var cl = a.clusterOf[id] || null;
|
|
299
|
-
return {
|
|
300
|
-
id: id,
|
|
301
|
-
title: displayTitle(n),
|
|
302
|
-
isPrivate: !n.title,
|
|
303
|
-
version: n.version_id || '',
|
|
304
|
-
status: n.status || '',
|
|
305
|
-
category: n.category ? n.category.label : '',
|
|
306
|
-
done: n.task_done || 0,
|
|
307
|
-
total: n.task_total || 0,
|
|
308
|
-
waits: named(a.waits[id]),
|
|
309
|
-
needed: named(a.needed[id]),
|
|
310
|
-
waitsGoals: n.depends_on != null ? n.depends_on : (a.waits[id] || []).length,
|
|
311
|
-
neededGoals: n.depended_on_by != null ? n.depended_on_by : (a.needed[id] || []).length,
|
|
312
|
-
cluster: cl ? cl.index : 0,
|
|
313
|
-
clusterSize: cl ? cl.size : 1,
|
|
314
|
-
clusterLabel: cl ? 'Cluster ' + cl.index + ' · ' + plural(cl.size, 'goal') : 'Stands alone',
|
|
315
|
-
};
|
|
316
|
-
});
|
|
317
|
-
// Big cluster first, stand-alone last; within a group the busiest goal first.
|
|
318
|
-
rows.sort(function (x, y) {
|
|
319
|
-
var cx = x.cluster || 1e9;
|
|
320
|
-
var cy = y.cluster || 1e9;
|
|
321
|
-
return (cx - cy) || (y.total - x.total) || byNumericId(x.id, y.id);
|
|
322
|
-
});
|
|
323
|
-
return rows;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// describe — the sentence that states the split out loud (ADR 0227 §10.4 item 1:
|
|
327
|
-
// "with the split stated on the page"). Numbers only; no claim the data cannot back.
|
|
328
|
-
function describe(a) {
|
|
329
|
-
var goals = a.order.length;
|
|
330
|
-
if (!goals) return { summary: 'No goal carries any work yet.', graph: '' };
|
|
331
|
-
if (!a.connected) {
|
|
332
|
-
return {
|
|
333
|
-
summary: plural(goals, 'goal') + ' carry work, and no goal waits on another goal’s tasks.',
|
|
334
|
-
graph: 'No goal depends on another goal’s work, so there is no graph to draw — the table below is the whole map.',
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
var largest = a.clusters[0].length;
|
|
338
|
-
var summary = plural(goals, 'goal') + ' carry work. ' + a.connected + ' of them ' + (a.connected === 1 ? 'is' : 'are')
|
|
339
|
-
+ ' tied to another goal by a dependency; the other ' + a.isolated.length + ' stand alone. '
|
|
340
|
-
+ 'The ' + a.connected + ' form ' + plural(a.clusters.length, 'cluster') + ', the largest with ' + plural(largest, 'goal') + '.';
|
|
341
|
-
return { summary: summary, graph: '' };
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return {
|
|
345
|
-
analyse: analyse, layout: layout, categories: categories, colourOf: colourOf, weightOf: weightOf,
|
|
346
|
-
tableRows: tableRows, describe: describe, displayTitle: displayTitle,
|
|
347
|
-
plural: plural, CATEGORY_COLOURS: CATEGORY_COLOURS,
|
|
348
|
-
};
|
|
349
|
-
}));
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/* goal-map.css — the goal map (task 1003479, goal 1000089; ADR 0227 §6 item 3).
|
|
2
|
-
*
|
|
3
|
-
* The page is the board archetype: the shell's .scroll panels, the kit's ledger
|
|
4
|
-
* for the table, and only the shapes no other page has — the node-link figure, its
|
|
5
|
-
* category colour channel and legend, and the stand-alone chips.
|
|
6
|
-
*
|
|
7
|
-
* COLOUR. Every value below is a var() or a color-mix() of the thirteen contract
|
|
8
|
-
* colours (DESIGN.md's Fifteen Rule), so the page inherits dark and light and an
|
|
9
|
-
* instance's palette. Category is a colour AND a word on every node — the ADR 0192
|
|
10
|
-
* set is not colour-distinguishable at this many nodes — so the eight hues below
|
|
11
|
-
* are a redundant channel, and a ninth category repeating the first costs nothing.
|
|
12
|
-
* These are page-scoped classes on the figure, never a :root (the pack's :root is
|
|
13
|
-
* spliced at (0,1,0); a page's own would neither win reliably nor lose cleanly).
|
|
14
|
-
*
|
|
15
|
-
* NO SIDEWAYS SCROLL ON THE PAGE. The figure scrolls inside .gm-scroll, which names
|
|
16
|
-
* BOTH axes — a lone overflow-x makes the other axis `auto` too and any 1px
|
|
17
|
-
* overhang grows a vertical scrollbar nothing can scroll (task 1004130).
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
.gm-figure { margin: 0; }
|
|
21
|
-
.gm-figure .scroll__lede { margin-bottom: var(--sp-md); }
|
|
22
|
-
|
|
23
|
-
.gm-scroll {
|
|
24
|
-
overflow-x: auto;
|
|
25
|
-
overflow-y: hidden;
|
|
26
|
-
max-width: 100%;
|
|
27
|
-
border: var(--line) solid var(--rule-soft);
|
|
28
|
-
border-radius: var(--radius);
|
|
29
|
-
background: color-mix(in srgb, var(--ink) 3%, var(--bg-card));
|
|
30
|
-
}
|
|
31
|
-
.gm-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
32
|
-
.gm-svg { display: block; }
|
|
33
|
-
|
|
34
|
-
/* Eight categorical hues, derived. */
|
|
35
|
-
.gm-c0 { --gm-c: var(--sea); }
|
|
36
|
-
.gm-c1 { --gm-c: var(--accent-deep); }
|
|
37
|
-
.gm-c2 { --gm-c: var(--terracotta); }
|
|
38
|
-
.gm-c3 { --gm-c: color-mix(in srgb, var(--sea) 55%, var(--terracotta)); }
|
|
39
|
-
.gm-c4 { --gm-c: color-mix(in srgb, var(--sea) 55%, var(--ink)); }
|
|
40
|
-
.gm-c5 { --gm-c: color-mix(in srgb, var(--accent-deep) 60%, var(--ink)); }
|
|
41
|
-
.gm-c6 { --gm-c: color-mix(in srgb, var(--accent) 55%, var(--sea)); }
|
|
42
|
-
.gm-c7 { --gm-c: var(--ink-soft); }
|
|
43
|
-
|
|
44
|
-
.gm-edge {
|
|
45
|
-
fill: none;
|
|
46
|
-
stroke: var(--ink-faint);
|
|
47
|
-
opacity: 0.85;
|
|
48
|
-
}
|
|
49
|
-
.gm-arrowhead { fill: var(--ink-faint); }
|
|
50
|
-
|
|
51
|
-
.gm-card {
|
|
52
|
-
fill: var(--bg-card);
|
|
53
|
-
stroke: var(--rule);
|
|
54
|
-
stroke-width: 1;
|
|
55
|
-
}
|
|
56
|
-
.gm-stripe { fill: var(--gm-c, transparent); }
|
|
57
|
-
/* Uncategorised is legal (ADR 0192): a hollow, dashed stripe — present, and plainly not a colour. */
|
|
58
|
-
.gm-cnone .gm-stripe { fill: none; stroke: var(--ink-faint); stroke-width: 1; stroke-dasharray: 2 2; }
|
|
59
|
-
|
|
60
|
-
.gm-name { font: 600 13px var(--font-body); fill: var(--ink); }
|
|
61
|
-
.gm-cat { font: 500 11px var(--font-body); fill: var(--ink-soft); }
|
|
62
|
-
.gm-tasks { font: 500 11px var(--font-body); fill: var(--ink-faint); }
|
|
63
|
-
|
|
64
|
-
/* The node's WEIGHT (ADR 0227 §6 item 3: "node weight = task count") — a bar
|
|
65
|
-
* scaled to the busiest goal drawn on THIS map, so its length is a real second
|
|
66
|
-
* channel beside the task-count text, not a decoration repeating it. */
|
|
67
|
-
.gm-weight-track { fill: var(--rule-soft); }
|
|
68
|
-
.gm-weight-fill { fill: var(--gm-c, var(--ink-soft)); }
|
|
69
|
-
|
|
70
|
-
/* The legend is a run of labelled swatches under the figure. */
|
|
71
|
-
.gm-legend {
|
|
72
|
-
display: flex;
|
|
73
|
-
flex-wrap: wrap;
|
|
74
|
-
gap: var(--sp-hair) var(--sp-md);
|
|
75
|
-
margin: var(--sp-md) 0 0;
|
|
76
|
-
padding: 0;
|
|
77
|
-
list-style: none;
|
|
78
|
-
font-size: 13px;
|
|
79
|
-
color: var(--ink-soft);
|
|
80
|
-
}
|
|
81
|
-
.gm-legend li { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; }
|
|
82
|
-
.gm-swatch {
|
|
83
|
-
width: 10px;
|
|
84
|
-
height: 10px;
|
|
85
|
-
border-radius: 2px;
|
|
86
|
-
background: var(--gm-c, transparent);
|
|
87
|
-
}
|
|
88
|
-
.gm-swatch.gm-cnone { background: none; box-shadow: inset 0 0 0 1px var(--ink-faint); }
|
|
89
|
-
.gm-n { color: var(--ink-faint); }
|
|
90
|
-
|
|
91
|
-
/* The stand-alone goals: chips that wrap, not a grid of cards. */
|
|
92
|
-
.gm-alone {
|
|
93
|
-
display: flex;
|
|
94
|
-
flex-wrap: wrap;
|
|
95
|
-
gap: var(--sp-hair);
|
|
96
|
-
margin: 0;
|
|
97
|
-
padding: 0;
|
|
98
|
-
list-style: none;
|
|
99
|
-
}
|
|
100
|
-
/* A long goal title wraps inside its chip rather than pushing past the panel. */
|
|
101
|
-
.gm-chip {
|
|
102
|
-
min-height: 24px;
|
|
103
|
-
max-width: 100%;
|
|
104
|
-
flex-wrap: wrap;
|
|
105
|
-
align-items: center;
|
|
106
|
-
gap: 0 var(--sp-hair);
|
|
107
|
-
white-space: normal;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/* Table cells that list goals. */
|
|
111
|
-
/* A floor on the dependency columns: without it a phone squeezes them to a few
|
|
112
|
-
characters and every row becomes a tall, empty tower. With it the table is
|
|
113
|
-
wider than the card and scrolls inside it (the hall's own ledger rule). */
|
|
114
|
-
.gm-deps { margin: 0; padding: 0; list-style: none; min-width: 14rem; }
|
|
115
|
-
.gm-deps li { font-size: 13px; line-height: 1.45; }
|
|
116
|
-
.gm-none { color: var(--ink-faint); }
|
|
117
|
-
.gm-private { color: var(--ink-soft); font-style: italic; }
|