@bongos/core 1.19.459 → 1.19.461

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.
Files changed (34) hide show
  1. package/.bongos-core.json +65 -35
  2. package/.claude/skills/design/SKILL.md +10 -3
  3. package/docs/adr/0220-an-in-house-rebuilt-skill-is-a-first-party-skill.md +46 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/copy-inventory.md +52 -61
  6. package/docs/copy-registry.json +92 -203
  7. package/docs/file-map.md +1 -1
  8. package/docs/module-api-changelog.md +4 -0
  9. package/modules/hall-ui/public/collab-lib.js +40 -4
  10. package/modules/hall-ui/public/collab.css +18 -34
  11. package/modules/hall-ui/public/collab.html +17 -17
  12. package/modules/hall-ui/public/collab.js +75 -39
  13. package/modules/hall-ui/public/collab.states.json +2 -2
  14. package/modules/hall-ui/public/copy-desk.css +64 -109
  15. package/modules/hall-ui/public/copy-desk.html +76 -69
  16. package/modules/hall-ui/public/copy-desk.js +175 -80
  17. package/modules/hall-ui/public/copy-desk.states.json +59 -2
  18. package/modules/hall-ui/public/oversight.css +11 -2
  19. package/modules/lifecycle/help-requests.js +18 -8
  20. package/modules/ui-design/module.json +1 -1
  21. package/modules/ui-design/skills/PREAMBLE.md +3 -3
  22. package/modules/ui-design/skills/README.md +13 -0
  23. package/modules/ui-design/skills/design-taste-frontend/SKILL.md +136 -0
  24. package/modules/ui-design/skills/design-taste-frontend-v1/SKILL.md +125 -0
  25. package/modules/ui-design/skills/high-end-visual-design/SKILL.md +128 -0
  26. package/modules/ui-design/skills/redesign-existing-projects/SKILL.md +120 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/docs-entropy.js +5 -1
  30. package/src/module-api.js +1 -1
  31. package/tests/collab_page.mjs +13 -7
  32. package/tests/docs_entropy.mjs +29 -1
  33. package/tests/hall_collab_world.mjs +214 -0
  34. package/tests/ui_design_skills.mjs +99 -9
@@ -9,6 +9,7 @@
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <link rel="stylesheet" href="/builders/oversight.css">
12
13
  <link rel="stylesheet" href="/builders/copy-desk.css">
13
14
  </head>
14
15
  <body data-page="copy-desk" data-page-title="Copy desk">
@@ -19,42 +20,33 @@
19
20
  <div class="app-body">
20
21
  <header class="app-topbar" id="app-topbar"></header>
21
22
  <div class="app-content">
22
- <!-- task 1003307: the board page head. Copy desk shipped with NO <h1> at all
23
- (one-header-main-h1 measured 0 on every render), so the re-skin
24
- carries the structural fix rather than porting the hole. The heading
25
- is the LABEL voice, not a display line: on a working surface the
26
- page's own name is the least interesting thing on it. -->
23
+ <!-- The board page head: the page's own name in the LABEL voice, not a
24
+ display line — on a working surface it is the least interesting
25
+ thing on it. -->
27
26
  <div class="page-head page-head--board">
28
27
  <h1 class="page-head__h">Copy desk</h1>
29
28
  <p class="page-head__sub" id="cd-sub">Loading the copy desk…</p>
30
29
  </div>
31
30
 
32
31
  <main id="hall-main">
33
- <!-- The counts. Deliberately four numbers and not a dashboard: open work,
34
- the fast pile (targets that have since been edited away), untouched
35
- duplicates, and how big the inventory is. -->
36
- <section class="scroll" id="cd-summary-scroll" aria-labelledby="cd-summary-h">
37
- <h2 id="cd-summary-h" class="scroll__h">Where the words stand</h2>
38
- <div class="cd-stats" id="cd-stats"></div>
39
- <p class="cd-note" id="cd-registry-note"></p>
40
- </section>
32
+ <!-- The counts: the shell's glass band of figures at the top of the page,
33
+ not a panel of boxes. Deliberately four numbers and not a dashboard:
34
+ open work, the fast pile (targets that have since been edited away),
35
+ untouched duplicates, and how big the inventory is — each with the
36
+ clause that says what it means. The provenance line under it names
37
+ the artifact this whole page describes. -->
38
+ <section class="stat-row ov-summary cd-summary" id="cd-summary-scroll" aria-label="Where the words stand"></section>
39
+ <p class="ov-foot cd-registry-note" id="cd-registry-note"></p>
41
40
 
