Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.9] - 2026-05-06

### Fixed (F15)

- **CRITICAL crash on duplicate edges** — workspaces with two `from→to:relation` tuples on the same artifact pair triggered Svelte `each_key_duplicate` runtime error across Force / Tree / Radial / Lanes / Matrix views. `filterEdges` in `lib/filter.ts` now dedupes by composite key (keeps first occurrence). 4 unit tests added.
- **HealthBar readability** — chips restructured: `<strong class="chip-value">{n}</strong> <span class="chip-label">{label}</span>` with monospace tabular numerals + uppercase 0.12em letter-spacing labels. Reads as "30 TOTAL · 27 ACTIVE · 3 DRAFT" cleanly instead of mashed `289total177active...`.
- **Chrome 132+ devtools probe 404** — added `template/static/.well-known/appspecific/com.chrome.devtools.json = {}`. SvelteKit serves it as static asset; browser stops probing.
- **ArtifactPanel button styling** — `.ghost` rule was missing inside the panel component (lived only in HomePage, scoped CSS didn't reach). Buttons rendered as browser-default. Added local `.ghost` rule: monospace, uppercase 0.06em, accent stroke on hover/`aria-expanded="true"`. Now matches landing-style design tokens (`.fp-eyebrow`, `.fp-meta-label`).

### Changed (F15)

- **ArtifactPanel default width 380 → 658 px** (+73%). Reading PRD bodies side-by-side with the graph is now comfortable. `PANEL_MAX_RATIO` raised to 0.7 (70vw drag cap from 60vw).
- **Resizable left edge** — 4px hit-area drag handle on the panel's left edge. `pointerdown/move/up` with `setPointerCapture`; `aria-orientation="vertical"`; ArrowLeft/Right keyboard alt; persisted to `localStorage.forgeplan-web.panelWidth`. Range [320, 0.7×innerWidth]. `prefers-reduced-motion` respected.
- **"📋 Copy as markdown" button moved** out of `.impact-actions` (Show downstream / Show upstream / Clear) into a new `.body-actions` row inside `<section class="body">` next to `+ Show body / − Hide body`.
- **Body section opens by default** — `bodyExpanded = $state(true)`. localStorage hydration overrides for users who explicitly muted; new users see body markdown immediately. Race-safe via `bodyHydrated` guard so the writer-effect can't overwrite the saved value before the reader-effect runs.
- **Removed inner scroll containers** — `.artifact-body { max-height: 60vh; overflow-y: auto }` and `.links ul { max-height: 30vh; overflow-y: auto }` dropped. The panel itself scrolls; no nested scrollbars to fight.
- **Page-level overflow** — `.root { overflow: hidden }`. No horizontal/vertical scroll on the page shell.
- **Sticky-stack panel sections + meta-trail** — As the user scrolls inside the ArtifactPanel, each section (`impact-actions / meta / links / body-actions`) pins below the header (`top: var(--header-h)`) with a solid background. JS state `activeStickyKey` derived from `panel.scrollTop` ensures **only one block is sticky at any moment** — earlier rows get `class:passed` → `position: static` so they scroll away with content. (Without this, a shorter active block would leave a taller previous block visibly poking out underneath, since both would be pinned at the same `top:`.) When `body-actions` becomes the active sticky, `depth` and `updated_at` from the meta block fade-slide into the right side of the row (`pointer-events: none; aria-hidden`). 220ms ease-out; honours `prefers-reduced-motion`. Header z-index raised to 10.
- **Outgoing / Incoming collapse** — Each links section now has its own toggle (`+ N · Outgoing` / `− Hide`). Auto-collapsed on artifact change when list length > 8 to prevent the panel from blowing up on hub artifacts. Per-artifact state — toggle survives the 10s poll re-deriving identical edges (the auto-collapse $effect keys on `id`, not on length).

## [0.1.8] - 2026-05-06

### Fixed (F14, post-audit cleanup)
Expand Down Expand Up @@ -258,7 +278,8 @@ host project via `npx @forgeplan/web init -y`. No `npm install` at user
side: `dist/` ships its own `node_modules/` populated with
`--omit=dev --omit=peer`.

[Unreleased]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.8...HEAD
[Unreleased]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.9...HEAD
[0.1.9]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.5...v0.1.6
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@forgeplan/web",
"version": "0.1.8",
"version": "0.1.9",
"description": "Interactive realtime web map for a Forgeplan workspace. Ships a pre-built SvelteKit app and a tiny init/start CLI — no npm install at user side.",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion template/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "forgeplan-web-app",
"private": true,
"version": "0.1.8",
"version": "0.1.9",
"type": "module",
"scripts": {
"dev": "vite dev --port 5174 --host 127.0.0.1",
Expand Down
109 changes: 106 additions & 3 deletions template/src/pages/home/ui/HomePage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
let settingsHydrated = $state(false);
let notifyEnabled = $state(false);
let liveText = $state('');

const PANEL_MIN = 320;
const PANEL_MAX_RATIO = 0.7;
const PANEL_DEFAULT = 658;
const PANEL_STORAGE_KEY = 'forgeplan-web.panelWidth';

let panelWidth = $state(PANEL_DEFAULT);
// Plain `let`, not $state: this is the "previous tick" memo for the
// breach-detection effect below, not a value the template renders.
// Making it $state caused effect_update_depth_exceeded — the effect
Expand Down Expand Up @@ -152,6 +159,60 @@
notifyBus.pendingFocus = null;
}
});

