diff --git a/CLAUDE.md b/CLAUDE.md index 9eef5ce9..0bd1e5d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,16 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: preview slot and status are `--p-bg`. The address field's fill and edge step off the ROW (`--p-side-flat`), not the page: off the page they sat at 1.01:1 on the row on every dark style (MEASURED). Near-black is still judged by the page (`nearBlackField(bg, text, row)`). +- **THE TOOLBAR'S BUTTONS ARE NEVER COLOURED; EVERY TAB WEARS AN ICON BY WHAT IT HOLDS** (#308; + owner, 2026-10-06: "i dont like the look of the buttons being colored ... search is more minor ... + the preview panel is almost always opened ... an icon that changes based on whether the panel is + opened or hidden ... default icons per type of page explorer, project and settings"; 2026-10-07, of + `research/prism/2026-10-06-new-themes/toolbar-options.html`: "A3 and C2"). The preview toggle + (`PreviewGlyph`) and the search button wear Back's grey and no fill in every state; the preview's + right column is SOLID while open, empty while hidden; `data-active` on search is a mark, not a + look. Every tab carries `TabKindIcon` (Explorer a folder, project code brackets, Settings a gear) + in the tab's own ink, SOLID on the tab you are on and lines on the rest; it replaced the + folder-coloured glyph only Explorer tabs had. The `toolbarIcons` e2e holds both. ## Scope diff --git a/package-lock.json b/package-lock.json index fd813750..df880cc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.93.1", + "version": "0.94.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.93.1", + "version": "0.94.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index dba9f3bc..0d55f2a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.93.1", + "version": "0.94.0", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/renderer/src/components/TabKindIcon.tsx b/src/renderer/src/components/TabKindIcon.tsx new file mode 100644 index 00000000..20851ac5 --- /dev/null +++ b/src/renderer/src/components/TabKindIcon.tsx @@ -0,0 +1,89 @@ +import { useId, type JSX } from 'react' + +/** What a tab holds, as its icon says it (#308). */ +export type TabKind = 'explorer' | 'project' | 'settings' + +/* The C2 drawings from the approved mockup (owner, 2026-10-07: "A3 and C2"; + research/prism/2026-10-06-new-themes/toolbar-options.html). Lines on the + tabs you are not on, solid on the one you are: a second cue next to the + accent rule along the top. */ +const FOLDER = 'M3 6.5A1.5 1.5 0 0 1 4.5 5H9l2 2h8.5A1.5 1.5 0 0 1 21 8.5v9a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z' +const FOLDER_SOLID = + 'M2.5 6.5A1.5 1.5 0 0 1 4 5h5.2l2 2H20a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 20 19H4a1.5 1.5 0 0 1-1.5-1.5z' +const CODE = 'M8.5 7L3.5 12l5 5M15.5 7l5 5-5 5M13.5 4.5l-3 15' +const CODE_CUT = 'M9 9l-3 3 3 3M15 9l3 3-3 3M13 7.5l-2 9' +const GEAR = + 'M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z' + +const SIZE = 14 + +/** + * The tab's own icon, in the tab's ink (currentColor): the label's dim grey + * on a tab at rest, its brighter text on the one you are on and under a + * hover, the agent's onTint ink on a Full fill. Never a colour of its own + * (owner, 2026-10-06: "i dont like the look of the buttons being colored"). + */ +export function TabKindIcon({ kind, solid }: { kind: TabKind; solid: boolean }): JSX.Element { + // The solid code tile's brackets are CUT OUT of it by a mask rather than + // drawn over it in the strip's colour: the strip may be see-through (two + // coats of a glass ground are an opaque slab, #294) or under an agent's + // fill, and a hole shows whatever is really behind. + const mask = `tab-kind-${useId().replace(/:/g, '')}` + const common = { + viewBox: '0 0 24 24', + width: SIZE, + height: SIZE, + className: 'shrink-0', + 'data-tab-icon': kind, + 'data-tab-icon-fill': solid ? 'solid' : 'outline', + 'aria-hidden': true + } as const + if (!solid) + return ( + + + + ) + if (kind === 'explorer') + return ( + + + + ) + if (kind === 'settings') + return ( + + + + ) + return ( + + + + + + + + ) +} diff --git a/src/renderer/src/components/TabStrip.tsx b/src/renderer/src/components/TabStrip.tsx index fc502c11..22653f84 100644 --- a/src/renderer/src/components/TabStrip.tsx +++ b/src/renderer/src/components/TabStrip.tsx @@ -8,6 +8,7 @@ import { tabsOf, useStyle } from '../lib/theme' import { pinnedRoots, plusMenuList, recentLabels, recentRoots, togglePin } from 'prism-term-core/renderer/lib/recentRoots' import { DRAG_MIME, dragPayload, droppedPaths, setDrag, type DragPayload } from '../lib/dragDrop' import { ContextMenu } from './ContextMenu' +import { TabKindIcon } from './TabKindIcon' import { useTabWidth } from '../lib/tabWidthPrefs' /** @@ -454,7 +455,17 @@ export function TabStrip({ either, which is why the slot itself goes with the icon; the widths only settle differently, and only when the setting is deliberately changed. */} - {isExplorerTab(t) ? : indicator === 'full' && ( + {/* EVERY TAB SAYS WHAT IT HOLDS (#308; owner, 2026-10-06: "we + should have some default icons per type of page explorer, + project and settings"; 2026-10-07, of the mockup: "A3 and C2"). + A folder, code brackets or a gear in the tab's own ink, solid + on the tab you are on and lines on the rest. It replaced the + small folder in the folder colour that only Explorer tabs had. */} + + {!isExplorerTab(t) && t.kind !== 'settings' && indicator === 'full' && ( {tint && ( ) } + +/** + * THE PREVIEW BUTTON SAYS ITS STATE IN ITS DRAWING (#308; owner, 2026-10-07, + * of the mockup: "A3"; before it, "the preview panel is almost always opened + * and that button then is colored ... an icon that changes based on whether + * the panel is opened or hidden"). A window whose right column is SOLID while + * the pane is open and EMPTY while it is hidden, in the row's own grey: no + * accent and no fill in any state. A3's paths and stroke, at the row's size. + */ +export function PreviewGlyph({ open }: { open: boolean }): JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/browse/FolderBrowser.tsx b/src/renderer/src/components/browse/FolderBrowser.tsx index 62fc1bb4..6835434f 100644 --- a/src/renderer/src/components/browse/FolderBrowser.tsx +++ b/src/renderer/src/components/browse/FolderBrowser.tsx @@ -1,6 +1,6 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react' import { formatBytes } from '../../lib/format' -import { BrowseIcon } from './BrowseIcon' +import { BrowseIcon, PreviewGlyph } from './BrowseIcon' import { BrowseList } from './BrowseList' import { BrowseSearchStatus } from './BrowseSearchStatus' import { BrowseSearchPopup } from './BrowseSearchPopup' @@ -290,7 +290,7 @@ export function FolderBrowser(props: FolderBrowserProps): JSX.Element { aria-pressed={props.previewEnabled} onClick={props.onPreviewToggle} > - + ) const places = (props.placesVisible !== false || sliding || !!props.placesPeek) && ( @@ -456,8 +456,11 @@ export function FolderBrowser(props: FolderBrowserProps): JSX.Element { aria-haspopup="dialog" title="Search (Ctrl+F)" data-testid="browse-search-button" - // Lit while the list shows a search, so the way back to it and - // to the query is where the search began. + // Marks that the list shows a search. A mark for tests and + // nothing else: the button wears no accent in any state (#308; + // owner, 2026-10-06: "search is more minor and i dont think it + // needs to be colored since you just see a search box when its + // clicked"). data-active={!!props.query.trim() || undefined} onClick={openSearch} > diff --git a/src/renderer/src/components/browse/browse.css b/src/renderer/src/components/browse/browse.css index 45e8a7fa..2fbd6850 100644 --- a/src/renderer/src/components/browse/browse.css +++ b/src/renderer/src/components/browse/browse.css @@ -331,14 +331,11 @@ border-radius: 3px; } /* SEARCH IS A BUTTON (#267): the field became a popup (search-popup.css). - Lit in the accent while the list is showing a search's results. */ -.browse-toolbar .browse-search-button[data-active] { - color: var(--p-accent-hi); -} -.browse-toolbar > .browse-icon-button[aria-pressed='true'] { - color: var(--p-accent-hi); - background-color: var(--p-hover); -} + NEITHER IT NOR THE PREVIEW BUTTON IS EVER COLOURED (#308; owner, + 2026-10-06: "i dont like the look of the buttons being colored"): both + wear the row's grey and hover and focus like Back, in every state. The + preview's state is in its drawing (PreviewGlyph), the search's in the + popup and the list. */ .browse-places { grid-column: 1; grid-row: 2 / -1; diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index eb3a62ce..6faa06e8 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -9888,6 +9888,224 @@ async function columnHeadersScenario(fixtures) { * preview toggle and the search button, in that order. Screenshots of the * toolbar on Void and on Paper go to .e2e/shots. */ +/** + * A PREVIEW BUTTON THAT SHOWS ITS STATE, A QUIET SEARCH, AN ICON ON EVERY TAB + * (#308; owner, 2026-10-07, of the mockup: "A3 and C2"). The preview toggle + * and the search button wear Back's grey and no fill in every state, the + * preview's drawing carrying its state (a solid right column while open); each + * tab shows what it holds (folder, code brackets, gear) in its own ink, solid + * on the tab you are on and lines on the rest. Fails on main, where the + * pressed preview button and a searching search button were the accent and + * only an Explorer tab had an icon, a folder in the folder colour. + */ +async function toolbarIconsScenario(fixtures) { + console.log('toolbar icons') + const dir = join(fixtures, 'tbicons') + rmSync(dir, { recursive: true, force: true }) + mkdirSync(dir, { recursive: true }) + for (const n of ['one.txt', 'two.txt', 'three.md']) writeFileSync(join(dir, n), `icons ${n}\n`) + EXTRA_ENV = { PRISM_E2E_INDEX_ROOT: join(tmpdir(), 'prism-e2e-no-index') } + const { app, win } = await launch(join(dir, 'one.txt')) + EXTRA_ENV = {} + let before + const current = () => win.evaluate(() => document.querySelector('.folder-browser nav.browse-path button[aria-current]')?.textContent ?? '') + const preview = win.locator('.folder-browser [data-testid="browse-toolbar"] button[aria-label="Preview pane"]') + const search = win.locator('[data-testid="browse-search-button"]') + const popup = win.locator('[data-testid="browse-search-popup"]') + // Nothing hovered or focused: the pointer to the list's empty foot, the + // focus off the toolbar, so what is read is each button at rest. + const rest = async () => { + await win.mouse.move(5, 300) + await win.evaluate(() => (document.activeElement instanceof HTMLElement ? document.activeElement.blur() : undefined)) + await sleep(250) + } + const buttons = () => + win.evaluate(() => { + const tb = document.querySelector('.folder-browser [data-testid="browse-toolbar"]') + const read = (b) => { + if (!b) return null + const s = getComputedStyle(b) + const svg = b.querySelector('svg') + return { color: s.color, bg: s.backgroundColor, svg: svg ? getComputedStyle(svg).color : null } + } + const probe = document.createElement('span') + probe.style.color = 'var(--p-accent-hi)' + document.body.append(probe) + const accent = getComputedStyle(probe).color + probe.remove() + const pv = tb.querySelector('button[aria-label="Preview pane"]') + const glyph = pv?.querySelector('[data-preview-glyph]') + return { + accent, + back: read(tb.querySelector('button[aria-label="Back"]')), + preview: read(pv), + pressed: pv?.getAttribute('aria-pressed') ?? null, + glyph: glyph?.getAttribute('data-preview-glyph') ?? null, + column: pv?.querySelectorAll('[data-preview-column]').length ?? -1, + search: read(tb.querySelector('[data-testid="browse-search-button"]')), + searching: tb.querySelector('[data-testid="browse-search-button"]')?.hasAttribute('data-active') ?? false + } + }) + const sameAsBack = (l, b, what) => { + ok(l[b].color === l.back.color && l[b].bg === l.back.bg, `${what}: it wears Back's grey and ground (${l[b].color} / ${l[b].bg}; Back ${l.back.color} / ${l.back.bg})`) + ok(l[b].color !== l.accent && l[b].svg !== l.accent, `${what}: never the accent (${l[b].color}, accent ${l.accent})`) + } + const tabsLook = () => + win.evaluate(() => + [...document.querySelectorAll('[role="tablist"] [data-tab-role]')].map((el) => { + const icon = el.querySelector('[data-tab-icon]') + const label = el.querySelector('[role="tab"]') + const close = el.querySelector('[data-tab-close]') + const box = (n) => (n ? n.getBoundingClientRect() : null) + const ib = box(icon) + const lb = box(label) + const cb = box(close) + return { + role: el.getAttribute('data-tab-role'), + pinned: el.hasAttribute('data-pinned'), + active: label?.getAttribute('aria-selected') === 'true', + label: label?.textContent ?? '', + ink: getComputedStyle(el).color, + icons: el.querySelectorAll('[data-tab-icon]').length, + kind: icon?.getAttribute('data-tab-icon') ?? null, + fill: icon?.getAttribute('data-tab-icon-fill') ?? null, + iconInk: icon ? getComputedStyle(icon).color : null, + // Any colour drawn into the icon other than its ink (currentColor) + // or the mask's black and white is a colour of its own. + ownColour: icon + ? [...icon.querySelectorAll('path, rect')].some((p) => { + if (p.closest('mask')) return false + const f = p.getAttribute('fill') + const s = p.getAttribute('stroke') + return [f, s].some((v) => v && !['none', 'currentColor'].includes(v)) + }) + : false, + pin: el.querySelectorAll('[data-pin="on"]').length, + oldFolder: el.querySelectorAll('svg[fill="var(--p-tree-folder)"]').length, + // Laid out left to right without overlap: icon, label, close. + order: ib && lb ? ib.right <= lb.left + 0.5 && (!cb || lb.right <= cb.left + 0.5) : false, + inside: ib ? ib.left >= el.getBoundingClientRect().left && ib.width > 10 && ib.width <= 16 : false + } + }) + ) + const wantKind = { explorer: 'explorer', project: 'project', settings: 'settings' } + const checkTabs = (tabs, where) => { + ok(tabs.length >= 3, `${where}: an Explorer, a project and a Settings tab (${tabs.map((t) => t.role).join(', ')})`) + for (const t of tabs) { + const name = `${where}: ${t.role}${t.pinned ? ' (pinned)' : ''} "${t.label}"` + ok(t.icons === 1 && t.kind === wantKind[t.role], `${name} wears one ${wantKind[t.role]} icon (${t.icons}, ${t.kind})`) + ok(t.fill === (t.active ? 'solid' : 'outline'), `${name} is ${t.active ? 'solid, the tab you are on' : 'lines, a tab at rest'} (${t.fill})`) + ok(t.iconInk === t.ink && !t.ownColour, `${name}: the icon is the tab's own ink (${t.iconInk} on ${t.ink})`) + ok(t.oldFolder === 0, `${name}: the folder-coloured glyph is gone`) + ok(t.order && t.inside, `${name}: icon, name and close sit in a row without overlap`) + if (t.pinned) ok(t.pin === 1, `${name} keeps its pin`) + } + const active = tabs.filter((t) => t.active) + const restTab = tabs.find((t) => !t.active) + ok(active.length === 1 && !!restTab && active[0].ink !== restTab.ink, `${where}: the tab you are on is brighter than the rest (${active[0]?.ink} / ${restTab?.ink})`) + } + const shootTabs = (name) => win.locator('[role="tablist"]').first().screenshot({ path: join(SHOTS, `toolbar-icons-tabs-${name}.png`) }) + const shootBar = (name) => + win.locator('.folder-browser [data-testid="browse-toolbar"]').first().screenshot({ path: join(SHOTS, `toolbar-icons-${name}.png`) }) + const pickTabWidth = async (name) => { + const seg = (await gotoPref(win, 'tab-width')).getByRole('button', { name, exact: true }) + await seg.scrollIntoViewIfNeeded() + await seg.click() + await sleep(300) + } + try { + await win.locator('[role="tablist"] [data-pinned] [role="tab"]').click() + await win.waitForSelector('[data-testid="browse-list"] .browse-row', { timeout: 10000 }) + if ((await current()) !== 'tbicons') await win.locator('[data-testid="browse-list"] [data-browse-path$="\\tbicons" i]').dblclick() + ok(await until(async () => (await current()) === 'tbicons', 10000), 'the Explorer shows the folder') + + for (const style of ['new-void', 'paper']) { + const was = await switchStyle(win, style) + if (before === undefined) before = was + await sleep(500) + // BOTH STATES of the preview, each at rest. + if ((await preview.getAttribute('aria-pressed')) !== 'true') await preview.click() + await rest() + const open = await buttons() + ok(open.pressed === 'true' && open.glyph === 'open' && open.column === 1, `${style}: open, the pane's column is drawn solid (${open.pressed}, ${open.glyph}, ${open.column})`) + sameAsBack(open, 'preview', `${style}: the preview button, pane open`) + sameAsBack(open, 'search', `${style}: the search button at rest`) + await shootBar(`${style}-open`) + await preview.click() + await rest() + const hidden = await buttons() + ok(hidden.pressed === 'false' && hidden.glyph === 'hidden' && hidden.column === 0, `${style}: hidden, the column is empty (${hidden.pressed}, ${hidden.glyph}, ${hidden.column})`) + sameAsBack(hidden, 'preview', `${style}: the preview button, pane hidden`) + await shootBar(`${style}-hidden`) + await preview.click() + await rest() + + // A hover is the row's grey fill, the same as Back's. + await win.locator('.folder-browser [data-testid="browse-toolbar"] button[aria-label="Back"]').hover() + await sleep(200) + const backHover = (await buttons()).back + await preview.hover() + await sleep(200) + const pvHover = (await buttons()).preview + ok(pvHover.bg === backHover.bg && pvHover.bg !== open.back.bg, `${style}: a hover fills it as it fills Back (${pvHover.bg} / ${backHover.bg})`) + + // SEARCH: the popup open, then a search shown in the list; never lit. + await search.click() + await popup.waitFor({ timeout: 5000 }) + await win.mouse.move(5, 300) + await sleep(200) + const asking = await buttons() + ok(asking.search.color === asking.back.color && asking.search.color !== asking.accent, `${style}: the search button stays grey while its popup is up (${asking.search.color})`) + await popup.locator('input[role="combobox"]').fill('one') + await popup.locator('input[role="combobox"]').press('Control+Enter') + ok(await until(async () => (await popup.count()) === 0 && (await buttons()).searching, 15000), `${style}: a search is shown in the list`) + await rest() + const searching = await buttons() + sameAsBack(searching, 'search', `${style}: the search button while the list shows a search`) + await shootBar(`${style}-searching`) + await win.locator('[data-testid="browse-search-clear"]').click() + await until(async () => !(await buttons()).searching, 8000) + } + + // THE TABS, on Void and on Paper, with the Explorer in front and then + // with Settings in front, in both widths. + await switchStyle(win, 'new-void') + await settingsPage(win, 'appearance') + await win.waitForSelector('[data-tab-role="settings"]', { timeout: 10000 }) + for (const width of ['Dynamic', 'Fixed']) { + await pickTabWidth(width) + for (const style of ['new-void', 'paper']) { + await switchStyle(win, style) + await sleep(500) + await win.locator('[data-tab-role="settings"] [role="tab"]').click() + await win.mouse.move(5, 300) + await sleep(300) + checkTabs(await tabsLook(), `${style}, ${width}, Settings in front`) + await shootTabs(`${style}-${width.toLowerCase()}-settings`) + await win.locator('[role="tablist"] [data-pinned] [role="tab"]').click() + await win.mouse.move(5, 300) + await sleep(300) + checkTabs(await tabsLook(), `${style}, ${width}, Explorer in front`) + await shootTabs(`${style}-${width.toLowerCase()}-explorer`) + const project = win.locator('[data-tab-role="project"] [role="tab"]').first() + if (await project.count()) { + await project.click() + await win.mouse.move(5, 300) + await sleep(300) + checkTabs(await tabsLook(), `${style}, ${width}, a project in front`) + await shootTabs(`${style}-${width.toLowerCase()}-project`) + } + } + await switchStyle(win, 'new-void') + } + await pickTabWidth('Dynamic') + } finally { + if (before !== undefined) await switchStyle(win, before).catch(() => {}) + await app.close().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + } +} + async function addressFieldScenario(fixtures) { console.log('address field') const dir = join(fixtures, 'addrfield') @@ -10396,7 +10614,7 @@ async function searchPopupScenario(fixtures) { ok(await until(async () => (await popup.count()) === 0, 5000), 'Show more closes the popup') const searched = () => win.locator('[data-testid="browse-list"] [data-browse-path]').count() ok(await until(async () => (await searched()) >= 5 && (await win.locator('[data-testid="browse-search-status"]').count()) === 1, 15000), `and the list shows every match (${await searched()})`) - ok((await win.locator('[data-testid="browse-search-button"]').getAttribute('data-active')) !== null, 'the search button is lit while it does') + ok((await win.locator('[data-testid="browse-search-button"]').getAttribute('data-active')) !== null, 'the search button is marked while it does (marked, never lit: #308)') await win.locator('[data-testid="browse-search-clear"]').click() ok(await until(async () => (await win.locator('[data-testid="browse-search-status"]').count()) === 0, 8000), 'Clear search goes back to the folder') await open() @@ -15828,6 +16046,7 @@ await run(tabSwitchInstantScenario) await run(rememberFoldersScenario) await run(listScrollbarScenario) await run(addressFieldScenario) +await run(toolbarIconsScenario) await run(explorerVerbsScenario) await run(searchPopupScenario) await run(searchNavScenario)