anentrypoint-design 0.0.358 → 0.0.360

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.358",
3
+ "version": "0.0.360",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -23,6 +23,9 @@
23
23
  "./components/shell.js": "./src/components/shell.js",
24
24
  "./components/files.js": "./src/components/files.js",
25
25
  "./components/files-modals.js": "./src/components/files-modals.js",
26
+ "./components/overlay-primitives.js": "./src/components/overlay-primitives.js",
27
+ "./components/git-status.js": "./src/components/git-status.js",
28
+ "./components/worktree-switcher.js": "./src/components/worktree-switcher.js",
26
29
  "./web-components/ds-chat.js": "./src/web-components/ds-chat.js",
27
30
  "./web-components/freddie-chat.js": "./src/web-components/freddie-chat.js",
28
31
  "./lint": {
@@ -15,6 +15,8 @@ import { queueMessage, watchReconnect, isOnline } from '../idb-outbox.js';
15
15
  import { ChatMessage, ChatComposer } from './chat.js';
16
16
  import { fmtTime, fmtAgo } from './sessions.js';
17
17
  import { createVirtualizer, measureRef } from '../virtual-scroll.js';
18
+ import { GitStatusPanel, GitDiffView } from './git-status.js';
19
+ import { WorktreeSwitcher } from './worktree-switcher.js';
18
20
 
19
21
  const h = webjsx.createElement;
20
22
 
@@ -868,12 +870,92 @@ export const debug = makePage((ctx) => {
868
870
  };
869
871
  });
870
872
 
873
+ // ---- git ---------------------------------------------------------------
874
+
875
+ export const git = makePage((ctx) => {
876
+ Object.assign(ctx.state, { cwd: null, status: null, log: null, worktrees: null, diff: null, activeFile: null, diffLoading: false, note: null });
877
+ async function load() {
878
+ try {
879
+ const proj = await api('/api/projects').catch(() => null);
880
+ const active = proj && proj.active;
881
+ const cwd = (active && typeof active === 'object' ? active.path : null) || ctx.state.cwd || '';
882
+ const qs = '?cwd=' + encodeURIComponent(cwd);
883
+ const [status, log, worktrees] = await Promise.all([
884
+ api('/api/git/status' + qs).catch((e) => ({ _err: e })),
885
+ api('/api/git/log' + qs + '&limit=20').catch((e) => ({ _err: e })),
886
+ api('/api/worktree' + qs).catch((e) => ({ _err: e })),
887
+ ]);
888
+ ctx.set({ loading: false, cwd, status, log, worktrees, error: null });
889
+ } catch (e) { ctx.set({ loading: false, error: e }); }
890
+ }
891
+ async function openDiff(file) {
892
+ ctx.set({ activeFile: file.path, diffLoading: true, diff: null });
893
+ try {
894
+ const qs = '?cwd=' + encodeURIComponent(ctx.state.cwd || '') + '&file=' + encodeURIComponent(file.path);
895
+ const res = await api('/api/git/diff' + qs);
896
+ ctx.set({ diff: res, diffLoading: false });
897
+ } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) }, diffLoading: false }); }
898
+ }
899
+ async function createWorktree() {
900
+ const path = (ctx.state.newWtPath || '').trim();
901
+ const branch = (ctx.state.newWtBranch || '').trim();
902
+ if (!path) { ctx.set({ note: { kind: 'warn', msg: 'path required' } }); return; }
903
+ ctx.set({ busy: true, note: null });
904
+ try {
905
+ await api('/api/worktree', { method: 'POST', body: { cwd: ctx.state.cwd || '', path, branch: branch || undefined } });
906
+ ctx.state.newWtPath = ''; ctx.state.newWtBranch = '';
907
+ await load();
908
+ } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
909
+ ctx.set({ busy: false });
910
+ }
911
+ load();
912
+ return () => {
913
+ const s = ctx.state;
914
+ if (s.loading) return loadingState('loading git status…');
915
+ if (s.error && !s.status) return errorState(s.error, load);
916
+ const statusFailed = s.status && s.status._err;
917
+ const logFailed = s.log && s.log._err;
918
+ const wtFailed = s.worktrees && s.worktrees._err;
919
+ const files = statusFailed ? [] : (s.status && s.status.files) || [];
920
+ const commits = logFailed ? [] : (s.log && s.log.commits) || s.log || [];
921
+ const worktrees = wtFailed ? [] : (s.worktrees && s.worktrees.worktrees) || s.worktrees || [];
922
+ const current = Array.isArray(worktrees) ? (worktrees.find(w => w.path === s.cwd) || {}).path : undefined;
923
+ return [
924
+ PageHeader({ eyebrow: 'freddie', title: 'git', lede: s.cwd || 'active project' }),
925
+ noteAlert(s.note),
926
+ statusFailed ? refreshError(statusFailed) : null,
927
+ section('worktrees',
928
+ WorktreeSwitcher({
929
+ worktrees: Array.isArray(worktrees) ? worktrees : [],
930
+ current,
931
+ onSwitch: () => {},
932
+ onCreate: () => ctx.set({ showWtForm: !s.showWtForm }),
933
+ }),
934
+ s.showWtForm ? h('div', { class: 'fd-row-actions' },
935
+ TextField({ label: 'path', value: s.newWtPath, onInput: (v) => { s.newWtPath = v; }, placeholder: '/path/to/worktree' }),
936
+ TextField({ label: 'branch (optional)', value: s.newWtBranch, onInput: (v) => { s.newWtBranch = v; }, placeholder: 'feature/x' }),
937
+ Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'create', onClick: createWorktree })) : null),
938
+ section('changed files',
939
+ statusFailed ? errorState(statusFailed) : GitStatusPanel({ files, onFileClick: openDiff, active: s.activeFile })),
940
+ section('diff' + (s.activeFile ? ' · ' + s.activeFile : ''),
941
+ s.diffLoading ? loadingState('loading diff…')
942
+ : s.diff ? GitDiffView({ diff: s.diff.diff || s.diff, filename: s.activeFile })
943
+ : emptyState('select a file to view its diff')),
944
+ section('log',
945
+ logFailed ? errorState(logFailed)
946
+ : commits.length
947
+ ? Table({ headers: ['sha', 'message', 'author', 'date'], rows: commits.slice(0, 20).map(c => [String(c.sha || c.hash || '').slice(0, 8), truncSpan(c.message || c.subject, TRUNC_SUB), c.author || '', c.date ? fmtAgo(c.date) : ''])})
948
+ : emptyState('no commits')),
949
+ ].filter(Boolean);
950
+ };
951
+ });
952
+
871
953
  // ---- registry --------------------------------------------------------------
