Commit graph

2 commits

Author SHA1 Message Date
lrnselfreliance
86d4bbab98
Bookmarks: a nav menu of user links, stored in bookmarks.yaml (#729)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
* bookmarks: user bookmarks in the navigation bar, stored in bookmarks.yaml

A "Bookmarks" tab between Calculators and Flasher opens on hover or click and
lists the user's bookmarks, with directories as flyout submenus (Menu.Sub).
The mobile hamburger shows directories as labelled groups instead.  The tab
always shows, with an "Edit bookmarks" item, so the feature can be found
before any bookmark exists.

Bookmarks live in a new config-only bookmarks.yaml: a nested tree where a
node with `children` is a directory and a node with `url` is a bookmark.
Every node carries an integer id so the API can rename, move and delete
without relying on names.  A URL may be a path on this WROLPi, a port on this
host (`:8096/` or `http://:8096/`, resolved against whatever hostname the
browser used, so it works over LAN IP, mDNS and the hotspot alike), or an
absolute URL.  Each bookmark chooses whether to open in a new tab.

The editor at /more/bookmarks follows the playlist items table: numbered rows,
up/down/edit/delete, and inline "Add a bookmark" / "Add a directory" forms
with a Location picker.  Deleting a directory asks first, since its contents
go with it.  The menu and the editor are in the component gallery.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bookmarks: mark More current when the editor is folded inside it; rem indents in the mobile menu

Found on the QA Pi at a width where Bookmarks overflows into More: the More
button carried no mark on /more/bookmarks, because the bookmarks link has no
`to` for isLinkActive to match.  isLinkActive now knows the bookmarks link owns
its editor page, and the Bookmarks tab uses the same rule.

The mobile menu's directory labels sat a few pixels left of the labels around
them: the indent was in em, and a Menu.Label is set smaller than a Menu.Item.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bookmarks: parse URLs instead of matching a prefix; validate the whole tree on every write

The URL contract was checked by prefix on both sides, and a prefix is not a
form: `//evil.com` starts like a path and `:8096@evil.com` starts like a port,
and a browser takes both off this host.  A path is now a single leading slash
that resolves to this origin, a port form is an optional scheme, `:`, digits
and then only a path, query or fragment (no `@`, no backslash, no control
characters), and anything else must parse as http(s).  The same rules live in
wrolpi/bookmarks.py and BookmarksContext.js, with the same bypass strings in
both test suites.

The config's dataclass validator only saw that `bookmarks` was a list, so a
hand-edited file or the generic config API could store a `javascript:` URL
that the bookmarks API would refuse, and the nav rendered it.  The config now
validates the tree itself on every write and import: URL forms, exactly one of
url/children, unique integer ids, boolean new_tab, and no cycles (a YAML
anchor can build one).  An invalid file is not imported and, since it was never
successfully imported, is never overwritten.

On the page, a stored URL that does not resolve is rendered as text, never as
an href.  A refused add keeps what was typed instead of clearing the form, and
an edit whose move is refused still refreshes the nav (the rename was
committed) and keeps the modal open.  An IPv6 host is bracketed before a port.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* help: document Bookmarks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:11:36 -06:00
lrnselfreliance
eaa8b83e54
ui: the navbar shows which section you are in (#649)
* ui: the navbar shows which section you are in

Clicking Videos opened the videos page and left the bar looking untouched.

react-router was doing its part the whole time -- the tab carried `active` and
`aria-current="page"` on every one of those pages.  Nothing in the stylesheet
drew that class, so all eleven tabs rendered identically and the mark was
visible only to a screen reader.

The current tab now takes a deepened version of the hover wash plus an
underline.  The underline is a pseudo-element in `currentColor` rather than a
border, for two reasons: `currentColor` is the foreground the bar already
computed for whichever of the fourteen navbar colors the user picked, so one
rule covers every color in every theme without naming one; and a pseudo-element
adds no height, where a border would jog the whole row as the mark moved
between tabs.  The overflow "More" menu and the mobile hamburger mark their
copy of the tab too -- on a phone that menu is where most tabs live.

Calculators and Statistics also lose `end: true`.  A tab is meant to stay
current for the pages beneath it, and `end` restricts the match to the exact
path, so either one would have dropped its mark the moment it grew a sub-page.
The other nine links never had it, which is why /videos/1234 already kept
Videos marked.

The test renders the real bar, which needed two pieces of browser jsdom does
not have: fresnel renders no navbar at all unless a breakpoint query matches,
and every width is 0, which convinces the overflow logic that nothing fits and
sweeps all eleven tabs into the closed "More" menu -- where every assertion
about them would have passed vacuously.  Both are stubbed, and the styling
itself is asserted against the stylesheet, since jsdom applies none.

* review: the mark follows the tab into the overflow menu

Six review points, no bugs among them.  The substantive one: when the window
narrows enough to fold the current tab into "More", the bar went blank again --
the trigger is a plain button, not a NavLink, so nothing was marked until the
user opened the menu to look, which is exactly when they least need telling.
"More" now takes the mark when it holds the current section.

That needs the matching rule outside a NavLink, where the hooks that answer it
cannot be called in a loop, so `isLinkActive` states it once: a link owns the
pages beneath it unless it asks for an exact match.  The trigger gets the class
only -- the real NavLink inside the menu already carries `aria-current`, and a
second one on the button that opens the menu announces the same page twice.

The other five, all fair:

  * The mobile hamburger and the overflow menu had no test at all, so
    `.mantine-Menu-item.active` -- the whole of the mark on a phone -- was
    never exercised.  Both now are, which meant splitting the viewport and the
    bar width out of the fixture so a test can ask for a narrow bar or a phone.
    The mobile case also needs the real getBoundingClientRect back: floating-ui
    positions the menu's portal and crashes the worker on a stub DOMRect.
  * The CSS guard matched the selector as a substring, which still passed with
    the rule body emptied or the underline deleted -- and the underline is the
    half that survives on a dark bar.  It now reads the declarations.
  * The table cases used `toContain`, which a regression leaving Home
    permanently marked would satisfy on every row.  `toEqual([text])`.
  * A comment claimed the tab "only matched its own exact path", which is the
    root cause the PR description explicitly rules out -- Videos never carried
    `end`.  Corrected to what those cases actually guard.
  * The navbar offers twelve colors, not fourteen.  I had carried over
    Semantic's palette count, which includes black and white.

Writing the tightened height assertion I reached for `(?!0)`, which calls
0.1875rem zero.  It reads the number instead.
2026-08-08 18:05:55 -06:00