@bongos/core 1.20.76 → 1.20.78

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 (62) hide show
  1. package/.bongos-core.json +93 -63
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  8. package/docs/adr/0360-bongos-follows-every-candidate-every-other-project-follows-releases.md +66 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +70 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +531 -458
  13. package/docs/copy-registry.json +1269 -589
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +11 -1
  16. package/docs/page-readings.json +1208 -1082
  17. package/docs/recipes/upgrading-the-core.md +11 -2
  18. package/modules/copy-desk/artist-stats.js +15 -1
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +41 -0
  21. package/modules/copy-desk/routes/copy-desk-artist.js +44 -0
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +11 -2
  23. package/modules/hall-ui/public/deploy.states.json +1 -1
  24. package/modules/hall-ui/public/profile-crafts.js +231 -0
  25. package/modules/hall-ui/public/profile.css +60 -0
  26. package/modules/hall-ui/public/profile.html +31 -12
  27. package/modules/hall-ui/public/profile.js +180 -75
  28. package/modules/hall-ui/public/profile.states.json +34 -0
  29. package/modules/hall-ui/public/sky-draw.js +66 -39
  30. package/modules/hall-ui/public/sky-interaction.js +1 -1
  31. package/modules/hall-ui/public/sky-panel.js +1 -1
  32. package/modules/hall-ui/public/thinking.css +52 -24
  33. package/modules/hall-ui/records/profile-roles.md +18 -0
  34. package/modules/npm-release/public/work.js +72 -0
  35. package/modules/npm-release/work.js +67 -1
  36. package/modules/provisioning/planet-physics.js +8 -0
  37. package/modules/provisioning/soft-limits.js +9 -0
  38. package/modules/public-landing/public/projects.html +579 -37
  39. package/modules/public-landing/public/projects.probes.json +6 -3
  40. package/modules/public-landing/public/projects.states.json +20 -14
  41. package/modules/ui-design/kit/serve.js +26 -3
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/release-notes.json +24 -0
  45. package/scripts/gds/update-channel.js +68 -1
  46. package/scripts/gds/update-sweep.js +100 -4
  47. package/scripts/migrate.sh +6 -2
  48. package/src/module-api.js +1 -1
  49. package/tests/hall_sky.mjs +77 -0
  50. package/tests/helpers.mjs +32 -7
  51. package/tests/migrate_applied_check.mjs +94 -0
  52. package/tests/npm_release_page.mjs +67 -0
  53. package/tests/npm_release_work.mjs +82 -0
  54. package/tests/profile_activity.mjs +23 -15
  55. package/tests/profile_role_headline.mjs +4 -4
  56. package/tests/profile_role_tabs.mjs +163 -0
  57. package/tests/projects_hub_app_step.mjs +4 -1
  58. package/tests/provisioning_planet_physics.mjs +24 -2
  59. package/tests/update_channel.mjs +80 -1
  60. package/tests/update_subscription_engine.mjs +99 -0
  61. package/tests/wizard_physics.mjs +11 -4
  62. package/tests/wizard_physics_more.mjs +470 -0
@@ -406,22 +406,17 @@
406
406
  // OTBKit's server-offset pager mode — the kit's own mode, added by this task,
407
407
  // rather than an eleventh hand-rolled pager (hall-ui/CLAUDE.md's hard rule).
408
408
 
409
- // The craft tabs. 'all' leads because most tasks in the corpus carry
410
- // discipline='unclassified': a page that only offered the four named crafts
411
- // would hide the majority of a person's record and read as "you did less than
412
- // you did". The four named crafts match the Goals page's Craft filter
413
- // (goals-page.js f-goal-role) so the two surfaces speak one vocabulary.
414
- const CRAFT_TABS = [
415
- { id: 'all', label: 'All', role: null },
416
- { id: 'engineer', label: 'Engineer', role: 'engineer' },
417
- { id: 'artist', label: 'Artist', role: 'artist' },
418
- { id: 'ideator', label: 'Ideator', role: 'ideator' },
419
- { id: 'ui', label: 'UI', role: 'ui' },
420
- ];
421
- // Filing an idea is ideator work whatever craft the resulting task turned out
422
- // to be, so the trail belongs to the ideator view — and to 'all', which is the
423
- // whole record by definition. It is not a per-craft slice of the ideas.
424
- const TRAIL_TABS = new Set(['all', 'ideator']);
409
+ // The role tabs (task 1004435, the approved mock): one per role the person
410
+ // HAS — their main role and every other role that has earned credits, the
411
+ // same `roles.shown` the header's chips read. Each tab is that craft's own
412
+ // record, never "tasks that happen to carry its label":
413
+ // engineer the works they completed and the goals they created, every
414
+ // craft of task included (most of the corpus is 'unclassified',
415
+ // and a filter to 'engineer' would read as "you did less")
416
+ // artist the pages they rewrote in Tweak Mode that were approved
417
+ // ideator the room's public trail: every thought they filed, in the sky's
418
+ // five states, drawn and listed (the old idea trail, folded in)
419
+ const ROLE_TAB_WORD = { engineer: 'Engineer', artist: 'Artist', ideator: 'Ideator' };
425
420
 
426
421
  // Every href below goes through BOTH encodings, and it needs both: the ids are
427
422
  // DB integers today, but encodeURIComponent leaves a literal `'` alone, so on
@@ -586,10 +581,12 @@
586
581
  // not rebuild the DOM (which would drop the reader's per-page preference).
587
582
  let activity = null;
588
583
 
589
- function buildActivity(builderId) {
584
+ // `view` is the person as the header reads them (task 1004435): their name,
585
+ // their main role, the roles to give a tab and the count beside each.
586
+ function buildActivity(builderId, view = {}) {
590
587
  const kit = window.OTBKit;
591
588
  if (!kit) return null;
592
- let role = null;
589
+ const { name: ownerName, main: mainRole, roles: roleTabs, counts: roleCounts } = view;
593
590
 
594
591
  const tasks = makeActivityList({
595
592
  mountId: 'activity-tasks',
@@ -603,7 +600,7 @@
603
600
  emptyMsg: `No completed works in this ${window.OTB.roleAxisLabel().toLowerCase()}.`,
604
601
  fetchPage: async (offset, limit) => {
605
602
  const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
606
- + `?section=tasks&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
603
+ + `?section=tasks&limit=${limit}&offset=${offset}`);
607
604
  if (!res.ok) throw new Error(`activity tasks → ${res.status}`);
608
605
  return { rows: res.data.rows || [], total: Number(res.data.total) || 0 };
609
606
  },
@@ -633,7 +630,7 @@
633
630
  emptyMsg: `No goals created in this ${window.OTB.roleAxisLabel().toLowerCase()}.`,
634
631
  fetchPage: async (offset, limit) => {
635
632
  const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
636
- + `?section=goals&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
633
+ + `?section=goals&limit=${limit}&offset=${offset}`);
637
634
  if (!res.ok) throw new Error(`activity goals → ${res.status}`);
638
635
  return { rows: res.data.rows || [], total: Number(res.data.total) || 0 };
639
636
  },
@@ -654,83 +651,179 @@
654
651
  },
655
652
  });
656
653
 