42
- <!-- The queue. -->
41
+ <!-- The queue. Every flag is a row of the hall's row archetype: the
42
+ string as the title (the product's own words, as evidence), the
43
+ reason as the prose under it, the kind and the target's state as
44
+ pills, who flagged it as a fact, when in the right column. -->
43
45
  <section class="scroll" id="cd-flags-scroll" aria-labelledby="cd-flags-h">
44
46
  <h2 id="cd-flags-h" class="scroll__h">Flagged</h2>
45
- <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>
46
- <div class="cd-filters">
47
- <label class="cd-filters__label" for="cd-status">Show</label>
48
- <select id="cd-status" class="cd-filters__select">
49
- <option value="open" selected>Open</option>
50
- <option value="resolved">Resolved</option>
51
- <option value="dismissed">Dismissed</option>
52
- <option value="all">Everything</option>
53
- </select>
54
- <label class="cd-filters__label" for="cd-surface">Surface</label>
55
- <select id="cd-surface" class="cd-filters__select"><option value="">All surfaces</option></select>
56
- </div>
57
- <div class="cd-list" id="cd-flags"><div class="cd-empty">Loading…</div></div>
47
+ <p class="scroll__lede">Copy someone has judged as needing attention, with their reason. Anyone can file one; closing it needs the <span id="cd-close-rank">Metic</span> rank or above, or that it is your own.</p>
48
+ <div id="cd-filters"></div>
49
+ <div id="cd-flags"><div class="empty-state"><p>Loading…</p></div></div>
58
50
  </section>
59
51
 
60
52
  <!-- The one signal a machine can compute with no judgement layer at all
@@ -63,7 +55,7 @@
63
55
  <section class="scroll" id="cd-dupes-scroll" aria-labelledby="cd-dupes-h">
64
56
  <h2 id="cd-dupes-h" class="scroll__h">Said more than once</h2>
65
57
  <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>
66
- <div class="cd-list" id="cd-dupes"><div class="cd-empty">Loading…</div></div>
58
+ <div id="cd-dupes"><div class="empty-state"><p>Loading…</p></div></div>
67
59
  </section>
68
60
 
69
61
  <!-- Find any string in the product and flag it. Without this the queue
@@ -71,13 +63,9 @@
71
63
  <section class="scroll" id="cd-find-scroll" aria-labelledby="cd-find-h">
72
64
  <h2 id="cd-find-h" class="scroll__h">Find something to flag</h2>
73
65
  <p class="scroll__lede">Search every string the product shows a person, by what it says or the file it lives in.</p>
74
- <div class="cd-browse">
75
- <label class="cd-browse__label" for="cd-search">Search the copy</label>
76
- <input type="search" id="cd-search" class="cd-browse__input" autocomplete="off"
77
- placeholder="a phrase you saw, or a file name…">
78
- <p class="cd-note" id="cd-search-count" role="status" aria-live="polite"></p>
79
- </div>
80
- <div class="cd-list" id="cd-results"></div>
66
+ <div class="cd-find" id="cd-find"></div>
67
+ <p class="ov-foot cd-search-count" id="cd-search-count" role="status" aria-live="polite"></p>
68
+ <div id="cd-results"></div>
81
69
  </section>
82
70
  </main>
83
71
 
@@ -88,50 +76,68 @@
88
76
 
89
77
  <!-- The flag composer. A dialog rather than an inline field because the reason is
90
78
  the point: a one-line input invites "typo", and the queue is only worth
91
- reading if the reasons are. -->
92
- <dialog id="cd-dialog" class="cd-dialog">
79
+ reading if the reasons are. A native <dialog> (its own focus trap and
80
+ Escape) dressed as the family's confirm modal. -->
81
+ <dialog id="cd-dialog" class="ov-modal cd-dialog">
93
82
  <form method="dialog" id="cd-form">
94
- <h3 class="cd-dialog__h" id="cd-dialog-h">Flag this copy</h3>
83
+ <h3 class="scroll__h" id="cd-dialog-h">Flag this copy</h3>
95
84
  <p class="cd-dialog__target" id="cd-dialog-target"></p>
96
- <label class="cd-dialog__label" for="cd-kind">What kind of wrong?</label>
97
- <select id="cd-kind" class="cd-dialog__select">
98
- <option value="off-voice">Off voice — says it in the wrong register</option>
99
- <option value="slop">Slop — reads as machine-written</option>
100
- <option value="unclear">Unclear — a person would not know what to do</option>
101
- <option value="stale">Stale — describes something that changed</option>
102
- <option value="duplicate">Duplicate — should be said once, in one place</option>
103
- <option value="typo">Typo or grammar</option>
104
- <option value="other" selected>Something else</option>
105
- </select>
106
- <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>
107
- <textarea id="cd-reason" class="cd-dialog__text" rows="4" minlength="10" required
108
- placeholder="What is wrong with it, and what would better look like?"></textarea>
85
+ <div class="ov-form">
86
+ <div class="ov-field ov-field--wide">
87
+ <label class="ov-field__l" for="cd-kind">What kind of wrong?</label>
88
+ <select id="cd-kind">
89
+ <option value="off-voice">Off voice — says it in the wrong register</option>
90
+ <option value="slop">Slop — reads as machine-written</option>
91
+ <option value="unclear">Unclear — a person would not know what to do</option>
92
+ <option value="stale">Stale — describes something that changed</option>
93
+ <option value="duplicate">Duplicate — should be said once, in one place</option>
94
+ <option value="typo">Typo or grammar</option>
95
+ <option value="other" selected>Something else</option>
96
+ </select>
97
+ </div>
98
+ <div class="ov-field ov-field--wide">
99
+ <label class="ov-field__l" for="cd-reason">Why?</label>
100
+ <textarea id="cd-reason" rows="4" minlength="10" required
101
+ placeholder="What is wrong with it, and what would better look like?"></textarea>
102
+ </div>
103
+ <p class="ov-form__why">The artist reads this instead of guessing. A pointer with no judgement costs them a look and tells them nothing.</p>
104
+ </div>
109
105
  <p class="cd-dialog__err" id="cd-dialog-err" role="alert"></p>
110
- <div class="cd-dialog__actions">
111
- <button type="button" class="btn btn--ghost" id="cd-cancel">Cancel</button>
112
- <button type="button" class="btn" id="cd-submit">File the flag</button>
106
+ <div class="ov-modal__actions">
107
+ <button type="button" class="btn-ghost" id="cd-cancel">Cancel</button>
108
+ <button type="button" class="btn-accent" id="cd-submit">File the flag</button>
113
109
  </div>
114
110
  </form>
115
111
  </dialog>
116
112
 
117
113
  <!-- Closing a flag asks for a note for the same reason opening one does. -->
118
- <dialog id="cd-close-dialog" class="cd-dialog">
114
+ <dialog id="cd-close-dialog" class="ov-modal cd-dialog">
119
115
  <form method="dialog" id="cd-close-form">
120
- <h3 class="cd-dialog__h">Close this flag</h3>
116
+ <h3 class="scroll__h">Close this flag</h3>
121
117
  <p class="cd-dialog__target" id="cd-close-target"></p>
122
- <label class="cd-dialog__label" for="cd-close-status">Outcome</label>
123
- <select id="cd-close-status" class="cd-dialog__select">
124
- <option value="resolved" selected>Resolved — the copy was changed</option>
125
- <option value="dismissed">Dismissed — looked at, leaving it as it is</option>
126
- </select>
127
- <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>
128
- <input type="number" id="cd-close-task" class="cd-dialog__input" placeholder="e.g. 1003113" min="1">
129
- <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>
130
- <textarea id="cd-close-note" class="cd-dialog__text" rows="3" minlength="10" required></textarea>
118
+ <div class="ov-form">
119
+ <div class="ov-field ov-field--wide">
120
+ <label class="ov-field__l" for="cd-close-status">Outcome</label>
121
+ <select id="cd-close-status">
122
+ <option value="resolved" selected>Resolved — the copy was changed</option>
123
+ <option value="dismissed">Dismissed — looked at, leaving it as it is</option>
124
+ </select>
125
+ </div>
126
+ <div class="ov-field ov-field--wide">
127
+ <label class="ov-field__l" for="cd-close-task">Task that carried the fix</label>
128
+ <input type="number" id="cd-close-task" placeholder="e.g. 1003113" min="1">
129
+ </div>
130
+ <p class="ov-form__why">Optional. This is the ledger link — a copy change ships as a task like every other change.</p>
131
+ <div class="ov-field ov-field--wide">
132
+ <label class="ov-field__l" for="cd-close-note">Note</label>
133
+ <textarea id="cd-close-note" rows="3" minlength="10" required></textarea>
134
+ </div>
135
+ <p class="ov-form__why">The next person to notice this sentence reads your note instead of re-litigating it.</p>
136
+ </div>
131
137
  <p class="cd-dialog__err" id="cd-close-err" role="alert"></p>
132
- <div class="cd-dialog__actions">
133
- <button type="button" class="btn btn--ghost" id="cd-close-cancel">Cancel</button>
134
- <button type="button" class="btn" id="cd-close-submit">Close it</button>
138
+ <div class="ov-modal__actions">
139
+ <button type="button" class="btn-ghost" id="cd-close-cancel">Cancel</button>
140
+ <button type="button" class="btn-accent" id="cd-close-submit">Close it</button>
135
141
  </div>
136
142
  </form>
137
143
  </dialog>
@@ -141,6 +147,7 @@
141
147
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
142
148
  <script src="/builders/palette.js" defer></script>
143
149
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
144
- <script src="/builders/copy-desk.js?v=2026-08-18-r02"></script>
150
+ <script src="/builders/hall-kit.js"></script>
151
+ <script src="/builders/copy-desk.js?v=2026-09-01-redesign"></script>
145
152
  </body>
146
153
  </html>
@@ -1,18 +1,23 @@
1
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.
2
+ // (/builders/copy-desk). Task 1003113 / R02 of goal 1000074, the artist's loop;
3
+ // rebuilt on the hall's row archetype by task 1003471 (goal 1000081).
3
4
  //
4
5
  // The surface half of the flag: R01 built the inventory of every string the
5
6
  // product shows a person; the copy-desk module turned "this one is wrong" into a
6
7
  // row; this page is where an artist reads those rows and where everyone else
7
8
  // files them.
8
9
  //
9
- // FOUR PANELS, IN THE ORDER AN ARTIST WOULD WORK:
10
- // 1. the counts — what is outstanding, and the fast pile
10
+ // FOUR PARTS, IN THE ORDER AN ARTIST WOULD WORK:
11
+ // 1. the counts — the glass figure strip: what is outstanding, and the fast pile
11
12
  // 2. Flagged — the queue itself, newest first, open by default
12
13
  // 3. Said more than once — the deterministic duplicate signal, minus what has
13
- // already been flagged or dismissed
14
+ // already been flagged or dismissed, paged through the kit
14
15
  // 4. Find something to flag — search the whole inventory
15
16
  //
17
+ // Every list is a run of the family's .ov-row (oversight.css): the string as
18
+ // the title, the judgement as prose, the kind and the target's state as pills.
19
+ // State is COLOUR from the verification vocabulary, never opacity.
20
+ //
16
21
  // NO LIVE CMS. Nothing on this page edits a string. The composer collects a
17
22
  // JUDGEMENT (kind + reason); the fix ships as a claimed, graded task. There is
18
23
  // deliberately no "new text" field — see modules/copy-desk/tests/copy_no_cms.mjs.
@@ -23,17 +28,21 @@
23
28
  const API = '/api/bongos';
24
29
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
25
30
  const { escapeHtml, $, toast, fmtInt } = window.OTB;
31
+ const kit = window.OTBKit;
26
32
 
27
33
  let viewer = null;
28
34
  let queue = null;
29
35
  // The composer's current target, set when a "Flag" button is pressed.
30
36
  let target = null;
31
37
  let closing = null;
38
+ let dupePager = null;
32
39
 
33
40
  // ---------------------------------------------------------------------------
34
41
  // rendering helpers
35
42
  // ---------------------------------------------------------------------------
36
43
 
44
+ const int = (n) => String(fmtInt ? fmtInt(n) : n);
45
+
37
46
  // A string shown as evidence is UNTRUSTED text from the product's own source
38
47
  // tree, rendered into this page — so it is escaped like any other user data,
39
48
  // and clipped so one runaway template literal cannot push the queue off screen.
@@ -45,7 +54,17 @@
45
54
 
46
55
  function where(file, line) {
47
56
  if (!file) return '';
48
- return `<span class="cd-where">${escapeHtml(file)}${Number.isInteger(line) ? `:${line}` : ''}</span>`;
57
+ return `<span class="ov-key ov-key--faint">${escapeHtml(file)}${Number.isInteger(line) ? `:${line}` : ''}</span>`;
58
+ }
59
+
60
+ function pill(mod, text, title) {
61
+ return `<span class="fact-pill${mod ? ` ${mod}` : ''}"${title ? ` title="${escapeHtml(title)}"` : ''}>${escapeHtml(text)}</span>`;
62
+ }
63
+
64
+ function when(iso) {
65
+ const d = iso ? new Date(iso) : null;
66
+ if (!d || Number.isNaN(d.getTime())) return '';
67
+ return `<span class="ov-row__when">${escapeHtml(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }))}</span>`;
49
68
  }
50
69
 
51
70
  const KIND_LABEL = {
@@ -60,10 +79,10 @@
60
79
  // never something the server closes on its own.
61
80
  function targetState(f) {
62
81
  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>';
82
+ return pill('fact-pill--warn', 'target gone', 'The string this flag names is no longer in the registry — it was edited or removed. Probably already fixed; confirm and close it.');
64
83
  }
65
84
  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>';
85
+ return pill('', 'moved', 'Same text, different place — it moved file or line since the flag was filed.');
67
86
  }
68
87
  return '';
69
88
  }
