diff --git a/docs/developer/app-structure.md b/docs/developer/app-structure.md index b982c545..9df36e66 100644 --- a/docs/developer/app-structure.md +++ b/docs/developer/app-structure.md @@ -46,13 +46,18 @@ Theme primitives hold palette and appearance values. Structural semantic tokens map those values to the first-level regions, and structural selectors consume the semantic tokens rather than naming the current contents. -| Structural token | Current mapping | +| Structural token | Current base mapping | | --- | --- | -| `--app-shell-background` | `--paper` | +| `--app-shell-background` | `--hover-line` | | `--app-header-background` | `--app-shell-background` | -| `--left-pane-background` | `rgb(var(--surface-rgb) / 42%)` | +| `--left-pane-background` | `--neutral-soft` | | `--center-pane-background` | `--paper` | -| `--right-pane-background` | `--accent-soft` | +| `--right-pane-background` | `--neutral-soft` | + +Palette and appearance overrides may replace a base mapping with a literal or +another theme role. For example, Olive light maps the app shell to `#dde1d2`. +The floating theme-token inspector reports each token's live computed custom- +property value so these overrides remain truthful. Use full property suffixes such as `-background` for structural tokens. Child content and component tokens keep their existing names until their vocabulary diff --git a/scripts/app-layout.ts b/scripts/app-layout.ts index afb7de67..8ff64d62 100644 --- a/scripts/app-layout.ts +++ b/scripts/app-layout.ts @@ -54,6 +54,7 @@ export const runtimeModuleNames = [ 'startup-contract', 'startup-diagnostic', 't3-thread-titles', + 'window-bounds', 'workspace-state', 'workspace-store' ] as const diff --git a/src/annotation-block.ts b/src/annotation-block.ts index c6f61f43..afb00fdf 100644 --- a/src/annotation-block.ts +++ b/src/annotation-block.ts @@ -127,13 +127,16 @@ } const onEdit = options.onEdit if (onEdit) { + if (!options.renderEditIcon) { + throw new Error('Editable annotations require an edit icon renderer') + } body.classList.add('has-edit') const edit = document.createElement('button') edit.className = 'rendered-annotation-edit' edit.type = 'button' edit.title = 'Edit annotation' edit.setAttribute('aria-label', `Edit annotation on ${view.lineLabel}`) - edit.textContent = '✎' + edit.append(options.renderEditIcon()) edit.addEventListener('click', (event) => { event.stopPropagation() onEdit(options.node) @@ -221,6 +224,7 @@ onInlineImage: options.onInlineImage, onSelect: options.onSelect, onEdit: options.onEdit, + renderEditIcon: options.renderEditIcon, renderTitle: options.renderTitle, renderMarkdown: options.renderMarkdown }) diff --git a/src/contracts.ts b/src/contracts.ts index fc87d527..2d62b0a0 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -506,6 +506,7 @@ declare global { onInlineImage?: ((source: string, label: string) => void) | undefined onSelect?: ((node: TNode) => void) | undefined onEdit?: ((node: TNode) => void) | null | undefined + renderEditIcon?: (() => Element) | undefined renderTitle?: ((title: string) => string) | undefined renderMarkdown: (value: string) => string } diff --git a/src/index.html b/src/index.html index 0c2705cb..ff243cf9 100644 --- a/src/index.html +++ b/src/index.html @@ -273,14 +273,7 @@

Selected UI direction

- @@ -289,7 +282,7 @@

Selected UI direction

sample.md - Loading… +
- -
siblings @@ -466,7 +461,7 @@

Selected UI direction

aria-valuemin="360" tabindex="0" >
-
+
+ @@ -949,6 +945,23 @@

Resolve selected reviews?