657
- const ideas = makeActivityList({
658
- mountId: 'activity-ideas',
659
- storageKey: 'otb.profile.activityIdeasPerPage',
660
- columns: [
661
- { label: '#', class: 'ledger__num' },
662
- 'Idea',
663
- 'What became of it',
664
- { label: 'Filed', class: 'ledger__date' },
665
- ],
666
- emptyMsg: 'No ideas filed yet.',
667
- fetchPage: async (offset, limit) => {
668
- const res = await api.request('GET', `${API}/inbox/by-builder/${encodeURIComponent(builderId)}`
669
- + `?limit=${limit}&offset=${offset}`);
654
+ // ---- the ideator: the room's public trail (task 1004435) ----------------
655
+ // ONE read of up to the route's cap, drawn whole and listed under the
656
+ // drawing, so every mark has its row and every row its mark (the sky's
657
+ // accessibility rule). The ledger pages that list in the browser; past the
658
+ // cap the foot says how many the drawing leaves out.
659
+ const crafts = window.OTBProfileCrafts;
660
+ const TRAIL_CAP = 100;
661
+ let trailRows = [];
662
+ const trailPager = kit.makePager('activity-ideas', 'otb.profile.trailPerPage', { defaultPerPage: 10, minPerPage: 10 });
663
+ const trailLedger = crafts && $('#activity-ideas') ? kit.makeLedger({
664
+ mount: $('#activity-ideas'),
665
+ columns: ['Thought', 'State', { label: 'Size', class: 'ledger__r' }, { label: 'Still alive', class: 'ledger__r' }, 'What became of it'],
666
+ emptyMsg: 'No thoughts filed yet.',
667
+ pager: trailPager,
668
+ rowHtml: (row) => crafts.thoughtRowHtml(row, trailRows.indexOf(row), { outcomeHtml: ideaOutcomeHtml, ideaHref }),
669
+ }) : null;
670
+ let trailLoaded = false;
671
+ async function loadTrail() {
672
+ if (!crafts || !trailLedger || trailLoaded) return;
673
+ trailLoaded = true;
674
+ const first = String(ownerName || 'This builder').split(' ')[0];
675
+ const lede = $('#trail-lede');
676
+ if (lede) lede.textContent = crafts.thoughtsLede(first, mainRole === 'ideator');
677
+ try {
678
+ const res = await api.request('GET', `${API}/inbox/by-builder/${encodeURIComponent(builderId)}?limit=${TRAIL_CAP}&offset=0`);
670
679
  if (!res.ok) throw new Error(`idea trail → ${res.status}`);
671
- return { rows: res.data.ideas || [], total: Number(res.data.total) || 0 };
672
- },
673
- rowHtml: (i) => `<tr>
674
- <td class="ledger__num">#${escapeHtml(String(i.id))}</td>
675
- <td><a href="${ideaHref(i.id)}">${escapeHtml(i.title || `Idea ${i.id}`)}</a></td>
676
- <td>${ideaOutcomeHtml(i)}</td>
677
- <td class="ledger__date">${fmtDate(i.captured_at)}</td>
678
- </tr>`,
680
+ trailRows = res.data.ideas || [];
681
+ const total = Number(res.data.total) || trailRows.length;
682
+ const fig = $('#trail-figure');
683
+ if (fig) {
684
+ fig.innerHTML = crafts.trailSvgHtml(trailRows, { width: fig.clientWidth || 860, height: matchMedia('(max-width: 640px)').matches ? 190 : 230 });
685
+ fig.hidden = !trailRows.length;
686
+ }
687
+ const legend = $('#trail-legend');
688
+ if (legend) legend.innerHTML = trailRows.length ? crafts.legendHtml() : '';
689
+ trailLedger.render(trailRows);
690
+ const foot = $('#trail-foot');
691
+ if (foot) {
692
+ const parts = [];
693
+ if (total > trailRows.length) parts.push(`The newest ${trailRows.length} of ${total} thoughts are drawn here.`);
694
+ if (trailRows.some((r) => r.size && r.size.historical === true && Number(r.size.estimated) > 0)) parts.push('~ marks a size that includes a historical estimate.');
695
+ foot.textContent = parts.join(' ');
696
+ foot.hidden = !parts.length;
697
+ }
698
+ } catch (err) {
699
+ trailLoaded = false;
700
+ console.error('[profile] idea trail failed', err);
701
+ trailLedger.render([]);
702
+ }
703
+ }
704
+ // Hovering or focusing a mark lights its row, and a row its mark: one
705
+ // listener per mount, read off data-i, so a re-render needs no re-wiring.
706
+ function hot(i) {
707
+ document.querySelectorAll('#trail-figure .pt-m').forEach((m) => m.classList.toggle('is-hot', m.getAttribute('data-i') === String(i)));
708
+ document.querySelectorAll('#activity-ideas tr[data-i]').forEach((r) => r.classList.toggle('is-hot', r.getAttribute('data-i') === String(i)));
709
+ }
710
+ const hotFrom = (e) => { const el = e.target.closest && e.target.closest('[data-i]'); hot(el ? el.getAttribute('data-i') : -1); };
711
+ ['#trail-figure', '#activity-ideas'].forEach((sel) => {
712
+ const el = $(sel);
713
+ if (!el) return;
714
+ el.addEventListener('mouseover', hotFrom);
715
+ el.addEventListener('focusin', hotFrom);
716
+ el.addEventListener('mouseleave', () => hot(-1));
679
717
  });
718
+ const fig = $('#trail-figure');
719
+ if (fig) {
720
+ fig.addEventListener('click', (e) => {
721
+ const m = e.target.closest && e.target.closest('.pt-m[data-i]');
722
+ const row = m && document.getElementById(`pt-row-${m.getAttribute('data-i')}`);
723
+ if (row) row.focus();
724
+ });
725
+ }
680
726
 
681
- // NO `panel` PER TAB, and that is deliberate. makeTabs toggles [hidden] on
682
- // one panel PER TAB; here every craft shows the same three ledgers with
683
- // different rows, so pointing all five tabs at one element would make the
684
- // last tab's `hidden = (id !== 'ui')` win and blank the section. Omitting
685
- // `panel` makes the toggle a no-op ($id(undefined) is null) and leaves this
686
- // file owning what a craft change means: a re-fetch, not a panel swap.
727
+ // ---- the artist: approved pages (task 1004435) ---------------------------
728
+ // A few at a time, newest first, each with its first changed line before and
729
+ // after and, when the round has them, its approval renders.
730
+ const PAGES_STEP = 4;
731
+ let pagesShown = 0;
732
+ let pagesHtml = '';
733
+ let pagesTotal = 0;
734
+ async function visualsFor(taskId) {
735
+ try {
736
+ const res = await api.request('GET', `${API}/tasks/${encodeURIComponent(taskId)}/visuals`);
737
+ if (!res.ok) return null;
738
+ const by = {};
739
+ for (const s of (res.data && res.data.slots) || []) if (s && s.slot) by[s.slot] = s;
740
+ return by;
741
+ } catch { return null; }
742
+ }
743
+ async function loadPages(more) {
744
+ const list = $('#activity-pages');
745
+ if (!crafts || !list) return;
746
+ if (!more) { pagesShown = 0; pagesHtml = ''; list.innerHTML = ''; }
747
+ const first = String(ownerName || 'This builder').split(' ')[0];
748
+ try {
749
+ const res = await api.request('GET', `${API}/copy-desk/artists/${encodeURIComponent(builderId)}/pages?limit=${PAGES_STEP}&offset=${pagesShown}`);
750
+ if (!res.ok) throw new Error(`artist pages → ${res.status}`);
751
+ const rows = res.data.rows || [];
752
+ pagesTotal = Number(res.data.total) || 0;
753
+ const vis = await Promise.all(rows.map((p) => visualsFor(p.task_id)));
754
+ const thumb = window.OTBVisual ? window.OTBVisual.shipVisualThumbHtml : null;
755
+ pagesHtml += rows.map((p, k) => crafts.pageRowHtml(p, { visuals: vis[k], thumb })).join('');
756
+ list.innerHTML = pagesHtml;
757
+ pagesShown += rows.length;
758
+ const lede = $('#pages-lede');
759
+ if (lede) lede.textContent = crafts.pagesLede(first, pagesTotal);
760
+ } catch (err) {
761
+ console.error('[profile] artist pages failed', err);
762
+ const lede = $('#pages-lede');
763
+ if (lede && !pagesShown) lede.textContent = 'The approved pages could not be read just now.';
764
+ }
765
+ const moreEl = $('#pages-more');
766
+ if (moreEl) {
767
+ const left = pagesTotal - pagesShown;
768
+ moreEl.innerHTML = left > 0
769
+ ? `<p class="pt-foot">Showing ${pagesShown} of ${pagesTotal}. <button type="button" class="btn-ghost" id="pages-more-btn">Show ${Math.min(PAGES_STEP, left)} more</button></p>`
770
+ : '';
771
+ const btn = $('#pages-more-btn');
772
+ if (btn) btn.addEventListener('click', () => loadPages(true), { once: true });
773
+ }
774
+ }
775
+
776
+ // ---- the tabs ---------------------------------------------------------------
777
+ // One panel per role tab, so makeTabs' own panel toggle does the showing; a
778
+ // tab's record loads the first time it is opened.
779
+ const shownRoles = (roleTabs && roleTabs.length ? roleTabs : ['engineer']).filter((c) => ROLE_TAB_WORD[c]);
780
+ const loaded = new Set();
781
+ function load(id) {
782
+ if (id === 'engineer') {
783
+ [tasks, goals].forEach((l) => { if (l) { l.reset(); l.load(); } });
784
+ } else if (id === 'ideator') {
785
+ loadTrail();
786
+ } else if (id === 'artist' && !loaded.has(id)) {
787
+ loadPages(false);
788
+ }
789
+ loaded.add(id);
790
+ }
687
791
  const tabs = kit.makeTabs({
688
792
  mount: $('#activity-tabs'),
689
- tabs: CRAFT_TABS.map((t) => ({ id: t.id, label: t.label })),
690
- onChange: (id) => {
691
- const tab = CRAFT_TABS.find((t) => t.id === id) || CRAFT_TABS[0];
692
- role = tab.role;
693
- show(id);
694
- },
793
+ tabs: shownRoles.map((c) => ({ id: c, label: ROLE_TAB_WORD[c], panel: `activity-${c}-panel` })),
794
+ onChange: (id) => load(id),
695
795
  });
696
-
697
- function show(tabId) {
698
- const trail = TRAIL_TABS.has(tabId);
699
- const ideasPanel = $('#activity-ideas-panel');
700
- if (ideasPanel) ideasPanel.hidden = !trail;
701
- [tasks, goals].forEach((l) => { if (l) { l.reset(); l.load(); } });
702
- if (trail && ideas) { ideas.reset(); ideas.load(); }
703
- }
796
+ // The count beside each tab: the same numbers the header leads with.
797
+ for (const c of shownRoles) if (roleCounts && roleCounts[c] != null) tabs.setCount(c, roleCounts[c]);
704
798
 
705
799
  // makeTabs activates its initial tab during construction, which already ran
706
- // onChange → show(). So the caller must NOT kick it again on first build.
707
- return { show, active: () => tabs.active() || 'all' };
800
+ // onChange → load(). So the caller must NOT kick it again on first build.
801
+ return { show: (id) => load(id), active: () => tabs.active() || shownRoles[0] };
708
802
  }
709
803
 
710
- function renderActivity(builderId, signedIn) {
804
+ function renderActivity(builderId, signedIn, view) {
711
805
  const section = $('#activity-scroll');
712
806
  const placeholder = $('#activity-placeholder');
713
807
  if (!section) return;
808
+ const PANELS = ['activity-engineer-panel', 'activity-artist-panel', 'activity-ideator-panel'];
714
809
  if (!signedIn) {
715
- // The public portfolio above stays public; this one section says plainly
716
- // why it is not, rather than rendering an empty log that reads as "this
717
- // person did nothing".
810
+ // Owner decision (a), 2026-09-30: "no one should be able to open up the
811
+ // hood of data without being signed in." The identity band, the tally and
812
+ // By the numbers stay public; every role's record says so instead.
813
+ const first = String((view && view.name) || 'This builder').split(' ')[0];
718
814
  if (placeholder) {
719
815
  placeholder.hidden = false;
720
- placeholder.innerHTML = `Idea trails and goals are visible to signed-in builders — `
721
- + `<a href="${API}/auth/web/start">sign in</a> to see this builder's activity.`;
816
+ placeholder.innerHTML = `${escapeHtml(first)}'s activity is visible to signed-in builders of this project — `
817
+ + `<a href="${API}/auth/web/start">sign in</a> to see it.`;
722
818
  }
723
- ['activity-tasks-panel', 'activity-goals-panel', 'activity-ideas-panel']
724
- .forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = true; });
819
+ PANELS.forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = true; });
725
820
  const tabsMount = $('#activity-tabs');
