Page MenuHomePhabricator

Technical feasibility study on Minerva TOC
Closed, ResolvedPublic3 Estimated Story Points

Description

Related to https://phabricator.wikimedia.org/T410325
Acceptance criteria

  • understand rough scope of work, i.e., dev weeks required, for each option in the designs, including experimentation and instrumentation (if not reusable from prior projects)
  • document assumptions or decisions needed that will influence complexity/scope of work

Note: no code needs to be written, just a technical write up

Event Timeline

HSwan-WMF moved this task from Incoming/Inbox to Needs Refinement on the Reader Growth Team board.
HSwan-WMF set the point value for this task to 3.Dec 10 2025, 5:27 PM
HSwan-WMF updated the task description. (Show Details)

TL;DR: both design options seem reasonable; I've checked (see below) most of the complexity and nothing sticks out as particularly problematic to implement.
While I still have a couple of implementation questions (most of which don't really impact complexity much), I would conservatively estimate that we should be able to implement either of the proposed solutions in 2-3 sprints (or, given how similar both designs are, 3-4 sprints for both combined)

1. TOC

Open question: do we want to always show the TOC, even when editors chose not to through the use of the NOTOC magic word/template?
While it is likely that the TOC is usually hidden in order not to visually interrupt in cases where its benefit would be dubious, and those reasons may not similarly apply in our setting (1/ screens are smaller so the odds of having to scroll around more to find content are larger, and 2/ it wouldn't be getting in the way of the content as it is will not be visible until a toggle is pressed), that is also the case for new Vector, where the TOC has been pulled out of the content into the sidebar, but it still respects the NOTOC directive (i.e. there is no TOC for such pages)
Note that mobile is already documented to follow an alternative navigation scheme (although that was thus far hiding it altogether rather than ignoring the NOTOC directive)
Here's a list of such articles with NOTOC: https://en.wikipedia.org/w/index.php?search=insource%3A%2F__notoc__%2F&title=Special%3ASearch&ns0=1
@JScherer-WMF Thoughts?

1.1. Existing TOC

Minerva will hide the TOC by default, but will show a collapsible TOC on screens wider than @min-width-breakpoint-tablet: 720px;
Now that we will be adding a TOC elsewhere on the screen, we need to ensure that:

  • either our new button is also not visible on screens wider than that breakpoint, or
  • that the existing TOC is always hidden

@JScherer-WMF Any preference here? Should our new stuff also be applied on screens wider than 720px (and hide the existing TOC toggle there), or do we want to stick with existing functionality there? And would your answer be the same for both scenarios (sticky headers and floating button)?

1.2. TOC retrieval

1.2.1. From DOM

Pages where the TOC is not suppressed by editors through the NOTOC directive will have the TOC included in the DOM already (albeit hidden), and we can easily extract/clone it from there.
Parsoid & legacy output are the same, other than Parsoid adding ids to all nodes.

document.querySelector( '#toc > ul' );
// note: if we end up cloning this node tree, we ought to traverse it and remove all ids (added by Parsoid) as those are meant to be unique

1.2.2. From API

If we also need the TOC for pages where it is not available (NOTOC), or the existing structure does not fit our needs (unlike based on existing designs), we can fetch it from the parse API.
Below is a quick code sample that would produce the same HTML the parsers currently do:

async function getTocHtml() {
    const response = await new mw.Api().get( {
        action: 'parse',
        oldid: mw.config.get( 'wgRevisionId' ),
        prop: 'tocdata'
    } );

    const sectionsByParent = response.parse.tocdata.sections.reduce( ( restructured, section ) => {
        const parent = section.number.replace( /(^|\.)[^/.]+$/, '' );
        return { ...restructured, [ parent ]: ( restructured[ parent ] || [] ).concat( section ) };
    }, {} );

    const parseSectionsFor = ( parent ) => {
        if ( !( parent in sectionsByParent ) ) {
            return '';
        }
        return '<ul>' + 
            sectionsByParent[ parent ].map( ( section ) => `<li class="toclevel-${ section.tocLevel } tocsection-${ section.index }">` +
                `<a href="#${ section.anchor }">` +
                    `<span class="tocnumber">${ section.number }</span>` +
                    `<span class="toctext">${ section.line }</span>` + // note: can contain HTML (and is allowed to), e.g. italics
                '</a>' +
                parseSectionsFor( section.number ) +
            '</li>' ).join( '' ) +
        '</ul>';
    };
    return parseSectionsFor( '' );
}

Not that when fetching from the API, we ought to:

  • make sure it is for the correct revision (mw.config.get( 'wgRevisionId' ))
  • reduce calls as much as possible
    • only load as needed
    • cache the results
    • insofar possible, potentially even attempt to use existing TOC first, before falling back to API

2. Expanded content

2.1. Expand by default

Like in our previous sticky headers experiment, content needs to be expanded by default, which can be done by adding the collapsible-headings-expanded class on the body:

public function onBeforePageDisplay( $out, $skin ): void {
    $out->addBodyClasses( 'collapsible-headings-expanded' );
}

2.2. Remove support for collapsing?

@JScherer-WMF Do we want section to otherwise remain collapsible, or do we want to get rid of that altogether? And is that answer the same for both options?

2.2.1. Legacy

If we do want sections to no longer be collapsible at all, we'll want to use the $wgMFNamespacesWithoutCollapsibleSections config var to make sure that they are not.

Note: there is some client-side support for disabling the behavior (adding collapsible-heading-disabled class to the heading), but it doesn't remove the arrows, and it might interfere with auto-expand on load; in short: we'll want to stay away from this.

If we do this, then "3.1. Expand by default" is redundant, as sections will already be expanded.

2.2.2. Parsoid

The Parsoid implementation doesn't support either $wgMFNamespacesWithoutCollapsibleSections nor the collapsible-heading-disabled class.
Either we dive into MobileFrontend and make it possible to disable section collapsing, or we force all visits to use legacy, e.g. like this:

public function onBeforeInitialize( &$title, $unused, $output, $user, $request, $mediaWiki ): void {
    $request->setVal( 'useparsoid', 0 );
}

3. Sticky header

@JScherer-WMF Am I correct in assuming that we want to update the sticky header with all headings - not just the main title h1 + h2s, but also subsections that are not currently collapsible? And they'll all be displayed the same (height, color, ...), regardless of the level of heading? And the sticky header will have a fixed height with the title being a single, truncated, line?

3.1. Positioning & style

Sticky header positioning has proven tricky in our previous experiment.
Having a single node will already make things slightly simpler this time around, compared to re-using the existing headers and making sure they're all positioned correctly.

// rather than overflowing up to the root element (which messes
// up sticky/fixed positioned elements elsewhere in the DOM),
// set the overflow window on the content element itself
.collapsible-block, .mf-collapsible-content {
    overflow-x: auto;
}

// this should suffice to ensure our new sticky header remains at
// the top, assuming it is placed outside of the
// .collapsible-block and .mf-collapsible-content nodes
// (i.e. 
.the-new-sticky-header {
    position: fixed;
    top: 0;
    display: flex;
    width: 100%;

    span { // or whatever element the text ends up being
        // single-line, truncated, title
        display: block;
        flex: 1;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
}

3.2. Updating on scroll

Keeping track of which heading should be in view has been another tricky thing in our previous experiment. This one is slightly different, but not necessarily less challenging.

The "sketchy prototype" has animation happening throughout the scroll (old fades out and new fades in depending on scroll height), which complicates things:

  • it necessitates a scroll listener that is executed for every scroll (i.e. figuring out which heading should be shown is fairly intensive, and that would have to run for every scroll)
  • the animation would be a more complicated thing, as it would probably depend on the size of the heading (longs vs short text, h2 vs h3, ...)

@JScherer-WMF Would it be ok if the header does not gradually update as a user scrolls, but at once (i.e. switches from one heading to the other as it crosses a specific threshold location)
If so, we can probably stick to an IntersectionObserver only, something like this:

const headings = Array.from( document.querySelectorAll( 'h1, .mw-heading' ) )
const stickyHeadingHeight = 50; // @todo calculate actual sticky heading's height
const intersectingHeadings = new Set();
const updateStickyHeader = ( entries ) => {
    // We'd rather not traverse & process all headings on every intersection change,
    // so we'll try to use the intersecting entries to reduce the set of headings
    // to work with.
    // We can't rely on those intersecting, because that set might be empty (in 2
    // different ways: heading fell off from the top, or from the bottom of the
    // viewport)
    // We could solely rely on those that come into/fall out of the viewport (i.e.
    // those given to this handler) as a starting point, but (especially in the case
    // of collapsed sections) that could still mean having to process a lot of
    // irrelevant headings between the newly-entered-from-the-bottom heading and
    // the one we actually care about.
    // Instead, we'll use both the set of headings we know to be intersecting, and
    // those that we just lost. The very first one from that combined list should
    // always be pretty damn close to the heading we're interested in, effectively
    // minimizing the work we need to do here.
    const relevantHeadings = new Set( intersectingHeadings );
    entries.forEach( ( entry ) => {
        if ( entry.isIntersecting ) {
            intersectingHeadings.add( entry.target );
            relevantHeadings.add( entry.target );
        } else {
            intersectingHeadings.delete( entry.target );
        }
    } );

    const firstKnownHeadingCloseToViewportIndex = Array.from( relevantHeadings ).reduce(
        ( index, heading ) => Math.min( index, headings.indexOf( heading ) ),
        headings.length - 1
    );

    // Find the first heading that is below the top of the viewport
    let firstUpcomingHeadingIndex = headings.length - 1;
    for ( let i = firstKnownHeadingCloseToViewportIndex; i < headings.length; i++ ) {
        // @todo probably tweak the threshold for when the sticky should update
        if ( headings[ i ].getBoundingClientRect().top > stickyHeadingHeight ) {
            firstUpcomingHeadingIndex = i;
            break;
        }
    }

    // Find the last non-hidden (e.g. no collapsed subsections) heading before that
    let lastVisibleHeadingIndex = 0;
    for ( let i = firstUpcomingHeadingIndex - 1; i >= 0; i-- ) {
        // innerText will produce an empty string for elements that are not rendered
        if ( headings[ i ].innerText ) {
            lastVisibleHeadingIndex = i;
            break;
        }
    }

    console.log( headings[ lastVisibleHeadingIndex ].innerText ); // heading text to update sticky header with
    console.log( headings[ lastVisibleHeadingIndex ].querySelector( '.mw-editsection a' ) ); // edit link (if available; will need to special-case main title)
};

const intersectionObserver = new IntersectionObserver( updateStickyHeader );
headings.forEach( ( heading ) => {
    intersectionObserver.observe( heading );
} );

Thanks for your work on this @matthiasmullie ! Answers below.

do we want to always show the TOC, even when editors chose not to through the use of the NOTOC magic word/template?

We should respect editor decisions to hide ToC, even if that decision was made with another context in mind. They can always go in and remove the tag later.

@JScherer-WMF Any preference here? Should our new stuff also be applied on screens wider than 720px (and hide the existing TOC toggle there), or do we want to stick with existing functionality there?

In both cases, the new designs are better than the existing wide-viewport Minerva ToC because they float down the page with the reader whereas the existing Minerva ToC is baked into the content near the top of the page. If, for example, a reader deep links into an article from google, they'd never get to use the ToC in the old designs. So the new ToC design will replace all previous Minerva ToCs at all viewports.

@JScherer-WMF Do we want section to otherwise remain collapsible, or do we want to get rid of that altogether? And is that answer the same for both options?

Yes, I think we should get rid of collapsible section behaviour altogether for the ToC experiments. If the ToC experiments fail, we'll want to put the expanding/collapsing back in relatively quickly.

@JScherer-WMF Am I correct in assuming that we want to update the sticky header with all headings - not just the main title h1 + h2s, but also subsections that are not currently collapsible? And they'll all be displayed the same (height, color, ...), regardless of the level of heading? And the sticky header will have a fixed height with the title being a single, truncated, line?

We should update the sticky header with all headlines that are available in the ToC. I think that's only the H1/2s for now, right?
They should allow 2 lines before truncating. One line just isn't enough for a lot of articles/languages. Otherwise they'll display the same, yes, regardless of heading level.

@JScherer-WMF Would it be ok if the header does not gradually update as a user scrolls, but at once (i.e. switches from one heading to the other as it crosses a specific threshold location)

That's fine for an experiment. I agree gradual animation is too much anyways. We'll probably want some kind of transition at the intersection detection threshold if the feature ever goes wide, though.

I've spun off the work into separate tickets: T413399, T413400, T413401, T413402, T413403, T413404, T413405
I've set up an initial code structure and attempted to isolate the tickets/code as much as possible to reduce the odds of interference as everyone picks up their chunk of work.
TBH, I think we can actually get the bulk of the work done in a single sprint (for both designs), and spend another on QA/bug bash/instrumentation.
I think we can resolve this ticket - we're ready for decisions & action.