@@ -74,68 +93,97 @@
74
93
  return viewer.rank === 'metic' || viewer.rank === 'archon';
75
94
  }
76
95
 
96
+ // The outcome of a closed flag: the status as a pill, the task that carried
97
+ // the fix as a chip, the note as a line — every fact the closer wrote.
98
+ function closedFacts(f) {
99
+ if (f.status === 'open') return '';
100
+ const task = f.resolved_task_id
101
+ ? `<a class="fact-chip" href="/builders/task/${escapeHtml(String(f.resolved_task_id))}">task #${escapeHtml(String(f.resolved_task_id))}</a>`
102
+ : '';
103
+ return pill(f.status === 'resolved' ? 'fact-pill--ok' : 'fact-pill--quiet', f.status) + task;
104
+ }
105
+
77
106
  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() : '';
107
+ const who = f.flagged_by_name || f.flagged_by_login || 'someone';
80
108
  // Show the live text when the string is still there, and the snapshot when it
81
109
  // 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>`
110
+ // said when someone objected". A whole-surface flag names the surface.
111
+ let title;
112
+ let place;
113
+ if (f.scope === 'surface') {
114
+ title = `<span class="ov-key">${escapeHtml(f.surface)}</span>`;
115
+ place = pill('', 'whole surface');
116
+ } else if (f.current) {
117
+ title = quote(f.current.text);
118
+ place = where(f.current.file, f.current.line);
119
+ } else {
120
+ title = `<span class="cd-snapshot-label">said, when flagged:</span> ${quote(f.text_at_flag)}`;
121
+ place = where(f.file_at_flag, f.line_at_flag);
122
+ }
123
+ const note = f.status !== 'open' && f.resolution_note
124
+ ? `<p class="cd-closed"><b>${escapeHtml(f.status)}</b> — ${escapeHtml(f.resolution_note)}</p>`
125
+ : '';
126
+ const actions = f.status === 'open' && canClose(f)
127
+ ? `<div class="ov-row__actions"><button class="btn-ghost cd-close-btn" data-flag="${escapeHtml(String(f.id))}">Close…</button></div>`
93
128
  : '';
