mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-05-14 09:15:32 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39a41e561c | |||
| 4424db22cb | |||
| 28a796e24f | |||
| f3c15e3e1c | |||
| 3301f8d95f | |||
| a575fddaa2 | |||
| c9fe7d8583 | |||
| 03f17a6e05 |
3
.claude/.gitignore
vendored
Normal file
3
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.settings.json
|
||||
@@ -4,52 +4,8 @@
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash(git status*)",
|
||||
"Bash(git log*)",
|
||||
"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*)",
|
||||
"Bash(*)",
|
||||
"Read(//tmp/**)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
@@ -57,21 +13,28 @@
|
||||
"WebFetch(domain:docs.docker.com)",
|
||||
"WebFetch(domain:pypi.org)",
|
||||
"WebFetch(domain:docs.cypress.io)",
|
||||
"WebFetch(domain:flask.palletsprojects.com)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push*)",
|
||||
"Bash(docker run*)",
|
||||
"Bash(curl*)"
|
||||
"WebFetch(domain:flask.palletsprojects.com)",
|
||||
"Skill(update-config)",
|
||||
"Skill(update-config:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(sudo*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push*)",
|
||||
"Bash(docker run*)",
|
||||
"Bash(curl*)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/tmp"
|
||||
]
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"autoAllowBashIfSandboxed": true,
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
|
||||
@@ -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
|
||||
|
||||
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
|
||||
|
||||
7
Makefile
7
Makefile
@@ -92,11 +92,6 @@ install-dev:
|
||||
# Install runtime and developer dependencies from pyproject.toml.
|
||||
$(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
|
||||
lint-actions:
|
||||
# Lint GitHub Actions workflows.
|
||||
@@ -145,7 +140,7 @@ security: install-dev test-security
|
||||
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e: npm-install
|
||||
test-e2e:
|
||||
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
|
||||
-docker stop portfolio 2>/dev/null || true
|
||||
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
||||
|
||||
@@ -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', () => {
|
||||
const base = {
|
||||
|
||||
@@ -111,17 +111,13 @@ div#navbarNavfooter li.nav-item {
|
||||
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#navbarNavfooter .navbar-nav {
|
||||
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 {
|
||||
@@ -197,7 +193,6 @@ iframe{
|
||||
/* 1. Make sure headers and footers can collapse */
|
||||
header,
|
||||
footer {
|
||||
overflow: hidden;
|
||||
/* choose a max-height that’s >= your tallest header/footer */
|
||||
max-height: 200px;
|
||||
padding: 1rem;
|
||||
@@ -206,9 +201,11 @@ footer {
|
||||
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 footer {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
/* Top-level dropdown menu */
|
||||
.nav-item .dropdown-menu {
|
||||
position: absolute; /* Important for positioning */
|
||||
top: 100%; /* Default opening direction: downwards */
|
||||
/* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
|
||||
.nav-item.dropdown > .dropdown-menu,
|
||||
.nav-item.dropup > .dropdown-menu {
|
||||
position: absolute;
|
||||
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 */
|
||||
|
||||
@@ -1,6 +1,58 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuItems = document.querySelectorAll('.nav-item.dropdown');
|
||||
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||
function getDirectChildByClass(item, className) {
|
||||
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) {
|
||||
items.forEach(item => {
|
||||
@@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function onMouseEnter() {
|
||||
clearTimeout(timeout);
|
||||
openMenu(item, isTopLevel);
|
||||
openMenu(item, isTopLevel, 'hover');
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
@@ -25,7 +77,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Open and adjust position on click
|
||||
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
|
||||
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')) {
|
||||
closeMenu(item);
|
||||
} else {
|
||||
@@ -35,40 +109,60 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
|
||||
|
||||
function addAllMenuEventListeners() {
|
||||
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown');
|
||||
const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||
addMenuEventListeners(updatedMenuItems, true);
|
||||
addMenuEventListeners(updatedSubMenuItems, false);
|
||||
}
|
||||
|
||||
ensureDropdownInstances();
|
||||
addAllMenuEventListeners();
|
||||
|
||||
// Global click listener to close menus when clicking outside
|
||||
document.addEventListener('click', () => {
|
||||
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
|
||||
});
|
||||
|
||||
function openMenu(item, isTopLevel) {
|
||||
function openMenu(item, isTopLevel, openedBy = 'script') {
|
||||
item.classList.add('open');
|
||||
const submenu = item.querySelector('.dropdown-menu');
|
||||
if (submenu) {
|
||||
const submenu = getMenu(item);
|
||||
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.opacity = '1';
|
||||
submenu.style.visibility = 'visible';
|
||||
adjustMenuPosition(submenu, item, isTopLevel);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu(item) {
|
||||
item.classList.remove('open');
|
||||
const submenu = item.querySelector('.dropdown-menu');
|
||||
if (submenu) {
|
||||
delete item.dataset.openedBy;
|
||||
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.opacity = '0';
|
||||
submenu.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
function isSmallScreen() {
|
||||
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{% else %}
|
||||
<!-- Dropdown Menu -->
|
||||
<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 %}
|
||||
{{ render_icon_and_name(item) }}
|
||||
{% else %}
|
||||
|
||||
@@ -12,4 +12,13 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./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
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
vendor:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfolio-ui"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
70
tests/unit/test_navigation_template.py
Normal file
70
tests/unit/test_navigation_template.py
Normal 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()
|
||||
Reference in New Issue
Block a user