726
821
  if (tabsMount) tabsMount.innerHTML = '';
727
822
  return;
728
823
  }
729
824
  if (placeholder) { placeholder.hidden = true; placeholder.innerHTML = ''; }
730
- ['activity-tasks-panel', 'activity-goals-panel']
731
- .forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = false; });
732
825
  if (!activity) {
733
- activity = buildActivity(builderId); // its makeTabs already loaded page 1
826
+ activity = buildActivity(builderId, view); // its makeTabs already loaded the first tab
734
827
  return;
735
828
  }
736
829
  activity.show(activity.active());
@@ -831,7 +924,19 @@
831
924
  buildWorksLedger(builder.id, selfId != null, data.recent_ships);
832
925
  // The activity log needs a session (private goals + the idea trail), so it
833
926
  // keys off whether /me resolved — the same signal the self-detect uses.
834
- renderActivity(builder.id, selfId != null);
927
+ // The Activity tabs are the header's roles (task 1004435): one per role
928
+ // shown, each counted by the number its craft leads with.
929
+ const r = data.roles || {};
930
+ renderActivity(builder.id, selfId != null, {
931
+ name: builder.display_name || builder.github_login || 'This builder',
932
+ main: view.crafts[0].craft,
933
+ roles: view.crafts.map((c) => c.craft),
934
+ counts: {
935
+ engineer: Number(data.shipped_count) || 0,
936
+ artist: r.artist && Number.isFinite(r.artist.pages_approved) ? r.artist.pages_approved : null,
937
+ ideator: r.ideator && Number.isFinite(r.ideator.filed) ? r.ideator.filed : null,
938
+ },
939
+ });
835
940
  // Connections (section 6, task 1002296) — a separate hub-only script that
836
941
  // reveals its own section or leaves it hidden. Optional by design: a page
837
942
  // that does not load it simply has no connections section.
@@ -73,6 +73,40 @@
73
73
  ]
74
74
  }
75
75
  },