94
129
 
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>
130
+ return `<li class="ov-row" data-flag="${escapeHtml(String(f.id))}">
131
+ <div class="ov-row__main">
132
+ <span class="ov-row__title">${title}</span>
133
+ <p class="ov-row__sub cd-reason">${escapeHtml(f.reason)}</p>
134
+ <div class="ov-row__facts">
135
+ ${pill('', KIND_LABEL[f.kind] || f.kind)}${targetState(f)}${place}
136
+ <span class="ov-fact">flagged by <b>${escapeHtml(who)}</b></span>
137
+ ${closedFacts(f)}
138
+ </div>
139
+ ${note}
100
140
  </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>`;
141
+ <div class="ov-row__right">${when(f.flagged_at)}${actions}</div>
142
+ </li>`;
143
+ }
144
+
145
+ function flagButton(d) {
146
+ return `<div class="ov-row__actions"><button class="btn-ghost cd-flag-btn"
147
+ data-id="${escapeHtml(d.id)}" data-surface="${escapeHtml(d.surface)}" data-text="${escapeHtml(d.text)}">Flag this…</button></div>`;
148
+ }
149
+
150
+ // The duplicate's state. Colour marks the EXCEPTION here, the inverse of the
151
+ // queue: most of this list is untouched work, so the untouched row is the
152
+ // plain pill and colour goes to the few already in the queue or looked at.
153
+ function dupeState(d) {
154
+ if (d.flagged) return pill('fact-pill--open', 'already flagged');
155
+ if (d.settled) return pill('fact-pill--ok', 'looked at');
156
+ return pill('', 'untouched');
108
157
  }
109
158
 
110
159
  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
160
  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>
161
+ return `<li class="ov-row">
162
+ <div class="ov-row__main">
163
+ <span class="ov-row__title">${quote(d.text)}</span>
164
+ <div class="ov-row__facts">
165
+ ${dupeState(d)}
166
+ <span class="ov-fact"><b>${escapeHtml(String(d.occurrences))}</b> places</span>
167
+ <span class="fact-chip">${escapeHtml(d.surface)}</span>
168
+ </div>
169
+ <ul class="cd-places">${places}</ul>
121
170
  </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>`;
171
+ <div class="ov-row__right">${d.flagged ? '' : flagButton(d)}</div>
172
+ </li>`;
127
173
  }
