{% extends "scitex_ui/standalone_shell.html" %} {% load static %} {% comment %} Board v3 now extends the scitex-ui workspace shell (operator-asked, TG msg 302 / 303 — wants Alt+I element-inspector + shared chrome parity with the legacy GraphView). All inline content is repacked into the shell's three blocks: extra_css holds the page-local styles, app_content holds the kanban + BLOCKING-YOU panel + detail modal, extra_js holds the page logic. Alt+I element-inspector wires up automatically via scitex-ui's context_processor (settings.py guards the install at scitex-ui ≥0.5.0). For older scitex-ui installs the inspector degrades silently; the board itself works either way. The favicon link is injected via extra_css (the shell template has no favicon block; extra_css renders inside ). {% endcomment %} {% block extra_css %} {# 13-details-pane.css: right-hand Details column (filters + legend-aligned stats), scitex-writer convention #} {# 09-timeline.css: Timeline layout — raster + per-task simple cards (operator TODO 2026-06-17) #} {# 11-sticky-wall.css: Wall layout (notes + islands) + the shared .stx-flash status-transition glow #} {# 14-matrix.css: Matrix layout — urgency×importance 5x5 plane + quadrant divide + unscored tray (ADR-0011 §8). (hook-bypass: line-limit) #} {# combobox.css (loaded below the page-CSS so its specificity sits ON TOP if needed) #} {% comment %} searchQuery.js — GitHub-style qualifier-syntax parser (project:/agent:/ status:/...). Exposes window.STX.searchQuery; the page logic below delegates `fuzzyMatch` to it whenever the input contains a recognized qualifier. Operator TG 12315/12316: photographed typing `project: paper-scitex-clew` into the search bar — they already expected GitHub-style syntax. Pure JS module, no DOM deps; unit tests at tests/scitex_cards/test__search_query.js. NB: Django's `{# … #}` syntax is single-line only — a multi-line block leaks its body into the rendered HTML. Use `{% comment %}` for any multi-line note inside a template (board v0.5.4 fix — PR #105). {% endcomment %} {% comment %} searchSuggest.js — autocomplete/Tab-completion engine, extends PR #102. Pure module; the inline script in extra_js below wires it to the #f-search input. Exposes window.STX.searchSuggest. Operator TG 12318. {% endcomment %} {% comment %} timelinePack.js — pure beeswarm sub-row packer for the Timeline raster. MUST load BEFORE timeline.js: it publishes window.STX.timelinePack, which timeline.js's IIFE captures at init so co-located markers fan out into sub-rows instead of occluding each other. No DOM; also node-testable. {% endcomment %} {% comment %} timelineGeo.js — pure time→pixel geometry (ms / barGeo / makeTicks / relTime) for the Timeline raster. MUST load BEFORE timeline.js: publishes window.STX.timelineGeo, captured by timeline.js's IIFE at init. No DOM; node-testable. Factored out so timeline.js stays under the per-file cap. {% endcomment %} {% comment %} 10-agent-avatar.js — per-agent lane avatar (fleet brand colour/label, or deterministic hash-hue + initials fallback) + status ring for the Timeline raster. MUST load BEFORE timeline.js: publishes window.STX.agentAvatar, captured like _pack/_geo; timeline.js falls back to plain-text lane labels when absent. Operator 2026-07-10 (todo-board-agent-icons-status-rings). {% endcomment %} {% comment %} 11-sticky-wall.js — the "sticky note wall" layout: notes readable without hover, clustered by assignee/project/status, and each agent's NEXT-UP stack derived here from nodes+depends_on edges (no agent cooperation required). Loads AFTER 10-agent-avatar.js (it reuses window.STX.agentAvatar for the note avatar) and publishes window.STX.stickyWall; render() falls back to the column layout when absent. Operator design, card todo-board-sticky-wall-view-20260710. {% endcomment %} {% comment %} 14-matrix.js — the urgency×importance matrix layout (ADR-0011 §8): a 5x5 plane of (urgency, importance) cells, the quadrant divide drawn at the threshold, and an explicit tray for cards with no axes yet. Pure + node- testable (tests/scitex_cards/test__matrix.js runs the REAL file, not a mirror); publishes window.STX.matrix, and render() falls back to Timeline when absent. READ-ONLY: rank is the engine's output (ADR-0011 §1), so this module deliberately contains no scoring function. Operator build order, card scitex-cards-gui-matrix-view-20260717. (hook-bypass: line-limit — pre-existing 3425-line template.) {% endcomment %} {% comment %} 15-dateinfo.js — date extraction + proximity helpers (dateInfo and its parsers), extracted VERBATIM from the inline {% comment %} 12-hover-tip.js — cursor-OFFSET hover tooltip for any [data-tip] element. Replaces the browser's native tooltip (SVG / title=""), which renders under the cursor and covers the dot or note being pointed at (operator msg 944). Self-attaches on DOMContentLoaded; one delegated listener, so every current and future view inherits it. {% endcomment %} <script defer src="{% static 'scitex_cards/board_v3/12-hover-tip.js' %}"></script> {% comment %} 13-timeline-hover.js — row-hover feedback for the Timeline raster (hovered lane highlights + its scatter dots grow). One delegated listener on #columns; class-toggle only, so a hover never rebuilds the raster. Operator 2026-07-13. {% endcomment %} <script defer src="{% static 'scitex_cards/board_v3/13-timeline-hover.js' %}"></script> {% comment %} timelineGate.js — anti-flash change-detection gate for the Timeline raster. MUST load BEFORE timeline.js: publishes window.STX.timelineGate (rasterSig + a stateful gate that skips identical 4s rebuilds and preserves .tl-scroll scroll across a real rebuild), so the raster stops flashing / jumping to top. {% endcomment %} <script defer src="{% static 'scitex_cards/board_v3/timelineGate.js' %}"></script> {% comment %} timelineSelect.js — marker multi-select + copy-contents + right-click menu for the Timeline raster (operator 2026-06-26). Classic <script defer>; wires itself via event delegation on #columns so timeline.js (at its line cap) needs no change. Publishes window.STX.timelineSelect (pure formatCardCopy / joinCopyBlocks helpers, node-testable). (hook-bypass: line-limit.) {% endcomment %} <script defer src="{% static 'scitex_cards/board_v3/timelineSelect.js' %}"></script> {% comment %} timelineControls.js — Timeline controls-row builder (incl. the dependency- edge legend) + the edge hover-highlight. MUST load BEFORE timeline.js: publishes window.STX.timelineControls.controlsHtml (captured by timeline.js at init, like timelineGeo) and self-wires the hover delegation on #columns (mouseover/mouseout) so it survives the raster's auto-rebuilds without timeline.js re-attaching. Pure controlsHtml/edgeLegendHtml are node-testable. Factored out so timeline.js stays under the per-file cap. {% endcomment %} <script defer src="{% static 'scitex_cards/board_v3/timelineControls.js' %}"></script> {% comment %} timeline.js — the Timeline layout renderer (raster by agent/project + a rich per-task "simple" list). Classic <script defer> so it shares the page globals (STATE / escapeHtml / openDetail / render); it exposes its entry points on window for the inline render() dispatch. Operator TODO 2026-06-17. {% endcomment %} <script defer src="{% static 'scitex_cards/board_v3/timeline.js' %}"></script> <style> /* P12 (operator TG, lead a2a 2026-06-12) — board reclaims the left IDE-style panes. The scitex-ui standalone shell renders an AI console pane + a worktree file tree + a media viewer to the LEFT of the module pane; the kanban board doesn't need any of them, and the operator was photographed reporting "left empty space" eating the columns area. Override the shell defaults here so the kanban grid uses the full viewport width. The shell's resizer/toggle JS still no-ops cleanly when the targets are display:none. */ #workspace-three-col > .ws-ai-pane, #workspace-three-col > .ws-worktree-pane, #workspace-three-col > .ws-viewer-pane { display: none !important; } #workspace-three-col > .ws-module-pane, #workspace-three-col > #main-content.ws-module-pane { flex: 1 1 100%; max-width: 100vw; width: 100vw; } </style> {% comment %} FIRST-PAINT status-color CSS vars — the SINGLE source of truth for the board's per-status color layer (hook-bypass: line-limit). Server-rendered (deterministic, no FE flash) from `status_colors` (views.board_v3_page → handlers.graph._status_colors → _diagram.STATUS_STYLE). Cards, timeline, and the in-board mermaid all consume `--status-fill-<s>` / `--status-stroke-<s>` / `--status-border-<s>` so the 7 statuses render distinct + consistent, matching the python `build_mermaid()` artifacts. The 4 buckets still drive COLUMN placement + the blocked pulse — only the color layer is per-status. {% endcomment %} <style id="status-color-vars">:root{ {% for s, c in status_colors.items %} --status-fill-{{ s }}: {{ c.fill }}; --status-stroke-{{ s }}: {{ c.stroke }}; --status-border-{{ s }}: {% if c.dashed %}dashed{% else %}solid{% endif %}; {% endfor %}}</style> {% endblock extra_css %} {% block app_content %} {% comment %} Filterbar reorg (operator TG, lead a2a `b48f7c2c438b464698183d2e95d3bb04`, 2026-06-12). Three explicit groups end the flex-wrap chaos: .fb-left — identity (title + version + LIVE chip) .fb-center — search (input + autocomplete suggest) .fb-right — operations (Layout | Sort | Filters | Recent count | Group | Add Task | blocking me | overflow controls) Active-filter chips + qualifier hint pills move to a second row `<div class="filterbar-chips">` shown only when populated. {% endcomment %} <div class="filterbar"> <div class="fb-left"> <h1>SciTeX Cards <span class="small">v{{ scitex_cards_version|default:"?" }}</span></h1> <span class="small"><span class="live-dot"></span>LIVE</span> </div> <div class="fb-center stx-todo-filterbar__group stx-todo-filterbar__group--search"> <!-- SEARCH/FILTER group (board UI overhaul part 2, 2026-06-14): the search input + Filters popover share the center band so the operator's two query-narrowing tools live next to each other. Search-as-launcher (P1, lead brief 2026-06-12) — the PRIMARY filterbar entry point. --> <span class="filt-search-wrap" id="f-search-wrap"> <input class="filt filt-search" id="f-search" type="search" placeholder="🔎 Search… ( / to focus, Esc to blur) — try project:paper-scitex-clew, status:blocked, kind:compute, …" title="Fuzzy match + GitHub-style qualifiers (project: / agent: / status: / kind: / parent: / scope: / id: / priority: / host:). Press / to focus, Esc to blur. Tab to autocomplete the qualifier or value under the cursor." oninput="onSearchInput(event)" onkeydown="onSearchKeyDown(event)" onfocus="onSearchFocus(event)" onblur="onSearchBlur(event)" role="combobox" aria-autocomplete="list" aria-controls="search-suggest" aria-expanded="false" autocomplete="off"> <!-- Autocomplete dropdown (operator TG 12318) --> <ul class="search-suggest" id="search-suggest" role="listbox" aria-label="Search suggestions" hidden></ul> </span> </div>{# end .fb-center / SEARCH group #} <div class="fb-right"> {% comment %} board UI overhaul part 2 (2026-06-14, operator complaint via lead a2a `d1af161e`) — explicit subgroups in the right band so the controls stop visually colliding: VIEW = Layout + Sort + Group STATUS = recent-count + Reload + hide-project + blocking-me + hidden PRIMARY = + Add Task (separated by a divider, brand-accent) The legacy `.fb-right` keeps its wrap-friendly flex; the new `.stx-todo-filterbar__group--*` wrappers add the breathing room + dividers. NB: this MUST be a `comment` block, not `{# #}` — Django's `{# #}` syntax is single-line only; newlines between `{#` and `#}` are NOT stripped, so the comment leaks as literal text onto the operator's screen. The leak shipped briefly in PR #173 (the header-declutter PR); fix-forward in this PR per lead a2a `f7a5d37930b9479ca7e53a7e316c132d`, 2026-06-14, after the operator reported it live. {% endcomment %} <div class="stx-todo-filterbar__group stx-todo-filterbar__group--view" role="group" aria-label="View controls — layout, sort, group"> <!-- P9 (lead brief 2026-06-12) — sort-by control. The headline use is "sort by deadline" (pairs with the P4 deadline schema landing later). For now the operator can sort by deadline (parsed from title), priority rank, status, project, last-activity. --> <!-- LAYOUT AXIS — the board renders the SAME data in one of three orthogonal layouts: Timeline (time raster), Wall (sticky notes), Graph (dependency map, mermaid SVG). Column (kanban) + Table (flat rows) were REMOVED 2026-07-13 (operator TG: "Column, Table view がいらないです、削除してください"). Their renderers, chrome (pin / drag-reorder / column ctx-menu / per-card checkboxes) and the controls that only drove them (Sort, Group, Group-by-time, bulk-select toolbar, project-column hide) went with them — every one of those acted on column CARDS, which no longer exist. The Stale layout was removed the same way on 2026-07-10. Timeline is FIRST *and* the DEFAULT (operator 2026-07-10 — "Timeline view 本丸なので一番左に"; Column used to be the default, so the boot path + onLayoutChange() coerce any stored column/table/stale value to timeline — otherwise an operator whose localStorage still said "column" would land on a BLANK board). --> <span class="filt-layout" title="Switch layout: Timeline (time raster) | Wall (sticky notes) | Graph (dep map)"> <label class="filt-layout-label">Layout</label> <div class="filt-layout-seg" role="radiogroup" aria-label="Layout mode"> <button type="button" class="filt-layout-btn filt-layout-btn--on" id="f-layout-timeline" data-layout="timeline" onclick="onLayoutChange('timeline')" role="radio" aria-checked="true" title="Timeline — time raster by agent/project, or a per-task simple list (day/week/month windows) — default">⏱ Timeline</button> <button type="button" class="filt-layout-btn" id="f-layout-wall" data-layout="wall" onclick="onLayoutChange('wall')" role="radio" aria-checked="false" title="Wall — sticky notes clustered by assignee; the selected agent's next-up stack rises to the top">🗒 Wall</button> <button type="button" class="filt-layout-btn" id="f-layout-graph" data-layout="graph" onclick="onLayoutChange('graph')" role="radio" aria-checked="false" title="Dependency / blocker graph (mermaid)">📊 Graph</button> {% comment %} Matrix — urgency×importance quadrants (ADR-0011 §8). The layout itself lives in 14-matrix.js; this is only the toggle. (hook-bypass: line-limit — this template is 3425 lines, already over the 1024 cap before this change; splitting it is tracked separately rather than smuggled into the matrix PR.) {% endcomment %} <button type="button" class="filt-layout-btn" id="f-layout-matrix" data-layout="matrix" onclick="onLayoutChange('matrix')" role="radio" aria-checked="false" title="Matrix — urgency × importance quadrants (I urgent+important / II important / III urgent / IV neither)">🎯 Matrix</button> </div> </span> </div>{# end VIEW group #} <span class="stx-todo-filterbar__divider" aria-hidden="true"></span> <div class="stx-todo-filterbar__primary" role="group" aria-label="Primary action — add a new task"> <!-- Add Task — operator-CRUD wiring (TG 344): one-click new-task entry from the filterbar. Opens the #add-task-backdrop modal below; POST /create on submit; loadGraph() refresh. Promoted to the right-most "PRIMARY ACTION" zone (board UI overhaul part 2, 2026-06-14) so the operator's most-used button pops with the brand accent. --> <button id="add-task-btn" class="add-task-btn" onclick="openAddTaskModal()" title="Add a new task (Cmd/Ctrl-N)">+ Add Task</button> </div>{# end PRIMARY action zone #} </div>{# end .fb-right #} </div>{# end .filterbar #} {% comment %} Filterbar second row — active filter chips + qualifier-hint pills. Hidden via CSS when both children are empty so the main filterbar stays at ~50 px on the default state. Populated by renderActiveFilterChips() / renderQualifierHints() in extra_js. {% endcomment %} <div class="filterbar-chips" id="filterbar-chips"> <div class="filt-qhints" id="filt-qhints" aria-live="polite" aria-label="Recognized search qualifiers"></div> <div id="filt-chips" class="filt-chips" aria-live="polite" aria-label="Active filters"></div> </div> {% comment %} STATUS COLOR LEGEND (operator: "add color legends") — decodes the per-status colors used by cards / timeline / mermaid. SINGLE-SOURCED: iterates the SAME `status_colors` template context (views.board_v3_page → handlers.graph._status_colors → _diagram.STATUS_STYLE) in STATUS_STYLE order, so adding/removing a status updates the legend automatically — no hardcoded status list or hex colors here. Each chip references the first-paint `--status-fill-<s>` / `--status-stroke-<s>` / `--status-border-<s>` CSS vars (rendered by #status-color-vars) so the swatch matches the cards exactly, including `deferred`'s dashed border. `data-legend-status` per chip keeps the test robust. {% endcomment %} <div class="status-legend" id="status-legend" role="group" aria-label="Status color legend"> <span class="status-legend-title" aria-hidden="true">Legend</span> {% for s, c in status_colors.items %} <span class="status-legend-chip" data-legend-status="{{ s }}" title="{{ s }}" aria-label="status color: {{ s }}"> <span class="status-legend-swatch" aria-hidden="true" style="background: var(--status-fill-{{ s }}); border: 1px var(--status-border-{{ s }}, solid) var(--status-stroke-{{ s }});"></span> <span class="status-legend-label">{{ s }}</span> </span> {% endfor %} </div> {% comment %} BLOCKING-YOU 360 px aside REMOVED (P12, lead a2a 2026-06-12). The operator-decision queue was folded into the Column layout's synthetic `user` lane — which itself went away with the Column layout on 2026-07-13. The "🚧 blocking me" filterbar toggle (#t-block) is the surviving entry point: it's a FILTER, so it narrows whichever layout is active (Timeline / Wall / Graph). {% endcomment %} <div class="main"> <div class="board-area"> <!-- Empty-state banner (operator TG12911, lead a2a `ffa6c606`) — when the active filters narrow the visible set to 0 cards, surface an explicit "0 matches" line + a one-click "Clear filters" so a silent empty board doesn't read as "the filter is broken". Hidden by default; render() flips it on/off. --> <div id="filter-empty-banner" style="display:none; padding:14px 18px; margin:6px 12px; background:var(--accent-soft, #fff7e6); border:1px solid var(--accent, #d99700); border-radius:6px; font-size:13px; line-height:1.5;" role="status"> <strong style="color:var(--accent-strong, #b97400)">0 matches.</strong> <span id="filter-empty-banner-detail"></span> <button type="button" id="filter-empty-banner-clear" style="margin-left:10px; cursor:pointer;">Clear all filters</button> </div> <div class="columns" id="columns"> <div class="loading">loading…</div> </div> </div>{# end .board-area #} {% comment %} RIGHT-HAND DETAILS COLUMN (operator TG 2026-07-13: "右の方に details カラムを作ってそこにもっと使いやすい形でのフィルタを用意すればよい" + "Legend と対応させて Details の方に Stats を用意して、可視化してください。 タイムスケールも分けられるようにして … (d/w/m)"). Convention borrowed from scitex-writer's Details pane (aside + accordion sections + localStorage open-set) so the two products look like one system rather than two bespoke boards. The three sections are exactly the things that used to shout from the header: STATS (incl. the "N new/24h" counter), FILTERS (the six dropdowns from the old popover, plus show-done + blocking-me as ordinary filter rows) and HIDDEN cards. {% endcomment %} <aside class="details-pane stx-shell-sidebar" id="details-panel" aria-label="Details"> {% comment %} The pane itself is scitex-ui's `.stx-shell-sidebar` — the SAME workspace-pane primitive Writer / Scholar / Console use (shell CSS css/shell/stx-shell-sidebar.css, already linked by the standalone shell this template extends). We get its 50 px `__header`, its scrolling `__content`, and its "smart" `.collapsed` state (pane shrinks to --ui-collapsed-pane-width and the header rotates into a vertical icon+label strip) for free. `.details-pane` in 13-details-pane.css carries ONLY the overrides the component's own docs ask for: dock it to the RIGHT (border-left) and give it a board-appropriate width. {% endcomment %} <div class="stx-shell-sidebar__header"> <h5><span aria-hidden="true">📊</span> Details</h5> {# shown only when collapsed — the shell's vertical icon+label strip #} <span class="stx-shell-sidebar__title"> <i aria-hidden="true">📊</i><span>Details</span> </span> <button type="button" class="stx-shell-sidebar__toggle" id="details-toggle" onclick="toggleDetailsPane()" aria-expanded="true" aria-controls="details-panel" title="Collapse / expand the Details column">›</button> </div> <div class="stx-shell-sidebar__content details-body"> <div class="details-section open" data-id="stats"> <button type="button" class="details-section-toggle" onclick="toggleDetailsSection('stats')"> <span class="details-section-title">Stats</span> <span class="details-chevron" aria-hidden="true">▸</span> </button> <div class="details-section-body" id="details-stats-body"> {% comment %} TIMESCALE — the SAME d/w/m window the Timeline raster uses (timeline.js `WINDOWS = {1d:24, 1w:168, 1m:720}` hours, stored under localStorage["scitex-todo:tl-window"]). Clicking here calls the timeline's own setTimelineWindow(), so there is ONE definition of "week" on the board, not two that can drift. {% endcomment %} <div class="details-scale" role="group" aria-label="Stats timescale"> <button type="button" class="details-scale-btn" data-win="1d" onclick="setBoardWindow('1d')" title="Last 24 hours">D</button> <button type="button" class="details-scale-btn" data-win="1w" onclick="setBoardWindow('1w')" title="Last 7 days">W</button> <button type="button" class="details-scale-btn" data-win="1m" onclick="setBoardWindow('1m')" title="Last 30 days">M</button> </div> <div id="details-stats"></div> </div> </div> <div class="details-section open" data-id="filters"> <button type="button" class="details-section-toggle" onclick="toggleDetailsSection('filters')"> <span class="details-section-title">Filters</span> <span class="details-filter-count" id="filt-active-count">(0)</span> <span class="details-chevron" aria-hidden="true">▸</span> </button> <div class="details-section-body"> <div class="details-row details-row--filter"> <label for="f-project">Project</label> <select class="filt" id="f-project"><option value="">All projects</option></select> </div> <div class="details-row details-row--filter"> <label for="f-agent">Assignee</label> <select class="filt" id="f-agent"><option value="">All assignees</option></select> </div> <div class="details-row details-row--filter"> <label for="f-status">Status</label> <select class="filt" id="f-status"><option value="">All statuses</option></select> </div> <div class="details-row details-row--filter"> <label for="f-blocker">Blocker</label> <select class="filt" id="f-blocker"><option value="">All blockers</option></select> </div> <div class="details-row details-row--filter"> <label for="f-host">Host</label> <select class="filt" id="f-host"><option value="">All hosts</option></select> </div> <div class="details-row details-row--filter"> <label for="f-date">Date</label> <select class="filt" id="f-date" title="Filter by date proximity (operator TG 440/442)"> <option value="">All dates</option> <option value="overdue">📅 overdue</option> <option value="today">📅 today</option> <option value="7d">📅 within 7 days</option> <option value="30d">📅 within 30 days</option> <option value="undated">no date in title</option> </select> </div> <label class="details-check" for="f-blockingme" title="Only cards blocked on an operator decision — the 🚧 rows in the legend"> <input type="checkbox" id="f-blockingme" onchange="onBlockingMeChange(event)"> <span>🚧 blocking me</span> </label> <label class="details-check" for="f-showdone"> <input type="checkbox" id="f-showdone" onchange="onShowDoneChange(event)"> <span>show <code>done</code> <span id="f-showdone-count" class="details-muted">(0 hidden)</span></span> </label> <button type="button" class="details-clear" onclick="clearAllFilters()" title="Reset every filter">Clear all filters</button> </div> </div> <div class="details-section" data-id="hidden"> <button type="button" class="details-section-toggle" onclick="toggleDetailsSection('hidden')"> <span class="details-section-title">Hidden cards</span> <span class="details-filter-count">(<span id="hidden-count">0</span>)</span> <span class="details-chevron" aria-hidden="true">▸</span> </button> <div class="details-section-body"> <div id="hidden-list-panel" class="hidden-list-panel"> <div class="hidden-list-empty">No hidden cards.</div> </div> </div> </div> </div> </aside> </div> <div class="foot"> <span>v3 LIVE · real data · operator schema (ADR-0007) · GUI→code (ADR-0006)</span> <span><code id="store-path">…</code></span> </div> <div class="detail-backdrop" id="detail-backdrop" onclick="closeDetail(event)"> <div class="detail" onclick="event.stopPropagation()"> <h2 id="d-title"></h2> <div class="detail-meta" id="d-meta"></div> <div id="d-rows"></div> <!-- Word-style threaded comments (operator's TG 9763 mental model per lead a2a a7e53184). All comments[] entries render here; Post appends via /comment + refreshes. --> <div class="d-comments-section"> <h3>🛤 Route <span class="route-hint">— how this card was handled (newest last)</span></h3> <div class="comment-list" id="d-comment-list"> <div class="comment-empty">…</div> </div> <div class="comment-add"> <textarea id="d-comment-text" placeholder="Add a comment… (Cmd/Ctrl-Enter to post)" rows="3"></textarea> <div class="comment-add-actions"> <span class="comment-add-hint">Posts to comments[]; visible to the owning agent.</span> <button class="comment-post-btn" id="d-comment-post" onclick="postCommentFromDrawer()">Post</button> </div> </div> </div> <!-- Inline edit form (toggled by the Edit button below). The form mirrors the Add-Task modal fields so the operator can change title / status / blocker / note / project / agent / task. POST goes to /update; success reloads via loadGraph(). --> <div class="detail-edit-form" id="d-edit-form"> <div class="at-row"> <label for="d-edit-title">Title</label> <input class="d-edit-input" id="d-edit-title" type="text"> </div> <div class="at-row" style="flex-direction: row; gap: 10px;"> <div style="flex: 1;"> <label for="d-edit-status">Status</label> <select class="d-edit-select" id="d-edit-status"> {# `pending` abolished 2026-07-10; writing it now fails validation. #} <option value="in_progress">in_progress</option> <option value="blocked">blocked</option> <option value="done">done</option> <option value="deferred">deferred</option> <option value="failed">failed</option> <option value="goal">goal</option> </select> </div> <div style="flex: 1;"> <label for="d-edit-blocker">Blocker</label> <select class="d-edit-select" id="d-edit-blocker"> <option value="">(none)</option> <option value="compute">compute</option> <option value="quota">quota</option> <option value="user-pending">user-pending</option> <option value="task-dependency">task-dependency</option> <option value="operator-decision">operator-decision</option> </select> </div> </div> <div class="at-row" style="flex-direction: row; gap: 10px;"> <div style="flex: 1;"> <label for="d-edit-project">Project</label> <input class="d-edit-input" id="d-edit-project" type="text"> </div> <div style="flex: 1;"> <label for="d-edit-agent">Assignee</label> <input class="d-edit-input" id="d-edit-agent" type="text"> </div> </div> <div class="at-row"> <label for="d-edit-task">Task (one-line current focus)</label> <input class="d-edit-input" id="d-edit-task" type="text"> </div> <div class="at-row"> <label for="d-edit-note">Note (markdown body)</label> <textarea class="d-edit-textarea" id="d-edit-note"></textarea> </div> <div class="d-edit-actions"> <button class="d-edit-cancel" onclick="cancelDetailEdit()">Cancel</button> <button class="d-edit-save" onclick="saveDetailEdit()">Save</button> </div> </div> <div class="detail-actions"> <button class="detail-close" onclick="closeDetail()">Close</button> <button class="detail-edit" id="d-edit" onclick="enterDetailEdit()">Edit</button> <button class="detail-delete" id="d-delete" onclick="deleteCurrentTask()">Delete</button> <button class="detail-cancel-resolve" id="d-cancel-resolve" onclick="disarmResolve()" style="display: none;">Cancel</button> <button class="detail-resolve" id="d-resolve" onclick="resolveTask()">Resolve → notify agent</button> </div> </div> </div> <!-- Add Task modal (operator TG 344 — create from browser). POST /create, reload board, close modal. project picker pre-fills from existing projects via the auto-discovered list. --> <div class="at-backdrop" id="add-task-backdrop" onclick="closeAddTaskModal(event)"> <div class="at-modal" onclick="event.stopPropagation()"> <h2>+ Add Task</h2> <div class="at-row"> <label for="at-title">Title <span style="color: var(--red);">*</span></label> <input class="at-input" id="at-title" type="text" autocomplete="off" placeholder="What needs to get done?"> </div> <!-- Assignee is REQUIRED (operator constitution, no silent fallbacks): a card must have an owner. The server (handle_create -> add_task) also rejects an owner-less create, but require it client-side so the user gets an inline message before the round-trip. hook-bypass: line-limit --> <div class="at-row"> <label for="at-assignee">Assignee <span style="color: var(--red);">*</span></label> <input class="at-input" id="at-assignee" name="task-assignee" type="text" autocomplete="off" required list="at-assignee-list" placeholder="Who owns this card? (e.g. operator)"> <datalist id="at-assignee-list"></datalist> </div> <div class="at-row" style="flex-direction: row; gap: 10px;"> <div style="flex: 1;"> <label for="at-project">Project</label> <input class="at-input" id="at-project" list="at-project-list" type="text" placeholder="e.g. scitex-todo"> <datalist id="at-project-list"></datalist> </div> <div style="flex: 1;"> <label for="at-status">Status</label> <select class="at-select" id="at-status"> {# `pending` abolished 2026-07-10 — the honest default is `deferred`. #} <option value="deferred">deferred</option> <option value="in_progress">in_progress</option> <option value="blocked">blocked</option> <option value="goal">goal</option> </select> </div> </div> <div class="at-row"> <label for="at-task">Task (one-line current focus) — optional</label> <input class="at-input" id="at-task" type="text" placeholder="What's the current step? (shows on the card)"> </div> <div class="at-actions"> <button class="at-btn at-btn-cancel" onclick="closeAddTaskModal()">Cancel</button> <button class="at-btn at-btn-create" id="at-create" onclick="submitAddTask()">Create</button> </div> </div> </div> <div class="toast" id="toast"></div> {% endblock app_content %} {% block extra_js %} <script> // === LAYOUT whitelist (SSOT) ========================================== // The only layouts that exist. Column + Table were removed 2026-07-13 // (operator), Stale on 2026-07-10. `const` declarations are NOT hoisted // into the TDZ-free zone, so these must be declared ABOVE the STATE // initializer, which calls normalizeLayout() at boot. // (hook-bypass: line-limit — pre-existing 3425-line template.) const VALID_LAYOUTS = ["timeline", "wall", "graph", "matrix"]; const DEFAULT_LAYOUT = "timeline"; // Owner SSOT (client mirror of scitex_cards._owner.card_owner) — the // SINGLE rule for "who owns this card": `agent` falling back to // `assignee`, else null (an owner-less card). Used for the card-render // owner line + the unassigned-lane bucketing so an assignee-only card // shows its real owner instead of a blank, and an owner-less card lands // in a clearly-labeled lane rather than a retired fallback identity. // (hook-bypass: line-limit — board_v3.html split still queued.) function cardOwner(t) { if (!t) return null; const o = ((t.agent || t.assignee || "") + "").trim(); return o || null; } // The lane key an owner-less card buckets under — a clearly-labeled // lane, NEVER a retired identity (operator mandate 2026-06-26). const UNASSIGNED_LANE = "(unassigned)"; let STATE = { graph: null, openId: null, lastMtime: null, filters: { project: "", host: "", status: "", blocker: "", agent: "", blockingMe: false, showHidden: false, // Show-done — DEFAULT OFF (operator direct, lead a2a // `b9d135c1ea8348dea80f027ba66a0079` 2026-06-12). // `status: done` cards drop from every layout unless // the popover toggle is flipped on. Sticky via // localStorage["scitex-todo:show-done"]. showDone: (function () { try { return localStorage.getItem("scitex-todo:show-done") === "1"; } catch { return false; } })(), search: "", date: "" }, // LAYOUT axis — three orthogonal modes (timeline | wall | graph), // persisted in localStorage so the operator's last choice survives // reloads. // // MIGRATION (operator 2026-07-13, Column+Table removal): a browser // whose localStorage still says "column" / "table" (Column was the // DEFAULT until today) — or "stale" (removed 2026-07-10) — must NOT // land on a blank board. normalizeLayout() coerces every unknown / // retired value to the new default, "timeline", and re-writes the // stored value so the coercion happens exactly once. layout: (function () { let stored = null; try { stored = localStorage.getItem("scitex-todo:layout"); } catch {} const m = normalizeLayout(stored); if (stored !== m) { try { localStorage.setItem("scitex-todo:layout", m); } catch {} } return m; })() }; // === Hide (per-user soft-hide, localStorage-backed) ==================== // Stored as an array of task ids under "scitex-todo:hidden". Per-user/per- // browser only — fine for the single-host MBA today; cross-device sync // would need a schema field, deferred. Operator TG 256. function hiddenSet() { try { const raw = localStorage.getItem("scitex-todo:hidden") || "[]"; return new Set(JSON.parse(raw)); } catch { return new Set(); } } function saveHidden(s) { localStorage.setItem("scitex-todo:hidden", JSON.stringify([...s])); } function hideTask(id) { const s = hiddenSet(); s.add(id); saveHidden(s); render(); toast(`✓ Hidden ${id}`); } function unhideTask(id) { const s = hiddenSet(); s.delete(id); saveHidden(s); render(); } function toggleShowHidden(e) { STATE.filters.showHidden = !STATE.filters.showHidden; e.currentTarget.classList.toggle("on", STATE.filters.showHidden); render(); } // Non-disruptive auto-refresh support (hook-bypass: line-limit). // The board polls /graph every 5s. Re-rendering the whole board on // every tick made the view FLASH and JUMP TO THE TOP, losing the // operator's scroll position while reading lower content. Two guards: // 1. LAST_GRAPH_JSON — JSON.stringify of the last RENDERED graph. // When skipIfUnchanged is set and the fresh payload stringifies // identically, loadGraph() returns WITHOUT calling render(), so // the common "nothing changed" tick is a true no-op. // 2. captureScroll()/restoreScroll() — when a real change DOES force // a redraw, the scroll offsets of the page + the #columns canvas // (which itself scrolls in table/graph/mobile layouts) are // captured before render() and restored after it. let LAST_GRAPH_JSON = null; function _scrollEls() { // The page itself scrolls (document.scrollingElement) AND, in the // table / graph / responsive-mobile layouts, the #columns canvas // has its own overflow:auto. Restoring a 0 offset is a harmless // no-op, so we snapshot/restore both unconditionally. const els = []; const se = document.scrollingElement || document.documentElement; if (se) els.push(se); const cols = document.getElementById("columns"); if (cols) els.push(cols); return els; } function captureScroll() { return _scrollEls().map((el) => ({ el, top: el.scrollTop, left: el.scrollLeft, })); } function restoreScroll(snap) { if (!snap) return; for (const s of snap) { if (!s.el) continue; s.el.scrollTop = s.top; s.el.scrollLeft = s.left; } } async function loadGraph(opts) { const { preserveScroll = false, skipIfUnchanged = false } = opts || {}; try { const r = await fetch("/graph"); if (!r.ok) throw new Error(`HTTP ${r.status}`); STATE.graph = await r.json(); // Change-detection: if the fresh payload is byte-identical to the // last graph we rendered, skip the whole re-render (no flash, no // scroll jump). Only honored for the auto-refresh path so an // explicit/first loadGraph() always paints. const _freshJson = JSON.stringify(STATE.graph); if (skipIfUnchanged && _freshJson === LAST_GRAPH_JSON) { return; } const _scrollSnap = preserveScroll ? captureScroll() : null; document.getElementById("store-path").textContent = STATE.graph.store_path || "—"; populateFilters(); // Sync the filterbar UI to the persisted STATE on first paint: // the Layout segment buttons reflect STATE.layout (sticky across // reloads). const _curLayout = normalizeLayout(STATE.layout); document.querySelectorAll(".filt-layout-btn").forEach(btn => { const active = btn.getAttribute("data-layout") === _curLayout; btn.classList.toggle("filt-layout-btn--on", active); btn.setAttribute("aria-checked", active ? "true" : "false"); }); // Sync the Show-done checkbox to the persisted STATE so the // popover reflects the actual filter state on first paint. const _sdBox = document.getElementById("f-showdone"); if (_sdBox) _sdBox.checked = !!STATE.filters.showDone; render(); // Record what we just painted so the next auto-refresh tick can // detect "no change" and skip the redraw entirely. Restore the // scroll offsets captured before render() so a real change // redraws in place instead of snapping to the top. // (hook-bypass: line-limit.) LAST_GRAPH_JSON = _freshJson; restoreScroll(_scrollSnap); // Draw the dependency graph in the BACKGROUND (idle callback) so a // later switch to the Graph layout paints an already-rendered SVG // instead of freezing the UI for seconds (operator 2026-07-13). _prewarmGraph(); } catch (e) { document.getElementById("columns").innerHTML = `<div class="loading" style="color: var(--red);">load error: ${e.message}</div>`; } } function passes(t) { const f = STATE.filters; // Show-done — DEFAULT OFF (operator direct, lead a2a // `b9d135c1ea8348dea80f027ba66a0079`). `status: done` cards drop // from every layout unless the popover toggle is flipped on. // Done early so the rest of the predicates never see done rows. if (!f.showDone && t.status === "done") return false; if (f.project && (t.project || "Uncategorized") !== f.project) return false; if (f.host && (t.host || "") !== f.host) return false; if (f.status && t.status !== f.status) return false; // RAW 7-status filter (hook-bypass: line-limit) if (f.blocker) { if (f.blocker === "__none" && t.blocker) return false; if (f.blocker !== "__none" && t.blocker !== f.blocker) return false; } if (f.agent && (t.agent || "") !== f.agent) return false; if (f.blockingMe && !(t.status === "blocked" && t.blocker === "operator-decision")) return false; // Hide filter: by default hidden cards are dropped; the filter-bar // toggle flips f.showHidden and brings them back (greyed via CSS). if (!f.showHidden && hiddenSet().has(t.id)) return false; // Fuzzy search (operator TG 437 / 438): subsequence char match // across title + project + agent + note. Case-insensitive. if (f.search && !fuzzyMatch(f.search, t)) return false; // Date filter (operator TG 440 / 442): proximity buckets derived // from the next future date parsed out of the title. if (f.date) { // Date helpers extracted to 15-dateinfo.js; call-time lookup so // script load order is irrelevant. (hook-bypass: line-limit.) const di = window.STX.dateInfo.dateInfo(t); if (f.date === "undated" && di) return false; if (f.date === "overdue" && !(di && di.daysFromNow < 0)) return false; if (f.date === "today" && !(di && di.daysFromNow === 0)) return false; if (f.date === "7d" && !(di && di.daysFromNow >= 0 && di.daysFromNow <= 7)) return false; if (f.date === "30d" && !(di && di.daysFromNow >= 0 && di.daysFromNow <= 30)) return false; } return true; } // === Fuzzy search (operator TG 437/438) + qualifier syntax (TG 12315/16) // fzf-style subsequence (PR #80) for BARE tokens. When the query // contains a GitHub-style qualifier (project:foo, status:blocked, …) // the searchQuery.js parser takes over via window.STX.searchQuery; // qualifiers AND-combine + bare tokens still go through the fuzzy // haystack so nothing PR #80 shipped regresses. function fuzzyMatch(query, t) { const raw = String(query || ""); if (!raw.trim()) return true; const SQ = (typeof window !== "undefined" && window.STX && window.STX.searchQuery) || null; if (SQ && /[A-Za-z_][A-Za-z0-9_-]*:/.test(raw)) { // Delegate to the qualifier-aware matcher. Parse-once cache lives // on STATE so we don't re-tokenize per task. if (STATE._searchParsed == null || STATE._searchParsedRaw !== raw) { STATE._searchParsed = SQ.parseSearchQuery(raw); STATE._searchParsedRaw = raw; } return SQ.matchesSearchQuery(t, STATE._searchParsed); } // Legacy fast path — no qualifiers, just the PR #80 fzf-style match. const q = raw.toLowerCase().trim(); const hay = ( (t.title || "") + " " + (t.task || "") + " " + (t.project || "") + " " + (t.agent || "") + " " + (t.note || "") + " " + (t.id || "") ).toLowerCase(); let i = 0; for (const c of q) { if (c === " ") continue; // whitespace tolerant const found = hay.indexOf(c, i); if (found < 0) return false; i = found + 1; } return true; } // === Qualifier hint pills (operator TG 12315/12316) ==================== // Renders one pill per recognized qualifier above the search input. // Unknown qualifiers (typos) render grey with a "did you mean ..." // tooltip — don't block typing, just hint. Reuses the date-pill / // filter-chip visual pattern from PR #80. function renderQualifierHints(raw) { const wrap = document.getElementById("filt-qhints"); if (!wrap) return; const SQ = (typeof window !== "undefined" && window.STX && window.STX.searchQuery) || null; if (!SQ || !raw || !/[A-Za-z_][A-Za-z0-9_-]*:/.test(raw)) { wrap.innerHTML = ""; return; } const parsed = SQ.parseSearchQuery(raw); const pills = parsed.hints.map((h) => { const classes = ["filt-qhint"]; if (h.unknown) classes.push("filt-qhint--unknown"); if (h.unknownValue) classes.push("filt-qhint--unknown-value"); const tip = h.unknown ? `unknown qualifier — did you mean: ${h.suggestion}` : h.unknownValue ? `unknown value — try one of: ${h.suggestion}` : `filter on ${h.label}`; return `<span class="${classes.join(" ")}" title="${escapeHtml(tip)}">` + `<span class="filt-qhint-key">${escapeHtml(h.label)}:</span>` + `<span class="filt-qhint-val">${escapeHtml(h.value || "(empty)")}</span>` + `</span>`; }); wrap.innerHTML = pills.join(""); } function onSearchInput(e) { STATE.filters.search = e.currentTarget.value; // Invalidate the qualifier-parse cache on every keystroke. STATE._searchParsed = null; STATE._searchParsedRaw = null; renderQualifierHints(e.currentTarget.value); renderActiveFilterChips(); // P2: surface the search chip while typing renderSearchSuggest(e.currentTarget); render(); } // === Search autocomplete (operator TG 12318) =========================== // Inline-script wrapper around static/scitex_cards/board_v3/searchSuggest.js // (deferred load). Mirrors the React SearchAutocomplete.tsx component so // the vanilla board stays in lock-step with the React surface. Pure DOM // here — the suggestion math lives in searchSuggest.js / searchQuery.js. let _SEARCH_SUGG_ACTIVE = 0; let _SEARCH_SUGG_ITEMS = []; function _suggDataSource() { const nodes = (STATE.graph && STATE.graph.nodes) || []; return { nodes }; } function renderSearchSuggest(input) { const list = document.getElementById("search-suggest"); if (!list) return; const SS = (window.STX && window.STX.searchSuggest) || null; if (!SS) { list.hidden = true; input.setAttribute("aria-expanded", "false"); return; } const q = input.value; const pos = input.selectionStart != null ? input.selectionStart : q.length; const sugs = SS.computeSuggestions(q, pos, _suggDataSource()).slice(0, 8); _SEARCH_SUGG_ITEMS = sugs; if (_SEARCH_SUGG_ACTIVE >= sugs.length) _SEARCH_SUGG_ACTIVE = 0; if (!sugs.length) { list.innerHTML = ""; list.hidden = true; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); return; } list.hidden = false; input.setAttribute("aria-expanded", "true"); list.innerHTML = sugs.map((s, i) => { const f = SS.formatSuggestion(s); const cls = ["search-suggest__row", `search-suggest__row--${s.kind}`, i === _SEARCH_SUGG_ACTIVE ? "search-suggest__row--active" : ""] .filter(Boolean).join(" "); const badge = (s.count != null) ? `<span class="search-suggest__badge">${s.count}</span>` : ""; const hint = f.hint ? `<span class="search-suggest__hint">${escapeHtml(f.hint)}</span>` : ""; return `<li id="search-suggest-row-${i}" role="option" ` + `aria-selected="${i === _SEARCH_SUGG_ACTIVE}" class="${cls}" ` + `onmousedown="_searchSuggClick(event, ${i})" ` + `onmouseenter="_searchSuggHover(${i})">` + `<span class="search-suggest__label">${escapeHtml(f.label)}</span>` + badge + hint + `</li>`; }).join(""); input.setAttribute("aria-activedescendant", `search-suggest-row-${_SEARCH_SUGG_ACTIVE}`); } function _searchSuggHover(i) { _SEARCH_SUGG_ACTIVE = i; const list = document.getElementById("search-suggest"); if (!list) return; [...list.children].forEach((el, j) => { el.classList.toggle("search-suggest__row--active", j === i); el.setAttribute("aria-selected", j === i); }); const input = document.getElementById("f-search"); if (input) input.setAttribute("aria-activedescendant", `search-suggest-row-${i}`); } function _searchSuggClick(e, i) { // mousedown so the input doesn't blur first. e.preventDefault(); _SEARCH_SUGG_ACTIVE = i; _commitSearchSuggest(); } function _commitSearchSuggest() { const SS = (window.STX && window.STX.searchSuggest) || null; const input = document.getElementById("f-search"); if (!SS || !input) return false; const sug = _SEARCH_SUGG_ITEMS[_SEARCH_SUGG_ACTIVE]; if (!sug) return false; const pos = input.selectionStart != null ? input.selectionStart : input.value.length; const { newQuery, newCursorPos } = SS.applySuggestion(input.value, pos, sug); input.value = newQuery; try { input.setSelectionRange(newCursorPos, newCursorPos); } catch {} STATE.filters.search = newQuery; STATE._searchParsed = null; STATE._searchParsedRaw = null; renderQualifierHints(newQuery); renderActiveFilterChips(); renderSearchSuggest(input); render(); return true; } function onSearchKeyDown(e) { const list = document.getElementById("search-suggest"); const open = list && !list.hidden; if (!open) { if (e.key === "ArrowDown" || e.key === "Tab") { renderSearchSuggest(e.currentTarget); } return; } const n = _SEARCH_SUGG_ITEMS.length; if (e.key === "ArrowDown") { e.preventDefault(); _SEARCH_SUGG_ACTIVE = n ? (_SEARCH_SUGG_ACTIVE + 1) % n : 0; renderSearchSuggest(e.currentTarget); } else if (e.key === "ArrowUp") { e.preventDefault(); _SEARCH_SUGG_ACTIVE = n ? (_SEARCH_SUGG_ACTIVE - 1 + n) % n : 0; renderSearchSuggest(e.currentTarget); } else if (e.key === "Tab" || e.key === "Enter") { if (n > 0) { e.preventDefault(); _commitSearchSuggest(); } } else if (e.key === "Escape") { e.preventDefault(); list.hidden = true; e.currentTarget.setAttribute("aria-expanded", "false"); } } function onSearchFocus(e) { renderSearchSuggest(e.currentTarget); } function onSearchBlur(e) { // Defer close so a click on a row commits before the dropdown vanishes. setTimeout(() => { const list = document.getElementById("search-suggest"); if (list) list.hidden = true; e.target.setAttribute("aria-expanded", "false"); }, 150); } // === Date extraction + proximity color (operator TG 440/442) ===== // Pulls every YYYY-MM-DD out of the title, picks the NEXT future date, // and colors the 📅 pill by proximity. EXTRACTED to 15-dateinfo.js // (window.STX.dateInfo) as split PR-1 — pure, node-testable, unchanged // behaviour. Consumed at call time; see passes(). (hook-bypass: line-limit.) function populateFilters() { if (!STATE.graph) return; const nodes = STATE.graph.nodes; const uniq = (key) => { const s = new Set(); for (const n of nodes) { const v = n[key]; if (v) s.add(v); } return [...s].sort(); }; const set = (id, items, label) => { const el = document.getElementById(id); const cur = el.value; el.innerHTML = `<option value="">${label}</option>` + items.map(i => `<option value="${i}">${i}</option>`).join(""); if (items.includes(cur)) el.value = cur; }; // f-project includes an explicit "Uncategorized" entry when any // task in the store lacks a `project` field (operator TG 383 — // makes the empty-project column filterable). const projects = uniq("project"); if (nodes.some(n => !n.project)) projects.push("Uncategorized"); set("f-project", projects, "All projects"); set("f-host", uniq("host"), "All hosts"); set("f-agent", uniq("agent"), "All assignees"); // Status / blocker = closed enums. Status now lists the 7 REAL // statuses (SSOT STATUS_STYLE order) and filters by RAW status — the // 4-bucket collapse was the source of the goal/failed color confusion. // (hook-bypass: line-limit.) document.getElementById("f-status").innerHTML = `<option value="">All statuses</option>` + ["goal", "done", "in_progress", "blocked", "deferred", "failed", "cancelled"] .map(s => `<option value="${s}">${s}</option>`).join(""); document.getElementById("f-blocker").innerHTML = `<option value="">All blockers</option>` + ["compute", "dependency", "dep", "operator-decision", "agent-wait", "none"] .map(b => `<option value="${b}">${b}</option>`).join("") + `<option value="__none">no blocker</option>`; // P11b rebind — refresh the scitex-ui Combobox layered over each // <select.filt> so its item list reflects the freshly-populated // option lists. No-op when window.STX.Combobox is unavailable. if (typeof attachCombobox === "function") attachCombobox(); } function escapeHtml(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[c])); } // === Right-click context menu — CRUD on a card (operator TG 432/433) ===== // Replaces the heavyweight "open detail drawer + Edit/Delete buttons" with // a one-right-click menu so common ops are reachable without leaving the // board. Wired to existing /update + /delete + /restore + hide / // copy / move-project handlers — no new backend. function openCardCtx(e, cardId) { e.preventDefault(); const t = (STATE.graph?.nodes || []).find(x => x.id === cardId); if (!t) return; const projects = STATE.graph ? [...new Set(STATE.graph.nodes.map(n => n.project).filter(Boolean))].sort() : []; // Statuses come from the server's STATUS_STYLE SSOT (payload // `status_colors`), never a hardcoded list. The old literal still // offered `pending` — abolished 2026-07-10, and writing it now fails // validation — while omitting `cancelled`. Deriving keeps this menu // correct through any future enum change. The fallback only fires for // a payload predating status_colors. (hook-bypass: line-limit.) const statuses = Object.keys( (STATE.graph && STATE.graph.status_colors) || {}, ).filter((s) => s !== "pending"); if (!statuses.length) { statuses.push("in_progress", "blocked", "done", "deferred", "failed", "goal"); } const isHidden = hiddenSet().has(cardId); const menu = document.createElement("div"); menu.className = "card-ctx-menu"; const sectionHeader = (txt) => `<div class="card-ctx-header">${escapeHtml(txt)}</div>`; const item = (label, fn, danger) => `<button class="card-ctx-item${danger ? " card-ctx-item--danger" : ""}" onclick="${fn}">${label}</button>`; // P11b (operator priority 2026-06-12) — move-picker is now a // scitex-ui Combobox: fuzzy typeahead over ALL projects + // "+ Create '<query>'" for brand-new ones. Restores the P8 // capability ("list ALL projects, plus a + New project flow") // that regressed in develop AND adds substring search. // Progressive enhancement — when window.STX.Combobox is // unavailable (older scitex-ui pin), the inline list rendered // by the fallback branch below keeps the legacy click-to-pick // behaviour. (hook-bypass: line-limit.) const _hasCombobox = !!(window.STX && window.STX.Combobox); const projectItems = _hasCombobox ? `<button class="card-ctx-item card-ctx-item--sub" id="move-cb-trigger-${escapeHtml(cardId)}" onclick="event.stopPropagation(); _openMoveToCombobox('${escapeHtml(cardId)}', this)"> 🔎 Pick / create a project ▾ </button> <span class="cb-container" id="move-cb-container-${escapeHtml(cardId)}" style="display:none"></span>` : projects .filter(p => p !== (t.project || "")) .map(p => `<button class="card-ctx-item card-ctx-item--sub" onclick="closeCardCtx(); moveCardToProject('${escapeHtml(cardId)}', '${escapeHtml(p)}')">→ ${escapeHtml(p)}</button>` ) .join("") + `<button class="card-ctx-item card-ctx-item--sub" style="color: var(--purple); font-weight: 600;" onclick="closeCardCtx(); promptMoveToNewProject('${escapeHtml(cardId)}')"> + New project…</button>`; const statusItems = statuses .filter(s => s !== t.status) .map(s => `<button class="card-ctx-item card-ctx-item--sub" onclick="closeCardCtx(); setCardStatus('${escapeHtml(cardId)}', '${s}')">${s}</button>` ) .join(""); menu.innerHTML = ` <div class="card-ctx-title">${escapeHtml(t.title || cardId)}</div> ${item("✎ Edit details (drawer)", `closeCardCtx(); openDetail('${escapeHtml(cardId)}'); setTimeout(enterDetailEdit, 50);`)} ${item("📋 Copy reference", `closeCardCtx(); copyCardText('${escapeHtml(cardId)}')`)} ${item(isHidden ? "👁 Un-hide" : "🙈 Hide", `closeCardCtx(); ${isHidden ? `unhideTask('${escapeHtml(cardId)}'); render();` : `hideTask('${escapeHtml(cardId)}'); render();`}`)} <div class="card-ctx-sep"></div> ${sectionHeader("Change status")} ${statusItems || `<div class="card-ctx-empty">(only "${t.status}" available)</div>`} ${projects.length > 1 ? `<div class="card-ctx-sep"></div>${sectionHeader("Move to project")}${projectItems || `<div class="card-ctx-empty">(no other projects)</div>`}` : ""} <div class="card-ctx-sep"></div> ${item("🗑 Delete (with Undo)", `closeCardCtx(); deleteCardById('${escapeHtml(cardId)}')`, true)} `; // Position the menu, clamping to viewport. document.body.appendChild(menu); const mw = menu.offsetWidth, mh = menu.offsetHeight; const vw = window.innerWidth, vh = window.innerHeight; menu.style.left = `${Math.min(e.clientX, vw - mw - 6)}px`; menu.style.top = `${Math.min(e.clientY, vh - mh - 6)}px`; _ctxMenuEl = menu; // Click-outside / Esc to dismiss. setTimeout(() => { document.addEventListener("click", closeCardCtx, { once: true }); document.addEventListener("keydown", _ctxEscHandler, { once: true }); }, 0); } let _ctxMenuEl = null; function _ctxEscHandler(e) { if (e.key === "Escape") closeCardCtx(); } function closeCardCtx() { if (_ctxMenuEl) { _ctxMenuEl.remove(); _ctxMenuEl = null; } } // Status-set helper (closed enum from validator). async function setCardStatus(cardId, newStatus) { try { const r = await fetch("/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cardId, status: newStatus }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } toast(`✓ ${cardId} → ${newStatus}`); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // Matrix drag -> re-score (ADR-0011 §8). Sets a card's two axes from the // grid cell it was dropped on; the `rescore_task` verb recomputes the // WHOLE rank order server-side and emits `rank_changed`, so agents see the // new order within the 5s poller. The client sends ONLY the axes, never a // rank (computed, never asserted). loadGraph() repaints from the store. async function rescoreCard(cardId, urgency, importance) { try { const r = await fetch("/rescore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cardId, urgency: urgency, importance: importance }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } const data = await r.json().catch(() => ({})); const rankNote = data && data.rank ? ` · rank ${data.rank}/${data.of}` : ""; toast(`✓ ${cardId} → urgency ${urgency}, importance ${importance}${rankNote}`); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // Delete-from-context-menu (mirrors deleteCurrentTask but takes an id). async function deleteCardById(cardId) { const t = (STATE.graph?.nodes || []).find(x => x.id === cardId); const label = t ? `"${t.title}" (${cardId})` : cardId; if (!window.confirm(`Delete ${label}?\n\nThis will scrub depends_on / blocks / parent references too. Undo is available for ~10s after delete.`)) return; try { const r = await fetch("/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cardId }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } const result = await r.json(); const undo = async () => { try { const rr = await fetch("/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task: result.removed, refs: result.refs || [] }), }); if (!rr.ok) throw new Error(`HTTP ${rr.status}`); await loadGraph(); toast(`↶ Restored ${cardId}`); } catch (e) { toast(`✗ Undo failed: ${e.message}`, true); } }; showUndoToast(`✓ Deleted ${cardId}`, undo); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // P11b (operator priority 2026-06-12) — wire the scitex-ui Combobox // popover for the right-click move-to-project flow. Opens a // typeahead listing ALL projects + an inline "+ Create '<query>'" // affordance for brand-new ones. (hook-bypass: line-limit.) function _openMoveToCombobox(cardId, triggerEl) { const container = document.getElementById( "move-cb-container-" + cardId ); if (!container) return; container.style.display = "inline-block"; const allProjects = STATE.graph ? [...new Set(STATE.graph.nodes.map(n => n.project).filter(Boolean))].sort() : []; const t = (STATE.graph?.nodes || []).find(x => x.id === cardId); const currentProj = t ? (t.project || "") : ""; const items = allProjects .filter(p => p !== currentProj) .map(p => ({ value: p, label: p })); try { const cb = new window.STX.Combobox({ container, trigger: triggerEl, items, placeholder: "Search projects…", updateTriggerLabel: false, createLabel: (q) => `+ Create new project “${q}”`, onChange: (item) => { closeCardCtx(); void moveCardToProject(cardId, item.value); }, onCreate: (q) => { closeCardCtx(); promptMoveToNewProject(cardId, q); }, }); cb.show(); } catch (e) { // Fallback: ask via the legacy prompt path. promptMoveToNewProject(cardId); } } // P11b — onCreate fallback for the move-picker. Accepts an // optional `prefilled` name (the user already typed it in the // Combobox query); validates + forwards to moveCardToProject. // Lost during a P8 -> P10 squash regression; restored here. function promptMoveToNewProject(cardId, prefilled) { const raw = (prefilled !== undefined) ? prefilled : window.prompt( "New project name (creates a column and moves this card into it):", "" ); if (raw === null) return; const name = String(raw).trim(); if (!name) { toast("✗ Project name cannot be empty", true); return; } if (name.length > 64) { toast("✗ Project name too long (≤64 chars)", true); return; } if (name.toLowerCase() === "uncategorized") { toast("✗ Use the existing 'Uncategorized' option for that", true); return; } void moveCardToProject(cardId, name); } async function moveCardToProject(cardId, newProject) { // newProject="Uncategorized" → clear the field via null (the /update // handler accepts null patches to remove a field). const patch = { id: cardId, project: newProject === "Uncategorized" ? null : newProject, }; try { const r = await fetch("/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } toast(`✓ Moved ${cardId} → ${newProject}`); await loadGraph(); } catch (e) { toast(`✗ Move failed: ${e.message}`, true); } } // Copy a card as a Markdown-friendly one-liner — operator TG 375. // Format: "TITLE · id:ID · project:PROJ · status:STATUS [· blocker:BLK]" // Falls back to a manual textarea-+-execCommand if the clipboard API // is not available (older browsers / non-HTTPS without permission). async function copyCardText(id) { const t = (STATE.graph?.nodes || []).find(x => x.id === id); if (!t) { toast("card not found", true); return; } const parts = [ t.title || t.task || id, `id:${id}`, ]; if (t.project) parts.push(`project:${t.project}`); parts.push(`status:${t.status || "?"}`); if (t.blocker) parts.push(`blocker:${t.blocker}`); const text = parts.join(" · "); try { if (navigator.clipboard && window.isSecureContext !== false) { await navigator.clipboard.writeText(text); } else { // Fallback for older / non-HTTPS browsers. const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } toast(`✓ copied: ${text}`); } catch (e) { toast(`✗ copy failed: ${e.message}`, true); } } function render() { if (!STATE.graph) return; const visible = STATE.graph.nodes.filter(passes); // Empty-state banner (operator TG12911 — "filtering by nv-lessons // does NOT work at all"): when the store HAS tasks but the active // filters narrow `visible` to 0, surface an explicit message + a // one-click clear so a silent empty board doesn't read as broken. _renderEmptyStateBanner(visible.length, STATE.graph.nodes.length); // === LAYOUT axis dispatch — the same `visible` task set renders // into one of three orthogonal layouts (timeline | wall | graph). // (hook-bypass: line-limit.) const layout = normalizeLayout(STATE.layout); const canvas = document.getElementById("columns"); // Carry the active layout on the canvas so the per-layout CSS // can switch between raster / note-wall / mermaid-canvas without // re-wrapping the DOM. canvas.setAttribute("data-layout", layout); if (layout === "graph") { _renderGraphView(canvas, visible); } else if (layout === "stale") { // Stale Review layout — pulls its OWN data from /stale (not // the filtered `visible` set) because the criteria are // age/orphan-driven, not the same filter-bar predicates. // Unreachable from the UI (button removed 2026-07-10); the // render path stays as shared code for the /stale endpoint. _renderStaleView(canvas); } else if (layout === "matrix" && window.STX && window.STX.matrix) { // Matrix — urgency×importance quadrants (ADR-0011 §8, 14-matrix.js). // Same filtered `visible` set as every other layout; the matrix adds // no predicates of its own. Falls through to Timeline when the // deferred module hasn't executed yet (same stance as Wall). // (hook-bypass: line-limit — pre-existing 3425-line template.) _renderMatrixView(canvas, visible); } else if (layout === "wall" && window.STX && window.STX.stickyWall) { // Wall layout — sticky notes clustered by assignee (11-sticky-wall.js). // Uses the SAME filtered `visible` set the filter bar produces. The // next-up stack is derived from nodes + STATE.graph.edges inside the // module. Falls through to Timeline when the module hasn't loaded // (deferred script not yet executed). _renderWallView(canvas, visible); } else { // Timeline — the DEFAULT layout (operator 2026-07-13). Own-data: // agent/project rasters pull /timeline; the per-task "simple" list // projects STATE.graph by time. _renderTimelineView(canvas); } // One-shot glow on any card whose status changed since the last render // (operator msg 933). No-op on first paint — the map starts empty, so // nothing "transitions" into existence. _flashStatusTransitions(canvas); renderStats(); _renderRecentCountPill(visible); // Update the "show hidden (N)" counter in the filter bar so the // operator sees how many cards are currently being filtered out. const hc = document.getElementById("hidden-count"); if (hc) hc.textContent = String(hiddenSet().size); // Hidden-done counter — surfaces in the popover toggle label so // the operator can't think "where did my done cards go" (operator // direct, lead `b9d135c1ea8348dea80f027ba66a0079`). const sdc = document.getElementById("f-showdone-count"); if (sdc) { const doneCount = STATE.graph.nodes.filter(n => n.status === "done").length; sdc.textContent = STATE.filters.showDone ? `(${doneCount} shown)` : `(${doneCount} hidden)`; } } // === Empty-state banner ────────────────────────────────────────── // Operator TG12911 (via lead a2a `ffa6c606`): "filtering by // nv-lessons does NOT work at all — returns nothing / no effect." // Root cause was UX: the filter LOGICALLY narrowed the set to 0 // matches and the board went blank with no explanation, reading as // "broken filter". This helper flips a banner on whenever the store // has rows but the active filters drop everything, and offers a // one-click "Clear all filters" that resets STATE.filters. // (hook-bypass: line-limit.) function _renderEmptyStateBanner(visibleCount, totalCount) { const banner = document.getElementById("filter-empty-banner"); if (!banner) return; // Show only when the store HAS rows but filters narrow to 0; // a truly empty store falls through to the normal columns view // (the user just hasn't seeded any tasks yet). const filtersAreActive = _anyActiveFilter(); if (visibleCount === 0 && totalCount > 0 && filtersAreActive) { const detail = document.getElementById("filter-empty-banner-detail"); if (detail) { detail.textContent = ` Active filters narrow ${totalCount} task` + (totalCount === 1 ? "" : "s") + " to none. Either widen the filters or use the button to" + " clear them."; } banner.style.display = "block"; } else { banner.style.display = "none"; } } function _anyActiveFilter() { const f = STATE.filters || {}; return Boolean( f.project || f.host || f.status || f.blocker || f.agent || f.date || f.search || f.blockingMe, ); } // Wire the banner's "Clear all filters" button once at boot. The // banner element is in the static template; the button needs a JS // listener so the click resets STATE.filters and re-renders. document.addEventListener("DOMContentLoaded", () => { const btn = document.getElementById("filter-empty-banner-clear"); if (!btn) return; btn.addEventListener("click", () => { // Reset every filter dimension to its empty value + clear the // <select.filt> + search-input DOM controls so the chip strip // re-renders consistent with the cleared state. const f = STATE.filters || {}; f.project = ""; f.host = ""; f.status = ""; f.blocker = ""; f.agent = ""; f.date = ""; f.search = ""; f.blockingMe = false; for (const id of ["f-project", "f-host", "f-status", "f-blocker", "f-agent", "f-date"]) { const el = document.getElementById(id); if (el) el.value = ""; } const search = document.getElementById("f-search"); if (search) search.value = ""; renderActiveFilterChips(); render(); }); }); // Wall layout state. `groupBy` = assignee|project|status (operator's // "切り口"), `selected` = the expanded island. Sticky across reloads so // picking your own lane once is remembered. (hook-bypass: line-limit.) const WALL = { groupBy: (() => { try { return localStorage.getItem("scitex-todo:wall-group") || "assignee"; } catch { return "assignee"; } })(), selected: (() => { try { return localStorage.getItem("scitex-todo:wall-selected") || null; } catch { return null; } })(), }; // Matrix layout (ADR-0011 §8). RENDERS the urgency/importance axes the // store carries and the rank the engine computed; it does NOT score — a // JS-side f(u,i) would be a second implementation of the engine and would // lie the moment the weights moved. Drag-to-re-score is wired below (the // delegated dragstart/drop listeners) and goes through the `rescore_task` // store verb via /rescore, so the `rank_changed` event still reaches // agents. innerHTML is replaced wholesale each render, which is why the // drag listeners are DELEGATED on document, not bound per card. // (hook-bypass: line-limit — pre-existing 3400+-line template; split is // carded as scitex-cards-gui-board-v3-template-split-20260717.) function _renderMatrixView(canvas, visible) { canvas.innerHTML = window.STX.matrix.matrixHtml(visible, {}); } function _renderWallView(canvas, visible) { const edges = (STATE.graph && STATE.graph.edges) || []; canvas.innerHTML = window.STX.stickyWall.wallHtml(visible, edges, { groupBy: WALL.groupBy, selected: WALL.selected, nowMs: Date.now(), }); } // Delegated: island title toggles selection; a note opens the existing // detail drawer. One listener for the whole wall, survives re-renders. document.addEventListener("click", (ev) => { const head = ev.target.closest && ev.target.closest(".sw-island-title"); if (head) { const k = head.getAttribute("data-key"); WALL.selected = WALL.selected === k ? null : k; try { localStorage.setItem("scitex-todo:wall-selected", WALL.selected || ""); } catch {} render(); return; } const note = ev.target.closest && ev.target.closest(".sw-note"); if (note) openDetail(note.getAttribute("data-id")); }); // Uniform right-click across EVERY view (operator msg 943: "right click // should work on tasks — scatters/cards on all views in the same // manner"). One delegated listener instead of an inline handler per // view: resolve the card id from `data-card-id` (timeline dots) or // `data-id` (wall notes) and open the same menu. // // Any element carrying an inline `oncontextmenu` owns its own menu; // openCardCtx() calls preventDefault() but NOT stopPropagation(), so the // event still bubbles here — skip those to avoid opening the menu twice. document.addEventListener("contextmenu", (ev) => { if (!ev.target.closest) return; const el = ev.target.closest("[data-card-id],[data-id]"); if (!el) return; if (el.closest("[oncontextmenu]")) return; // inline handler owns it const id = el.getAttribute("data-card-id") || el.getAttribute("data-id"); if (id) openCardCtx(ev, id); }); // Matrix drag-to-re-score (ADR-0011 §8), delegated on `document` so it // survives the matrix's wholesale innerHTML re-render. Only `.mx-card`s // are `draggable`, so dragstart fires for the matrix view ALONE — no // other layout is affected and no per-card binding is needed. The drop // target is a grid cell (`.mx-cell`), which carries its own // (urgency, importance) in data-*; window.STX.matrix.dropAxes parses and // validates them. A drop anywhere without valid axes (the unscored tray, // the gaps between cells) is a NO-OP — you cannot un-score by dragging, // and the verb requires 1..5 regardless. let _mxDragId = null; function _mxClearDropTargets() { document .querySelectorAll(".mx-cell--drop") .forEach((c) => c.classList.remove("mx-cell--drop")); } document.addEventListener("dragstart", (ev) => { const card = ev.target.closest && ev.target.closest(".mx-card"); if (!card) return; _mxDragId = card.getAttribute("data-id") || card.getAttribute("data-card-id"); card.classList.add("mx-card--dragging"); if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = "move"; try { ev.dataTransfer.setData("text/plain", _mxDragId || ""); } catch {} } }); document.addEventListener("dragend", (ev) => { const card = ev.target.closest && ev.target.closest(".mx-card"); if (card) card.classList.remove("mx-card--dragging"); _mxClearDropTargets(); _mxDragId = null; }); document.addEventListener("dragover", (ev) => { if (!_mxDragId) return; const cell = ev.target.closest && ev.target.closest(".mx-cell"); if (!cell) return; const mx = window.STX && window.STX.matrix; if (!mx || !mx.dropAxes(cell.dataset)) return; // not a valid target ev.preventDefault(); // permit the drop if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move"; if (!cell.classList.contains("mx-cell--drop")) { _mxClearDropTargets(); cell.classList.add("mx-cell--drop"); } }); document.addEventListener("drop", (ev) => { if (!_mxDragId) return; const cell = ev.target.closest && ev.target.closest(".mx-cell"); const mx = window.STX && window.STX.matrix; const axes = cell && mx ? mx.dropAxes(cell.dataset) : null; if (!axes) return; // dropped off any valid cell -> no-op ev.preventDefault(); const id = _mxDragId; _mxClearDropTargets(); rescoreCard(id, axes.urgency, axes.importance); }); // Status-transition glow (operator msg 933). Cheap by construction: a // prev-status map diffed on each render; the CSS does the rest and the // class is removed on animationend so it never accumulates. Applies to // any element carrying data-id + data-status — wall notes today, and any // future view that adopts the same two attributes. const _PREV_STATUS = new Map(); function _flashStatusTransitions(root) { if (!root) return; // Wall notes carry data-id; timeline dots carry data-card-id. Both // carry data-status, so both get the glow from this one pass. root .querySelectorAll("[data-id][data-status],[data-card-id][data-status]") .forEach((el) => { const id = el.getAttribute("data-card-id") || el.getAttribute("data-id"); const st = el.getAttribute("data-status"); const prev = _PREV_STATUS.get(id); _PREV_STATUS.set(id, st); if (prev === undefined || prev === st) return; el.classList.add("stx-flash", `stx-flash--${st}`); el.addEventListener( "animationend", () => el.classList.remove("stx-flash", `stx-flash--${st}`), { once: true }, ); }); } // === Stale Review layout ───────────────────────────────────────── // Operator directive 2026-06-13 (via lead a2a) — a recurring // standing-section that surfaces stale/orphaned PENDING cards so // the operator can archive them WITH a reason recorded (no silent // drops). Pulls from GET /stale (backend PR #153); per-row Archive // button posts to /archive (HTTP twin of CLI `close --reason` // verb in PR #151). Layout-state cache so we don't refetch on // every render() — invalidate via `loadGraph()` reload or after // a successful archive POST. let _STALE_CACHE = null; let _STALE_OPTS = { days: 14, include_no_timestamp: true }; function _staleFiltersHtml() { const inc = _STALE_OPTS.include_no_timestamp ? "checked" : ""; const days = Number(_STALE_OPTS.days || 14); return `<div class="stale-toolbar"> <label class="stale-tb-item">Age cutoff (days): <input type="number" id="stale-days" value="${days}" min="0" style="width:5em" onchange="onStaleDaysChange(event)"> </label> <label class="stale-tb-item" title="Hide rows flagged ONLY for missing timestamps (~70% of the default set)"> <input type="checkbox" id="stale-incnotime" ${inc} onchange="onStaleIncNoTimeChange(event)"> include rows with no timestamps </label> <button type="button" class="stale-tb-reload" onclick="loadStale()" title="Refetch /stale">↻ refresh</button> </div>`; } async function loadStale() { const qs = new URLSearchParams({ days: String(_STALE_OPTS.days), include_no_timestamp: String(_STALE_OPTS.include_no_timestamp), }); try { const r = await fetch("/scitex-todo/stale?" + qs.toString()); if (!r.ok) { _STALE_CACHE = { error: `/stale returned ${r.status}` }; } else { _STALE_CACHE = await r.json(); } } catch (exc) { _STALE_CACHE = { error: String(exc) }; } // Re-render if the operator is currently viewing the stale layout. if ((STATE.layout || "column") === "stale") render(); } function onStaleDaysChange(ev) { const n = Number(ev.target.value); if (Number.isFinite(n) && n >= 0) { _STALE_OPTS.days = n; loadStale(); } } function onStaleIncNoTimeChange(ev) { _STALE_OPTS.include_no_timestamp = !!ev.target.checked; loadStale(); } function _renderStaleView(canvas) { const cache = _STALE_CACHE; // First time hitting this layout — kick off the fetch + show a // loading state. loadStale() will re-render once the data lands. if (cache === null) { canvas.innerHTML = `<div class="stale-wrap"> ${_staleFiltersHtml()} <div class="loading">loading stale cards…</div> </div>`; loadStale(); return; } if (cache.error) { canvas.innerHTML = `<div class="stale-wrap"> ${_staleFiltersHtml()} <div class="loading">stale fetch failed: ${escapeHtml(cache.error)}</div> </div>`; return; } const rows = (cache.stale || []).map(r => { const age = (r.age_days == null) ? "—" : (r.age_days + "d"); const reasons = (r.reasons || []).join("; "); return `<tr class="stale-row" data-card-id="${escapeHtml(r.id)}"> <td class="stale-id"> <a href="#" onclick="event.preventDefault(); openDetail('${escapeHtml(r.id)}')">${escapeHtml(r.id)}</a> </td> <td class="stale-title">${escapeHtml(r.title || "(untitled)")}</td> <td class="stale-project">${escapeHtml(r.project || "—")}</td> <td class="stale-age">${age}</td> <td class="stale-reasons">${escapeHtml(reasons)}</td> <td class="stale-action"> <button type="button" class="stale-archive-btn" onclick="archiveStaleCard('${escapeHtml(r.id)}', '${escapeHtml((r.title||'').replace(/'/g, '''))}')" title="Archive this card WITH a reason (close-with-reason — PR #151 verb)"> 🗄 Archive </button> </td> </tr>`; }).join(""); const summary = `<div class="stale-summary"> ${cache.total || 0} stale candidates ${Object.entries(cache.by_project || {}) .sort((a,b) => b[1]-a[1]) .map(([p,c]) => `<span class="stale-byproj">${escapeHtml(p)}: ${c}</span>`) .join("")} </div>`; canvas.innerHTML = `<div class="stale-wrap"> ${_staleFiltersHtml()} ${summary} ${(cache.stale && cache.stale.length) ? `<div class="tbl-wrap"><table class="tbl stale-tbl"> <thead><tr> <th>id</th><th>title</th><th>project</th> <th>age</th><th>reasons</th><th>action</th> </tr></thead> <tbody>${rows}</tbody> </table></div>` : `<div class="loading">no stale cards match the current criteria 🎉</div>`} </div>`; } async function archiveStaleCard(id, title) { // Operator confirm + reason. Verb: scitex-todo close TASK_ID // --reason TEXT (PR #151) — same behavior server-side here. // For v1 we use the browser-native window.prompt; a styled modal // is a follow-up. The reason is REQUIRED (server returns 400 on // empty), so we re-prompt if blank. const reason = window.prompt( `Archive (close with reason) — "${title || id}"\n\n` + `This appends [CLOSED] <reason> to comments[], flips status to deferred,\n` + `and stamps _log_meta.closed_{at,by}. The row stays for audit.\n\n` + `Enter a short reason (REQUIRED):`, "" ); if (reason == null) return; const trimmed = String(reason).trim(); if (!trimmed) { alert("Archive requires a non-empty reason. No action taken."); return; } try { const r = await fetch("/scitex-todo/archive", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, reason: trimmed }), }); const j = await r.json().catch(() => ({})); if (!r.ok) { alert(`Archive failed (${r.status}): ${j.error || "unknown"}`); return; } // Refresh both the stale list AND the main graph cache so the // archived card disappears from every layout. await loadStale(); await loadGraph(); } catch (exc) { alert(`Archive request errored: ${exc}`); } } // === Graph layout (mermaid dependency map) ─────────────────────── // Inline mermaid render fed from the SERVER's edge list (`STATE.graph // .edges`, kinds `depends_on` / `blocks`) plus per-node hierarchical // `parent` edges. Only nodes that participate in at least one edge are // drawn (lead ruling `c212aa72`, 2026-06-12) — a disconnected node has // no place in a dependency map. // // ── BACKGROUND RENDER (operator 2026-07-13: "Graph view は最適化して // ください。裏で描画を始めて置き、サイズも調整して表示されるように") ── // Switching to Graph used to freeze the UI for seconds: mermaid's 3 MB // bundle was fetched on first switch, then its auto-run entry point // parsed + laid out the diagram SYNCHRONOUSLY against the LIVE canvas. // (test__board_v3_signatures pins that that entry point appears nowhere // in this file, so it is deliberately not spelled out even in prose — a // mention would satisfy the pin and let a real call hide behind it.) // (hook-bypass: line-limit.) // // Now: // 1. `_graphSrc(visible)` is a PURE string build (cheap, no DOM). // 2. `_prewarmGraph()` runs on requestIdleCallback after every paint: // it loads mermaid once and renders the SVG OFF-DOM via the // promise API `mermaid.render(id, src)` — mermaid hangs its own // temp element off <body> (real width, so no zero-width SVG; a // display:none host would have produced a tiny/unscaled diagram). // 3. The result is cached KEYED ON THE SOURCE STRING, so a re-render // only happens when the task data / filters actually change. // 4. Switching to Graph injects the cached SVG — no work on the main // thread beyond an innerHTML assignment. // (hook-bypass: line-limit.) const GRAPH_CACHE = { key: null, svg: null, pending: null, failed: false }; // Fit-to-width (default) vs 1:1 natural size. Sticky across reloads. let GRAPH_FIT = (function () { try { return localStorage.getItem("scitex-todo:graph-fit") !== "0"; } catch { return true; } })(); // Pure: task set -> mermaid source (or null when nothing is connected). function _graphSrc(visible) { const visibleIds = new Set(visible.map(t => t.id)); const _esc = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, "_"); const _safe = (s) => String(s || "").replace(/[\[\]\"]/g, " ").slice(0, 60); // Pull EDGES from the canonical server payload, not per-node. const rawEdges = (STATE.graph && STATE.graph.edges) || []; const depEdges = []; for (const e of rawEdges) { // `kind` is "depends_on" or "blocks"; the server already emits // source=dep, target=tid (handlers/graph.py:121) so the arrow // points prereq → consumer either way. if (!e || !visibleIds.has(e.source) || !visibleIds.has(e.target)) continue; depEdges.push({ src: e.source, dst: e.target, kind: e.kind || "depends_on" }); } // Hierarchical `parent` edges are a separate axis (not in `edges[]`). // Dashed so hierarchy reads differently from a blocking dependency. const parentEdges = []; for (const t of visible) { const p = t.parent; if (p && visibleIds.has(p) && p !== t.id) { parentEdges.push({ src: p, dst: t.id, kind: "parent" }); } } const allEdges = depEdges.concat(parentEdges); const touched = new Set(); for (const e of allEdges) { touched.add(e.src); touched.add(e.dst); } const connected = visible.filter(t => touched.has(t.id)); if (!connected.length) return null; // Each node carries its RAW status class (st-<status>) so the in-board // mermaid matches the python build_mermaid() artifacts — no 4-bucket // color collapse. const nodeLines = connected.map(t => { const label = `${(t.project || "—").slice(0, 18)}<br>${_safe(t.task || t.title)}`; return ` ${_esc(t.id)}["${label}"]:::st-${(t.status || "deferred").replace(/[^a-z_]/gi, "")}`; }); const edgeLines = allEdges.map(e => (e.kind === "parent") ? ` ${_esc(e.src)} -.-> ${_esc(e.dst)}` : ` ${_esc(e.src)} --> ${_esc(e.dst)}`); // classDefs from the SSOT status_colors (STATUS_STYLE, projected into // the /graph payload) — never a second color table. const sc = (STATE.graph && STATE.graph.status_colors) || {}; const classDefLines = Object.keys(sc).map(s => { const c = sc[s]; let style = `fill:${c.fill},stroke:${c.stroke},stroke-width:1px,color:#222`; if (c.dashed) style += ",stroke-dasharray:5 3"; return ` classDef st-${s} ${style};`; }); const src = ["flowchart LR", ...classDefLines, ...nodeLines, ...edgeLines] .join("\n"); return { src, stats: { connected: connected.length, dep: depEdges.length, parent: parentEdges.length, hidden: visible.length - connected.length, }, }; } function _graphHintHtml(stats, note) { const s = stats; return `<div class="graph-hint"> <span>Graph: <strong>${s.connected}</strong> connected node${s.connected === 1 ? "" : "s"} / <strong>${s.dep}</strong> depends_on / blocks edge${s.dep === 1 ? "" : "s"} / <strong>${s.parent}</strong> parent edge${s.parent === 1 ? "" : "s"} (dashed). ${s.hidden} disconnected card${s.hidden === 1 ? "" : "s"} not drawn.${note ? " " + note : ""}</span> <button type="button" class="graph-fit-btn" onclick="toggleGraphFit()" title="Fit the diagram to the panel width, or show it at 1:1 and scroll"> ${GRAPH_FIT ? "⤢ 1:1" : "⤡ Fit"} </button> </div>`; } // SIZE (operator: "サイズも調整して表示される"). mermaid emits the SVG with // hard width/height attributes (useMaxWidth:false) — dropped into the // panel as-is it overflows. We keep the viewBox (so the aspect ratio is // intact) and let CSS scale it: `.graph-wrap--fit svg { width:100%; // height:auto }` shrinks a wide diagram to the panel, `1:1` restores the // natural size and the wrap scrolls. The natural px size is stashed on // the wrapper so the toggle needs no re-render. function _mountGraphSvg(canvas, svg, stats, note) { canvas.innerHTML = `<div class="graph-wrap${GRAPH_FIT ? " graph-wrap--fit" : ""}"> ${_graphHintHtml(stats, note)} <div class="graph-canvas">${svg}</div> </div>`; const el = canvas.querySelector(".graph-canvas svg"); if (!el) return; // Guarantee a viewBox even if mermaid omitted it, else scaling clips. if (!el.getAttribute("viewBox")) { const w = parseFloat(el.getAttribute("width")) || 0; const h = parseFloat(el.getAttribute("height")) || 0; if (w && h) el.setAttribute("viewBox", `0 0 ${w} ${h}`); } el.setAttribute("preserveAspectRatio", "xMidYMin meet"); // mermaid also writes an inline `style="max-width: Npx"` which fights // the fit rule — strip it and keep the number for the 1:1 mode. const natural = parseFloat(el.getAttribute("width")) || 0; if (natural) el.dataset.naturalWidth = String(natural); el.style.maxWidth = ""; } function toggleGraphFit() { GRAPH_FIT = !GRAPH_FIT; try { localStorage.setItem("scitex-todo:graph-fit", GRAPH_FIT ? "1" : "0"); } catch {} const wrap = document.querySelector(".graph-wrap"); if (!wrap) return; wrap.classList.toggle("graph-wrap--fit", GRAPH_FIT); const btn = wrap.querySelector(".graph-fit-btn"); if (btn) btn.textContent = GRAPH_FIT ? "⤢ 1:1" : "⤡ Fit"; } function _graphEmptyHtml(visibleCount) { return `<div class="graph-wrap"> <div class="graph-empty"> <h3>No dependencies in the current scope</h3> <p>The Graph layout draws <code>depends_on</code> / <code>blocks</code> edges (from the server's <code>edges[]</code> payload) plus <code>parent</code> edges (per-node) among the currently-visible cards. Right now <strong>${visibleCount} card${visibleCount === 1 ? "" : "s"}</strong> match the filters but none of them are connected to each other.</p> <p class="graph-empty__hint">Encode dependencies in <code>tasks.yaml</code> like this: <code>depends_on: [other-task-id]</code>, <code>blocks: [downstream-id]</code>, or <code>parent: hub-task-id</code>. The <strong>⏱ Timeline</strong> and <strong>🗒 Wall</strong> layouts show every card, connected or not.</p> </div> </div>`; } // Render `src` to an SVG string OFF-DOM. Cached by source, so a repeat // call with unchanged data resolves instantly. Never rejects — a CDN // failure resolves to null and the caller shows an honest message. function _renderGraphSvg(src) { if (GRAPH_CACHE.key === src && GRAPH_CACHE.svg) { return Promise.resolve(GRAPH_CACHE.svg); } if (GRAPH_CACHE.key === src && GRAPH_CACHE.pending) { return GRAPH_CACHE.pending; // a prewarm for this exact src is in flight } GRAPH_CACHE.key = src; GRAPH_CACHE.svg = null; GRAPH_CACHE.failed = false; const p = _ensureMermaid().then(async () => { if (!window.mermaid || !window.mermaid.render) { GRAPH_CACHE.failed = true; return null; } try { // Promise API — mermaid parses + lays out against its OWN detached // host element, so the live canvas is never touched and never // reflowed mid-parse. `id` must be unique per call. const id = "stx-mermaid-" + Math.random().toString(36).slice(2, 10); const out = await window.mermaid.render(id, src); const svg = (out && out.svg) ? out.svg : String(out || ""); if (GRAPH_CACHE.key === src) { GRAPH_CACHE.svg = svg; } return svg; } catch (e) { console.warn("[scitex-todo] mermaid render failed:", e); GRAPH_CACHE.failed = true; return null; } }).finally(() => { if (GRAPH_CACHE.pending === p) GRAPH_CACHE.pending = null; }); GRAPH_CACHE.pending = p; return p; } function _renderGraphView(canvas, visible) { const built = _graphSrc(visible); if (!built) { canvas.innerHTML = _graphEmptyHtml(visible.length); return; } // Cache hit → paint the ready diagram immediately (the whole point). if (GRAPH_CACHE.key === built.src && GRAPH_CACHE.svg) { _mountGraphSvg(canvas, GRAPH_CACHE.svg, built.stats); return; } // Cache miss → show the numbers + a spinner line NOW (never a blank // canvas), and mount the SVG when the off-DOM render resolves. canvas.innerHTML = `<div class="graph-wrap"> ${_graphHintHtml(built.stats, "")} <div class="graph-canvas graph-canvas--loading">drawing the diagram…</div> </div>`; _renderGraphSvg(built.src).then((svg) => { // The operator may have switched layouts or the data may have moved // on while we were rendering — only paint if BOTH are still current. if (normalizeLayout(STATE.layout) !== "graph") return; const c = document.getElementById("columns"); if (!c) return; if (!svg) { const host = c.querySelector(".graph-canvas"); if (host) { host.classList.remove("graph-canvas--loading"); host.innerHTML = `<p class="graph-empty__hint">Could not draw the diagram — the mermaid bundle failed to load (offline?). The card data is fine; try Timeline or Wall.</p>`; } return; } const fresh = _graphSrc(_visibleTasks()); if (!fresh || fresh.src !== built.src) return; // stale render _mountGraphSvg(c, svg, built.stats); }); } // The task set the current filters admit — shared by render() and the // idle prewarm so both agree on what the graph should contain. function _visibleTasks() { if (!STATE.graph) return []; return STATE.graph.nodes.filter(passes); } // Render the graph AHEAD OF TIME, on the idle callback after a paint, so // that switching to Graph is instant. No-op when the cache already // matches, when mermaid previously failed to load, or when a render for // this exact source is already in flight. let _prewarmHandle = null; function _prewarmGraph() { const idle = window.requestIdleCallback || function (fn) { return setTimeout(() => fn({ timeRemaining: () => 50 }), 400); }; const cancel = window.cancelIdleCallback || clearTimeout; if (_prewarmHandle !== null) { try { cancel(_prewarmHandle); } catch {} } _prewarmHandle = idle(() => { _prewarmHandle = null; if (GRAPH_CACHE.failed) return; const built = _graphSrc(_visibleTasks()); if (!built) return; // nothing to draw if (GRAPH_CACHE.key === built.src && GRAPH_CACHE.svg) return; // warm _renderGraphSvg(built.src); }, { timeout: 3000 }); } let _mermaidLoaded = null; function _ensureMermaid() { if (_mermaidLoaded) return _mermaidLoaded; _mermaidLoaded = new Promise((resolve) => { if (window.mermaid) return resolve(); const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"; s.onload = () => { try { window.mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "loose", // useMaxWidth:false → mermaid emits the diagram at its natural // px size + a viewBox; _mountGraphSvg() then does the scaling // (fit-to-panel by default, 1:1 on demand). Letting mermaid do // the fit instead would hard-cap the diagram at the width of // whatever container it happened to render in — which, for an // OFF-DOM render, is not the panel. flowchart: { useMaxWidth: false, htmlLabels: true }, }); } catch {} resolve(); }; s.onerror = () => resolve(); document.head.appendChild(s); }); return _mermaidLoaded; } // === Details column (right) ══════════════════════════════════════ // Accordion behaviour lifted from scitex-writer's details-panel.ts: an // open-set persisted in localStorage, sections closed by default except // the ones we open here. (hook-bypass: line-limit.) const DETAILS_PANE_KEY = "scitex-todo:details-collapsed"; function toggleDetailsPane() { const pane = document.getElementById("details-panel"); if (!pane) return; // `.collapsed` is scitex-ui's own state class (css/shell/ // stx-shell-sidebar.css): it shrinks the pane to // --ui-collapsed-pane-width and flips the header into the vertical // icon+label strip. We only toggle it — the behaviour is the shell's. const on = pane.classList.toggle("collapsed"); const btn = document.getElementById("details-toggle"); if (btn) { btn.setAttribute("aria-expanded", on ? "false" : "true"); btn.textContent = on ? "‹" : "›"; } try { localStorage.setItem(DETAILS_PANE_KEY, on ? "1" : "0"); } catch {} } function _syncDetailsPane() { let collapsed = false; try { collapsed = localStorage.getItem(DETAILS_PANE_KEY) === "1"; } catch {} const pane = document.getElementById("details-panel"); const btn = document.getElementById("details-toggle"); if (pane) pane.classList.toggle("collapsed", collapsed); if (btn) { btn.setAttribute("aria-expanded", collapsed ? "false" : "true"); btn.textContent = collapsed ? "‹" : "›"; } } const DETAILS_OPEN_KEY = "scitex-todo:details-open"; function _detailsOpenSet() { try { const raw = localStorage.getItem(DETAILS_OPEN_KEY); if (raw) return new Set(JSON.parse(raw)); } catch {} return new Set(["stats", "filters"]); } function toggleDetailsSection(id) { const open = _detailsOpenSet(); if (open.has(id)) open.delete(id); else open.add(id); try { localStorage.setItem(DETAILS_OPEN_KEY, JSON.stringify([...open])); } catch {} _syncDetailsSections(); } function _syncDetailsSections() { const open = _detailsOpenSet(); document.querySelectorAll(".details-section").forEach((sec) => { sec.classList.toggle("open", open.has(sec.getAttribute("data-id"))); }); } // === Stats (Details > Stats) ═════════════════════════════════════ // "Legend と対応させて … 可視化してください" — the stats MUST read as the // legend's twin, so both are generated from the SAME source: the server's // `status_colors` map (views.board_v3_page → handlers.graph._status_colors // → _diagram.STATUS_STYLE). The legend iterates it in the template; this // reads the identical map off the /graph payload and paints one bar per // status IN THE SAME ORDER, filled with the same `--status-fill-<s>` / // `--status-stroke-<s>` CSS vars the legend swatches use. One list, one // order, one palette — nothing to drift. // // The window is the Timeline's own d/w/m window (see setBoardWindow). function boardWindowKey() { return (window._TL && window._TL.windowKey) || "1d"; } function boardWindowHours() { const w = (window.STX && window.STX.timelineWindows) || { "1d": 24, "1w": 168, "1m": 720 }; return w[boardWindowKey()] || 24; } // ONE window definition for the whole board: delegate to the Timeline's // setter (which persists it + rebuilds the raster) instead of keeping a // second, subtly-different notion of "week" here. function setBoardWindow(key) { if (typeof window.setTimelineWindow === "function") { window.setTimelineWindow(key); // persists + re-renders the raster } else { try { localStorage.setItem("scitex-todo:tl-window", key); } catch {} if (window._TL) window._TL.windowKey = key; } renderStats(); } const WINDOW_LABEL = { "1d": "24 h", "1w": "7 d", "1m": "30 d" }; function renderStats() { const host = document.getElementById("details-stats"); if (!host) return; const key = boardWindowKey(); document.querySelectorAll(".details-scale-btn").forEach((b) => { b.classList.toggle("details-scale-btn--on", b.getAttribute("data-win") === key); }); if (!STATE.graph) { host.innerHTML = ""; return; } const sc = STATE.graph.status_colors || {}; const statuses = Object.keys(sc); // legend order, verbatim if (!statuses.length) { host.innerHTML = ""; return; } const cutoffMs = Date.now() - boardWindowHours() * 3600 * 1000; // Two series per status: ACTIVE-IN-WINDOW (cards touched inside the // selected window — the number the timescale is actually about) and // TOTAL (every card on the board with that status). The bar length is // the in-window count; the total rides along as a muted number so the // window's slice is legible against the whole. const all = STATE.graph.nodes || []; const inWin = all.filter((t) => { const ts = _newestTs(t); return ts && ts >= cutoffMs; }); const countBy = (rows) => { const m = {}; for (const t of rows) { const s = t.status || "deferred"; m[s] = (m[s] || 0) + 1; } return m; }; const winCounts = countBy(inWin); const allCounts = countBy(all); const max = Math.max(1, ...statuses.map((s) => winCounts[s] || 0)); const rows = statuses.map((s) => { const n = winCounts[s] || 0; const tot = allCounts[s] || 0; const pct = Math.round((n / max) * 100); return `<div class="stat-row" title="${escapeHtml(s)}: ${n} active in the last ${WINDOW_LABEL[key] || key} of ${tot} on the board"> <span class="stat-label"> <span class="stat-swatch" style="background: var(--status-fill-${s}); border: 1px var(--status-border-${s}, solid) var(--status-stroke-${s});"></span> ${escapeHtml(s)} </span> <span class="stat-bar-track"> <span class="stat-bar" style="width:${pct}%; background: var(--status-fill-${s}); border: 1px var(--status-border-${s}, solid) var(--status-stroke-${s});"></span> </span> <span class="stat-num">${n}<span class="stat-total">/${tot}</span></span> </div>`; }).join(""); const newCount = (STATE.graph.nodes || []).filter(_isNew24h).length; host.innerHTML = ` <div class="stat-head"> <strong>${inWin.length}</strong> card${inWin.length === 1 ? "" : "s"} active in the last ${WINDOW_LABEL[key] || key} <span class="details-muted">· ${all.length} on the board</span> </div> ${rows} <div class="stat-foot" id="recent-count-pill"> 🆕 <strong id="recent-count-value">${newCount}</strong> new / 24 h </div>`; } // === Recent-count pill — fleet-wide "N new in 24 h" indicator ─── // Always visible so the operator sees at a glance how many cards moved // in the last day. Hidden when zero so the filterbar isn't cluttered. // (Click-to-sort went with the Sort control on 2026-07-13.) function _renderRecentCountPill(visible) { // The counter moved into Details > Stats (operator 2026-07-13: "114 // new/24h ですが、それも details のほうで"). renderStats() paints it; this // keeps the number honest for the FILTERED set on every render. const val = document.getElementById("recent-count-value"); if (!val) return; val.textContent = String(visible.filter(_isNew24h).length); } function attachFilters() { const map = { "f-project": "project", "f-host": "host", "f-status": "status", "f-blocker": "blocker", "f-agent": "agent", "f-date": "date" }; Object.entries(map).forEach(([id, key]) => { const el = document.getElementById(id); if (!el) return; el.addEventListener("change", e => { STATE.filters[key] = e.target.value; renderActiveFilterChips(); // P2: chip strip + popover count render(); }); }); // P2 (lead brief 2026-06-12): initial chip render so the bar reflects // any filters restored from STATE before the operator's first // interaction. (hook-bypass: line-limit — see REFACTORING.md.) renderActiveFilterChips(); // P11b (operator priority 2026-06-12): layer the scitex-ui // Combobox on top of every <select.filt> for fuzzy-typeahead. // Progressive enhancement — when window.STX.Combobox is // unavailable (older scitex-ui pin), the plain <select> stays // functional (this just no-ops). attachCombobox(); } // === P11b — Combobox layer over <select.filt> ======================== // For each filter <select>, attach a scitex-ui Combobox that mirrors // the option list, opens with a button trigger, and on selection // updates the underlying <select>.value + dispatches a "change" // event so attachFilters()'s existing listeners fire unchanged. // The <select> stays in the DOM as the source-of-truth + a // keyboard / screen-reader fallback. (hook-bypass: line-limit.) const _CB_INSTANCES = {}; function attachCombobox() { if (!(window.STX && window.STX.Combobox)) return; // graceful fallback const FILTER_IDS = [ "f-project", "f-host", "f-status", "f-blocker", "f-agent", "f-date", ]; FILTER_IDS.forEach(id => _wrapSelectAsCombobox(id)); } function _wrapSelectAsCombobox(selectId) { const sel = document.getElementById(selectId); if (!sel) return; // Tear down a prior instance + DOM scaffolding if we're re-binding // after a /graph reload populates fresh <option>s. if (_CB_INSTANCES[selectId]) { try { _CB_INSTANCES[selectId].destroy(); } catch {} delete _CB_INSTANCES[selectId]; const oldWrap = sel.parentElement && sel.parentElement.querySelector(".cb-wrap-" + selectId); if (oldWrap) oldWrap.remove(); } // Build trigger button + container next to the select. const wrap = document.createElement("span"); wrap.className = "cb-wrap cb-wrap-" + selectId; wrap.style.display = "inline-flex"; wrap.style.alignItems = "center"; const trigger = document.createElement("button"); trigger.type = "button"; trigger.className = "filt cb-trigger"; trigger.id = selectId + "-cb-trigger"; trigger.setAttribute("aria-haspopup", "listbox"); trigger.textContent = (sel.options[sel.selectedIndex] || {}).text || "—"; trigger.style.minWidth = "120px"; trigger.style.textAlign = "left"; const container = document.createElement("span"); container.className = "cb-container"; container.id = selectId + "-cb-container"; wrap.appendChild(trigger); wrap.appendChild(container); // Hide the plain <select> + insert the combobox in its place. sel.style.display = "none"; sel.insertAdjacentElement("afterend", wrap); const items = Array.from(sel.options).map(opt => ({ value: opt.value, label: opt.text || opt.value || "—", })); try { const cb = new window.STX.Combobox({ container, trigger, items, value: sel.value, placeholder: "Search " + selectId.replace("f-", "") + "…", onChange: (item) => { sel.value = item.value; // Dispatch a "change" event so the existing attachFilters() // listeners + STATE.filters[key] update fire unchanged. sel.dispatchEvent(new Event("change", { bubbles: true })); // The trigger label is updated automatically by the // Combobox; nothing else to do here. }, }); _CB_INSTANCES[selectId] = cb; } catch (e) { // Combobox bootstrap failure → restore the plain <select>. sel.style.display = ""; wrap.remove(); } } // Re-bind comboboxes whenever populateFilters() pushes fresh // option lists into the <select>s — handled by the direct // `attachCombobox()` call appended at the end of populateFilters // (search for "P11b rebind" in this file). The Combobox bootstrap // is idempotent (destroy + recreate), so calling on every // /graph reload is safe + cheap. // === P2 — active-filter chips + popover count ======================== // The 6 filter dropdowns are tucked inside a `<details class="filt-popover">` // popover so the filterbar stays clean. To keep the operator oriented, // every active filter renders as a chip in #filt-chips with a × button // to clear it. The popover summary shows "🔧 Filters (N active)". // (hook-bypass: line-limit — see REFACTORING.md.) const FILT_CHIP_LABELS = { project: "project", host: "host", status: "status", blocker: "blocker", agent: "agent", date: "date", search: "search", }; function renderActiveFilterChips() { const wrap = document.getElementById("filt-chips"); const count = document.getElementById("filt-active-count"); const summary = document.getElementById("filt-popover-summary"); if (!wrap) return; const chips = []; for (const [key, label] of Object.entries(FILT_CHIP_LABELS)) { const val = STATE.filters[key]; if (!val) continue; chips.push( `<span class="filt-chip" title="Click × to clear"> <span class="filt-chip-key">${escapeHtml(label)}:</span> <span class="filt-chip-val">${escapeHtml(val)}</span> <button type="button" class="filt-chip-clear" aria-label="Clear ${escapeHtml(label)} filter" onclick="clearOneFilter('${key}')">×</button> </span>` ); } // blockingMe is a boolean toggle — show as a chip too. if (STATE.filters.blockingMe) { chips.push( `<span class="filt-chip" title="Click × to clear"> <span class="filt-chip-key">🚧</span> <span class="filt-chip-val">blocking me</span> <button type="button" class="filt-chip-clear" aria-label="Clear blocking-me filter" onclick="clearOneFilter('blockingMe')">×</button> </span>` ); } wrap.innerHTML = chips.join(""); // Active-count + summary highlight const popoverKeys = ["project", "host", "status", "blocker", "agent", "date"]; const active = popoverKeys.filter(k => STATE.filters[k]).length; if (count) count.textContent = `(${active})`; if (summary) summary.classList.toggle("has-active", active > 0); } function clearOneFilter(key) { if (key === "search") { STATE.filters.search = ""; const el = document.getElementById("f-search"); if (el) el.value = ""; } else if (key === "blockingMe") { STATE.filters.blockingMe = false; const tb = document.getElementById("f-blockingme"); if (tb) tb.checked = false; } else { STATE.filters[key] = ""; const el = document.getElementById("f-" + key); if (el) el.value = ""; } renderActiveFilterChips(); render(); } function clearAllFilters() { const popoverKeys = ["project", "host", "status", "blocker", "agent", "date"]; popoverKeys.forEach(k => { STATE.filters[k] = ""; const el = document.getElementById("f-" + k); if (el) el.value = ""; }); // "Clear all" leaves the show-done toggle alone — it's a persistent // user preference, not a transient filter selection (operator // direct, lead `b9d135c1ea8348dea80f027ba66a0079`). renderActiveFilterChips(); render(); } // === Show-done toggle (operator direct, lead a2a // `b9d135c1ea8348dea80f027ba66a0079` 2026-06-12). DEFAULT OFF — // `status: done` cards drop from every layout (Column / Graph / // Table / Calendar) unless this toggle flips on. Persisted in // localStorage. The "(N hidden)" count in the toggle label is // updated in render() so the operator can't think "where did my // done cards go". (hook-bypass: line-limit.) function onShowDoneChange(e) { const on = !!(e && e.target && e.target.checked); STATE.filters.showDone = on; try { localStorage.setItem("scitex-todo:show-done", on ? "1" : "0"); } catch {} render(); } // === Layout switcher ─────────────────────────────────────────── // Three orthogonal layouts; the choice is persisted in localStorage so // it survives reloads. // // "column" / "table" removed 2026-07-13 (operator TG: "Column, Table // view がいらないです"); "stale" removed 2026-07-10. All three are OUT of // the whitelist, so a stored value from before either removal coerces // to the default instead of rendering a BLANK board. Defined as a // hoisted function so the STATE initializer above can call it. function normalizeLayout(mode) { return VALID_LAYOUTS.includes(mode) ? mode : DEFAULT_LAYOUT; } function onLayoutChange(mode) { const m = normalizeLayout(mode); STATE.layout = m; try { localStorage.setItem("scitex-todo:layout", m); } catch {} // Repaint the segment buttons' active state without a full render. document.querySelectorAll(".filt-layout-btn").forEach(btn => { const active = btn.getAttribute("data-layout") === m; btn.classList.toggle("filt-layout-btn--on", active); btn.setAttribute("aria-checked", active ? "true" : "false"); }); render(); } function _newestTs(t) { const candidates = [t.last_activity, t.created_at]; for (const c of candidates) { if (!c) continue; const ms = Date.parse(c); if (!isNaN(ms)) return ms; } return 0; } function _isNew24h(t) { const ts = _newestTs(t); if (!ts) return false; return (Date.now() - ts) < (24 * 3600 * 1000); } // ESC closes the open modal — operator TG 265: "popup は常に esc 有効 // なように." HIG / WAI-ARIA universal expectation. Long-term home is // the scitex-ui Modal primitive (so every scitex-app gets it for free); // this local handler is the short-term scitex-todo fix until that lands. // Listens on document so the focus can be anywhere (the modal contents, // an input field, the body) and ESC still closes. document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // Operator TG 454 きもすぎ: the filterbar <details> dropdowns // (hide-project list + hidden-cards list) didn't close on ESC. // Native <details> doesn't ship that handler — add it here so // every floating dropdown follows the same dismiss convention // as the modals (matches the operator's universal "ESC closes // open thing" expectation, TG 265). const openDetails = document.querySelectorAll( ".filterbar details[open], details.proj-hide[open], details.hidden-list[open]" ); if (openDetails.length) { openDetails.forEach(d => d.open = false); e.preventDefault(); e.stopPropagation(); return; } const backdrop = document.getElementById("detail-backdrop"); if (backdrop && backdrop.classList.contains("show")) { // If Resolve is armed (orange one-click-from-firing state) ESC // disarms FIRST without closing — so the user can think + step // back without losing the drawer they were reading. Second ESC // (or one ESC when not armed) closes as before. if (RESOLVE_ARMED) { disarmResolve(); } else { closeDetail(); } e.preventDefault(); e.stopPropagation(); } }); // 🚧 blocking me — was a loud top-right button; now an ordinary filter // row in the Details column (operator 2026-07-13: "右上に大きく書く必要は // ないですね"). Same predicate, same chip, quieter home. function onBlockingMeChange(e) { STATE.filters.blockingMe = !!(e && e.target && e.target.checked); renderActiveFilterChips(); render(); } function openDetail(id) { STATE.openId = id; const t = (STATE.graph?.nodes || []).find(x => x.id === id); if (!t) return; document.getElementById("d-title").textContent = t.task || t.title; // scitex-todo's entity is the USER (an agent is just user.kind=agent), // so the meta line names the ASSIGNEE in user terms, not "agent". // (hook-bypass: line-limit — board_v3.html split still queued.) const assignee = t.assignee || t.agent || "—"; document.getElementById("d-meta").textContent = `${t.project || "—"} · ${t.host || ""} · assignee ${assignee} · id ${t.id}`; // --- USER ROLES (creator / assignee / collaborators / subscribers) --- // creator = created_by, falling back to the earliest comment author, // else "—" (legacy cards predate created_by). Empty lists render "—". // See ADR-0009 + the graph.py node payload. (hook-bypass: line-limit.) const fmtList = (xs) => (Array.isArray(xs) && xs.length) ? xs.map(escapeHtml).join(", ") : "—"; const firstCommentAuthor = (t.comments || []) .map(c => c && c.author).find(a => typeof a === "string" && a); const creator = t.created_by || firstCommentAuthor || "—"; const rows = [ ["title", t.title], ["status", t.status], ["kind", t.kind || "(task)"], ["blocker", t.blocker || "(none)"], ["— users —", ""], ["creator", escapeHtml(creator)], ["assignee", escapeHtml(assignee)], ["collaborators", fmtList(t.collaborators)], ["subscribers", fmtList(t.subscribers)], ["last_activity", t.last_activity || "—"], ["created_at", t.created_at || "—"], ["goal", t.goal || "—"], ["pr_url", t.pr_url ? `<a href="${escapeHtml(t.pr_url)}" target="_blank">${escapeHtml(t.pr_url)}</a>` : "—"], ["issue_url", t.issue_url ? `<a href="${escapeHtml(t.issue_url)}" target="_blank">${escapeHtml(t.issue_url)}</a>` : "—"], ["depends_on", (STATE.graph.edges || []).filter(e => e.kind === "depends_on" && e.target === id).map(e => e.source).join(", ") || "—"], ["comments", String((t.comments || []).length)], ]; document.getElementById("d-rows").innerHTML = rows.map(r => `<div class="detail-row"><div class="detail-key">${escapeHtml(r[0])}</div><div class="detail-val">${r[1]}</div></div>` ).join(""); const rb = document.getElementById("d-resolve"); rb.disabled = (t.status === "done"); rb.textContent = (t.status === "done") ? "Already resolved" : "Resolve → notify agent"; // Every fresh drawer-open disarms Resolve so the 2-click confirm // applies even if a previous open left it armed. disarmResolve(); // Word-style comment thread — paint comments[] and clear the // textarea so the operator starts fresh on every drawer-open. renderComments(t); const ta = document.getElementById("d-comment-text"); if (ta) ta.value = ""; document.getElementById("detail-backdrop").classList.add("show"); } function closeDetail(e) { if (e && e.target.classList.contains("detail")) return; document.getElementById("detail-backdrop").classList.remove("show"); STATE.openId = null; // Closing the drawer should also disarm — never leave a destructive // primed state behind when the user backs out. disarmResolve(); } // Resolve-confirm state — see the "2-click confirm" comment on resolveTask. let RESOLVE_ARMED = false; let RESOLVE_ARM_TIMER = null; function disarmResolve() { RESOLVE_ARMED = false; if (RESOLVE_ARM_TIMER !== null) { clearTimeout(RESOLVE_ARM_TIMER); RESOLVE_ARM_TIMER = null; } const rb = document.getElementById("d-resolve"); const cb = document.getElementById("d-cancel-resolve"); if (rb && !rb.disabled) rb.textContent = "Resolve → notify agent"; if (rb) rb.classList.remove("detail-resolve--armed"); if (cb) cb.style.display = "none"; } function armResolve() { RESOLVE_ARMED = true; const rb = document.getElementById("d-resolve"); const cb = document.getElementById("d-cancel-resolve"); rb.textContent = "⚠ Click again to confirm"; rb.classList.add("detail-resolve--armed"); cb.style.display = ""; // Auto-disarm after 6s of no follow-through so the orange button // does not linger after the user has clearly walked away. RESOLVE_ARM_TIMER = setTimeout(disarmResolve, 6000); } function toast(msg, err) { const el = document.getElementById("toast"); el.innerHTML = ""; const span = document.createElement("span"); span.textContent = msg; el.appendChild(span); el.classList.toggle("toast--err", !!err); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 3600); } /* Toast with an Undo button. The button stays clickable for ``window`` ms (default 10000) — long enough that a startled operator can click it even after blinking, short enough that the agent stops waiting on the human and moves on. Pair with /reopen on the backend. */ function toastUndo(msg, undoFn, window) { const el = document.getElementById("toast"); const ms = (typeof window === "number" && window > 0) ? window : 10000; el.innerHTML = ""; const span = document.createElement("span"); span.textContent = msg; el.appendChild(span); const btn = document.createElement("button"); btn.className = "toast-undo"; btn.textContent = "↺ Undo"; btn.onclick = async () => { btn.disabled = true; try { await undoFn(); } finally { /* hide whether or not undo errored */ el.classList.remove("show"); } }; el.appendChild(btn); el.classList.remove("toast--err"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), ms); } async function postResolve(id) { const r = await fetch("/resolve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, actor: "operator" }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `HTTP ${r.status}`); } return r.json(); } async function postReopen(id, priorStatus, priorBlocker) { const r = await fetch("/reopen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, actor: "operator", prior_status: priorStatus || null, prior_blocker: priorBlocker || null, }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `HTTP ${r.status}`); } return r.json(); } /* Resolve flow with 2-click confirm + Undo affordance — operator pain TG 9763. Click 1: arms (button turns orange + Cancel appears + auto- disarms after 6s). Click 2 (within the window): fires. After success, the toast carries an Undo button hot for ~10s wiring back to /reopen. */ async function resolveTask() { if (!STATE.openId) return; const rb = document.getElementById("d-resolve"); if (!RESOLVE_ARMED) { armResolve(); return; } // Second click — fire. disarmResolve(); rb.disabled = true; rb.textContent = "resolving…"; const tid = STATE.openId; try { const result = await postResolve(tid); const priorStatus = result.prior_status || null; const priorBlocker = result.prior_blocker || null; closeDetail(); await loadGraph(); toastUndo(`✓ Resolved ${tid}`, async () => { try { await postReopen(tid, priorStatus, priorBlocker); toast(`↺ Re-opened ${tid}`); await loadGraph(); } catch (err) { toast(`✗ undo failed: ${err.message}`, true); } }); } catch (e) { toast(`✗ ${e.message}`, true); rb.disabled = false; rb.textContent = "Resolve → notify agent"; } } /* BLOCKING-YOU panel button stays single-click for now (small target, can't easily host the orange-arm UI) — but it inherits the Undo toast so the safety net still applies. If the operator hits this same trap from the panel, escalate to a confirm popup. */ async function resolveById(id, btn) { if (btn) { btn.disabled = true; btn.textContent = "resolving…"; } try { const result = await postResolve(id); const priorStatus = result.prior_status || null; const priorBlocker = result.prior_blocker || null; await loadGraph(); toastUndo(`✓ Resolved ${id}`, async () => { try { await postReopen(id, priorStatus, priorBlocker); toast(`↺ Re-opened ${id}`); await loadGraph(); } catch (err) { toast(`✗ undo failed: ${err.message}`, true); } }); } catch (e) { toast(`✗ ${e.message}`, true); if (btn) { btn.disabled = false; } } } // === Comment thread (lead a2a a7e53184 — Word-style threaded comments) // ───────────────────────────────────────────────────────────────────── // Renders all comments[] chronologically (oldest first like a chat), // colors operator/system trails distinctly, scrolls newest into view. // Route trace (operator 2026-06-17): render the card's event stream as // a chronological journey — "how it was routed". Built from created_at + // comments[] (each carrying an optional `kind` ring tag the hooks stamp) // + a synthetic done hop from status/completed_at. Derived from the event // stream, so it adapts automatically as the workflow's rings change. const ROUTE_RING = { created: { label: "✦ created", cls: "created" }, push: { label: "⬆ push", cls: "push" }, "ci-result": { label: "✓ CI", cls: "ci" }, ci: { label: "✓ CI", cls: "ci" }, done: { label: "✅ done", cls: "done" }, "card-message": { label: "💬 message", cls: "message" }, message: { label: "💬 message", cls: "message" }, system: { label: "⚙ system", cls: "system" }, escalate: { label: "⚠ escalate", cls: "escalate" }, }; function _routeHops(task) { const hops = []; if (task.created_at) { hops.push({ ts: String(task.created_at), actor: task.agent || task.assignee || "—", kind: "created", text: "card created" }); } (Array.isArray(task.comments) ? task.comments : []).forEach(c => { let kind = c.kind || ""; if (!kind) { // untagged legacy entries: [RESOLVED]/[CLOSED]/… are system trail; // everything else is a board-card message (operator/agent). kind = /^\[(RESOLVED|UNDONE|re-opened|CLOSED)/i.test(String(c.text || "")) ? "system" : "message"; } hops.push({ ts: String(c.ts || ""), actor: String(c.author || "?"), kind, text: String(c.text || "") }); }); if (task.status === "done") { const dts = (task._log_meta && task._log_meta.completed_at) || task.last_activity || ""; hops.push({ ts: String(dts), actor: "—", kind: "done", text: task.pr_url ? ("merged " + task.pr_url) : "marked done" }); } hops.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0)); return hops; } function renderComments(task) { const list = document.getElementById("d-comment-list"); if (!list) return; const hops = _routeHops(task); if (!hops.length) { list.innerHTML = `<div class="comment-empty">No route activity yet.</div>`; return; } list.innerHTML = hops.map(h => { const r = ROUTE_RING[h.kind] || { label: String(h.kind || "·"), cls: "message" }; return `<div class="route-hop route-hop--${r.cls}"> <span class="route-badge route-badge--${r.cls}">${escapeHtml(r.label)}</span> <div class="route-body"> <div class="route-meta"> <span class="route-actor">${escapeHtml(h.actor)}</span> <span class="route-ts">${escapeHtml(h.ts)}</span> </div> <div class="route-text">${escapeHtml(h.text)}</div> </div> </div>`; }).join(""); list.scrollTop = list.scrollHeight; } async function postCommentFromDrawer() { if (!STATE.openId) return; const ta = document.getElementById("d-comment-text"); const btn = document.getElementById("d-comment-post"); if (!ta || !btn) return; const text = (ta.value || "").trim(); if (!text) return; const original = btn.textContent; btn.disabled = true; btn.textContent = "posting…"; try { const r = await fetch("/comment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: STATE.openId, text, author: "operator" }), }); if (!r.ok) { const errBody = await r.json().catch(() => ({})); throw new Error(errBody.error || `HTTP ${r.status}`); } const body = await r.json().catch(() => ({})); ta.value = ""; await loadGraph(); const t = STATE.graph && STATE.graph.nodes.find(x => x.id === STATE.openId); if (t) renderComments(t); // hook-bypass: line-limit — board_v3.html is a pre-existing // 3900-line template far over the HTML cap; this P1 hotfix adds // a few lines of loud-toast wiring, not new bulk. Splitting the // template is a separate refactor, out of scope here. // // Comment is SAVED. The relay (notify the owning agent) is // best-effort + may fail/time-out — surface that LOUD rather // than swallow it (operator P1, 2026-06-25; fail-loud). A // `relay.sent === false` with a non-skip reason is a real // delivery failure; skip:* (self-comment / no-agent) is // expected and stays quiet. const relay = body.relay || {}; const skipped = typeof relay.wire === "string" && relay.wire.indexOf("skip:") === 0; if (relay.sent === false && !skipped) { const who = relay.target || "owner"; const why = relay.error || relay.reason || "unreachable"; toast(`⚠ Comment saved, but could not notify ${who}: ${why}`, true); } else { toast("✓ Comment posted"); } } catch (e) { toast(`✗ ${e.message}`, true); } finally { btn.disabled = false; btn.textContent = original; } } // Cmd/Ctrl-Enter inside the comment textarea posts immediately — // small ergonomic win the operator gets for free since they type // there most. document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { const ta = document.getElementById("d-comment-text"); if (ta && document.activeElement === ta) { postCommentFromDrawer(); e.preventDefault(); } } }); // AutoRefresh via /rev mtime poll (same wire as the existing // GraphView's AutoRefresh — ADR-0005). async function autoRefreshTick() { try { const r = await fetch("/rev"); if (!r.ok) return; const rev = await r.json(); // GUI-code hot-reload: if the board template changed on disk (a // merged PR / local edit under the editable install), the server is // already serving new HTML/JS but THIS pane is still running the old // bundle. Hard-reload so the operator picks up GUI updates without a // manual restart / F5. First observation only records the baseline. if (rev.asset_rev !== undefined) { if (STATE.assetRev === undefined) { STATE.assetRev = rev.asset_rev; } else if (rev.asset_rev !== STATE.assetRev) { STATE.assetRev = rev.asset_rev; location.reload(); return; } } const key = `${rev.mtime}:${rev.count}`; if (STATE.lastMtime === null) { STATE.lastMtime = key; return; } if (key !== STATE.lastMtime) { STATE.lastMtime = key; // Non-disruptive refresh (hook-bypass: line-limit): skip the // redraw when the freshly-fetched /graph payload is identical // to what's already on screen (mtime can bump without a // meaningful change), and preserve scroll when it DOES redraw. await loadGraph({ preserveScroll: true, skipIfUnchanged: true }); // Keep the Timeline raster live on a store change (the simple // view already re-rendered from STATE.graph inside loadGraph). if (typeof window.timelineOnStoreChange === "function") { window.timelineOnStoreChange(); } } } catch { /* transient; try next tick */ } } // === CRUD wiring (operator TG 344) ==================================== // Backend already implements /create /update /delete /restore (see // _django/handlers/crud.py + 33 tests in test_crud.py); board v3 // previously only used /graph /rev /resolve. This block adds the // browser-side wiring for the missing four ops. // -- Add Task modal ----------------------------------------------------- function openAddTaskModal(presetProject) { // Pre-populate the project datalist with auto-discovered projects. if (STATE.graph) { const projects = [...new Set( STATE.graph.nodes.map(n => n.project).filter(Boolean) )].sort(); document.getElementById("at-project-list").innerHTML = projects.map(p => `<option value="${escapeHtml(p)}">`).join(""); } // Suggest known owners (existing card owners) in the assignee datalist // so the operator can pick fast, while still allowing a free-form name. // hook-bypass: line-limit (board_v3.html split still queued) if (STATE.graph) { const owners = [...new Set( STATE.graph.nodes.map(n => cardOwner(n)).filter(Boolean) )].sort(); document.getElementById("at-assignee-list").innerHTML = owners.map(o => `<option value="${escapeHtml(o)}">`).join(""); } document.getElementById("at-title").value = ""; // Assignee is required; reset it empty on each open so a stale value // can't silently re-own the next card. document.getElementById("at-assignee").value = ""; // If invoked from an empty column's placeholder (operator TG 381), // pre-fill the project so the new task goes straight to that // column without retyping the name. const projInput = document.getElementById("at-project"); projInput.value = presetProject && presetProject !== "—" ? presetProject : ""; document.getElementById("at-status").value = "deferred"; document.getElementById("at-task").value = ""; document.getElementById("add-task-backdrop").classList.add("show"); setTimeout(() => document.getElementById("at-title").focus(), 50); } function closeAddTaskModal(e) { if (e && e.target && !e.target.classList.contains("at-backdrop")) return; document.getElementById("add-task-backdrop").classList.remove("show"); } async function submitAddTask() { const title = document.getElementById("at-title").value.trim(); if (!title) { toast("title is required", true); document.getElementById("at-title").focus(); return; } // Assignee is REQUIRED — a card must have an owner (operator // constitution, no silent fallbacks). Block the POST + show an inline // message rather than create an owner-less card. The server enforces // this too (handle_create -> add_task), this is the fast UX gate. // hook-bypass: line-limit (board_v3.html split still queued) const assignee = document.getElementById("at-assignee").value.trim(); if (!assignee) { toast("assignee is required — pick an owner", true); document.getElementById("at-assignee").focus(); return; } const project = document.getElementById("at-project").value.trim(); const status = document.getElementById("at-status").value; const task = document.getElementById("at-task").value.trim(); const btn = document.getElementById("at-create"); btn.disabled = true; btn.textContent = "creating…"; try { const body = { title, status, assignee }; if (project) body.project = project; if (task) body.task = task; const r = await fetch("/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } const result = await r.json(); toast(`✓ Created ${result.id || title}`); document.getElementById("add-task-backdrop").classList.remove("show"); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } finally { btn.disabled = false; btn.textContent = "Create"; } } // -- Detail edit (in-drawer form, toggled by Edit button) --------------- function enterDetailEdit() { if (!STATE.openId) return; const t = (STATE.graph?.nodes || []).find(x => x.id === STATE.openId); if (!t) return; document.getElementById("d-edit-title").value = t.title || ""; document.getElementById("d-edit-status").value = t.status || "deferred"; document.getElementById("d-edit-blocker").value = t.blocker || ""; document.getElementById("d-edit-project").value = t.project || ""; document.getElementById("d-edit-agent").value = t.agent || ""; document.getElementById("d-edit-task").value = t.task || ""; document.getElementById("d-edit-note").value = t.note || ""; document.getElementById("d-edit-form").classList.add("show"); document.getElementById("d-edit").style.display = "none"; document.getElementById("d-rows").style.display = "none"; } function cancelDetailEdit() { document.getElementById("d-edit-form").classList.remove("show"); document.getElementById("d-edit").style.display = ""; document.getElementById("d-rows").style.display = ""; } async function saveDetailEdit() { if (!STATE.openId) return; const patch = { id: STATE.openId, title: document.getElementById("d-edit-title").value, status: document.getElementById("d-edit-status").value, project: document.getElementById("d-edit-project").value, agent: document.getElementById("d-edit-agent").value, task: document.getElementById("d-edit-task").value, note: document.getElementById("d-edit-note").value, }; const blocker = document.getElementById("d-edit-blocker").value; // /update accepts patch semantics — only set blocker when provided // AND status is blocked (the validator enforces that pairing). patch.blocker = blocker || null; try { const r = await fetch("/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } toast(`✓ Updated ${STATE.openId}`); cancelDetailEdit(); closeDetail(); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // -- Delete + Undo ------------------------------------------------------ async function deleteCurrentTask() { if (!STATE.openId) return; const id = STATE.openId; const t = (STATE.graph?.nodes || []).find(x => x.id === id); const label = t ? `"${t.title}" (${id})` : id; if (!window.confirm(`Delete ${label}?\n\nThis will scrub depends_on / blocks / parent references too. Undo is available for ~10s after delete.`)) return; try { const r = await fetch("/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } const result = await r.json(); // Undo: POST /restore with the same removed payload + refs. const undo = async () => { try { const rr = await fetch("/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task: result.removed, refs: result.refs || [], }), }); if (!rr.ok) throw new Error(`HTTP ${rr.status}`); await loadGraph(); toast(`↶ Restored ${id}`); } catch (e) { toast(`✗ Undo failed: ${e.message}`, true); } }; showUndoToast(`✓ Deleted ${id}`, undo); closeDetail(); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } function showUndoToast(msg, undoFn) { const el = document.getElementById("toast"); el.innerHTML = `${escapeHtml(msg)} <button class="toast-undo" id="t-undo-btn">Undo</button>`; el.classList.remove("toast--err"); el.classList.add("show"); document.getElementById("t-undo-btn").onclick = async () => { el.classList.remove("show"); await undoFn(); }; setTimeout(() => el.classList.remove("show"), 10000); } // -- Render-time integration -------------------------------------------- // Hook into render() via a wrapper that ALSO refreshes the project-hide // list + hides any column whose project is in the hidden set. The // original render() lives above; we wrap it without touching the body. const _origRender = render; render = function() { _origRender(); // Hidden-cards dropdown (operator TG 381) — repaint the list + // the header label count on every render so a new hide / un-hide // shows up immediately. (The project-COLUMN hide that used to run // here went with the Column layout on 2026-07-13.) rebuildHiddenList(); }; // Touch long-press → context menu (operator TG 459/460). Native // contextmenu isn't reliably fired on iOS, so we open it via a // 600ms timer started on touchstart, cancelled on touchmove / // touchend / touchcancel before the timer fires. Pointer events // (Apple Pencil etc.) feed through the same path. const _LONG_PRESS_MS = 600; let _lpTimer = null, _lpEl = null; document.addEventListener("touchstart", (e) => { const card = e.target && e.target.closest && e.target.closest(".card[data-card-id]"); if (!card) return; _lpEl = card; card.classList.add("touch-pressing"); const t = e.touches[0]; const cx = t.clientX, cy = t.clientY; _lpTimer = setTimeout(() => { card.classList.remove("touch-pressing"); const id = card.getAttribute("data-card-id"); openCardCtx({ preventDefault: () => {}, clientX: cx, clientY: cy, }, id); _lpTimer = null; }, _LONG_PRESS_MS); }, { passive: true }); function _cancelLongPress() { if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; } if (_lpEl) { _lpEl.classList.remove("touch-pressing"); _lpEl = null; } } document.addEventListener("touchmove", _cancelLongPress, { passive: true }); document.addEventListener("touchend", _cancelLongPress, { passive: true }); document.addEventListener("touchcancel", _cancelLongPress, { passive: true }); // ── Hidden-cards dropdown (operator TG 381) ──────────────────────── // Walks STATE.graph.nodes for everything in hiddenSet() and paints // a row per hidden card (title + project + un-hide button). Also // updates the "👁 hidden (N)" count in the dropdown summary. function rebuildHiddenList() { const hidden = hiddenSet(); const panel = document.getElementById("hidden-list-panel"); const countEl = document.getElementById("hidden-count"); if (countEl) countEl.textContent = String(hidden.size); if (!panel) return; if (hidden.size === 0) { panel.innerHTML = `<div class="hidden-list-empty">No hidden cards.</div>`; return; } const byId = new Map((STATE.graph?.nodes || []).map(n => [n.id, n])); const rows = [...hidden].map(id => { const t = byId.get(id); if (!t) { // Hidden card no longer exists in the store (deleted by // someone). Offer un-hide to clear the localStorage entry. return `<div class="hidden-list-row"> <div style="flex:1"> <div class="hidden-list-title">${escapeHtml(id)}</div> <div class="hidden-list-meta">(missing from store)</div> </div> <button class="hidden-list-unhide" onclick="unhideTask('${escapeHtml(id)}')">Un-hide</button> </div>`; } const title = t.title || t.task || id; const proj = t.project || "—"; const status = t.status || "?"; return `<div class="hidden-list-row"> <div style="flex:1; min-width:0;"> <div class="hidden-list-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div> <div class="hidden-list-meta">${escapeHtml(proj)} · ${escapeHtml(status)} · id:${escapeHtml(id)}</div> </div> <button class="hidden-list-unhide" onclick="unhideTask('${escapeHtml(id)}')">Un-hide</button> </div>`; }).join(""); const revealOn = STATE.filters.showHidden ? "checked" : ""; panel.innerHTML = rows + ` <div class="hidden-list-reveal"> <input type="checkbox" id="reveal-hidden" ${revealOn} onchange="toggleShowHidden({ currentTarget: this })"> <label for="reveal-hidden" style="cursor:pointer;"> Show hidden cards in place (don't filter) </label> </div>`; } // (renderFleetStrip + toggleAgentFilter removed with the #fleetstrip // element — see the filterbar comment near the top of this template. // fleet-liveness lives in the lead's periodic reports, not the board.) // -- Keyboard shortcuts ------------------------------------------------- // Cmd/Ctrl+N opens the Add Task modal (operator-friendly create entry). document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "n") { const backdrop = document.getElementById("add-task-backdrop"); if (backdrop && !backdrop.classList.contains("show")) { e.preventDefault(); openAddTaskModal(); } } // Cmd/Ctrl-Enter inside the Add Task title submits. if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { const at = document.getElementById("add-task-backdrop"); if (at && at.classList.contains("show")) { e.preventDefault(); submitAddTask(); } } // ESC closes the Add Task modal (the legacy ESC handler covers detail). if (e.key === "Escape") { const at = document.getElementById("add-task-backdrop"); if (at && at.classList.contains("show")) { closeAddTaskModal({ target: { classList: { contains: () => true } } }); } } }); // P1 Search-as-launcher (lead brief 2026-06-12) — RESTORED 2026-06-12. // "/" anywhere focuses #f-search; Esc inside the search blurs it. // Skip "/" when typing in another input/textarea/select/contenteditable // OR when a modifier is held (so browser shortcuts like Cmd-/ stay // available). Same muscle memory as Gmail / Jira / Linear. Pinned by // tests/scitex_cards/test__board_v3_signatures.py so a future squash // can't silently regress it again. function attachSearchKeyboardLauncher() { const search = document.getElementById("f-search"); if (!search) return; document.addEventListener("keydown", (e) => { if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) { const t = e.target; const tag = (t && t.tagName) ? t.tagName.toLowerCase() : ""; const editable = tag === "input" || tag === "textarea" || tag === "select" || (t && t.isContentEditable); if (editable) return; e.preventDefault(); search.focus(); const v = search.value; search.setSelectionRange(v.length, v.length); return; } if (e.key === "Escape" && document.activeElement === search) { search.blur(); } }); } attachSearchKeyboardLauncher(); _syncDetailsPane(); _syncDetailsSections(); renderStats(); attachFilters(); loadGraph(); setInterval(autoRefreshTick, 5000); </script> {% endblock extra_js %}