@bongos/core 1.24.79 → 1.24.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +495 -350
- package/.claude/skills/ask-for-help/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0274-one-kernel-three-role-packs.md +2 -2
- package/docs/adr/0399-the-visor-is-glass-over-the-scene-and-owns-no-camera.md +91 -0
- package/docs/adr/0400-a-chain-reserves-work-and-each-sub-quest-is-still-claimed-alone.md +109 -0
- package/docs/adr/0401-one-options-menu-opens-every-settings-screen-in-a-frame.md +53 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +195 -3
- package/docs/api-reference.md +8 -4
- package/docs/architecture.md +7 -3
- package/docs/copy-inventory.md +390 -370
- package/docs/copy-registry.json +719 -518
- package/docs/design/characters/character-sprites-source.webp +0 -0
- package/docs/file-map.md +7 -8
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +5 -0
- package/docs/onboarding/slash-commands.md +1 -1
- package/docs/packs/{engineer.md → artisan.md} +5 -5
- package/docs/packs/artist.md +50 -4
- package/docs/page-inventory.json +15 -4
- package/docs/page-readings.json +3182 -3336
- package/docs/{packs/ideator.md → recipes/ideation-playbook.md} +5 -48
- package/migrations/core_296_crafts_artisan_artist.sql +72 -0
- package/migrations/core_297_builder_character.sql +30 -0
- package/modules/copy-desk/proposals.js +1 -1
- package/modules/economy/credits-by-craft.js +29 -15
- package/modules/hall-ui/public/ai-studio.html +4 -4
- package/modules/hall-ui/public/atlas.html +5 -5
- package/modules/hall-ui/public/atlas.js +3 -1
- package/modules/hall-ui/public/blockers.html +5 -5
- package/modules/hall-ui/public/board-room.html +4 -4
- package/modules/hall-ui/public/characters/artisan.png +0 -0
- package/modules/hall-ui/public/characters/artist.png +0 -0
- package/modules/hall-ui/public/characters/patron.png +0 -0
- package/modules/hall-ui/public/city.css +2 -1
- package/modules/hall-ui/public/city.html +9 -6
- package/modules/hall-ui/public/city.js +7 -1
- package/modules/hall-ui/public/collab.html +4 -4
- package/modules/hall-ui/public/commands.html +4 -4
- package/modules/hall-ui/public/copy-desk.html +4 -4
- package/modules/hall-ui/public/craft-mode.js +258 -64
- package/modules/hall-ui/public/deploy.html +4 -4
- package/modules/hall-ui/public/diagram-viewer.js +2 -1
- package/modules/hall-ui/public/dom-utils.js +1 -1
- package/modules/hall-ui/public/goal-map.html +9 -6
- package/modules/hall-ui/public/goals.html +5 -5
- package/modules/hall-ui/public/government.html +4 -4
- package/modules/hall-ui/public/hall-render.js +3 -1
- package/modules/hall-ui/public/home-city-draw.js +3 -3
- package/modules/hall-ui/public/home-city.css +4 -0
- package/modules/hall-ui/public/home-city.js +17 -9
- package/modules/hall-ui/public/home-gallery.js +8 -1
- package/modules/hall-ui/public/home-idea-box.css +2 -2
- package/modules/hall-ui/public/home-idea-box.js +3 -3
- package/modules/hall-ui/public/idea.html +5 -5
- package/modules/hall-ui/public/ideas.html +5 -5
- package/modules/hall-ui/public/index.html +11 -7
- package/modules/hall-ui/public/market-watch-item.html +4 -4
- package/modules/hall-ui/public/market-watch-rules.html +4 -4
- package/modules/hall-ui/public/market-watch.html +6 -9
- package/modules/hall-ui/public/market-watch.js +22 -45
- package/modules/hall-ui/public/modules.html +4 -4
- package/modules/hall-ui/public/money.html +4 -4
- package/modules/hall-ui/public/my-agents.html +4 -4
- package/modules/hall-ui/public/options-menu.js +409 -0
- package/modules/hall-ui/public/paintbrush.js +3 -3
- package/modules/hall-ui/public/palette.js +11 -13
- package/modules/hall-ui/public/primer.html +5 -5
- package/modules/hall-ui/public/profile-crafts.js +1 -1
- package/modules/hall-ui/public/profile-roles.js +23 -19
- package/modules/hall-ui/public/profile.css +1 -1
- package/modules/hall-ui/public/profile.html +19 -18
- package/modules/hall-ui/public/profile.js +19 -18
- package/modules/hall-ui/public/profile.states.json +4 -4
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/roster.html +4 -4
- package/modules/hall-ui/public/roster.js +3 -3
- package/modules/hall-ui/public/settings.css +24 -4
- package/modules/hall-ui/public/settings.html +21 -17
- package/modules/hall-ui/public/settings.js +44 -44
- package/modules/hall-ui/public/shell-game.css +65 -0
- package/modules/hall-ui/public/shell-game.js +283 -3
- package/modules/hall-ui/public/shell.js +7 -26
- package/modules/hall-ui/public/sky-interaction.js +7 -0
- package/modules/hall-ui/public/speciality-tools.js +1 -2
- package/modules/hall-ui/public/street-inside.js +3 -4
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/studio.html +5 -5
- package/modules/hall-ui/public/style.css +73 -47
- package/modules/hall-ui/public/task-detail.js +5 -4
- package/modules/hall-ui/public/task.html +5 -5
- package/modules/hall-ui/public/thinking.css +10 -0
- package/modules/hall-ui/public/thinking.html +9 -6
- package/modules/hall-ui/public/tweak-editor.html +5 -5
- package/modules/hall-ui/public/tweak-editor.js +5 -1
- package/modules/hall-ui/public/update-pill.js +91 -2
- package/modules/hall-ui/public/visor.css +80 -0
- package/modules/hall-ui/public/visor.js +316 -0
- package/modules/hall-ui/public/watch-repo-invite.js +159 -0
- package/modules/hall-ui/public/watch.html +6 -5
- package/modules/hall-ui/public/watch.js +17 -2
- package/modules/hall-ui/public/welcome-boot.js +15 -6
- package/modules/hall-ui/public/work.html +5 -5
- package/modules/hall-ui/records/builders-roster.md +2 -2
- package/modules/hall-ui/records/craft-mode.md +13 -11
- package/modules/hall-ui/records/game-mode.md +2 -2
- package/modules/hall-ui/records/governor-street.md +1 -1
- package/modules/hall-ui/records/home-city.md +3 -1
- package/modules/hall-ui/records/market-watch-settings.md +2 -0
- package/modules/hall-ui/records/options-menu.md +15 -0
- package/modules/hall-ui/records/profile-roles.md +11 -11
- package/modules/hall-ui/records/sky.md +3 -3
- package/modules/hall-ui/records/start-screen.md +21 -0
- package/modules/hall-ui/records/tweak-editor.md +1 -1
- package/modules/hall-ui/records/update-pill.md +12 -0
- package/modules/hall-ui/records/visor.md +23 -0
- package/modules/hall-ui/records/welcome-tower.md +5 -5
- package/modules/ideas/ideator-stats.js +3 -1
- package/modules/ideas/inbox.js +2 -2
- package/modules/lifecycle/activity.js +1 -1
- package/modules/lifecycle/cascade.js +4 -4
- package/modules/lifecycle/goal-spec-task.js +1 -1
- package/modules/lifecycle/help-requests.js +19 -15
- package/modules/lifecycle/kickoff-checklist.js +3 -3
- package/modules/lifecycle/kickoff-seed.js +1 -1
- package/modules/lifecycle/migrations/lifecycle_021_crafts_artisan_artist.sql +29 -0
- package/modules/lifecycle/mingle.js +4 -4
- package/modules/lifecycle/page-tweak-write.js +1 -1
- package/modules/lifecycle/routes/task-write-routes.js +3 -1
- package/modules/lifecycle/routes/tasks.js +3 -3
- package/modules/lifecycle/ship-card.js +2 -3
- package/modules/lifecycle/task-classifier.js +39 -29
- package/modules/lifecycle/tests/cascade.mjs +7 -7
- package/modules/onboarding/founding-mode.js +3 -0
- package/modules/onboarding/genesis-home.js +21 -1
- package/modules/onboarding/routes/onboarding.js +2 -0
- package/modules/page-notes/notes.js +3 -2
- package/modules/page-notes/split.js +1 -1
- package/modules/platform-identity/craft-rollup.js +35 -7
- package/modules/platform-identity/migrations/platform_identity_036_crafts_artisan_artist.sql +45 -0
- package/modules/platform-identity/platform-identity.js +2 -2
- package/modules/platform-identity/routes/community-leaderboard.js +2 -1
- package/modules/platform-identity/routes/community-search.js +2 -1
- package/modules/platform-identity/routes/sso.js +2 -1
- package/modules/platform-identity/tests/platform-identity.mjs +6 -6
- package/modules/public-landing/public/community.html +3 -5
- package/modules/public-landing/public/index.html +1 -1
- package/modules/public-landing/public/projects.html +1 -1
- package/modules/specialities/migrations/specialities_004_crafts_artisan_artist.sql +30 -0
- package/modules/specialities/routes/specialities.js +2 -2
- package/modules/status-ui/public/status.js +1 -1
- package/modules/ui-design/kit/fixtures/community-leaderboard.json +10 -10
- package/modules/ui-design/kit/fixtures/community-search.json +2 -2
- package/modules/ui-design/kit/fixtures/scouting-recruiter.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +70 -0
- package/scripts/bongos/autobongos-loop.js +1 -1
- package/scripts/bongos/discipline-modes.json +7 -12
- package/scripts/bongos/fitness.js +1 -1
- package/scripts/bongos/gen-hall-sitemap.js +7 -7
- package/scripts/bongos/grade-smoke.js +2 -2
- package/scripts/bongos/init.js +2 -2
- package/scripts/bongos/newcomer-floor.config.json +1 -2
- package/scripts/bongos/newcomer-floor.js +4 -3
- package/scripts/bongos/newcomer-floor.templates.json +10 -12
- package/scripts/bongos/newcomer-floor.test.js +47 -38
- package/scripts/bongos/role-pack-guard.js +5 -4
- package/scripts/bongos/seed-tasks.example.json +3 -3
- package/scripts/bongos/setup.js +20 -107
- package/scripts/bongos/start.js +2 -2
- package/scripts/bongos/task.js +5 -4
- package/scripts/bongos/triage.js +1 -1
- package/scripts/bongos/worktree.js +16 -4
- package/scripts/setup-builder.js +6 -46
- package/src/bongos/auth-admission.js +8 -8
- package/src/bongos/craft-mode.js +2 -0
- package/src/bongos/db.js +39 -1
- package/src/bongos/github-repos.js +78 -0
- package/src/bongos/module-scope-map.js +22 -0
- package/src/bongos/role-stats.js +27 -11
- package/src/bongos/routes/github.js +100 -2
- package/src/bongos/routes/me.js +33 -0
- package/src/bongos/serve-internal.js +26 -9
- package/src/crafts.js +66 -0
- package/src/module-api.js +6 -1
- package/src/module-loader/manifest-schema.js +16 -0
- package/src/modules.js +2 -2
- package/src/role-responsibilities.js +6 -5
- package/tests/acclaim_feed_evidence_db.mjs +1 -1
- package/tests/activity_rollup_as_of.mjs +1 -1
- package/tests/api_alias_caller_ratchet.mjs +3 -3
- package/tests/application_lifecycle.mjs +1 -1
- package/tests/artist_review.mjs +1 -1
- package/tests/artist_review_goal.mjs +1 -1
- package/tests/blocker_hall_live_proof.mjs +1 -1
- package/tests/blocker_tasks_named_db.mjs +2 -2
- package/tests/canonical_profile_url.mjs +1 -1
- package/tests/cap_newcomer_tasks.mjs +13 -13
- package/tests/character_creation.mjs +294 -0
- package/tests/claim_gate_open_blocker.mjs +2 -2
- package/tests/claim_gate_rebase.mjs +6 -6
- package/tests/claude_materialize.mjs +1 -1
- package/tests/clawback_confirmed_backlog.mjs +3 -3
- package/tests/collab_page.mjs +8 -9
- package/tests/collab_self_settling.mjs +2 -2
- package/tests/community_leaderboard_route.mjs +13 -5
- package/tests/community_proof_db.mjs +3 -0
- package/tests/community_search_route.mjs +7 -6
- package/tests/conductor.mjs +1 -1
- package/tests/conductor_main_e2e.mjs +8 -8
- package/tests/copy_desk_flags_db.mjs +1 -1
- package/tests/copy_desk_page_approve.mjs +2 -2
- package/tests/copy_desk_page_claim.mjs +2 -2
- package/tests/copy_desk_page_next.mjs +1 -1
- package/tests/copy_desk_page_release.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +2 -2
- package/tests/core_258_box_session_claims_db.mjs +1 -1
- package/tests/craft_mode.mjs +48 -34
- package/tests/craft_mode_home.mjs +20 -20
- package/tests/craft_mode_looks.mjs +16 -15
- package/tests/craft_mode_nav.mjs +31 -29
- package/tests/craft_review_mode.mjs +46 -41
- package/tests/crafts_vocabulary.mjs +70 -0
- package/tests/cross_discipline_priority_direction.mjs +5 -5
- package/tests/fitness.mjs +7 -11
- package/tests/funded_project_pays_proof_db.mjs +1 -1
- package/tests/genesis_home.mjs +21 -1
- package/tests/github_chip.mjs +3 -3
- package/tests/github_repo_invite.mjs +379 -0
- package/tests/goal_criterion_confirm_ui.mjs +1 -1
- package/tests/goal_invite_consent.mjs +1 -1
- package/tests/goal_map_page.mjs +2 -2
- package/tests/goal_sort_staleness.mjs +1 -1
- package/tests/goal_spec_task.mjs +1 -1
- package/tests/goal_spec_task_db.mjs +3 -3
- package/tests/grade_engine.mjs +2 -2
- package/tests/grader_no_artifact.mjs +1 -1
- package/tests/grader_prompt_fencing.mjs +1 -1
- package/tests/grader_prompt_window_bound.mjs +1 -1
- package/tests/grader_reader_lens.mjs +2 -2
- package/tests/grader_severity_gate.mjs +1 -1
- package/tests/grader_unbacked_score.mjs +1 -1
- package/tests/hall_approval_queue.mjs +1 -1
- package/tests/hall_board_world.mjs +2 -2
- package/tests/hall_builders_page_pass.mjs +1 -1
- package/tests/hall_city.mjs +1 -1
- package/tests/hall_fullscreen.mjs +27 -3
- package/tests/hall_kit_charts.mjs +1 -1
- package/tests/hall_landing_boot.mjs +1 -1
- package/tests/hall_my_agents.mjs +16 -16
- package/tests/hall_nav.mjs +22 -15
- package/tests/hall_options_menu.mjs +247 -0
- package/tests/hall_oversight_world.mjs +1 -1
- package/tests/hall_palette.mjs +8 -9
- package/tests/hall_profile_world.mjs +3 -3
- package/tests/hall_record_world.mjs +2 -2
- package/tests/hall_settings_world.mjs +2 -1
- package/tests/hall_sitemap.mjs +6 -5
- package/tests/hall_start_screen.mjs +244 -0
- package/tests/hall_street.mjs +2 -2
- package/tests/hall_street_inside.mjs +17 -9
- package/tests/hall_studio_world.mjs +3 -3
- package/tests/hall_visor.mjs +224 -0
- package/tests/help_requests.mjs +47 -15
- package/tests/helpers.mjs +4 -4
- package/tests/home_city.mjs +6 -4
- package/tests/home_city_widget.mjs +33 -14
- package/tests/home_gallery_widget.mjs +4 -4
- package/tests/home_idea_box.mjs +6 -6
- package/tests/hub_community_page.mjs +3 -3
- package/tests/hub_craft_rollup.mjs +75 -36
- package/tests/hub_recent_ships.mjs +3 -2
- package/tests/idea_cascade.mjs +2 -2
- package/tests/idea_credit_streams_db.mjs +1 -1
- package/tests/idea_detail_page.mjs +1 -1
- package/tests/idea_ideate_full.mjs +5 -2
- package/tests/idea_spark_hall.mjs +1 -1
- package/tests/init.mjs +3 -3
- package/tests/instance_manifest.mjs +2 -2
- package/tests/kickoff_checklist.mjs +1 -1
- package/tests/leaderboard_roles.mjs +34 -25
- package/tests/lifecycle_claim_ladder_refs.mjs +55 -0
- package/tests/lightbulb.mjs +14 -10
- package/tests/main_discipline.mjs +8 -8
- package/tests/markdown_line_endings.mjs +1 -1
- package/tests/market_watch_pages.mjs +52 -74
- package/tests/me_streak_leaderboard_queries.mjs +3 -3
- package/tests/migration_allocation.mjs +1 -1
- package/tests/mingle.mjs +36 -21
- package/tests/module-scope-map.mjs +22 -2
- package/tests/module_api.mjs +1 -0
- package/tests/module_contributions.mjs +13 -13
- package/tests/module_manifest.mjs +29 -0
- package/tests/module_skills_hook.mjs +2 -2
- package/tests/modules.mjs +14 -7
- package/tests/nav_permission_atoms.mjs +2 -1
- package/tests/needs_rebase.mjs +1 -1
- package/tests/newcomer_restock.mjs +13 -13
- package/tests/page_notes_panel.mjs +22 -22
- package/tests/page_tweak_no_block.mjs +1 -1
- package/tests/paintbrush.mjs +7 -7
- package/tests/privacy_matrix.mjs +1 -0
- package/tests/profile_activity.mjs +20 -23
- package/tests/profile_earnings.mjs +1 -1
- package/tests/profile_nudge_prefs.mjs +4 -4
- package/tests/profile_role_headline.mjs +73 -46
- package/tests/profile_role_stats.mjs +55 -45
- package/tests/profile_rollup_consent.mjs +19 -6
- package/tests/profile_route.mjs +12 -12
- package/tests/profile_ui_cross_project.mjs +4 -4
- package/tests/project_invite_suggestions.mjs +2 -2
- package/tests/promote_goal_id.mjs +1 -1
- package/tests/publish_manifest.mjs +1 -1
- package/tests/rank_floor_wiring.mjs +1 -1
- package/tests/regrade_actual_minutes.mjs +1 -1
- package/tests/removal_proposal.mjs +2 -2
- package/tests/reward_gate.mjs +4 -4
- package/tests/reward_mode_cost_plus_only.mjs +1 -1
- package/tests/role_responsibilities.mjs +18 -12
- package/tests/role_stats_db.mjs +17 -16
- package/tests/scouting_rollup_fanout.mjs +5 -5
- package/tests/seed_tasks.mjs +5 -2
- package/tests/session_earnings_mirror_db.mjs +1 -1
- package/tests/session_reward_on_land_db.mjs +1 -1
- package/tests/session_reward_reconcile_db.mjs +2 -2
- package/tests/sessions_kit_pager.mjs +1 -1
- package/tests/settings_art_key_absent.mjs +2 -2
- package/tests/settings_main_role.mjs +21 -20
- package/tests/setup_flow.mjs +3 -3
- package/tests/setup_smoke.mjs +20 -0
- package/tests/ship_card.mjs +6 -6
- package/tests/specialities.mjs +16 -4
- package/tests/speciality_db_shape.mjs +6 -6
- package/tests/speciality_injection.mjs +17 -17
- package/tests/speciality_offer_ui.mjs +5 -5
- package/tests/speciality_offered.mjs +6 -5
- package/tests/speciality_session_skills.mjs +7 -7
- package/tests/speciality_skills.mjs +11 -11
- package/tests/speciality_walkthrough_ui.mjs +2 -2
- package/tests/sso_checkin_identity_spoof.mjs +2 -1
- package/tests/task_classifier_vocab.mjs +10 -0
- package/tests/task_cli.mjs +11 -3
- package/tests/task_criteria_unlink_db.mjs +2 -2
- package/tests/task_discipline_required.mjs +38 -16
- package/tests/task_goal_required.mjs +4 -4
- package/tests/task_needs_db.mjs +1 -1
- package/tests/tweak_replaces_artist_review.mjs +1 -1
- package/tests/update_pill.mjs +153 -2
- package/tests/upkeep_home.mjs +1 -1
- package/tests/v3_c1_options_proof.mjs +145 -0
- package/tests/visual_review.mjs +3 -3
- package/tests/watch_applications_queue.mjs +8 -5
- package/tests/watch_sealed_floor.mjs +3 -1
- package/tests/welcome_boot.mjs +12 -9
- package/tests/welcome_boot_browser.mjs +13 -13
- package/tests/worktree_helper.mjs +16 -1
|
@@ -93,7 +93,8 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
|
|
|
93
93
|
addEventListener() {}, dispatchEvent() {},
|
|
94
94
|
get cookie() { return ''; }, set cookie(_v) {},
|
|
95
95
|
};
|
|
96
|
-
const
|
|
96
|
+
const assigned = []; // task 1005144: where the page sent the browser (game mode's Escape opens the menu)
|
|
97
|
+
const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash, search, assign(u) { assigned.push(u); }, replace() {} };
|
|
97
98
|
// `session` is the TAB's sessionStorage: pass one object to several boots and
|
|
98
99
|
// they are page loads in one tab (the trail, task 1004476).
|
|
99
100
|
const sessionStorage = {
|
|
@@ -133,6 +134,7 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
|
|
|
133
134
|
const root = documentObj.documentElement;
|
|
134
135
|
return {
|
|
135
136
|
store,
|
|
137
|
+
assigned,
|
|
136
138
|
game: () => windowObj.OTBShellGame,
|
|
137
139
|
gameStamp: () => root.dataset.game === 'on',
|
|
138
140
|
// The "how to leave" line: showing = the shell created it and it is not hidden.
|
|
@@ -518,8 +520,8 @@ test('game mode: every hall page carries the switch — none opts out, and each
|
|
|
518
520
|
test('game mode: leaving brings the bars back for THIS page only, and never rewrites a page\'s own memory', () => {
|
|
519
521
|
const store = { 'hall-game': 'on', 'hall-fullscreen:thinking': 'on' };
|
|
520
522
|
const goals = boot({ page: 'goals', optIn: false, store });
|
|
521
|
-
goals.
|
|
522
|
-
assert.equal(goals.on(), false, '
|
|
523
|
+
goals.button().click();
|
|
524
|
+
assert.equal(goals.on(), false, 'the corner button still leaves (Escape opens the options menu, below)');
|
|
523
525
|
assert.equal(store['hall-fullscreen:goals'], undefined, 'nothing remembered for the page');
|
|
524
526
|
assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'the next load opens without bars again');
|
|
525
527
|
const sky = boot({ page: 'thinking', store });
|
|
@@ -528,6 +530,28 @@ test('game mode: leaving brings the bars back for THIS page only, and never rewr
|
|
|
528
530
|
assert.equal(store['hall-fullscreen:thinking'], 'on', 'the sky\'s own choice survives game mode');
|
|
529
531
|
});
|
|
530
532
|
|
|
533
|
+
// task 1005144 (BONGOS-V3 R04, ADR 0399 §2.4 row 6): in game mode the last Escape is the
|
|
534
|
+
// options menu's. It opens the menu, remembering where it came from, and the bars stay away.
|
|
535
|
+
test('game mode: the last Escape opens the options menu, and the bars stay hidden', () => {
|
|
536
|
+
const store = { 'hall-game': 'on' };
|
|
537
|
+
const goals = boot({ page: 'goals', optIn: false, store, hash: '#/goal/7' });
|
|
538
|
+
goals.press('Escape');
|
|
539
|
+
assert.equal(goals.on(), true, 'the shell did not take the press');
|
|
540
|
+
assert.deepEqual(goals.assigned, ['/settings?from=' + encodeURIComponent('/goals#/goal/7')], 'the menu, and the way back');
|
|
541
|
+
const apex = boot({ page: 'goals', optIn: false, store, path: '/builders/goals' });
|
|
542
|
+
apex.press('Escape');
|
|
543
|
+
assert.deepEqual(apex.assigned, ['/builders/settings?from=' + encodeURIComponent('/builders/goals')], 'on the apex too');
|
|
544
|
+
const used = boot({ page: 'goals', optIn: false, store });
|
|
545
|
+
used.press('Escape', { pageUses: true });
|
|
546
|
+
used.press('Escape', { target: { tagName: 'INPUT' } });
|
|
547
|
+
assert.deepEqual(used.assigned, [], 'a press a page used, or one in a field, never opens it');
|
|
548
|
+
const plain = boot({ page: 'goals', optIn: false, store: {} });
|
|
549
|
+
plain.button().click();
|
|
550
|
+
plain.press('Escape');
|
|
551
|
+
assert.deepEqual(plain.assigned, [], 'outside game mode Escape is the shell\'s, as before');
|
|
552
|
+
assert.equal(plain.on(), false, 'and it leaves full screen');
|
|
553
|
+
});
|
|
554
|
+
|
|
531
555
|
test('game mode: the Escape hand-off is unchanged — a press the page used, or one in a field, does not leave', () => {
|
|
532
556
|
const p = boot({ page: 'goals', optIn: false, store: { 'hall-game': 'on' } });
|
|
533
557
|
p.press('Escape', { pageUses: true });
|
|
@@ -379,7 +379,7 @@ test('the migrated pages run for real against the kit and emit its classes', ()
|
|
|
379
379
|
assert.equal(typeof s.window.OTBHallRender, 'function', 'the renderer factory survived the migration');
|
|
380
380
|
assert.equal(typeof s.window.OTBKit.barSeriesHtml, 'function');
|
|
381
381
|
assert.match(
|
|
382
|
-
s.window.OTBKit.barSeriesHtml([{ label: '
|
|
382
|
+
s.window.OTBKit.barSeriesHtml([{ label: 'artisan', value: 3 }]),
|
|
383
383
|
/class="kit-bars"/,
|
|
384
384
|
);
|
|
385
385
|
});
|
|
@@ -72,7 +72,7 @@ async function boot({ meStatus, settle = false }) {
|
|
|
72
72
|
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
73
73
|
roleAxisLabel: () => 'Role',
|
|
74
74
|
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
75
|
-
roleOptions: () => [{ value: '', label: '— any —' }, { value: '
|
|
75
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
|
|
76
76
|
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
77
77
|
ROLE_UNSET: 'unclassified',
|
|
78
78
|
moduleOn: () => true,
|
package/tests/hall_my_agents.mjs
CHANGED
|
@@ -388,48 +388,48 @@ function bootCraft({ session = {} } = {}) {
|
|
|
388
388
|
}
|
|
389
389
|
const seatOf = (has, title) => ({ known: true, has_position: has, title: has ? title : null });
|
|
390
390
|
|
|
391
|
-
test('CRAFT: with no seat, Governing is not offered anywhere', () => {
|
|
391
|
+
test('CRAFT: with no seat, Patron (Governing until task 1005141) is not offered anywhere', () => {
|
|
392
392
|
const b = bootCraft();
|
|
393
393
|
b.craft.setPositions(seatOf(false));
|
|
394
|
-
assert.match(b.craft.switchHtml(), /data-craft-opt="
|
|
395
|
-
b.craft.choose('
|
|
394
|
+
assert.match(b.craft.switchHtml(), /data-craft-opt="patron"[^>]* hidden>/, 'the switch hides it');
|
|
395
|
+
b.craft.choose('patron');
|
|
396
396
|
assert.equal(b.root.dataset.craft, undefined, 'and choosing it does nothing');
|
|
397
397
|
b.craft.open();
|
|
398
398
|
const picker = b.body.children[0];
|
|
399
399
|
assert.ok(picker, 'the picker opened');
|
|
400
|
-
assert.doesNotMatch(picker.innerHTML, /data-craft-pick="
|
|
401
|
-
assert.match(picker.innerHTML, /data-doors="
|
|
400
|
+
assert.doesNotMatch(picker.innerHTML, /data-craft-pick="patron"/, 'no Patron door');
|
|
401
|
+
assert.match(picker.innerHTML, /data-doors="2"/, 'two doors, laid out as two');
|
|
402
402
|
});
|
|
403
403
|
|
|
404
|
-
test('CRAFT: a stored Governing reads as no mode for a builder known to hold no seat', () => {
|
|
404
|
+
test('CRAFT: a stored Patron — even under its pre-rename id, Governing — reads as no mode for a builder known to hold no seat', () => {
|
|
405
405
|
const b = bootCraft({ session: { 'hall-craft': 'governing' } });
|
|
406
|
-
assert.equal(b.craft.current(), '
|
|
406
|
+
assert.equal(b.craft.current(), 'patron', 'unknown seats: today\'s behaviour, the old id read as Patron (task 1005141)');
|
|
407
407
|
b.craft.setPositions(seatOf(false));
|
|
408
408
|
assert.equal(b.craft.current(), null);
|
|
409
409
|
assert.equal(b.session['hall-craft'], undefined, 'and the stored choice is dropped');
|
|
410
410
|
});
|
|
411
411
|
|
|
412
|
-
test('CRAFT: a seat names the
|
|
412
|
+
test('CRAFT: a seat names the Patron mode', () => {
|
|
413
413
|
const b = bootCraft();
|
|
414
414
|
b.craft.setPositions(seatOf(true, 'Treasury Lead'));
|
|
415
415
|
assert.equal(b.craft.seatName(), 'Treasury Lead');
|
|
416
|
-
assert.match(b.craft.switchHtml(), /data-craft-opt="
|
|
417
|
-
b.craft.choose('
|
|
418
|
-
assert.equal(b.root.dataset.craft, '
|
|
416
|
+
assert.match(b.craft.switchHtml(), /data-craft-opt="patron"[^>]*>[\s\S]*?Treasury Lead<\/span>/);
|
|
417
|
+
b.craft.choose('patron');
|
|
418
|
+
assert.equal(b.root.dataset.craft, 'patron');
|
|
419
419
|
b.craft.open();
|
|
420
420
|
assert.match(b.body.children[0].innerHTML, /craft-door__name">Treasury Lead</);
|
|
421
|
-
assert.match(b.body.children[0].innerHTML, /data-doors="
|
|
421
|
+
assert.match(b.body.children[0].innerHTML, /data-doors="3"/);
|
|
422
422
|
});
|
|
423
423
|
|
|
424
|
-
test('CRAFT: unknown seats are today\'s picker —
|
|
424
|
+
test('CRAFT: unknown seats are today\'s picker — three doors and a free Patron', () => {
|
|
425
425
|
const b = bootCraft();
|
|
426
426
|
b.craft.setPositions(null);
|
|
427
427
|
assert.equal(b.craft.seatName(), null);
|
|
428
428
|
assert.doesNotMatch(b.craft.switchHtml(), / hidden>/);
|
|
429
|
-
assert.match(b.craft.switchHtml(), />
|
|
429
|
+
assert.match(b.craft.switchHtml(), />Patron<\/span>/);
|
|
430
430
|
b.craft.setPositions({ known: false, has_position: false });
|
|
431
|
-
b.craft.choose('
|
|
432
|
-
assert.equal(b.root.dataset.craft, '
|
|
431
|
+
b.craft.choose('patron');
|
|
432
|
+
assert.equal(b.root.dataset.craft, 'patron', 'an unknown answer never takes the mode away');
|
|
433
433
|
});
|
|
434
434
|
|
|
435
435
|
// ---------------------------------------------------------------------------
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -301,7 +301,8 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
301
301
|
// how your own Claude works for you, the personal half of "the city steers
|
|
302
302
|
// everyone, My agents steers yours". Its subject is the viewer's own agents.
|
|
303
303
|
// task 1005050: 'standing' (Accomplishments) left — your numbers are the one Profile page.
|
|
304
|
-
|
|
304
|
+
// task 1005144 (ADR 0401): 'my-agents' LEFT for the one options menu (the profile chip).
|
|
305
|
+
assert.deepEqual(nav[3].items.map((i) => i.id), ['profile'], 'You = your own numbers');
|
|
305
306
|
// task 1003444: 'people' LEFT the group — the owner deleted the hall's
|
|
306
307
|
// cross-project directory (ADR 0337 D5); its home is the hub, and /people
|
|
307
308
|
// redirects to /roster. Builders is the hall's directory now.
|
|
@@ -324,7 +325,7 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
324
325
|
// Economy and Diagrams explained what the Primer explains; they are its chapters
|
|
325
326
|
// now, so Docs is the Primer and the four references that are not a document.
|
|
326
327
|
assert.deepEqual(nav[6].items.map((i) => i.id),
|
|
327
|
-
['primer', 'commands', 'atlas', '
|
|
328
|
+
['primer', 'commands', 'atlas', 'apidocs'], 'Docs = reference material (Modules is an options menu entry since task 1005144)');
|
|
328
329
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
329
330
|
// 'project-settings' joins the Government group at the metic floor (task 1003280,
|
|
330
331
|
// goal 1000072) — it sits before 'government', which is the one archon-only item.
|
|
@@ -342,7 +343,12 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
342
343
|
// idea 1001310). The settings are an owner-only Settings tab on /market-watch now.
|
|
343
344
|
// 'gate' (Autobongos) and 'sessions' LEFT the group in task 1005049 (owner decision
|
|
344
345
|
// 2026-11-03, idea 1001315): they are tabs on Watch, and /gate + /sessions redirect there.
|
|
345
|
-
|
|
346
|
+
// task 1005144 (ADR 0401): 'project-settings' and 'government' (Permissions) LEFT for the
|
|
347
|
+
// one options menu; their URLs open it (serve-internal.js RETIRED_TO_OPTIONS).
|
|
348
|
+
assert.deepEqual(nav[5].items.map((i) => i.id), ['watch', 'money', 'deploy']);
|
|
349
|
+
for (const gone of ['/my-agents', '/project-settings', '/government', '/modules']) {
|
|
350
|
+
assert.ok(!nav.flatMap((g) => g.items).some((i) => i.href === gone), `no item points at ${gone}, an options menu entry now`);
|
|
351
|
+
}
|
|
346
352
|
assert.ok(!nav.flatMap((g) => g.items).some((i) => i.href === '/gate' || i.href === '/sessions'), 'no item points at a page that became a Watch tab');
|
|
347
353
|
// task 1005045: and no label is left twice anywhere in the sidebar, so the next pair
|
|
348
354
|
// fails here instead of in front of the owner.
|
|
@@ -792,7 +798,7 @@ test('no glyph path data reads as an IP address to the release leak scan', () =>
|
|
|
792
798
|
// block (task 1004713). These drive the real shell and the real gov-positions.js.
|
|
793
799
|
|
|
794
800
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
795
|
-
const GOVERNING = ['city', 'board-room', 'watch', '
|
|
801
|
+
const GOVERNING = ['city', 'board-room', 'watch', 'money', 'deploy'];
|
|
796
802
|
const lead = (building, title) => ({ kind: 'lead', seat: 'lead:' + building, building, title });
|
|
797
803
|
const block = (held) => ({ held, title: held.length ? held[0].title : null, has_position: held.length > 0 });
|
|
798
804
|
|
|
@@ -821,7 +827,7 @@ test('POSITION: a Lead keeps every governing surface, and the seat names it', as
|
|
|
821
827
|
const b = await seated({ builder: { id: '424242', rank: 'thetes' }, positions: block([lead('planning', 'Planning Lead')]) });
|
|
822
828
|
assert.equal(b.shell.positions().title, 'Planning Lead');
|
|
823
829
|
// a thetes Lead: the seat opens the governing tabs their rank and permissions allow
|
|
824
|
-
for (const id of ['board-room', 'city', '
|
|
830
|
+
for (const id of ['board-room', 'city', 'profile']) assert.equal(b.navHidden(id), false, `${id} shows`);
|
|
825
831
|
});
|
|
826
832
|
|
|
827
833
|
test('POSITION: the fixture viewer holds what the fixture /me says', async () => {
|
|
@@ -833,10 +839,10 @@ test('POSITION: the fixture viewer holds what the fixture /me says', async () =>
|
|
|
833
839
|
|
|
834
840
|
test('POSITION: a builder with no seat sees no governing surface, and only those go', async () => {
|
|
835
841
|
// an archon the server says holds no seat: rank, craft and permissions do not make one
|
|
836
|
-
const b = await seated({ builder: { id: '424242', rank: 'archon', preferred_disciplines: ['
|
|
842
|
+
const b = await seated({ builder: { id: '424242', rank: 'archon', preferred_disciplines: ['artisan'] }, positions: block([]) });
|
|
837
843
|
assert.equal(b.shell.positions().has_position, false);
|
|
838
|
-
for (const id of ['board-room', 'city', 'watch', '
|
|
839
|
-
for (const id of ['home', 'work', 'goals', '
|
|
844
|
+
for (const id of ['board-room', 'city', 'watch', 'money']) assert.equal(b.navHidden(id), true, `${id} hides`);
|
|
845
|
+
for (const id of ['home', 'work', 'goals', 'profile', 'primer']) assert.equal(b.navHidden(id), false, `${id} still shows`);
|
|
840
846
|
});
|
|
841
847
|
|
|
842
848
|
test('POSITION: unknown is today\'s nav — a /me with no positions block hides nothing', async () => {
|
|
@@ -859,18 +865,19 @@ test('POSITION: ?as=nobody views the hall with no seat, and can only hide', asyn
|
|
|
859
865
|
const b = await seated({ builder: me.builder, positions: me.positions, search: '?as=nobody' });
|
|
860
866
|
assert.equal(b.shell.positions().as, 'nobody');
|
|
861
867
|
for (const id of GOVERNING) assert.equal(b.navHidden(id), true, `${id} hides for the stand-in`);
|
|
862
|
-
assert.equal(b.navHidden('
|
|
868
|
+
assert.equal(b.navHidden('profile'), false);
|
|
863
869
|
// a xenos stand-in stays a xenos: the pretend rank never rises above the real one
|
|
864
870
|
const x = await seated({ builder: { id: '9', rank: 'xenos' }, positions: block([lead('planning', 'Planning Lead')]), search: '?as=nobody' });
|
|
865
871
|
assert.equal(x.navHidden('goal-map'), true, 'a member page stays hidden from a real xenos');
|
|
866
872
|
});
|
|
867
873
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
assert.ok(
|
|
874
|
+
// task 1005144 (ADR 0401): My agents, Project settings, Permissions and Modules are entries
|
|
875
|
+
// in the one options menu, reached by the profile chip, which now says so.
|
|
876
|
+
test('the settings pages left the sidebar for the options menu, and the profile chip names it', () => {
|
|
877
|
+
const html = boot().sidebarHtml;
|
|
878
|
+
const ids = parseNav(html).flatMap((g) => g.items).map((i) => i.id);
|
|
879
|
+
for (const gone of ['my-agents', 'project-settings', 'government', 'modules']) assert.ok(!ids.includes(gone), `${gone} is not a nav item`);
|
|
880
|
+
assert.ok(html.includes('<a class="shell-user" id="shell-user" href="/settings" title="Options" aria-label="Options"'), 'the chip opens the menu and says so');
|
|
874
881
|
});
|
|
875
882
|
|
|
876
883
|
// task 1005050 (owner decision 2026-11-03, idea 1001315): Accomplishments
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// tests/hall_options_menu.mjs — the ONE options menu (task 1005144, BONGOS-V3 R04,
|
|
2
|
+
// goal 1000141, criterion game-opens-not-navigates; ADR 0401).
|
|
3
|
+
//
|
|
4
|
+
// Every setting and configuration is reached from one vertical, grouped menu with a
|
|
5
|
+
// search box at the top: the Settings page. The screens that were pages of their own
|
|
6
|
+
// (My agents, Project settings, Modules, Permissions, Market Watch's settings and rules)
|
|
7
|
+
// open INSIDE it, in a frame asking for the old address with ?embed=1, and a plain visit
|
|
8
|
+
// to an old address is sent to the menu with that screen open. These run the real
|
|
9
|
+
// options-menu.js (its pure half), the real shell-game.js in a DOM stub, and read the
|
|
10
|
+
// server's redirect map, so a menu entry, its old URL and its frame cannot drift apart.
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import vm from 'node:vm';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
21
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
22
|
+
const Menu = createRequire(import.meta.url)(path.join(HALL, 'options-menu.js'));
|
|
23
|
+
const SERVE = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
|
|
24
|
+
|
|
25
|
+
const ids = (menu) => menu.flatMap((g) => g.items.map((i) => i.id));
|
|
26
|
+
const signed = (rank) => ({ authed: true, rank });
|
|
27
|
+
|
|
28
|
+
test('the menu holds every settings surface: Settings\' own groups and the six screens that were pages', () => {
|
|
29
|
+
const all = Menu.entries();
|
|
30
|
+
const embeds = all.filter((e) => e.embed).map((e) => e.embed).sort();
|
|
31
|
+
assert.deepEqual(embeds, ['/government', '/market-watch', '/market-watch-rules', '/modules', '/my-agents', '/project-settings']);
|
|
32
|
+
for (const e of all.filter((x) => x.embed)) {
|
|
33
|
+
assert.ok(fs.existsSync(path.join(HALL, e.embed.slice(1) + '.html')), `${e.embed}.html exists for the frame`);
|
|
34
|
+
}
|
|
35
|
+
// every native entry is a real group on the Settings page, and every group has an entry
|
|
36
|
+
const html = read('settings.html');
|
|
37
|
+
const groups = new Set([...html.matchAll(/data-group="([a-z-]+)"/g)].map((m) => m[1]));
|
|
38
|
+
const natives = all.filter((e) => e.native).map((e) => e.id);
|
|
39
|
+
for (const id of natives) assert.ok(groups.has(id), `#${id} is a Settings group`);
|
|
40
|
+
for (const g of groups) assert.ok(natives.includes(g), `the ${g} group is reachable from the menu`);
|
|
41
|
+
// ids are unique, so one address opens one thing
|
|
42
|
+
assert.equal(new Set(all.map((e) => e.id)).size, all.length);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the proof\'s three settings are all in the one menu: a sound, a CLI token and a module toggle', () => {
|
|
46
|
+
const menu = Menu.visibleMenu(signed('archon'));
|
|
47
|
+
const hit = (q) => Menu.search(q, menu)[0];
|
|
48
|
+
assert.equal(hit('sound').id, 'sound');
|
|
49
|
+
assert.deepEqual([hit('cli token').id, hit('cli token').anchor], ['access', 'cli']);
|
|
50
|
+
assert.equal(hit('module toggle').id, 'modules');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('who sees what: the same rank keys the sidebar used, and module and contribution gates', () => {
|
|
54
|
+
const xenos = ids(Menu.visibleMenu(signed('xenos')));
|
|
55
|
+
assert.ok(xenos.includes('account') && xenos.includes('my-agents') && xenos.includes('access') && xenos.includes('market-watch-rules'));
|
|
56
|
+
for (const id of ['modules', 'project-settings', 'permissions', 'market-watch']) assert.ok(!xenos.includes(id), `a xenos does not see ${id}`);
|
|
57
|
+
const thetes = ids(Menu.visibleMenu(signed('thetes')));
|
|
58
|
+
assert.ok(thetes.includes('modules') && !thetes.includes('project-settings'), 'Modules from member, as its sidebar item was');
|
|
59
|
+
const metic = ids(Menu.visibleMenu(signed('metic')));
|
|
60
|
+
assert.ok(metic.includes('project-settings') && metic.includes('permissions') && !metic.includes('market-watch'));
|
|
61
|
+
const archon = ids(Menu.visibleMenu(signed('archon')));
|
|
62
|
+
assert.ok(archon.includes('market-watch'), 'Market Watch settings: the owner, as its tab was');
|
|
63
|
+
assert.deepEqual(ids(Menu.visibleMenu({ authed: false, rank: 'archon' })), [], 'signed out: nothing');
|
|
64
|
+
const off = ids(Menu.visibleMenu(signed('archon'), { moduleOn: (k) => k !== 'market-watch' }));
|
|
65
|
+
assert.ok(!off.includes('market-watch') && !off.includes('market-watch-rules'), 'a module that is off has no entries');
|
|
66
|
+
assert.ok(!archon.includes('extensions'), 'Extensions only when a module contributed a panel');
|
|
67
|
+
assert.ok(ids(Menu.visibleMenu(signed('archon'), { contributed: true })).includes('extensions'));
|
|
68
|
+
// a group with nothing in it is not drawn
|
|
69
|
+
for (const g of Menu.visibleMenu(signed('xenos'))) assert.ok(g.items.length > 0, `${g.head} has entries`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the search: every word must match, label hits first, and a sub-place says where it is', () => {
|
|
73
|
+
const menu = Menu.visibleMenu(signed('archon'));
|
|
74
|
+
assert.deepEqual(Menu.search('', menu), []);
|
|
75
|
+
assert.deepEqual(Menu.search('zzzz', menu), []);
|
|
76
|
+
const talk = Menu.search('talks', menu)[0];
|
|
77
|
+
assert.deepEqual([talk.id, talk.anchor, talk.where], ['my-agents', 'talk', 'Your agents']);
|
|
78
|
+
assert.equal(Menu.search('PERMISSIONS', menu)[0].id, 'permissions', 'case does not matter');
|
|
79
|
+
// Enter opens the first line, so the first line must be the place meant
|
|
80
|
+
assert.equal(Menu.search('token', menu)[0].label, 'CLI access', 'the exact place above the group that holds it');
|
|
81
|
+
assert.equal(Menu.search('access', menu)[0].label, 'Access and keys', 'but a label hit on the group still leads');
|
|
82
|
+
assert.equal(Menu.search('danger zone', menu)[0].id, 'project-settings', 'a word only the entry\'s words carry still finds it');
|
|
83
|
+
// a viewer never finds what they cannot open
|
|
84
|
+
assert.ok(!Menu.search('permissions', Menu.visibleMenu(signed('xenos'))).some((h) => h.id === 'permissions'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('what an address opens: ?open=<entry> with its #anchor, a bare #<entry>, and nothing else', () => {
|
|
88
|
+
assert.deepEqual(Menu.target('?open=my-agents', '#talk'), { id: 'my-agents', anchor: 'talk' });
|
|
89
|
+
assert.deepEqual(Menu.target('?open=market-watch', ''), { id: 'market-watch', anchor: 'settings' }, 'an entry\'s own anchor');
|
|
90
|
+
assert.deepEqual(Menu.target('', '#sound'), { id: 'sound', anchor: null });
|
|
91
|
+
assert.equal(Menu.target('', '#cli'), null, 'a panel anchor is the page\'s own to land');
|
|
92
|
+
assert.equal(Menu.target('?open=nope', ''), null);
|
|
93
|
+
assert.equal(Menu.frameSrc(Menu.entry('my-agents'), 'talk', ''), '/my-agents?embed=1#talk');
|
|
94
|
+
assert.equal(Menu.frameSrc(Menu.entry('permissions'), null, '/builders'), '/builders/government?embed=1');
|
|
95
|
+
assert.equal(Menu.frameSrc(Menu.entry('sound'), null, ''), null, 'a native group has no frame');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('closing goes back only to a same-site path', () => {
|
|
99
|
+
assert.equal(Menu.closeTo('?from=' + encodeURIComponent('/goals#/goal/7')), '/goals#/goal/7');
|
|
100
|
+
for (const bad of ['//evil.example/x', 'https://evil.example', '/\\evil', '']) {
|
|
101
|
+
assert.equal(Menu.closeTo('?from=' + encodeURIComponent(bad)), null, `${bad || 'nothing'} is not a way back`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the server sends every old address to its entry, lets the frame through, and the two maps agree', () => {
|
|
106
|
+
const m = SERVE.match(/const RETIRED_TO_OPTIONS = Object\.freeze\((\{[\s\S]*?\})\);/);
|
|
107
|
+
assert.ok(m, 'the server map is a literal');
|
|
108
|
+
const server = vm.runInNewContext('(' + m[1] + ')');
|
|
109
|
+
const client = Object.fromEntries(Object.entries(Menu.RETIRED).map(([p, id]) => [p.slice(1), id]));
|
|
110
|
+
assert.deepEqual({ ...server }, client, 'serve-internal.js RETIRED_TO_OPTIONS and options-menu.js RETIRED are one map');
|
|
111
|
+
for (const id of Object.values(client)) assert.ok(Menu.entry(id), `${id} is a menu entry`);
|
|
112
|
+
const reDecl = 'const RETIRED_OPTIONS_RE = ';
|
|
113
|
+
const i = SERVE.indexOf(reDecl);
|
|
114
|
+
const rx = vm.runInNewContext(SERVE.slice(i + reDecl.length, SERVE.indexOf(';', i)));
|
|
115
|
+
for (const p of Object.keys(server)) {
|
|
116
|
+
for (const v of [`/builders/${p}`, `/builders/${p}/`, `/builders/${p}.html`]) assert.ok(rx.test(v), `${v} is redirected`);
|
|
117
|
+
}
|
|
118
|
+
for (const p of ['/builders/settings', '/builders/market-watch', '/builders/modulesx', '/modules/x/public/a.js']) assert.ok(!rx.test(p), `${p} is not`);
|
|
119
|
+
const block = SERVE.slice(i, SERVE.indexOf('});', i));
|
|
120
|
+
assert.match(block, /if \(embed === '1' && m\[1\] !== 'market-watch-settings'\) return next\(\);/, 'the frame passes through to the page and its own gate');
|
|
121
|
+
assert.match(block, /\/settings\?open=\$\{RETIRED_TO_OPTIONS\[m\[1\]\]\}/);
|
|
122
|
+
// it runs BEFORE the page gates, which still guard the framed request
|
|
123
|
+
assert.ok(i < SERVE.indexOf('PROJECT_SETTINGS_PAGE_RE.test(norm)'), 'ahead of the page-gate dispatch');
|
|
124
|
+
assert.match(SERVE, /GOVERNMENT_PAGE_RE\.test\(norm\)\) \{\s*return gdsAuth\.requireGovernmentPage/, 'the Permissions frame keeps its page gate');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the Settings page IS the menu: one search over the groups, loaded before settings.js', () => {
|
|
128
|
+
const html = read('settings.html');
|
|
129
|
+
assert.match(html, /<nav class="opt-rail" id="settings-rail" aria-label="Options"><\/nav>/);
|
|
130
|
+
assert.ok(html.indexOf('options-menu.js') > 0 && html.indexOf('options-menu.js') < html.indexOf('/builders/settings.js'));
|
|
131
|
+
assert.match(html, /<title>Options — /);
|
|
132
|
+
const js = read('settings.js');
|
|
133
|
+
assert.match(js, /menu\.mount\(\{/, 'settings.js hands the menu the viewer');
|
|
134
|
+
assert.doesNotMatch(js, /makeSectionRail/, 'the old rail is gone, not drawn beside the menu');
|
|
135
|
+
const css = read('settings.css');
|
|
136
|
+
assert.match(css, /\.settings-groups > \.opt-embed\.opt-embed--on \{ display: block; \}/, 'one framed screen shows at a time');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---- shell-game.js: the embedded page and game mode's Escape --------------------
|
|
140
|
+
|
|
141
|
+
function bootGame({ search = '', framed = true, game = false, path: p = '/my-agents' } = {}) {
|
|
142
|
+
const docListeners = {};
|
|
143
|
+
const winListeners = {};
|
|
144
|
+
const timers = [];
|
|
145
|
+
const assigned = [];
|
|
146
|
+
const root = { dataset: {} };
|
|
147
|
+
const bodyAttrs = {};
|
|
148
|
+
const parent = { OTBOptions: { escapes: 0, escape() { this.escapes += 1; } } };
|
|
149
|
+
const win = {
|
|
150
|
+
location: { pathname: p, search, hash: '', origin: 'https://h', href: 'https://h' + p + search, assign(u) { assigned.push(u); } },
|
|
151
|
+
localStorage: { getItem: (k) => (k === 'hall-game' && game ? 'on' : null), setItem() {} },
|
|
152
|
+
sessionStorage: { getItem: () => null, setItem() {} },
|
|
153
|
+
addEventListener(t, fn) { (winListeners[t] ||= []).push(fn); },
|
|
154
|
+
setTimeout(fn) { timers.push(fn); },
|
|
155
|
+
};
|
|
156
|
+
win.parent = framed ? parent : win;
|
|
157
|
+
const document = {
|
|
158
|
+
documentElement: root,
|
|
159
|
+
body: { setAttribute(k, v) { bodyAttrs[k] = v; } },
|
|
160
|
+
addEventListener(t, fn) { (docListeners[t] ||= []).push(fn); },
|
|
161
|
+
};
|
|
162
|
+
const ctx = vm.createContext({ window: win, document, URLSearchParams, URL });
|
|
163
|
+
vm.runInContext(read('shell-game.js'), ctx);
|
|
164
|
+
return {
|
|
165
|
+
root, bodyAttrs, parent, assigned, game: win.OTBShellGame,
|
|
166
|
+
press(key, target = { tagName: 'DIV' }) {
|
|
167
|
+
const ev = { key, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
|
168
|
+
for (const fn of winListeners.keydown || []) fn(ev);
|
|
169
|
+
while (timers.length) timers.shift()();
|
|
170
|
+
return ev;
|
|
171
|
+
},
|
|
172
|
+
click(href, target = null) {
|
|
173
|
+
const attrs = { href };
|
|
174
|
+
if (target) attrs.target = target;
|
|
175
|
+
const a = { getAttribute: (k) => (k in attrs ? attrs[k] : null), setAttribute: (k, v) => { attrs[k] = v; } };
|
|
176
|
+
for (const fn of docListeners.click || []) fn({ target: { closest: () => a } });
|
|
177
|
+
return attrs.target || null;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test('EMBEDDED: only a framed ?embed=1 page drops its chrome, and the switch is off', () => {
|
|
183
|
+
const framed = bootGame({ search: '?embed=1' });
|
|
184
|
+
assert.equal(framed.root.dataset.embed, 'on');
|
|
185
|
+
assert.equal(framed.bodyAttrs['data-fullscreen-switch'], 'off', 'no corner button, no Escape-to-leave');
|
|
186
|
+
assert.equal(framed.game.embedded(), true);
|
|
187
|
+
const alone = bootGame({ search: '?embed=1', framed: false });
|
|
188
|
+
assert.equal(alone.root.dataset.embed, undefined, 'the same URL opened on its own keeps its chrome');
|
|
189
|
+
assert.equal(bootGame({ search: '' }).root.dataset.embed, undefined);
|
|
190
|
+
const css = read('style.css');
|
|
191
|
+
assert.match(css, /html\[data-embed="on"\] \.app-sidebar,\s*html\[data-embed="on"\] \.app-scrim,\s*html\[data-embed="on"\] \.app-foot,\s*html\[data-embed="on"\] \.app-topbar \{ display: none; \}/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('EMBEDDED: an Escape nobody used goes to the menu; a link to another page leaves the frame', () => {
|
|
195
|
+
const p = bootGame({ search: '?embed=1' });
|
|
196
|
+
p.press('Escape');
|
|
197
|
+
assert.equal(p.parent.OTBOptions.escapes, 1, 'the menu got it');
|
|
198
|
+
p.press('Escape', { tagName: 'INPUT' });
|
|
199
|
+
assert.equal(p.parent.OTBOptions.escapes, 1, 'a field keeps its own Escape');
|
|
200
|
+
assert.equal(p.click('/task/42'), '_top', 'another page opens in the whole window');
|
|
201
|
+
assert.equal(p.click('#talk'), null, 'a place in the same page stays in the frame');
|
|
202
|
+
assert.equal(p.click('/goals', '_blank'), '_blank', 'a link that names its own target keeps it');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('GAME MODE: on the menu itself the last Escape closes it; outside game mode it is not the menu\'s', () => {
|
|
206
|
+
// the menu page registers window.OTBOptions; shell-game hands it the press
|
|
207
|
+
const menu = bootGame({ game: true, framed: false, path: '/settings' });
|
|
208
|
+
menu.parent.OTBOptions.escapes = 0;
|
|
209
|
+
const ev = menu.press('Escape');
|
|
210
|
+
assert.equal(ev.defaultPrevented, true, 'marked used, so the shell does not also bring the bars back');
|
|
211
|
+
assert.deepEqual(menu.assigned, ['/settings?from=%2Fsettings'], 'with no menu script loaded it opens the menu page');
|
|
212
|
+
const off = bootGame({ game: false, framed: false, path: '/goals' });
|
|
213
|
+
off.press('Escape');
|
|
214
|
+
assert.deepEqual(off.assigned, [], 'outside game mode the press stays the shell\'s');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('options-menu.js gives the menu its Escape: close in game mode, nothing outside it', () => {
|
|
218
|
+
const src = read('options-menu.js');
|
|
219
|
+
assert.match(src, /function escape\(\) \{\s*var game = window\.OTBShellGame;\s*if \(game && game\.on && game\.on\(\)\) close\(\);/);
|
|
220
|
+
assert.match(src, /root\.OTBOptions = \{/, 'the menu registers itself where shell-game.js looks');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// In game mode an Escape nobody used opens the options menu, so a pop-up that closes on
|
|
224
|
+
// Escape must say it used the press (ADR 0399 §2.4 row 5). These seven closed something and
|
|
225
|
+
// let the press fall through: before task 1005144 that also brought the bars back (the city
|
|
226
|
+
// card was task 1005188); after it, it would also have left the page for the menu.
|
|
227
|
+
test('every pop-up that closes on Escape spends the press, so the menu never opens from under it', () => {
|
|
228
|
+
const spent = {
|
|
229
|
+
'atlas.js': /ev\.key === 'Escape' && popup\.classList\.contains\('show'\)\) \{ ev\.preventDefault\(\); closePopup\(\);/,
|
|
230
|
+
'city.js': /if \(e\.key === 'Escape'\) \{ if \(e\.preventDefault\) e\.preventDefault\(\); closeCard\(\);/,
|
|
231
|
+
'home-gallery.js': /if \(isFs\(\) \|\| reading\.length\) e\.preventDefault\(\);/,
|
|
232
|
+
'task-detail.js': /e\.key === 'Escape' && document\.getElementById\('deeplink-card'\)\) \{ e\.preventDefault\(\); clearLink\(\);/,
|
|
233
|
+
'tweak-editor.js': /\$\('tw-openwrap'\)\.classList\.contains\('is-open'\)\) ev\.preventDefault\(\);/,
|
|
234
|
+
'update-pill.js': /state\.card && !state\.card\.hidden\) \{ e\.preventDefault\(\); closeCard\(\);/,
|
|
235
|
+
'diagram-viewer.js': /&& fullscreen\) \{ e\.preventDefault\(\); toggleFullscreen\(fullscreen\);/,
|
|
236
|
+
};
|
|
237
|
+
for (const [f, re] of Object.entries(spent)) assert.match(read(f), re, `${f} spends the Escape it used`);
|
|
238
|
+
// and a framed screen never asks for a craft: the menu page around it does
|
|
239
|
+
assert.match(read('craft-mode.js'), /if \(window\.OTBShellGame && window\.OTBShellGame\.embedded && window\.OTBShellGame\.embedded\(\)\) return;/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('the start screen\'s Options is the menu, and its first tip says what Escape does now', () => {
|
|
243
|
+
const src = read('shell-game.js');
|
|
244
|
+
assert.match(src, /\{ id: 'options', label: 'Options', href: '\/settings' \}/, 'Options opens the one options menu');
|
|
245
|
+
assert.match(src, /'Escape opens the options menu, where every setting lives\.'/);
|
|
246
|
+
assert.doesNotMatch(src, /Escape brings the navigation back/, 'no tip promises what Escape no longer does');
|
|
247
|
+
});
|
|
@@ -206,7 +206,7 @@ async function bootWatchLedger(overrides = {}) {
|
|
|
206
206
|
};
|
|
207
207
|
sandbox.globalThis = sandbox;
|
|
208
208
|
vm.createContext(sandbox);
|
|
209
|
-
for (const f of ['dom-utils.js', 'hall-kit.js', 'watch.js']) {
|
|
209
|
+
for (const f of ['dom-utils.js', 'hall-kit.js', 'watch-repo-invite.js', 'watch.js']) {
|
|
210
210
|
vm.runInContext(read(f), sandbox, { filename: f });
|
|
211
211
|
}
|
|
212
212
|
// load() is async: let every section's promise settle before reading the DOM.
|
package/tests/hall_palette.mjs
CHANGED
|
@@ -156,11 +156,10 @@ test('Government section anchors exist in government.js makeTabs', () => {
|
|
|
156
156
|
const ids = [...read('government.js').matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
|
|
157
157
|
assert.ok(ids.length === 2, `sanity: found the two government tabs (${ids})`);
|
|
158
158
|
assert.ok(!ids.includes('board-room'), 'the Board Room is a page, not a tab (ADR 0266)');
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
159
|
+
// task 1005144: Permissions and the Constitution are an options menu entry, rank-gated
|
|
160
|
+
// in the menu, so the palette declares no /government row — one offered to every
|
|
161
|
+
// signed-in reader would land a sub-Metic one on a menu without that entry.
|
|
162
|
+
assert.equal(declaredHrefs((x) => x.startsWith('/government')).length, 0, 'no palette row points at the old page');
|
|
164
163
|
});
|
|
165
164
|
|
|
166
165
|
test('Settings panel anchors exist in settings.js RAIL_GROUPS', () => {
|
|
@@ -182,8 +181,8 @@ test('My agents anchors are ones my-agents.js lands, and the moved Settings pane
|
|
|
182
181
|
const block = (src.match(/const ANCHORS = \{([\s\S]*?)\n \};/) || [, ''])[1];
|
|
183
182
|
const keys = [...block.matchAll(/^\s*([a-z-]+): \{/gm)].map((m) => m[1]);
|
|
184
183
|
assert.ok(keys.length >= 5, `sanity: my-agents.js ANCHORS (${keys})`);
|
|
185
|
-
const declared = declaredHrefs((x) => x.startsWith('/my-agents#'));
|
|
186
|
-
assert.ok(declared.length >= 5, 'the palette offers the agent settings where they live now');
|
|
184
|
+
const declared = declaredHrefs((x) => x.startsWith('/settings?open=my-agents#'));
|
|
185
|
+
assert.ok(declared.length >= 5, 'the palette offers the agent settings where they live now (the options menu, task 1005144)');
|
|
187
186
|
for (const h of declared) assert.ok(keys.includes(h.split('#')[1]), `${h} must be a my-agents.js anchor`);
|
|
188
187
|
assert.equal(declaredHrefs((x) => /\/settings#(interaction|wandering|rendering|voices|specialities)/.test(x)).length, 0,
|
|
189
188
|
'no palette row points at a Settings panel that left');
|
|
@@ -207,10 +206,10 @@ test('pages come from the sidebar, and a gated-off nav item is not a destination
|
|
|
207
206
|
test('section groups appear only when their owning nav item is visible', () => {
|
|
208
207
|
const all = bootPalette({ navItems: NAV_FIXTURE }).compute('');
|
|
209
208
|
assert.ok(all.some((r) => r.group === 'Profile'), 'Profile sections offered when nav-profile shows');
|
|
210
|
-
assert.ok(all.some((r) => r.group === '
|
|
209
|
+
assert.ok(all.some((r) => r.group === 'Options'), 'Options (the one menu, task 1005144) offered when the profile chip shows');
|
|
211
210
|
|
|
212
211
|
const gatedOut = bootPalette({ navItems: NAV_FIXTURE, visibleIds: [] }).compute('');
|
|
213
|
-
assert.ok(!gatedOut.some((r) => ['Profile', 'Tasks', '
|
|
212
|
+
assert.ok(!gatedOut.some((r) => ['Profile', 'Tasks', 'Options'].includes(r.group)),
|
|
214
213
|
'no sub-destinations when their owners are hidden');
|
|
215
214
|
});
|
|
216
215
|
|
|
@@ -164,7 +164,7 @@ function bootCharts() {
|
|
|
164
164
|
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
165
165
|
roleAxisLabel: () => 'Role',
|
|
166
166
|
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
167
|
-
roleOptions: () => [{ value: '', label: '— any —' }, { value: '
|
|
167
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
|
|
168
168
|
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
169
169
|
ROLE_UNSET: 'unclassified',
|
|
170
170
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
@@ -308,7 +308,7 @@ function bootRender({ rows, status = 200, analytics = null, earnings = [] }) {
|
|
|
308
308
|
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
309
309
|
roleAxisLabel: () => 'Role',
|
|
310
310
|
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
311
|
-
roleOptions: () => [{ value: '', label: '— any —' }, { value: '
|
|
311
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
|
|
312
312
|
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
313
313
|
ROLE_UNSET: 'unclassified',
|
|
314
314
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
@@ -328,7 +328,7 @@ function bootRender({ rows, status = 200, analytics = null, earnings = [] }) {
|
|
|
328
328
|
return { C: sandbox.window.OTBProfileCharts, api, requests, mount, controls };
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: '
|
|
331
|
+
const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'artisan', credits_awarded: 10, credits_reward: 12, ...extra });
|
|
332
332
|
// an earnings row: what the ledger paid on one day, for one kind of credit
|
|
333
333
|
const pay = (d, credits, kind = 'session') => ({ day: d, kind, credits });
|
|
334
334
|
|
|
@@ -395,7 +395,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
395
395
|
// the same reason categoryChip is: an undefined helper throws mid-render.
|
|
396
396
|
roleAxisLabel: () => 'Role',
|
|
397
397
|
roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
|
|
398
|
-
roleOptions: () => [{ value: '', label: '— any —' }, { value: '
|
|
398
|
+
roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
|
|
399
399
|
roleIsUnset: (d) => !d || d === 'unclassified',
|
|
400
400
|
ROLE_UNSET: 'unclassified',
|
|
401
401
|
// task 1004038 — the kind axis asks the same question. Stubbed for the
|
|
@@ -446,7 +446,7 @@ const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
|
446
446
|
|
|
447
447
|
const BASE_TASK = {
|
|
448
448
|
id: '1003305', title: 'A record in the world', status: 'shipped', version_id: 'BONGOS-V1',
|
|
449
|
-
kind: 'feature', discipline: '
|
|
449
|
+
kind: 'feature', discipline: 'artisan', priority: 1, requires_rank: 'metic',
|
|
450
450
|
credits_reward: 60, est_minutes: 240, actual_minutes: 37,
|
|
451
451
|
created_at: '2026-08-28T00:00:00Z', description: 'the description', touches: ['a.js', 'b.js'],
|
|
452
452
|
dependencies: [], dependents: [],
|
|
@@ -222,7 +222,8 @@ test('every old link to those sections lands on My agents', () => {
|
|
|
222
222
|
// settings.js asks before it draws anything, and on every later hash
|
|
223
223
|
const html = read('settings.html');
|
|
224
224
|
assert.ok(html.indexOf('settings-moved.js') > 0 && html.indexOf('settings-moved.js') < html.indexOf('/builders/settings.js'), 'loaded before settings.js');
|
|
225
|
-
|
|
225
|
+
// task 1005144: My agents is an options menu entry now, so a moved link opens it in place.
|
|
226
|
+
assert.match(settingsJs, /OTBOptions\.activate\('my-agents', anchor\)/, 'opened in the menu, no page load');
|
|
226
227
|
assert.doesNotMatch(settingsJs, /'(interaction|wandering|rendering|voices)': '[a-z-]+-scroll'/, 'no PANEL_ANCHORS entry for a section that left');
|
|
227
228
|
});
|
|
228
229
|
|