128
174
 
129
175
  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>`}
176
+ return `<li class="ov-row ov-row--dense">
177
+ <div class="ov-row__main">
178
+ <span class="ov-row__title">${quote(e.text)}</span>
179
+ <div class="ov-row__facts">
180
+ ${where(e.file, e.line)}
181
+ <span class="fact-chip">${escapeHtml(e.surface)}</span>
182
+ ${e.origin ? `<span class="ov-fact">${escapeHtml(e.origin)}</span>` : ''}
183
+ </div>
137
184
  </div>
138
- </article>`;
185
+ <div class="ov-row__right">${e.flagged ? pill('fact-pill--open', 'already flagged') : flagButton(e)}</div>
186
+ </li>`;
139
187
  }
140
188
 
141
189
  // ---------------------------------------------------------------------------
@@ -146,14 +194,18 @@
146
194
  const c = queue.counts;
147
195
  const r = queue.registry || {};
148
196
  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('');
197
+ const surfaces = Object.keys(r.surfaces || {}).length;
198
+ const tile = (label, n, sub) => `<div class="stat-tile">
199
+ <span class="stat-tile__label">${escapeHtml(label)}</span>
200
+ <span class="stat-tile__num">${escapeHtml(int(n))}</span>
201
+ <span class="stat-tile__sub">${escapeHtml(sub)}</span>
202
+ </div>`;
203
+ $('#cd-summary-scroll').innerHTML = [
204
+ tile('Open flags', c.flags_open, c.flags_shown === c.flags_open ? 'in the queue below' : `${int(c.flags_shown)} shown below`),
205
+ tile('Target since edited', c.flags_target_gone, 'probably already fixed; confirm and close'),
206
+ tile('Duplicates untouched', c.duplicates_untouched, `of ${int(c.duplicates_total)} said more than once`),
207
+ tile('Strings inventoried', total, `across ${int(surfaces)} surface${surfaces === 1 ? '' : 's'}`),
208
+ ].join('');
157
209
 