$effect(() => {
if (typeof localStorage === 'undefined') return;
const saved = localStorage.getItem(PANEL_STORAGE_KEY);
if (saved === null) return;
const n = Number(saved);
if (Number.isFinite(n) && n >= PANEL_MIN) panelWidth = clampWidth(n);
});

function clampWidth(w: number): number {
const max = typeof window !== 'undefined'
? window.innerWidth * PANEL_MAX_RATIO
: Number.POSITIVE_INFINITY;
return Math.max(PANEL_MIN, Math.min(max, w));
}

function persistWidth() {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(PANEL_STORAGE_KEY, String(Math.round(panelWidth)));
}

function onResizeStart(e: PointerEvent) {
e.preventDefault();
const startX = e.clientX;
const startWidth = panelWidth;
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);

function move(ev: PointerEvent) {
const dx = startX - ev.clientX;
panelWidth = clampWidth(startWidth + dx);
}
function up() {
target.removeEventListener('pointermove', move);
target.removeEventListener('pointerup', up);
target.removeEventListener('pointercancel', up);
persistWidth();
}
target.addEventListener('pointermove', move);
target.addEventListener('pointerup', up);
target.addEventListener('pointercancel', up);
}

function onResizeKey(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
panelWidth = clampWidth(panelWidth + 24);
persistWidth();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
panelWidth = clampWidth(panelWidth - 24);
persistWidth();
}
}
</script>

<div class="root">
Expand All @@ -163,7 +224,11 @@
<button type="button" class="retry" onclick={() => { listPoller.refresh(); graphPoller.refresh(); }}>retry</button>
</div>
{/if}
<main class="layout" class:has-panel={selectedId !== null}>
<main
class="layout"
class:has-panel={selectedId !== null}
style:--panel-w={`${panelWidth}px`}
>
<Filters
kinds={[...new Set(nodes.map((n) => n.kind.toLowerCase()))].sort()}
statuses={[...new Set(nodes.map((n) => n.status.toLowerCase()))].sort()}
Expand Down Expand Up @@ -212,6 +277,17 @@
<InsightsRail bind:activeTab onSelect={(detail) => selectNode(detail)} />
{#if selectedId}
<div class="panel">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize artifact panel"
tabindex="0"
onpointerdown={onResizeStart}
onkeydown={onResizeKey}
></div>
<ArtifactPanel
id={selectedId}
{edges}
Expand All @@ -231,6 +307,7 @@
width: 100vw;
background: var(--bg);
color: var(--fg-1);
overflow: hidden;
}
.error-bar {
display: flex;
Expand Down Expand Up @@ -271,7 +348,7 @@
min-height: 0;
}
.layout.has-panel {
grid-template-columns: 200px 1fr 320px 380px;
grid-template-columns: 200px 1fr 320px var(--panel-w, 658px);
}
.canvas {
display: flex;
Expand Down Expand Up @@ -355,15 +432,41 @@
color: var(--fg-3);
}
.panel {
position: relative;
min-width: 0;
min-height: 0;
}
.resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
margin-left: -3px;
cursor: ew-resize;
background: transparent;
z-index: 10;
transition: background 120ms;
}
.resize-handle:hover,
.resize-handle:focus-visible {
background: var(--accent-dim);
outline: none;
}
.resize-handle:active {
background: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
.resize-handle {
transition: none;
}
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 200px 1fr;
}
.layout.has-panel {
grid-template-columns: 200px 1fr 380px;
grid-template-columns: 200px 1fr var(--panel-w, 658px);
}
.layout :global(aside.rail),
.layout.has-panel :global(aside.rail) {
Expand Down
Loading
Loading