76
+ "activity-artist": {
77
+ "_": "The artist's Activity tab (task 1004435): approved pages, each with its before/after. Fixtures builders__2001__profile + copy-desk__artists__2001__pages.",
78
+ "auth": true,
79
+ "url": "/profile?id=2001#activity",
80
+ "actions": [
81
+ [
82
+ "wait",
83
+ 1500
84
+ ]
85
+ ],
86
+ "expect": {
87
+ "visible": [
88
+ "#activity-tabs",
89
+ "#activity-pages"
90
+ ]
91
+ }
92
+ },
93
+ "activity-ideator": {
94
+ "_": "The ideator's Activity tab (task 1004435): the room's public trail, every mark also a row. Fixtures builders__2002__profile + inbox__by-builder__2002.",
95
+ "auth": true,
96
+ "url": "/profile?id=2002#activity",
97
+ "actions": [
98
+ [
99
+ "wait",
100
+ 1500
101
+ ]
102
+ ],
103
+ "expect": {
104
+ "visible": [
105
+ "#activity-tabs",
106
+ "#trail-figure"
107
+ ]
108
+ }
109
+ },
76
110
  "other": {
77
111
  "auth": true,
78
112
  "url": "/profile?id=1000025",
@@ -20,6 +20,31 @@
20
20
 
21
21
  const ACC = [251, 149, 105], CHART = [242, 210, 122];
22
22
  const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
23
+ // THE PALETTE (task 1004500). The template's white-on-void, read off the stage's
24
+ // --sky-*-rgb channels (thinking.css) so the light theme paints ink on paper.
25
+ // The arrays are filled IN PLACE: SKY.ACC / SKY.CHART stay the same objects.
26
+ // Re-read only when the theme flips (shell.js writes html[data-theme]).
27
+ const INK = [255, 255, 255], VOID = [0, 0, 0], COLD = [20, 20, 24]; let TEMP = 1;
28
+ // ink(a) is called per mark per frame, so each alpha's string is built once per palette.
29
+ const inkCache = new Map();
30
+ const ink = (a) => { let s = inkCache.get(a); if (!s) { s = rgba(INK, a); inkCache.set(a, s); } return s; };
31
+ let paletteStale = true;
32
+ function readPalette() {
33
+ paletteStale = false; inkCache.clear();
34
+ const st = typeof document.querySelector === 'function' && document.querySelector('.sky-stage');
35
+ if (!st || typeof getComputedStyle !== 'function') return;
36
+ const cs = getComputedStyle(st); if (!cs || typeof cs.getPropertyValue !== 'function') return;
37
+ const fill = (name, into) => { const v = String(cs.getPropertyValue(name) || '').split(',').map((n) => Number(n.trim())); if (v.length === 3 && v.every((n) => Number.isFinite(n))) into.splice(0, 3, ...v); };
38
+ fill('--sky-ink-rgb', INK); fill('--sky-void-rgb', VOID); fill('--sky-cold-rgb', COLD); fill('--sky-accent-rgb', ACC); fill('--sky-chart-rgb', CHART);
39
+ const t = Number(String(cs.getPropertyValue('--sky-temp') || '').trim()); TEMP = Number.isFinite(t) && t > 0 ? t : 1;
40
+ }
41
+ const tone = (c) => (TEMP === 1 ? c : c.map((v) => Math.round(v * TEMP)));
42
+ {
43
+ const stale = () => { paletteStale = true; S.dirty = true; };
44
+ if (typeof MutationObserver === 'function' && document.documentElement) new MutationObserver(stale).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
45
+ const mq = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)');
46
+ if (mq && typeof mq.addEventListener === 'function') mq.addEventListener('change', stale);
47
+ }
23
48
  function goalAlpha(g, now) {
24
49
  if (!present(g)) return 0;
25
50
  let a = 1;
@@ -31,7 +56,7 @@
31
56
  function shuttle(x, y, ang, s, glow) {
32
57
  ctx.save(); ctx.translate(x, y); ctx.rotate(ang);
33
58
  if (glow) { ctx.shadowColor = rgba(CHART, .7); ctx.shadowBlur = 10; }
34
- ctx.fillStyle = glow ? '#fff' : 'rgba(255,255,255,.85)'; ctx.strokeStyle = glow ? rgba(CHART, 1) : 'rgba(255,255,255,.5)'; ctx.lineWidth = 1;
59
+ ctx.fillStyle = glow ? ink(1) : ink(.85); ctx.strokeStyle = glow ? rgba(CHART, 1) : ink(.5); ctx.lineWidth = 1;
35
60
  ctx.beginPath(); ctx.moveTo(8 * s, 0); ctx.lineTo(2 * s, -2.2 * s); ctx.lineTo(-3 * s, -2.2 * s); ctx.lineTo(-5 * s, -6 * s); ctx.lineTo(-7 * s, -6 * s); ctx.lineTo(-6 * s, -1.6 * s); ctx.lineTo(-7 * s, 0); ctx.lineTo(-6 * s, 1.6 * s); ctx.lineTo(-7 * s, 6 * s); ctx.lineTo(-5 * s, 6 * s); ctx.lineTo(-3 * s, 2.2 * s); ctx.lineTo(2 * s, 2.2 * s); ctx.closePath(); ctx.fill(); ctx.stroke();
36
61
  ctx.restore();
37
62
  }
@@ -40,7 +65,8 @@
40
65
  const { W, H, DPR, cam, lens, hover, picked, gravityGoal, tourSpot, charted, goals, edges, ideas, BANDS } = S;
41
66
  const tt = (now - S.t0) / 1000;
42
67
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
43
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
68
+ if (paletteStale) readPalette();
69
+ ctx.fillStyle = rgba(VOID, 1); ctx.fillRect(0, 0, W, H);
44
70
  const k = cam.k;
45
71
  const visible = (x, y, r) => { const [sx, sy] = toScreen(x, y); const sr = r * k; return sx + sr > -40 && sx - sr < W + 40 && sy + sr > -40 && sy - sr < H + 40; };
46
72
 
@@ -48,9 +74,9 @@
48
74
  const [cx0, cy0] = toScreen(0, 0);
49
75
  for (let bi = lens === 'simple' ? -1 : BANDS.length - 1; bi >= 0; bi--) {
50
76
  const b = BANDS[bi]; const hot = hover && hover.kind === 'band' && hover.ref === b;
51
- ctx.fillStyle = `rgba(255,255,255,${(hot ? b.tone * 1.6 : b.tone).toFixed(4)})`;
77
+ ctx.fillStyle = ink((hot ? b.tone * 1.6 : b.tone).toFixed(4));
52
78
  ctx.beginPath(); ctx.ellipse(cx0, cy0, b.r1 * k * S.ASPECT, b.r1 * k, 0, 0, TAU); if (b.r0) ctx.ellipse(cx0, cy0, b.r0 * k * S.ASPECT, b.r0 * k, 0, 0, TAU, true); ctx.fill('evenodd');
53
- ctx.strokeStyle = hot ? 'rgba(255,255,255,.14)' : 'rgba(255,255,255,.05)'; ctx.lineWidth = 1; ctx.setLineDash([]);
79
+ ctx.strokeStyle = hot ? ink(.14) : ink(.05); ctx.lineWidth = 1; ctx.setLineDash([]);
54
80
  ctx.beginPath(); ctx.ellipse(cx0, cy0, b.r1 * k * S.ASPECT, b.r1 * k, 0, 0, TAU); ctx.stroke();
55
81
  }
56
82
 
@@ -59,7 +85,7 @@
59
85
  for (const e of (lens === 'simple' ? [] : edges)) {
60
86
  if (!present(e.a) || !present(e.b)) continue;
61
87
  const c = curve(e); const job = charted.get(e.id);
62
- if (!job) { if (lens === 'all' && k > 0.03 && seen(e.a) && seen(e.b)) { ctx.strokeStyle = 'rgba(255,255,255,.02)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(c.ax, c.ay); ctx.quadraticCurveTo(c.mx, c.my, c.bx, c.by); ctx.stroke(); } continue; }
88
+ if (!job) { if (lens === 'all' && k > 0.03 && seen(e.a) && seen(e.b)) { ctx.strokeStyle = ink(.02); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(c.ax, c.ay); ctx.quadraticCurveTo(c.mx, c.my, c.bx, c.by); ctx.stroke(); } continue; }
63
89
  const rev = job.from === e.b; const P = (t) => pointOn(c, rev ? 1 - t : t);
64
90
  let p = job.done ? 1 : clamp((now - job.t0) / job.ms, 0, 1);
65
91
  if (p >= 1 && !job.done) { job.done = true; reveal(rev ? e.a : e.b); }
@@ -87,23 +113,23 @@
87
113
  const has = g.thoughts.some(shown);
88
114
  if (g.band !== BANDS[0] && !has) { g._stubs = []; ctx.globalAlpha = 1; continue; }
89
115
  ctx.globalAlpha = has ? 1 : 0.55; ctx.lineWidth = 1; ctx.setLineDash([]);
90
- ctx.strokeStyle = isPicked || isHover ? 'rgba(255,255,255,.22)' : 'rgba(255,255,255,.085)'; ctx.beginPath(); ctx.arc(sx, sy, sr, 0, TAU); ctx.stroke();
91
- ctx.strokeStyle = 'rgba(255,255,255,.05)'; ctx.beginPath(); ctx.arc(sx, sy, sr * 0.5, 0, TAU); ctx.stroke();
116
+ ctx.strokeStyle = isPicked || isHover ? ink(.22) : ink(.085); ctx.beginPath(); ctx.arc(sx, sy, sr, 0, TAU); ctx.stroke();
117
+ ctx.strokeStyle = ink(.05); ctx.beginPath(); ctx.arc(sx, sy, sr * 0.5, 0, TAU); ctx.stroke();
92
118
  if (sr > 26 && (has || g.status === 'open')) labels.push({ x: sx, y: sy - sr - 6, text: short(g.title).toLowerCase(), sub: '', big: false, small: true, hot: isPicked || isHover, alpha: 1 });
93
119
  g._stubs = []; ctx.globalAlpha = 1; continue;
94
120
  }
95
121
  if (!seen(g)) { // a silhouette: something is there, and you have not been
96
- ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1; ctx.setLineDash([2, 6]); ctx.beginPath(); ctx.arc(sx, sy, Math.max(3, sr), 0, TAU); ctx.stroke(); ctx.setLineDash([]);
122
+ ctx.strokeStyle = ink(.5); ctx.lineWidth = 1; ctx.setLineDash([2, 6]); ctx.beginPath(); ctx.arc(sx, sy, Math.max(3, sr), 0, TAU); ctx.stroke(); ctx.setLineDash([]);
97
123
  if (isHover) labels.push({ x: sx, y: sy - sr - 8, text: 'unexplored', sub: 'follow a route to reach it', big: false, hot: true, alpha: 1 });
98
124
  g._stubs = []; ctx.globalAlpha = 1; continue;
99
125
  }
100
126
  if (g.status === 'open') {
101
127
  // alive: a builder holding a task now, or work landing this week — the one accent a well can wear
102
128
  if (g.live > 0) { const br = reduce ? 1 : 0.85 + 0.15 * Math.sin(tt * 0.9 + hash(g.id) * 6); const hg = ctx.createRadialGradient(sx, sy, sr * 0.92, sx, sy, sr * 1.35); hg.addColorStop(0, rgba(ACC, 0.04 + 0.14 * g.live * br)); hg.addColorStop(1, rgba(ACC, 0)); ctx.fillStyle = hg; ctx.beginPath(); ctx.arc(sx, sy, sr * 1.35, 0, TAU); ctx.fill(); }
103
- const grd = ctx.createRadialGradient(sx, sy, sr * 0.2, sx, sy, sr); grd.addColorStop(0, 'rgba(255,255,255,.05)'); grd.addColorStop(1, 'rgba(255,255,255,.012)');
129
+ const grd = ctx.createRadialGradient(sx, sy, sr * 0.2, sx, sy, sr); grd.addColorStop(0, ink(.05)); grd.addColorStop(1, ink(.012));
104
130
  ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(sx, sy, sr, 0, TAU); ctx.fill();
105
131
  ctx.lineWidth = 1; ctx.setLineDash([]);
106
- ctx.strokeStyle = isGrav || isSpot ? rgba(ACC, .8) : (isPicked || isHover) ? 'rgba(255,255,255,.4)' : g.live > 0 ? rgba(ACC, 0.18 + 0.3 * g.live) : 'rgba(255,255,255,.14)';
132
+ ctx.strokeStyle = isGrav || isSpot ? rgba(ACC, .8) : (isPicked || isHover) ? ink(.4) : g.live > 0 ? rgba(ACC, 0.18 + 0.3 * g.live) : ink(.14);
107
133
  if (isGrav || isSpot) { ctx.shadowColor = rgba(ACC, .9); ctx.shadowBlur = 22; }
108
134
  ctx.beginPath(); ctx.arc(sx, sy, sr, 0, TAU); ctx.stroke(); ctx.shadowBlur = 0;
109
135
  if (sr > 22) for (let i = 0; i < n; i++) {
@@ -111,8 +137,8 @@
111
137
  const hot = (hoverRing && hoverRing.goal === g && hoverRing.index === i) || (tourSpot && tourSpot.kind === 'ring' && tourSpot.goal === g && tourSpot.index === i);
112
138
  ctx.beginPath(); ctx.arc(sx, sy, rr, 0, TAU);
113
139
  if (hot) { ctx.setLineDash([]); ctx.strokeStyle = rgba(ACC, .9); ctx.lineWidth = ringWidth(c) + 0.5; ctx.shadowColor = rgba(ACC, .8); ctx.shadowBlur = 14; }
114
- else if (c.satisfied) { ctx.setLineDash([]); ctx.strokeStyle = 'rgba(255,255,255,.42)'; ctx.lineWidth = ringWidth(c); }
115
- else { ctx.setLineDash([3, 5]); ctx.strokeStyle = 'rgba(255,255,255,.13)'; ctx.lineWidth = ringWidth(c); }
140
+ else if (c.satisfied) { ctx.setLineDash([]); ctx.strokeStyle = ink(.42); ctx.lineWidth = ringWidth(c); }
141
+ else { ctx.setLineDash([3, 5]); ctx.strokeStyle = ink(.13); ctx.lineWidth = ringWidth(c); }
116
142
  ctx.stroke(); ctx.shadowBlur = 0;
117
143
  if (c.bornOf && sr > 40) { const a = hash(c.bornOf) * TAU; ctx.setLineDash([]); ctx.strokeStyle = rgba(ACC, .9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(sx + Math.cos(a) * (rr - 7), sy + Math.sin(a) * (rr - 7)); ctx.lineTo(sx + Math.cos(a) * (rr + 7), sy + Math.sin(a) * (rr + 7)); ctx.stroke(); }
118
144
  }
@@ -120,30 +146,30 @@
120
146
  const tasksNow = g.tasks.filter(present);
121
147
  if (tasksNow.length && sr > 60) {
122
148
  const bodies = smooth(sr, 170, 330);
123
- if (bodies < 1) { ctx.globalAlpha = al * (1 - bodies); for (let i = -1; i < n; i++) { const rr = (i < 0 ? g.r * 0.99 : ringR(g, i, n)) * k; const cnt = tasksNow.filter((t) => (i < 0 ? t.ci < 0 || t.ci >= n : t.ci === i)).length; if (!cnt) continue; ctx.strokeStyle = `rgba(255,255,255,${Math.min(.3, 0.03 + cnt * 0.004)})`; ctx.lineWidth = Math.min(7, 1.5 + cnt * 0.03); ctx.beginPath(); ctx.arc(sx, sy, rr, 0, TAU); ctx.stroke(); } ctx.lineWidth = 1; ctx.globalAlpha = al; }
149
+ if (bodies < 1) { ctx.globalAlpha = al * (1 - bodies); for (let i = -1; i < n; i++) { const rr = (i < 0 ? g.r * 0.99 : ringR(g, i, n)) * k; const cnt = tasksNow.filter((t) => (i < 0 ? t.ci < 0 || t.ci >= n : t.ci === i)).length; if (!cnt) continue; ctx.strokeStyle = ink(Math.min(.3, 0.03 + cnt * 0.004)); ctx.lineWidth = Math.min(7, 1.5 + cnt * 0.03); ctx.beginPath(); ctx.arc(sx, sy, rr, 0, TAU); ctx.stroke(); } ctx.lineWidth = 1; ctx.globalAlpha = al; }
124
150
  if (bodies > 0) { ctx.globalAlpha = al * bodies; for (const t of tasksNow) { const [ox, oy] = taskAt(t, tt); const [tx, ty] = toScreen(g.x + ox, g.y + oy); t._sx = tx; t._sy = ty; drawTask(t, tx, ty, tt, picked && picked.kind === 'task' && picked.ref === t, hover && hover.kind === 'task' && hover.ref === t); } ctx.globalAlpha = al; }
125
151
  }
126
152
  const done = tasksNow.filter((t) => ['shipped', 'confirmed', 'completed'].includes(t.status)).length;
127
153
  const speaks = sr > 200 || (sr > 34 && g.mass >= 1500) || isPicked || isHover || isGrav || isSpot;
128
154
  if (speaks) labels.push({ x: sx, y: sr > 200 ? sy - sr + 44 : sy - sr - 8, text: g.title, sub: `${S.clock < NOW - 3600000 ? done : g.taskDone}/${S.clock < NOW - 3600000 ? tasksNow.length : g.taskTotal} tasks · ${g.credits ? g.credits + ' credits · ' : ''}${g.crit.filter((c) => c.satisfied).length} of ${g.crit.length} rings closed${g.live > 0 ? ' · alive' : ''}`, big: sr > 120, hot: isPicked || isHover || isGrav || isSpot, alpha: al });
129
155
  } else if (g.status === 'achieved') {
130
- const grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(4, sr)); grd.addColorStop(0, 'rgba(255,255,255,.16)'); grd.addColorStop(0.5, 'rgba(255,255,255,.04)'); grd.addColorStop(1, 'rgba(255,255,255,0)');
156
+ const grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(4, sr)); grd.addColorStop(0, ink(.16)); grd.addColorStop(0.5, ink(.04)); grd.addColorStop(1, ink(0));
131
157
  ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(sx, sy, Math.max(4, sr), 0, TAU); ctx.fill();
132
- ctx.lineWidth = 1; ctx.setLineDash([]); ctx.strokeStyle = (isPicked || isHover || isSpot) ? 'rgba(255,255,255,.6)' : 'rgba(255,255,255,.22)';
158
+ ctx.lineWidth = 1; ctx.setLineDash([]); ctx.strokeStyle = (isPicked || isHover || isSpot) ? ink(.6) : ink(.22);
133
159
  ctx.beginPath(); ctx.arc(sx, sy, Math.max(3, sr), 0, TAU); ctx.stroke();
134
- if (sr > 22) for (let i = 0; i < n; i++) { const c = g.crit[i]; const rr = ringR(g, i, n) * k; const hot = hoverRing && hoverRing.goal === g && hoverRing.index === i; ctx.strokeStyle = hot ? rgba(ACC, .9) : 'rgba(255,255,255,.3)'; ctx.lineWidth = ringWidth(c); ctx.beginPath(); ctx.arc(sx, sy, rr, 0, TAU); ctx.stroke(); if (c.bornOf && sr > 40) { const a = hash(c.bornOf) * TAU; ctx.strokeStyle = rgba(ACC, .9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(sx + Math.cos(a) * (rr - 7), sy + Math.sin(a) * (rr - 7)); ctx.lineTo(sx + Math.cos(a) * (rr + 7), sy + Math.sin(a) * (rr + 7)); ctx.stroke(); } }
160
+ if (sr > 22) for (let i = 0; i < n; i++) { const c = g.crit[i]; const rr = ringR(g, i, n) * k; const hot = hoverRing && hoverRing.goal === g && hoverRing.index === i; ctx.strokeStyle = hot ? rgba(ACC, .9) : ink(.3); ctx.lineWidth = ringWidth(c); ctx.beginPath(); ctx.arc(sx, sy, rr, 0, TAU); ctx.stroke(); if (c.bornOf && sr > 40) { const a = hash(c.bornOf) * TAU; ctx.strokeStyle = rgba(ACC, .9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(sx + Math.cos(a) * (rr - 7), sy + Math.sin(a) * (rr - 7)); ctx.lineTo(sx + Math.cos(a) * (rr + 7), sy + Math.sin(a) * (rr + 7)); ctx.stroke(); } }
135
161
  ctx.lineWidth = 1;
136
162
  if (g.tasks.length && sr > 170) { const bodies = smooth(sr, 170, 330); ctx.globalAlpha = al * bodies; for (const t of g.tasks.filter(present)) { const [ox, oy] = taskAt(t, tt); const [tx, ty] = toScreen(g.x + ox, g.y + oy); t._sx = tx; t._sy = ty; drawTask(t, tx, ty, tt, false, hover && hover.kind === 'task' && hover.ref === t); } ctx.globalAlpha = al; }
137
- if (sr > 8) { ctx.strokeStyle = 'rgba(255,255,255,.05)'; ctx.beginPath(); ctx.arc(sx, sy, sr * 1.35, 0, TAU); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,.03)'; ctx.beginPath(); ctx.arc(sx, sy, sr * 1.7, 0, TAU); ctx.stroke(); }
138
- const core = Math.max(2.4, Math.min(sr * 0.14, 9)); const cg = ctx.createRadialGradient(sx, sy, 0, sx, sy, core * 3); cg.addColorStop(0, 'rgba(255,255,255,.5)'); cg.addColorStop(1, 'rgba(255,255,255,0)');
139
- ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(sx, sy, core * 3, 0, TAU); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,.92)'; ctx.beginPath(); ctx.arc(sx, sy, core, 0, TAU); ctx.fill();
163
+ if (sr > 8) { ctx.strokeStyle = ink(.05); ctx.beginPath(); ctx.arc(sx, sy, sr * 1.35, 0, TAU); ctx.stroke(); ctx.strokeStyle = ink(.03); ctx.beginPath(); ctx.arc(sx, sy, sr * 1.7, 0, TAU); ctx.stroke(); }
164
+ const core = Math.max(2.4, Math.min(sr * 0.14, 9)); const cg = ctx.createRadialGradient(sx, sy, 0, sx, sy, core * 3); cg.addColorStop(0, ink(.5)); cg.addColorStop(1, ink(0));
165
+ ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(sx, sy, core * 3, 0, TAU); ctx.fill(); ctx.fillStyle = ink(.92); ctx.beginPath(); ctx.arc(sx, sy, core, 0, TAU); ctx.fill();
140
166
  const speaks = sr > 200 || (sr > 40 && g.mass >= 1500) || isPicked || isHover || isSpot;
141
167
  if (speaks) labels.push({ x: sx, y: sr > 200 ? sy - sr + 44 : sy - sr - 8, text: g.title, sub: `settled · ${g.taskDone} tasks${g.credits ? ' · ' + g.credits + ' credits' : ''} · ${g.crit.length} rings closed`, big: sr > 120, hot: isPicked || isHover || isSpot, small: sr < 60, alpha: al });
142
168
  } else {
143
- ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(sx, sy, Math.max(2, sr), 0, TAU); ctx.fill();
144
- ctx.strokeStyle = (isPicked || isHover || isSpot) ? 'rgba(255,255,255,.6)' : 'rgba(255,255,255,.22)'; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); ctx.arc(sx, sy, Math.max(2, sr), 0, TAU); ctx.stroke();
145
- if (sr > 5) { ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.beginPath(); ctx.arc(sx, sy, sr * 1.5, 0, TAU); ctx.stroke(); }
146
- if (sr > 30) { const grd = ctx.createRadialGradient(sx, sy, sr * 0.6, sx, sy, sr); grd.addColorStop(0, 'rgba(255,255,255,0)'); grd.addColorStop(1, 'rgba(255,255,255,.05)'); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(sx, sy, sr, 0, TAU); ctx.fill(); }
169
+ ctx.fillStyle = rgba(VOID, 1); ctx.beginPath(); ctx.arc(sx, sy, Math.max(2, sr), 0, TAU); ctx.fill();
170
+ ctx.strokeStyle = (isPicked || isHover || isSpot) ? ink(.6) : ink(.22); ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); ctx.arc(sx, sy, Math.max(2, sr), 0, TAU); ctx.stroke();
171
+ if (sr > 5) { ctx.strokeStyle = ink(.06); ctx.beginPath(); ctx.arc(sx, sy, sr * 1.5, 0, TAU); ctx.stroke(); }
172
+ if (sr > 30) { const grd = ctx.createRadialGradient(sx, sy, sr * 0.6, sx, sy, sr); grd.addColorStop(0, ink(0)); grd.addColorStop(1, ink(.05)); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(sx, sy, sr, 0, TAU); ctx.fill(); }
147
173
  const speaks = sr > 160 || isPicked || isHover || isSpot;
148
174
  if (speaks) labels.push({ x: sx, y: sr > 160 ? sy - sr + 40 : sy - sr - 8, text: g.title, sub: `collapsed · ${g.taskDone} of ${g.taskTotal} tasks`, big: false, hot: isPicked || isHover || isSpot, small: sr < 60, alpha: al });
149
175
  }
@@ -155,7 +181,7 @@
155
181
  const o = e.a === g ? e.b : e.a; if (!present(o)) continue; const a = Math.atan2(o.y - g.y, o.x - g.x);
156
182
  const r0 = sr + 5, r1 = sr + 19; const x0 = sx + Math.cos(a) * r0, y0 = sy + Math.sin(a) * r0, x1 = sx + Math.cos(a) * r1, y1 = sy + Math.sin(a) * r1;
157
183
  const hot = hover && hover.kind === 'stub' && hover.edge === e && hover.goal === g; const done = charted.has(e.id);
158
- ctx.strokeStyle = hot ? rgba(CHART, 1) : done ? rgba(CHART, .5) : 'rgba(255,255,255,.3)'; ctx.lineWidth = hot ? 1.6 : 1; ctx.setLineDash([]);
184
+ ctx.strokeStyle = hot ? rgba(CHART, 1) : done ? rgba(CHART, .5) : ink(.3); ctx.lineWidth = hot ? 1.6 : 1; ctx.setLineDash([]);
159
185
  ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
160
186
  const out = e.a === g; const hx = out ? x1 : x0, hy = out ? y1 : y0, ha = out ? a : a + Math.PI;
161
187
  ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx - Math.cos(ha - 0.5) * 5, hy - Math.sin(ha - 0.5) * 5); ctx.moveTo(hx, hy); ctx.lineTo(hx - Math.cos(ha + 0.5) * 5, hy - Math.sin(ha + 0.5) * 5); ctx.stroke();