872
954
 
873
955
  export const FREDDIE_PAGES = {
874
956
  home, chat, voice, sessions, projects, agents, analytics,
875
957
  models, cron, skills, config, env, tools, batch, gateway, chains,
876
- machines, health, debug, logs,
958
+ machines, health, debug, logs, git,
877
959
  };
878
960
 
879
961
  export { skillLabel, getRecentPaths, saveRecentPath, renderChatMessages };
@@ -0,0 +1,153 @@
1
+ // Git status + diff primitives — changed-file list and unified-diff renderer.
2
+ // Ported from pi-web's BranchNavigator concept into this design system's
3
+ // pure-webjsx idiom (see files.js for the sibling file-list pattern this
4
+ // mirrors: FileIcon-style type glyph, ds-file-row-style clickable row).
5
+
6
+ import * as webjsx from '../../vendor/webjsx/index.js';
7
+ import { Icon } from './shell.js';
8
+ import { fileGlyph } from './files.js';
9
+ import { highlightAllUnder } from '../highlight.js';
10
+ const h = webjsx.createElement;
11
+
12
+ // git status letter -> { label, tone, icon } used for both the row's status
13
+ // chip and its left-edge tone. Mirrors the porcelain status codes pi-web's
14
+ // git API returns (A/M/D/R/? ...), collapsed to the ones a status list needs.
15
+ const STATUS_META = {
16
+ A: { label: 'added', tone: 'add', glyph: 'A' },
17
+ M: { label: 'modified', tone: 'modify', glyph: 'M' },
18
+ D: { label: 'deleted', tone: 'delete', glyph: 'D' },
19
+ R: { label: 'renamed', tone: 'modify', glyph: 'R' },
20
+ C: { label: 'copied', tone: 'modify', glyph: 'C' },
21
+ U: { label: 'conflicted', tone: 'delete', glyph: 'U' },
22
+ '?': { label: 'untracked', tone: 'add', glyph: '?' },
23
+ '!': { label: 'ignored', tone: 'neutral', glyph: '!' },
24
+ };
25
+
26
+ function statusMeta(status) {
27
+ return STATUS_META[status] || { label: status || 'changed', tone: 'neutral', glyph: (status || '?').slice(0, 1) };
28
+ }
29
+
30
+ // Guess a file "type" (for FileIcon-style glyph reuse) from its extension —
31
+ // git-status entries are paths, not the richer {type} shape files.js rows get
32
+ // from a directory listing.
33
+ const EXT_TYPE = {
34
+ js: 'code', mjs: 'code', cjs: 'code', ts: 'code', tsx: 'code', jsx: 'code',
35
+ py: 'code', rs: 'code', go: 'code', java: 'code', c: 'code', cpp: 'code', h: 'code',
36
+ css: 'code', scss: 'code', html: 'code', json: 'code', yml: 'code', yaml: 'code', toml: 'code', sh: 'code',
37
+ md: 'document', mdx: 'document', txt: 'text',
38
+ png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', svg: 'image', webp: 'image',
39
+ mp4: 'video', mov: 'video', webm: 'video',
40
+ mp3: 'audio', wav: 'audio',
41
+ zip: 'archive', tar: 'archive', gz: 'archive',
42
+ };
43
+
44
+ function fileTypeFromPath(pathname = '') {
45
+ const base = pathname.split('/').pop() || pathname;
46
+ const dot = base.lastIndexOf('.');
47
+ if (dot <= 0) return 'other';
48
+ return EXT_TYPE[base.slice(dot + 1).toLowerCase()] || 'other';
49
+ }
50
+
51
+ // GitStatusPanel — a list of changed files with add/modify/delete indicators.
52
+ // files: [{ path, status, staged?, insertions?, deletions? }]
53
+ // onFileClick(file) opens the diff for a row.
54
+ export function GitStatusPanel({ files = [], onFileClick, emptyText = 'no changes', active } = {}) {
55
+ if (!files.length) {
56
+ return h('div', { class: 'ds-git-empty', role: 'status' },
57
+ h('span', { class: 'ds-git-empty-glyph', 'aria-hidden': 'true' }, Icon('circle-dot', { size: 22 })),
58
+ h('span', {}, emptyText));
59
+ }
60
+ return h('div', { class: 'ds-git-status-grid', role: 'group', 'aria-label': 'changed files' },
61
+ ...files.map((f, i) => GitStatusRow({ key: f.path || i, file: f, onClick: onFileClick, active: active === f.path })));
62
+ }
63
+
64
+ function GitStatusRow({ key, file, onClick, active } = {}) {
65
+ const meta = statusMeta(file.status);
66
+ const type = fileTypeFromPath(file.path);
67
+ const stats = [
68
+ file.insertions != null ? h('span', { class: 'ds-git-stat ds-git-stat-add' }, '+' + file.insertions) : null,
69
+ file.deletions != null ? h('span', { class: 'ds-git-stat ds-git-stat-del' }, '-' + file.deletions) : null,
70
+ ].filter(Boolean);
71
+ return h('button', {
72
+ key, type: 'button',
73
+ class: 'ds-git-row' + (active ? ' active' : ''),
74
+ onclick: onClick ? () => onClick(file) : null,
75
+ disabled: onClick ? null : true,
76
+ 'aria-label': meta.label + ': ' + file.path,
77
+ 'aria-pressed': active ? 'true' : 'false',
78
+ },
79
+ h('span', { class: 'ds-git-status-chip tone-' + meta.tone, 'aria-hidden': 'true' }, meta.glyph),
80
+ h('span', { class: 'ds-git-row-icon', 'aria-hidden': 'true' }, Icon(fileGlyph(type), { size: 15 })),
81
+ h('span', { class: 'ds-git-row-path' }, file.path),
82
+ file.staged ? h('span', { class: 'ds-git-row-tag' }, 'staged') : null,
83
+ stats.length ? h('span', { class: 'ds-git-row-stats' }, ...stats) : null,
84
+ );
85
+ }
86
+
87
+ // Parse a unified diff into hunks of typed lines so we can color +/- context
88
+ // independently of the raw text (avoids relying on Prism's diff grammar for
89
+ // the leading marker column, which we style ourselves).
90
+ function parseUnifiedDiff(diff = '') {
91
+ const lines = diff.split('\n');
92
+ const hunks = [];
93
+ let current = null;
94
+ for (const line of lines) {
95
+ if (line.startsWith('@@')) {
96
+ current = { header: line, lines: [] };
97
+ hunks.push(current);
98
+ continue;
99
+ }
100
+ if (line.startsWith('diff --git') || line.startsWith('index ') ||
101
+ line.startsWith('--- ') || line.startsWith('+++ ')) {
102
+ continue; // file-header noise; the host already knows the filename
103
+ }
104
+ if (!current) continue;
105
+ let kind = 'context';
106
+ if (line.startsWith('+')) kind = 'add';
107
+ else if (line.startsWith('-')) kind = 'del';
108
+ current.lines.push({ kind, text: line });
109
+ }
110
+ return hunks;
111
+ }
112
+
113
+ const EXT_LANG = {
114
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
115
+ ts: 'typescript', tsx: 'tsx', py: 'python', rs: 'rust', go: 'go',
116
+ css: 'css', scss: 'css', html: 'markup', md: 'markdown', json: 'json',
117
+ yml: 'yaml', yaml: 'yaml', sh: 'bash', sql: 'sql', toml: 'toml',
118
+ };
119
+
120
+ function langFromFilename(filename = '') {
121
+ const base = filename.split('/').pop() || filename;
122
+ const dot = base.lastIndexOf('.');
123
+ if (dot <= 0) return null;
124
+ return EXT_LANG[base.slice(dot + 1).toLowerCase()] || null;
125
+ }
126
+
127
+ // GitDiffView — unified-diff renderer with +/- line coloring. Uses
128
+ // highlight.js's Prism loader for language detection by file extension; the
129
+ // +/- marker column is drawn by CSS (tone classes), Prism only tokenizes the
130
+ // code content inside each line.
131
+ export function GitDiffView({ diff = '', filename } = {}) {
132
+ const hunks = parseUnifiedDiff(diff);
133
+ const lang = langFromFilename(filename);
134
+ const highlightRef = (el) => {
135
+ if (!el) return;
136
+ try { highlightAllUnder(el); } catch { /* progressive enhancement only */ }
137
+ };
138
+ if (!hunks.length) {
139
+ return h('div', { class: 'ds-git-diff-empty', role: 'status' }, 'no diff to show');
140
+ }
141
+ return h('div', { class: 'ds-git-diff', ref: highlightRef },
142
+ filename ? h('div', { class: 'ds-git-diff-head' }, h('span', { class: 'name' }, filename)) : null,
143
+ ...hunks.map((hunk, hi) => h('div', { key: 'hunk' + hi, class: 'ds-git-hunk' },
144
+ h('div', { class: 'ds-git-hunk-header' }, hunk.header),
145
+ h('pre', { class: 'ds-git-hunk-body' + (lang ? ' lang-' + lang : '') },
146
+ ...hunk.lines.map((l, li) => h('code', {
147
+ key: li,
148
+ class: 'ds-git-line ds-git-line-' + l.kind + (lang ? ' language-' + lang : ''),
149
+ }, l.text + '\n'))
150
+ )
151
+ ))
152
+ );
153
+ }
@@ -27,12 +27,15 @@ export function Brand({ name = '247420', leaf } = {}) {
27
27
  * @param {string} [props.tone=''] - semantic color tone (empty = neutral).
28
28
  * @param {'sm'|'md'|'lg'} [props.size='md']
29
29
  * @param {boolean} [props.tag=false] - true renders a rectangular sentence-case variant for dense data (drops the all-caps pill styling). Orthogonal to tone.
30
+ * @param {Function} [props.onRemove] - if given, renders a trailing dismiss (x) button that calls onRemove() on click. Omitted entirely (no button) when not supplied.
30
31
  * @param {*} props.children
31
32
  * @returns {*} webjsx vnode
32
33
  */
33
- export function Chip({ tone = '', size = 'md', tag = false, children }) {
34
+ export function Chip({ tone = '', size = 'md', tag = false, onRemove, children }) {
34
35
  const sizeCls = size === 'sm' ? ' chip--sm' : (size === 'lg' ? ' chip--lg' : '');
35
- return h('span', { class: 'chip' + sizeCls + (tag ? ' chip--tag' : '') + (tone ? ' tone-' + tone : '') }, children);
36
+ return h('span', { class: 'chip' + sizeCls + (tag ? ' chip--tag' : '') + (tone ? ' tone-' + tone : '') + (onRemove ? ' ds-chip-removable' : '') },
37
+ children,
38
+ onRemove ? h('button', { type: 'button', class: 'ds-chip-remove-btn', 'aria-label': 'Remove', onclick: (e) => { e.stopPropagation(); onRemove(); } }, Icon('x')) : null);
36
39
  }
37
40
 
38
41
  /**
@@ -0,0 +1,57 @@
1
+ // WorktreeSwitcher — a dropdown for listing/switching git worktrees + branches,
2
+ // with a "new worktree" action. Built on overlay-primitives.js's Dropdown
3
+ // (itself the Popover/useFloating primitives + roving-focus menu keyboard
4
+ // handling already in this kit) rather than re-deriving popover positioning,
5
+ // outside-click, Escape, and focus-trap logic from scratch.
6
+
7
+ import * as webjsx from '../../vendor/webjsx/index.js';
8
+ import { Icon } from './shell.js';
9
+ import { Dropdown } from './overlay-primitives.js';
10
+ const h = webjsx.createElement;
11
+
12
+ const NEW_WORKTREE_ID = '__ds_new_worktree__';
13
+
14
+ // WorktreeSwitcher({ worktrees, current, onSwitch, onCreate })
15
+ // worktrees: [{ path, branch, current? }]
16
+ // current: path of the active worktree (falls back to a worktree's own `current` flag)
17
+ // onSwitch(worktree) — fired when an existing worktree is picked
18
+ // onCreate() — fired from the trailing "new worktree" row; the host owns the create flow (prompt/modal)
19
+ export function WorktreeSwitcher({ worktrees = [], current, onSwitch, onCreate, ariaLabel = 'switch worktree' } = {}) {
20
+ const isCurrent = (wt) => wt.current || (current != null && wt.path === current);
21
+ const activeWt = worktrees.find(isCurrent) || worktrees[0];
22
+ const byId = new Map(worktrees.map((wt, i) => [wt.path || String(i), wt]));
23
+
24
+ const items = [
25
+ ...worktrees.map((wt, i) => ({
26
+ id: wt.path || String(i),
27
+ label: h('span', { class: 'ds-wts-item-body' },
28
+ h('span', { class: 'ds-wts-item-check', 'aria-hidden': 'true' },
29
+ isCurrent(wt) ? Icon('check', { size: 14 }) : null),
30
+ h('span', { class: 'ds-wts-item-text' },
31
+ h('span', { class: 'ds-wts-item-branch' }, wt.branch || '(detached HEAD)'),
32
+ h('span', { class: 'ds-wts-item-path' }, wt.path)
33
+ ),
34
+ ),
35
+ disabled: isCurrent(wt),
36
+ })),
37
+ worktrees.length && onCreate ? { separator: true } : null,
38
+ onCreate ? {
39
+ id: NEW_WORKTREE_ID,
40
+ glyph: '+',
41
+ label: 'new worktree',
42
+ } : null,
43
+ ].filter(Boolean);
44
+
45
+ const onSelect = (id) => {
46
+ if (id === NEW_WORKTREE_ID) { onCreate && onCreate(); return; }
47
+ const wt = byId.get(id);
48
+ if (wt && onSwitch && !isCurrent(wt)) onSwitch(wt);
49
+ };
50
+
51
+ const trigger = h('button', { type: 'button', class: 'ds-wts-trigger' },
52
+ h('span', { class: 'ds-wts-trigger-branch' }, (activeWt && activeWt.branch) || 'select worktree'),
53
+ h('span', { class: 'ds-wts-trigger-caret', 'aria-hidden': 'true' }, Icon('chevron-down', { size: 13 }))
54
+ );
55
+
56
+ return h('span', { class: 'ds-wts' }, Dropdown({ trigger, items, onSelect, ariaLabel }));
57
+ }
@@ -0,0 +1,112 @@
1
+ /* ============================================================
2
+ Git status panel + diff view — changed-file list, unified diff.
3
+ ============================================================ */
4
+
5
+ .ds-git-status-grid {
6
+ display: flex; flex-direction: column; gap: 2px;
7
+ }
8
+ .ds-git-row {
9
+ display: flex; align-items: center; gap: 10px;
10
+ width: 100%; padding: 7px 12px;
11
+ background: none; border: var(--bw-hair) solid transparent; border-radius: var(--r-2);
12
+ color: var(--fg); font: inherit; text-align: left; cursor: pointer;
13
+ transition: background var(--dur-snap) var(--ease), border-color var(--dur-snap) var(--ease);
14
+ }
15
+ .ds-git-row:hover { background: var(--bg-2); border-color: var(--rule); }
16
+ .ds-git-row.active { background: var(--accent-tint); border-color: var(--accent); }
17
+ .ds-git-row:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: var(--focus-offset); }
18
+ .ds-git-row[disabled] { cursor: default; }
19
+
20
+ .ds-git-status-chip {
21
+ display: inline-flex; align-items: center; justify-content: center;
22
+ flex: 0 0 auto; width: 18px; height: 18px;
23
+ font-family: var(--ff-mono); font-size: var(--fs-micro); font-weight: 700;
24
+ border-radius: var(--r-1);
25
+ }
26
+ .ds-git-status-chip.tone-add { color: var(--green-2); background: color-mix(in oklab, var(--green-2) 16%, transparent); }
27
+ .ds-git-status-chip.tone-modify { color: var(--sun); background: color-mix(in oklab, var(--sun) 16%, transparent); }
28
+ .ds-git-status-chip.tone-delete { color: var(--flame); background: color-mix(in oklab, var(--flame) 16%, transparent); }
29
+ .ds-git-status-chip.tone-neutral { color: var(--fg-3); background: var(--bg-2); }
30
+
31
+ .ds-git-row-icon { display: inline-flex; flex: 0 0 auto; color: var(--fg-3); }
32
+ .ds-git-row-path {
33
+ flex: 1 1 auto; min-width: 0;
34
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
35
+ font-family: var(--ff-mono); font-size: var(--fs-sm);
36
+ }
37
+ .ds-git-row-tag {
38
+ flex: 0 0 auto; font-size: var(--fs-micro); color: var(--fg-3);
39
+ border: var(--bw-hair) solid var(--rule); border-radius: var(--r-1); padding: 1px 6px;
40
+ }
41
+ .ds-git-row-stats { flex: 0 0 auto; display: inline-flex; gap: 6px; font-family: var(--ff-mono); font-size: var(--fs-micro); }
42
+ .ds-git-stat-add { color: var(--green-2); }
43
+ .ds-git-stat-del { color: var(--flame); }
44
+
45
+ .ds-git-empty {
46
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
47
+ gap: var(--space-2); padding: var(--space-6) var(--space-3);
48
+ color: var(--fg-3); text-align: center;
49
+ }
50
+ .ds-git-empty-glyph { opacity: 0.55; }
51
+
52
+ /* Diff view */
53
+ .ds-git-diff {
54
+ display: flex; flex-direction: column; gap: var(--space-2);
55
+ background: var(--ink); color: var(--paper);
56
+ border-radius: var(--r-2); overflow: hidden;
57
+ }
58
+ .ds-git-diff-head {
59
+ padding: var(--space-2) var(--space-3);
60
+ border-bottom: var(--bw-hair) solid color-mix(in oklab, var(--paper) 18%, transparent);
61
+ font-family: var(--ff-mono); font-size: var(--fs-xs);
62
+ }
63
+ .ds-git-diff-empty { padding: var(--space-3); color: var(--fg-3); font-size: var(--fs-sm); }
64
+ .ds-git-hunk + .ds-git-hunk { border-top: var(--bw-hair) solid color-mix(in oklab, var(--paper) 12%, transparent); }
65
+ .ds-git-hunk-header {
66
+ padding: 4px var(--space-3);
67
+ color: var(--purple-2); font-family: var(--ff-mono); font-size: var(--fs-micro);
68
+ background: color-mix(in oklab, var(--paper) 6%, transparent);
69
+ }
70
+ .ds-git-hunk-body {
71
+ margin: 0; padding: 0 0 var(--space-2);
72
+ font-family: var(--ff-mono); font-size: var(--fs-xs); line-height: var(--lh-base);
73
+ white-space: pre; overflow-x: auto; tab-size: 2;
74
+ }
75
+ .ds-git-line {
76
+ display: block; padding: 0 var(--space-3);
77
+ }
78
+ .ds-git-line-add { background: color-mix(in oklab, var(--green-2) 16%, transparent); color: var(--paper); }
79
+ .ds-git-line-del { background: color-mix(in oklab, var(--flame) 16%, transparent); color: var(--paper); }
80
+ .ds-git-line-context { color: var(--paper-3); }
81
+
82
+ /* ============================================================
83
+ WorktreeSwitcher — trigger + dropdown menu of worktrees/branches.
84
+ ============================================================ */
85
+ .ds-wts { display: inline-flex; }
86
+ .ds-wts-trigger {
87
+ display: inline-flex; align-items: center; gap: 8px;
88
+ padding: 5px 10px; min-height: 32px;
89
+ background: var(--bg); border: var(--bw-hair) solid var(--rule); border-radius: var(--r-1);
90
+ color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
91
+ max-width: 220px;
92
+ }
93
+ .ds-wts-trigger:hover { background: var(--bg-2); }
94
+ .ds-wts-trigger:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: var(--focus-offset); }
95
+ .ds-wts-trigger-branch {
96
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
97
+ font-family: var(--ff-mono);
98
+ }
99
+ .ds-wts-trigger-caret { display: inline-flex; flex: 0 0 auto; color: var(--fg-3); }
100
+
101
+ .ds-wts-item-body { display: flex; align-items: center; gap: 8px; min-width: 0; }
102
+ .ds-wts-item-check { display: inline-flex; flex: 0 0 16px; color: var(--accent-ink); }
103
+ .ds-wts-item-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
104
+ .ds-wts-item-branch {
105
+ font-family: var(--ff-mono); font-size: var(--fs-sm);
106
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
107
+ }
108
+ .ds-wts-item-path {
109
+ font-size: var(--fs-micro); color: var(--fg-3);
110
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
111
+ }
112
+ .ds-dropdown-item[aria-disabled="true"] .ds-wts-item-branch { color: var(--fg); font-weight: 600; }
@@ -175,6 +175,25 @@
175
175
  .chip.tone-orange { background: color-mix(in oklab, var(--flame) 30%, var(--sun) 70%); color: var(--ink); }
