← Cycles
Design · c98

Nav Submenu

archivedcreated 2026-09-26· last activity 2026-09-26· mode: evolution. This adds one member to the existing `NavVertical` / `NavVerticalItem` family; it does not start a new nav system.

A layer nav item that owns child items and discloses them by URL. First use: THE FLEET › Inventory in the qcontrol sidebar shows its 9 listing pages whenever `/inventory` is in the URL.

New in this cycle: NavVerticalSubmenu: a sidebar row that owns child rows and opens them by URL. In qdash, THE FLEET moves up to second and Inventory lists its 9 listing pages (italic, med 13, 1px gap on a stroke-strong rail) on every /inventory page

Why

Inventory has grown into a hub with 9 listing pages under it: Harnesses, Endpoints, MCP Servers, Models, Providers, Sessions, Skills, Tools, Repos & Workspaces. The qcontrol sidebar lists pages flat, so today those listings are only reachable from the sidebar through the WORKING dev toggle. The sidebar should say where you are without anyone clicking to expand anything. When you're anywhere under /inventory, the Inventory item should open to show its children.

Objective

  • Layer: NavVerticalSubmenu (new) + a non-breaking NavVerticalItem change. Children inside a submenu take the sub treatment: Inter italic (med 13 since 2026-09-26, first specced as bold), tighter rows, a hairline rail at the parent's left edge.
  • Design site: demo + registry entry with full prop coverage and empty / minimal / typical / overflow scenarios.
  • qdash: the Inventory item in AppSidebar.vue becomes a submenu.

Done means:

  • The qdash sidebar matches reference.png on every /inventory* page and stays collapsed elsewhere.
  • The demo proves every form of the component.

Scope

  • q-nuxt-layer/components/nav/VerticalSubmenu.vue (new), nav/VerticalItem.vue (inject + sub styling), layer CLAUDE.md Nav table
  • design/app/pages/components/_demos/NavVerticalSubmenu.vue, design/app/data/component-registry.ts
  • qcontrol/crates/qdash/ui/app/components/AppSidebar.vue (children derived from WORKING_LISTINGS)

Plan: plan.md · Prompt: initial-prompt.md

Decisions (Mark, 2026-09-26)

  1. URL matching lives in the component. match takes path prefixes and defaults to to, and v-model:open overrides it. This follows IconNavItem's basePath precedent. Row active stays caller-controlled.
  2. The parent row stays default ink on child pages. It is grape only on /inventory itself.
  3. Children prefix-match, so Models stays lit on its detail pages. Top-level items keep exact match.
  4. WORKING stays as is. The 9 listings show in both places for now.

Defaults, unless Mark objects:

  • The name is NavVerticalSubmenu.
  • No chevron.
  • A URL-matched list can't be click-collapsed.
  • It opens instantly, with no animation.