@@ -179,7 +205,7 @@
179
205
  if (S.flying) { const f = S.flying; const p = clamp((now - f.t0) / f.ms, 0, 1); const e = ease(p); const [sx0, sy0] = [W / 2, 150]; const [sx1, sy1] = toScreen(f.idea.x, f.idea.y); const sx = lerp(sx0, sx1, e), sy = lerp(sy0, sy1, e) - Math.sin(p * Math.PI) * 40; drawThought(f.idea, sx, sy, k, tt, false, false); if (p >= 1) { f.idea.landed = true; S.flying = null; } }
180
206
  if (picked && picked.kind === 'idea' && picked.ref.lineage.length && picked.ref.x != null) {
181
207
  const i = picked.ref; const [sx, sy] = toScreen(i.x, i.y); const base = Math.max(22, diameter(i) * Math.min(1.6, k) + 10);
182
- i.lineage.forEach((t, n) => { const orb = base + n * 14; const a = hash(t.id) * TAU + tt * (reduce ? 0 : 0.05) * (n % 2 ? -1 : 1); ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); ctx.arc(sx, sy, orb, 0, TAU); ctx.stroke(); const tx = sx + Math.cos(a) * orb, ty = sy + Math.sin(a) * orb; drawTask({ status: t.status }, tx, ty, tt, false, hover && hover.kind === 'ltask' && hover.ref === t); t._sx = tx; t._sy = ty; });
208
+ i.lineage.forEach((t, n) => { const orb = base + n * 14; const a = hash(t.id) * TAU + tt * (reduce ? 0 : 0.05) * (n % 2 ? -1 : 1); ctx.strokeStyle = ink(.08); ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); ctx.arc(sx, sy, orb, 0, TAU); ctx.stroke(); const tx = sx + Math.cos(a) * orb, ty = sy + Math.sin(a) * orb; drawTask({ status: t.status }, tx, ty, tt, false, hover && hover.kind === 'ltask' && hover.ref === t); t._sx = tx; t._sy = ty; });
183
209
  }