158
210
  // Say which artifact this page is describing, and how to check it is current.
159
211
  // The registry has no timestamp by design (its generator is deterministic and
@@ -166,23 +218,43 @@
166
218
  + (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
219
  }
168
220
 
221
+ // The queue, as one run when every flag is in the same state (the default,
222
+ // open) and as a group per state when the filter shows everything.
169
223
  function paintFlags() {
170
224
  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>';
225
+ const rows = queue.flags;
226
+ if (!rows.length) {
227
+ el.innerHTML = kit.emptyStateHtml('Nothing flagged here. That is either good news or an unread queue — the duplicates below are the cheapest place to find out.');
173
228
  return;
174
229
  }
175
- el.innerHTML = queue.flags.map(flagRow).join('');
230
+ const states = ['open', 'resolved', 'dismissed'].filter((s) => rows.some((f) => f.status === s));
231
+ if (states.length < 2) { el.innerHTML = `<ul class="ov-rows">${rows.map(flagRow).join('')}</ul>`; return; }
232
+ el.innerHTML = states.map((s) => {
233
+ const g = rows.filter((f) => f.status === s);
234
+ return `<section class="ov-group">
235
+ <div class="ov-group__head"><span class="ov-group__name">${escapeHtml(s)}</span><span class="ov-group__n">${g.length}</span></div>
236
+ <ul class="ov-rows">${g.map(flagRow).join('')}</ul>
237
+ </section>`;
238
+ }).join('');
176
239
  }
