claude-code-kanban 4.16.0 → 4.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +142 -26
- package/public/index.html +52 -27
- package/public/style.css +99 -8
- package/server.js +13 -3
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
//#region STATE
|
|
2
|
+
// The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
|
|
3
|
+
// from the query string, and what decides whether a control tints ember in the header summary.
|
|
4
|
+
const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20' };
|
|
5
|
+
|
|
2
6
|
let sessions = [];
|
|
3
7
|
let currentSessionId = null;
|
|
4
8
|
let currentTasks = [];
|
|
5
9
|
let viewMode = 'session';
|
|
6
|
-
let sessionFilter =
|
|
10
|
+
let sessionFilter = FILTER_DEFAULTS.session;
|
|
7
11
|
// Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
|
|
8
12
|
const activityFilter = new Set(); // kinds: 'waiting' | 'active'
|
|
9
|
-
let sessionLimit =
|
|
10
|
-
let filterProject =
|
|
13
|
+
let sessionLimit = FILTER_DEFAULTS.limit;
|
|
14
|
+
let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
|
|
11
15
|
let recentProjects = new Set();
|
|
12
16
|
let projectsCacheDirty = true;
|
|
13
17
|
const collapsedProjectGroups = new Set();
|
|
@@ -75,9 +79,9 @@ function updateUrl() {
|
|
|
75
79
|
if (viewMode === 'all') params.set('view', 'all');
|
|
76
80
|
if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
|
|
77
81
|
if (currentSessionId) params.set('session', currentSessionId);
|
|
78
|
-
if (sessionFilter !==
|
|
79
|
-
if (sessionLimit !==
|
|
80
|
-
if (filterProject && filterProject !==
|
|
82
|
+
if (sessionFilter !== FILTER_DEFAULTS.session) params.set('filter', sessionFilter);
|
|
83
|
+
if (sessionLimit !== FILTER_DEFAULTS.limit) params.set('limit', sessionLimit);
|
|
84
|
+
if (filterProject && filterProject !== FILTER_DEFAULTS.project) params.set('project', filterProject);
|
|
81
85
|
if (ownerFilter) params.set('owner', ownerFilter);
|
|
82
86
|
if (searchQuery) params.set('search', searchQuery);
|
|
83
87
|
if (messagePanelOpen) params.set('messages', '1');
|
|
@@ -94,6 +98,9 @@ function persistLastView() {
|
|
|
94
98
|
view: viewMode,
|
|
95
99
|
session: currentSessionId,
|
|
96
100
|
projectPath: viewMode === 'project' ? currentProjectPath : null,
|
|
101
|
+
// The project filter also lives in the URL, but the hub recreates each iframe at the app's
|
|
102
|
+
// base URL on reload, so the query string alone doesn't survive a hub refresh.
|
|
103
|
+
project: filterProject,
|
|
97
104
|
};
|
|
98
105
|
localStorage.setItem(LAST_VIEW_KEY, JSON.stringify(data));
|
|
99
106
|
} catch (_) {}
|
|
@@ -112,9 +119,9 @@ function resetState() {
|
|
|
112
119
|
try {
|
|
113
120
|
localStorage.removeItem(LAST_VIEW_KEY);
|
|
114
121
|
} catch (_) {}
|
|
115
|
-
sessionFilter =
|
|
116
|
-
sessionLimit =
|
|
117
|
-
filterProject =
|
|
122
|
+
sessionFilter = FILTER_DEFAULTS.session;
|
|
123
|
+
sessionLimit = FILTER_DEFAULTS.limit;
|
|
124
|
+
filterProject = FILTER_DEFAULTS.project;
|
|
118
125
|
ownerFilter = '';
|
|
119
126
|
searchQuery = '';
|
|
120
127
|
viewMode = 'all';
|
|
@@ -126,7 +133,7 @@ function resetState() {
|
|
|
126
133
|
const searchInput = document.getElementById('search-input');
|
|
127
134
|
if (searchInput) searchInput.value = '';
|
|
128
135
|
document.getElementById('search-clear-btn')?.classList.remove('visible');
|
|
129
|
-
|
|
136
|
+
renderFilterState();
|
|
130
137
|
fetchSessions().then(() => showAllTasks());
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -5819,10 +5826,95 @@ function getOwnerColor(name) {
|
|
|
5819
5826
|
//#endregion
|
|
5820
5827
|
|
|
5821
5828
|
//#region FILTERS
|
|
5829
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5830
|
+
function toggleFilterMenu(e) {
|
|
5831
|
+
// Stops the click reaching .section-header (which would collapse the section) *and*
|
|
5832
|
+
// document (which would immediately close the menu we are opening).
|
|
5833
|
+
e?.stopPropagation();
|
|
5834
|
+
if (closeFilterMenu()) return;
|
|
5835
|
+
document.getElementById('filter-menu').classList.add('open');
|
|
5836
|
+
document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
|
|
5837
|
+
renderFilterState();
|
|
5838
|
+
document.addEventListener('click', closeFilterMenu, { once: true });
|
|
5839
|
+
// Capture phase: the global keydown handler bails out on SELECT targets, and every
|
|
5840
|
+
// focusable thing in this popover is a select — Escape would never reach it otherwise.
|
|
5841
|
+
document.addEventListener('keydown', filterMenuKeydown, true);
|
|
5842
|
+
document.getElementById('project-filter')?.focus();
|
|
5843
|
+
}
|
|
5844
|
+
|
|
5845
|
+
function filterMenuKeydown(e) {
|
|
5846
|
+
if (e.key !== 'Escape') return;
|
|
5847
|
+
e.stopPropagation();
|
|
5848
|
+
if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
|
|
5849
|
+
}
|
|
5850
|
+
|
|
5851
|
+
function closeFilterMenu() {
|
|
5852
|
+
const menu = document.getElementById('filter-menu');
|
|
5853
|
+
if (!menu?.classList.contains('open')) return false;
|
|
5854
|
+
menu.classList.remove('open');
|
|
5855
|
+
document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'false');
|
|
5856
|
+
document.removeEventListener('keydown', filterMenuKeydown, true);
|
|
5857
|
+
return true;
|
|
5858
|
+
}
|
|
5859
|
+
|
|
5860
|
+
// One row per filter control: the select it mirrors, its value, whether it differs from the
|
|
5861
|
+
// default, and two label forms — terse tokens for the header strip (which has ~15 characters
|
|
5862
|
+
// before the sidebar's width ellipsises it) and long form for its tooltip.
|
|
5863
|
+
const FILTERS = [
|
|
5864
|
+
{
|
|
5865
|
+
id: 'project-filter',
|
|
5866
|
+
value: () => filterProject ?? '',
|
|
5867
|
+
active: () => filterProject !== FILTER_DEFAULTS.project,
|
|
5868
|
+
short: () => (filterProject ? filterProject.split(/[/\\]/).pop() : 'all proj'),
|
|
5869
|
+
long: () => (filterProject ? `project ${filterProject}` : 'all projects'),
|
|
5870
|
+
},
|
|
5871
|
+
{
|
|
5872
|
+
id: 'session-filter',
|
|
5873
|
+
value: () => sessionFilter,
|
|
5874
|
+
active: () => sessionFilter !== FILTER_DEFAULTS.session,
|
|
5875
|
+
short: () => 'inactive',
|
|
5876
|
+
long: () => 'including inactive sessions',
|
|
5877
|
+
},
|
|
5878
|
+
{
|
|
5879
|
+
id: 'session-limit',
|
|
5880
|
+
value: () => sessionLimit,
|
|
5881
|
+
active: () => sessionLimit !== FILTER_DEFAULTS.limit,
|
|
5882
|
+
short: () => (sessionLimit === 'all' ? 'n=∞' : `n=${sessionLimit}`),
|
|
5883
|
+
long: () => (sessionLimit === 'all' ? 'no session limit' : `showing ${sessionLimit}`),
|
|
5884
|
+
},
|
|
5885
|
+
];
|
|
5886
|
+
|
|
5887
|
+
// Keeps the header (summary + funnel tint) and the popover's selects in sync with state.
|
|
5888
|
+
// Safe to call on every filter change — it only touches classes, values, and text.
|
|
5889
|
+
function renderFilterState() {
|
|
5890
|
+
const short = [];
|
|
5891
|
+
const long = [];
|
|
5892
|
+
for (const f of FILTERS) {
|
|
5893
|
+
const active = f.active();
|
|
5894
|
+
if (active) {
|
|
5895
|
+
short.push(f.short());
|
|
5896
|
+
long.push(f.long());
|
|
5897
|
+
}
|
|
5898
|
+
const el = document.getElementById(f.id);
|
|
5899
|
+
if (!el) continue;
|
|
5900
|
+
const value = String(f.value());
|
|
5901
|
+
if (el.value !== value) el.value = value;
|
|
5902
|
+
el.classList.toggle('non-default', active);
|
|
5903
|
+
}
|
|
5904
|
+
|
|
5905
|
+
const summary = document.getElementById('filter-summary');
|
|
5906
|
+
if (summary) {
|
|
5907
|
+
summary.textContent = short.join(' · ');
|
|
5908
|
+
summary.title = long.length ? `Active filters: ${long.join(', ')}` : '';
|
|
5909
|
+
}
|
|
5910
|
+
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5822
5913
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5823
5914
|
function filterBySessions(value) {
|
|
5824
5915
|
sessionFilter = value;
|
|
5825
5916
|
if (value !== 'active') activityFilter.clear();
|
|
5917
|
+
renderFilterState();
|
|
5826
5918
|
updateUrl();
|
|
5827
5919
|
// Instant feedback from cached data, then refetch — the cached list was fetched
|
|
5828
5920
|
// with the previous filter's server params (e.g. filter=active), so "All Sessions"
|
|
@@ -5835,6 +5927,7 @@ function filterBySessions(value) {
|
|
|
5835
5927
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5836
5928
|
function changeSessionLimit(value) {
|
|
5837
5929
|
sessionLimit = value;
|
|
5930
|
+
renderFilterState();
|
|
5838
5931
|
updateUrl();
|
|
5839
5932
|
fetchSessions();
|
|
5840
5933
|
}
|
|
@@ -5848,6 +5941,31 @@ function matchesProjectFilter(project) {
|
|
|
5848
5941
|
//#endregion
|
|
5849
5942
|
|
|
5850
5943
|
//#region EVENT_DELEGATION
|
|
5944
|
+
// Selecting text inside a dialog and releasing past its edge puts the mouseup on the
|
|
5945
|
+
// backdrop, so the synthesized click targets the overlay and the `onclick="close…()"`
|
|
5946
|
+
// on every .modal-overlay would dismiss it. Only a press that *started* on the
|
|
5947
|
+
// backdrop counts as a dismissal.
|
|
5948
|
+
let _overlayPressTarget = null;
|
|
5949
|
+
document.addEventListener(
|
|
5950
|
+
'mousedown',
|
|
5951
|
+
(e) => {
|
|
5952
|
+
// Only an overlay is ever compared below, so don't hold a reference to any other node —
|
|
5953
|
+
// that would keep the last-pressed element (and a detached subtree, after a re-render)
|
|
5954
|
+
// alive until the next mousedown anywhere.
|
|
5955
|
+
_overlayPressTarget = e.target.classList?.contains('modal-overlay') ? e.target : null;
|
|
5956
|
+
},
|
|
5957
|
+
true,
|
|
5958
|
+
);
|
|
5959
|
+
document.addEventListener(
|
|
5960
|
+
'click',
|
|
5961
|
+
(e) => {
|
|
5962
|
+
if (!e.target.classList?.contains('modal-overlay')) return;
|
|
5963
|
+
if (_overlayPressTarget === e.target) return;
|
|
5964
|
+
e.stopPropagation();
|
|
5965
|
+
},
|
|
5966
|
+
true,
|
|
5967
|
+
);
|
|
5968
|
+
|
|
5851
5969
|
document.addEventListener('click', (e) => {
|
|
5852
5970
|
const pathToggle = e.target.closest('[data-group-action="toggle-path"]');
|
|
5853
5971
|
if (pathToggle) {
|
|
@@ -5903,6 +6021,7 @@ document.addEventListener('click', (e) => {
|
|
|
5903
6021
|
// Used in HTML; also called by the hub project shim, so no biome suppression is needed.
|
|
5904
6022
|
function filterByProject(project) {
|
|
5905
6023
|
filterProject = project || null;
|
|
6024
|
+
renderFilterState();
|
|
5906
6025
|
updateUrl();
|
|
5907
6026
|
fetchSessions(false);
|
|
5908
6027
|
showAllTasks();
|
|
@@ -5964,6 +6083,8 @@ function renderProjectDropdown(dropdown, projects) {
|
|
|
5964
6083
|
return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
|
|
5965
6084
|
})
|
|
5966
6085
|
.join('');
|
|
6086
|
+
// The option list just changed under the select — re-assert tint and header summary.
|
|
6087
|
+
renderFilterState();
|
|
5967
6088
|
}
|
|
5968
6089
|
|
|
5969
6090
|
function updateThemeColor(isLight) {
|
|
@@ -6187,14 +6308,6 @@ function loadPanelWidths() {
|
|
|
6187
6308
|
|
|
6188
6309
|
//#endregion
|
|
6189
6310
|
|
|
6190
|
-
//#region PREFERENCES
|
|
6191
|
-
function loadPreferences() {
|
|
6192
|
-
document.getElementById('session-filter').value = sessionFilter;
|
|
6193
|
-
document.getElementById('session-limit').value = sessionLimit;
|
|
6194
|
-
}
|
|
6195
|
-
|
|
6196
|
-
//#endregion
|
|
6197
|
-
|
|
6198
6311
|
//#region SESSION_INFO
|
|
6199
6312
|
async function showSessionInfoModal(sessionId) {
|
|
6200
6313
|
const session = sessions.find((s) => s.id === sessionId);
|
|
@@ -7240,13 +7353,16 @@ fetch('/api/version')
|
|
|
7240
7353
|
.catch(() => {});
|
|
7241
7354
|
|
|
7242
7355
|
const urlState = getUrlState();
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7356
|
+
const lastView = loadLastView();
|
|
7357
|
+
sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
|
|
7358
|
+
sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
|
|
7359
|
+
// The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
|
|
7360
|
+
// restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
|
|
7361
|
+
filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
|
|
7246
7362
|
ownerFilter = urlState.owner || '';
|
|
7247
7363
|
searchQuery = urlState.search || '';
|
|
7248
7364
|
|
|
7249
|
-
|
|
7365
|
+
renderFilterState();
|
|
7250
7366
|
pinnedSessionIds = loadPinnedSessions();
|
|
7251
7367
|
stickySessionIds = loadStickySessions();
|
|
7252
7368
|
setupEventSource();
|
|
@@ -7308,12 +7424,12 @@ Promise.all([
|
|
|
7308
7424
|
|
|
7309
7425
|
window.addEventListener('popstate', () => {
|
|
7310
7426
|
const s = getUrlState();
|
|
7311
|
-
sessionFilter = s.filter ||
|
|
7312
|
-
sessionLimit = s.limit ||
|
|
7313
|
-
filterProject = s.project ||
|
|
7427
|
+
sessionFilter = s.filter || FILTER_DEFAULTS.session;
|
|
7428
|
+
sessionLimit = s.limit || FILTER_DEFAULTS.limit;
|
|
7429
|
+
filterProject = s.project || FILTER_DEFAULTS.project;
|
|
7314
7430
|
ownerFilter = s.owner || '';
|
|
7315
7431
|
searchQuery = s.search || '';
|
|
7316
|
-
|
|
7432
|
+
renderFilterState();
|
|
7317
7433
|
// fetchSessions derives query params from the globals set above — refetch so
|
|
7318
7434
|
// back/forward across a filter change doesn't render a stale server-filtered list.
|
|
7319
7435
|
fetchSessions(false);
|
package/public/index.html
CHANGED
|
@@ -56,10 +56,58 @@
|
|
|
56
56
|
<!-- Tasks -->
|
|
57
57
|
<div class="sidebar-section flex-1">
|
|
58
58
|
<div class="section-header" onclick="toggleSection('sessions-filters', 'sessions-chevron')" style="cursor: pointer;">
|
|
59
|
-
<span>Sessions</span>
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
<span class="section-header-label">Sessions</span>
|
|
60
|
+
<span id="filter-summary" class="filter-summary" title=""></span>
|
|
61
|
+
<span class="section-header-actions">
|
|
62
|
+
<button
|
|
63
|
+
id="filter-menu-btn"
|
|
64
|
+
class="icon-btn section-btn"
|
|
65
|
+
onclick="toggleFilterMenu(event)"
|
|
66
|
+
title="Filters"
|
|
67
|
+
aria-label="Filters"
|
|
68
|
+
aria-haspopup="true"
|
|
69
|
+
aria-expanded="false"
|
|
70
|
+
>
|
|
71
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
72
|
+
<path d="M20 4H4l6.5 8v6l3 2v-8L20 4z"/>
|
|
73
|
+
</svg>
|
|
74
|
+
</button>
|
|
75
|
+
<button
|
|
76
|
+
class="icon-btn section-btn"
|
|
77
|
+
onclick="event.stopPropagation(); resetState()"
|
|
78
|
+
title="Reset filters and clear selection"
|
|
79
|
+
aria-label="Reset filters and clear selection"
|
|
80
|
+
>
|
|
81
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
82
|
+
<path d="M3 2v6h6"/>
|
|
83
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L3 8"/>
|
|
84
|
+
</svg>
|
|
85
|
+
</button>
|
|
86
|
+
<svg id="sessions-chevron" class="collapse-chevron" viewBox="0 0 24 24">
|
|
87
|
+
<path d="M6 9l6 6 6-6"/>
|
|
88
|
+
</svg>
|
|
89
|
+
</span>
|
|
90
|
+
<div id="filter-menu" class="filter-menu" onclick="event.stopPropagation()">
|
|
91
|
+
<div class="filter-row">
|
|
92
|
+
<select id="project-filter" class="filter-dropdown" onchange="filterByProject(this.value)" aria-label="Filter by project">
|
|
93
|
+
<option value="">All Projects</option>
|
|
94
|
+
</select>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="filter-row">
|
|
97
|
+
<select id="session-filter" class="filter-dropdown" onchange="filterBySessions(this.value)" aria-label="Filter by session status">
|
|
98
|
+
<option value="all">All Sessions</option>
|
|
99
|
+
<option value="active">Active Only</option>
|
|
100
|
+
</select>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="filter-row">
|
|
103
|
+
<select id="session-limit" class="filter-dropdown" onchange="changeSessionLimit(this.value)" aria-label="Number of sessions to show">
|
|
104
|
+
<option value="10">Show 10</option>
|
|
105
|
+
<option value="20">Show 20</option>
|
|
106
|
+
<option value="50">Show 50</option>
|
|
107
|
+
<option value="all">Show All</option>
|
|
108
|
+
</select>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
63
111
|
</div>
|
|
64
112
|
<div id="sessions-filters" class="collapsible-section">
|
|
65
113
|
<div class="search-container">
|
|
@@ -76,29 +124,6 @@
|
|
|
76
124
|
</svg>
|
|
77
125
|
</button>
|
|
78
126
|
</div>
|
|
79
|
-
<div class="filter-row">
|
|
80
|
-
<select id="project-filter" class="filter-dropdown" onchange="filterByProject(this.value)" aria-label="Filter by project">
|
|
81
|
-
<option value="">All Projects</option>
|
|
82
|
-
</select>
|
|
83
|
-
<select id="session-filter" class="filter-dropdown" onchange="filterBySessions(this.value)" aria-label="Filter by session status">
|
|
84
|
-
<option value="all">All Sessions</option>
|
|
85
|
-
<option value="active">Active Only</option>
|
|
86
|
-
</select>
|
|
87
|
-
</div>
|
|
88
|
-
<div class="filter-row">
|
|
89
|
-
<select id="session-limit" class="filter-dropdown" onchange="changeSessionLimit(this.value)" aria-label="Number of sessions to show">
|
|
90
|
-
<option value="10">Show 10</option>
|
|
91
|
-
<option value="20">Show 20</option>
|
|
92
|
-
<option value="50">Show 50</option>
|
|
93
|
-
<option value="all">Show All</option>
|
|
94
|
-
</select>
|
|
95
|
-
<button class="icon-btn reset-btn" onclick="resetState()" title="Reset all filters" aria-label="Reset all filters">
|
|
96
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
97
|
-
<path d="M3 12a9 9 0 1 1 9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
|
98
|
-
<path d="M3 22v-6h6"/>
|
|
99
|
-
</svg>
|
|
100
|
-
</button>
|
|
101
|
-
</div>
|
|
102
127
|
</div>
|
|
103
128
|
<div id="sessions-list" class="sessions-list"></div>
|
|
104
129
|
</div>
|
package/public/style.css
CHANGED
|
@@ -353,10 +353,11 @@ body::before {
|
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
.section-header {
|
|
356
|
+
position: relative;
|
|
356
357
|
display: flex;
|
|
357
358
|
align-items: center;
|
|
358
|
-
|
|
359
|
-
padding: 14px
|
|
359
|
+
gap: 8px;
|
|
360
|
+
padding: 14px 16px 10px 20px;
|
|
360
361
|
font-size: 11px;
|
|
361
362
|
font-weight: 500;
|
|
362
363
|
text-transform: uppercase;
|
|
@@ -364,16 +365,95 @@ body::before {
|
|
|
364
365
|
color: var(--text-muted);
|
|
365
366
|
}
|
|
366
367
|
|
|
368
|
+
.section-header-label {
|
|
369
|
+
flex: 0 0 auto;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* Non-default filters, spelled out in the header so folding them into the popover
|
|
373
|
+
never hides *that* the list is filtered. Empty (and invisible) at defaults. */
|
|
374
|
+
.filter-summary {
|
|
375
|
+
flex: 1;
|
|
376
|
+
min-width: 0;
|
|
377
|
+
overflow: hidden;
|
|
378
|
+
white-space: nowrap;
|
|
379
|
+
text-overflow: ellipsis;
|
|
380
|
+
font-size: 10px;
|
|
381
|
+
font-weight: 400;
|
|
382
|
+
letter-spacing: 0.02em;
|
|
383
|
+
text-transform: none;
|
|
384
|
+
color: var(--accent-text);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.section-header-actions {
|
|
388
|
+
flex: 0 0 auto;
|
|
389
|
+
display: flex;
|
|
390
|
+
align-items: center;
|
|
391
|
+
gap: 2px;
|
|
392
|
+
margin-left: auto;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* Quiet at rest: no box around a header glyph. The hairline arrives on hover.
|
|
396
|
+
Compounded with .icon-btn because that rule is declared later in this file and would
|
|
397
|
+
otherwise win on size, border, and colour. */
|
|
398
|
+
.icon-btn.section-btn {
|
|
399
|
+
width: 26px;
|
|
400
|
+
height: 26px;
|
|
401
|
+
padding: 0;
|
|
402
|
+
background: none;
|
|
403
|
+
border-color: transparent;
|
|
404
|
+
color: var(--text-muted);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.icon-btn.section-btn:hover {
|
|
408
|
+
background: var(--bg-hover);
|
|
409
|
+
border-color: var(--border);
|
|
410
|
+
color: var(--text-primary);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.icon-btn.section-btn:focus-visible {
|
|
414
|
+
outline: none;
|
|
415
|
+
box-shadow: 0 0 0 2px var(--accent-dim);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.icon-btn.section-btn svg {
|
|
419
|
+
width: 15px;
|
|
420
|
+
height: 15px;
|
|
421
|
+
stroke-width: 1.75;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* Ember only while something is actually filtered — at rest the funnel is just chrome. */
|
|
425
|
+
.icon-btn.section-btn.has-filters {
|
|
426
|
+
color: var(--accent-text);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.filter-menu {
|
|
430
|
+
display: none;
|
|
431
|
+
position: absolute;
|
|
432
|
+
top: calc(100% - 2px);
|
|
433
|
+
right: 16px;
|
|
434
|
+
left: 16px;
|
|
435
|
+
z-index: 100;
|
|
436
|
+
padding: 8px;
|
|
437
|
+
background: var(--bg-elevated);
|
|
438
|
+
border: 1px solid var(--border);
|
|
439
|
+
border-radius: 8px;
|
|
440
|
+
box-shadow:
|
|
441
|
+
0 16px 44px rgba(0, 0, 0, 0.38),
|
|
442
|
+
0 4px 12px rgba(0, 0, 0, 0.2);
|
|
443
|
+
cursor: default;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.filter-menu.open {
|
|
447
|
+
display: block;
|
|
448
|
+
}
|
|
449
|
+
|
|
367
450
|
.filter-row {
|
|
368
451
|
display: flex;
|
|
369
|
-
gap:
|
|
370
|
-
padding: 0 16px 10px;
|
|
452
|
+
gap: 5px;
|
|
371
453
|
}
|
|
372
454
|
|
|
373
|
-
.
|
|
374
|
-
|
|
375
|
-
width: 32px;
|
|
376
|
-
height: 32px;
|
|
455
|
+
.filter-row + .filter-row {
|
|
456
|
+
margin-top: 5px;
|
|
377
457
|
}
|
|
378
458
|
|
|
379
459
|
.filter-dropdown {
|
|
@@ -399,6 +479,17 @@ body::before {
|
|
|
399
479
|
border-color: var(--border);
|
|
400
480
|
}
|
|
401
481
|
|
|
482
|
+
/* A filter holding a non-default value tints toward the ember, so it reads as "active"
|
|
483
|
+
before you read the value. Border and text only — the fill never floods. */
|
|
484
|
+
.filter-dropdown.non-default {
|
|
485
|
+
color: var(--accent-text);
|
|
486
|
+
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.filter-dropdown.non-default:hover {
|
|
490
|
+
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
|
|
491
|
+
}
|
|
492
|
+
|
|
402
493
|
.filter-dropdown option {
|
|
403
494
|
background: var(--bg-surface);
|
|
404
495
|
color: var(--text-primary);
|
package/server.js
CHANGED
|
@@ -2659,9 +2659,19 @@ async function readPreviewFile(absPath) {
|
|
|
2659
2659
|
return { content: await fs.readFile(absPath, 'utf8'), kind };
|
|
2660
2660
|
}
|
|
2661
2661
|
|
|
2662
|
+
// Paths copied out of a browser or a slide deck carry a URL fragment
|
|
2663
|
+
// ("deck.html#10"). Dropped only when the literal path is missing and the trimmed one
|
|
2664
|
+
// exists, so a filename that genuinely contains '#' still resolves to itself.
|
|
2665
|
+
function dropUrlFragment(abs) {
|
|
2666
|
+
const i = abs.lastIndexOf('#');
|
|
2667
|
+
if (i <= 0 || existsSync(abs)) return abs;
|
|
2668
|
+
const trimmed = abs.slice(0, i);
|
|
2669
|
+
return existsSync(trimmed) ? trimmed : abs;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2662
2672
|
function resolvePreviewPath(filePath, base) {
|
|
2663
2673
|
if (!filePath || typeof filePath !== 'string') return null;
|
|
2664
|
-
if (path.isAbsolute(filePath)) return filePath;
|
|
2674
|
+
if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
|
|
2665
2675
|
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
2666
2676
|
let baseDir = base;
|
|
2667
2677
|
try {
|
|
@@ -2670,9 +2680,9 @@ function resolvePreviewPath(filePath, base) {
|
|
|
2670
2680
|
// base doesn't exist — fall back to dirname if it looks like a file
|
|
2671
2681
|
if (path.extname(base)) baseDir = path.dirname(base);
|
|
2672
2682
|
}
|
|
2673
|
-
return path.resolve(baseDir, filePath);
|
|
2683
|
+
return dropUrlFragment(path.resolve(baseDir, filePath));
|
|
2674
2684
|
}
|
|
2675
|
-
return path.resolve(filePath);
|
|
2685
|
+
return dropUrlFragment(path.resolve(filePath));
|
|
2676
2686
|
}
|
|
2677
2687
|
|
|
2678
2688
|
app.post('/api/preview', async (req, res) => {
|