+ + + diff --git a/src/lucide-esm.d.ts b/src/lucide-esm.d.ts index b04c63c6..7b52e52d 100644 --- a/src/lucide-esm.d.ts +++ b/src/lucide-esm.d.ts @@ -13,6 +13,7 @@ declare module 'lucide/dist/esm/lucide/src/lucide.js' { MessagesSquare, PanelLeft, PanelLeftClose, + PenLine, Server, createElement } from 'lucide' diff --git a/src/lucide-icons.ts b/src/lucide-icons.ts index 95fd9d99..e2949e38 100644 --- a/src/lucide-icons.ts +++ b/src/lucide-icons.ts @@ -12,6 +12,7 @@ import { MessagesSquare, PanelLeft, PanelLeftClose, + PenLine, Server, createElement as createLucideElement, type IconNode @@ -31,6 +32,7 @@ const icons = { 'messages-square': MessagesSquare, 'panel-left': PanelLeft, 'panel-left-close': PanelLeftClose, + 'pen-line': PenLine, server: Server } satisfies Record diff --git a/src/main.ts b/src/main.ts index 6b6e3292..2b43ef62 100644 --- a/src/main.ts +++ b/src/main.ts @@ -124,6 +124,10 @@ import { } from './service-endpoint' import { SettingsStore } from './settings-store' import { t3ThreadTitleSnapshot } from './t3-thread-titles' +import { + WindowBoundsStore, + clampWindowBounds +} from './window-bounds' import { WorkspaceStore } from './workspace-store' import { smokeReviewTree } from './smoke-fixture' import { @@ -279,6 +283,7 @@ let remoteGatewayQueue: Promise = Promise.resolve() let serviceRepairQueue: Promise = Promise.resolve() let settingsStore: SettingsStore | null = null let workspaceStore: WorkspaceStore | null = null +let windowBoundsStore: WindowBoundsStore | null = null let zoomWriter: Promise = Promise.resolve() let managedAutosave: ReviewAutosave | null = null let snapshotSequence = 0 @@ -1136,11 +1141,18 @@ function createWindow( const startupSettings = settingsEnvelope( settingsStore?.settings || DEFAULT_SETTINGS ) + const workAreaRect = screen.getPrimaryDisplay().workArea const workArea = screen.getPrimaryDisplay().workAreaSize const minimumSize = minimumWindowSize(startupSettings.zoomPercent, workArea) + const rememberedBounds = smokeMode || !windowBoundsStore?.bounds + ? null + : clampWindowBounds(windowBoundsStore.bounds, workAreaRect, minimumSize) const window = new BrowserWindow({ - width: Math.min(1180, workArea.width), - height: Math.min(760, workArea.height), + width: rememberedBounds?.width ?? Math.min(1180, workArea.width), + height: rememberedBounds?.height ?? Math.min(760, workArea.height), + ...(rememberedBounds + ? { x: rememberedBounds.x, y: rememberedBounds.y } + : {}), minWidth: minimumSize.width, minHeight: minimumSize.height, show: show && !showWithoutActivating, @@ -1158,6 +1170,32 @@ function createWindow( } }) mainWindow = window + if (rememberedBounds?.maximized) window.maximize() + let boundsWriteTimer: NodeJS.Timeout | null = null + const recordWindowBounds = (): void => { + if (smokeMode || !windowBoundsStore || window.isDestroyed()) return + if (window.isFullScreen() || window.isMinimized()) return + const normal = window.getNormalBounds() + windowBoundsStore.save({ + x: normal.x, + y: normal.y, + width: normal.width, + height: normal.height, + maximized: window.isMaximized() + }) + } + const scheduleWindowBoundsWrite = (): void => { + if (boundsWriteTimer) clearTimeout(boundsWriteTimer) + boundsWriteTimer = setTimeout(recordWindowBounds, 250) + } + window.on('resize', scheduleWindowBoundsWrite) + window.on('move', scheduleWindowBoundsWrite) + window.on('maximize', scheduleWindowBoundsWrite) + window.on('unmaximize', scheduleWindowBoundsWrite) + window.on('close', () => { + if (boundsWriteTimer) clearTimeout(boundsWriteTimer) + recordWindowBounds() + }) let currentDisplayId = screen.getDisplayMatching(window.getBounds()).id const applyCurrentWindowZoom = (): void => { if (window.isDestroyed()) return @@ -2312,6 +2350,11 @@ if (!hasSingleInstanceLock) { ) workspaceStore = privateWorkspaceStore await privateWorkspaceStore.load() + const boundsStore = new WindowBoundsStore( + path.join(app.getPath('userData'), 'window.json') + ) + windowBoundsStore = boundsStore + await boundsStore.load() managedAutosave = new ReviewAutosave(requireReviewStore(), { maximumDelayMs: initialSettings.autosaveMaximumDelayMs, onFailure(reviewId, error) { diff --git a/src/renderer.ts b/src/renderer.ts index e99e4794..15f8ccb5 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -68,7 +68,6 @@ interface RendererState { documentName: string documentPath: string | null finishAttachmentLabelEdit: ((commit?: boolean) => void) | null - hoveredId: string | null reviewId: string | null selectedId: string | null annotatedOnly: boolean @@ -140,7 +139,6 @@ const elements = { annotationViewList: requiredElement('#annotation-view-list'), annotationViewSelected: requiredElement('#annotation-view-selected'), attachmentList: requiredElement('#attachment-list'), - brandLogotype: requiredElement('#brand-logotype'), brandMark: requiredElement('#brand-mark'), checksum: requiredElement('#document-checksum'), copyTreeButton: requiredElement('#copy-tree-button'), @@ -170,9 +168,8 @@ const elements = { selectedAnnotationView: requiredElement('#selected-annotation-view'), selectedSource: requiredElement('#selected-source'), selectedTitle: requiredElement('#selected-title'), - scrollbarRowCover: requiredElement('#scrollbar-row-cover'), - hoverScrollbarRowCover: requiredElement('#hover-scrollbar-row-cover'), sourceCancel: requiredElement('#source-cancel'), + sourceCardState: requiredElement('#source-card-state'), sourceContent: requiredElement('#source-content'), sourceDiff: requiredElement('#source-diff'), sourceDiffStats: requiredElement('#source-diff-stats'), @@ -212,7 +209,6 @@ const elements = { reviewFilter: requiredElement('#review-filter'), reviewIdActivation: requiredElement('#review-id-activation'), reviewIdInput: requiredElement('#review-id-input'), - reviewListCount: requiredElement('#review-list-count'), reviewNavigationInbox: requiredElement('#review-navigation-inbox'), reviewNavigationProjects: requiredElement('#review-navigation-projects'), reviewResolutionCancel: requiredElement('#review-resolution-cancel'), @@ -250,7 +246,6 @@ const state: RendererState = { documentName: 'sample.md', documentPath: null, finishAttachmentLabelEdit: null, - hoveredId: null, reviewId: null, selectedId: null, annotatedOnly: false, @@ -777,6 +772,7 @@ function createRenderedAnnotation( onAttachment: options.mode === 'peek' ? null : openImagePreview, onEdit: options.onEdit, onSelect: options.onSelect, + renderEditIcon: () => markoverIcon('pen-line'), renderTitle: (title) => inlineMarkdown.renderInline(title), renderMarkdown: (feedback) => inlineMarkdown.render(feedback) }) @@ -988,7 +984,6 @@ function updatePinnedSelection(): void { if (!selectedRow || !selectedRow.getClientRects().length) { elements.pinnedSelection.hidden = true elements.pinnedSelection.replaceChildren() - updateScrollbarRowCover() return } @@ -997,7 +992,6 @@ function updatePinnedSelection(): void { elements.pinnedSelection.hidden = !shouldPin if (!shouldPin) { elements.pinnedSelection.replaceChildren() - updateScrollbarRowCover() return } @@ -1009,54 +1003,6 @@ function updatePinnedSelection(): void { button.tabIndex = -1 }) elements.pinnedSelection.replaceChildren(pinnedRow) - updateScrollbarRowCover() -} - -function positionScrollbarRowCover( - cover: HTMLElement, - row: HTMLElement | null, - hovered: boolean -): void { - if (!row || !row.getClientRects().length) { - cover.hidden = true - return - } - - const rowRect = row.getBoundingClientRect() - const treeRect = elements.tree.getBoundingClientRect() - if (rowRect.bottom <= treeRect.top || rowRect.top >= treeRect.bottom) { - cover.hidden = true - return - } - - const paneRect = elements.centerPane.getBoundingClientRect() - cover.className = [ - 'scrollbar-row-cover', - hovered ? 'is-hovered' : '', - row.querySelector('.block-content.code') ? 'is-code' : '' - ].filter(Boolean).join(' ') - cover.style.top = `${rowRect.top - paneRect.top}px` - cover.style.height = `${rowRect.height}px` - cover.hidden = false -} - -function updateScrollbarRowCover(): void { - const selectedRow = elements.tree.querySelector( - `[data-node-id="${state.selectedId}"]` - ) - const hoveredRow = state.hoveredId - ? elements.tree.querySelector(`[data-node-id="${state.hoveredId}"]`) - : null - positionScrollbarRowCover( - elements.scrollbarRowCover, - elements.pinnedSelection.hidden ? selectedRow : null, - false - ) - positionScrollbarRowCover( - elements.hoverScrollbarRowCover, - hoveredRow && hoveredRow !== selectedRow ? hoveredRow : null, - true - ) } function renderNode( @@ -1201,14 +1147,6 @@ function renderNode( row.addEventListener('click', () => { selectNode(node.id, true) }) - row.addEventListener('mouseenter', () => { - state.hoveredId = node.id - updateScrollbarRowCover() - }) - row.addEventListener('mouseleave', () => { - if (state.hoveredId === node.id) state.hoveredId = null - updateScrollbarRowCover() - }) row.addEventListener('dblclick', () => { if (!state.annotatedOnly && node.children.length) { if (!state.collapsedBlockIds.delete(node.id)) { @@ -1850,6 +1788,7 @@ function renderAnnotationList(): void { onInlineImage: openSourceImagePreview, onSelect: selectAnnotationFromList, onEdit: isCurrentReviewEditable() ? editAnnotationFromList : null, + renderEditIcon: () => markoverIcon('pen-line'), renderTitle: (title) => inlineMarkdown.renderInline(title), renderMarkdown: (feedback) => inlineMarkdown.render(feedback) }) @@ -1865,7 +1804,7 @@ function renderAnnotationList(): void { } } -function renderAnnotationPaneView(node: ReviewNode): void { +function renderAnnotationViewState(node: ReviewNode): void { const nodes = annotatedNodes() if (state.annotationView === 'list' && !nodes.length) { state.annotationView = 'selected' @@ -1936,7 +1875,7 @@ function renderAnnotation(node: ReviewNode): void { ? 'Annotated' : 'Not annotated' renderAttachmentList(node) - renderAnnotationPaneView(node) + renderAnnotationViewState(node) renderReviewEditability() updateAnnotationCount() } @@ -2310,6 +2249,10 @@ function renderActiveMetadataState( elements.sourceState.hidden = !stateLabel elements.sourceState.textContent = stateLabel || '' elements.sourceState.title = issues.join(' ') + const sourceCardState = row.sourceState === 'changed' ? 'Source changed' : null + elements.sourceCardState.hidden = sourceCardState === null + elements.sourceCardState.textContent = sourceCardState || '' + elements.sourceCardState.title = sourceCardState ? issues.join(' ') : '' elements.reviewContextButton.classList.toggle('has-metadata-error', issues.length > 0) elements.reviewContextButton.ariaLabel = issues.length ? `Show review context. ${issues.join(' ')}` @@ -2375,6 +2318,8 @@ function renderReviewContext(): void { if (!review) { elements.documentReviewId.textContent = '' elements.sourceState.hidden = true + elements.sourceCardState.hidden = true + elements.sourceCardState.textContent = '' elements.reviewContextIssues.hidden = true elements.reviewContextButton.classList.remove('has-metadata-error') closeReviewContext(false) @@ -2412,6 +2357,8 @@ function renderReviewContext(): void { elements.reviewContextIssues.hidden = true elements.reviewContextIssues.textContent = '' elements.sourceState.hidden = true + elements.sourceCardState.hidden = true + elements.sourceCardState.textContent = '' elements.reviewContextButton.classList.remove('has-metadata-error') elements.reviewContextButton.ariaLabel = 'Show review context' } @@ -3221,18 +3168,16 @@ function renderInboxReviews( ): DocumentFragment { if (filter !== 'all') { const fragment = document.createDocumentFragment() - const heading = document.createElement('div') - heading.className = 'review-list-section-heading' const label = filter === 'needs-me' ? 'Needs me' : filter === 'with-agent' ? 'With agent' : 'Completed' const rows = [...editing, ...history] - heading.innerHTML = `${label}${String(rows.length)} shown` - fragment.append(heading) const list = document.createElement('div') list.className = 'review-list-rows' + list.setAttribute('role', 'group') + list.setAttribute('aria-label', `${label} reviews`) list.append(...rows.map(createReviewListRow)) if (!rows.length) { list.append(createEmptyReviewMessage(`No ${label.toLowerCase()} reviews.`)) @@ -3485,8 +3430,14 @@ function setReviewNavigationMode( reviewNavigationMode = mode elements.reviewNavigationInbox.classList.toggle('is-active', mode === 'inbox') elements.reviewNavigationProjects.classList.toggle('is-active', mode === 'projects') - elements.reviewNavigationInbox.setAttribute('aria-pressed', String(mode === 'inbox')) - elements.reviewNavigationProjects.setAttribute('aria-pressed', String(mode === 'projects')) + elements.reviewNavigationInbox.setAttribute('aria-selected', String(mode === 'inbox')) + elements.reviewNavigationProjects.setAttribute('aria-selected', String(mode === 'projects')) + elements.reviewNavigationInbox.tabIndex = mode === 'inbox' ? 0 : -1 + elements.reviewNavigationProjects.tabIndex = mode === 'projects' ? 0 : -1 + elements.documentsListTree.setAttribute( + 'aria-labelledby', + mode === 'inbox' ? 'review-navigation-inbox' : 'review-navigation-projects' + ) renderDocumentsList() if (persist) { persistWorkspaceState() @@ -3555,12 +3506,12 @@ function renderDocumentsList(): void { renderReviewBatchActions() scheduleDocumentsListClockRefresh(sessions) elements.leftPane.hidden = !hasReviews + elements.leftPaneResizer.hidden = !hasReviews elements.leftPaneCollapse.hidden = sessions.length === 0 elements.leftPaneOpen.hidden = !hasReviews || !leftPaneCollapsed elements.paneLayout.classList.toggle('has-left-pane', hasReviews) elements.reviewTabStrip.hidden = sessions.length === 0 - elements.reviewInboxCount.textContent = String(projection.filterCounts['needs-me']) - elements.reviewInboxCount.hidden = projection.filterCounts['needs-me'] === 0 + elements.reviewInboxCount.textContent = `(${String(projection.filterCounts['needs-me'])})` const filterLabels: Record = { 'needs-me': 'Needs me', 'with-agent': 'With agent', @@ -3572,13 +3523,6 @@ function renderDocumentsList(): void { option.textContent = `${filterLabels[value]} (${String(projection.filterCounts[value])})` } elements.reviewFilter.value = reviewFilter - elements.reviewListCount.textContent = reviewNavigationMode === 'inbox' - ? `${projection.filterCounts[reviewFilter]} ${filterLabels[reviewFilter].toLowerCase()}${incompatibleReviews.length - ? ` · ${incompatibleReviews.length} incompatible` - : ''}` - : `${projection.projects.length} projects${incompatibleReviews.length - ? ` · ${incompatibleReviews.length} incompatible` - : ''}` applyLeftPaneWidth() applyRightPaneWidth() elements.documentsListTree.replaceChildren() @@ -3604,6 +3548,10 @@ function applyLeftPaneWidth(): void { '--left-pane-width', `${leftPaneWidth}px` ) + elements.appHeader.style.setProperty( + '--left-pane-column-width', + leftPaneCollapsed ? '0px' : `${leftPaneWidth}px` + ) elements.reviewTabStrip.style.setProperty( '--left-pane-width', leftPaneCollapsed ? '0px' : `${leftPaneWidth}px` @@ -3628,17 +3576,17 @@ function applyLeftPaneWidth(): void { function applyRightPaneWidth(): void { const currentWidth = rightPaneWidth ?? elements.rightPane.getBoundingClientRect().width - const leftPaneWidthForLayout = elements.leftPane.getBoundingClientRect().width + const leftWidth = elements.leftPane.getBoundingClientRect().width const paneLayoutWidth = elements.paneLayout.clientWidth || window.innerWidth const clampedWidth = MarkoverReviewSessions.clampRightPaneWidth( currentWidth, paneLayoutWidth, - leftPaneWidthForLayout + leftWidth ) const maximumWidth = MarkoverReviewSessions.clampRightPaneWidth( Number.POSITIVE_INFINITY, paneLayoutWidth, - leftPaneWidthForLayout + leftWidth ) if (rightPaneWidth !== null) { rightPaneWidth = clampedWidth @@ -3647,6 +3595,10 @@ function applyRightPaneWidth(): void { `${rightPaneWidth}px` ) } + elements.appHeader.style.setProperty( + '--right-pane-column-width', + `${clampedWidth}px` + ) elements.rightPaneResizer.setAttribute( 'aria-valuenow', String(Math.round(clampedWidth)) @@ -3723,12 +3675,7 @@ async function themeBrandAssets(): Promise { const primary = palette.getPropertyValue('--markover-primary').trim() const secondary = palette.getPropertyValue('--markover-secondary').trim() elements.brandMark.src = themedBrandSource( - brandAssetSources.mark, - primary, - secondary - ) - elements.brandLogotype.src = themedBrandSource( - brandAssetSources.logotype, + brandAssetSources.lockup, primary, secondary ) @@ -4256,7 +4203,6 @@ async function activateReview( state.sourceDrafts = session.sourceDrafts state.sourceEditingId = session.sourceEditingId state.attachmentPreviewUrls = session.attachmentPreviewUrls - state.hoveredId = null bridge.activateReview(reviewId) elements.name.textContent = session.documentName @@ -4779,6 +4725,36 @@ elements.reviewNavigationProjects.addEventListener('click', () => { selectedReviewIds.clear() setReviewNavigationMode('projects') }) +function moveReviewNavigationTabFromKeyboard(event: KeyboardEvent): void { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return + const currentMode = event.currentTarget === elements.reviewNavigationInbox + ? 'inbox' + : 'projects' + const targetMode = event.key === 'Home' + ? 'inbox' + : event.key === 'End' + ? 'projects' + : currentMode === 'inbox' + ? 'projects' + : 'inbox' + event.preventDefault() + selectedReviewIds.clear() + setReviewNavigationMode(targetMode) + requestAnimationFrame(() => { + const target = targetMode === 'inbox' + ? elements.reviewNavigationInbox + : elements.reviewNavigationProjects + target.focus() + }) +} +elements.reviewNavigationInbox.addEventListener( + 'keydown', + moveReviewNavigationTabFromKeyboard +) +elements.reviewNavigationProjects.addEventListener( + 'keydown', + moveReviewNavigationTabFromKeyboard +) elements.reviewFilter.addEventListener('change', () => { const value = elements.reviewFilter.value if ( @@ -5105,6 +5081,7 @@ async function rendererSmokeResult(): Promise<{ async function initialize(): Promise { const startupInfo = await bridge.getStartupInfo() startupUi.development(startupInfo.development) + installThemeTokenInspector(startupInfo) if (startupInfo.elementCallouts) { const callouts = installDevelopmentElementCallouts(document, { copyText: bridge.copyText, @@ -5337,3 +5314,189 @@ void initialize().catch(async (error: unknown) => { } console.error('Markover renderer startup failed', error) }) + +/* TEMPORARY theme-token inspector. Remove with its markup and styles. */ +function installThemeTokenInspector(startupInfo: StartupInfo): void { + interface TokenRow { readonly name: string } + type Group = readonly [string, readonly TokenRow[]] + + const t = (name: string): TokenRow => ({ name }) + + /* Theme tokens hold palette values; the inspector flags duplicate computed values. */ + const THEME: readonly Group[] = [ + ['Theme · brand', [t('--markover-primary'), t('--markover-secondary'), t('--brand-soft')]], + ['Theme · ground', [t('--paper'), t('--surface'), t('--neutral-soft')]], + ['Theme · ink', [t('--ink'), t('--muted'), t('--primary-contrast'), t('--secondary-contrast')]], + ['Theme · rules', [t('--line'), t('--hover-line'), t('--selection-line')]], + ['Theme · material', [t('--input'), t('--input-muted'), t('--thumbnail'), t('--code'), t('--shadow')]], + ['Theme · status', [t('--status-revised'), t('--status-progress'), t('--source-error')]] + ] + + /* Semantic tokens name roles and resolve through theme or semantic tokens. */ + const SEMANTIC: readonly Group[] = [ + ['Accent', [ + t('--brand-orange'), + t('--brand-burgundy'), + t('--accent'), + t('--accent-deep'), + t('--accent-soft'), + t('--focus') + ]], + ['Window', [ + t('--window-background') + ]], + ['App structure', [ + t('--app-shell-background'), + t('--app-header-background'), + t('--left-pane-background'), + t('--center-pane-background'), + t('--right-pane-background') + ]], + ['Components', [ + t('--review-navigation-bg'), + t('--review-navigation-active-bg'), + t('--document-tree-scrollbar-track-background'), + t('--selection-bridge-background'), + t('--keyboard-help-background'), + t('--theme-token-inspector-background'), + t('--theme-token-inspector-border-color'), + t('--theme-token-inspector-shadow-color'), + t('--theme-token-inspector-foreground'), + t('--theme-token-inspector-muted-foreground'), + t('--theme-token-inspector-control-background'), + t('--theme-token-inspector-duplicate-foreground') + ]], + ['Buttons', [ + t('--primary-button-bg'), + t('--primary-button-hover'), + t('--primary-button-text'), + t('--primary-button-hover-text') + ]], + ['Pane labels', [ + t('--pane-label-base'), + t('--pane-label-highlight'), + t('--pane-label-color'), + t('--pane-label-hover'), + t('--pane-label-inactive') + ]], + ['Status', [ + t('--status-editing'), + t('--status-pending'), + t('--status-done'), + t('--status-other'), + t('--status-outline') + ]] + ] + + const inspector = document.querySelector('#theme-token-inspector') + const close = document.querySelector('#theme-token-inspector-close') + const appHeaderBackground = document.querySelector('#theme-token-inspector-app-header-background') + const showDocumentChecksum = document.querySelector('#theme-token-inspector-show-document-checksum') + const list = document.querySelector('#theme-token-inspector-tokens') + + if ( + startupInfo.development && + inspector && + close && + appHeaderBackground && + showDocumentChecksum && + list + ) { + inspector.hidden = false + elements.checksum.hidden = !showDocumentChecksum.checked + const root = document.documentElement + const renderedRows: Array<{ name: string; value: HTMLElement; theme: boolean }> = [] + + const addGroups = (groups: readonly Group[], theme: boolean): void => { + for (const [group, tokens] of groups) { + const heading = document.createElement('div') + heading.className = 'theme-token-inspector-group' + heading.textContent = group + list.append(heading) + + for (const token of tokens) { + const row = document.createElement('div') + row.className = 'theme-token-inspector-token' + + const swatch = document.createElement('i') + swatch.className = 'theme-token-inspector-swatch' + swatch.style.background = `var(${token.name})` + + const label = document.createElement('code') + label.textContent = token.name + + const value = document.createElement('span') + renderedRows.push({ name: token.name, value, theme }) + + row.append(swatch, label, value) + list.append(row) + } + } + } + + addGroups(THEME, true) + addGroups(SEMANTIC, false) + + const defaultOption = document.createElement('option') + defaultOption.value = '' + defaultOption.textContent = '(theme default)' + appHeaderBackground.append(defaultOption) + for (const groups of [THEME, SEMANTIC]) { + for (const [, tokens] of groups) { + for (const token of tokens) { + if (token.name === '--app-header-background') continue + const option = document.createElement('option') + option.value = token.name + option.textContent = token.name + appHeaderBackground.append(option) + } + } + } + + /* Theme values must be unique; flag any literal that appears twice. */ + const refresh = (): void => { + const computed = getComputedStyle(root) + const seen = new Map() + for (const row of renderedRows) { + const value = computed.getPropertyValue(row.name).trim() + row.value.textContent = value + row.value.title = value + if (row.theme) { + const names = seen.get(value) || [] + names.push(row.name) + seen.set(value, names) + } + } + for (const row of renderedRows) { + if (!row.theme) continue + const names = seen.get(row.value.textContent || '') || [] + const duplicate = names.length > 1 + row.value.classList.toggle('is-duplicate', duplicate) + row.value.title = duplicate + ? `${row.value.textContent} · same colour as ${names.filter((n) => n !== row.name).join(', ')}` + : row.value.textContent || '' + } + } + + appHeaderBackground.addEventListener('change', () => { + if (appHeaderBackground.value) { + root.style.setProperty('--app-header-background', `var(${appHeaderBackground.value})`) + } else { + root.style.removeProperty('--app-header-background') + } + refresh() + }) + + showDocumentChecksum.addEventListener('change', () => { + elements.checksum.hidden = !showDocumentChecksum.checked + }) + + close.addEventListener('click', () => { inspector.hidden = true }) + + new MutationObserver(refresh).observe(root, { + attributeFilter: ['data-palette', 'data-appearance', 'data-colorization'] + }) + + refresh() + } +} diff --git a/src/styles.css b/src/styles.css index e82f76c8..5f9fdb91 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,7 +7,7 @@ --brand-soft: #f5e3da; --ink: #26211e; --muted: #6f6761; - --paper: #eee8e0; + --paper: #f7f4ee; --surface: #fffdf9; --line: #ddd5cc; --surface-rgb: 255 253 249; @@ -22,6 +22,9 @@ --neutral-soft: #ece9e2; --thumbnail: #e7e2d9; --shadow: rgb(38 33 30 / 18%); + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; + --font-serif: Georgia, "Times New Roman", serif; --annotation-font-size: 13px; --annotation-list-font-size: 8.5px; --accent: var(--brand-orange); @@ -30,19 +33,29 @@ --focus: var(--brand-orange); --code: #262b2b; --pane-header-height: 40px; - --window-background: var(--paper); - --app-shell-background: var(--paper); + --tree-gutter: 15px; + --window-background: var(--hover-line); + --app-shell-background: var(--hover-line); --app-header-background: var(--app-shell-background); - --review-navigation-bg: rgb(var(--surface-rgb) / 55%); + --review-navigation-bg: var(--paper); --review-navigation-active-bg: var(--surface); - --left-pane-background: rgb(var(--surface-rgb) / 42%); + --left-pane-background: var(--neutral-soft); --center-pane-background: var(--paper); - --right-pane-background: var(--accent-soft); + --right-pane-background: var(--neutral-soft); + --document-tree-scrollbar-track-background: var(--right-pane-background); + --selection-bridge-background: var(--right-pane-background); --keyboard-help-background: color-mix( in srgb, var(--app-shell-background) 92%, transparent ); + --theme-token-inspector-background: var(--surface); + --theme-token-inspector-border-color: var(--line); + --theme-token-inspector-shadow-color: var(--shadow); + --theme-token-inspector-foreground: var(--ink); + --theme-token-inspector-muted-foreground: var(--muted); + --theme-token-inspector-control-background: var(--input); + --theme-token-inspector-duplicate-foreground: var(--source-error); --primary-button-bg: var(--markover-primary); --primary-button-hover: var(--markover-secondary); --primary-button-text: var(--primary-contrast); @@ -131,11 +144,11 @@ --window-background: #dde1d2; --app-shell-background: #dde1d2; --app-header-background: var(--app-shell-background); - --review-navigation-bg: #e8eadf; + --review-navigation-bg: var(--paper); --review-navigation-active-bg: #ebece4; - --left-pane-background: #f4f5ee; - --center-pane-background: #fff; - --right-pane-background: #f8faeb; + --left-pane-background: var(--neutral-soft); + --center-pane-background: var(--paper); + --right-pane-background: var(--neutral-soft); --primary-button-bg: var(--markover-secondary); --primary-button-hover: var(--markover-primary); --primary-button-text: var(--secondary-contrast); @@ -226,7 +239,7 @@ gap: 10px; color: var(--ink); background: var(--paper); - font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: var(--font-sans); } .startup-screen[hidden] { @@ -431,18 +444,20 @@ body, overflow: hidden; } -body { - color: var(--ink); - background: var(--window-background); - font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - .app-shell { display: flex; flex-direction: column; background: var(--app-shell-background); } +body { + color: var(--ink); + background: var(--window-background); + font-family: var(--font-sans); + font-optical-sizing: auto; + -webkit-font-smoothing: antialiased; +} + button, textarea { font: inherit; @@ -458,8 +473,9 @@ textarea { } .app-header-bar { - min-height: 84px; - padding: 18px 22px 14px; + position: relative; + min-height: 67px; + padding: 10px 22px 8px 12px; display: flex; align-items: center; justify-content: space-between; @@ -495,16 +511,8 @@ textarea { .brand-mark { display: block; - width: 40px; - height: 34px; - pointer-events: none; - opacity: 0; -} - -.brand-logotype { - display: block; - width: 112px; - height: 19px; + width: calc(161px * var(--brand-scale, 0.82)); + height: auto; pointer-events: none; opacity: 0; } @@ -517,7 +525,7 @@ textarea { border-radius: 8px; background: color-mix(in srgb, var(--surface) 84%, var(--paper)); color: var(--ink); - font: 600 17px/1 ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; + font: 600 17px/1 var(--font-sans); letter-spacing: -0.02em; white-space: nowrap; } @@ -526,7 +534,7 @@ textarea { opacity: 0; } -.is-brand-ready :is(.brand-mark, .brand-logotype, .app-empty-state-lockup) { +.is-brand-ready :is(.brand-mark, .app-empty-state-lockup) { opacity: 1; } @@ -586,12 +594,15 @@ p { } .document-meta { + position: absolute; + right: var(--right-pane-column-width, 360px); + left: var(--left-pane-column-width, 0px); display: flex; min-width: 0; - flex: 1 1 auto; flex-direction: column; - align-items: flex-end; - margin-right: 8px; + align-items: center; + padding-inline: 24px; + text-align: center; } .document-meta strong { @@ -607,15 +618,15 @@ p { overflow: hidden; margin-top: 3px; color: var(--muted); - font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10px/1.2 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } .document-meta .document-source-state { color: var(--source-error); - font-family: ui-sans-serif, sans-serif; - font-weight: 750; + font-family: var(--font-sans); + font-weight: 600; } .review-context-button { @@ -629,7 +640,7 @@ p { color: var(--muted); background: rgb(var(--surface-rgb) / 76%); cursor: pointer; - font: 800 12px/1 ui-serif, Georgia, serif; + font: 600 12px/1 var(--font-sans); } .review-context-button:hover, @@ -651,7 +662,7 @@ p { border-radius: 9px; cursor: pointer; font-size: 12px; - font-weight: 700; + font-weight: 600; transition: transform 100ms ease, background 100ms ease; } @@ -681,69 +692,65 @@ p { .review-tab-strip { --left-pane-width: 390px; - display: grid; + position: relative; + display: none; min-height: 30px; grid-template-columns: var(--left-pane-width) minmax(0, 1fr); - border-top: 1px solid var(--line); - background: var(--review-navigation-bg); } .review-tab-strip.is-left-pane-collapsed { grid-template-columns: 0 minmax(0, 1fr); } -.review-navigation-tabs { - display: grid; - min-width: 0; - grid-template-columns: 1fr 1fr; - overflow: hidden; - border-right: 1px solid var(--line); -} - -.review-navigation-tab { +.review-navigation-bar { + position: relative; display: flex; min-width: 0; - height: 30px; + min-height: 28px; + flex: none; align-items: center; - justify-content: center; - gap: 7px; - border: 0; - color: var(--muted); - background: transparent; - cursor: pointer; - font-size: 10px; - font-weight: 750; } -.review-navigation-tab + .review-navigation-tab { - border-left: 1px solid var(--line); +.review-navigation-bar::before { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 1px; + background: var(--line); + content: ""; } -.review-navigation-tab:hover { - color: var(--ink); - background: rgb(var(--surface-rgb) / 74%); +.left-pane:focus-within > .review-navigation-bar::before { + height: 4px; + background: var(--pane-label-color); } -.review-navigation-tab.is-active { - color: var(--accent); - background: var(--review-navigation-active-bg); +.review-navigation-tabs { + min-width: 0; + flex: 1 1 auto; + gap: 6px; + padding: 0 6px 0 8px; + overflow: hidden; +} + +.review-navigation-tabs::before { + display: none; +} + +.review-navigation-bar > .left-pane-disclosure { + position: relative; + z-index: 1; + flex: 0 0 24px; + margin-right: 4px; } .review-inbox-count { - display: inline-grid; - min-width: 18px; - height: 18px; - place-items: center; - padding: 0 5px; - border-radius: 999px; - color: var(--primary-contrast); - background: var(--accent-deep); - font-size: 9px; - font-weight: 800; + color: inherit; } .review-id-activation { - display: flex; + display: none; min-height: 30px; padding: 3px 14px; align-items: center; @@ -760,7 +767,7 @@ p { border-radius: 6px; color: var(--ink); background: rgb(var(--surface-rgb) / 72%); - font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10px/1 var(--font-mono); } .review-id-activation button { @@ -772,7 +779,7 @@ p { background: rgb(var(--surface-rgb) / 72%); cursor: pointer; font-size: 9px; - font-weight: 750; + font-weight: 600; } .review-id-activation button:hover { @@ -851,20 +858,15 @@ p { height: var(--pane-header-height); min-height: var(--pane-header-height); align-items: center; - justify-content: flex-end; + justify-content: center; padding: 0 5px; color: var(--pane-label-color); font-size: 9px; - font-weight: 800; + font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; } -.documents-list-header .pane-header-leading { - min-width: 0; - margin-right: auto; -} - .review-filter { max-width: 116px; height: 24px; @@ -872,9 +874,9 @@ p { padding: 0 22px 0 7px; border: 1px solid var(--line); border-radius: 6px; - color: var(--ink); + color: var(--muted); background-color: rgb(var(--surface-rgb) / 78%); - font: 700 9px/1 ui-sans-serif, system-ui, sans-serif; + font: 600 9px/1 var(--font-sans); letter-spacing: 0; text-transform: none; } @@ -904,7 +906,7 @@ p { margin-right: auto; color: var(--accent-deep); font-size: 9px; - font-weight: 800; + font-weight: 600; } .review-batch-actions button { @@ -916,7 +918,7 @@ p { background: var(--surface); cursor: pointer; font-size: 8px; - font-weight: 750; + font-weight: 600; } .review-batch-actions button:disabled { @@ -961,7 +963,7 @@ p { background: var(--surface); cursor: pointer; font-size: 8px; - font-weight: 750; + font-weight: 600; } .review-project-leaf > .review-return-needs-me { @@ -970,27 +972,6 @@ p { bottom: auto; } -.review-list-count { - overflow: hidden; - color: var(--muted); - font-size: 8px; - font-weight: 700; - letter-spacing: 0; - text-overflow: ellipsis; - text-transform: none; - white-space: nowrap; -} - -.documents-list-header::before { - position: absolute; - top: -1px; - right: 0; - left: 0; - height: 1px; - background: var(--line); - content: ""; -} - .left-pane-disclosure { display: grid; width: 24px; @@ -1002,7 +983,7 @@ p { color: var(--muted); background: transparent; cursor: pointer; - font: 700 18px/1 ui-sans-serif, sans-serif; + font: 600 18px/1 var(--font-sans); } .left-pane-disclosure:hover { @@ -1130,7 +1111,7 @@ p { background: var(--surface); cursor: pointer; font: inherit; - font-weight: 700; + font-weight: 600; } .review-project-icon, @@ -1141,7 +1122,7 @@ p { border: 1px solid var(--line); color: var(--muted); background: var(--surface); - font-weight: 800; + font-weight: 600; } .review-project-icon { @@ -1278,7 +1259,7 @@ p { color: var(--muted); background: rgb(var(--surface-rgb) / 76%); font-size: 9px; - font-weight: 750; + font-weight: 600; line-height: 1.15; } @@ -1300,7 +1281,7 @@ p { .review-list-row-pr:is(.is-linked, .is-open) { color: #2e7d4f; - font-weight: 750; + font-weight: 600; } button.review-list-row-pr { @@ -1325,17 +1306,17 @@ button.review-list-row-pr:hover { .review-list-row-pr.is-draft { color: #a66f0f; - font-weight: 750; + font-weight: 600; } .review-list-row-pr.is-merged { color: #7652a8; - font-weight: 750; + font-weight: 600; } .review-list-row-pr.is-closed { color: var(--source-error); - font-weight: 750; + font-weight: 600; } .review-list-row-title { @@ -1344,7 +1325,7 @@ button.review-list-row-pr:hover { align-items: center; gap: 6px; font-size: 12px; - font-weight: 750; + font-weight: 600; } .review-list-empty { @@ -1408,7 +1389,7 @@ button.review-list-row-pr:hover { .review-history-group > summary > span:first-child { color: var(--ink); - font-weight: 750; + font-weight: 600; } .review-history-group small { @@ -1427,7 +1408,7 @@ button.review-list-row-pr:hover { background: transparent; cursor: pointer; font-size: 9px; - font-weight: 750; + font-weight: 600; } .review-list-more:hover { @@ -1478,7 +1459,7 @@ button.review-list-row-pr:hover { color: var(--primary-contrast); background: var(--accent-deep); font-size: 8px; - font-weight: 800; + font-weight: 600; text-transform: uppercase; } @@ -1675,8 +1656,9 @@ button.review-list-row-pr:hover { .pane-header::before { position: absolute; - top: -1px; - right: 0; + z-index: 1; + top: 0; + right: -1px; left: 0; height: 1px; background: var(--line); @@ -1686,7 +1668,7 @@ button.review-list-row-pr:hover { .pane:focus > .pane-header::before, .pane:focus-within > .pane-header::before, .pane.focus-within > .pane-header::before { - top: -1px; + top: 0; height: 4px; background: var(--pane-label-color); } @@ -1697,7 +1679,7 @@ button.review-list-row-pr:hover { left: 22px; color: var(--pane-label-color); font-size: 9px; - font-weight: 800; + font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; } @@ -1707,7 +1689,7 @@ button.review-list-row-pr:hover { top: 50%; left: 50%; margin: 0; - font: 700 17px/1 Georgia, "Times New Roman", serif; + font: 600 17px/1 var(--font-sans); transform: translate(-50%, -35%); } @@ -1719,7 +1701,7 @@ button.review-list-row-pr:hover { color: var(--muted); background: transparent; cursor: copy; - font: 9px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 9px/1.2 var(--font-mono); white-space: nowrap; } @@ -1744,7 +1726,7 @@ button.review-list-row-pr:hover { color: var(--muted); background: rgb(var(--surface-rgb) / 72%); font-size: 10px; - font-weight: 700; + font-weight: 600; transform: translateY(-50%); } @@ -1787,6 +1769,11 @@ button.review-list-row-pr:hover { gap: 7px; } +.center-pane > .pane-header::before { + right: var(--tree-gutter); + left: -1px; +} + .center-pane > .pane-header { display: grid; grid-template-columns: auto minmax(max-content, 1fr) auto; @@ -1829,7 +1816,7 @@ button.review-list-row-pr:hover { background: transparent; cursor: pointer; font-size: 9px; - font-weight: 750; + font-weight: 600; } .document-tree-view-toggle button:last-child { @@ -1852,7 +1839,6 @@ button.review-list-row-pr:hover { display: flex; grid-column: 2; flex-direction: column; - border-right: 1px solid var(--selection-line); background: var(--center-pane-background); } @@ -1863,21 +1849,72 @@ button.review-list-row-pr:hover { overflow-anchor: none; padding: 15px 0 58px; scroll-behavior: smooth; + + /* The divider, painted under the rows rather than on the scrollbar track, + so a selected row's fill breaks it and the bridge reaches the pane. */ + background-image: linear-gradient( + var(--selection-line), + var(--selection-line) + ); + background-repeat: no-repeat; + background-size: 1px 100%; + background-position: right var(--tree-gutter) top 0; +} + +.tree::-webkit-scrollbar { + width: 15px; +} + +.tree::-webkit-scrollbar-track { + background: var(--document-tree-scrollbar-track-background); +} + +.tree::-webkit-scrollbar-thumb { + border: 4px solid transparent; + border-radius: 999px; + background: rgb(var(--ink-rgb) / 22%); + background-clip: content-box; +} + +.tree::-webkit-scrollbar-thumb:hover { + background: rgb(var(--ink-rgb) / 36%); + background-clip: content-box; +} + +/* Carries the track band and its hairline up through the pane header, + which has no scrollbar. Delete for the track-only version. */ +.pane-layout:has(.right-pane:focus-within) .center-pane > .pane-header::after, +.pane-layout:has(.right-pane.focus-within) .center-pane > .pane-header::after { + border-top: 4px solid var(--pane-label-color); +} + +.center-pane > .pane-header::after { + position: absolute; + z-index: 0; + top: 0; + right: 0; + bottom: 0; + width: var(--tree-gutter); + border-top: 1px solid var(--line); + border-left: 1px solid var(--selection-line); + background: var(--document-tree-scrollbar-track-background); + content: ""; } .pinned-selection { position: absolute; top: calc(var(--pane-header-height) - 1px); - right: -1px; + right: var(--tree-gutter); left: 0; z-index: 5; + clip-path: inset(0 0 -32px 0); pointer-events: none; } .pinned-selection .block-row { margin-top: 0; margin-bottom: 0; - background: var(--accent-soft); + background: #fff; } .pinned-selection::after { @@ -1891,41 +1928,6 @@ button.review-list-row-pr:hover { transform: translateX(-50%); } -.scrollbar-row-cover { - position: absolute; - right: 1px; - z-index: 6; - width: 15px; - border-top: 1px solid var(--selection-line); - border-bottom: 1px solid var(--selection-line); - background: var(--accent-soft); - pointer-events: none; -} - -.scrollbar-row-cover.is-hovered { - border-color: var(--hover-line); - background: rgb(var(--surface-rgb) / 96%); -} - -.scrollbar-row-cover:not(.is-hovered) { - right: -1px; - width: 17px; -} - -.scrollbar-row-cover.is-code { - border-color: var(--accent); - background: - linear-gradient(rgb(var(--accent-rgb) / 20%), rgb(var(--accent-rgb) / 20%)), - var(--code); -} - -.scrollbar-row-cover.is-code.is-hovered { - border-color: var(--hover-line); - background: - linear-gradient(rgb(255 255 255 / 11%), rgb(255 255 255 / 11%)), - var(--code); -} - .block { position: relative; } @@ -1959,6 +1961,7 @@ button.review-list-row-pr:hover { border-top: 1px solid transparent; border-bottom: 1px solid transparent; border-radius: 0; + box-shadow: inset -1px 0 0 var(--selection-line); cursor: default; } @@ -1969,12 +1972,27 @@ button.review-list-row-pr:hover { .block-row.is-selected { border-color: var(--selection-line); - background: var(--accent-soft); + background: #fff; box-shadow: none; } +.block-row.is-selected::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: var(--selection-bridge-width, 40px); + background: linear-gradient( + to right, + transparent, + var(--selection-bridge-background) var(--selection-bridge-stop, 96%) + ); + content: ""; + pointer-events: none; +} + .pinned-selection .block-row.is-selected { - box-shadow: 0 10px 14px -9px rgb(38 33 30 / 72%); + box-shadow: 0 7px 11px -7px rgb(38 33 30 / 60%); } .disclosure { @@ -2014,7 +2032,7 @@ button.review-list-row-pr:hover { border-radius: 5px; color: var(--muted); background: rgb(var(--ink-rgb) / 8%); - font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 600 9px/1 var(--font-mono); } .block-content { @@ -2023,7 +2041,8 @@ button.review-list-row-pr:hover { } .block-content.heading { - font-family: Georgia, "Times New Roman", serif; + font-family: var(--font-sans); + letter-spacing: -0.01em; font-weight: 700; } @@ -2101,7 +2120,7 @@ button.review-list-row-pr:hover { display: block; overflow: hidden; color: #f0ede5; - font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 11px/1.5 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } @@ -2111,16 +2130,16 @@ button.review-list-row-pr:hover { border-radius: 4px; color: var(--accent-deep); background: rgb(var(--accent-rgb) / 12%); - font: 0.88em/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 0.88em/1.4 var(--font-mono); } .block-content.frontmatter { font-size: 13px; - font-weight: 750; + font-weight: 600; } .block-content.frontmatter-entry { - font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 12px/1.5 var(--font-mono); white-space: pre-wrap; } @@ -2143,7 +2162,7 @@ button.review-list-row-pr:hover { } .pinned-selection .block-row.is-selected:has(.block-content.code) { - box-shadow: 0 10px 14px -9px rgb(0 0 0 / 85%); + box-shadow: 0 7px 11px -7px rgb(0 0 0 / 75%); } .annotation-dot { @@ -2191,7 +2210,7 @@ button.review-list-row-pr:hover { overflow: hidden; margin-bottom: 8px; font-size: 12px; - font-weight: 750; + font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; @@ -2220,7 +2239,7 @@ button.review-list-row-pr:hover { .review-hover-entry-label { color: inherit; - font-weight: 750; + font-weight: 600; } .review-hover-entry > span:last-child { @@ -2274,7 +2293,7 @@ button.review-list-row-pr:hover { color: var(--ink); background: var(--surface); box-shadow: 0 12px 28px var(--shadow); - font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10px/1.45 var(--font-mono); pointer-events: none; white-space: pre-wrap; } @@ -2306,7 +2325,7 @@ button.review-list-row-pr:hover { overflow: hidden; color: var(--brand-orange); font-size: 14px; - font-weight: 700; + font-weight: 600; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; @@ -2328,6 +2347,7 @@ button.review-list-row-pr:hover { .rendered-annotation-body.has-edit { padding-right: 44px; + min-height: 43px; } .rendered-annotation-content.is-empty { @@ -2357,7 +2377,7 @@ button.review-list-row-pr:hover { border-radius: 6px; color: #f8f4ef; background: var(--code); - font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10px/1.45 var(--font-mono); } .rendered-annotation-content code:not(pre code) { @@ -2376,7 +2396,7 @@ button.review-list-row-pr:hover { height: 1.1em; color: var(--brand-orange); font-size: 15px; - font-weight: 800; + font-weight: 600; line-height: 1.1; } @@ -2450,7 +2470,7 @@ button.rendered-annotation-attachment { .attachment-indicator { margin-top: 4px; color: var(--accent); - font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 11px/1 var(--font-mono); text-align: center; } @@ -2503,7 +2523,7 @@ kbd { border-radius: 4px; color: var(--ink); background: var(--input); - font: 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 9px/1 var(--font-mono); text-align: center; } @@ -2554,11 +2574,7 @@ kbd { user-select: none; } -.right-pane.is-read-only { - background: var(--neutral-soft); -} - -.annotation-view-tabs { +.pane-view-tabs { position: relative; display: flex; min-height: 28px; @@ -2568,9 +2584,9 @@ kbd { gap: 18px; } -.annotation-view-tabs::before { +.pane-view-tabs::before { position: absolute; - top: -1px; + top: 0; right: 0; left: 0; height: 1px; @@ -2585,7 +2601,7 @@ kbd { background: var(--pane-label-color); } -.annotation-view-tabs button { +.pane-view-tabs > button { position: relative; min-width: 0; min-height: 28px; @@ -2597,21 +2613,21 @@ kbd { background: transparent; cursor: pointer; font-size: 9px; - font-weight: 800; + font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; } -.annotation-view-tabs button:hover:not(.is-active):not(:disabled) { +.pane-view-tabs > button:hover:not(.is-active):not(:disabled) { color: var(--pane-label-hover); } -.annotation-view-tabs button.is-active { +.pane-view-tabs > button.is-active { color: var(--pane-label-color); } -.annotation-view-tabs button.is-active::after { +.pane-view-tabs > button.is-active::after { position: absolute; right: 0; bottom: 2px; @@ -2621,7 +2637,7 @@ kbd { content: ""; } -.annotation-view-tabs button:disabled { +.pane-view-tabs > button:disabled { cursor: default; opacity: 0.45; } @@ -2645,7 +2661,7 @@ kbd { overflow-x: hidden; overflow-y: auto; outline: none; - background: rgb(var(--surface-rgb) / 35%); + background: var(--right-pane-background); } .annotation-list { @@ -2701,19 +2717,24 @@ kbd { place-items: center; padding: 0; flex: none; - border: 1px solid var(--primary-button-bg); + border: 1px solid var(--line); border-radius: 5px; - color: var(--primary-button-text); - background: var(--primary-button-bg); + color: var(--muted); + background: rgb(var(--surface-rgb) / 72%); cursor: pointer; font-size: 13px; } .rendered-annotation-edit:hover, .rendered-annotation-edit:active { - border-color: var(--primary-button-hover); - color: var(--primary-button-hover-text); - background: var(--primary-button-hover); + border-color: var(--accent); + color: var(--accent-deep); + background: var(--accent-soft); +} + +.rendered-annotation-edit .lucide-icon { + width: 16px; + height: 16px; } .annotation-list-empty { @@ -2731,7 +2752,7 @@ kbd { color: var(--muted); background: rgb(var(--ink-rgb) / 7%); font-size: 10px; - font-weight: 850; + font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; } @@ -2780,7 +2801,7 @@ kbd { background: transparent; cursor: pointer; font-size: 10px; - font-weight: 800; + font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; } @@ -2809,6 +2830,13 @@ kbd { padding-right: 6px; } +.source-card-state { + color: var(--source-error); + font-size: 9px; + font-weight: 600; + white-space: nowrap; +} + .source-action { min-height: 24px; padding: 3px 6px; @@ -2818,7 +2846,7 @@ kbd { background: rgb(var(--surface-rgb) / 72%); cursor: pointer; font-size: 9px; - font-weight: 750; + font-weight: 600; text-transform: uppercase; } @@ -2835,7 +2863,7 @@ kbd { .source-diff-stats, .source-edit-summary { - font: 750 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 600 9px/1 var(--font-mono); white-space: nowrap; } @@ -2863,7 +2891,7 @@ kbd { padding: 9px 10px 10px; color: var(--muted); background: rgb(var(--surface-rgb) / 72%); - font: 9.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 9.5px/1.45 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; } @@ -2888,7 +2916,7 @@ kbd { outline: none; color: var(--ink); background: var(--input); - font: 10.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10.5px/1.45 var(--font-mono); } .source-editor:focus { @@ -2933,7 +2961,7 @@ kbd { justify-content: space-between; margin: 10px 22px 6px; font-size: 12px; - font-weight: 800; + font-weight: 600; } .annotation-label span { @@ -3014,7 +3042,7 @@ kbd { border-radius: 6px; color: #f1eee9; background: var(--code); - font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10px/1.45 var(--font-mono); } .attachment-list { @@ -3144,7 +3172,7 @@ body.is-control-pressed .attachment-remove:hover { .annotation-footer span { flex: none; - font-weight: 800; + font-weight: 600; } .toast { @@ -3157,7 +3185,7 @@ body.is-control-pressed .attachment-remove:hover { color: var(--paper); background: var(--ink); font-size: 11px; - font-weight: 700; + font-weight: 600; opacity: 0; pointer-events: none; transform: translate(-50%, 8px); @@ -3195,7 +3223,7 @@ body.is-control-pressed .attachment-remove:hover { background: var(--surface); box-shadow: 0 12px 36px rgb(15 12 10 / 24%); font-size: 11px; - font-weight: 700; + font-weight: 600; } .incoming-review-notice[hidden] { @@ -3217,7 +3245,7 @@ body.is-control-pressed .attachment-remove:hover { color: var(--primary-contrast); background: var(--accent); cursor: pointer; - font: 800 10px/1 ui-sans-serif, -apple-system, sans-serif; + font: 600 10px/1 var(--font-sans); } .image-preview { @@ -3272,7 +3300,7 @@ body.is-control-pressed .attachment-remove:hover { color: white; background: rgb(20 18 16 / 68%); box-shadow: 0 2px 12px rgb(0 0 0 / 22%); - font: 700 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 600 11px/1.2 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } @@ -3389,7 +3417,7 @@ body.is-control-pressed .attachment-remove:hover { gap: 7px; padding-right: 16px; color: var(--muted); - font-weight: 750; + font-weight: 600; } .review-context-field-icon { @@ -3400,7 +3428,7 @@ body.is-control-pressed .attachment-remove:hover { .review-context-fields dd { overflow-wrap: anywhere; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .review-context-fields dd.is-error, @@ -3413,7 +3441,7 @@ body.is-control-pressed .attachment-remove:hover { padding-top: 12px; border-top: 1px solid currentcolor; font-size: 11px; - font-weight: 700; + font-weight: 600; line-height: 1.5; } @@ -3436,7 +3464,7 @@ body.is-control-pressed .attachment-remove:hover { color: var(--muted); background: var(--surface); cursor: pointer; - font: 700 9px/1.2 ui-sans-serif, sans-serif; + font: 600 9px/1.2 var(--font-sans); } .review-context-copy-value button:hover { @@ -3501,7 +3529,7 @@ body.is-control-pressed .attachment-remove:hover { .settings-header h2 { margin-top: 3px; - font: 700 23px/1.1 Georgia, "Times New Roman", serif; + font: 700 23px/1.1 var(--font-sans); } .settings-close { @@ -3579,7 +3607,7 @@ body.is-control-pressed .attachment-remove:hover { border-radius: 7px; color: var(--ink); background: var(--input); - font: 700 11px/1 ui-sans-serif, -apple-system, sans-serif; + font: 500 11px/1 var(--font-sans); } .settings-field input:is([type="number"], [type="text"]) { @@ -3590,7 +3618,7 @@ body.is-control-pressed .attachment-remove:hover { border-radius: 7px; color: var(--ink); background: var(--input); - font: 700 11px/1 ui-sans-serif, -apple-system, sans-serif; + font: 500 11px/1 var(--font-sans); } .settings-integration-status > span { @@ -3613,7 +3641,7 @@ body.is-control-pressed .attachment-remove:hover { width: 100%; min-height: 92px; resize: vertical; - font: 500 11px/1.45 ui-sans-serif, -apple-system, sans-serif; + font: 500 11px/1.45 var(--font-sans); } .settings-policy-link { @@ -3624,7 +3652,7 @@ body.is-control-pressed .attachment-remove:hover { color: var(--focus); background: transparent; cursor: pointer; - font: 700 10px/1.35 ui-sans-serif, -apple-system, sans-serif; + font: 600 10px/1.35 var(--font-sans); text-decoration: underline; text-underline-offset: 2px; } @@ -3809,10 +3837,6 @@ body.is-control-pressed .attachment-remove:hover { min-width: 40px; } - .brand-logotype { - display: none; - } - .instance-badge { margin-left: 4px; } @@ -3910,7 +3934,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { color: var(--muted); background: rgb(var(--surface-rgb) / 72%); font-size: 9px; - font-weight: 750; + font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; white-space: nowrap; @@ -3938,7 +3962,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { .inbox-prototype-document-meta span { color: var(--muted); - font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10px/1.2 var(--font-mono); } .inbox-prototype-status, @@ -3948,7 +3972,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { color: var(--primary-button-text); background: var(--primary-button-bg); font-size: 9px; - font-weight: 800; + font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; white-space: nowrap; @@ -3981,7 +4005,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { color: var(--muted); cursor: pointer; font-size: 10px; - font-weight: 750; + font-weight: 600; } .inbox-prototype-navigation-tabs label:last-child { @@ -4034,7 +4058,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { border-radius: 6px; color: var(--ink); background: rgb(var(--surface-rgb) / 72%); - font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 10px/1 var(--font-mono); } .inbox-prototype-review-id-activation button { @@ -4044,7 +4068,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { color: var(--muted); background: var(--surface); font-size: 9px; - font-weight: 750; + font-weight: 600; } .inbox-prototype-review-id { @@ -4175,7 +4199,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { color: var(--accent-deep); background: var(--surface); font-size: 11px; - font-weight: 850; + font-weight: 600; } .inbox-prototype-project-icon img { @@ -4250,7 +4274,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { .inbox-prototype-thread-title strong { font-size: 11px; - font-weight: 750; + font-weight: 600; } .inbox-prototype-thread-title > i, @@ -4266,7 +4290,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { background: var(--surface); font-size: 8px; font-style: normal; - font-weight: 850; + font-weight: 600; } .inbox-prototype-thread-title > i.is-claude, @@ -4344,7 +4368,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { background: transparent; cursor: pointer; font-size: 9px; - font-weight: 750; + font-weight: 600; } .inbox-prototype-project-tree { @@ -4467,7 +4491,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { min-height: 0; padding: 18px 24px 48px; overflow-y: auto; - font: 14px/1.5 ui-serif, Georgia, serif; + font: 14px/1.5 var(--font-serif); } .inbox-prototype-document-body h2 { @@ -4503,7 +4527,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { border-radius: 6px; color: var(--muted); background: var(--neutral-soft); - font: 10px/1 ui-sans-serif, sans-serif; + font: 10px/1 var(--font-sans); } .inbox-prototype-block.is-selected { @@ -4552,7 +4576,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { .inbox-prototype-annotation-body section code { color: var(--ink); - font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + font: 11px/1.5 var(--font-mono); white-space: normal; } @@ -4561,7 +4585,7 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { margin: 0 1px 7px; justify-content: space-between; font-size: 11px; - font-weight: 750; + font-weight: 600; } .inbox-prototype-annotation-body label span { @@ -4611,3 +4635,140 @@ html[data-inbox-prototype="true"] #inbox-prototype[hidden] { display: none; } } + +/* TEMPORARY theme-token inspector. Remove with its markup and wiring. */ +.theme-token-inspector { + position: fixed; + bottom: 18px; + left: 18px; + z-index: 300; + display: flex; + width: 292px; + height: min(560px, calc(100vh - 40px)); + min-width: 210px; + min-height: 130px; + max-width: calc(100vw - 36px); + max-height: calc(100vh - 36px); + overflow: hidden; + resize: both; + flex-direction: column; + gap: 10px; + padding: 12px 14px 13px; + border: 1px solid var(--theme-token-inspector-border-color); + border-radius: 10px; + color: var(--theme-token-inspector-foreground); + background: var(--theme-token-inspector-background); + box-shadow: 0 14px 34px var(--theme-token-inspector-shadow-color); +} + +.theme-token-inspector header { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + color: var(--theme-token-inspector-muted-foreground); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.theme-token-inspector header button { + padding: 0 4px; + border: 0; + color: var(--theme-token-inspector-muted-foreground); + background: transparent; + cursor: pointer; + font-size: 13px; +} + +.theme-token-inspector-field { + display: grid; + flex: none; + gap: 4px; + font-size: 10px; + font-weight: 500; +} + +.theme-token-inspector-field select { + width: 100%; + padding: 4px 6px; + border: 1px solid var(--theme-token-inspector-border-color); + border-radius: 6px; + color: var(--theme-token-inspector-foreground); + background: var(--theme-token-inspector-control-background); + font: 500 10px/1.3 var(--font-mono); +} + +.theme-token-inspector-toggle { + display: flex; + flex: none; + align-items: center; + gap: 6px; + color: var(--theme-token-inspector-foreground); + font-size: 10px; + font-weight: 500; +} + +.theme-token-inspector-toggle input { + margin: 0; + accent-color: var(--accent); +} + +.theme-token-inspector-tokens { + min-height: 0; + overflow-y: auto; + flex: 1; + margin: 0 -4px; + padding: 0 4px; +} + +.theme-token-inspector-group { + margin-top: 9px; + color: var(--theme-token-inspector-muted-foreground); + font-size: 8.5px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.theme-token-inspector-group:first-child { + margin-top: 0; +} + +.theme-token-inspector-token { + display: grid; + align-items: center; + gap: 7px; + grid-template-columns: 18px minmax(0, 1fr) auto; + padding: 2px 0; +} + +.theme-token-inspector-swatch { + width: 18px; + height: 14px; + border: 1px solid var(--theme-token-inspector-border-color); + border-radius: 3px; +} + +.theme-token-inspector-token code { + overflow: hidden; + color: var(--theme-token-inspector-foreground); + font: 9px/1.4 var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-token-inspector-token span { + max-width: 120px; + overflow: hidden; + color: var(--theme-token-inspector-muted-foreground); + font: 8.5px/1.4 var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-token-inspector-token span.is-duplicate { + color: var(--theme-token-inspector-duplicate-foreground); + font-weight: 600; +} diff --git a/src/window-bounds.ts b/src/window-bounds.ts new file mode 100644 index 00000000..d176bf9b --- /dev/null +++ b/src/window-bounds.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +export interface WindowBounds { + x: number + y: number + width: number + height: number + maximized: boolean +} + +interface Rect { + x: number + y: number + width: number + height: number +} + +interface Size { + width: number + height: number +} + +function finiteInteger(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : null +} + +export function parseWindowBounds(value: unknown): WindowBounds | null { + if (value === null || typeof value !== 'object') return null + const record = value as Record + const x = finiteInteger(record.x) + const y = finiteInteger(record.y) + const width = finiteInteger(record.width) + const height = finiteInteger(record.height) + if (x === null || y === null || width === null || height === null) return null + if (width <= 0 || height <= 0) return null + return { x, y, width, height, maximized: record.maximized === true } +} + +/** + * Keeps a remembered window on screen. A display can disappear or shrink + * between runs, so restored bounds are fitted to the current work area + * rather than trusted. + */ +export function clampWindowBounds( + bounds: WindowBounds, + workArea: Rect, + minimum: Size +): WindowBounds { + const width = Math.min( + Math.max(bounds.width, minimum.width), + workArea.width + ) + const height = Math.min( + Math.max(bounds.height, minimum.height), + workArea.height + ) + const x = Math.min( + Math.max(bounds.x, workArea.x), + workArea.x + workArea.width - width + ) + const y = Math.min( + Math.max(bounds.y, workArea.y), + workArea.y + workArea.height - height + ) + return { x, y, width, height, maximized: bounds.maximized } +} + +/** + * Window geometry is a convenience, never review data, so every failure + * here is swallowed and the app falls back to its default size. + */ +export class WindowBoundsStore { + readonly filePath: string + bounds: WindowBounds | null = null + private writer: Promise = Promise.resolve() + + constructor(filePath: string) { + this.filePath = filePath + } + + async load(): Promise { + try { + const parsed: unknown = JSON.parse( + await fs.readFile(this.filePath, 'utf8') + ) + this.bounds = parseWindowBounds(parsed) + } catch { + this.bounds = null + } + return this.bounds + } + + save(bounds: WindowBounds): void { + this.bounds = bounds + this.writer = this.writer.then(async () => { + try { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }) + await fs.writeFile( + this.filePath, + `${JSON.stringify(bounds, null, 2)}\n`, + 'utf8' + ) + } catch { + /* A window that cannot record its size still opens. */ + } + }) + } + + async flush(): Promise { + await this.writer + } +} diff --git a/test/accessibility.test.ts b/test/accessibility.test.ts index bdbf46df..470bae3a 100644 --- a/test/accessibility.test.ts +++ b/test/accessibility.test.ts @@ -149,6 +149,23 @@ test('keyboard access reaches native controls and retains an explicit pane short ) }) +test('left pane tabs expose their controlled review panel', () => { + const html = read('src/index.html') + const renderer = read('src/renderer.ts') + const dom = new JSDOM(html) + const tablist = dom.window.document.querySelector('[aria-label="Review organization"]') + const collapse = dom.window.document.querySelector('#left-pane-collapse') + + assert.ok(tablist) + assert.ok(collapse) + assert.equal(tablist.querySelectorAll(':scope > [role="tab"]').length, 2) + assert.equal(tablist.contains(collapse), false) + assert.match(html, /id="review-navigation-inbox"[\s\S]*role="tab"[\s\S]*aria-controls="documents-list-tree"/) + assert.match(html, /id="review-navigation-projects"[\s\S]*role="tab"[\s\S]*aria-controls="documents-list-tree"/) + assert.match(html, /id="documents-list-tree"[\s\S]*role="tabpanel"[\s\S]*aria-labelledby="review-navigation-inbox"/) + assert.match(renderer, /documentsListTree\.setAttribute\([\s\S]*'aria-labelledby',[\s\S]*review-navigation-inbox[\s\S]*review-navigation-projects/) +}) + test('attachment preview and destructive workflow restore a useful focus target', () => { const dom = new JSDOM(read('src/index.html')) const document = dom.window.document diff --git a/test/annotation-block.test.ts b/test/annotation-block.test.ts index 3bd80570..fefda3d3 100644 --- a/test/annotation-block.test.ts +++ b/test/annotation-block.test.ts @@ -24,6 +24,9 @@ const styles = fs.readFileSync( path.join(__dirname, '../app/src/styles.css'), 'utf8' ) +const renderer = fs.readFileSync(path.join(__dirname, '../../src/renderer.ts'), 'utf8') +const icons = fs.readFileSync(path.join(__dirname, '../../src/lucide-icons.ts'), 'utf8') +const annotationBlock = fs.readFileSync(path.join(__dirname, '../../src/annotation-block.ts'), 'utf8') test('builds one annotation view model for previews and list entries', () => { assert.deepEqual(model({ @@ -239,6 +242,11 @@ test('annotation lists reuse rendered annotation blocks and track selection', () }, onEdit: (node) => { calls.push(`edit:${node.id}`) + }, + renderEditIcon: () => { + const icon = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg') + icon.classList.add('lucide-icon') + return icon } }) @@ -251,6 +259,7 @@ test('annotation lists reuse rendered annotation blocks and track selection', () element(element(selectedBlock.querySelector('.rendered-annotation-edit')).parentElement).className, 'rendered-annotation-body has-edit' ) + assert.ok(selectedBlock.querySelector('.rendered-annotation-edit .lucide-icon')) assert.equal( element(selectedBlock.querySelector('.rendered-annotation-attachment img')).src, 'file:///tmp/img-2.png' @@ -299,7 +308,11 @@ test('annotation list cards use scannable titles, compact thumbnails, and primar assert.match(styles, /\.rendered-annotation-overflow \{[^}]*font-size: 15px;/) assert.match(styles, /\.rendered-annotation--list \.rendered-annotation-attachment \{[^}]*padding: 0;/) assert.match(styles, /\.rendered-annotation--list \.rendered-annotation-attachment span \{[^}]*display: none;/) - assert.match(styles, /\.rendered-annotation-edit \{[^}]*color: var\(--primary-button-text\);[^}]*background: var\(--primary-button-bg\);/) + assert.match(styles, /\.rendered-annotation-edit \{[^}]*color: var\(--muted\);[^}]*background: rgb\(var\(--surface-rgb\) \/ 72%\);/) + assert.match(styles, /\.rendered-annotation-edit \.lucide-icon \{[^}]*width: 16px;[^}]*height: 16px;/) + assert.match(icons, /PenLine/) + assert.match(renderer, /renderEditIcon: \(\) => markoverIcon\('pen-line'\)/) + assert.doesNotMatch(annotationBlock, /✎/) assert.match(styles, /\.annotation-list-view \{[^}]*min-width: 0;[^}]*overflow-x: hidden;/) assert.match(styles, /\.annotation-list \{[^}]*grid-template-columns: minmax\(0, 1fr\);/) assert.match(styles, /\.annotation-list \.rendered-annotation \{[^}]*min-width: 0;[^}]*overflow: hidden;/) diff --git a/test/brand.test.ts b/test/brand.test.ts index 9363d2a6..6b3fe304 100644 --- a/test/brand.test.ts +++ b/test/brand.test.ts @@ -75,12 +75,13 @@ test('the app composes external brand assets and exposes a true empty state', () const renderer = read('src/renderer.ts') assert.match(html, /class="brand" role="img" aria-label="Markover"/) - assert.match(html, /M//) + assert.match(html, /
[\s\S]*
/) assert.match(html, /
/) assert.match(html, /
/) assert.match(renderer, /function setAppEmptyState\(empty: boolean\): void/) @@ -105,7 +106,7 @@ test('the application palette matches the brand brief at startup and in CSS', () '--brand-burgundy: var(--markover-secondary)', '--ink: #26211e', '--muted: #6f6761', - '--paper: #eee8e0', + '--paper: #f7f4ee', '--surface: #fffdf9', '--line: #ddd5cc', '--brand-soft: #f5e3da' @@ -126,9 +127,9 @@ test('the application palette matches the brand brief at startup and in CSS', () assert.match(renderer, /function themedBrandSource\([\s\S]*source: string,[\s\S]*primary: string,[\s\S]*secondary: string[\s\S]*\): string/) assert.match(renderer, /replaceAll\('#c94e1f', primary\)[\s\S]*replaceAll\('#6d211f', secondary\)/) assert.match(renderer, /finally \{\s*document\.documentElement\.classList\.add\('is-brand-ready'\)/) - assert.match(styles, /\.is-brand-ready :is\(\.brand-mark, \.brand-logotype, \.app-empty-state-lockup\)/) - assert.match(styles, /data-palette="olive"\]:not\(\[data-appearance="dark"\]\)[\s\S]*--app-shell-background: #dde1d2;[\s\S]*--app-header-background: var\(--app-shell-background\);[\s\S]*--review-navigation-bg: #e8eadf;[\s\S]*--center-pane-background: #fff;/) - assert.match(styles, /\.review-tab-strip \{[^}]*border-top: 1px solid var\(--line\);/) + assert.match(styles, /\.is-brand-ready :is\(\.brand-mark, \.app-empty-state-lockup\)/) + assert.match(styles, /data-palette="olive"\]:not\(\[data-appearance="dark"\]\)[\s\S]*--app-shell-background: #dde1d2;[\s\S]*--app-header-background: var\(--app-shell-background\);[\s\S]*--center-pane-background: var\(--paper\);/) + assert.match(styles, /\.review-tab-strip \{[^}]*display: none;/) assert.doesNotMatch(styles, /\.review-tab-strip \{[^}]*border-bottom:/) assert.match(reviewInbox, /if \(status === 'revised'\) return 'Revised'/) assert.match(reviewInbox, /if \(status === 'done'\) return 'Done'/) @@ -136,44 +137,107 @@ test('the application palette matches the brand brief at startup and in CSS', () assert.match(styles, /--status-progress: #d89b35;/) assert.match(styles, /data-appearance="dark"[\s\S]*--status-editing: color-mix\(in srgb, var\(--markover-primary\) 70%, white\);/) - assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.brand-logotype \{\s*display: none;/) + assert.doesNotMatch(styles, /\.brand-logotype \{/) assert.match(main, /backgroundColor: windowBackground\(/) }) +test('the floating theme-token inspector uses canonical structure and component roles', () => { + const html = read('src/index.html') + const renderer = read('src/renderer.ts') + const styles = read('src/styles.css') + + assert.match(html, /id="theme-token-inspector" class="theme-token-inspector" hidden/) + assert.match(html, />Theme tokensApp header background { const styles = read('src/styles.css') const renderer = read('src/renderer.ts') const html = read('src/index.html') + const leftPane = html.slice( + html.indexOf('