8 Commits

Author SHA1 Message Date
39a41e561c Release version 1.2.0 2026-05-11 02:51:17 +02:00
4424db22cb fix(cypress): allow scrollTo on non-scrollable pages in footer dropup test
cy.scrollTo('bottom') threw on CI ("element is not scrollable") whenever
the rendered page fit inside the viewport. Pass ensureScrollable:false
so the call is a no-op on short pages — the footer is already in view
and the subsequent rect-position pre-check enforces the actual
precondition that chooseDirection() needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:06 +02:00
28a796e24f chore(claude): enable sandbox and consolidate bash allowlist
Activate the harness sandbox (enabled + autoAllowBashIfSandboxed +
filesystem write/deny rules) and replace the ~30 specific Bash(...)
permission entries with a single Bash(*) wildcard. The existing deny
list (git push --force, git reset --hard, rm -rf, sudo) and ask list
(git push, docker run, curl) keep their precedence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:36:28 +02:00
f3c15e3e1c fix(navigation): unclip dropdowns and flip toward the side with more space
- Move <header> overflow:hidden into body.fullscreen scope and drop the
  implicit-vertical-clip overflow-x:auto from .navbar-nav so dropdown
  menus can escape the navbar.
- Drive top-level dropdowns through bootstrap.Dropdown (popperConfig
  strategy:'fixed'), and add a chooseDirection() helper that toggles
  .dropup/.dropdown on the .nav-item based on space above vs below
  before each show. Split the navigation.css rules to position the menu
  with top:100% or bottom:100% accordingly.
- Mark the dropdown toggle with data-bs-toggle="dropdown" in the
  template; cover that with a Jinja-rendered unit test and add Cypress
  specs for the header (opens downward) and footer (flips to .dropup)
  cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:26:02 +02:00
3301f8d95f chore(claude): allow jobs bash command in harness
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:22:06 +02:00
a575fddaa2 build(compose): run npm install in container, persist deps in named volumes
Replace the host-side `make npm-install` step with a compose-level
`command:` override that runs `npm install` inside the container on
every start. Back node_modules and static/vendor with named volumes so
the bind-mounted source tree no longer shadows the install while state
still survives container restarts. Drop the now-redundant npm-install
target and its references in up/dev/prod/test-e2e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:17:53 +02:00
c9fe7d8583 chore(claude): expand harness allowlist and ignore local state
Add permissions for read-only test/inspection commands (make test-e2e,
docker exec/restart, /tmp reads) and gitignore everything under .claude
except the shared settings/gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:15:04 +02:00
03f17a6e05 build(make): run npm-install before docker-compose targets
Add npm-install dependency to up/dev/prod targets so app/static/vendor
and app/node_modules exist on the host before the ./app:/app bind mount
masks the build-time artifacts inside the container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:40:19 +02:00
13 changed files with 307 additions and 92 deletions

3
.claude/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore
!.settings.json

View File