184
210
  for (const s of shuttles) shuttle(s.x, s.y, s.ang, 1.1, s.live || (hover && hover.kind === 'shuttle' && hover.ref === s.e));
185
211
  S.shuttles = shuttles;
@@ -196,9 +222,9 @@
196
222
  let y = l.y; let rect = { x0: l.x - w / 2, x1: l.x + w / 2, y0: y - h, y1: y };
197
223
  if (collides(rect)) { y = l.y + h + 6; rect = { x0: l.x - w / 2, x1: l.x + w / 2, y0: y - h, y1: y }; if (collides(rect) && !l.hot) continue; }
198
224
  placed.push(rect);
199
- ctx.fillStyle = l.hot ? 'rgba(255,255,255,.95)' : l.small ? 'rgba(255,255,255,.4)' : 'rgba(255,255,255,.6)';
225
+ ctx.fillStyle = l.hot ? ink(.95) : l.small ? ink(.4) : ink(.6);
200
226
  ctx.fillText(text, l.x, y - (l.small ? 0 : 14));
201
- if (!l.small && l.sub) { ctx.font = '400 10px "JetBrains Mono", monospace'; ctx.fillStyle = l.hot ? 'rgba(255,255,255,.6)' : 'rgba(255,255,255,.3)'; ctx.fillText(l.sub.toUpperCase(), l.x, y); }
227
+ if (!l.small && l.sub) { ctx.font = '400 10px "JetBrains Mono", monospace'; ctx.fillStyle = l.hot ? ink(.6) : ink(.3); ctx.fillText(l.sub.toUpperCase(), l.x, y); }
202
228
  }