176
176
  .chip.tone-yellow { background: var(--sun); color: var(--ink); }
177
177
 
178
+ /* Removable chip — a trailing dismiss (x) button for editable tag/filter
179
+ chip lists (session tags, saved-search chips). Reuses the chip's own
180
+ tone/size; the button itself stays neutral-at-rest, per the standing
181
+ color-reserved-for-meaning convention, tinting only on hover/focus. */
182
+ .chip.ds-chip-removable { padding-right: var(--space-1); }
183
+ .ds-chip-remove-btn {
184
+ display: inline-flex; align-items: center; justify-content: center;
185
+ width: 16px; height: 16px; margin-left: var(--space-half);
186
+ padding: 0; border: none; border-radius: var(--r-pill);
187
+ background: transparent; color: currentColor; opacity: 0.7;
188
+ cursor: pointer;
189
+ }
190
+ .ds-chip-remove-btn:hover, .ds-chip-remove-btn:focus-visible { opacity: 1; background: color-mix(in oklab, currentColor 16%, transparent); }
191
+ .ds-chip-remove-btn svg { width: 10px; height: 10px; }
192
+ @media (pointer: coarse) {
193
+ .ds-chip-remove-btn { width: 24px; height: 24px; margin-left: 0; }
194
+ .ds-chip-remove-btn svg { width: 12px; height: 12px; }
195
+ }
196
+
178
197
  /* ds-pill — plain neutral chip, distinct from the toned .ds-badge/.chip family
179
198
  (gmsniff's .pill: a bare rounded label with no semantic tone). */
180
199
  .ds-pill {