Key Changes

  • Kick-off (2026-09-26): plan drafted, four decisions settled, cycle activated.
  • Layer + demo (2026-09-26):
    • NavVerticalSubmenu (q-nuxt-layer/components/nav/VerticalSubmenu.vue): the parent row is a NavVerticalItem (:sub="false") or a toggle <button>, followed by a role="group" list. Disclosure is segment-aware prefix match (defaults to to), with v-model:open as the override.
    • Rail: a ::before pseudo, so the top-2 bottom-2 inset doesn't push the children down. It uses stroke-strong, which matches the reference pixel for pixel (rgb 212, the same stroke as the section title rule).
    • NavVerticalItem gains sub (default undefined, which inherits via inject('navSubmenu')): bold italic, py-0.5. The measured result is 15px/700 italic, 26px rows against the parent's 30px. The reference pitch is ~27.5px.
    • Demo _demos/NavVerticalSubmenu.vue: interactive sidebar + empty / minimal / typical / unmatched / button / controlled / overflow scenarios. Registry entry added; the NavVerticalItem entry and demo gain sub. Layer CLAUDE.md: Nav table rows + an unreleased migration note.
    • Gotcha: the button's first class name ._toggle collided with UxToggle's unscoped ._toggle::after (a 2px white dot). It was renamed ._submenu-toggle, and the rail class ._submenu-rail.
  • Sub weight + size props (2026-09-26, Mark): NavVerticalSubmenu gains subWeight (reg | med | semi | bold | exbold | black, default bold) and subSize (px on the type scale 11–16, default 15).
    • How it reaches the children: the submenu now provides { weight, size } instead of true. NavVerticalItem maps them through literal class tables, so Tailwind sees every class; an off-scale value falls back to bold 15.
    • Demo: a weight dropdown + a 11–16 size slider. All six weights and six sizes verified in the browser. The parent row stays semi 15.
    • Consistency: named weight steps + scale-bound sizes keep it inside "size is constrained", with no arbitrary px.
  • Provisional subGap (2026-09-26, Mark): px of space between child rows (default 4, the old py-0.5), split into top/bottom padding by inline style so the click area stays contiguous. Demo slider 0–16.
    • Measured pitch = line-height + gap − 2 (the link's -mb-[2px]): 0 → 22px, 4 → 26px, 8 → 30px, 16 → 38px at 15px text. The reference's ~27.5px pitch sits at a gap of about 5–6.
    • To be removed once dialed: bake the chosen value into NavVerticalItem's SUB constant and drop the prop, the provide field and the demo slider.
  • Sub defaults dialed (2026-09-26, Mark, from the demo controls): subWeight med, subSize 13, subGap 1 are now the defaults (were bold / 15 / 4).
    • Measured: children render 500 13px italic with 0.5px top/bottom padding, 23px rows. The parent row is unchanged at semi 15.
    • Scope: NavVerticalItem's SUB constant holds the same values, so sub forced outside a submenu matches, and the scoped .sub py-0.5 rule is gone. subGap stays provisional until the next look; if 1 holds, bake it into SUB and drop the prop.
  • qdash wiring (2026-09-26): in qcontrol/crates/qdash/ui/app/components/AppSidebar.vue, a nav item listed in a SUBMENUS map renders as a NavVerticalSubmenu. Today that is only /inventory, whose children are WORKING_LISTINGS filtered to /inventory/*, so they're derived rather than typed a second time. Everything else renders as a NavVerticalItem, as before.
    • Active rules: new isSubActive is a segment-aware prefix match for children. The top-level isActive stays exact. WORKING is untouched.
    • Verified on the live :3030 server (NUXT_LOCAL_LAYER):
      • / and /users: collapsed.
      • /inventory: open, parent grape, no child lit.
      • /inventory/models/detail?id=…: open, parent ink, Models lit.
      • /inventory/workspaces: Repos & Workspaces lit.
      • Children render 500 13px italic, 23px rows, rail rgb 212.
      • working-nav.test.ts passes (6/6).
  • THE FLEET moved up (2026-09-26, Mark): qdash NAV now reads WHERE ARE WE → THE FLEET → WHAT GOOD LOOKS LIKE → ACT → PROVE. The groups still follow the Rust dashboard's 5-section IA, but the order now diverges from it; AppSidebar.vue notes this. Verified on :3030.
  • Inventory page title (2026-09-26, Mark): /inventory PageHeader "Harness Inventory" → "Inventory", matching the nav label. That was its only use of the old title in qdash.

Outcome

Completed 2026-09-26. The qcontrol sidebar now shows where you are. On every /inventory page, Inventory opens to its nine listings: Harnesses, Endpoints, MCP Servers, Models, Providers, Sessions, Skills, Tools, Repos & Workspaces. The current listing is lit, including on its detail pages. Elsewhere the submenu collapses to one row. THE FLEET moved up to second in the rail, and the Inventory page is titled "Inventory".

  • Layer (unreleased; rides the next bump with the queued 0.9.22 work):
    • NavVerticalSubmenu: parent row + disclosed children; route-aware match (defaults to to, segment-aware) + v-model:open; a button form when there's no to.
    • NavVerticalItem gains sub, inherited by injection. Existing call sites are unchanged.
  • Design site: demo with DevCanvas controls and seven scenarios; registry entry; archive entry content/archive/2026-q3/2026-09-26-c98-nav-submenu.md; six captures in public/cycles/design/c98-*.
  • Learnings:
    • Tune in the demo. Exposing weight / size / gap as props turned "a bit less space" into a two-minute session on the demo's controls. The dialed values (med 13, 1px) became the defaults. Worth repeating for any styling question the reference doesn't settle.
    • Measure the reference. Sampling the reference PNG (PIL) settled the rail colour exactly: it is stroke-strong, the section title rule, not stroke. Size and pitch came from the same sampling, not from eyeballing.
    • Layer class names can collide. An underscore-prefixed class in a layer component can hit another component's unscoped rule (UxToggle's ._toggle::after). Prefix with the component's own name.
  • Carried forward:
    • Remove the provisional subGap once 1px holds: bake it into NavVerticalItem's SUB and drop the prop and slider.
    • Release the layer; qdash's committed dist/ rebuilds on that bump.
    • qcontrol is uncommitted (on purpose). The qdash edits (AppSidebar.vue, pages/inventory/index.vue) sit in files carrying the uncommitted c92 harness-rename work, on feat/new-pages. The THE FLEET block that moved is the renamed one, and isSubActive sits against the rewritten isActive. Another session also has files staged in that index. They commit together when that work does.
    • Other sections could take submenus through the same SUBMENUS map in AppSidebar.vue.
    • WORKING still duplicates the nine listings by choice. Revisit when WORKING is next touched.

Visual record

7 captures — heroes, page slices, and full-page renders. Click to view.

qdash /inventory: THE FLEET now sits second in the rail, and Inventory (grape, the current page) has its 9 listings open under it on the stroke-strong rail. The page title reads 'Inventory', no longer 'Harness Inventory'
qdash /inventory: THE FLEET now sits second in the rail, and Inventory (grape, the current page) has its 9 listings open under it on the stroke-strong rail. The page title reads 'Inventory', no longer 'Harness Inventory' history · 6 moments →
The NavVerticalSubmenu demo at its dialed defaults (italic, med 13, 1px gap): the interactive THE FLEET sidebar opened by a `match` on the demo's own path, then the scenarios: no children (no rail), one child, the 9 inventory listings, an unmatched route (collapsed), the button form, v-model:open, and overflow
The NavVerticalSubmenu demo at its dialed defaults (italic, med 13, 1px gap): the interactive THE FLEET sidebar opened by a `match` on the demo's own path, then the scenarios: no children (no rail), one child, the 9 inventory listings, an unmatched route (collapsed), the button form, v-model:open, and overflow open page →history · 2 moments →
qdash /inventory/models: the submenu stays open because the route is under /inventory. Models lights up and the Inventory row drops back to ink, so only the current page is grape
qdash /inventory/models: the submenu stays open because the route is under /inventory. Models lights up and the Inventory row drops back to ink, so only the current page is grape history · 7 moments →
The same demo with the tuning knobs pushed: subWeight black, subSize 16, subGap 8. Only the children change; the parent row stays semi 15. Mark dialed these knobs back to med 13 with a 1px gap, now the defaults. subGap is provisional
The same demo with the tuning knobs pushed: subWeight black, subSize 16, subGap 8. Only the children change; the parent row stays semi 15. Mark dialed these knobs back to med 13 with a 1px gap, now the defaults. subGap is provisional open page →history · 2 moments →
qdash Model detail (claude-sonnet-4-6): sub-items prefix-match, so Models stays lit on its own detail pages and the open submenu still says which listing you came from. Top-level items keep the exact-match rule
qdash Model detail (claude-sonnet-4-6): sub-items prefix-match, so Models stays lit on its own detail pages and the open submenu still says which listing you came from. Top-level items keep the exact-match rule history · 5 moments →
qdash Fleet Overview: outside /inventory the submenu is collapsed to its parent row, and the rail reads WHERE ARE WE → THE FLEET → WHAT GOOD LOOKS LIKE → PROVE
qdash Fleet Overview: outside /inventory the submenu is collapsed to its parent row, and the rail reads WHERE ARE WE → THE FLEET → WHAT GOOD LOOKS LIKE → PROVE history · 6 moments →
c98 full
open page →history · 2 moments →

In the archive

Artifacts

/inventory (local dev)/inventory/models/detail?model=claude-sonnet-4-6 (local dev)/inventory/models (local dev)/ (local dev)/components/navigation/nav-vertical-submenu

Qpoint Brand Style Guide