@@ -4,52 +4,8 @@
"Read", "Read",
"Edit", "Edit",
"Write", "Write",
"Bash(git status*)", "Bash(*)",
"Bash(git log*)", "Read(//tmp/**)",
"Bash(git diff*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(git checkout*)",
"Bash(git branch*)",
"Bash(git fetch*)",
"Bash(git stash*)",
"Bash(git -C:*)",
"Bash(make*)",
"Bash(python3*)",
"Bash(python*)",
"Bash(pip show*)",
"Bash(pip list*)",
"Bash(pip install*)",
"Bash(npm install*)",
"Bash(npm run*)",
"Bash(npx*)",
"Bash(docker pull*)",
"Bash(docker build*)",
"Bash(docker images*)",
"Bash(docker ps*)",
"Bash(docker inspect*)",
"Bash(docker logs*)",
"Bash(docker create*)",
"Bash(docker export*)",
"Bash(docker rm*)",
"Bash(docker rmi*)",
"Bash(docker stop*)",
"Bash(docker compose*)",
"Bash(docker-compose*)",
"Bash(docker container prune*)",
"Bash(grep*)",
"Bash(find*)",
"Bash(ls*)",
"Bash(cat*)",
"Bash(head*)",
"Bash(tail*)",
"Bash(wc*)",
"Bash(sort*)",
"Bash(tar*)",
"Bash(mkdir*)",
"Bash(cp*)",
"Bash(mv*)",
"Bash(jq*)",
"WebSearch", "WebSearch",
"WebFetch(domain:github.com)", "WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:raw.githubusercontent.com)",
@@ -57,21 +13,28 @@
"WebFetch(domain:docs.docker.com)", "WebFetch(domain:docs.docker.com)",
"WebFetch(domain:pypi.org)", "WebFetch(domain:pypi.org)",
"WebFetch(domain:docs.cypress.io)", "WebFetch(domain:docs.cypress.io)",
"WebFetch(domain:flask.palletsprojects.com)" "WebFetch(domain:flask.palletsprojects.com)",
], "Skill(update-config)",
"ask": [ "Skill(update-config:*)"
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
], ],
"deny": [ "deny": [
"Bash(git push --force*)", "Bash(git push --force*)",
"Bash(git reset --hard*)", "Bash(git reset --hard*)",
"Bash(rm -rf*)", "Bash(rm -rf*)",
"Bash(sudo*)" "Bash(sudo*)"
],
"ask": [
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
],
"additionalDirectories": [
"/tmp"
] ]
}, },
"sandbox": { "sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"filesystem": { "filesystem": {
"allowWrite": [ "allowWrite": [
".", ".",

0
.codex Normal file
View File

View File

@@ -1,3 +1,11 @@
## [1.2.0] - 2026-05-11
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle
* Compose-driven dependencies: docker-compose runs npm install inside the container on every up and persists node_modules plus static/vendor in named volumes, removing the host-side npm-install step from up, dev, prod, and test-e2e
* Test coverage: New Cypress specs cover both header and footer dropdown directions, with a Jinja unit test guarding the data-bs-toggle attribute on top-level dropdown toggles
* Harness configuration: Enabled the Claude Code sandbox with scoped filesystem rules, consolidated the bash allowlist behind a single wildcard, and gitignored local-only state under .claude
## [1.1.0] - 2026-03-30 ## [1.1.0] - 2026-03-30
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads * *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads

View File

@@ -92,11 +92,6 @@ install-dev:
# Install runtime and developer dependencies from pyproject.toml. # Install runtime and developer dependencies from pyproject.toml.
$(PYTHON) -m pip install -e ".[dev]" $(PYTHON) -m pip install -e ".[dev]"
.PHONY: npm-install
npm-install:
# Install Node.js dependencies for browser tests.
cd app && npm install
.PHONY: lint-actions .PHONY: lint-actions
lint-actions: lint-actions:
# Lint GitHub Actions workflows. # Lint GitHub Actions workflows.
@@ -145,7 +140,7 @@ security: install-dev test-security
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt $(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
.PHONY: test-e2e .PHONY: test-e2e
test-e2e: npm-install test-e2e:
# Run Cypress end-to-end tests via act (stop portfolio container to free port first). # Run Cypress end-to-end tests via act (stop portfolio container to free port first).
-docker stop portfolio 2>/dev/null || true -docker stop portfolio 2>/dev/null || true
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e $(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e

View File

@@ -1,4 +1,70 @@
// cypress/e2e/dynamic_popup.spec.js // cypress/e2e/menu.spec.js
describe('Navigation dropdowns', () => {
beforeEach(() => {
cy.viewport(1280, 720);
cy.visit('/');
});
it('opens top-level dropdowns with explicit Bootstrap instances', () => {
cy.get('#navbarNavheader .nav-item.dropdown > .nav-link.dropdown-toggle')
.first()
.as('toggle')
.should('have.attr', 'data-bs-toggle', 'dropdown')
.and('have.attr', 'aria-expanded', 'false');
cy.get('@toggle').then($toggle => {
cy.window().then(win => {
expect(win.bootstrap.Dropdown.getInstance($toggle[0])).to.exist;
});
});
cy.get('@toggle')
.parent('.nav-item')
.find('> .dropdown-menu')
.as('menu')
.should('not.have.class', 'show')
.should('not.be.visible');
cy.get('@toggle').click();
cy.get('@toggle').should('have.attr', 'aria-expanded', 'true');
cy.get('@toggle').parent('.nav-item').should('have.class', 'dropdown');
cy.get('@menu')
.should('have.class', 'show')
.and('be.visible');
});
it('flips footer dropdowns upward where there is more space above', () => {
cy.get('#navbarNavfooter .nav-item .nav-link.dropdown-toggle')
.first()
.as('toggle');
cy.get('@toggle')
.parent('.nav-item')
.as('item')
.find('> .dropdown-menu')
.as('menu')
.should('not.have.class', 'show');
// Make sure the footer sits at the bottom of the viewport before clicking
// — otherwise the toggle could land near the top and chooseDirection would
// keep .dropdown (more space below than above).
// ensureScrollable:false because on short pages the body isn't scrollable
// and the footer is already in view (which is fine for this test).
cy.scrollTo('bottom', { ensureScrollable: false });
cy.get('@toggle').then($toggle => {
const rect = $toggle[0].getBoundingClientRect();
expect(rect.top, 'toggle is in the lower half of the viewport')
.to.be.greaterThan(Cypress.config('viewportHeight') / 2);
});
cy.get('@toggle').click({ scrollBehavior: false });
cy.get('@item').should('have.class', 'dropup');
cy.get('@item').should('not.have.class', 'dropdown');
cy.get('@menu')
.should('have.class', 'show')
.and('be.visible');
});
});
describe('Dynamic Popup', () => { describe('Dynamic Popup', () => {
const base = { const base = {

View File

@@ -111,17 +111,13 @@ div#navbarNavfooter li.nav-item {
margin-right: 6px; margin-right: 6px;
} }
/* Prevent nav items from wrapping to a second line */ /* Prevent nav items from wrapping to a second line.
overflow is intentionally NOT set here — overflow-x:auto would
implicitly clip overflow-y too and hide dropdown menus that open
below the navbar. */
div#navbarNavheader .navbar-nav, div#navbarNavheader .navbar-nav,
div#navbarNavfooter .navbar-nav { div#navbarNavfooter .navbar-nav {
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
}
div#navbarNavheader .navbar-nav::-webkit-scrollbar,
div#navbarNavfooter .navbar-nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
} }
main, footer, header, nav { main, footer, header, nav {
@@ -197,7 +193,6 @@ iframe{
/* 1. Make sure headers and footers can collapse */ /* 1. Make sure headers and footers can collapse */
header, header,
footer { footer {
overflow: hidden;
/* choose a max-height thats >= your tallest header/footer */ /* choose a max-height thats >= your tallest header/footer */
max-height: 200px; max-height: 200px;
padding: 1rem; padding: 1rem;
@@ -206,9 +201,11 @@ footer {
padding var(--anim-duration) ease-in-out; padding var(--anim-duration) ease-in-out;
} }
/* 2. In fullscreen mode, collapse them */ /* 2. In fullscreen mode, collapse them. overflow: hidden is scoped here
so dropdown menus can escape the header in normal mode. */
body.fullscreen header, body.fullscreen header,
body.fullscreen footer { body.fullscreen footer {
overflow: hidden;
max-height: 0; max-height: 0;
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;

View File

@@ -1,9 +1,19 @@
/* Top-level dropdown menu */ /* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
.nav-item .dropdown-menu { .nav-item.dropdown > .dropdown-menu,
position: absolute; /* Important for positioning */ .nav-item.dropup > .dropdown-menu {
top: 100%; /* Default opening direction: downwards */ position: absolute;
left: 0; left: 0;
z-index: 1050; /* Ensures the menu appears above other elements */ z-index: 1050;
}
.nav-item.dropdown > .dropdown-menu {
top: 100%;
bottom: auto;
}
.nav-item.dropup > .dropdown-menu {
top: auto;
bottom: 100%;
} }
/* Submenu position */ /* Submenu position */

View File

@@ -1,6 +1,58 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const menuItems = document.querySelectorAll('.nav-item.dropdown'); function getDirectChildByClass(item, className) {
const subMenuItems = document.querySelectorAll('.dropdown-submenu'); return Array.from(item.children).find(child => child.classList?.contains(className));
}
function getMenu(item) {
return getDirectChildByClass(item, 'dropdown-menu');
}
function getToggle(item) {
return getDirectChildByClass(item, 'dropdown-toggle');
}
function isTopLevelDropdown(item) {
return (
item.classList.contains('nav-item') &&
(item.classList.contains('dropdown') || item.classList.contains('dropup'))
);
}
function chooseDirection(item) {
const rect = item.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceAbove > spaceBelow) {
item.classList.add('dropup');
item.classList.remove('dropdown');
} else {
item.classList.add('dropdown');
item.classList.remove('dropup');
}
}
function ensureDropdownInstances(root = document) {
const scope = root && root.querySelectorAll ? root : document;
scope
.querySelectorAll('.nav-item.dropdown > .dropdown-toggle, .nav-item.dropup > .dropdown-toggle')
.forEach(toggle => {
toggle.setAttribute('data-bs-toggle', 'dropdown');
if (!toggle.hasAttribute('aria-expanded')) {
toggle.setAttribute('aria-expanded', 'false');
}
if (window.bootstrap?.Dropdown) {
// Use Popper strategy: 'fixed' so the menu is positioned relative
// to the viewport and escapes ancestors with overflow:hidden
// (e.g. <header> which clips for the fullscreen-collapse animation).
window.bootstrap.Dropdown.getInstance(toggle)?.dispose();
new window.bootstrap.Dropdown(toggle, {
popperConfig(defaultBsPopperConfig) {
return { ...defaultBsPopperConfig, strategy: 'fixed' };
},
});
}
});
}
function addMenuEventListeners(items, isTopLevel) { function addMenuEventListeners(items, isTopLevel) {
items.forEach(item => { items.forEach(item => {
@@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
function onMouseEnter() { function onMouseEnter() {
clearTimeout(timeout); clearTimeout(timeout);
openMenu(item, isTopLevel); openMenu(item, isTopLevel, 'hover');
} }
function onMouseLeave() { function onMouseLeave() {
@@ -25,7 +77,29 @@ document.addEventListener('DOMContentLoaded', () => {
// Open and adjust position on click // Open and adjust position on click
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
const toggle = getToggle(item);
const clickedToggle = toggle && (e.target === toggle || toggle.contains(e.target));
if (isTopLevel && !clickedToggle) {
e.stopPropagation();
return;
}
e.stopPropagation(); // Prevents menus from closing when clicking inside e.stopPropagation(); // Prevents menus from closing when clicking inside
if (isTopLevel) {
e.preventDefault();
if (window.bootstrap?.Dropdown) {
if (item.dataset.openedBy === 'click') {
closeMenu(item);
} else if (getMenu(item)) {
item.dataset.openedBy = 'click';
item.classList.add('open');
chooseDirection(item);
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
}
return;
}
}
if (item.classList.contains('open')) { if (item.classList.contains('open')) {
closeMenu(item); closeMenu(item);
} else { } else {
@@ -35,40 +109,60 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
function addAllMenuEventListeners() { function addAllMenuEventListeners() {
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown'); const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu'); const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
addMenuEventListeners(updatedMenuItems, true); addMenuEventListeners(updatedMenuItems, true);
addMenuEventListeners(updatedSubMenuItems, false); addMenuEventListeners(updatedSubMenuItems, false);
} }
ensureDropdownInstances();
addAllMenuEventListeners(); addAllMenuEventListeners();
// Global click listener to close menus when clicking outside // Global click listener to close menus when clicking outside
document.addEventListener('click', () => { document.addEventListener('click', () => {
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item)); [...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
}); });
function openMenu(item, isTopLevel) { function openMenu(item, isTopLevel, openedBy = 'script') {
item.classList.add('open'); item.classList.add('open');
const submenu = item.querySelector('.dropdown-menu'); const submenu = getMenu(item);
if (submenu) { if (!submenu) return;
if (isTopLevel) {
item.dataset.openedBy = openedBy;
const toggle = getToggle(item);
if (toggle && window.bootstrap?.Dropdown) {
chooseDirection(item);
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
return;
}
}
submenu.style.display = 'block'; submenu.style.display = 'block';
submenu.style.opacity = '1'; submenu.style.opacity = '1';
submenu.style.visibility = 'visible'; submenu.style.visibility = 'visible';
adjustMenuPosition(submenu, item, isTopLevel); adjustMenuPosition(submenu, item, isTopLevel);
} }
}
function closeMenu(item) { function closeMenu(item) {
item.classList.remove('open'); item.classList.remove('open');
const submenu = item.querySelector('.dropdown-menu'); delete item.dataset.openedBy;
if (submenu) { const submenu = getMenu(item);
if (!submenu) return;
if (isTopLevelDropdown(item)) {
const toggle = getToggle(item);
if (toggle && window.bootstrap?.Dropdown) {
window.bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
return;
}
}
submenu.style.display = 'none'; submenu.style.display = 'none';
submenu.style.opacity = '0'; submenu.style.opacity = '0';
submenu.style.visibility = 'hidden'; submenu.style.visibility = 'hidden';
} }
}
function isSmallScreen() { function isSmallScreen() {
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg' return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'

View File

@@ -85,7 +85,7 @@
{% else %} {% else %}
<!-- Dropdown Menu --> <!-- Dropdown Menu -->
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-display="dynamic" aria-expanded="false"> <a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false">
{% if item.icon is defined and item.icon.class is defined %} {% if item.icon is defined and item.icon.class is defined %}
{{ render_icon_and_name(item) }} {{ render_icon_and_name(item) }}
{% else %} {% else %}

View File

@@ -12,4 +12,13 @@ services:
- .env - .env
volumes: volumes:
- ./app:/app - ./app:/app
- node_modules:/app/node_modules
- vendor:/app/static/vendor
# Run `npm install` on every container start so the named volumes
# reflect the current package.json (postinstall regenerates vendor/).
command: sh -c "npm install --prefix /app --no-audit --no-fund && python app.py"
restart: unless-stopped restart: unless-stopped
volumes:
node_modules:
vendor:

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "portfolio-ui" name = "portfolio-ui"
version = "1.1.0" version = "1.2.0"
description = "A lightweight YAML-driven portfolio and landing-page generator." description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View File

@@ -0,0 +1,70 @@
import unittest
from html.parser import HTMLParser
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
class AnchorCollector(HTMLParser):
def __init__(self):
super().__init__()
self.anchors = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.anchors.append(dict(attrs))
class TestNavigationTemplate(unittest.TestCase):
def test_top_level_dropdowns_have_bootstrap_toggle_attribute(self):
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
environment = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
)
environment.globals["url_for"] = lambda _endpoint, filename: (
f"/static/{filename}"
)
rendered = environment.get_template("moduls/navigation.html.j2").render(
menu_type="header",
platform={
"titel": "Portfolio",
"logo": {"cache": "logo.png"},
},
navigation={
"header": {
"children": [
{
"name": "Apps",
"description": "Application menu",
"icon": {"class": "fa-solid fa-grid"},
"children": [
{
"name": "Example",
"description": "Example app",
"icon": {"class": "fa-solid fa-link"},
"url": "https://example.test",
}
],
}
]
}
},
)
parser = AnchorCollector()
parser.feed(rendered)
dropdown_toggles = [
anchor
for anchor in parser.anchors
if "nav-link" in anchor.get("class", "")
and "dropdown-toggle" in anchor.get("class", "")
]
self.assertEqual(len(dropdown_toggles), 1)
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
if __name__ == "__main__":
unittest.main()