@bongos/core 1.19.484 → 1.19.485

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.
@@ -0,0 +1,274 @@
1
+ // tests/hall_settings_world.mjs — the PANEL family in the chrome world
2
+ // (task 1003308, goal 1000081): settings, profile, pair, not-ready.
3
+ //
4
+ // tests/hall_tokens.mjs pins the TIER these pages spend; this pins how they
5
+ // spend it. Everything here is a fact that is silent in a browser: the page
6
+ // still renders, it just renders wrong, or it renders right on one machine and
7
+ // fails a floor on the reviewer's. Each assertion is something the redesign
8
+ // measured and would otherwise have to re-measure by hand every time.
9
+ //
10
+ // What it locks:
11
+ // * ONE ARCHETYPE SHEET. panel.css holds the row, the switch, the field, the
12
+ // chip control and the family's voices; the four page sheets hold only what
13
+ // one page has. Every page links panel.css BEFORE its own sheet.
14
+ // * THE ONE h1. None of the five had one — the hall predates the floor — so
15
+ // every render failed one-header-main-h1. The h1 is the page's name in the
16
+ // room voice, and it lives in the static markup so the DOM has it in every
17
+ // state, not only the happy path.
18
+ // * THE SWITCH IS A SWITCH. A toggle is role=switch with aria-checked, drawn
19
+ // as a 44x24 track inside a 46x38 target; the page scripts keep reading
20
+ // data-on, which they wrote. The wandering pick is a radio row, not four
21
+ // "Choose" buttons.
22
+ // * THE 24px FLOOR ON BOTH AXES. .craft-pref was 13x13 — the smallest control
23
+ // in the hall; .profile__disc-edit (123x19) and .profile__needs-act (18px)
24
+ // on the Standing card belonged to this family and are bought up with the
25
+ // prose-link recipe.
26
+ // * WHITE ON THE ACCENT, AND THE ONE EXCEPTION. Eight literals across three
27
+ // sheets are gone; the Discord blurple pair stays, because it is Discord's
28
+ // mark and not the world's, and it is named as the exception in a comment.
29
+ // * THE COMPAT ALIASES. Five sheets, zero aliases spent.
30
+ //
31
+ // Reproduce the renders these came from (the family needs the fixture harness —
32
+ // every panel is filled from /me/* reads the kit stub does not carry):
33
+ // node scripts/hall-preview/server.js --port 4621 --fixture-me
34
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/settings.states.json \
35
+ // --base-in http://builders.localhost:4621 --base-out http://builders.localhost:4621
36
+ // (and profile / pair / not-ready .states.json the same way)
37
+
38
+ import { test } from 'node:test';
39
+ import assert from 'node:assert/strict';
40
+ import fs from 'node:fs';
41
+ import path from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
45
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
46
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
47
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
48
+
49
+ const PAGES = ['settings', 'profile', 'pair', 'not-ready'];
50
+ const SHEETS = ['panel.css', 'settings.css', 'pair.css', 'profile.css'];
51
+
52
+ const panelCss = read('panel.css');
53
+ const settingsCss = read('settings.css');
54
+ const styleCss = read('style.css');
55
+ const settingsJs = read('settings.js');
56
+
57
+ // The body of the first rule whose selector LIST contains `selector` as one of
58
+ // its comma-separated members — the family sheet groups the page scripts' own
59
+ // names onto one rule, so a rule is found by membership, not by a bare prefix.
60
+ function ruleOf(css, selector) {
61
+ // an @media wrapper is unwrapped so the rules inside it scan as flat rules;
62
+ // the FIRST match wins, which is the unwrapped one for a selector that also
63
+ // has a breakpoint override
64
+ const bare = stripComments(css).replace(/@media[^{]*\{/g, '');
65
+ // a lookbehind, not a consuming group: a consumed `}` would hide every other rule
66
+ const re = /(?<=^|[}])\s*([^{}]+?)\s*\{([^}]*)\}/g;
67
+ let m;
68
+ while ((m = re.exec(bare))) {
69
+ const members = m[1].split(',').map((s) => s.trim());
70
+ if (members.includes(selector)) return m[2];
71
+ }
72
+ return null;
73
+ }
74
+
75
+ // ---- 1. one archetype sheet, linked before the page's own -------------------
76
+
77
+ test('every page in the family links panel.css before its own sheet', () => {
78
+ for (const p of PAGES) {
79
+ const html = read(`${p}.html`);
80
+ const links = [...html.matchAll(/<link rel="stylesheet" href="\/builders\/([a-z-]+\.css)[^"]*">/g)].map((x) => x[1]);
81
+ const at = links.indexOf('panel.css');
82
+ assert.ok(at > -1, `${p}.html links panel.css`);
83
+ assert.ok(links.indexOf('style.css') < at, `${p}.html: the shell first, then the archetype`);
84
+ const own = `${p}.css`;
85
+ if (links.includes(own)) assert.ok(links.indexOf(own) > at, `${p}.html: the page sheet after the archetype`);
86
+ }
87
+ assert.ok(read('profile.html').includes('/builders/profile.css'), 'the profile has a sheet of its own now');
88
+ });
89
+
90
+ test('the archetype holds the row, the switch, the field and the voices; the page sheets do not redefine them', () => {
91
+ for (const sel of ['.prow', '.sw', '.sw__track', '.sw__knob', '.field-label', '.pinput', '.chip-ctl', '.panel-note', '.panel-status', '.gate']) {
92
+ assert.ok(ruleOf(panelCss, sel), `panel.css defines ${sel}`);
93
+ }
94
+ for (const sheet of ['settings.css', 'pair.css', 'profile.css']) {
95
+ const css = read(sheet);
96
+ for (const sel of ['.prow', '.sw', '.sw__track', '.pinput', '.chip-ctl']) {
97
+ assert.equal(ruleOf(css, sel), null, `${sheet} must not redefine ${sel} — that is how the family would drift`);
98
+ }
99
+ }
100
+ });
101
+
102
+ // ---- 2. the one h1, in the static markup ----------------------------------
103
+
104
+ test('each of the five pages has exactly one h1, in the family\'s page head', () => {
105
+ for (const p of PAGES) {
106
+ const html = read(`${p}.html`).replace(/<!--[\s\S]*?-->/g, '');
107
+ const h1s = html.match(/<h1\b/g) || [];
108
+ assert.equal(h1s.length, 1, `${p}.html: one-header-main-h1 — the page had none until task 1003308`);
109
+ assert.match(html, /<div class="page-head page-head--room page-head--panel">\s*(<p class="page-head__eyebrow">[^<]*<\/p>\s*)?<h1 id="[a-z-]+-h1" class="page-head__h">/,
110
+ `${p}.html: the h1 is the page's name in the room voice, static so every state has it`);
111
+ }
112
+ assert.match(ruleOf(panelCss, '.page-head--room.page-head--panel') || '', /padding-top:\s*0/,
113
+ 'the family takes the room VOICE, not its top air — a form page starts at the content edge');
114
+ });
115
+
116
+ // ---- 3. the switch, the radio, the chip: the 24px floor on both axes -------
117
+
118
+ test('a toggle is a role=switch with aria-checked, and the page script keeps data-on', () => {
119
+ assert.match(settingsJs, /function switchHtml\(/, 'one switch builder');
120
+ const fn = settingsJs.match(/function switchHtml\([\s\S]*?\n \}\n/);
121
+ assert.ok(fn, 'switchHtml is a whole function');
122
+ assert.match(fn[0], /role="switch"/, 'role=switch');
123
+ assert.match(fn[0], /aria-checked="\$\{on \? 'true' : 'false'\}"/, 'state is aria-checked');
124
+ assert.match(fn[0], /data-on="\$\{on \? '1' : '0'\}"/, 'and data-on stays, because every delegated click handler reads it');
125
+ assert.match(fn[0], /sw__track[\s\S]*sw__knob/, 'drawn as a track and a knob');
126
+ assert.doesNotMatch(settingsJs, /aria-pressed/, 'no aria-pressed toggles are left on the page');
127
+ for (const fnName of ['renderSounds', 'renderRender', 'renderCrossProject', 'privacyRow']) {
128
+ const body = settingsJs.match(new RegExp(`function ${fnName}\\([\\s\\S]*?\\n \\}\\n`));
129
+ assert.ok(body && /switchHtml\(/.test(body[0]), `${fnName} renders the switch`);
130
+ }
131
+ });
132
+
133
+ test('the switch clears the floor: a 46x38 target drawing a 44x24 track', () => {
134
+ const sw = ruleOf(panelCss, '.sw');
135
+ assert.ok(sw, '.sw has a rule');
136
+ assert.match(sw, /width:\s*46px/, 'the target is 46 wide');
137
+ assert.match(sw, /height:\s*38px/, 'and 38 tall — this is what the floor measures');
138
+ const track = ruleOf(panelCss, '.sw__track');
139
+ assert.match(track, /width:\s*44px/, 'the drawn track stays the world\'s small shape');
140
+ assert.match(track, /height:\s*24px/);
141
+ const on = ruleOf(panelCss, '.sw[aria-checked="true"] .sw__track');
142
+ assert.match(on, /background:\s*var\(--accent-tint\)/, 'on is the tint, the accent arrives on the knob');
143
+ });
144
+
145
+ test('the wandering pick is a radio row, wired on change', () => {
146
+ const render = settingsJs.match(/function renderWandering\([\s\S]*?\n \}\n/);
147
+ assert.ok(render, 'renderWandering is a whole function');
148
+ assert.match(render[0], /<label class="prow prow--pick" for="\$\{id\}"/, 'the whole row is the radio\'s label');
149
+ assert.match(render[0], /<input class="pradio" type="radio" name="wandering"/, 'a real radio group');
150
+ assert.match(render[0], /locked \? ' disabled' : ''/, 'a level above the rank ceiling is a disabled radio');
151
+ assert.doesNotMatch(render[0], /Rank-locked<\/button>|Choose<\/button>/, 'the four "Choose" buttons are gone');
152
+ const load = settingsJs.match(/async function loadWandering\([\s\S]*?\n \}\n/);
153
+ assert.match(load[0], /addEventListener\('change'/, 'a radio fires change, not click');
154
+ assert.match(load[0], /closest\('\.pradio'\)/);
155
+ const radio = ruleOf(panelCss, '.pradio');
156
+ assert.match(radio, /width:\s*24px/);
157
+ assert.match(radio, /height:\s*24px/, 'a native radio is 13px; the floor is 24');
158
+ });
159
+
160
+ test('.craft-pref is a 24px target with a 14px drawn box — it was 13x13, the smallest control in the hall', () => {
161
+ const box = ruleOf(panelCss, '.craft-pref');
162
+ assert.ok(box, '.craft-pref has a rule');
163
+ assert.match(box, /width:\s*24px/);
164
+ assert.match(box, /height:\s*24px/);
165
+ assert.match(box, /appearance:\s*none/, 'the native box has to go, or sizing it up sizes the glyph up too');
166
+ const before = ruleOf(panelCss, '.craft-pref::before');
167
+ assert.match(before, /width:\s*14px/, 'the drawn box stays 14px inside the 24px target');
168
+ const check = ruleOf(panelCss, '.craft-pref:checked::after');
169
+ assert.match(check, /border:\s*solid var\(--accent-on\)/, 'the tick is the dark ink on the salmon, never white');
170
+ });
171
+
172
+ test('the Standing card\'s two under-floor links buy both axes and hand the room back', () => {
173
+ for (const sel of ['.profile__disc-edit', '.profile__needs-act']) {
174
+ const r = ruleOf(styleCss, sel);
175
+ assert.ok(r, `${sel} has a rule`);
176
+ assert.match(r, /display:\s*inline-block/, `${sel}: inline-block, so min-height applies to a run of text`);
177
+ assert.match(r, /min-height:\s*24px/, `${sel}: the height half of the floor (it measured 19px and 18px)`);
178
+ assert.match(r, /min-width:\s*24px/, `${sel}: the width half`);
179
+ assert.match(r, /padding:\s*3px 4px/, `${sel}: the row recipe`);
180
+ assert.match(r, /margin:\s*-3px -4px/, `${sel}: the negative margin gives the padding back to the line`);
181
+ }
182
+ });
183
+
184
+ // ---- 4. the world's own bans -------------------------------------------------
185
+
186
+ test('no sheet in the family spends a compat alias', () => {
187
+ const aliases = ['--surface', '--surface-2', '--border', '--border-strong', '--accent-weak', '--accent-text'];
188
+ for (const sheet of SHEETS) {
189
+ const bare = stripComments(read(sheet));
190
+ const spent = aliases.filter((a) => new RegExp(`var\\(\\s*${a}\\s*[,)]`).test(bare));
191
+ assert.deepEqual(spent, [], `${sheet}: the aliases exist for un-reskinned pages, so spending one here opts the sheet out of the world`);
192
+ }
193
+ });
194
+
195
+ test('white on the accent is gone, and the Discord mark is the one named exception', () => {
196
+ for (const sheet of ['panel.css', 'pair.css', 'profile.css']) {
197
+ const bare = stripComments(read(sheet));
198
+ assert.doesNotMatch(bare, /#[0-9a-f]{3,8}\b/i, `${sheet} carries no colour literal`);
199
+ assert.doesNotMatch(bare, /:\s*white\b/i, `${sheet} carries no named white`);
200
+ }
201
+ const bare = stripComments(settingsCss);
202
+ const literals = [...bare.matchAll(/#[0-9a-f]{3,8}\b/gi)].map((m) => m[0].toLowerCase());
203
+ assert.deepEqual([...new Set(literals)].sort(), ['#4853c4', '#5865f2', '#ffffff'],
204
+ 'settings.css: the blurple pair and its white are the only literals, and they are Discord\'s');
205
+ for (const lit of literals) {
206
+ // every literal sits inside a .settings-btn--discord rule
207
+ const idx = bare.indexOf(lit);
208
+ const ruleStart = bare.lastIndexOf('{', idx);
209
+ const selector = bare.slice(bare.lastIndexOf('}', ruleStart) + 1, ruleStart);
210
+ assert.match(selector, /\.settings-btn--discord/, `${lit} is spent only on the Discord button`);
211
+ }
212
+ assert.match(settingsCss, /THIRD-PARTY MARK, KEPT ON PURPOSE/, 'the exception is named in a comment so a literal sweep leaves it alone');
213
+ // the save/accent buttons take --accent-on. `.pair-approve` and `.craft-save`
214
+ // are members of the base pill rule AND the save rule, so the save rule is
215
+ // found by its own name and the two are asserted as its members.
216
+ const save = ruleOf(panelCss, '.pbtn--save');
217
+ assert.ok(save, '.pbtn--save has a rule');
218
+ assert.match(save, /background:\s*var\(--accent\)/);
219
+ assert.match(save, /color:\s*var\(--accent-on\)/, 'the dark ink for the salmon — white on it fails AA');
220
+ const saveSelector = stripComments(panelCss).match(/([^{}]*\.pbtn--save[^{}]*)\{/);
221
+ assert.ok(saveSelector, 'the save rule\'s selector list is readable');
222
+ for (const member of ['.craft-save', '.pair-approve']) {
223
+ assert.ok(saveSelector[1].split(',').map((s) => s.trim()).includes(member), `${member} is the accent door on its page`);
224
+ }
225
+ });
226
+
227
+ // ---- 5. the token is quoted, the project is a row, the profile is a band ----
228
+
229
+ test('the CLI token is quoted — one tonal step down, wrapping anywhere', () => {
230
+ const fn = settingsJs.match(/async function issueCliToken\([\s\S]*?\n \}\n/);
231
+ assert.ok(fn, 'issueCliToken is a whole function');
232
+ assert.match(fn[0], /class="quote"/, 'the result renders the quote');
233
+ assert.match(fn[0], /<pre class="quote__pre">\$\{escapeHtml\(body\.paste_command \|\| ''\)\}<\/pre>/, 'the secret is escaped into the pre');
234
+ assert.doesNotMatch(fn[0], /style="/, 'no inline style on the foot');
235
+ const pre = ruleOf(settingsCss, '.quote__pre');
236
+ assert.match(pre, /overflow-wrap:\s*anywhere/, 'a 64-character token wraps inside its quote instead of widening the panel');
237
+ assert.match(ruleOf(settingsCss, '.quote') || '', /background:\s*var\(--glass\)/, 'one tonal step down from the panel');
238
+ });
239
+
240
+ // The hall's my-projects page is GONE (owner decision, task 1003308): "my
241
+ // projects" is a user-level page that already lives on the hub, so the hall
242
+ // carries neither the page nor a link nor a redirect to it.
243
+ test('the hall no longer ships a my-projects page', () => {
244
+ for (const f of ['my-projects.html', 'my-projects.js', 'my-projects.css', 'my-projects.states.json']) {
245
+ assert.ok(!fs.existsSync(path.join(HALL, f)), `${f} is gone — the page is the hub's`);
246
+ }
247
+ for (const f of ['shell.js', 'palette.js']) {
248
+ assert.doesNotMatch(read(f), /my-projects/, `${f} does not link the page`);
249
+ }
250
+ });
251
+
252
+ test('the profile identity band is a MODIFIER on the shell\'s .profile, never a second base rule', () => {
253
+ assert.match(read('profile.html'), /<div class="profile profile--ident" id="profile-head">/, 'the band opts in from its markup');
254
+ const css = read('profile.css');
255
+ assert.ok(ruleOf(css, '.profile--ident'), 'profile.css styles the modifier');
256
+ assert.equal(ruleOf(css, '.profile'), null, 'and never redeclares bare .profile — Home\'s Standing card shares it');
257
+ assert.match(ruleOf(css, '.profile--ident .profile__avatar') || '', /var\(--skeleton\)/, 'the avatar placeholder is the skeleton tone, not --bg-deep (pure black in dark)');
258
+ assert.match(ruleOf(css, '.ach') || '', /background:\s*var\(--bg-card\)/, 'a shelf tile on the bare section is the panel, not a card in a card');
259
+ });
260
+
261
+ // ---- 6. the floors are reproducible ------------------------------------------
262
+
263
+ test('all five pages have a states file, so the floors are reproducible', () => {
264
+ for (const p of PAGES) {
265
+ const json = JSON.parse(read(`${p}.states.json`));
266
+ assert.equal(json.surface, 'hall-ui', `${p}.states.json names its surface`);
267
+ assert.ok(Object.keys(json.states).length >= 2, `${p}.states.json renders more than the default state`);
268
+ }
269
+ assert.ok(Object.keys(JSON.parse(read('settings.states.json')).states).length >= 4, 'settings renders every rail group');
270
+ for (const fx of ['me__sessions', 'auth__app-pair__info']) {
271
+ assert.ok(fs.existsSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', `${fx}.json`)),
272
+ `the harness serves ${fx} — without it the page only ever rendered its empty state`);
273
+ }
274
+ });
@@ -94,19 +94,11 @@ test('the relay sends the composed note and the applicant’s name — not a con
94
94
  assert.match(join, /note: \{ type: 'string', maxLength: APPLICANT_NOTE_MAX \}/, 'the note is declared in the route’s schema');
95
95
  });
96
96
 
97
- test('the join box offers the note as optional, and never demands it', () => {
98
- const html = src('modules/hall-ui/public/my-projects.html');
99
- assert.match(html, /<textarea id="join-note"/, 'the note field exists');
100
- assert.match(html, /maxlength="400"/, 'the field cannot outrun the route’s cap');
101
- assert.match(html, /Add a note \(optional\)/, 'and it is labelled optional');
102
- assert.match(html, /id="join-status"/, 'the answer line is its own element, not the note field');
103
- });
104
-
105
- test('a door refusal keeps what the applicant typed; a filed request clears it', () => {
106
- const js = src('modules/hall-ui/public/my-projects.js');
107
- const click = (js.match(/joinBtn\.addEventListener\('click'[\s\S]*?\n \}\);\n/) || [''])[0];
108
- assert.ok(click, 'the join handler must exist');
109
- assert.match(click, /note \? \{ origin: origin, note: note \} : \{ origin: origin \}/, 'a blank note is omitted, not sent as ""');
110
- assert.match(click, /if \(!refused\) \{ joinInput\.value = ''; joinNoteInput\.value = ''; load\(\); \}/,
111
- 'cleared only when the request was actually filed — a refusal keeps the words for the next try');
97
+ // The hall's my-projects page (the join box + its click handler) was REMOVED in
98
+ // task 1003308 — "my projects" is a user-level page that lives on the hub, and
99
+ // the hall keeps no copy. The route contract above is the whole surface now;
100
+ // the hub's own join form is pinned by tests/public_landing_projects.mjs.
101
+ test('the hall no longer carries a join box of its own', () => {
102
+ assert.ok(!fs.existsSync(path.join(ROOT, 'modules/hall-ui/public/my-projects.html')), 'the hall page is gone');
103
+ assert.ok(!fs.existsSync(path.join(ROOT, 'modules/hall-ui/public/my-projects.js')), 'and its script with it');
112
104
  });
@@ -126,7 +126,7 @@ test('platform-identity joined the roster (task 1002322) — the catalog territo
126
126
  'modules/platform-identity/platform-identity.js', // visibility column + catalog domain
127
127
  'modules/platform-identity/routes/projects.js', // the feed + invite routes
128
128
  'modules/status-ui/public/explore.js', // orb render-type surface
129
- 'modules/hall-ui/public/my-projects.js', // owner settings / applications queue
129
+ 'modules/hall-ui/public/settings.js', // the builder's own settings (my-projects.js left the hall in task 1003308)
130
130
  'modules/public-landing/public/projects.html', // the creation step, ?view=new since task 1002763
131
131
  'modules/provisioning/', // module-bundle selection at provision time
132
132
  ]) {
@@ -1,62 +0,0 @@
1
- /* my-projects.css — page styles for the cross-project view (task 1002868).
2
- Every color derives from the style.css tokens (pack-driven); this file owns
3
- only the page's own layout — rows, the join form, the inline notes. */
4
-
5
- .row {
6
- display: flex;
7
- align-items: center;
8
- gap: 10px;
9
- background: var(--surface);
10
- border: 1px solid var(--border);
11
- border-radius: var(--radius-sm);
12
- padding: 10px 12px;
13
- margin-bottom: 8px;
14
- }
15
- .row .meta { flex: 1; min-width: 0; }
16
- .row .name { font-weight: 600; }
17
- .row .origin { color: var(--accent-text); font-size: 12.5px; word-break: break-all; }
18
- .row .owner-pill {
19
- font-size: 11px;
20
- background: var(--accent);
21
- color: #fff;
22
- border-radius: 999px;
23
- padding: 2px 8px;
24
- }
25
-
26
- .empty { margin: 0; color: var(--ink-soft); font-size: 13.5px; }
27
-
28
- .join { display: flex; gap: 8px; }
29
- .join input {
30
- flex: 1;
31
- font: inherit;
32
- font-size: 13px;
33
- padding: 0 10px;
34
- height: 30px;
35
- border: 1px solid var(--border-strong);
36
- border-radius: var(--radius-sm);
37
- background: var(--surface);
38
- color: var(--ink);
39
- }
40
- /* The application's optional note (task 1002330). Sits under the one-click join
41
- row so the click stays the primary act and the note is plainly skippable. */
42
- .join-note-label {
43
- display: block;
44
- margin: 10px 0 4px;
45
- color: var(--ink-soft);
46
- font-size: 12px;
47
- }
48
- .join-note {
49
- display: block;
50
- width: 100%;
51
- box-sizing: border-box;
52
- font: inherit;
53
- font-size: 13px;
54
- line-height: 1.4;
55
- padding: 6px 10px;
56
- resize: vertical;
57
- border: 1px solid var(--border-strong);
58
- border-radius: var(--radius-sm);
59
- background: var(--surface);
60
- color: var(--ink);
61
- }
62
- .note { margin: 10px 0 0; color: var(--ink-soft); font-size: 13px; min-height: 1.2em; }
@@ -1,73 +0,0 @@
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>My Projects — {{worldName}}</title>
7
- <meta name="description" content="Your cross-project view: the projects you own and build on across {{worldName}}.">
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 rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
- <link rel="stylesheet" href="/builders/my-projects.css">
13
- </head>
14
- <body data-page="my-projects" data-page-title="My Projects">
15
-
16
- <div class="app">
17
- <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
18
- <div class="app-scrim" id="app-scrim" hidden></div>
19
- <div class="app-body">
20
- <header class="app-topbar" id="app-topbar"></header>
21
- <div class="app-content">
22
- <div class="page-head">
23
- <p class="page-head__sub">Every project you build on across the platform.</p>
24
- </div>
25
-
26
- <main id="hall-main">
27
- <section class="scroll" aria-labelledby="owned-h">
28
- <h2 id="owned-h" class="scroll__h">Projects you own</h2>
29
- <div id="owned"><p class="empty">—</p></div>
30
- </section>
31
-
32
- <section class="scroll" id="invites-section" hidden aria-labelledby="invites-h">
33
- <h2 id="invites-h" class="scroll__h">Invitations</h2>
34
- <div id="invites"></div>
35
- </section>
36
-
37
- <section class="scroll" aria-labelledby="building-h">
38
- <h2 id="building-h" class="scroll__h">Projects you build on</h2>
39
- <div id="building"><p class="empty">—</p></div>
40
- </section>
41
-
42
- <section class="scroll" aria-labelledby="join-h">
43
- <h2 id="join-h" class="scroll__h">Join a project</h2>
44
- <div class="join">
45
- <input id="join-origin" type="url" placeholder="https://project.cloudbongos.com" autocomplete="off">
46
- <button id="join-btn" type="button" class="btn-ghost">Request to join</button>
47
- </div>
48
- <!-- An application is one click; the note is the applicant's own words,
49
- and it is optional on purpose (task 1002330 — lightweight, never an
50
- owner-defined form). It reaches the project owner's queue. -->
51
- <label class="join-note-label" for="join-note">Add a note (optional)</label>
52
- <textarea id="join-note" class="join-note" rows="2" maxlength="400"
53
- placeholder="Anything the project's owner should know — what you'd like to work on, who you are."></textarea>
54
- <p id="join-status" class="note"></p>
55
- </section>
56
- </main>
57
-
58
- <footer class="app-foot" id="app-foot"></footer>
59
- </div>
60
- </div>
61
- </div>
62
-
63
- <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
64
-
65
- <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
66
- chrome exists before the page script runs. -->
67
- <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
68
- <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
69
- <script src="/builders/palette.js" defer></script>
70
- <script src="/builders/brand-apply.js"></script>
71
- <script src="/builders/my-projects.js"></script>
72
- </body>
73
- </html>
@@ -1,153 +0,0 @@
1
- // my-projects.js — the signed-in builder's cross-project view (ADR 0141 §3, task
2
- // #1002112). Fetches /api/gds/my-projects (session cookie, same-origin) and renders
3
- // owned + building-on lists with self-service leave, plus a join-by-origin form
4
- // whose application is one click plus an optional note (task 1002330).
5
- // All network values are rendered via textContent — never innerHTML.
6
- (function () {
7
- 'use strict';
8
- var ownedEl = document.getElementById('owned');
9
- var buildingEl = document.getElementById('building');
10
- var joinBtn = document.getElementById('join-btn');
11
- var joinInput = document.getElementById('join-origin');
12
- var joinNoteInput = document.getElementById('join-note'); // the applicant's own words (optional)
13
- var joinStatus = document.getElementById('join-status'); // what the project answered
14
-
15
- function el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
16
-
17
- function safeOriginHost(origin) {
18
- try {
19
- var u = new URL(String(origin));
20
- return (u.protocol === 'https:' || u.protocol === 'http:') ? u.host : null;
21
- } catch (_) { return null; }
22
- }
23
-
24
- function renderList(container, items, opts) {
25
- container.textContent = '';
26
- if (!items || items.length === 0) {
27
- var p = el('p', 'empty');
28
- p.textContent = opts.emptyText;
29
- container.appendChild(p);
30
- return;
31
- }
32
- items.forEach(function (m) {
33
- var row = el('div', 'row');
34
- var meta = el('div', 'meta');
35
- var name = el('div', 'name');
36
- name.textContent = m.name || m.client_id;
37
- meta.appendChild(name);
38
- var host = safeOriginHost(m.origin);
39
- if (host) {
40
- var o = el('div', 'origin');
41
- o.textContent = host;
42
- meta.appendChild(o);
43
- }
44
- row.appendChild(meta);
45
- if (m.membership_kind === 'owner') {
46
- var pill = el('span', 'owner-pill');
47
- pill.textContent = 'owner';
48
- row.appendChild(pill);
49
- }
50
- if (opts.leavable) {
51
- var btn = el('button', 'btn-ghost');
52
- btn.type = 'button';
53
- btn.textContent = 'Leave';
54
- btn.addEventListener('click', function () { leave(m.client_id, row, btn); });
55
- row.appendChild(btn);
56
- }
57
- container.appendChild(row);
58
- });
59
- }
60
-
61
- function leave(clientId, row, btn) {
62
- btn.disabled = true;
63
- btn.textContent = 'Leaving…';
64
- fetch('/api/gds/my-projects/' + encodeURIComponent(clientId) + '/leave', { method: 'POST' })
65
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
66
- .then(function () { row.remove(); })
67
- .catch(function () { btn.disabled = false; btn.textContent = 'Leave'; });
68
- }
69
-
70
- function resolveInvite(clientId, accept, row, btns) {
71
- btns.forEach(function (b) { b.disabled = true; });
72
- fetch('/api/gds/my-projects/invites/' + encodeURIComponent(clientId) + '/' + (accept ? 'accept' : 'decline'), { method: 'POST' })
73
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
74
- .then(function () { row.remove(); if (accept) load(); loadInvites(); })
75
- .catch(function () { btns.forEach(function (b) { b.disabled = false; }); });
76
- }
77
-
78
- function loadInvites() {
79
- var section = document.getElementById('invites-section');
80
- var container = document.getElementById('invites');
81
- fetch('/api/gds/my-projects/invites', { headers: { Accept: 'application/json' } })
82
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
83
- .then(function (data) {
84
- var invites = (data && data.invites) || [];
85
- if (invites.length === 0) { section.hidden = true; return; }
86
- container.textContent = '';
87
- invites.forEach(function (inv) {
88
- var row = el('div', 'row');
89
- var meta = el('div', 'meta');
90
- var name = el('div', 'name');
91
- name.textContent = inv.name || inv.client_id;
92
- meta.appendChild(name);
93
- var host = safeOriginHost(inv.origin);
94
- if (host) { var o = el('div', 'origin'); o.textContent = host; meta.appendChild(o); }
95
- row.appendChild(meta);
96
- var accept = el('button', 'btn-ghost'); accept.type = 'button'; accept.textContent = 'Accept';
97
- var decline = el('button', 'btn-ghost'); decline.type = 'button'; decline.textContent = 'Decline';
98
- accept.addEventListener('click', function () { resolveInvite(inv.client_id, true, row, [accept, decline]); });
99
- decline.addEventListener('click', function () { resolveInvite(inv.client_id, false, row, [accept, decline]); });
100
- row.appendChild(accept); row.appendChild(decline);
101
- container.appendChild(row);
102
- });
103
- section.hidden = false;
104
- })
105
- .catch(function () { section.hidden = true; });
106
- }
107
-
108
- function load() {
109
- fetch('/api/gds/my-projects', { headers: { Accept: 'application/json' } })
110
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
111
- .then(function (data) {
112
- renderList(ownedEl, data.owned, { emptyText: 'You don’t own any projects yet.', leavable: false });
113
- renderList(buildingEl, data.building, { emptyText: 'You’re not building on any other projects yet.', leavable: true });
114
- })
115
- .catch(function () {
116
- ownedEl.textContent = ''; buildingEl.textContent = '';
117
- var p = el('p', 'empty'); p.textContent = 'Couldn’t load your projects right now.';
118
- buildingEl.appendChild(p);
119
- });
120
- }
121
-
122
- joinBtn.addEventListener('click', function () {
123
- var origin = (joinInput.value || '').trim();
124
- if (!origin) { joinStatus.textContent = 'Enter a project URL.'; return; }
125
- var note = (joinNoteInput.value || '').trim();
126
- joinBtn.disabled = true;
127
- joinStatus.textContent = 'Requesting…';
128
- fetch('/api/gds/my-projects/join', {
129
- method: 'POST',
130
- headers: { 'Content-Type': 'application/json' },
131
- /* note is omitted entirely when blank — an application with nothing written
132
- is still an application (task 1002330). */
133
- body: JSON.stringify(note ? { origin: origin, note: note } : { origin: origin }),
134
- })
135
- .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
136
- .then(function (res) {
137
- joinBtn.disabled = false;
138
- if (res.ok && res.body && res.body.message) {
139
- joinStatus.textContent = res.body.message;
140
- /* a door refusal rides a 200 (task 1003044): keep the address AND the
141
- note the applicant wrote, and don't repaint as if a request went out */
142
- var refused = res.body.status === 'invite_only' || res.body.status === 'open_enrollment' || res.body.status === 'members_only';
143
- if (!refused) { joinInput.value = ''; joinNoteInput.value = ''; load(); }
144
- }
145
- else if (res.body && res.body.error && res.body.error.code === 'unknown_project') { joinStatus.textContent = 'That project isn’t on the platform.'; }
146
- else { joinStatus.textContent = 'Could not request access right now.'; }
147
- })
148
- .catch(function () { joinBtn.disabled = false; joinStatus.textContent = 'Could not request access right now.'; });
149
- });
150
-
151
- load();
152
- loadInvites();
153
- }());