Merge post-options-menu from swarmforge-architect

By specifier.
This commit is contained in:
Chris Troutner
2026-09-16 10:05:06 -07:00
13 changed files with 1056 additions and 35 deletions
+117
View File
@@ -0,0 +1,117 @@
# post-options-menu — Architect Review
Task: `post-options-menu`
Component: `psf-memo-client`
Base: `5e62d1e` (last merged architect review); inbound refactorer commit `d0882bb`
## What was reviewed
Inbound refactorer batch (priority 10), merged onto `swarmforge-architect` by
fast-forwarding to `d0882bb`. The linear chain reviewed:
- **`091272d`** — specifier: *Record feed-total-cap completion in backlog and briefing*.
- **`bc964fa`** — specifier: *Specify post options menu for all post cards*. Adds
`psf-memo-client/specs/post-options-menu.feature` (9 scenario outlines across the
recent/following/topic/thread/profile surfaces) and the backlog entry.
- **`20e67da`** — coder: *Implement post options menu on every post card*. Adds the
pure `src/services/post-options.js`, the `src/components/post-feed/post-options-menu.js`
shared React component, the acceptance render adapter, the post-options acceptance
handlers, and unit tests; wires the menu into `post-feed-item.js` and `profile/index.js`.
- **`d0882bb`** — refactorer: *Refactor post options menu and add property tests*.
Moves the key-command mapping into `postOptionsKeyCommand` in the pure service so the
component is a thin adapter, DRYs the duplicated acceptance step bodies behind
`togglePostOptionsMenu` / `transitionActivePostOptionsMenu`, and adds
`test/property/post-options.property.test.js`.
**Architect review commit: `07ff61392b`** — the two `mutate4javascript` manifest
footers, the soft `gherkin-mutator` acceptance-mutation manifest stamp on
`post-options-menu.feature`, and the hardening changes described below. The
summary and verification record are committed on top, so
`git diff 07ff61392b HEAD` touches only `docs/`. The record's `git_sha` is
`07ff61392b`, the commit that contains the verified source state.
## Architectural findings and fixes applied
The refactorer's structure is sound: a framework-free core service, a React
adapter, and a separate acceptance render adapter. Two testability gaps found
by language mutation were fixed with a behavior-preserving extraction.
1. **UI/Core separation.** `src/services/post-options.js` is plain CommonJS with
no React, DOM, or IO; it owns URL construction and the open/close/focus
transitions. The React component consumes it through `require` and only maps
state to markup. The acceptance pipeline reaches the same service through
`acceptance/lib/render-post-options.js`, which server-renders the actual
component. Core behavior is exercised with no browser.
2. **Dependency rule.** The service depends on nothing; the component and the
acceptance adapter depend inward on the service. No framework or persistence
structure leaks across the boundary.
3. **Uncovered mutation / testable boundary.** The outside-click decision was an
inline `containerRef.current && !containerRef.current.contains(event.target)`
inside `useEffect`, which the unit (`node --test`) harness never executes, so
the `&& -> ||` mutation was **uncovered**. Extracted it to the pure
`isOutsidePostOptions(container, target)` in the service; the effect is now a
thin adapter and the decision is unit tested. This follows the role rule to
maximize testable modules and minimize the environmentally unsuitable shell.
4. **Mutation survivors.** Two `-1` default-focus literals survived because no
unit test rendered an *open* menu with the default (unfocused) state: the
`initialFocusedIndex = -1` default and the `tabIndex ... : -1` else branch.
Added a unit test asserting the default open menu makes no item tabbable,
killing both. (Property tests are a separate suite and are intentionally not
part of the language-mutation test command.)
5. **Duplication.** The refactorer's acceptance-step DRY is local and clear; the
new service helper adds no duplication. `dry4javascript` reported 96
duplicate blocks, all pre-existing `acceptance/lib/handlers.js`
step-handler boilerplate; none involves the post-options modules.
6. **Test/helper separation.** `render-post-options.js` (acceptance helper) and
the property-test generators live in their helper libraries, separate from
the unit and property test files.
## Verification results
### Language mutation (`mutate4javascript`, differential, `--max-workers 8`)
Initial component run before the fix: **3 killed, 2 survived, 1 uncovered**.
After the extraction and unit tests:
- **`src/services/post-options.js`**: 10 covered sites. Differential selected
1 of 10 after the new function; `mutate-file.sh` auto-reran `--mutate-all`:
**10 killed, 0 survived, 0 uncovered**.
- **`src/components/post-feed/post-options-menu.js`**: **5 killed, 0 survived,
0 uncovered**.
### DRY (`dry4javascript`)
Changed source/tests/handlers plus `acceptance/lib/handlers.js`:
**no duplicate candidate involves the new code**. 96 reported blocks are
pre-existing handler step-handler boilerplate, consistent with prior reviews.
### CRAP / cyclomatic complexity (`crap4javascript`)
All changed functions below the 8.0 threshold:
`PostOptionsMenu` (CC 3, 81.8% covered, CRAP 3.1 — the uncovered remainder is the
DOM `useEffect` shell), `postOptionsKeyCommand` (CC 3, CRAP 3.0),
`isOutsidePostOptions` (CC 2, 100%, CRAP 2.0), and the remaining service
accessors (CC 12, 100%).
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
- **`post-options-menu.feature`**: **20 executed, 20 killed, 0 survived,
0 errors**. All nine scenario outlines killed every soft mutation, so the tool
wrote a full-feature acceptance-mutation manifest stamp (no survivors to
document).
### Suite status
`swarmforge/scripts/verify.sh client --record
docs/reviews/post-options-menu-verification.json --task post-options-menu`
-> **pass (5/5)**:
unit **364 passing**, property **68 passing**, acceptance **all 28 suites
passing**, lint **pass**, build **pass**. Record `git_sha` = `07ff61392b`.
## Handoffs sent
- End-of-chain `git_handoff` to the specifier (task `post-options-menu`) with the
review commit so it can merge `swarmforge-architect` into `master`.
- No coder/refactorer handoff: the review is test hardening plus a
behavior-preserving extraction with no follow-up work for them.
By architect.
@@ -0,0 +1,46 @@
{
"schema_version": 1,
"task": "post-options-menu",
"component": "psf-memo-client",
"git_sha": "07ff61392b345547d320e71fd01594367afc1125",
"branch": "swarmforge-architect",
"timestamp": "2026-09-16T17:04:09.878Z",
"commands": [
{
"name": "unit",
"command": "npm test",
"exit": 0,
"duration_ms": 8834,
"summary": "364 pass / 0 fail"
},
{
"name": "property",
"command": "npm run test:property",
"exit": 0,
"duration_ms": 9064,
"summary": "68 pass / 0 fail"
},
{
"name": "acceptance",
"command": "npm run test:acceptance",
"exit": 0,
"duration_ms": 13642,
"summary": "all 28 acceptance suites passed"
},
{
"name": "lint",
"command": "npm run lint",
"exit": 0,
"duration_ms": 2283,
"summary": "ok"
},
{
"name": "build",
"command": "npm run build",
"exit": 0,
"duration_ms": 64231,
"summary": "ok"
}
],
"result": "pass"
}
+166
View File
@@ -54,6 +54,8 @@ const MemoPollVote = require('../../src/services/memo-poll-vote')
const PollVotePage = require('../../src/services/poll-vote-page')
const { renderPostText } = require('./render-post')
const { renderAccountAvatar } = require('./render-account-avatar')
const { renderPostOptions } = require('./render-post-options')
const PostOptions = require('../../src/services/post-options')
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
@@ -3260,9 +3262,173 @@ const handlers = [
renderPostText(post.text, { initialFailedImages: [...world.failedImages] })
)
}
},
{
name: 'page shows a post options button for the post',
pattern: /^the page shows a post options button for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = findPostOnCurrentPage(txid, world)
if (!post) {
throw new Error(`The displayed page does not show a post with txid ${txid}.`)
}
const menu = getPostOptionsMenu(world, txid)
const html = renderPostOptions(txid, { open: menu.open })
if (!html.includes('aria-label="Post options"')) {
throw new Error(`Post ${txid} does not render a post options button.`)
}
}
},
{
name: 'post options menu is hidden',
pattern: /^the post options menu is hidden for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const menu = getPostOptionsMenu(world, txid)
if (menu.open) {
throw new Error(`Post options menu for ${txid} is open, but it should be hidden.`)
}
const html = renderPostOptions(txid, { open: menu.open })
if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) {
throw new Error(`Post options menu for ${txid} renders items while hidden.`)
}
}
},
{
name: 'click post options button',
pattern: /^I click the post options button for the post with txid (.+)$/,
run (m, example, world) {
togglePostOptionsMenu(world, resolveParam(m[1], example))
}
},
{
name: 'click post options button again',
pattern: /^I click the post options button again for the post with txid (.+)$/,
run (m, example, world) {
togglePostOptionsMenu(world, resolveParam(m[1], example))
}
},
{
name: 'post options menu is shown',
pattern: /^the post options menu is shown for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const menu = getPostOptionsMenu(world, txid)
if (!menu.open) {
throw new Error(`Post options menu for ${txid} is hidden, but it should be shown.`)
}
}
},
{
name: 'first post options menu item label',
pattern: /^the first item in the post options menu is "(.+)"$/,
run (m, example, world) {
const label = m[1]
const items = PostOptions.postOptionsItems(world.activeMenuTxid)
if (!items[0] || items[0].label !== label) {
throw new Error(`Expected the first post options item to be "${label}".`)
}
}
},
{
name: 'first post options menu item links in new tab',
pattern: /^the first post options menu item links to (.+) and opens in a new tab$/,
run (m, example, world) {
const href = resolveParam(m[1], example)
const txid = world.activeMenuTxid
const first = PostOptions.postOptionsItems(txid)[0]
if (!first || first.href !== href) {
throw new Error(`Expected the first post options item to link to ${href}.`)
}
if (first.target !== '_blank') {
throw new Error('Expected the first post options item to open in a new tab.')
}
const html = renderPostOptions(txid, { open: true, focusedIndex: 0 })
if (!html.includes(`href="${href}"`) || !html.includes('target="_blank"')) {
throw new Error(`The rendered post options menu does not link to ${href} in a new tab.`)
}
if (!html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) {
throw new Error('The rendered post options menu does not show the block explorer item.')
}
}
},
{
name: 'click outside post options menu',
pattern: /^I click outside the post options menu$/,
run (m, example, world) {
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsOutsideClick)
}
},
{
name: 'press Escape key',
pattern: /^I press the Escape key$/,
run (m, example, world) {
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsEscape)
}
},
{
name: 'press ArrowDown key',
pattern: /^I press the ArrowDown key$/,
run (m, example, world) {
const txid = world.activeMenuTxid
transitionActivePostOptionsMenu(world, (menu) =>
PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid))
)
}
},
{
name: 'first post options menu item has focus',
pattern: /^the first post options menu item has focus$/,
run (m, example, world) {
const txid = world.activeMenuTxid
const menu = getPostOptionsMenu(world, txid)
if (menu.focusedIndex !== 0) {
throw new Error('Expected the first post options item to have focus.')
}
const html = renderPostOptions(txid, { open: true, focusedIndex: 0 })
if (!html.includes('tabindex="0"')) {
throw new Error('The rendered first post options item is not focusable.')
}
}
}
]
// Find the post options menu state for a txid, creating a closed one on first use.
function getPostOptionsMenu (world, txid) {
if (!world.postOptionsMenus) world.postOptionsMenus = {}
if (!world.postOptionsMenus[txid]) {
world.postOptionsMenus[txid] = { txid, ...PostOptions.initialPostOptionsState() }
}
return world.postOptionsMenus[txid]
}
// Apply a post options state transition to the active menu.
function transitionActivePostOptionsMenu (world, transition) {
const menu = getPostOptionsMenu(world, world.activeMenuTxid)
Object.assign(menu, transition(menu))
}
// Toggle a post's options menu and make it the active menu.
function togglePostOptionsMenu (world, txid) {
const menu = getPostOptionsMenu(world, txid)
Object.assign(menu, PostOptions.togglePostOptions(menu))
world.activeMenuTxid = txid
}
// The posts currently rendered by the page the scenario has opened.
function postsOnCurrentPage (world) {
const path = world.currentPath || ''
if (world.threadPage && world.threadPage.rootPost) return world.threadPage.allPosts || []
if (path.startsWith(ProfilePage.PROFILE_PATH_PREFIX)) return world.profilePage?.posts || []
if (path.startsWith('/topics/')) return world.topicFeedPage?.posts || []
if (path === FollowingFeedPage.FOLLOWING_FEED_PATH) return world.followingFeedPage?.posts || []
return world.recentFeedPage?.posts || []
}
function findPostOnCurrentPage (txid, world) {
return postsOnCurrentPage(world).find((post) => post && post.txid === txid) || null
}
// Return the cached rendered feed HTML, computing it on first use.
function getRenderedFeed (world) {
if (!world.renderedFeed) {
@@ -0,0 +1,25 @@
/*
Acceptance rendering adapter for the shared post options menu.
Renders the same PostOptionsMenu component the browser uses to a static HTML
string, so acceptance assertions can inspect the button and menu markup
without running a browser. The component's open/closed and focused state can
be seeded through props for deterministic rendering.
*/
'use strict'
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu')
function renderPostOptions (txid, options = {}) {
const element = React.createElement(PostOptionsMenu, {
txid,
initialOpen: options.open,
initialFocusedIndex: options.focusedIndex
})
return ReactDOMServer.renderToStaticMarkup(element)
}
module.exports = { renderPostOptions }
@@ -1,3 +1,8 @@
# mutation-stamp: sha256=d6155399b60e3d5fda876a8b4dbc415d8b564bf233a6d61c67c8beae57ebc1da
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-16T16:59:26.923749533Z","feature_name":"Post Options Menu","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/post-options-menu.feature","background_hash":"0d66780cb1b8e277f0ada40a8ffe336dec7a8eaf658f19d2ea344815fb9bf26c","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Post Options Menu - 1 the post menu shows the block explorer link as its first item","scenario_hash":"9a82c0e05992fc1a2ad3e57acfe4796434a95310511dac60cb18de613be13be0","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":1,"name":"Post Options Menu - 2 clicking the post options button again closes the menu","scenario_hash":"723bdf16c5aee245b0e331a72ddbbc590fce23e8d9f46b232cc30658541873fc","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":2,"name":"Post Options Menu - 3 clicking outside the menu closes it","scenario_hash":"3cfd93550ed4b484efbd6c0572cbd6efaf61b3554a3e163e5aef34f43d4f1297","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":3,"name":"Post Options Menu - 4 pressing Escape closes the menu","scenario_hash":"109c8bd553e858e63d39639230981f1714fa0cff71c2abbcfeea69047aebbb12","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":4,"name":"Post Options Menu - 5 arrow keys move focus to the menu's first item","scenario_hash":"4f2c2c2f368c06fb399f4082398c3b41e423b46e55477b82296cedfe9c8a1f35","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":5,"name":"Post Options Menu - 6 the following feed post menu offers the block explorer link","scenario_hash":"49a9c0201d5a42ecbafaef4c4460248d1bf67218d7f81ea8cd5c00ac0ed0c57c","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":6,"name":"Post Options Menu - 7 the topic feed post menu offers the block explorer link","scenario_hash":"e96efacd24018eb02f348b53bae27e3d374969b692775f563e5c004ca1b90904","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":7,"name":"Post Options Menu - 8 the thread post menu offers the block explorer link","scenario_hash":"afc34b9045b83690e14e50c2850788787984f0c72324b54aa994ccc319af58d5","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":8,"name":"Post Options Menu - 9 the profile post menu offers the block explorer link","scenario_hash":"7b253454da281aa3447d1b1121f9aec8bb9d4efacfb98b61a1232ee9d039c253","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Post Options Menu - 1, Post Options Menu - 2, Post Options Menu - 3, Post Options Menu - 4, Post Options Menu - 5, Post Options Menu - 6, Post Options Menu - 7, Post Options Menu - 8, Post Options Menu - 9
#
# Every post card has a "Post options" button (three dots) in its upper-right
+74
View File
@@ -344,6 +344,80 @@ header,
border-top-color: var(--border);
}
/* Post options menu */
.post-options {
position: relative;
flex: 0 0 auto;
}
.post-options-button {
width: 34px;
height: 34px;
padding: 0;
color: var(--text-secondary);
background: transparent;
border: 0;
border-radius: var(--radius-round);
box-shadow: none;
font-size: 15px;
letter-spacing: 1px;
cursor: pointer;
}
.post-options-button:hover,
.post-options-button:focus-visible {
color: var(--text-primary);
background: var(--surface-muted);
border: 0;
box-shadow: none;
}
.post-options-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 20;
min-width: 200px;
margin: 0;
padding: 8px;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-menu);
}
.post-options-menu-item {
margin: 0;
padding: 0;
}
.post-options-link {
display: block;
padding: 10px 12px;
color: var(--text-primary);
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}
.post-options-link:hover,
.post-options-link:focus {
color: var(--text-primary);
background: var(--surface-muted);
text-decoration: none;
}
/* General page headings */
.main-content h1 {
@@ -14,6 +14,7 @@ import ProfilePage from '../../../services/profile-page'
import { getViewerAddress } from '../../../services/profile-wallet'
import PostReplyCount from '../../post-reply-count'
import LikeButton from '../../post-feed/like-button'
import PostOptionsMenu from '../../post-feed/post-options-menu'
import PostThreadModal from '../../post-thread-modal'
import '../../../App.css'
import './profile.css'
@@ -248,9 +249,12 @@ function Profile (props) {
{posts.map((post) => (
<Card key={post.txid} className='profile-post-card mb-3'>
<Card.Body>
<div className='profile-post-meta text-muted mb-2'>
<span>{formatSeen(post.seen)}</span>
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
<div className='profile-post-meta d-flex justify-content-between align-items-start text-muted mb-2'>
<div>
<span>{formatSeen(post.seen)}</span>
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
</div>
<PostOptionsMenu txid={post.txid} />
</div>
<Card.Text className='profile-post-text'>{post.text}</Card.Text>
<div className='profile-post-actions d-flex gap-3 align-items-center'>
@@ -16,6 +16,7 @@ import {
truncateTxid
} from './post-display'
import PostContent from './post-content'
import PostOptionsMenu from './post-options-menu'
import './post-feed.css'
const appUtil = new AppUtil()
@@ -131,14 +132,7 @@ function PostFeedItem ({
</span>
</div>
<button
type='button'
className='posts-feed-item-menu'
aria-label='Post options'
title='Post options'
>
<span aria-hidden='true'></span>
</button>
<PostOptionsMenu txid={post.txid} />
</header>
<div className='posts-feed-item-content'>
@@ -499,30 +499,6 @@
gap: 6px;
}
.posts-feed-item-menu {
width: 34px;
height: 34px;
padding: 0;
color: #262626;
background: transparent;
border: 0;
border-radius: 50%;
box-shadow: none;
font-size: 15px;
letter-spacing: 1px;
cursor: pointer;
}
.posts-feed-item-menu:hover {
color: #737373;
background: #f2f2f2;
border: 0;
box-shadow: none;
transform: none;
}
.posts-feed-item-content {
padding: 14px 16px 6px;
}
@@ -0,0 +1,117 @@
/*
Shared post options menu.
Every post card renders this same three-dots menu: a "Post options" button
whose menu's first item is "See on block explorer", a link to the post
transaction on bch.loping.net that opens in a new tab. The menu closes on the
button again, an outside click, or Escape, and ArrowDown moves focus to the
first item.
Written in plain React.createElement style so the same module can be used by
the JSX components in the browser build and by the acceptance adapter that
renders HTML under Node.
*/
const React = require('react')
const {
postOptionsItems,
togglePostOptions,
handlePostOptionsOutsideClick,
isOutsidePostOptions,
postOptionsKeyCommand
} = require('../../services/post-options')
function PostOptionsMenu ({
txid,
initialOpen = false,
initialFocusedIndex = -1
}) {
const [state, setState] = React.useState(() => ({
open: Boolean(initialOpen),
focusedIndex: initialFocusedIndex
}))
const containerRef = React.useRef(null)
const items = postOptionsItems(txid)
// Close the open menu when the user clicks anywhere outside it.
React.useEffect(() => {
if (!state.open) return undefined
const handleDocumentMouseDown = (event) => {
if (isOutsidePostOptions(containerRef.current, event.target)) {
setState((previous) => handlePostOptionsOutsideClick(previous))
}
}
document.addEventListener('mousedown', handleDocumentMouseDown)
return () => document.removeEventListener('mousedown', handleDocumentMouseDown)
}, [state.open])
const handleKeyDown = (event) => {
const command = postOptionsKeyCommand(event.key, items)
if (!command) return
if (command.preventDefault) event.preventDefault()
setState((previous) => command.transition(previous))
}
return React.createElement(
'div',
{
className: 'post-options',
ref: containerRef,
onKeyDown: handleKeyDown
},
React.createElement(
'button',
{
type: 'button',
className: 'post-options-button',
'aria-label': 'Post options',
'aria-haspopup': 'menu',
'aria-expanded': state.open ? 'true' : 'false',
title: 'Post options',
onClick: () => setState((previous) => togglePostOptions(previous))
},
React.createElement('span', { 'aria-hidden': 'true' }, '•••')
),
state.open &&
React.createElement(
'ul',
{
className: 'post-options-menu',
role: 'menu',
'aria-label': 'Post options'
},
items.map((item, index) =>
React.createElement(
'li',
{
key: item.id,
className: 'post-options-menu-item',
role: 'none'
},
React.createElement(
'a',
{
href: item.href,
target: item.target,
rel: item.rel,
role: 'menuitem',
className: 'post-options-link',
tabIndex: index === state.focusedIndex ? 0 : -1,
autoFocus: index === state.focusedIndex
},
item.label
)
)
)
)
)
}
module.exports = PostOptionsMenu
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-16T16:54:35.472Z","module_hash":"68c1b9ad46f613f4d84aecc6be2fc0a2e370b9f3c50b15e50f702f6587e93b45","functions":[{"id":"func/PostOptionsMenu","name":"PostOptionsMenu","line":24,"end_line":111,"hash":"df85571487654169765129cbe38d08247311fbd32bd94ad3a395834a202aa69b"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,117 @@
/*
Post options menu behavior: the shared model behind the three-dots menu on
every post card.
The menu's first item is a "See on block explorer" link to the post
transaction on bch.loping.net, opened in a new tab. This module owns the link
construction and the pure open/close/focus state transitions so the feed card
and the profile post card render identical behavior.
It is written in plain CommonJS with no UI or DOM concerns so it can be used
by the browser components, by unit tests, and by the acceptance handlers.
*/
const BLOCK_EXPLORER_LABEL = 'See on block explorer'
const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx'
// Block explorer URL for a post transaction, or '' without a txid.
function explorerTxUrl (txid) {
if (!txid) return ''
return `${BLOCK_EXPLORER_TX_BASE}/${txid}`
}
// The ordered menu items for a post. The block explorer link is first.
function postOptionsItems (txid) {
return [
{
id: 'block-explorer',
label: BLOCK_EXPLORER_LABEL,
href: explorerTxUrl(txid),
target: '_blank',
rel: 'noopener noreferrer'
}
]
}
// A fresh, closed menu state.
function initialPostOptionsState () {
return { open: false, focusedIndex: -1 }
}
// Open the menu, leaving any current item focus intact.
function openPostOptions (state) {
return { ...state, open: true }
}
// Close the menu and clear item focus.
function closePostOptions (state) {
return { ...state, open: false, focusedIndex: -1 }
}
// Open a closed menu, close an open one.
function togglePostOptions (state) {
return state.open ? closePostOptions(state) : openPostOptions(state)
}
// Move focus to the first menu item, if there is one.
function focusFirstPostOption (state, items = []) {
if (!items.length) return state
return { ...state, open: true, focusedIndex: 0 }
}
// Escape closes the menu.
function handlePostOptionsEscape (state) {
return closePostOptions(state)
}
// A click outside the menu closes it.
function handlePostOptionsOutsideClick (state) {
return closePostOptions(state)
}
// True when a mousedown target is outside the menu container. A missing
// container (menu not mounted) or target is treated as inside so a detached
// menu cannot close itself. Pure so the component's effect stays a thin
// adapter and the decision is unit testable without a DOM.
function isOutsidePostOptions (container, target) {
return Boolean(container) && !container.contains(target)
}
// Resolve a key press to the menu's next state. Returns null for keys that are
// not menu shortcuts. ArrowDown reveals the menu and focuses its first item;
// Escape closes it. preventDefault is true for keys that would otherwise scroll
// the page.
function postOptionsKeyCommand (key, items = []) {
if (key === 'Escape') {
return { transition: handlePostOptionsEscape, preventDefault: false }
}
if (key === 'ArrowDown') {
return {
transition: (state) => focusFirstPostOption(state, items),
preventDefault: true
}
}
return null
}
module.exports = {
BLOCK_EXPLORER_LABEL,
BLOCK_EXPLORER_TX_BASE,
explorerTxUrl,
postOptionsItems,
initialPostOptionsState,
openPostOptions,
closePostOptions,
togglePostOptions,
focusFirstPostOption,
handlePostOptionsEscape,
handlePostOptionsOutsideClick,
isOutsidePostOptions,
postOptionsKeyCommand
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-16T16:53:14.617Z","module_hash":"9564c40bbc416ef70c9b53f4dcdd849182f85734b9e48f90521b400921d3c6b3","functions":[{"id":"func/explorerTxUrl","name":"explorerTxUrl","line":18,"end_line":21,"hash":"b0284558de13bf4b63d70227a688add4739e577a8908593f3ced4ff557be6a16"},{"id":"func/postOptionsItems","name":"postOptionsItems","line":24,"end_line":34,"hash":"271f7b7971ae5d011c7014444b67d4e3f7cd25b85474f9a556e8bf8a8acb2b35"},{"id":"func/initialPostOptionsState","name":"initialPostOptionsState","line":37,"end_line":39,"hash":"1b6ab74976ac9fb54520f85e9fab7e61109572783da055866dcf64e36fc857f9"},{"id":"func/openPostOptions","name":"openPostOptions","line":42,"end_line":44,"hash":"4e91430330da69f5829a931fe6df67d2c6b7aa791f8092e1152e791c4935c384"},{"id":"func/closePostOptions","name":"closePostOptions","line":47,"end_line":49,"hash":"fe56a32123f3f5241891e22b551b0abba708bf182b2e04a9c1301d15e12fa466"},{"id":"func/togglePostOptions","name":"togglePostOptions","line":52,"end_line":54,"hash":"3bcb1a0d78907b5e83920c5f5e0d3d4fd55e0d62691d3c71bfc84bd1b9d8adac"},{"id":"func/focusFirstPostOption","name":"focusFirstPostOption","line":57,"end_line":60,"hash":"c3f174e7232a704c72bc79d84c4e5ef81ad1f32012a9c215d83c5a4eb7bd3291"},{"id":"func/handlePostOptionsEscape","name":"handlePostOptionsEscape","line":63,"end_line":65,"hash":"028896c476d907d1ca26361c5958c5996ac33a5455fa90ed8a7d48eaed9d2301"},{"id":"func/handlePostOptionsOutsideClick","name":"handlePostOptionsOutsideClick","line":68,"end_line":70,"hash":"66e71edaea9e5a93f14c2cb24026a338c02be5451f1ecdb00145705cc41e3ed0"},{"id":"func/isOutsidePostOptions","name":"isOutsidePostOptions","line":76,"end_line":78,"hash":"a35084f2e010d5e4726c25cac48b78c7e10fe331caf2ca3cabc663607cbf1962"},{"id":"func/postOptionsKeyCommand","name":"postOptionsKeyCommand","line":84,"end_line":97,"hash":"625fb7f69df3baf377fcd9a0b0d3d2bd129b31090e51591552f4eaa056fc120e"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,198 @@
/*
Property tests for the post options menu.
The unit tests probe post-options at a few fixed fixtures. These properties
pin down the service's and the component's invariants over broad random
inputs:
- explorerTxUrl composes the block explorer base with the txid and returns
'' for every falsy input.
- postOptionsItems always offers the block explorer link first, with the
expected label, target, and rel, and an href equal to explorerTxUrl.
- Close transitions are idempotent and always clear focus; open preserves
focus; toggle is an involution on the open flag.
- focusFirstPostOption (ArrowDown) opens and focuses index 0 when items
exist, and is a no-op with no items.
- The key command maps only Escape and ArrowDown, never prevents the
default for anything else.
- The rendered component always shows the button, shows menu items only
when open, makes exactly the focused item tabbable, and is deterministic.
*/
'use strict'
const test = require('node:test')
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const { seededRandom, forAll, intGen } = require('./harness')
const PostOptions = require('../../src/services/post-options')
const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu')
const rng = seededRandom(20260916)
const HEX = '0123456789abcdef'
const OTHER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowLeft', 'Escape ', 'escape', '']
function randomTxid () {
const n = intGen(rng, 1, 64)()
let out = ''
for (let i = 0; i < n; i++) out += HEX[Math.floor(rng() * HEX.length)]
return out
}
function randomState () {
return { open: rng() < 0.5, focusedIndex: intGen(rng, -1, 3)() }
}
function render (props) {
return ReactDOMServer.renderToStaticMarkup(
React.createElement(PostOptionsMenu, props)
)
}
test('explorerTxUrl composes the base and the txid', async () => {
await forAll(
() => randomTxid(),
async (txid) =>
PostOptions.explorerTxUrl(txid) === `${PostOptions.BLOCK_EXPLORER_TX_BASE}/${txid}`,
{ label: 'explorerTxUrl composition', samples: 2000 }
)
})
test('explorerTxUrl returns an empty string for every falsy input', async () => {
await forAll(
() => [undefined, null, '', 0, false][intGen(rng, 0, 4)()],
async (value) => PostOptions.explorerTxUrl(value) === '',
{ label: 'explorerTxUrl falsy inputs', samples: 500 }
)
})
test('postOptionsItems always puts the block explorer link first', async () => {
await forAll(
() => randomTxid(),
async (txid) => {
const items = PostOptions.postOptionsItems(txid)
if (items.length === 0) return false
const first = items[0]
return first.id === 'block-explorer' &&
first.label === PostOptions.BLOCK_EXPLORER_LABEL &&
first.target === '_blank' &&
first.rel === 'noopener noreferrer' &&
first.href === PostOptions.explorerTxUrl(txid)
},
{ label: 'postOptionsItems block explorer first', samples: 2000 }
)
})
test('close transitions are idempotent and clear focus', async () => {
await forAll(
() => randomState(),
async (state) => {
const transitions = [
PostOptions.closePostOptions,
PostOptions.handlePostOptionsEscape,
PostOptions.handlePostOptionsOutsideClick
]
for (const transition of transitions) {
const once = transition(state)
const twice = transition(once)
if (once.open !== false || once.focusedIndex !== -1) return false
if (JSON.stringify(once) !== JSON.stringify(twice)) return false
}
return true
},
{ label: 'post options close idempotence', samples: 2000 }
)
})
test('open preserves focus and toggle is an involution on open', async () => {
await forAll(
() => randomState(),
async (state) => {
const opened = PostOptions.openPostOptions(state)
if (opened.open !== true) return false
if (opened.focusedIndex !== state.focusedIndex) return false
const twice = PostOptions.togglePostOptions(PostOptions.togglePostOptions(state))
return twice.open === state.open
},
{ label: 'post options open and toggle', samples: 2000 }
)
})
test('focusFirstPostOption opens and focuses index 0 only when items exist', async () => {
await forAll(
() => randomState(),
async (state) => {
const items = PostOptions.postOptionsItems(randomTxid())
const focused = PostOptions.focusFirstPostOption(state, items)
if (focused.open !== true || focused.focusedIndex !== 0) return false
const empty = PostOptions.focusFirstPostOption(state, [])
return JSON.stringify(empty) === JSON.stringify(state)
},
{ label: 'post options focus first', samples: 2000 }
)
})
test('the key command maps only Escape and ArrowDown', async () => {
await forAll(
() => ({ key: OTHER_KEYS[intGen(rng, 0, OTHER_KEYS.length - 1)()], txid: randomTxid() }),
async ({ key, txid }) => {
const items = PostOptions.postOptionsItems(txid)
if (PostOptions.postOptionsKeyCommand(key, items) !== null) return false
const escape = PostOptions.postOptionsKeyCommand('Escape', items)
if (!escape || escape.preventDefault !== false) return false
if (escape.transition(PostOptions.initialPostOptionsState()).open !== false) return false
const arrowDown = PostOptions.postOptionsKeyCommand('ArrowDown', items)
if (!arrowDown || arrowDown.preventDefault !== true) return false
const next = arrowDown.transition(PostOptions.initialPostOptionsState())
return next.open === true && next.focusedIndex === 0
},
{ label: 'post options key command', samples: 1000 }
)
})
test('the rendered menu shows the button always and items only when open', async () => {
await forAll(
() => ({ txid: randomTxid(), open: rng() < 0.5 }),
async ({ txid, open }) => {
const html = render({ txid, initialOpen: open })
if (!html.includes('aria-label="Post options"')) return false
if (!html.includes(`aria-expanded="${open ? 'true' : 'false'}"`)) return false
if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL) !== open) return false
if (open && !html.includes(`href="${PostOptions.explorerTxUrl(txid)}"`)) return false
if (open && !html.includes('target="_blank"')) return false
return true
},
{ label: 'post options render open and closed', samples: 1000 }
)
})
test('exactly the focused menu item is tabbable', async () => {
await forAll(
() => ({ txid: randomTxid(), focusedIndex: intGen(rng, -1, 0)() }),
async ({ txid, focusedIndex }) => {
const html = render({ txid, initialOpen: true, initialFocusedIndex: focusedIndex })
const tabbable = (html.match(/tabindex="0"/g) || []).length
return tabbable === (focusedIndex === 0 ? 1 : 0)
},
{ label: 'post options tabbable item', samples: 500 }
)
})
test('rendering the same props twice yields the same markup', async () => {
await forAll(
() => ({
txid: randomTxid(),
initialOpen: rng() < 0.5,
initialFocusedIndex: intGen(rng, -1, 1)()
}),
async (props) => {
const first = render(props)
const second = render(props)
return first === second
},
{ label: 'post options render determinism', samples: 500 }
)
})
@@ -0,0 +1,182 @@
/*
Unit tests for the post options menu.
Every post card shows a three-dots "Post options" button whose menu's first
item is "See on block explorer", a link to the post transaction on
bch.loping.net that opens in a new tab. The menu opens on the button, closes
on the button again, an outside click, or Escape, and ArrowDown moves focus
to the first item.
The pure service owns the link construction and the open/close/focus state
transitions; the component renders that state to markup.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const PostOptions = require('../../src/services/post-options')
const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu')
const TXID = 'c96a46c8b55657fe125115e3ddf5ad30ad587bb41baa952cb8d9be9334161875'
const EXPLORER_URL = `https://bch.loping.net/tx/${TXID}`
function renderMenu (props = {}) {
return ReactDOMServer.renderToStaticMarkup(
React.createElement(PostOptionsMenu, { txid: TXID, ...props })
)
}
test('explorerTxUrl builds the bch.loping.net transaction link', () => {
assert.equal(PostOptions.explorerTxUrl(TXID), EXPLORER_URL)
})
test('explorerTxUrl returns an empty string without a txid', () => {
assert.equal(PostOptions.explorerTxUrl(''), '')
assert.equal(PostOptions.explorerTxUrl(null), '')
assert.equal(PostOptions.explorerTxUrl(undefined), '')
})
test('postOptionsItems puts the block explorer link first', () => {
const items = PostOptions.postOptionsItems(TXID)
assert.equal(items[0].label, 'See on block explorer')
assert.equal(items[0].href, EXPLORER_URL)
assert.equal(items[0].target, '_blank')
assert.equal(items[0].rel, 'noopener noreferrer')
})
test('the menu starts hidden', () => {
const state = PostOptions.initialPostOptionsState()
assert.equal(state.open, false)
assert.equal(state.focusedIndex, -1)
})
test('toggling the menu opens it, then closes it again', () => {
let state = PostOptions.initialPostOptionsState()
state = PostOptions.togglePostOptions(state)
assert.equal(state.open, true)
state = PostOptions.togglePostOptions(state)
assert.equal(state.open, false)
})
test('closing the menu also clears item focus', () => {
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
const focused = PostOptions.focusFirstPostOption(
open,
PostOptions.postOptionsItems(TXID)
)
const closed = PostOptions.closePostOptions(focused)
assert.equal(closed.open, false)
assert.equal(closed.focusedIndex, -1)
})
test('Escape and an outside click both close the menu', () => {
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
assert.equal(PostOptions.handlePostOptionsEscape(open).open, false)
assert.equal(PostOptions.handlePostOptionsOutsideClick(open).open, false)
})
test('isOutsidePostOptions is true only for a click outside a mounted menu', () => {
const inside = { contains: () => true }
const outside = { contains: () => false }
assert.equal(PostOptions.isOutsidePostOptions(inside, {}), false)
assert.equal(PostOptions.isOutsidePostOptions(outside, {}), true)
assert.equal(PostOptions.isOutsidePostOptions(null, {}), false)
assert.equal(PostOptions.isOutsidePostOptions(undefined, {}), false)
})
test('focusing the first item selects index 0 when items exist', () => {
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
const focused = PostOptions.focusFirstPostOption(
open,
PostOptions.postOptionsItems(TXID)
)
assert.equal(focused.focusedIndex, 0)
})
test('focusing the first item is a no-op with no items', () => {
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
const focused = PostOptions.focusFirstPostOption(open, [])
assert.equal(focused.focusedIndex, -1)
})
test('the Escape key command closes the menu without preventing default', () => {
const command = PostOptions.postOptionsKeyCommand(
'Escape',
PostOptions.postOptionsItems(TXID)
)
assert.equal(command.preventDefault, false)
const next = command.transition(
PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
)
assert.equal(next.open, false)
assert.equal(next.focusedIndex, -1)
})
test('the ArrowDown key command focuses the first item and prevents default', () => {
const command = PostOptions.postOptionsKeyCommand(
'ArrowDown',
PostOptions.postOptionsItems(TXID)
)
assert.equal(command.preventDefault, true)
const next = command.transition(PostOptions.initialPostOptionsState())
assert.equal(next.open, true)
assert.equal(next.focusedIndex, 0)
})
test('the ArrowDown key command keeps the menu closed when there are no items', () => {
const command = PostOptions.postOptionsKeyCommand('ArrowDown', [])
const next = command.transition(PostOptions.initialPostOptionsState())
assert.equal(next.open, false)
assert.equal(next.focusedIndex, -1)
})
test('other keys have no menu command', () => {
assert.equal(PostOptions.postOptionsKeyCommand('Enter', PostOptions.postOptionsItems(TXID)), null)
assert.equal(PostOptions.postOptionsKeyCommand('Tab', []), null)
})
test('the closed menu renders the post options button but no items', () => {
const html = renderMenu()
assert.match(html, /aria-label="Post options"/)
assert.match(html, /aria-haspopup="menu"/)
assert.match(html, /aria-expanded="false"/)
assert.doesNotMatch(html, /See on block explorer/)
})
test('the open menu renders the block explorer link that opens in a new tab', () => {
const html = renderMenu({ initialOpen: true })
assert.match(html, /aria-expanded="true"/)
assert.match(html, /See on block explorer/)
assert.match(html, new RegExp(`href="${EXPLORER_URL.replace(/[.]/g, '\\.')}"`))
assert.match(html, /target="_blank"/)
assert.match(html, /rel="noopener noreferrer"/)
})
test('the focused first item is tabbable and the others are not', () => {
const html = renderMenu({ initialOpen: true, initialFocusedIndex: 0 })
assert.match(html, /tabindex="0"/)
})
test('an open menu with no focused index makes no item tabbable', () => {
const html = renderMenu({ initialOpen: true })
assert.doesNotMatch(html, /tabindex="0"/)
})