203
229
  }
204
230
 
@@ -206,21 +232,21 @@
206
232
  const d = diameter(i) * clamp(k, 0.55, 1.6); const breathe = reduce ? 1 : 0.9 + 0.1 * Math.sin(tt * 1.3 + hash(i.id) * 6); ctx.setLineDash([]);
207
233
  if (i.shaped) { ctx.strokeStyle = rgba(ACC, .7); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, Math.max(d / 2 + 6, 10), 0, TAU); ctx.stroke(); }
208
234
  if (i.st === 'star') { const a = 0.32 + 0.68 * i.lit; const glow = d * (0.8 + 1.6 * i.lit) * breathe; const grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, glow); grd.addColorStop(0, rgba(ACC, 0.55 * i.lit)); grd.addColorStop(1, rgba(ACC, 0)); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(sx, sy, glow, 0, TAU); ctx.fill(); ctx.fillStyle = rgba(ACC, a); ctx.beginPath(); ctx.arc(sx, sy, d / 2, 0, TAU); ctx.fill(); }
209
- else if (i.st === 'remnant') { ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, d / 2 * 1.15, 0, TAU); ctx.stroke(); }
210
- else if (i.st === 'protostar') { const aw = i.awaiting || (i.state && i.state.awaitingNod); const pulse = aw && !reduce ? 0.5 + 0.5 * Math.sin(tt * 2 + hash(i.id) * 6) : 0; const c = SKY.temp(i); ctx.fillStyle = rgba(c, .06); ctx.beginPath(); ctx.arc(sx, sy, 7 + pulse * 6, 0, TAU); ctx.fill(); ctx.fillStyle = aw ? rgba(ACC, 0.55 + 0.45 * pulse) : rgba(c, i.well ? .6 : .5); ctx.beginPath(); ctx.arc(sx, sy, 3.2, 0, TAU); ctx.fill(); }
211
- else if (i.st === 'satellite') { ctx.fillStyle = 'rgba(255,255,255,.4)'; ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, TAU); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,.16)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, 6.5, 0, TAU); ctx.stroke(); if (i.host && i.host.x != null) { const [hx, hy] = toScreen(i.host.x, i.host.y); ctx.strokeStyle = 'rgba(255,255,255,.1)'; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(hx, hy); ctx.stroke(); } }
212
- else { ctx.fillStyle = '#141418'; ctx.beginPath(); ctx.arc(sx, sy, 3, 0, TAU); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,.18)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, 3, 0, TAU); ctx.stroke(); }
213
- if (isPicked || isHover) { ctx.strokeStyle = isPicked ? rgba(ACC, .7) : 'rgba(255,255,255,.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, Math.max(d / 2 + 8, 12), 0, TAU); ctx.stroke(); }
235
+ else if (i.st === 'remnant') { ctx.strokeStyle = ink(.4); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, d / 2 * 1.15, 0, TAU); ctx.stroke(); }
236
+ else if (i.st === 'protostar') { const aw = i.awaiting || (i.state && i.state.awaitingNod); const pulse = aw && !reduce ? 0.5 + 0.5 * Math.sin(tt * 2 + hash(i.id) * 6) : 0; const c = tone(SKY.temp(i)); ctx.fillStyle = rgba(c, .06); ctx.beginPath(); ctx.arc(sx, sy, 7 + pulse * 6, 0, TAU); ctx.fill(); ctx.fillStyle = aw ? rgba(ACC, 0.55 + 0.45 * pulse) : rgba(c, i.well ? .6 : .5); ctx.beginPath(); ctx.arc(sx, sy, 3.2, 0, TAU); ctx.fill(); }
237
+ else if (i.st === 'satellite') { ctx.fillStyle = ink(.4); ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, TAU); ctx.fill(); ctx.strokeStyle = ink(.16); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, 6.5, 0, TAU); ctx.stroke(); if (i.host && i.host.x != null) { const [hx, hy] = toScreen(i.host.x, i.host.y); ctx.strokeStyle = ink(.1); ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(hx, hy); ctx.stroke(); } }
238
+ else { ctx.fillStyle = rgba(COLD, 1); ctx.beginPath(); ctx.arc(sx, sy, 3, 0, TAU); ctx.fill(); ctx.strokeStyle = ink(.18); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, 3, 0, TAU); ctx.stroke(); }
239
+ if (isPicked || isHover) { ctx.strokeStyle = isPicked ? rgba(ACC, .7) : ink(.35); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(sx, sy, Math.max(d / 2 + 8, 12), 0, TAU); ctx.stroke(); }
214
240
  }
