@bongos/core 1.19.249 → 1.19.251
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 +110 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +14 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +131 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +257 -3
- package/docs/api-reference.md +10 -1
- package/docs/copy-inventory.md +1430 -0
- package/docs/copy-registry.json +13293 -0
- package/docs/file-map.md +1 -0
- package/modules/copy-desk/CLAUDE.md +15 -0
- package/modules/copy-desk/flags.js +245 -0
- package/modules/copy-desk/migrations/copy_desk_001_flags.sql +128 -0
- package/modules/copy-desk/module.json +11 -0
- package/modules/copy-desk/registry.js +317 -0
- package/modules/copy-desk/routes/copy-desk.js +272 -0
- package/modules/copy-desk/tests/copy_flags.mjs +246 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +104 -0
- package/modules/copy-desk/tests/copy_queue.mjs +257 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +10 -0
- package/modules/hall-ui/public/copy-desk.css +119 -0
- package/modules/hall-ui/public/copy-desk.html +139 -0
- package/modules/hall-ui/public/copy-desk.js +352 -0
- package/modules/hall-ui/public/shell.js +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +9 -0
- package/scripts/gds/run-unit-tests.js +11 -1
- package/src/bongos/module-scope-map.js +17 -0
- package/src/bongos/serve-internal.js +11 -1
- package/src/module-api.js +5 -1
- package/tests/copy_desk_flags_db.mjs +250 -0
- package/tests/hall_nav.mjs +7 -1
- package/tests/module_loader.mjs +5 -2
- package/tests/publish_manifest.mjs +82 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* modules/hall-ui/public/copy-desk.css — the Copy desk tab (task 1003113 / R02
|
|
2
|
+
of goal 1000074).
|
|
3
|
+
|
|
4
|
+
Built on the hall's existing tokens (style.css) rather than a new palette: the
|
|
5
|
+
page whose subject is "does this project speak with one voice" would be a poor
|
|
6
|
+
place to introduce a second visual one. Only the shapes this page needs are
|
|
7
|
+
defined here. */
|
|
8
|
+
|
|
9
|
+
.cd-stats {
|
|
10
|
+
display: grid;
|
|
11
|
+
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
|
12
|
+
gap: 0.75rem;
|
|
13
|
+
margin: 0.5rem 0 1rem;
|
|
14
|
+
}
|
|
15
|
+
.cd-stat {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
gap: 0.15rem;
|
|
19
|
+
padding: 0.75rem 0.9rem;
|
|
20
|
+
border: 1px solid var(--line, rgba(255, 255, 255, 0.12));
|
|
21
|
+
border-radius: 0.5rem;
|
|
22
|
+
background: var(--panel, rgba(255, 255, 255, 0.03));
|
|
23
|
+
}
|
|
24
|
+
.cd-stat__n { font-size: 1.6rem; font-weight: 700; line-height: 1.1; }
|
|
25
|
+
.cd-stat__l { font-size: 0.8rem; opacity: 0.7; }
|
|
26
|
+
|
|
27
|
+
.cd-note { font-size: 0.85rem; opacity: 0.75; line-height: 1.5; }
|
|
28
|
+
.cd-note code {
|
|
29
|
+
padding: 0.05rem 0.3rem;
|
|
30
|
+
border-radius: 0.25rem;
|
|
31
|
+
background: var(--panel, rgba(255, 255, 255, 0.06));
|
|
32
|
+
font-size: 0.95em;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.cd-filters { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center; margin: 0.5rem 0 1rem; }
|
|
36
|
+
.cd-filters__label { font-size: 0.8rem; opacity: 0.7; }
|
|
37
|
+
.cd-filters__select,
|
|
38
|
+
.cd-browse__input,
|
|
39
|
+
.cd-dialog__select,
|
|
40
|
+
.cd-dialog__input,
|
|
41
|
+
.cd-dialog__text {
|
|
42
|
+
padding: 0.4rem 0.6rem;
|
|
43
|
+
border: 1px solid var(--line, rgba(255, 255, 255, 0.15));
|
|
44
|
+
border-radius: 0.35rem;
|
|
45
|
+
background: var(--panel, rgba(0, 0, 0, 0.2));
|
|
46
|
+
color: inherit;
|
|
47
|
+
font: inherit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.cd-browse { margin: 0.5rem 0 1rem; }
|
|
51
|
+
.cd-browse__label { display: block; font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.3rem; }
|
|
52
|
+
.cd-browse__input { width: 100%; max-width: 34rem; }
|
|
53
|
+
|
|
54
|
+
.cd-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
|
55
|
+
.cd-empty { padding: 1rem; opacity: 0.65; font-size: 0.9rem; }
|
|
56
|
+
|
|
57
|
+
.cd-card {
|
|
58
|
+
padding: 0.85rem 1rem;
|
|
59
|
+
border: 1px solid var(--line, rgba(255, 255, 255, 0.12));
|
|
60
|
+
border-radius: 0.5rem;
|
|
61
|
+
background: var(--panel, rgba(255, 255, 255, 0.03));
|
|
62
|
+
}
|
|
63
|
+
.cd-card--slim { padding: 0.6rem 0.8rem; }
|
|
64
|
+
.cd-card__head { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-bottom: 0.4rem; }
|
|
65
|
+
.cd-card__target { margin: 0.2rem 0 0.45rem; line-height: 1.5; }
|
|
66
|
+
.cd-card__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: flex-end; margin-top: 0.5rem; }
|
|
67
|
+
|
|
68
|
+
/* The quoted string is the product's own words, shown as evidence — so it is
|
|
69
|
+
visually set apart from this page's own voice. Wrapping is preserved because
|
|
70
|
+
leading and trailing space in a string is often exactly what is wrong. */
|
|
71
|
+
.cd-quote {
|
|
72
|
+
quotes: '"' '"';
|
|
73
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
74
|
+
font-size: 0.9em;
|
|
75
|
+
white-space: pre-wrap;
|
|
76
|
+
word-break: break-word;
|
|
77
|
+
}
|
|
78
|
+
.cd-where { font-size: 0.8rem; opacity: 0.6; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
79
|
+
.cd-byline { font-size: 0.8rem; opacity: 0.65; margin-left: auto; }
|
|
80
|
+
.cd-reason { margin: 0.3rem 0 0; line-height: 1.55; }
|
|
81
|
+
.cd-snapshot-label { font-size: 0.8rem; opacity: 0.6; }
|
|
82
|
+
.cd-closed { margin: 0.5rem 0 0; font-size: 0.85rem; opacity: 0.8; line-height: 1.5; }
|
|
83
|
+
.cd-places { margin: 0.3rem 0 0; padding-left: 1.1rem; font-size: 0.8rem; opacity: 0.65; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
84
|
+
|
|
85
|
+
.cd-tag {
|
|
86
|
+
display: inline-block;
|
|
87
|
+
padding: 0.1rem 0.45rem;
|
|
88
|
+
border: 1px solid var(--line, rgba(255, 255, 255, 0.18));
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
font-size: 0.72rem;
|
|
91
|
+
letter-spacing: 0.02em;
|
|
92
|
+
opacity: 0.85;
|
|
93
|
+
}
|
|
94
|
+
.cd-tag--kind { font-weight: 600; opacity: 1; }
|
|
95
|
+
/* 'untouched' is the only state that is WORK, so it is the only one that carries
|
|
96
|
+
colour — a queue where everything is highlighted highlights nothing. */
|
|
97
|
+
.cd-tag--untouched { border-color: currentColor; opacity: 1; }
|
|
98
|
+
.cd-tag--flagged,
|
|
99
|
+
.cd-tag--settled,
|
|
100
|
+
.cd-tag--moved { opacity: 0.6; }
|
|
101
|
+
.cd-tag--gone { opacity: 0.75; font-style: italic; }
|
|
102
|
+
|
|
103
|
+
.cd-dialog {
|
|
104
|
+
width: min(34rem, 92vw);
|
|
105
|
+
padding: 1.25rem;
|
|
106
|
+
border: 1px solid var(--line, rgba(255, 255, 255, 0.18));
|
|
107
|
+
border-radius: 0.6rem;
|
|
108
|
+
background: var(--bg, #12131a);
|
|
109
|
+
color: inherit;
|
|
110
|
+
}
|
|
111
|
+
.cd-dialog::backdrop { background: rgba(0, 0, 0, 0.55); }
|
|
112
|
+
.cd-dialog__h { margin: 0 0 0.5rem; font-size: 1.1rem; }
|
|
113
|
+
.cd-dialog__target { margin: 0 0 0.9rem; line-height: 1.5; }
|
|
114
|
+
.cd-dialog__label { display: block; margin: 0.75rem 0 0.25rem; font-size: 0.85rem; font-weight: 600; }
|
|
115
|
+
.cd-dialog__hint { display: block; font-weight: 400; font-size: 0.78rem; opacity: 0.65; line-height: 1.45; margin-top: 0.1rem; }
|
|
116
|
+
.cd-dialog__select, .cd-dialog__input, .cd-dialog__text { width: 100%; }
|
|
117
|
+
.cd-dialog__text { resize: vertical; line-height: 1.5; }
|
|
118
|
+
.cd-dialog__err { min-height: 1.2rem; margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--danger, #ff8080); line-height: 1.45; }
|
|
119
|
+
.cd-dialog__actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.9rem; }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Copy desk — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="The artist's queue over the words this project shows people: flagged copy, who flagged it and why, and the duplicated strings nobody has looked at yet.">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
12
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
13
|
+
<link rel="stylesheet" href="/builders/copy-desk.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body data-page="copy-desk" data-page-title="Copy desk">
|
|
16
|
+
|
|
17
|
+
<div class="app">
|
|
18
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
19
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
20
|
+
<div class="app-body">
|
|
21
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
22
|
+
<div class="app-content">
|
|
23
|
+
<div class="page-head"><p class="page-head__sub" id="cd-sub">Loading the copy desk…</p></div>
|
|
24
|
+
|
|
25
|
+
<main>
|
|
26
|
+
<!-- The counts. Deliberately four numbers and not a dashboard: open work,
|
|
27
|
+
the fast pile (targets that have since been edited away), untouched
|
|
28
|
+
duplicates, and how big the inventory is. -->
|
|
29
|
+
<section class="scroll" id="cd-summary-scroll" aria-labelledby="cd-summary-h">
|
|
30
|
+
<h2 id="cd-summary-h" class="scroll__h">Where the words stand</h2>
|
|
31
|
+
<div class="cd-stats" id="cd-stats"></div>
|
|
32
|
+
<p class="cd-note" id="cd-registry-note"></p>
|
|
33
|
+
</section>
|
|
34
|
+
|
|
35
|
+
<!-- The queue. -->
|
|
36
|
+
<section class="scroll" id="cd-flags-scroll" aria-labelledby="cd-flags-h">
|
|
37
|
+
<h2 id="cd-flags-h" class="scroll__h">Flagged</h2>
|
|
38
|
+
<p class="scroll__lede">Copy someone has judged as needing attention, with their reason. Anyone can file one; closing it needs Metic+, or that it is your own.</p>
|
|
39
|
+
<div class="cd-filters">
|
|
40
|
+
<label class="cd-filters__label" for="cd-status">Show</label>
|
|
41
|
+
<select id="cd-status" class="cd-filters__select">
|
|
42
|
+
<option value="open" selected>Open</option>
|
|
43
|
+
<option value="resolved">Resolved</option>
|
|
44
|
+
<option value="dismissed">Dismissed</option>
|
|
45
|
+
<option value="all">Everything</option>
|
|
46
|
+
</select>
|
|
47
|
+
<label class="cd-filters__label" for="cd-surface">Surface</label>
|
|
48
|
+
<select id="cd-surface" class="cd-filters__select"><option value="">All surfaces</option></select>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="cd-list" id="cd-flags"><div class="cd-empty">Loading…</div></div>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<!-- The one signal a machine can compute with no judgement layer at all
|
|
54
|
+
(R01's registry computes it; this only subtracts what has been dealt
|
|
55
|
+
with). Stale and off-voice need a person or a judge — this does not. -->
|
|
56
|
+
<section class="scroll" id="cd-dupes-scroll" aria-labelledby="cd-dupes-h">
|
|
57
|
+
<h2 id="cd-dupes-h" class="scroll__h">Said more than once</h2>
|
|
58
|
+
<p class="scroll__lede">The same sentence, in several places. Sometimes that is right and the answer is to dismiss it; sometimes it is seven copies of an error message that should have been one. Nobody has looked at the ones marked untouched.</p>
|
|
59
|
+
<div class="cd-list" id="cd-dupes"><div class="cd-empty">Loading…</div></div>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<!-- Find any string in the product and flag it. Without this the queue
|
|
63
|
+
could only ever contain what a machine had already noticed. -->
|
|
64
|
+
<section class="scroll" id="cd-find-scroll" aria-labelledby="cd-find-h">
|
|
65
|
+
<h2 id="cd-find-h" class="scroll__h">Find something to flag</h2>
|
|
66
|
+
<p class="scroll__lede">Search every string the product shows a person, by what it says or the file it lives in.</p>
|
|
67
|
+
<div class="cd-browse">
|
|
68
|
+
<label class="cd-browse__label" for="cd-search">Search the copy</label>
|
|
69
|
+
<input type="search" id="cd-search" class="cd-browse__input" autocomplete="off"
|
|
70
|
+
placeholder="a phrase you saw, or a file name…">
|
|
71
|
+
<p class="cd-note" id="cd-search-count" role="status" aria-live="polite"></p>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="cd-list" id="cd-results"></div>
|
|
74
|
+
</section>
|
|
75
|
+
</main>
|
|
76
|
+
|
|
77
|
+
<footer class="app-foot" id="app-foot"></footer>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- The flag composer. A dialog rather than an inline field because the reason is
|
|
83
|
+
the point: a one-line input invites "typo", and the queue is only worth
|
|
84
|
+
reading if the reasons are. -->
|
|
85
|
+
<dialog id="cd-dialog" class="cd-dialog">
|
|
86
|
+
<form method="dialog" id="cd-form">
|
|
87
|
+
<h3 class="cd-dialog__h" id="cd-dialog-h">Flag this copy</h3>
|
|
88
|
+
<p class="cd-dialog__target" id="cd-dialog-target"></p>
|
|
89
|
+
<label class="cd-dialog__label" for="cd-kind">What kind of wrong?</label>
|
|
90
|
+
<select id="cd-kind" class="cd-dialog__select">
|
|
91
|
+
<option value="off-voice">Off voice — says it in the wrong register</option>
|
|
92
|
+
<option value="slop">Slop — reads as machine-written</option>
|
|
93
|
+
<option value="unclear">Unclear — a person would not know what to do</option>
|
|
94
|
+
<option value="stale">Stale — describes something that changed</option>
|
|
95
|
+
<option value="duplicate">Duplicate — should be said once, in one place</option>
|
|
96
|
+
<option value="typo">Typo or grammar</option>
|
|
97
|
+
<option value="other" selected>Something else</option>
|
|
98
|
+
</select>
|
|
99
|
+
<label class="cd-dialog__label" for="cd-reason">Why? <span class="cd-dialog__hint">The artist reads this instead of guessing. A pointer with no judgement costs them a look and tells them nothing.</span></label>
|
|
100
|
+
<textarea id="cd-reason" class="cd-dialog__text" rows="4" minlength="10" required
|
|
101
|
+
placeholder="What is wrong with it, and what would better look like?"></textarea>
|
|
102
|
+
<p class="cd-dialog__err" id="cd-dialog-err" role="alert"></p>
|
|
103
|
+
<div class="cd-dialog__actions">
|
|
104
|
+
<button type="button" class="btn btn--ghost" id="cd-cancel">Cancel</button>
|
|
105
|
+
<button type="button" class="btn" id="cd-submit">File the flag</button>
|
|
106
|
+
</div>
|
|
107
|
+
</form>
|
|
108
|
+
</dialog>
|
|
109
|
+
|
|
110
|
+
<!-- Closing a flag asks for a note for the same reason opening one does. -->
|
|
111
|
+
<dialog id="cd-close-dialog" class="cd-dialog">
|
|
112
|
+
<form method="dialog" id="cd-close-form">
|
|
113
|
+
<h3 class="cd-dialog__h">Close this flag</h3>
|
|
114
|
+
<p class="cd-dialog__target" id="cd-close-target"></p>
|
|
115
|
+
<label class="cd-dialog__label" for="cd-close-status">Outcome</label>
|
|
116
|
+
<select id="cd-close-status" class="cd-dialog__select">
|
|
117
|
+
<option value="resolved" selected>Resolved — the copy was changed</option>
|
|
118
|
+
<option value="dismissed">Dismissed — looked at, leaving it as it is</option>
|
|
119
|
+
</select>
|
|
120
|
+
<label class="cd-dialog__label" for="cd-close-task">Task that carried the fix <span class="cd-dialog__hint">Optional. This is the ledger link — a copy change ships as a task like every other change.</span></label>
|
|
121
|
+
<input type="number" id="cd-close-task" class="cd-dialog__input" placeholder="e.g. 1003113" min="1">
|
|
122
|
+
<label class="cd-dialog__label" for="cd-close-note">Note <span class="cd-dialog__hint">The next person to notice this sentence reads your note instead of re-litigating it.</span></label>
|
|
123
|
+
<textarea id="cd-close-note" class="cd-dialog__text" rows="3" minlength="10" required></textarea>
|
|
124
|
+
<p class="cd-dialog__err" id="cd-close-err" role="alert"></p>
|
|
125
|
+
<div class="cd-dialog__actions">
|
|
126
|
+
<button type="button" class="btn btn--ghost" id="cd-close-cancel">Cancel</button>
|
|
127
|
+
<button type="button" class="btn" id="cd-close-submit">Close it</button>
|
|
128
|
+
</div>
|
|
129
|
+
</form>
|
|
130
|
+
</dialog>
|
|
131
|
+
|
|
132
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
133
|
+
|
|
134
|
+
<script src="/builders/shell.js?v=2026-07-02-shell1"></script>
|
|
135
|
+
<script src="/builders/palette.js" defer></script>
|
|
136
|
+
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
137
|
+
<script src="/builders/copy-desk.js?v=2026-08-18-r02"></script>
|
|
138
|
+
</body>
|
|
139
|
+
</html>
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// modules/hall-ui/public/copy-desk.js — the hall "Copy desk" tab
|
|
2
|
+
// (/builders/copy-desk). Task 1003113 / R02 of goal 1000074, the artist's loop.
|
|
3
|
+
//
|
|
4
|
+
// The surface half of the flag: R01 built the inventory of every string the
|
|
5
|
+
// product shows a person; the copy-desk module turned "this one is wrong" into a
|
|
6
|
+
// row; this page is where an artist reads those rows and where everyone else
|
|
7
|
+
// files them.
|
|
8
|
+
//
|
|
9
|
+
// FOUR PANELS, IN THE ORDER AN ARTIST WOULD WORK:
|
|
10
|
+
// 1. the counts — what is outstanding, and the fast pile
|
|
11
|
+
// 2. Flagged — the queue itself, newest first, open by default
|
|
12
|
+
// 3. Said more than once — the deterministic duplicate signal, minus what has
|
|
13
|
+
// already been flagged or dismissed
|
|
14
|
+
// 4. Find something to flag — search the whole inventory
|
|
15
|
+
//
|
|
16
|
+
// NO LIVE CMS. Nothing on this page edits a string. The composer collects a
|
|
17
|
+
// JUDGEMENT (kind + reason); the fix ships as a claimed, graded task. There is
|
|
18
|
+
// deliberately no "new text" field — see modules/copy-desk/tests/copy_no_cms.mjs.
|
|
19
|
+
|
|
20
|
+
(() => {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const API = '/api/bongos';
|
|
24
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
25
|
+
const { escapeHtml, $, toast, fmtInt } = window.OTB;
|
|
26
|
+
|
|
27
|
+
let viewer = null;
|
|
28
|
+
let queue = null;
|
|
29
|
+
// The composer's current target, set when a "Flag" button is pressed.
|
|
30
|
+
let target = null;
|
|
31
|
+
let closing = null;
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// rendering helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
// A string shown as evidence is UNTRUSTED text from the product's own source
|
|
38
|
+
// tree, rendered into this page — so it is escaped like any other user data,
|
|
39
|
+
// and clipped so one runaway template literal cannot push the queue off screen.
|
|
40
|
+
function quote(text, max = 240) {
|
|
41
|
+
const s = String(text == null ? '' : text);
|
|
42
|
+
const clipped = s.length > max ? `${s.slice(0, max)}…` : s;
|
|
43
|
+
return `<q class="cd-quote">${escapeHtml(clipped)}</q>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function where(file, line) {
|
|
47
|
+
if (!file) return '';
|
|
48
|
+
return `<span class="cd-where">${escapeHtml(file)}${Number.isInteger(line) ? `:${line}` : ''}</span>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const KIND_LABEL = {
|
|
52
|
+
'off-voice': 'off voice', slop: 'slop', unclear: 'unclear',
|
|
53
|
+
stale: 'stale', duplicate: 'duplicate', typo: 'typo', other: 'flagged',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// The three states a flag's target can be in (registry.js annotateFlag).
|
|
57
|
+
// 'gone' is the interesting one and is spelled out rather than iconified: it
|
|
58
|
+
// means the sentence was edited or deleted since the flag was filed, which is
|
|
59
|
+
// a strong hint the flag is resolvable — but a hint for a person to act on,
|
|
60
|
+
// never something the server closes on its own.
|
|
61
|
+
function targetState(f) {
|
|
62
|
+
if (f.target_state === 'gone') {
|
|
63
|
+
return '<span class="cd-tag cd-tag--gone" title="The string this flag names is no longer in the registry — it was edited or removed. Probably already fixed; confirm and close it.">target gone</span>';
|
|
64
|
+
}
|
|
65
|
+
if (f.moved) {
|
|
66
|
+
return '<span class="cd-tag cd-tag--moved" title="Same text, different place — it moved file or line since the flag was filed.">moved</span>';
|
|
67
|
+
}
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function canClose(f) {
|
|
72
|
+
if (!viewer) return false;
|
|
73
|
+
if (String(f.flagged_by) === String(viewer.id)) return true;
|
|
74
|
+
return viewer.rank === 'metic' || viewer.rank === 'archon';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function flagRow(f) {
|
|
78
|
+
const who = escapeHtml(f.flagged_by_name || f.flagged_by_login || 'someone');
|
|
79
|
+
const when = f.flagged_at ? new Date(f.flagged_at).toLocaleDateString() : '';
|
|
80
|
+
// Show the live text when the string is still there, and the snapshot when it
|
|
81
|
+
// is not — labelled, so "what it says now" is never confused with "what it
|
|
82
|
+
// said when someone objected".
|
|
83
|
+
const body = f.current
|
|
84
|
+
? `${quote(f.current.text)} ${where(f.current.file, f.current.line)}`
|
|
85
|
+
: `<span class="cd-snapshot-label">said, when flagged:</span> ${quote(f.text_at_flag)} ${where(f.file_at_flag, f.line_at_flag)}`;
|
|
86
|
+
|
|
87
|
+
const scopeLine = f.scope === 'surface'
|
|
88
|
+
? `<span class="cd-tag">whole surface</span> <span class="cd-where">${escapeHtml(f.surface)}</span>`
|
|
89
|
+
: body;
|
|
90
|
+
|
|
91
|
+
const closed = f.status !== 'open'
|
|
92
|
+
? `<p class="cd-closed"><strong>${escapeHtml(f.status)}</strong>${f.resolved_task_id ? ` in task ${escapeHtml(String(f.resolved_task_id))}` : ''} — ${escapeHtml(f.resolution_note || '')}</p>`
|
|
93
|
+
: '';
|
|
94
|
+
|
|
95
|
+
return `<article class="cd-card" data-flag="${escapeHtml(String(f.id))}">
|
|
96
|
+
<div class="cd-card__head">
|
|
97
|
+
<span class="cd-tag cd-tag--kind">${escapeHtml(KIND_LABEL[f.kind] || f.kind)}</span>
|
|
98
|
+
${targetState(f)}
|
|
99
|
+
<span class="cd-byline">${who} · ${escapeHtml(when)}</span>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="cd-card__target">${scopeLine}</div>
|
|
102
|
+
<p class="cd-reason">${escapeHtml(f.reason)}</p>
|
|
103
|
+
${closed}
|
|
104
|
+
${f.status === 'open' && canClose(f)
|
|
105
|
+
? `<div class="cd-card__actions"><button class="btn btn--ghost cd-close-btn" data-flag="${escapeHtml(String(f.id))}">Close…</button></div>`
|
|
106
|
+
: ''}
|
|
107
|
+
</article>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function dupeRow(d) {
|
|
111
|
+
const state = d.flagged
|
|
112
|
+
? '<span class="cd-tag cd-tag--flagged">already flagged</span>'
|
|
113
|
+
: d.settled
|
|
114
|
+
? '<span class="cd-tag cd-tag--settled">looked at</span>'
|
|
115
|
+
: '<span class="cd-tag cd-tag--untouched">untouched</span>';
|
|
116
|
+
const places = (d.at || []).map((a) => `<li>${escapeHtml(a)}</li>`).join('');
|
|
117
|
+
return `<article class="cd-card">
|
|
118
|
+
<div class="cd-card__head">
|
|
119
|
+
${state}
|
|
120
|
+
<span class="cd-byline">${escapeHtml(String(d.occurrences))} places · ${escapeHtml(d.surface)}</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="cd-card__target">${quote(d.text)}</div>
|
|
123
|
+
<ul class="cd-places">${places}</ul>
|
|
124
|
+
${d.flagged ? '' : `<div class="cd-card__actions"><button class="btn btn--ghost cd-flag-btn"
|
|
125
|
+
data-id="${escapeHtml(d.id)}" data-surface="${escapeHtml(d.surface)}" data-text="${escapeHtml(d.text)}">Flag this…</button></div>`}
|
|
126
|
+
</article>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resultRow(e) {
|
|
130
|
+
return `<article class="cd-card cd-card--slim">
|
|
131
|
+
<div class="cd-card__target">${quote(e.text)} ${where(e.file, e.line)}</div>
|
|
132
|
+
<div class="cd-card__actions">
|
|
133
|
+
<span class="cd-byline">${escapeHtml(e.surface)} · ${escapeHtml(e.origin || '')}</span>
|
|
134
|
+
${e.flagged
|
|
135
|
+
? '<span class="cd-tag cd-tag--flagged">already flagged</span>'
|
|
136
|
+
: `<button class="btn btn--ghost cd-flag-btn" data-id="${escapeHtml(e.id)}" data-surface="${escapeHtml(e.surface)}" data-text="${escapeHtml(e.text)}">Flag this…</button>`}
|
|
137
|
+
</div>
|
|
138
|
+
</article>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// paint
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function paintStats() {
|
|
146
|
+
const c = queue.counts;
|
|
147
|
+
const r = queue.registry || {};
|
|
148
|
+
const total = (r.counts && r.counts.strings) || 0;
|
|
149
|
+
$('#cd-stats').innerHTML = [
|
|
150
|
+
['Open flags', c.flags_open],
|
|
151
|
+
['Target since edited', c.flags_target_gone],
|
|
152
|
+
['Duplicates untouched', c.duplicates_untouched],
|
|
153
|
+
['Strings inventoried', total],
|
|
154
|
+
].map(([label, n]) =>
|
|
155
|
+
`<div class="cd-stat"><span class="cd-stat__n">${escapeHtml(String(fmtInt ? fmtInt(n) : n))}</span><span class="cd-stat__l">${escapeHtml(label)}</span></div>`
|
|
156
|
+
).join('');
|
|
157
|
+
|
|
158
|
+
// Say which artifact this page is describing, and how to check it is current.
|
|
159
|
+
// The registry has no timestamp by design (its generator is deterministic and
|
|
160
|
+
// carries no clock), so this page must not imply one — it names the file and
|
|
161
|
+
// the command instead of showing an age that would be the checkout's.
|
|
162
|
+
const blind = (r.blind_spots || []).length;
|
|
163
|
+
$('#cd-registry-note').innerHTML =
|
|
164
|
+
`Read from <code>${escapeHtml(r.at || 'the copy registry')}</code> as committed. `
|
|
165
|
+
+ `It is generated, not live: run <code>${escapeHtml(r.freshness_check || 'node scripts/gds/copy-inventory.js --check')}</code> to see whether it still matches the tree.`
|
|
166
|
+
+ (blind ? ` This inventory has ${blind} documented blind spot${blind === 1 ? '' : 's'} — server-side messages, skill prose and runtime-composed strings are not in it, so it is not every word the project says.` : '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function paintFlags() {
|
|
170
|
+
const el = $('#cd-flags');
|
|
171
|
+
if (!queue.flags.length) {
|
|
172
|
+
el.innerHTML = '<div class="cd-empty">Nothing flagged here. That is either good news or an unread queue — the duplicates below are the cheapest place to find out.</div>';
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
el.innerHTML = queue.flags.map(flagRow).join('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function paintDupes() {
|
|
179
|
+
const el = $('#cd-dupes');
|
|
180
|
+
const rows = queue.duplicates || [];
|
|
181
|
+
if (!rows.length) { el.innerHTML = '<div class="cd-empty">No duplicated strings in the registry.</div>'; return; }
|
|
182
|
+
// Untouched first — it is the only part of this list that is work.
|
|
183
|
+
const ordered = [...rows].sort((a, b) =>
|
|
184
|
+
(Number(a.flagged || a.settled) - Number(b.flagged || b.settled)) || (b.occurrences - a.occurrences));
|
|
185
|
+
el.innerHTML = ordered.map(dupeRow).join('');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function paintSurfaces() {
|
|
189
|
+
const sel = $('#cd-surface');
|
|
190
|
+
const surfaces = Object.keys((queue.registry && queue.registry.surfaces) || {});
|
|
191
|
+
sel.innerHTML = '<option value="">All surfaces</option>'
|
|
192
|
+
+ surfaces.map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// load
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
async function loadQueue() {
|
|
200
|
+
const status = $('#cd-status').value;
|
|
201
|
+
const surface = $('#cd-surface').value;
|
|
202
|
+
const qs = new URLSearchParams({ status });
|
|
203
|
+
if (surface) qs.set('surface', surface);
|
|
204
|
+
|
|
205
|
+
const res = await api.request('GET', `${API}/copy-desk/queue?${qs}`);
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
const code = res.data && res.data.error && res.data.error.code;
|
|
208
|
+
const detail = (res.data && res.data.error && res.data.error.details) || {};
|
|
209
|
+
$('#cd-sub').textContent = code === 'registry_missing'
|
|
210
|
+
? `No copy registry on this instance yet. Run ${detail.run || 'node scripts/gds/copy-inventory.js'} and commit it.`
|
|
211
|
+
: 'Could not load the copy desk.';
|
|
212
|
+
$('#cd-flags').innerHTML = '<div class="cd-empty">—</div>';
|
|
213
|
+
$('#cd-dupes').innerHTML = '<div class="cd-empty">—</div>';
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
queue = res.data;
|
|
217
|
+
if (!$('#cd-surface').value && $('#cd-surface').options.length <= 1) paintSurfaces();
|
|
218
|
+
$('#cd-sub').textContent = 'The words this project shows people — what has been flagged, and what nobody has looked at.';
|
|
219
|
+
paintStats();
|
|
220
|
+
paintFlags();
|
|
221
|
+
paintDupes();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let searchTimer = null;
|
|
225
|
+
async function runSearch() {
|
|
226
|
+
const q = $('#cd-search').value.trim();
|
|
227
|
+
const surface = $('#cd-surface').value;
|
|
228
|
+
if (!q) { $('#cd-results').innerHTML = ''; $('#cd-search-count').textContent = ''; return; }
|
|
229
|
+
|
|
230
|
+
const qs = new URLSearchParams({ q });
|
|
231
|
+
if (surface) qs.set('surface', surface);
|
|
232
|
+
const res = await api.request('GET', `${API}/copy-desk/strings?${qs}`);
|
|
233
|
+
if (!res.ok) { $('#cd-search-count').textContent = 'Search failed.'; return; }
|
|
234
|
+
|
|
235
|
+
const { entries, matched, truncated, limit } = res.data;
|
|
236
|
+
// Say what is NOT shown. A silently capped list reads as a complete one.
|
|
237
|
+
$('#cd-search-count').textContent = truncated
|
|
238
|
+
? `${matched} matches — showing the first ${limit}. Narrow the search to see the rest.`
|
|
239
|
+
: `${matched} match${matched === 1 ? '' : 'es'}.`;
|
|
240
|
+
$('#cd-results').innerHTML = entries.map(resultRow).join('');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// the composer
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function openComposer(t) {
|
|
248
|
+
target = t;
|
|
249
|
+
$('#cd-dialog-target').innerHTML = t.string_id
|
|
250
|
+
? `${quote(t.text)}<br><span class="cd-where">${escapeHtml(t.surface)}</span>`
|
|
251
|
+
: `<span class="cd-tag">whole surface</span> <span class="cd-where">${escapeHtml(t.surface)}</span>`;
|
|
252
|
+
$('#cd-reason').value = '';
|
|
253
|
+
$('#cd-dialog-err').textContent = '';
|
|
254
|
+
$('#cd-dialog').showModal();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function submitFlag() {
|
|
258
|
+
const reason = $('#cd-reason').value.trim();
|
|
259
|
+
// Mirror the server's floor locally so the common case answers instantly —
|
|
260
|
+
// but the server and the DB constraint are the real gates, not this.
|
|
261
|
+
if (reason.replace(/\s+/g, ' ').length < 10) {
|
|
262
|
+
$('#cd-dialog-err').textContent = 'A flag needs a reason an artist can act on. What is wrong with it, and what would better look like?';
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const body = {
|
|
266
|
+
scope: target.string_id ? 'string' : 'surface',
|
|
267
|
+
surface: target.surface,
|
|
268
|
+
reason,
|
|
269
|
+
kind: $('#cd-kind').value,
|
|
270
|
+
};
|
|
271
|
+
if (target.string_id) body.string_id = target.string_id;
|
|
272
|
+
|
|
273
|
+
const res = await api.request('POST', `${API}/copy-desk/flags`, { body });
|
|
274
|
+
if (!res.ok) {
|
|
275
|
+
const err = (res.data && res.data.error) || {};
|
|
276
|
+
const d = err.details || {};
|
|
277
|
+
$('#cd-dialog-err').textContent = d.what_this_means || d.why || err.message || 'Could not file the flag.';
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
$('#cd-dialog').close();
|
|
281
|
+
toast('Flagged. It is in the queue.');
|
|
282
|
+
await loadQueue();
|
|
283
|
+
if ($('#cd-search').value.trim()) await runSearch();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function openCloser(id) {
|
|
287
|
+
closing = queue.flags.find((f) => String(f.id) === String(id));
|
|
288
|
+
if (!closing) return;
|
|
289
|
+
$('#cd-close-target').innerHTML = `${quote(closing.reason, 160)}`;
|
|
290
|
+
$('#cd-close-note').value = '';
|
|
291
|
+
$('#cd-close-task').value = '';
|
|
292
|
+
$('#cd-close-err').textContent = '';
|
|
293
|
+
$('#cd-close-dialog').showModal();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function submitClose() {
|
|
297
|
+
const note = $('#cd-close-note').value.trim();
|
|
298
|
+
if (note.replace(/\s+/g, ' ').length < 10) {
|
|
299
|
+
$('#cd-close-err').textContent = 'Closing is a judgement too — say what you decided, so the next person does not re-litigate it.';
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const body = { status: $('#cd-close-status').value, resolution_note: note };
|
|
303
|
+
const taskId = $('#cd-close-task').value.trim();
|
|
304
|
+
if (taskId) body.resolved_task_id = Number(taskId);
|
|
305
|
+
|
|
306
|
+
const res = await api.request('POST', `${API}/copy-desk/flags/${encodeURIComponent(closing.id)}/close`, { body });
|
|
307
|
+
if (!res.ok) {
|
|
308
|
+
const err = (res.data && res.data.error) || {};
|
|
309
|
+
const d = err.details || {};
|
|
310
|
+
$('#cd-close-err').textContent = d.what_this_means || d.why || err.message || 'Could not close the flag.';
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
$('#cd-close-dialog').close();
|
|
314
|
+
toast('Closed.');
|
|
315
|
+
await loadQueue();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// wire
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
document.addEventListener('click', (ev) => {
|
|
323
|
+
const flagBtn = ev.target.closest('.cd-flag-btn');
|
|
324
|
+
if (flagBtn) {
|
|
325
|
+
openComposer({
|
|
326
|
+
string_id: flagBtn.dataset.id,
|
|
327
|
+
surface: flagBtn.dataset.surface,
|
|
328
|
+
text: flagBtn.dataset.text,
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const closeBtn = ev.target.closest('.cd-close-btn');
|
|
333
|
+
if (closeBtn) openCloser(closeBtn.dataset.flag);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
$('#cd-cancel').addEventListener('click', () => $('#cd-dialog').close());
|
|
337
|
+
$('#cd-submit').addEventListener('click', submitFlag);
|
|
338
|
+
$('#cd-close-cancel').addEventListener('click', () => $('#cd-close-dialog').close());
|
|
339
|
+
$('#cd-close-submit').addEventListener('click', submitClose);
|
|
340
|
+
$('#cd-status').addEventListener('change', loadQueue);
|
|
341
|
+
$('#cd-surface').addEventListener('change', () => { loadQueue(); if ($('#cd-search').value.trim()) runSearch(); });
|
|
342
|
+
$('#cd-search').addEventListener('input', () => {
|
|
343
|
+
clearTimeout(searchTimer);
|
|
344
|
+
searchTimer = setTimeout(runSearch, 200);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
(async () => {
|
|
348
|
+
const me = await api.request('GET', `${API}/me`);
|
|
349
|
+
if (me.ok) viewer = me.data.builder || me.data;
|
|
350
|
+
await loadQueue();
|
|
351
|
+
})();
|
|
352
|
+
})();
|
|
@@ -122,6 +122,15 @@
|
|
|
122
122
|
// active-nav detection (#nav-work), the 'work' gate case, and the server
|
|
123
123
|
// page-gate all keep working — only the visible label and order change.
|
|
124
124
|
{ id: 'work', label: 'Tasks', icon: 'work', href: '/work', gate: 'signed' },
|
|
125
|
+
// task 1003113 (R02 of goal 1000074): the artist's queue over the words the
|
|
126
|
+
// product shows people. gate 'signed' and NOT a craft gate — filing a flag
|
|
127
|
+
// is open to every role on purpose (the engineer who wrote the toast and
|
|
128
|
+
// the newcomer who could not read the button are the two people most
|
|
129
|
+
// likely to notice bad copy, and neither is the artist), and an artist is a
|
|
130
|
+
// craft rather than a rank, so hiding this behind one would hide the
|
|
131
|
+
// artist's own queue from the artist. The module gate drops the tab on an
|
|
132
|
+
// instance that disables copy-desk.
|
|
133
|
+
{ id: 'copy-desk', label: 'Copy desk', icon: 'docs', href: '/copy-desk', gate: 'signed', module: 'copy-desk' },
|
|
125
134
|
] },
|
|
126
135
|
// Your own numbers. task 2174 folded Achievements into Standing and this task
|
|
127
136
|
// retires its nav entry; VIEW_ALIASES still routes #achievements here, so old
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.251",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.251",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.251",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|