177
240
 
241
+ // Untouched first — it is the only part of this list that is work — and paged
242
+ // through the kit, because the registry carries over a hundred of these.
178
243
  function paintDupes() {
179
244
  const el = $('#cd-dupes');
180
245
  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.
246
+ if (!rows.length) { el.innerHTML = kit.emptyStateHtml('No duplicated strings in the registry.'); return; }
183
247
  const ordered = [...rows].sort((a, b) =>
184
248
  (Number(a.flagged || a.settled) - Number(b.flagged || b.settled)) || (b.occurrences - a.occurrences));
185
- el.innerHTML = ordered.map(dupeRow).join('');
249
+ if (!dupePager) dupePager = kit.makePager('cd-dupes', 'copy-desk.dupes.perPage', { defaultPerPage: 25 });
250
+ let list = el.querySelector('.ov-rows');
251
+ const rerender = () => { list.innerHTML = kit.paginate(ordered, dupePager).map(dupeRow).join(''); };
252
+ if (!list) {
253
+ el.innerHTML = `<ul class="ov-rows"></ul>${kit.pagerBarHtml('cd-dupes', { label: 'rows' })}`;
254
+ list = el.querySelector('.ov-rows');
255
+ kit.wirePager(dupePager, rerender);
256
+ }
257
+ rerender();
186
258
  }
187
259
 
188
260
  function paintSurfaces() {
@@ -209,8 +281,8 @@
209
281
  $('#cd-sub').textContent = code === 'registry_missing'
210
282
  ? `No copy registry on this instance yet. Run ${detail.run || 'node scripts/gds/copy-inventory.js'} and commit it.`
211
283
  : 'Could not load the copy desk.';
212
- $('#cd-flags').innerHTML = '<div class="cd-empty">—</div>';
213
- $('#cd-dupes').innerHTML = '<div class="cd-empty">—</div>';
284
+ $('#cd-flags').innerHTML = kit.emptyStateHtml('—');
285
+ $('#cd-dupes').innerHTML = kit.emptyStateHtml('—');
214
286
  return;
215
287
  }
216
288
  queue = res.data;
@@ -237,7 +309,7 @@
237
309
  $('#cd-search-count').textContent = truncated
238
310
  ? `${matched} matches — showing the first ${limit}. Narrow the search to see the rest.`
239
311
  : `${matched} match${matched === 1 ? '' : 'es'}.`;