215
241
  function drawTask(t, sx, sy, tt, isPicked, isHover) {
216
242
  ctx.setLineDash([]); ctx.lineWidth = 1; const s = t.status;
217
243
  if (s === 'active') { const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 9); g.addColorStop(0, rgba(ACC, .6)); g.addColorStop(1, rgba(ACC, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(sx, sy, 9, 0, TAU); ctx.fill(); ctx.fillStyle = rgba(ACC, 1); ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, TAU); ctx.fill(); }
218
- else if (s === 'shipped' || s === 'confirmed' || s === 'completed') { ctx.fillStyle = 'rgba(255,255,255,.55)'; ctx.beginPath(); ctx.arc(sx, sy, 1.8, 0, TAU); ctx.fill(); }
219
- else if (s === 'ready') { ctx.strokeStyle = 'rgba(255,255,255,.7)'; ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, TAU); ctx.stroke(); }
220
- else if (s === 'backlog') { ctx.strokeStyle = 'rgba(255,255,255,.28)'; ctx.beginPath(); ctx.arc(sx, sy, 2.4, 0, TAU); ctx.stroke(); }
221
- else if (s === 'blocked') { ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, TAU); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sx - 4, sy + 4); ctx.lineTo(sx + 4, sy - 4); ctx.stroke(); }
222
- else { ctx.strokeStyle = 'rgba(255,255,255,.22)'; ctx.beginPath(); ctx.moveTo(sx - 3, sy); ctx.lineTo(sx + 3, sy); ctx.stroke(); }
223
- if (isPicked || isHover) { ctx.strokeStyle = isPicked ? rgba(ACC, .7) : 'rgba(255,255,255,.4)'; ctx.beginPath(); ctx.arc(sx, sy, 9, 0, TAU); ctx.stroke(); }
244
+ else if (s === 'shipped' || s === 'confirmed' || s === 'completed') { ctx.fillStyle = ink(.55); ctx.beginPath(); ctx.arc(sx, sy, 1.8, 0, TAU); ctx.fill(); }
245
+ else if (s === 'ready') { ctx.strokeStyle = ink(.7); ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, TAU); ctx.stroke(); }
246
+ else if (s === 'backlog') { ctx.strokeStyle = ink(.28); ctx.beginPath(); ctx.arc(sx, sy, 2.4, 0, TAU); ctx.stroke(); }
247
+ else if (s === 'blocked') { ctx.strokeStyle = ink(.5); ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, TAU); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sx - 4, sy + 4); ctx.lineTo(sx + 4, sy - 4); ctx.stroke(); }
248
+ else { ctx.strokeStyle = ink(.22); ctx.beginPath(); ctx.moveTo(sx - 3, sy); ctx.lineTo(sx + 3, sy); ctx.stroke(); }
249
+ if (isPicked || isHover) { ctx.strokeStyle = isPicked ? rgba(ACC, .7) : ink(.4); ctx.beginPath(); ctx.arc(sx, sy, 9, 0, TAU); ctx.stroke(); }
224
250
  }
225
251
 
226
252
  // ---------------------------------------------------------------- hit testing
@@ -241,5 +267,6 @@
241
267
  return null;
242
268
  }
243
269
 
244
- Object.assign(SKY, { ctx, ACC, CHART, rgba, goalAlpha, draw, drawThought, drawTask, hit });
270
+ // INK/VOID/ink/tone/readPalette are exported for tests/hall_sky.mjs; COLD and TEMP stay private.
271
+ Object.assign(SKY, { ctx, ACC, CHART, INK, VOID, rgba, ink, tone, readPalette, goalAlpha, draw, drawThought, drawTask, hit });
245
272
  })();