240
- $('#cd-results').innerHTML = entries.map(resultRow).join('');
312
+ $('#cd-results').innerHTML = `<ul class="ov-rows">${entries.map(resultRow).join('')}</ul>`;
241
313
  }
242
314
 
243
315
  // ---------------------------------------------------------------------------
@@ -247,8 +319,8 @@
247
319
  function openComposer(t) {
248
320
  target = t;
249
321
  $('#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>`;
322
+ ? `${quote(t.text)}<br><span class="ov-key ov-key--faint">${escapeHtml(t.surface)}</span>`
323
+ : `${pill('', 'whole surface')} <span class="ov-key ov-key--faint">${escapeHtml(t.surface)}</span>`;
252
324
  $('#cd-reason').value = '';
253
325
  $('#cd-dialog-err').textContent = '';
254
326
  $('#cd-dialog').showModal();
@@ -286,7 +358,14 @@
286
358
  function openCloser(id) {
287
359
  closing = queue.flags.find((f) => String(f.id) === String(id));
288
360
  if (!closing) return;
289
- $('#cd-close-target').innerHTML = `${quote(closing.reason, 160)}`;
361
+ // What is being closed: the string (the product's words, as evidence) or
362
+ // the surface, then the reason as the prose it is — not the reason in the
363
+ // evidence voice, which is what the first cut quoted.
364
+ const about = closing.scope === 'surface'
365
+ ? `${pill('', 'whole surface')} <span class="ov-key ov-key--faint">${escapeHtml(closing.surface)}</span>`
366
+ : quote(closing.current ? closing.current.text : closing.text_at_flag, 120);
367
+ const reason = String(closing.reason || '');
368
+ $('#cd-close-target').innerHTML = `${about}<br>${escapeHtml(reason.length > 160 ? `${reason.slice(0, 160)}…` : reason)}`;
290
369
  $('#cd-close-note').value = '';
291
370
  $('#cd-close-task').value = '';
292
371
  $('#cd-close-err').textContent = '';
@@ -319,6 +398,25 @@
319
398
  // wire
320
399
  // ---------------------------------------------------------------------------
321
400
 
401
+ // The queue's filters and the search field are the kit's filter bar, so the
402
+ // controls are the shell's own; the ids the loaders read are kept.
403
+ kit.makeFilterBar({
404
+ mount: $('#cd-filters'),
405
+ primary: [
406
+ { id: 'cd-status', label: 'Show', options: [
407
+ { value: 'open', label: 'Open' }, { value: 'resolved', label: 'Resolved' },
408
+ { value: 'dismissed', label: 'Dismissed' }, { value: 'all', label: 'Everything' },
409
+ ] },
410
+ { id: 'cd-surface', label: 'Surface', options: [{ value: '', label: 'All surfaces' }] },
411
+ ],
412
+ onChange: () => { loadQueue(); if ($('#cd-search').value.trim()) runSearch(); },
413
+ });
414
+ kit.makeFilterBar({
415
+ mount: $('#cd-find'),
416
+ search: { id: 'cd-search', label: 'Search the copy', placeholder: 'a phrase you saw, or a file name…' },
417
+ onChange: () => { clearTimeout(searchTimer); searchTimer = setTimeout(runSearch, 200); },
418
+ });
419
+
322
420
  document.addEventListener('click', (ev) => {
323
421
  const flagBtn = ev.target.closest('.cd-flag-btn');
324
422
  if (flagBtn) {
@@ -337,14 +435,11 @@
337
435
  $('#cd-submit').addEventListener('click', submitFlag);
338
436
  $('#cd-close-cancel').addEventListener('click', () => $('#cd-close-dialog').close());
339
437
  $('#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
438
 
347
439
  (async () => {
440
+ // The closing floor is written from the pack's rank label, never a hardcoded
441
+ // Greek name (ADR 0157 moved the floor; the pack owns the word).
442
+ if (window.OTB.rankLabel && $('#cd-close-rank')) $('#cd-close-rank').textContent = window.OTB.rankLabel('metic');
348
443
  const me = await api.request('GET', `${API}/me`);
349
444
  if (me.ok) viewer = me.data.builder || me.data;
350
445
  await loadQueue();