Compare commits

...
Author SHA1 Message Date
usrnk1 94ba546ff4 chore: add reference diff color override set
The tuner set used to pick this branch's colors, kept read-only for reference during review. Drop with the review commit before merge.
2026-10-01 20:21:04 +02:00
usrnk1 4eb60954f8 chore: add diff color review fixtures and tuner
Temporary review material, drop this commit before merge: diff fixtures with a patch of uncommitted edits, review notes and screenshots, and a local color tuner that writes overrides into the diff view through diff-color-tuning.ts.
2026-10-01 20:21:04 +02:00
usrnk1 e8c67888a7 feat(desktop): refine diff view colors
Use v2 green and red tokens for changed rows, gutters, inline highlights, change bars and line numbers in light and dark modes. Changed-row fills are final translucent colors instead of being re-mixed by Pierre, emphasized text overrides syntax colors so it stays legible on its highlight, and unchanged line numbers and fold labels use the faint text token.
2026-10-01 20:21:04 +02:00
58 changed files with 2404 additions and 6 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
// BEGIN: baseline — first-line change
type ReviewState = "pending" | "ready" | "failed"
type ReviewProps = { title: string; count: number; state: ReviewState }
export const heading = "Review the old checkout experience"
export const count = 8
export const obsolete = "delete this isolated row"
export const anchor = "unchanged between deletion and addition"
// Contiguous span versus several separated small spans.
export const contiguous = "Keep the original checkout message readable"
export const scattered = "alpha: old; beta: cold; gamma: slow"
export const oneCharacter = "item-7"
export const atStart = "before middle remains ending remains"
export const atMiddle = "start remains before ending remains"
export const atEnd = "start remains middle remains before"
export const punctuation = { label: "Ready", enabled: true }
export const spaces = "one two three"
export const indent = "indent-only change"
export const trailing = "trailing-space-only change"
// Syntax competition: comment, keyword, type, boolean, string, number.
export function ReviewCard(props: ReviewProps) {
const status: ReviewState = "pending"
const enabled = false
const retries = 3
// Keep this quiet explanation readable on a tinted row.
return <section aria-label="Old review" data-state={status}>
<h2>{props.title}</h2>
<span className="text-muted">{props.count} old items</span>
<button disabled={!enabled}>Continue checkout</button>
</section>
}
export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
// END: baseline — last-line change
+66
View File
@@ -0,0 +1,66 @@
// BEGIN baseline
export const first = "old"
// quiet context A01
// quiet context A02
// quiet context A03
// quiet context A04
// quiet context A05
// quiet context A06
// quiet context A07
// quiet context A08
// quiet context A09
// quiet context A10
// quiet context A11
// quiet context A12
// quiet context A13
// quiet context A14
// quiet context A15
// quiet context A16
// quiet context A17
// quiet context A18
// quiet context A19
// quiet context A20
export const second = "old"
// quiet context B01
// quiet context B02
// quiet context B03
// quiet context B04
// quiet context B05
// quiet context B06
// quiet context B07
// quiet context B08
// quiet context B09
// quiet context B10
// quiet context B11
// quiet context B12
// quiet context B13
// quiet context B14
// quiet context B15
// quiet context B16
// quiet context B17
// quiet context B18
// quiet context B19
// quiet context B20
export const third = "old"
// quiet context C01
// quiet context C02
// quiet context C03
// quiet context C04
// quiet context C05
// quiet context C06
// quiet context C07
// quiet context C08
// quiet context C09
// quiet context C10
// quiet context C11
// quiet context C12
// quiet context C13
// quiet context C14
// quiet context C15
// quiet context C16
// quiet context C17
// quiet context C18
// quiet context C19
// quiet context C20
export const fourth = "old"
// END baseline
+21
View File
@@ -0,0 +1,21 @@
# Before: plain-text review
Unchanged prose should remain quiet, not compete with changed rows.
Replace one contiguous phrase: the original checkout experience stays here.
Several little edits: red apple, cold tea, slow train.
One character: ticket 7.
before — the middle and the end remain unchanged.
The start remains — before — the end remains.
The start and the middle remain — before
Punctuation only: hello, world!
Whitespace only: one two three
Trailing whitespace only.
Delete this sentence without a replacement.
This unchanged sentence separates a deletion from an addition.
Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
Final sentence before.
+63
View File
@@ -0,0 +1,63 @@
// Large file: unchanged prefix and suffix surround a dense block.
export const records = [
{ id: "row-001", state: "pending", count: 1 },
{ id: "row-002", state: "pending", count: 2 },
{ id: "row-003", state: "pending", count: 3 },
{ id: "row-004", state: "pending", count: 4 },
{ id: "row-005", state: "pending", count: 5 },
{ id: "row-006", state: "pending", count: 6 },
{ id: "row-007", state: "pending", count: 7 },
{ id: "row-008", state: "pending", count: 8 },
{ id: "row-009", state: "pending", count: 9 },
{ id: "row-010", state: "pending", count: 10 },
{ id: "row-011", state: "pending", count: 11 },
{ id: "row-012", state: "pending", count: 12 },
{ id: "row-013", state: "pending", count: 13 },
{ id: "row-014", state: "pending", count: 14 },
{ id: "row-015", state: "pending", count: 15 },
{ id: "row-016", state: "pending", count: 16 },
{ id: "row-017", state: "pending", count: 17 },
{ id: "row-018", state: "pending", count: 18 },
{ id: "row-019", state: "pending", count: 19 },
{ id: "row-020", state: "pending", count: 20 },
{ id: "row-021", state: "pending", count: 21 },
{ id: "row-022", state: "pending", count: 22 },
{ id: "row-023", state: "pending", count: 23 },
{ id: "row-024", state: "pending", count: 24 },
{ id: "row-025", state: "pending", count: 25 },
{ id: "row-026", state: "pending", count: 26 },
{ id: "row-027", state: "pending", count: 27 },
{ id: "row-028", state: "pending", count: 28 },
{ id: "row-029", state: "pending", count: 29 },
{ id: "row-030", state: "pending", count: 30 },
{ id: "row-031", state: "pending", count: 31 },
{ id: "row-032", state: "pending", count: 32 },
{ id: "row-033", state: "pending", count: 33 },
{ id: "row-034", state: "pending", count: 34 },
{ id: "row-035", state: "pending", count: 35 },
{ id: "row-036", state: "pending", count: 36 },
{ id: "row-037", state: "pending", count: 37 },
{ id: "row-038", state: "pending", count: 38 },
{ id: "row-039", state: "pending", count: 39 },
{ id: "row-040", state: "pending", count: 40 },
{ id: "row-041", state: "pending", count: 41 },
{ id: "row-042", state: "pending", count: 42 },
{ id: "row-043", state: "pending", count: 43 },
{ id: "row-044", state: "pending", count: 44 },
{ id: "row-045", state: "pending", count: 45 },
{ id: "row-046", state: "pending", count: 46 },
{ id: "row-047", state: "pending", count: 47 },
{ id: "row-048", state: "pending", count: 48 },
{ id: "row-049", state: "pending", count: 49 },
{ id: "row-050", state: "pending", count: 50 },
{ id: "row-051", state: "pending", count: 51 },
{ id: "row-052", state: "pending", count: 52 },
{ id: "row-053", state: "pending", count: 53 },
{ id: "row-054", state: "pending", count: 54 },
{ id: "row-055", state: "pending", count: 55 },
{ id: "row-056", state: "pending", count: 56 },
{ id: "row-057", state: "pending", count: 57 },
{ id: "row-058", state: "pending", count: 58 },
{ id: "row-059", state: "pending", count: 59 },
{ id: "row-060", state: "pending", count: 60 },
]
+4
View File
@@ -0,0 +1,4 @@
This entire tracked file will be deleted after the baseline commit.
Only deletion tint should be present.
No syntax colors should compete with this text.
Last deleted row.
+41
View File
@@ -0,0 +1,41 @@
/* Baseline stylesheet: colors, units, selectors, at-rules. */
:root {
--brand: #3b5cf6;
--radius: 6px;
--gap: 8px;
}
.card {
display: flex;
gap: var(--gap);
padding: 12px 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--radius);
background: #ffffff;
color: #161616;
}
.card:hover {
background: #fafafa;
}
.card[data-state="error"] {
color: #b82d35;
border-color: #f2bbb7;
}
@media (max-width: 768px) {
.card {
flex-direction: column;
padding: 8px;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "diff-color-fixture",
"version": "1.0.0",
"private": true,
"settings": {
"theme": "light",
"fontSize": 13,
"lineHeight": 20,
"wrap": false,
"tabs": ["review", "files", "terminal"]
},
"features": {
"inlineHighlights": true,
"foldUnchanged": true,
"splitView": false
},
"limits": {
"maxFiles": 100,
"maxLineLength": 1000
}
}
+38
View File
@@ -0,0 +1,38 @@
"""Baseline Python service: decorators, f-strings, indentation, comments."""
import asyncio
from dataclasses import dataclass
@dataclass
class Review:
id: str
title: str
approved: bool = False
class ReviewService:
def __init__(self, timeout: float = 2.0) -> None:
self.timeout = timeout
self.cache: dict[str, Review] = {}
async def load(self, review_id: str) -> Review | None:
# Return cached reviews before touching the network.
if review_id in self.cache:
return self.cache[review_id]
await asyncio.sleep(self.timeout)
return None
def summary(self, review: Review) -> str:
status = "approved" if review.approved else "pending"
return f"Review {review.id}: {review.title} ({status})"
def main() -> None:
service = ReviewService()
review = Review(id="r-1", title="Checkout")
print(service.summary(review))
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
// Moved and reordered blocks: the same code appears as deletion here and addition elsewhere.
export function first() {
return "first"
}
export function second() {
const values = [1, 2, 3]
return values.map((value) => value * 2)
}
export function third() {
return "third"
}
export function fourth() {
return "fourth"
}
export const order = ["first", "second", "third", "fourth"]
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Baseline shell script: variables, quoting, conditionals.
set -euo pipefail
ENVIRONMENT="${1:-staging}"
REGION="us-east-1"
RETRIES=3
log() {
echo "[deploy] $*"
}
if [[ "$ENVIRONMENT" == "production" ]]; then
log "Deploying to production in $REGION"
else
log "Deploying to $ENVIRONMENT"
fi
for attempt in $(seq 1 "$RETRIES"); do
log "Attempt $attempt of $RETRIES"
done
+19
View File
@@ -0,0 +1,19 @@
name: review
on:
push:
branches: [v2]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install
run: bun install
- name: Check
run: bun run check
env:
CI: true
LOG_LEVEL: info
+12
View File
@@ -0,0 +1,12 @@
# Unicode and mixed scripts
Emoji: ✅ ready, ⚠️ warning, ❌ failed.
CJK: 代码审查 完成。
Japanese: レビューを開始します。
Korean: 변경 사항을 검토합니다.
Arabic (RTL): مراجعة التغييرات
Hebrew (RTL): סקירת שינויים
Accents: café, naïve, résumé.
Math: a ≤ b, x ≠ y, ∑ values.
Box: ┌─┐ │ │ └─┘
Combining: e\u0301 vs é
+18
View File
@@ -0,0 +1,18 @@
package fixture
import "fmt"
// Tab-indented Go: tab-width rendering and tab-only changes.
type Review struct {
ID string
Title string
Approved bool
}
func (r Review) Summary() string {
status := "pending"
if r.Approved {
status = "approved"
}
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
}
Whitespace-only changes.
@@ -0,0 +1,3 @@
This tracked file keeps existing but loses all content.
Every row should render as a deletion.
The file itself is not deleted.
+2
View File
@@ -0,0 +1,2 @@
First line
Last line without trailing newline
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Review</title>
</head>
<body class="light">
<main id="app" data-state="idle">
<h1>Old heading</h1>
<button type="button" disabled>Continue</button>
<!-- baseline comment -->
</main>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
SELECT id, title, status
FROM reviews
WHERE status = 'pending'
AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
LIMIT 50;
+15
View File
@@ -0,0 +1,15 @@
# Temporary diff color review
Local-only visual fixture. Not imported by product code. Do not format these files.
After the baseline commit, leave all example edits uncommitted.
- `01-inline.tsx`: adjacent rows, inline spans, one character, syntax, whitespace, long line.
- `02-folded.ts`: isolated hunks and multiple collapsed unchanged sections.
- `03-prose.md`: plain prose, punctuation, spaces, file boundaries, long prose.
- `04-many-rows.ts`: larger dense changed region.
- `05-deleted.txt` / `06-added.txt`: whole-file deletion / addition.
Open this worktree in OpenCode, open Review, select Uncommitted changes, and choose a file.
Compare the default OpenCode theme in Light and Dark with Unified and Split layouts.
Use a 1440 × 1000 viewport for comparisons and 800 × 1000 for the narrow case.
+147
View File
@@ -0,0 +1,147 @@
# File diff color tokens and CSS variables
Inventory for this worktree's V2 web/desktop file viewer. Source inspection only; no colors or behavior changed. Includes diff-specific colors, syntax colors, and the selection/search/comment colors used inside the viewer—not every unrelated global app variable. Unified and split use the same color system.
## Main controls and their wiring
| Visual role | Renderer variable | OpenCode source |
|---|---|---|
| Neutral file background | `--diffs-bg` | `--opencode-diffs-bg`, falling back to `--color-background-stronger` (alias of `--background-stronger`) |
| Default text | `--fg` / `--diffs-fg` | Registered theme foreground: `--text-base`; syntax spans override it |
| Addition seed / gutter bar | `--diffs-addition-color` → `--diffs-addition-base` | `--syntax-diff-add` |
| Deletion seed / gutter bar | `--diffs-deletion-color` → `--diffs-deletion-base` | `--syntax-diff-delete` |
| Modified seed | `--diffs-modified-color` → `--diffs-modified-base` | `--syntax-diff-unknown` |
| Added row | `--diffs-bg-addition`, `--diffs-bg-addition-override` | Mix of neutral background and addition seed |
| Deleted row | `--diffs-bg-deletion`, `--diffs-bg-deletion-override` | Mix of neutral background and deletion seed |
| Added inline span | `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` | Alpha of addition seed |
| Deleted inline span | `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` | Alpha of deletion seed; softened on selected deletion rows |
| Folded “unmodified lines” row | `--diffs-bg-separator`, `--diffs-bg-separator-override` | Mix of neutral background and `--diffs-mixer` |
| Ordinary line numbers / fold text | `--diffs-fg-number`, `--diffs-fg-number-override` | Foreground/background mix |
| Selection background | `--diffs-selection-base` → `--diffs-bg-selection` | `--v2-background-bg-accent` |
| Selected line-number text | `--diffs-selection-number-fg` | `--v2-text-text-accent` |
| Comment annotation background | `--diffs-comment-bg` | Alpha of `--v2-background-bg-accent` |
| Search match / current match | CSS highlight backgrounds | Alpha of `--surface-warning-base` / `--surface-warning-strong` |
**Important:** `--surface-diff-add-*`, `--surface-diff-delete-*`, and `--surface-diff-hidden-*` exist in the global theme, but are not the direct row/inline/fold controls in the current Pierre rendering path. The older/custom separator CSS in `components/file.css` does use `--surface-diff-hidden-base` and `--surface-diff-hidden-strong`.
## OpenCode diff-specific theme tokens — complete families
Each name below is a CSS custom property. Theme JSON override keys omit the leading `--`. Tailwind's `--color-…` aliases are defined in `packages/ui/src/styles/tailwind/colors.css` (for example `--color-surface-diff-add-base` → `--surface-diff-add-base`); these are aliases, not separate color decisions.
### Surfaces
```text
--surface-diff-unchanged-base
--surface-diff-skip-base
--surface-diff-hidden-base
--surface-diff-hidden-weak
--surface-diff-hidden-weaker
--surface-diff-hidden-strong
--surface-diff-hidden-stronger
--surface-diff-add-base
--surface-diff-add-weak
--surface-diff-add-weaker
--surface-diff-add-strong
--surface-diff-add-stronger
--surface-diff-delete-base
--surface-diff-delete-weak
--surface-diff-delete-weaker
--surface-diff-delete-strong
--surface-diff-delete-stronger
```
### Text, icons, and highlighter diff seeds
```text
--text-diff-add-base
--text-diff-add-strong
--text-diff-delete-base
--text-diff-delete-strong
--icon-diff-add-base
--icon-diff-add-hover
--icon-diff-add-active
--icon-diff-delete-base
--icon-diff-delete-hover
--icon-diff-modified-base
--syntax-diff-add
--syntax-diff-delete
--syntax-diff-unknown
```
The built-in OpenCode theme maps `--syntax-diff-add` and `--text-diff-add-base` to `--v2-state-fg-success`, and their delete counterparts to `--v2-state-fg-danger`. Optional palette seeds `diffAdd` and `diffDelete` also exist for theme resolution; they are theme inputs, not CSS variable names, and built-in overrides can supersede generated results.
## Syntax foreground tokens
```text
--syntax-comment
--syntax-regexp
--syntax-string
--syntax-keyword
--syntax-primitive
--syntax-operator
--syntax-variable
--syntax-property
--syntax-type
--syntax-constant
--syntax-punctuation
--syntax-object
--syntax-success
--syntax-warning
--syntax-critical
--syntax-info
--syntax-unknown
```
`--syntax-unknown` is referenced by the registered highlighter, but no definition was found in the current UI theme sources: treat it as an unresolved reference, not an existing resolved theme token. `--syntax-success` is available globally, although not directly used by that registered theme's current token-color rules.
Built-in syntax overrides additionally reference `--v2-text-text-muted`, `--v2-pink-800`, `--v2-green-800`, `--v2-orange-800`, `--v2-purple-800`, and `--v2-red-800` (with separate dark/light resolutions). Other syntax tokens use the ordinary text tokens or theme-resolved colors.
## Pierre renderer color variables — complete relevant inventory
These come from the installed `@pierre/diffs` **1.5.1** stylesheet plus OpenCode's injected CSS. `*-override` variables are hooks; other variables include derived outputs and internal implementation details, not independent OpenCode theme tokens. Some optional hooks are unset by default.
| Group | Variables |
|---|---|
| Base | `--diffs-bg`, `--diffs-fg`, `--diffs-mixer`, `--opencode-diffs-bg`, `--bg`, `--fg` |
| Theme foreground/background variants | `--diffs-light`, `--diffs-dark`, `--diffs-light-bg`, `--diffs-dark-bg` |
| Neutral backgrounds | `--diffs-bg-buffer`, `--diffs-bg-buffer-override`, `--diffs-bg-context`, `--diffs-bg-context-override`, `--diffs-bg-context-gutter`, `--diffs-bg-context-gutter-override`, `--diffs-bg-separator`, `--diffs-bg-separator-override` |
| Number / conflict-marker foreground | `--diffs-fg-number`, `--diffs-fg-number-override`, `--diffs-fg-number-addition-override`, `--diffs-fg-number-deletion-override`, `--diffs-fg-conflict-marker`, `--diffs-fg-conflict-marker-override` |
| Addition seed | `--diffs-addition-base`, `--diffs-addition-color`, `--diffs-addition-color-override`, `--diffs-light-addition-color`, `--diffs-dark-addition-color` |
| Deletion seed | `--diffs-deletion-base`, `--diffs-deletion-color`, `--diffs-deletion-color-override`, `--diffs-light-deletion-color`, `--diffs-dark-deletion-color` |
| Modified seed | `--diffs-modified-base`, `--diffs-modified-color`, `--diffs-modified-color-override`, `--diffs-light-modified-color`, `--diffs-dark-modified-color` |
| Renderer fallback colors | `--diffs-added-light`, `--diffs-added-dark`, `--diffs-deleted-light`, `--diffs-deleted-dark`, `--diffs-modified-light`, `--diffs-modified-dark`, `--diffs-warning-light`, `--diffs-warning-dark` |
| Added row/gutter/inline | `--diffs-bg-addition`, `--diffs-bg-addition-override`, `--diffs-bg-addition-number-override`, `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` |
| Deleted row/gutter/inline | `--diffs-bg-deletion`, `--diffs-bg-deletion-override`, `--diffs-bg-deletion-number-override`, `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` |
| Selection | `--diffs-selection-base`, `--diffs-selection-number-fg`, `--diffs-bg-selection`, `--diffs-bg-selection-override`, `--diffs-bg-selection-number`, `--diffs-bg-selection-number-override`, `--diffs-bg-selection-text` |
| Hover | `--diffs-bg-hover-override`, `--diffs-hover-mix-target` |
| Comments / decoration | `--diffs-comment-bg`, `--diffs-annotation-bg`, `--diffs-decoration-bg`, `--diffs-decoration-bar-color` |
| Internal background pipeline | `--diffs-computed-decoration-bg`, `--diffs-computed-diff-line-bg`, `--diffs-computed-selected-line-bg`, `--diffs-computed-editor-active-line-bg`, `--diffs-computed-hovered-line-bg`, `--diffs-line-bg` |
| Internal mix targets | `--diffs-diff-line-mix-target`, `--diffs-selection-mix-target`, `--diffs-selection-emphasis-mix-target` |
| Per-token light/dark colors | `--diffs-token-light`, `--diffs-token-dark`, `--diffs-token-light-bg`, `--diffs-token-dark-bg` |
| Conflict backgrounds (library support; not exercised by the fixture) | `--conflict-bg-current-header-override`, `--conflict-bg-current-number-override`, `--conflict-bg-current-override`, `--conflict-bg-incoming-header-override`, `--conflict-bg-incoming-number-override`, `--conflict-bg-incoming-override` |
Related mix controls are **percentages, not colors**: `--mix-light`, `--mix-dark`, `--mix-deco-light`, `--mix-deco-dark`, `--mix-selection-light`, `--mix-selection-dark`, and `--diffs-editor-active-line-source-mix`.
## Other color tokens used inside the file viewer
- Text: `--text-base`, `--text-weak`, `--text-strong`.
- Background fallback: `--background-stronger`, `--color-background-stronger`; comment actions also use `--background-base`.
- Selection: `--v2-background-bg-accent`, `--v2-text-text-accent`.
- Search: `--surface-warning-base`, `--surface-warning-strong`.
- Custom fold UI: `--surface-diff-hidden-base`, `--surface-diff-hidden-strong`, `--icon-strong-base`; `--text-mix-blend-mode` affects compositing but is not a color.
- Comment controls: `--icon-interactive-base`, `--white`, `--surface-raised-stronger-non-alpha`, `--surface-base`, `--surface-raised-base-hover`, `--border-base`, `--text-weak`, `--text-strong`, `--background-base`.
- Comment shadows (composite shadow tokens, not single colors): `--shadow-xs`, `--shadow-xs-border-focus`, `--shadow-xxs-border`, `--shadow-xs-border-select`.
Typography, sizing, gaps, gutter widths, font weight/style, and text-decoration variables are deliberately excluded from this color inventory.
## Source locations
- `packages/session-ui/src/pierre/index.ts`: viewer color overrides, selection, search, comment tint.
- `packages/ui/src/context/marked-theme.tsx`: registered foreground/background, diff seeds, syntax mappings.
- `packages/ui/src/theme/resolve.ts`: global diff/syntax token generation.
- `packages/ui/src/theme/themes/oc-2.json`: built-in light/dark mappings.
- `packages/ui/src/styles/theme.css` and `styles/tailwind/colors.css`: defaults and aliases.
- `packages/session-ui/src/components/file.css`: custom fold-separator styling.
- `packages/session-ui/src/components/line-comment-styles.ts`: comment controls.
- Installed `@pierre/diffs/dist/style.js`: renderer variables and derived backgrounds.
- Installed `@pierre/diffs/dist/utils/getHighlighterThemeStyles.js`: highlighter-to-renderer seed bridge.
+36
View File
@@ -0,0 +1,36 @@
# Diff color review (temporary)
Review-only material for the diff color PR. This whole commit is dropped before merge: `diff-color-fixture/`,
`diff-color-review-artifacts/`, `packages/session-ui/src/pierre/diff-color-tuning.ts`, and its import in
`packages/session-ui/src/pierre/index.ts`.
## See the example diffs
The fixture baselines are committed; their edits ship as a patch so they show up as uncommitted Git changes.
```sh
git apply diff-color-review-artifacts/fixture-edits.patch
```
Open a session in this worktree, open **Review → Git changes**, and pick a file in `diff-color-fixture/`.
Undo with `git checkout -- diff-color-fixture && git clean -fd diff-color-fixture`.
## Tune colors yourself
With the desktop dev app running from this worktree (`bun run dev:desktop`):
```sh
bun run diff-color-review-artifacts/tuner/server.ts
```
Open http://127.0.0.1:4455 (any browser, or the OpenCode browser pane).
- Every change saves to `diff-color-tuning.ts` (hot-reloaded) and is pushed live into open diffs through the dev
app's debug port (9222; override with `TUNER_CDP`).
- **Override set**: `Reference` is the committed, read-only set used for this PR (it matches the branch colors, so it
previews as no change). Editing it saves a copy; use **New** for a blank set. Your sets stay local and untracked.
- “Current” leaves the branch colors untouched. Slots can be an exact color or a v2 token, with opacity.
- **Copy CSS** / **Paste CSS** share a set with someone else; Undo/Redo and per-category toggles are in the toolbar.
`REPORT.md` and the screenshots document the colors before this change; `COLOR-TOKENS.md` lists the diff view's
color tokens and Pierre variables.
+69
View File
@@ -0,0 +1,69 @@
# OpenCode V2 diff color review — temporary fixture
[File diff color token / CSS variable inventory](COLOR-TOKENS.md)
## Location and refs
- Worktree: `/Users/usrnk1/.local/share/opencode/worktree/012780/diff-color-review`
- Branch: `diff-color-review` (local only).
- Fetched `origin/v2`: `7e42a897bc6dbbba580392d779e47137038bcafb`.
- Local baseline / HEAD: `5e14ede13c260b3b1bd2e16c5bb25722d12f366b`.
- Fixture: `diff-color-fixture/01-inline.tsx`, `02-folded.ts`, `03-prose.md`, `04-many-rows.ts`, `05-deleted.txt` (deleted), `06-added.txt` (untracked addition).
- `diff-color-fixture/README.md` is tracked and unchanged.
- Only fixtures changed. No renderer, token, behavior, formatter, push, PR, or deployment changes.
## Actual UI and how to open
The captures use the real source-backed OpenCode web app from this worktree, connected to the existing V2 background server (2.0.21). No mocked data or copied renderer. The app dev server remains available at `http://127.0.0.1:4444` and was started with `VITE_OPENCODE_SERVER_PORT=49374 bun run dev -- --port 4444` from `packages/app`. Existing app/server processes were not restarted.
Open:
`http://127.0.0.1:4444/server/aHR0cDovLzEyNy4wLjAuMTo0OTM3NA/session/ses_f085f0176ffeiNj9364c2CnHqm`
Alternatively open this session in OpenCode Desktop; its location is now this worktree. Toggle Review, choose **Git changes**, and select a fixture file. This UI calls the working-tree source “Git changes,” not “Uncommitted changes.” Use the file-tree toggle if the list is hidden. Unified/Split controls are in the review toolbar.
Captures: 1440 × 1000 CSS pixels, default OpenCode (`oc-2`) theme, system light/dark media preference. Chat pane was resized to its minimum and the file tree hidden to give the diff about 964 pixels. Wrapping was enabled by the production viewer. Close-ups below are unchanged crops of those screenshots, not re-rendered mockups.
## Representative screenshots
- [Dark inline close-up](dark-inline-closeup.png) / [Light inline close-up](light-inline-closeup.png)
- [Dark split inline](dark-split-inline.png) / [Light split inline](light-split-inline.png)
- [Dark split folded: all three bars](dark-split-folded.png) / [Light split folded](light-split-folded.png)
- [Dark unified folded](dark-unified-folded.png) / [Light unified folded](light-unified-folded.png)
- [Dark syntax and wrapped long line](dark-split-syntax-long.png) / [Light syntax and wrapped long line](light-split-syntax-long.png)
- [Dark Markdown](dark-split-prose.png) / [Light Markdown](light-split-prose.png)
Additional unified captures are in this same directory.
## Case notes
| Case | Unchanged sections | Red/green scan | Inline emphasis | Text readability |
|---|---|---|---|---|
| Single additions/deletions/replacements, adjacent rows (`01`, `03`) | Neutral background recedes; unchanged syntax can still draw the eye | Light is clearer; dark relies more on gutter bars and line numbers | Distinct but restrained | TS strings stay green even on red deletion rows, competing with change meaning |
| Contiguous versus separated edits (`01` lines 11–12; long line) | Common words inside an inline span do **not** recede | Row direction is still clear from gutter and tint | Current production `word-line` rendering joins separated edits into one broad span; multiple independent highlight islands were not available in this state | Broad wrapped emphasis adds visual weight without improving precision |
| One-character, start/middle/end edits (`01` lines 13–16; `03`) | Surrounding text remains readable | Large words are easy to find; one-character changes need deliberate attention | One-character patch is visible but easy to miss at normal scan speed | No observed loss of string readability |
| Punctuation and spaces/indent/trailing spaces (`01` lines 17–20; `03`) | Quiet surroundings | Row markers expose that something changed | Small semicolon/space blocks are visible on close inspection; blank blocks give little explanation without visible whitespace glyphs | Readable; punctuation has less salience than colored keywords |
| Isolated hunks and collapsed sections (`02`) | Neutral context recedes; three bars visible simultaneously in split | Distinct isolated rows, but dark tints are weak | Small changed-word blocks don't dominate rows | Fold-label text is unusually faint, especially dark; context comments are clearer than the fold labels |
| TS/TSX types, keywords, comments and JSX (`01`) | Syntax in unchanged rows remains fairly prominent | Pink keywords/green strings compete with the red/green layer | Emphasis is generally subordinate; the long merged span is the exception | Comments/strings/types readable overall; muted JSX words like “old,” “new,” and button text are weak on tinted/highlighted backgrounds in dark mode |
| Markdown and wrapped prose (`03`) | Plain prose is visually quieter than code | Light tints are clearer than dark | Long spans emphasize unchanged middle words as well as changes | Plain prose remains readable but looks muted; bold/heading syntax draws attention more strongly than small edit spans |
| Long/wrapped lines (`01`, `03`) | Common middle text is swallowed by merged emphasis | Tinted multi-line blocks are apparent | Broad highlights repeat across wraps and visually dominate small changes | Wrapping works in unified and split; side-by-side requires more vertical scanning |
| File beginning/end (`01`, `02`, `03`) | Neutral surrounding rows recede | Changes shown at first/last lines | Same inline behavior as middle edits | Readable |
| Dense larger file (`04`: 63 lines, 40 replacements / 80 changed rows) | **Not visually checked** | Not checked | Not checked | Fixture ready for review |
| Added/deleted whole files (`05`, `06`) | Viewer lists both with D/A badges | **File contents not visually checked** | Not checked | Fixture ready for review |
| Narrow viewport (planned 800 × 1000) | **Not checked** | Not checked | Not checked | Stopped expanding capture scope at user request |
## Specific problems to carry into a later design pass
1. Dark row backgrounds have weak separation from neutral context; syntax hue is often more salient than change direction.
2. Green string syntax appears on deleted rows too, weakening red/green semantic scanning. Pink keywords similarly attract attention independently of change status.
3. Collapsed-section labels recede too far: their subdued text is harder to read than surrounding context comments.
4. One-character, punctuation, and whitespace highlights are easy to miss without focused inspection. Their issue is subtlety/size, not overpowering brightness.
5. Muted JSX text on dark inline backgrounds is less readable than the surrounding syntax. Broad inline spans across wrapped lines add disproportionate visual mass.
The merged-span behavior is a renderer observation, not a proposed color fix. No final values selected, no fixes implemented, and no contrast-ratio compliance claim made.
## GitHub Desktop comparison boundary
The referenced discussion/image was not included in this session. A precise comparison to that direction is therefore **not verified**. As a provisional hierarchy comparison only: quiet context → recognizable changed row → localized stronger inline emphasis is the useful target. OpenCode already keeps ordinary inline backgrounds subordinate to rows, but dark row separation, syntax competition, faint fold controls, and merged broad spans weaken that hierarchy. This is not a claim that the specific GitHub Desktop reference was inspected.
Scope was intentionally stopped after the user's request to do less. No full lint/typecheck was run: these are standalone visual examples with deliberate whitespace/formatting changes, not product code.
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

@@ -0,0 +1,601 @@
diff --git a/diff-color-fixture/01-inline.tsx b/diff-color-fixture/01-inline.tsx
index 1d16049d61..5074a3bc83 100644
--- a/diff-color-fixture/01-inline.tsx
+++ b/diff-color-fixture/01-inline.tsx
@@ -1,37 +1,37 @@
-// BEGIN: baseline — first-line change
+// BEGIN: edited — first-line change
type ReviewState = "pending" | "ready" | "failed"
type ReviewProps = { title: string; count: number; state: ReviewState }
-export const heading = "Review the old checkout experience"
-export const count = 8
-export const obsolete = "delete this isolated row"
+export const heading = "Review the new checkout experience"
+export const count = 9
export const anchor = "unchanged between deletion and addition"
+export const added = "add this isolated row"
// Contiguous span versus several separated small spans.
-export const contiguous = "Keep the original checkout message readable"
-export const scattered = "alpha: old; beta: cold; gamma: slow"
-export const oneCharacter = "item-7"
-export const atStart = "before middle remains ending remains"
-export const atMiddle = "start remains before ending remains"
-export const atEnd = "start remains middle remains before"
-export const punctuation = { label: "Ready", enabled: true }
-export const spaces = "one two three"
-export const indent = "indent-only change"
-export const trailing = "trailing-space-only change"
+export const contiguous = "Keep the redesigned payment summary readable"
+export const scattered = "alpha: new; beta: warm; gamma: fast"
+export const oneCharacter = "item-8"
+export const atStart = "after middle remains ending remains"
+export const atMiddle = "start remains after ending remains"
+export const atEnd = "start remains middle remains after"
+export const punctuation = { label: "Ready", enabled: true };
+export const spaces = "one two three"
+ export const indent = "indent-only change"
+export const trailing = "trailing-space-only change"
// Syntax competition: comment, keyword, type, boolean, string, number.
export function ReviewCard(props: ReviewProps) {
- const status: ReviewState = "pending"
- const enabled = false
- const retries = 3
- // Keep this quiet explanation readable on a tinted row.
- return <section aria-label="Old review" data-state={status}>
+ const status: ReviewState | undefined = "ready"
+ const enabled = true
+ const retries = 4
+ // Keep this revised explanation readable on a tinted row.
+ return <article aria-label="New review" data-state={status}>
<h2>{props.title}</h2>
- <span className="text-muted">{props.count} old items</span>
- <button disabled={!enabled}>Continue checkout</button>
- </section>
+ <span className="text-primary">{props.count} new items</span>
+ <button disabled={!enabled}>Confirm payment</button>
+ </article>
}
-export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
+export const longLine = "Start unchanged | The new payment flow waits for automatic confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The new final instruction asks the reviewer to keep the window | End unchanged"
-// END: baseline — last-line change
+// END: edited — last-line change
diff --git a/diff-color-fixture/02-folded.ts b/diff-color-fixture/02-folded.ts
index 07541ab49d..a5b9b354c3 100644
--- a/diff-color-fixture/02-folded.ts
+++ b/diff-color-fixture/02-folded.ts
@@ -1,5 +1,5 @@
-// BEGIN baseline
-export const first = "old"
+// BEGIN edited
+export const first = "new"
// quiet context A01
// quiet context A02
// quiet context A03
@@ -20,7 +20,7 @@ export const first = "old"
// quiet context A18
// quiet context A19
// quiet context A20
-export const second = "old"
+export const second = "new"
// quiet context B01
// quiet context B02
// quiet context B03
@@ -41,7 +41,7 @@ export const second = "old"
// quiet context B18
// quiet context B19
// quiet context B20
-export const third = "old"
+export const third = "new"
// quiet context C01
// quiet context C02
// quiet context C03
@@ -62,5 +62,5 @@ export const third = "old"
// quiet context C18
// quiet context C19
// quiet context C20
-export const fourth = "old"
-// END baseline
+export const fourth = "new"
+// END edited
diff --git a/diff-color-fixture/03-prose.md b/diff-color-fixture/03-prose.md
index 4d5b31e3b9..7d01146b6a 100644
--- a/diff-color-fixture/03-prose.md
+++ b/diff-color-fixture/03-prose.md
@@ -1,21 +1,21 @@
-# Before: plain-text review
+# After: plain-text review
Unchanged prose should remain quiet, not compete with changed rows.
-Replace one contiguous phrase: the original checkout experience stays here.
-Several little edits: red apple, cold tea, slow train.
-One character: ticket 7.
-before — the middle and the end remain unchanged.
-The start remains — before — the end remains.
-The start and the middle remain — before
-Punctuation only: hello, world!
-Whitespace only: one two three
-Trailing whitespace only.
-Delete this sentence without a replacement.
+Replace one contiguous phrase: the redesigned payment summary stays here.
+Several little edits: green apple, warm tea, fast train.
+One character: ticket 8.
+after — the middle and the end remain unchanged.
+The start remains — after — the end remains.
+The start and the middle remain — after
+Punctuation only: hello; world?
+Whitespace only: one two three
+Trailing whitespace only.
This unchanged sentence separates a deletion from an addition.
+Add this sentence without a corresponding deletion.
-Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
+Long prose: The redesigned payment flow asks the reader to review the new confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final new phrase appears near the far right edge.
-**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
+**Syntax-rich Markdown** competes with `inline code`, [links](https://example.com/new), and _emphasis_.
-Final sentence before.
+Final sentence after.
diff --git a/diff-color-fixture/04-many-rows.ts b/diff-color-fixture/04-many-rows.ts
index 7a8c6ecd49..b3e544ae5f 100644
--- a/diff-color-fixture/04-many-rows.ts
+++ b/diff-color-fixture/04-many-rows.ts
@@ -10,46 +10,46 @@ export const records = [
{ id: "row-008", state: "pending", count: 8 },
{ id: "row-009", state: "pending", count: 9 },
{ id: "row-010", state: "pending", count: 10 },
- { id: "row-011", state: "pending", count: 11 },
- { id: "row-012", state: "pending", count: 12 },
- { id: "row-013", state: "pending", count: 13 },
- { id: "row-014", state: "pending", count: 14 },
- { id: "row-015", state: "pending", count: 15 },
- { id: "row-016", state: "pending", count: 16 },
- { id: "row-017", state: "pending", count: 17 },
- { id: "row-018", state: "pending", count: 18 },
- { id: "row-019", state: "pending", count: 19 },
- { id: "row-020", state: "pending", count: 20 },
- { id: "row-021", state: "pending", count: 21 },
- { id: "row-022", state: "pending", count: 22 },
- { id: "row-023", state: "pending", count: 23 },
- { id: "row-024", state: "pending", count: 24 },
- { id: "row-025", state: "pending", count: 25 },
- { id: "row-026", state: "pending", count: 26 },
- { id: "row-027", state: "pending", count: 27 },
- { id: "row-028", state: "pending", count: 28 },
- { id: "row-029", state: "pending", count: 29 },
- { id: "row-030", state: "pending", count: 30 },
- { id: "row-031", state: "pending", count: 31 },
- { id: "row-032", state: "pending", count: 32 },
- { id: "row-033", state: "pending", count: 33 },
- { id: "row-034", state: "pending", count: 34 },
- { id: "row-035", state: "pending", count: 35 },
- { id: "row-036", state: "pending", count: 36 },
- { id: "row-037", state: "pending", count: 37 },
- { id: "row-038", state: "pending", count: 38 },
- { id: "row-039", state: "pending", count: 39 },
- { id: "row-040", state: "pending", count: 40 },
- { id: "row-041", state: "pending", count: 41 },
- { id: "row-042", state: "pending", count: 42 },
- { id: "row-043", state: "pending", count: 43 },
- { id: "row-044", state: "pending", count: 44 },
- { id: "row-045", state: "pending", count: 45 },
- { id: "row-046", state: "pending", count: 46 },
- { id: "row-047", state: "pending", count: 47 },
- { id: "row-048", state: "pending", count: 48 },
- { id: "row-049", state: "pending", count: 49 },
- { id: "row-050", state: "pending", count: 50 },
+ { id: "row-011", state: "ready", count: 111 },
+ { id: "row-012", state: "ready", count: 112 },
+ { id: "row-013", state: "ready", count: 113 },
+ { id: "row-014", state: "ready", count: 114 },
+ { id: "row-015", state: "ready", count: 115 },
+ { id: "row-016", state: "ready", count: 116 },
+ { id: "row-017", state: "ready", count: 117 },
+ { id: "row-018", state: "ready", count: 118 },
+ { id: "row-019", state: "ready", count: 119 },
+ { id: "row-020", state: "ready", count: 120 },
+ { id: "row-021", state: "ready", count: 121 },
+ { id: "row-022", state: "ready", count: 122 },
+ { id: "row-023", state: "ready", count: 123 },
+ { id: "row-024", state: "ready", count: 124 },
+ { id: "row-025", state: "ready", count: 125 },
+ { id: "row-026", state: "ready", count: 126 },
+ { id: "row-027", state: "ready", count: 127 },
+ { id: "row-028", state: "ready", count: 128 },
+ { id: "row-029", state: "ready", count: 129 },
+ { id: "row-030", state: "ready", count: 130 },
+ { id: "row-031", state: "ready", count: 131 },
+ { id: "row-032", state: "ready", count: 132 },
+ { id: "row-033", state: "ready", count: 133 },
+ { id: "row-034", state: "ready", count: 134 },
+ { id: "row-035", state: "ready", count: 135 },
+ { id: "row-036", state: "ready", count: 136 },
+ { id: "row-037", state: "ready", count: 137 },
+ { id: "row-038", state: "ready", count: 138 },
+ { id: "row-039", state: "ready", count: 139 },
+ { id: "row-040", state: "ready", count: 140 },
+ { id: "row-041", state: "ready", count: 141 },
+ { id: "row-042", state: "ready", count: 142 },
+ { id: "row-043", state: "ready", count: 143 },
+ { id: "row-044", state: "ready", count: 144 },
+ { id: "row-045", state: "ready", count: 145 },
+ { id: "row-046", state: "ready", count: 146 },
+ { id: "row-047", state: "ready", count: 147 },
+ { id: "row-048", state: "ready", count: 148 },
+ { id: "row-049", state: "ready", count: 149 },
+ { id: "row-050", state: "ready", count: 150 },
{ id: "row-051", state: "pending", count: 51 },
{ id: "row-052", state: "pending", count: 52 },
{ id: "row-053", state: "pending", count: 53 },
diff --git a/diff-color-fixture/05-deleted.txt b/diff-color-fixture/05-deleted.txt
deleted file mode 100644
index e80ad6b276..0000000000
--- a/diff-color-fixture/05-deleted.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-This entire tracked file will be deleted after the baseline commit.
-Only deletion tint should be present.
-No syntax colors should compete with this text.
-Last deleted row.
diff --git a/diff-color-fixture/06-added.txt b/diff-color-fixture/06-added.txt
new file mode 100644
index 0000000000..f27ed60c9b
--- /dev/null
+++ b/diff-color-fixture/06-added.txt
@@ -0,0 +1,5 @@
+This entire file is newly added, not a replacement for the deleted example.
+Only addition tint should be present.
+Plain text: keep the words readable against the changed-row background.
+A deliberately long added line checks the horizontal extent of the green row while the reviewer compares the quiet surrounding application surface against the color used for new content in both dark and light modes.
+Last added row.
diff --git a/diff-color-fixture/07-styles.css b/diff-color-fixture/07-styles.css
index a757ef7085..4f4937480e 100644
--- a/diff-color-fixture/07-styles.css
+++ b/diff-color-fixture/07-styles.css
@@ -1,32 +1,35 @@
-/* Baseline stylesheet: colors, units, selectors, at-rules. */
+/* Edited stylesheet: colors, units, selectors, at-rules. */
:root {
- --brand: #3b5cf6;
- --radius: 6px;
+ --brand: #0b34f4;
+ --radius: 8px;
--gap: 8px;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.card {
- display: flex;
+ display: grid;
gap: var(--gap);
- padding: 12px 16px;
- border: 1px solid rgba(0, 0, 0, 0.1);
+ padding: 12px 20px;
+ border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: var(--radius);
background: #ffffff;
color: #161616;
+ box-shadow: var(--shadow);
}
-.card:hover {
- background: #fafafa;
+.card:hover,
+.card:focus-visible {
+ background: #f5f5f5;
}
.card[data-state="error"] {
- color: #b82d35;
+ color: #d92e3c;
border-color: #f2bbb7;
}
-@media (max-width: 768px) {
+@media (max-width: 640px) {
.card {
- flex-direction: column;
+ grid-template-columns: 1fr;
padding: 8px;
}
}
@@ -34,8 +37,10 @@
@keyframes fade-in {
from {
opacity: 0;
+ transform: translateY(4px);
}
to {
opacity: 1;
+ transform: none;
}
}
diff --git a/diff-color-fixture/08-config.json b/diff-color-fixture/08-config.json
index 09631d931c..8b6f1485fd 100644
--- a/diff-color-fixture/08-config.json
+++ b/diff-color-fixture/08-config.json
@@ -1,21 +1,22 @@
{
"name": "diff-color-fixture",
- "version": "1.0.0",
+ "version": "1.1.0",
"private": true,
"settings": {
- "theme": "light",
+ "theme": "dark",
"fontSize": 13,
- "lineHeight": 20,
- "wrap": false,
- "tabs": ["review", "files", "terminal"]
+ "lineHeight": 24,
+ "wrap": true,
+ "tabs": ["review", "files", "terminal", "browser"]
},
"features": {
"inlineHighlights": true,
"foldUnchanged": true,
- "splitView": false
+ "splitView": true,
+ "wordDiff": "word-line"
},
"limits": {
- "maxFiles": 100,
+ "maxFiles": 250,
"maxLineLength": 1000
}
}
diff --git a/diff-color-fixture/09-service.py b/diff-color-fixture/09-service.py
index 64fe32db80..5da0737cf5 100644
--- a/diff-color-fixture/09-service.py
+++ b/diff-color-fixture/09-service.py
@@ -1,36 +1,44 @@
-"""Baseline Python service: decorators, f-strings, indentation, comments."""
+"""Edited Python service: decorators, f-strings, indentation, comments."""
import asyncio
-from dataclasses import dataclass
+import logging
+from dataclasses import dataclass, field
+logger = logging.getLogger(__name__)
-@dataclass
+
+@dataclass(frozen=True)
class Review:
id: str
title: str
approved: bool = False
+ tags: list[str] = field(default_factory=list)
class ReviewService:
- def __init__(self, timeout: float = 2.0) -> None:
+ def __init__(self, timeout: float = 5.0) -> None:
self.timeout = timeout
self.cache: dict[str, Review] = {}
async def load(self, review_id: str) -> Review | None:
# Return cached reviews before touching the network.
if review_id in self.cache:
+ logger.debug("cache hit %s", review_id)
return self.cache[review_id]
- await asyncio.sleep(self.timeout)
+ try:
+ await asyncio.wait_for(asyncio.sleep(0), timeout=self.timeout)
+ except asyncio.TimeoutError:
+ return None
return None
def summary(self, review: Review) -> str:
- status = "approved" if review.approved else "pending"
- return f"Review {review.id}: {review.title} ({status})"
+ status = "approved" if review.approved else "needs review"
+ return f"Review {review.id}: {review.title!r} ({status})"
def main() -> None:
service = ReviewService()
- review = Review(id="r-1", title="Checkout")
+ review = Review(id="r-1", title="Checkout", tags=["ui"])
print(service.summary(review))
diff --git a/diff-color-fixture/10-moved-block.ts b/diff-color-fixture/10-moved-block.ts
index fbd5ecba84..214094d8eb 100644
--- a/diff-color-fixture/10-moved-block.ts
+++ b/diff-color-fixture/10-moved-block.ts
@@ -3,11 +3,6 @@ export function first() {
return "first"
}
-export function second() {
- const values = [1, 2, 3]
- return values.map((value) => value * 2)
-}
-
export function third() {
return "third"
}
@@ -16,4 +11,9 @@ export function fourth() {
return "fourth"
}
-export const order = ["first", "second", "third", "fourth"]
+export function second() {
+ const values = [1, 2, 3]
+ return values.map((value) => value * 2)
+}
+
+export const order = ["first", "third", "fourth", "second"]
diff --git a/diff-color-fixture/11-deploy.sh b/diff-color-fixture/11-deploy.sh
index 5390c2fe9f..ae367067d6 100644
--- a/diff-color-fixture/11-deploy.sh
+++ b/diff-color-fixture/11-deploy.sh
@@ -1,17 +1,20 @@
#!/usr/bin/env bash
-# Baseline shell script: variables, quoting, conditionals.
+# Edited shell script: variables, quoting, conditionals.
set -euo pipefail
ENVIRONMENT="${1:-staging}"
-REGION="us-east-1"
-RETRIES=3
+REGION="${REGION:-eu-west-1}"
+RETRIES=5
log() {
- echo "[deploy] $*"
+ printf '[deploy] %s\n' "$*" >&2
}
if [[ "$ENVIRONMENT" == "production" ]]; then
log "Deploying to production in $REGION"
+elif [[ "$ENVIRONMENT" == "preview" ]]; then
+ log "Skipping preview deploy"
+ exit 0
else
log "Deploying to $ENVIRONMENT"
fi
diff --git a/diff-color-fixture/12-pipeline.yml b/diff-color-fixture/12-pipeline.yml
index 1fbd5aa75c..251a7e4372 100644
--- a/diff-color-fixture/12-pipeline.yml
+++ b/diff-color-fixture/12-pipeline.yml
@@ -1,19 +1,20 @@
name: review
on:
push:
- branches: [v2]
+ branches: [v2, dev]
pull_request:
jobs:
check:
- runs-on: ubuntu-latest
- timeout-minutes: 10
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
- name: Install
- run: bun install
+ run: bun install --frozen-lockfile
- name: Check
run: bun run check
env:
CI: true
- LOG_LEVEL: info
+ LOG_LEVEL: debug
diff --git a/diff-color-fixture/13-unicode.md b/diff-color-fixture/13-unicode.md
index 1470ef9294..5bd43c3b1f 100644
--- a/diff-color-fixture/13-unicode.md
+++ b/diff-color-fixture/13-unicode.md
@@ -1,12 +1,12 @@
# Unicode and mixed scripts
-Emoji: ✅ ready, ⚠️ warning, ❌ failed.
-CJK: 代码审查 完成。
-Japanese: レビューを開始します。
+Emoji: ✅ ready, ⚠️ warning, ❌ failed, 🚀 shipped.
+CJK: 代码审查 已完成。
+Japanese: レビューを完了しました。
Korean: 변경 사항을 검토합니다.
-Arabic (RTL): مراجعة التغييرات
+Arabic (RTL): مراجعة جميع التغييرات
Hebrew (RTL): סקירת שינויים
-Accents: café, naïve, résumé.
-Math: a ≤ b, x ≠ y, ∑ values.
+Accents: café, naïve, résumé, déjà vu.
+Math: a ≥ b, x ≠ y, ∏ values.
Box: ┌─┐ │ │ └─┘
Combining: e\u0301 vs é
diff --git a/diff-color-fixture/14-tabs.go b/diff-color-fixture/14-tabs.go
index ab9aca86dd..53e5e11a7c 100644
--- a/diff-color-fixture/14-tabs.go
+++ b/diff-color-fixture/14-tabs.go
@@ -7,11 +7,12 @@ type Review struct {
ID string
Title string
Approved bool
+ Tags []string
}
func (r Review) Summary() string {
status := "pending"
- if r.Approved {
+ if r.Approved {
status = "approved"
}
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
diff --git a/diff-color-fixture/15-empty-to-content.txt b/diff-color-fixture/15-empty-to-content.txt
index e69de29bb2..aa4be0acdb 100644
--- a/diff-color-fixture/15-empty-to-content.txt
+++ b/diff-color-fixture/15-empty-to-content.txt
@@ -0,0 +1,2 @@
+This tracked file was empty in the baseline.
+Every row should render as an addition, without a file-added badge.
diff --git a/diff-color-fixture/16-content-to-empty.txt b/diff-color-fixture/16-content-to-empty.txt
index bb6e474b37..e69de29bb2 100644
--- a/diff-color-fixture/16-content-to-empty.txt
+++ b/diff-color-fixture/16-content-to-empty.txt
@@ -1,3 +0,0 @@
-This tracked file keeps existing but loses all content.
-Every row should render as a deletion.
-The file itself is not deleted.
diff --git a/diff-color-fixture/17-no-newline.txt b/diff-color-fixture/17-no-newline.txt
index 51b84a61e2..dc6945a1fa 100644
--- a/diff-color-fixture/17-no-newline.txt
+++ b/diff-color-fixture/17-no-newline.txt
@@ -1,2 +1,2 @@
First line
-Last line without trailing newline
\ No newline at end of file
+Last line now with trailing newline
diff --git a/diff-color-fixture/18-page.html b/diff-color-fixture/18-page.html
index bc1b2d9daa..9f4df11c24 100644
--- a/diff-color-fixture/18-page.html
+++ b/diff-color-fixture/18-page.html
@@ -2,13 +2,14 @@
<html lang="en">
<head>
<meta charset="utf-8" />
- <title>Review</title>
+ <meta name="viewport" content="width=device-width" />
+ <title>Review changes</title>
</head>
- <body class="light">
- <main id="app" data-state="idle">
- <h1>Old heading</h1>
- <button type="button" disabled>Continue</button>
- <!-- baseline comment -->
+ <body class="dark">
+ <main id="app" data-state="ready">
+ <h1>New heading</h1>
+ <button type="button">Continue</button>
+ <!-- edited comment -->
</main>
</body>
</html>
diff --git a/diff-color-fixture/19-query.sql b/diff-color-fixture/19-query.sql
index b078ad0869..88b64001e0 100644
--- a/diff-color-fixture/19-query.sql
+++ b/diff-color-fixture/19-query.sql
@@ -1,6 +1,6 @@
-SELECT id, title, status
+SELECT id, title, status, author_id
FROM reviews
-WHERE status = 'pending'
- AND created_at > NOW() - INTERVAL '7 days'
-ORDER BY created_at DESC
-LIMIT 50;
+WHERE status IN ('pending', 'blocked')
+ AND created_at > NOW() - INTERVAL '14 days'
+ORDER BY updated_at DESC
+LIMIT 100;
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

@@ -0,0 +1 @@
local.json
@@ -0,0 +1,697 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Diff color tuner</title>
<style>
:root {
color-scheme: light dark;
--bg: light-dark(#fafafa, #121212);
--panel: light-dark(#ffffff, #1b1b1b);
--line: light-dark(#e6e6e6, #2c2c2c);
--text: light-dark(#161616, #ededed);
--muted: light-dark(#6b6b6b, #9a9a9a);
--accent: #3b5cf6;
font: 13px/20px Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); }
header {
position: sticky; top: 0; z-index: 5; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line);
}
header h1 { font-size: 14px; line-height: 20px; margin: 0 8px 0 0; font-weight: 600; }
button, select, input[type="text"] {
font: inherit; color: inherit; background: var(--panel); border: 1px solid var(--line);
border-radius: 6px; height: 28px; padding: 0 8px;
}
button { cursor: default; }
button:hover { background: light-dark(#f2f2f2, #242424); }
label.toggle { display: inline-flex; gap: 6px; align-items: center; }
#status { color: var(--muted); margin-left: auto; font-variant-numeric: tabular-nums; }
main { padding: 12px 16px 48px; max-width: 1180px; }
h2 { font-size: 12px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 20px 0 8px; font-weight: 600; }
dialog { width: min(720px, calc(100vw - 32px)); padding: 16px; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0004; }
.sets { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 8px; }
.sets label { font-weight: 600; }
#set { min-width: 200px; }
#name-input { width: 100%; margin: 8px 0 12px; }
dialog::backdrop { background: #0006; }
dialog h3 { margin: 0 0 4px; font-size: 14px; line-height: 20px; }
#paste-text { width: 100%; height: 280px; margin-top: 8px; padding: 8px; resize: vertical; color: var(--text); background: var(--bg);
border: 1px solid var(--line); border-radius: 6px; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; }
#paste-result { min-height: 16px; }
#paste-result[data-error] { color: #d92e3c; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
h2 { display: flex; align-items: center; gap: 12px; }
.group-toggle { text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 12px; color: var(--text); }
.grid.off { opacity: .45; }
.grid { display: grid; grid-template-columns: minmax(220px, 1.1fr) 1fr 1fr; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); overflow: hidden; }
.grid > div { padding: 8px 10px; border-top: 1px solid var(--line); min-width: 0; }
.grid > .head { border-top: 0; font-weight: 600; color: var(--muted); font-size: 12px; line-height: 16px; }
.slot-label { font-weight: 500; }
.slot-help { color: var(--muted); font-size: 12px; line-height: 16px; }
.cell { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.cell[data-mode="dark"] { background: light-dark(#f6f6f6, #161616); }
.swatch { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--line); flex: none;
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
background-size: 8px 8px; background-position: 0 0, 4px 4px; position: relative; overflow: hidden; }
.swatch::after { content: ""; position: absolute; inset: 0; background: var(--swatch, transparent); }
.swatch[data-current]::after { background: repeating-linear-gradient(-45deg, transparent 0 4px, light-dark(#0001, #fff1) 4px 8px); }
input[type="color"] { width: 36px; height: 28px; padding: 0 2px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
input[type="range"] { width: 90px; }
button.token { display: inline-flex; align-items: center; gap: 6px; min-width: 200px; max-width: 100%; text-align: left; }
button.token .token-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
button.token .caret { color: var(--muted); }
.mini { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line); background: var(--swatch, transparent); flex: none; }
#picker { position: fixed; z-index: 50; width: 560px; max-width: calc(100vw - 16px); max-height: min(520px, calc(100vh - 16px)); display: flex; flex-direction: column;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0003; }
.picker-head { display: flex; gap: 8px; align-items: center; padding: 8px; border-bottom: 1px solid var(--line); }
#picker-search { flex: 1; }
#picker-mode { color: var(--muted); font-size: 12px; }
#picker-body { overflow: auto; padding: 4px 8px 8px; }
.family { margin-top: 8px; }
.family-name { color: var(--muted); font-size: 11px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip { width: 36px; height: 28px; padding: 0; border-radius: 5px; border: 1px solid var(--line); position: relative; overflow: hidden;
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
background-size: 8px 8px; background-position: 0 0, 4px 4px; }
.chip::after { content: ""; position: absolute; inset: 0; background: var(--swatch); }
.chip span { position: absolute; z-index: 1; inset: auto 0 1px; font-size: 9px; line-height: 10px; text-align: center; color: var(--chip-text); }
.chip[aria-selected="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
.rows { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 8px; }
.row { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 6px; border: 0; background: transparent; text-align: left; min-width: 0; }
.row:hover, .row[aria-selected="true"] { background: light-dark(#f0f0f0, #262626); }
.row[aria-selected="true"] { box-shadow: inset 0 0 0 1px var(--accent); }
.row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
.alpha { display: inline-flex; align-items: center; gap: 2px; color: var(--muted); }
.alpha-input { width: 56px; height: 28px; padding: 0 6px; font: inherit; font-variant-numeric: tabular-nums; color: var(--text);
background: var(--panel); border: 1px solid var(--line); border-radius: 6px; text-align: right; }
.hidden { display: none !important; }
pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow: auto; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; max-height: 360px; }
.note { color: var(--muted); max-width: 820px; }
</style>
</head>
<body>
<header>
<h1>Diff color tuner</h1>
<label class="toggle"><input type="checkbox" id="enabled" checked /> Overrides on</label>
<button id="undo" disabled title="Undo last change (⌘Z)">Undo</button>
<button id="redo" disabled title="Redo (⇧⌘Z)">Redo</button>
<button id="reset">Reset all</button>
<button id="copy">Copy CSS</button>
<button id="paste">Paste CSS</button>
<span id="status">Loading…</span>
</header>
<main>
<div class="sets">
<label for="set">Override set</label>
<select id="set"></select>
<button id="set-new">New</button>
<button id="set-duplicate">Duplicate</button>
<button id="set-rename">Rename</button>
<button id="set-delete">Delete</button>
<span id="set-note" class="slot-help"></span>
</div>
<p class="note">
Each change is pushed live into open diff views in the dev app and saved to
<code>packages/session-ui/src/pierre/diff-color-tuning.ts</code>. “Current” leaves the branch's colors untouched.
Picked fills are exact final colors; tokens resolve per mode from OpenCode v2 tokens.
</p>
<div id="groups"></div>
<h2>Generated CSS</h2>
<pre id="css"></pre>
</main>
<dialog id="name-dialog">
<form method="dialog">
<h3 id="name-title">Name</h3>
<input type="text" id="name-input" spellcheck="false" autocomplete="off" />
<div class="dialog-actions">
<button value="cancel">Cancel</button>
<button id="name-ok" value="ok" class="primary">Save</button>
</div>
</form>
</dialog>
<dialog id="paste-dialog">
<form method="dialog">
<h3>Paste CSS</h3>
<p class="slot-help">Paste CSS copied from this tuner. It replaces the current settings; Undo restores them.</p>
<textarea id="paste-text" spellcheck="false" placeholder="/* diff-color-tuner: … */"></textarea>
<p id="paste-result" class="slot-help"></p>
<div class="dialog-actions">
<button value="cancel">Cancel</button>
<button id="paste-apply" value="apply" class="primary">Apply</button>
</div>
</form>
</dialog>
<div id="picker" class="hidden" role="dialog" aria-label="Choose a v2 token">
<div class="picker-head">
<input type="text" id="picker-search" placeholder="Search tokens, e.g. red, state, 1200" spellcheck="false" autocomplete="off" />
<span id="picker-mode"></span>
</div>
<div id="picker-body"></div>
</div>
<script type="module">
const LIGHT = ':host(:not([data-color-scheme="dark"]))'
const DARK = ':host([data-color-scheme="dark"])'
const ADD = '[data-line-type="change-addition"]'
const DEL = '[data-line-type="change-deletion"]'
const CONTEXT = ':is([data-line-type="context"], [data-line-type="context-expanded"])'
const ROW = ':is([data-line], [data-no-newline])'
const GUTTER = ':is([data-column-number], [data-gutter-buffer])'
const exactRow = (sel) => (host, v) => `${host} ${sel} { --diffs-diff-line-mix-target: ${v}; --mix-light: 0%; --mix-dark: 0%; }`
const hostVar = (name) => (host, v) => `${host} { ${name}: ${v}; }`
const scopedVar = (name) => (host, v) => `${host} :is([data-diff], [data-file]) { ${name}: ${v}; }`
const direct = (sel, decl) => (host, v) => `${host} ${sel} { ${decl(v)} }`
// Syntax colors are inline style attributes on the token spans, so only !important can override them.
const inlineText = (line) => (host, v) =>
`${host} ${line} [data-diff-span], ${host} ${line} [data-diff-span] * { color: ${v} !important; }`
const GROUPS = [
["Backgrounds", [
{ id: "bg", label: "File background", help: "--diffs-bg. Base for context rows and Pierre's derived mixes.", rule: hostVar("--diffs-bg"), light: "v2-grey-50", dark: "v2-grey-1100" },
{ id: "context-row", label: "Unchanged row", help: "Context code rows.", rule: exactRow(`${CONTEXT}${ROW}`), light: "v2-grey-50", dark: "v2-grey-1100" },
{ id: "context-gutter", label: "Unchanged gutter", help: "Context line-number cells.", rule: exactRow(`${CONTEXT}${GUTTER}`), light: "v2-grey-50", dark: "v2-grey-1100" },
{ id: "fold-row", label: "Folded “unmodified lines” row", help: "--diffs-bg-separator-override.", rule: hostVar("--diffs-bg-separator-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
{ id: "buffer-bg", label: "Split empty side", help: "--diffs-bg-context-gutter-override (blank side of split rows).", rule: hostVar("--diffs-bg-context-gutter-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
{ id: "buffer-hatch", label: "Split empty side hatch", help: "--diffs-bg-buffer-override (Pierre stripes; OpenCode currently hides them).", rule: hostVar("--diffs-bg-buffer-override"), light: "v2-grey-200", dark: "v2-grey-900" },
{ id: "hover", label: "Hover tint", help: "--diffs-bg-hover-override. Mixed in at ~3% light / ~9% dark, not exact.", rule: hostVar("--diffs-bg-hover-override"), light: "v2-grey-1200", dark: "v2-grey-50" },
{ id: "selection", label: "Selected lines", help: "--diffs-selection-base (row and number tints derive from it).", rule: scopedVar("--diffs-selection-base"), light: "v2-background-bg-accent", dark: "v2-background-bg-accent" },
{ id: "comment", label: "Comment annotation", help: "--diffs-comment-bg.", rule: scopedVar("--diffs-comment-bg"), light: "v2-blue-100", dark: "v2-blue-1200" },
]],
["Additions", [
{ id: "add-row", label: "Added row", help: "Exact fill of added code rows.", rule: exactRow(`${ADD}${ROW}`), light: "v2-green-100", dark: "v2-green-1200" },
{ id: "add-gutter", label: "Added gutter", help: "Exact fill of added line-number cells.", rule: exactRow(`${ADD}${GUTTER}`), light: "v2-green-100", dark: "v2-green-1200" },
{ id: "add-inline", label: "Added inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${ADD} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-green-200", dark: "v2-green-1100" },
{ id: "add-inline-text", label: "Added inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(ADD), light: "v2-green-1000", dark: "v2-green-300" },
{ id: "add-bar", label: "Added change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${ADD}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
{ id: "add-number", label: "Added line-number text", help: "--diffs-fg-number-addition-override.", rule: hostVar("--diffs-fg-number-addition-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
{ id: "add-seed", label: "Addition seed", help: "--diffs-addition-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-addition-color-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
]],
["Deletions", [
{ id: "del-row", label: "Deleted row", help: "Exact fill of deleted code rows.", rule: exactRow(`${DEL}${ROW}`), light: "v2-red-100", dark: "v2-red-1200" },
{ id: "del-gutter", label: "Deleted gutter", help: "Exact fill of deleted line-number cells.", rule: exactRow(`${DEL}${GUTTER}`), light: "v2-red-100", dark: "v2-red-1200" },
{ id: "del-inline", label: "Deleted inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${DEL} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-red-200", dark: "v2-red-1100" },
{ id: "del-inline-text", label: "Deleted inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(DEL), light: "v2-red-1000", dark: "v2-red-300" },
{ id: "del-bar", label: "Deleted change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${DEL}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
{ id: "del-number", label: "Deleted line-number text", help: "--diffs-fg-number-deletion-override.", rule: hostVar("--diffs-fg-number-deletion-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
{ id: "del-seed", label: "Deletion seed", help: "--diffs-deletion-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-deletion-color-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
]],
["Text", [
{ id: "number", label: "Unchanged line-number text", help: "--diffs-fg-number-override. Also fold labels unless set below.", rule: hostVar("--diffs-fg-number-override"), light: "v2-text-text-faint", dark: "v2-text-text-faint" },
{ id: "fold-text", label: "Fold label and expand icon", help: "“N unmodified lines” text and expand button.", rule: direct(":is([data-separator-content], [data-expand-button])", (v) => `color: ${v};`), light: "v2-text-text-muted", dark: "v2-text-text-muted" },
]],
]
const SLOTS = GROUPS.flatMap((group) => group[1])
let tokens = []
let tokenMap = new Map()
let state = { enabled: true, slots: {} }
let liveTimer, saveTimer, liveBusy = false, livePending = false
const $ = (sel) => document.querySelector(sel)
const status = (text) => ($("#status").textContent = text)
function hex8(hex, alpha) {
return alpha >= 100 ? hex : hex + Math.round((alpha / 100) * 255).toString(16).padStart(2, "0")
}
function tokenHex(name, mode) {
const value = tokenMap.get(name)?.[mode]
return value && /^#[0-9a-f]{6}/i.test(value) ? value.slice(0, 7) : "#888888"
}
function valueOf(entry) {
if (!entry || entry.kind === "current") return null
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
if (!tokenMap.has(entry.token)) return null
return entry.alpha >= 100 ? `var(--${entry.token})` : `rgb(from var(--${entry.token}) r g b / ${entry.alpha / 100})`
}
function swatchOf(entry, mode) {
if (!entry || entry.kind === "current") return null
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
const value = tokenMap.get(entry.token)?.[mode]
if (!value) return null
return entry.alpha >= 100 ? value : `rgb(from ${value} r g b / ${entry.alpha / 100})`
}
function buildCSS() {
if (!state.enabled) return ""
const active = GROUPS.filter(([title]) => !state.disabledGroups?.[title]).flatMap((group) => group[1])
return [["light", LIGHT], ["dark", DARK]]
.flatMap(([mode, host]) =>
active.flatMap((slot) => {
const value = valueOf(state.slots[slot.id]?.[mode])
return value ? [`/* ${mode}: ${slot.label} */\n${slot.rule(host, value)}`] : []
}),
)
.join("\n")
}
function entryFor(slot, mode) {
state.slots[slot.id] ??= {}
state.slots[slot.id][mode] ??= { kind: "current" }
return state.slots[slot.id][mode]
}
function render() {
const root = $("#groups")
root.textContent = ""
for (const [title, slots] of GROUPS) {
const heading = document.createElement("h2")
heading.innerHTML = `<span></span><label class="toggle group-toggle"><input type="checkbox" /> <span>On</span></label>`
heading.firstElementChild.textContent = title
const enabled = !state.disabledGroups?.[title]
const toggle = heading.querySelector("input")
toggle.checked = enabled
heading.querySelector(".group-toggle span").textContent = enabled ? "On" : "Off"
toggle.onchange = () => {
state.disabledGroups ??= {}
if (toggle.checked) delete state.disabledGroups[title]
else state.disabledGroups[title] = true
render()
changed(true)
}
const grid = document.createElement("div")
grid.className = "grid"
grid.classList.toggle("off", !enabled)
grid.innerHTML = `<div class="head">Slot</div><div class="head">Light</div><div class="head">Dark</div>`
for (const slot of slots) {
const info = document.createElement("div")
info.innerHTML = `<div class="slot-label"></div><div class="slot-help"></div>`
info.children[0].textContent = slot.label
info.children[1].textContent = slot.help
grid.append(info, cell(slot, "light"), cell(slot, "dark"))
}
root.append(heading, grid)
}
$("#enabled").checked = state.enabled
$("#css").textContent = buildCSS() || "/* No overrides */"
}
function cell(slot, mode) {
const entry = entryFor(slot, mode)
const el = document.createElement("div")
el.className = "cell"
el.dataset.mode = mode
el.innerHTML = `
<span class="swatch"></span>
<select class="kind"><option value="current">Current</option><option value="color">Color</option><option value="token">v2 token</option></select>
<input type="color" class="hex" />
<button type="button" class="token"><span class="mini"></span><span class="token-name"></span><span class="caret">▾</span></button>
<input type="range" class="alpha-range" min="0" max="100" step="1" />
<span class="alpha"><input type="number" class="alpha-input" min="0" max="100" step="any" aria-label="Opacity percent" /><span>%</span></span>`
const swatch = el.querySelector(".swatch")
const kind = el.querySelector(".kind")
const hex = el.querySelector(".hex")
const token = el.querySelector(".token")
const alpha = el.querySelector(".alpha-range")
const alphaText = el.querySelector(".alpha")
const alphaInput = el.querySelector(".alpha-input")
const sync = () => {
kind.value = entry.kind
hex.classList.toggle("hidden", entry.kind !== "color")
token.classList.toggle("hidden", entry.kind !== "token")
alpha.classList.toggle("hidden", entry.kind === "current")
alphaText.classList.toggle("hidden", entry.kind === "current")
if (entry.kind === "color") hex.value = entry.hex
if (entry.kind === "token") {
token.querySelector(".token-name").textContent = entry.token
token.querySelector(".mini").style.setProperty("--swatch", tokenMap.get(entry.token)?.[mode] ?? "transparent")
}
alpha.value = entry.alpha ?? 100
if (document.activeElement !== alphaInput) alphaInput.value = entry.alpha ?? 100
const color = swatchOf(entry, mode)
if (color) swatch.style.setProperty("--swatch", color)
swatch.toggleAttribute("data-current", !color)
swatch.title = entry.kind === "token" ? `${entry.token}: ${tokenMap.get(entry.token)?.[mode] ?? "unknown token"}` : (color ?? "Current (unchanged)")
token.style.outline = entry.kind === "token" && !tokenMap.has(entry.token) ? "1px solid #d92e3c" : ""
}
kind.onchange = () => {
const previous = { ...entry }
entry.kind = kind.value
entry.alpha ??= 100
if (entry.kind === "color") entry.hex = previous.kind === "token" ? tokenHex(previous.token, mode) : (previous.hex ?? tokenHex(slot[mode], mode))
if (entry.kind === "token") entry.token = previous.token ?? slot[mode]
sync()
changed(true)
}
hex.oninput = () => { entry.hex = hex.value; sync(); changed(false) }
hex.onchange = () => changed(true)
token.onclick = () => openPicker(token, mode, entry.token, (name) => { entry.token = name; sync(); changed(true) })
alpha.oninput = () => { entry.alpha = Number(alpha.value); sync(); changed(false) }
alpha.onchange = () => changed(true)
alphaInput.oninput = () => {
const value = Number(alphaInput.value)
if (alphaInput.value === "" || !Number.isFinite(value)) return
entry.alpha = Math.min(100, Math.max(0, value))
sync()
changed(false)
}
alphaInput.onchange = () => { alphaInput.value = entry.alpha ?? 100; changed(true) }
alphaInput.onkeydown = (event) => { if (event.key === "Enter") alphaInput.blur() }
sync()
return el
}
const RAMP_ORDER = ["grey", "red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink", "alpha-dark", "alpha-light"]
const picker = { anchor: null, mode: "light", selected: "", onPick: null }
function familyOf(name) {
const ramp = /^v2-([a-z]+(?:-dark|-light)?)-(\d+)$/.exec(name)
if (ramp && RAMP_ORDER.includes(ramp[1])) return { family: ramp[1], step: ramp[2], ramp: true }
return { family: name.split("-")[1] ?? "other", step: "", ramp: false }
}
function readableOn(value) {
const hex = /^#([0-9a-f]{6})/i.exec(value)?.[1]
if (!hex) return "#000"
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.55 ? "#0009" : "#fffb"
}
function openPicker(anchor, mode, selected, onPick) {
Object.assign(picker, { anchor, mode, selected, onPick })
$("#picker-mode").textContent = `${mode} values`
$("#picker-search").value = ""
$("#picker").classList.remove("hidden")
renderPicker()
placePicker()
$("#picker-search").focus()
const current = $(`#picker-body [aria-selected="true"]`)
$("#picker-body").scrollTop = current ? current.offsetTop - $("#picker-body").offsetTop - 60 : 0
}
function closePicker() {
$("#picker").classList.add("hidden")
picker.anchor?.focus()
picker.anchor = null
}
function placePicker() {
const el = $("#picker")
const rect = picker.anchor.getBoundingClientRect()
const height = el.offsetHeight
const top = rect.bottom + 4 + height > innerHeight ? Math.max(8, rect.top - 4 - height) : rect.bottom + 4
el.style.top = `${top}px`
el.style.left = `${Math.min(Math.max(8, rect.left), innerWidth - el.offsetWidth - 8)}px`
}
function renderPicker() {
const terms = $("#picker-search").value.toLowerCase().split(/\s+/).filter(Boolean)
const matches = tokens.filter((token) => terms.every((term) => token.name.includes(term)))
const groups = new Map()
for (const token of matches) {
const info = familyOf(token.name)
if (!groups.has(info.family)) groups.set(info.family, { ramp: info.ramp, items: [] })
groups.get(info.family).items.push({ token, step: info.step })
}
const order = [...groups.keys()].sort((a, b) => {
const ia = RAMP_ORDER.indexOf(a), ib = RAMP_ORDER.indexOf(b)
if (ia !== -1 || ib !== -1) return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
return a.localeCompare(b)
})
const body = $("#picker-body")
body.textContent = ""
if (!matches.length) body.innerHTML = `<p class="slot-help">No tokens match.</p>`
for (const family of order) {
const group = groups.get(family)
const section = document.createElement("div")
section.className = "family"
const title = document.createElement("div")
title.className = "family-name"
title.textContent = family
const list = document.createElement("div")
list.className = group.ramp ? "chips" : "rows"
for (const item of group.items) {
const value = item.token[picker.mode]
const button = document.createElement("button")
button.type = "button"
button.title = `${item.token.name}\nlight ${item.token.light}\ndark ${item.token.dark}`
button.setAttribute("aria-selected", String(item.token.name === picker.selected))
button.style.setProperty("--swatch", value)
if (group.ramp) {
button.className = "chip"
button.style.setProperty("--chip-text", readableOn(value))
button.innerHTML = `<span>${item.step}</span>`
} else {
button.className = "row"
button.innerHTML = `<span class="mini"></span><span class="name"></span>`
button.querySelector(".name").textContent = item.token.name.replace(/^v2-/, "")
}
button.onclick = () => {
picker.onPick(item.token.name)
closePicker()
}
list.append(button)
}
section.append(title, list)
body.append(section)
}
}
$("#picker-search").oninput = renderPicker
$("#picker-search").onkeydown = (event) => {
if (event.key === "Escape") closePicker()
if (event.key === "Enter") $("#picker-body button")?.click()
}
document.addEventListener("mousedown", (event) => {
if (!picker.anchor || $("#picker").contains(event.target) || picker.anchor.contains(event.target)) return
closePicker()
})
window.addEventListener("resize", () => picker.anchor && placePicker())
document.addEventListener("scroll", () => picker.anchor && placePicker(), true)
// History holds committed snapshots only, so one drag of a picker or slider is one undo step.
const history = { undo: [], redo: [], committed: "" }
function record() {
const snapshot = JSON.stringify(state)
if (snapshot === history.committed) return
history.undo.push(history.committed)
history.redo = []
history.committed = snapshot
syncHistory()
}
function travel(from, to) {
if (!from.length) return
to.push(history.committed)
history.committed = from.pop()
state = JSON.parse(history.committed)
render()
syncHistory()
changed(false)
clearTimeout(saveTimer)
saveTimer = setTimeout(save, 150)
}
function syncHistory() {
$("#undo").disabled = !history.undo.length
$("#redo").disabled = !history.redo.length
}
$("#undo").onclick = () => travel(history.undo, history.redo)
$("#redo").onclick = () => travel(history.redo, history.undo)
document.addEventListener("keydown", (event) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return
if (event.target instanceof HTMLInputElement && ["text", "number"].includes(event.target.type)) return
event.preventDefault()
if (event.shiftKey) travel(history.redo, history.undo)
else travel(history.undo, history.redo)
})
// Live pushes stay responsive while dragging; file writes are debounced to limit dev-server hot reloads.
function changed(commit) {
if (commit) record()
const css = buildCSS()
$("#css").textContent = css || "/* No overrides */"
clearTimeout(liveTimer)
liveTimer = setTimeout(() => live(css), 30)
clearTimeout(saveTimer)
saveTimer = setTimeout(save, commit ? 150 : 700)
status("Saving…")
}
async function live(css) {
if (liveBusy) { livePending = true; return }
liveBusy = true
await fetch("/api/live", { method: "POST", body: JSON.stringify({ css }) }).catch(() => {})
liveBusy = false
if (livePending) { livePending = false; live(buildCSS()) }
}
async function save() {
// The committed reference set is read-only; the first edit forks it so the reference stays intact.
if (current.readonly) {
const name = `${state.name} copy`
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set: state }) }).then((r) => r.json())
state.name = name
state.readonly = false
await loadSets(created.id)
$("#set-note").textContent = `Reference is read-only, so your edits were saved as “${name}”.`
}
const response = await fetch(`/api/sets/${current.id}`, { method: "PUT", body: JSON.stringify({ set: state, css: buildCSS() }) }).catch(() => null)
if (!response?.ok) return status("Save failed — is server.ts running?")
const result = await response.json()
status(`Saved ${new Date().toLocaleTimeString()} · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
}
$("#enabled").onchange = () => { state.enabled = $("#enabled").checked; render(); changed(true) }
$("#reset").onclick = () => { if (!confirm("Reset every slot to Current?")) return; state = { ...state, enabled: true, disabledGroups: {}, slots: {} }; render(); changed(true) }
// Shared CSS carries the exact settings in a header so pasting restores token choices, opacity and category toggles.
function shareableCSS() {
const css = buildCSS()
if (!css) return ""
const slots = Object.fromEntries(
Object.entries(state.slots).flatMap(([id, modes]) => {
const active = Object.fromEntries(Object.entries(modes).filter(([, entry]) => entry.kind !== "current"))
return Object.keys(active).length ? [[id, active]] : []
}),
)
const settings = { enabled: state.enabled, disabledGroups: state.disabledGroups ?? {}, slots }
return `/* diff-color-tuner: ${JSON.stringify(settings)} */\n${css}`
}
function parseShared(text) {
// Chat apps often render "*…*" as Markdown emphasis and drop the asterisks, so don't require comment delimiters.
// The settings JSON is always on one line, so a greedy match within the line is safe.
const header = /diff-color-tuner:\s*(\{.*\})/.exec(text)
if (header) {
const settings = JSON.parse(header[1])
const slots = Object.fromEntries(Object.entries(settings.slots ?? {}).filter(([id]) => SLOTS.some((slot) => slot.id === id)))
return { state: { enabled: settings.enabled ?? true, disabledGroups: settings.disabledGroups ?? {}, slots }, count: Object.values(slots).reduce((sum, modes) => sum + Object.keys(modes).length, 0) }
}
// Fallback for CSS without the header: read each "/* mode: Label */" comment and the first color value after it.
const slots = {}
let count = 0
for (const match of text.matchAll(/^\s*\/\*?\s*(light|dark):\s*(.+?)\s*\*?\/\s*\n\s*([^\n]+)/gm)) {
const label = (value) => value.replace(/[“”"]/g, '"').replace(/\s*\*$/, "")
const slot = SLOTS.find((candidate) => label(candidate.label) === label(match[2]))
const entry = slot && parseValue(match[3].slice(match[3].indexOf("{")))
if (!entry) continue
slots[slot.id] ??= {}
slots[slot.id][match[1]] = entry
count++
}
return count ? { state: { enabled: true, disabledGroups: {}, slots }, count } : null
}
function parseValue(rule) {
const alphaToken = /rgb\(from var\(--([\w-]+)\) r g b \/ ([\d.]+)\)/.exec(rule)
if (alphaToken && tokenMap.has(alphaToken[1])) return { kind: "token", token: alphaToken[1], alpha: Number(alphaToken[2]) * 100 }
const token = /var\(--(v2-[\w-]+)\)/.exec(rule)
if (token && tokenMap.has(token[1])) return { kind: "token", token: token[1], alpha: 100 }
const hex = /#([0-9a-f]{6})([0-9a-f]{2})?\b/i.exec(rule)
if (hex) return { kind: "color", hex: `#${hex[1].toLowerCase()}`, alpha: hex[2] ? Math.round((parseInt(hex[2], 16) / 255) * 1000) / 10 : 100 }
return null
}
$("#paste").onclick = () => {
$("#paste-text").value = ""
$("#paste-result").textContent = ""
$("#paste-result").removeAttribute("data-error")
$("#paste-dialog").showModal()
$("#paste-text").focus()
}
$("#paste-apply").onclick = (event) => {
let parsed = null
try {
parsed = parseShared($("#paste-text").value)
} catch {}
if (!parsed) {
event.preventDefault()
$("#paste-result").textContent = "No tuner rules found in that text."
$("#paste-result").setAttribute("data-error", "")
return
}
state = { ...parsed.state, name: state.name, readonly: state.readonly }
render()
changed(true)
status(`Applied ${parsed.count} pasted override${parsed.count === 1 ? "" : "s"}`)
}
$("#copy").onclick = async () => {
const css = shareableCSS()
if (!css) return status("Nothing to copy — no active overrides")
const result = await fetch("/api/copy", { method: "POST", body: css }).then((r) => r.json()).catch(() => null)
if (result?.ok) return status(`Copied ${css.split("\n").filter((line) => /^\/\* (light|dark):/.test(line)).length} rules`)
const copied = await navigator.clipboard.writeText(css).then(() => true, () => false)
status(copied ? "CSS copied" : "Copy failed — select the CSS at the bottom manually")
}
let current = { id: "reference", readonly: true }
async function loadSets(selected) {
const data = await fetch("/api/sets").then((r) => r.json())
const id = selected ?? data.active
$("#set").innerHTML = ""
for (const set of data.sets) {
const option = document.createElement("option")
option.value = set.id
option.textContent = set.readonly ? `${set.name} (read-only)` : set.name
$("#set").append(option)
}
const match = data.sets.find((set) => set.id === id) ?? data.sets[0]
current = { id: match.id, readonly: match.readonly }
$("#set").value = current.id
$("#set-rename").disabled = current.readonly
$("#set-delete").disabled = current.readonly
return current.id
}
async function switchSet(id) {
clearTimeout(saveTimer)
await loadSets(id)
state = await fetch(`/api/sets/${current.id}`).then((r) => r.json())
history.undo = []
history.redo = []
history.committed = JSON.stringify(state)
syncHistory()
render()
$("#set-note").textContent = current.readonly ? "Edits to the reference are saved as a new set." : ""
const result = await fetch("/api/active", { method: "POST", body: JSON.stringify({ id: current.id, css: buildCSS() }) }).then((r) => r.json())
status(`Showing “${state.name}” · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
}
// window.prompt isn't available in Electron, so names come from an in-page dialog.
function askName(title, value) {
return new Promise((resolve) => {
const dialog = $("#name-dialog")
$("#name-title").textContent = title
$("#name-input").value = value
dialog.onclose = () => resolve(dialog.returnValue === "ok" ? $("#name-input").value.trim() || null : null)
dialog.returnValue = ""
$("#name-input").onkeydown = (event) => {
if (event.key !== "Enter") return
event.preventDefault()
dialog.close("ok")
}
dialog.showModal()
$("#name-input").select()
})
}
async function createSet(name, set) {
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set }) }).then((r) => r.json())
await switchSet(created.id)
}
$("#set").onchange = () => switchSet($("#set").value)
$("#set-new").onclick = async () => {
const name = await askName("New override set", "My overrides")
if (name) await createSet(name, { enabled: true, disabledGroups: {}, slots: {} })
}
$("#set-duplicate").onclick = async () => {
const name = await askName("Duplicate set", `${state.name} copy`)
if (name) await createSet(name, state)
}
$("#set-rename").onclick = async () => {
const name = await askName("Rename set", state.name)
if (!name) return
state.name = name
await save()
await loadSets(current.id)
}
$("#set-delete").onclick = async () => {
if (!confirm(`Delete “${state.name}”?`)) return
await fetch(`/api/sets/${current.id}`, { method: "DELETE" })
await switchSet("reference")
}
const tokenList = await fetch("/api/tokens").then((r) => r.json())
tokens = tokenList
tokenMap = new Map(tokens.map((token) => [token.name, token]))
await switchSet()
</script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
// Temporary diff color tuner. Delete this directory, packages/session-ui/src/pierre/diff-color-tuning.ts,
// and the matching import/interpolation in packages/session-ui/src/pierre/index.ts when finished.
import path from "node:path"
import { resolveThemeVariantV2 } from "../../packages/ui/src/theme/v2/resolve"
const root = path.resolve(import.meta.dir, "../..")
const setsDir = path.join(import.meta.dir, "sets")
const localPath = path.join(import.meta.dir, "local.json")
const outputPath = path.join(root, "packages/session-ui/src/pierre/diff-color-tuning.ts")
const cdp = process.env.TUNER_CDP ?? "http://127.0.0.1:9222"
const port = Number(process.env.TUNER_PORT ?? 4455)
const theme = await Bun.file(path.join(root, "packages/ui/src/theme/themes/oc-2.json")).json()
// Alpha ramps are static CSS, not part of the resolved theme.
const alpha = Object.fromEntries(
[...(await Bun.file(path.join(root, "packages/ui/src/styles/tokens/colors.css")).text()).matchAll(
/--(v2-alpha-(?:dark|light)-\d+):\s*([^;]+);/g,
)].map((match) => [match[1]!, match[2]!.trim()]),
)
const tokens = buildTokens()
const server = Bun.serve({
port,
hostname: "127.0.0.1",
routes: {
"/": () => new Response(Bun.file(path.join(import.meta.dir, "index.html"))),
"/api/tokens": Response.json(tokens),
"/api/sets": {
GET: async () => Response.json({ active: await activeSet(), sets: await listSets() }),
POST: async (request) => {
const body = await request.json()
const id = await uniqueID(body.name)
await writeSet(id, { ...body.set, name: body.name, readonly: false })
return Response.json({ id })
},
},
"/api/sets/:id": {
GET: async (request) => {
const file = Bun.file(setPath(request.params.id))
return (await file.exists()) ? Response.json(await file.json()) : new Response("Not found", { status: 404 })
},
PUT: async (request) => {
const id = request.params.id
const current = await readSet(id)
if (current?.readonly) return new Response("Read-only set", { status: 403 })
const body = await request.json()
await writeSet(id, { ...body.set, name: body.set.name ?? current?.name ?? id, readonly: false })
return Response.json(await apply(id, body.css))
},
DELETE: async (request) => {
const current = await readSet(request.params.id)
if (!current || current.readonly) return new Response("Cannot delete", { status: 403 })
await Bun.file(setPath(request.params.id)).delete()
return Response.json({ ok: true })
},
},
"/api/active": {
POST: async (request) => {
const body = await request.json()
return Response.json(await apply(body.id, body.css))
},
},
// The embedded browser pane may deny navigator.clipboard, so copy through the OS clipboard instead.
"/api/copy": {
POST: async (request) => {
const process = Bun.spawn(["pbcopy"], { stdin: new Blob([await request.text()]) })
return Response.json({ ok: (await process.exited) === 0 })
},
},
"/api/live": {
POST: async (request) => Response.json({ live: await push((await request.json()).css) }),
},
},
})
console.log(`Diff color tuner: http://127.0.0.1:${server.port}`)
type OverrideSet = { name: string; readonly?: boolean; enabled: boolean; disabledGroups?: Record<string, boolean>; slots: Record<string, unknown> }
function setPath(id: string) {
return path.join(setsDir, `${id.replace(/[^\w-]/g, "")}.json`)
}
async function readSet(id: string): Promise<OverrideSet | undefined> {
const file = Bun.file(setPath(id))
return (await file.exists()) ? file.json() : undefined
}
function writeSet(id: string, set: OverrideSet) {
return Bun.write(setPath(id), JSON.stringify(set, null, 2) + "\n")
}
async function listSets() {
const ids = [...new Bun.Glob("*.json").scanSync(setsDir)].map((file) => file.slice(0, -5))
const sets = await Promise.all(ids.map(async (id) => ({ id, ...(await readSet(id))! })))
return sets
.map((set) => ({ id: set.id, name: set.name, readonly: Boolean(set.readonly) }))
.sort((a, b) => Number(b.readonly) - Number(a.readonly) || a.name.localeCompare(b.name))
}
async function activeSet() {
const file = Bun.file(localPath)
const active = (await file.exists()) ? (await file.json()).active : undefined
return active && (await readSet(active)) ? active : "reference"
}
async function uniqueID(name: string) {
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "set"
const taken = new Set((await listSets()).map((set) => set.id))
return Array.from({ length: 100 }, (_, i) => (i ? `${base}-${i + 1}` : base)).find((id) => !taken.has(id))!
}
// Activating a set always rewrites the tuning module and pushes live CSS, so the dev app shows exactly the selected set.
async function apply(id: string, css: string) {
await Bun.write(localPath, JSON.stringify({ active: id }, null, 2) + "\n")
await Bun.write(outputPath, renderModule(css))
return { live: await push(css) }
}
function renderModule(css: string) {
const escaped = css.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
return `// Temporary diff color tuning output. Generated by diff-color-review-artifacts/tuner; delete with the tuner.\nexport const diffColorTuningCSS = \`${escaped}\`\n`
}
function buildTokens() {
const light = { ...alpha, ...resolveThemeVariantV2(theme.light, false) }
const dark = { ...alpha, ...resolveThemeVariantV2(theme.dark, true) }
const resolve = (map: Record<string, string>, value: string, depth = 0): string => {
const match = /^var\(--([\w-]+)\)$/.exec(value.trim())
if (!match || depth > 8) return value
return map[match[1]!] ? resolve(map, map[match[1]!]!, depth + 1) : value
}
return Object.keys(light)
.filter((name) => !name.includes("elevation") && !name.includes("font"))
.map((name) => ({ name, light: resolve(light, light[name]!), dark: resolve(dark, dark[name] ?? light[name]!) }))
.filter((token) => /^(#|rgb|hsl|oklch)/.test(token.light))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
}
// Live preview: Pierre reads unsafeCSS when a viewer mounts, so already-open diffs need the CSS pushed into their shadow roots.
async function push(css: string) {
const targets: { type: string; webSocketDebuggerUrl: string }[] = await fetch(`${cdp}/json/list`)
.then((response) => response.json())
.catch(() => [])
const counts = await Promise.all(
targets.filter((target) => target.type === "page").map((target) => evaluate(target.webSocketDebuggerUrl, injector(css))),
)
return counts.reduce<number>((sum, count) => sum + (typeof count === "number" ? count : 0), 0)
}
function injector(css: string) {
return `(() => {
window.__diffColorTuning = ${JSON.stringify(css)}
const apply = (host) => {
const root = host.shadowRoot
if (!root) return
let style = root.querySelector("style[data-diff-color-tuning]")
if (!style) {
style = document.createElement("style")
style.dataset.diffColorTuning = ""
root.appendChild(style)
}
if (style.textContent !== window.__diffColorTuning) style.textContent = window.__diffColorTuning
}
const all = () => document.querySelectorAll("diffs-container").forEach(apply)
all()
if (!window.__diffColorTuningObserver) {
let queued = false
window.__diffColorTuningObserver = new MutationObserver(() => {
if (queued) return
queued = true
requestAnimationFrame(() => { queued = false; all() })
})
window.__diffColorTuningObserver.observe(document.body, { childList: true, subtree: true })
}
return document.querySelectorAll("diffs-container").length
})()`
}
function evaluate(url: string, expression: string) {
return new Promise<unknown>((resolve) => {
const socket = new WebSocket(url)
const timer = setTimeout(() => {
socket.close()
resolve(undefined)
}, 2000)
socket.onopen = () =>
socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, returnByValue: true } }))
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data))
if (message.id !== 1) return
clearTimeout(timer)
socket.close()
resolve(message.result?.result?.value)
}
socket.onerror = () => {
clearTimeout(timer)
resolve(undefined)
}
})
}
@@ -0,0 +1,3 @@
*
!.gitignore
!reference.json
@@ -0,0 +1,176 @@
{
"name": "Reference",
"readonly": true,
"enabled": true,
"disabledGroups": {},
"slots": {
"add-row": {
"light": {
"kind": "token",
"token": "v2-green-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-green-800",
"alpha": 10
}
},
"add-gutter": {
"light": {
"kind": "token",
"token": "v2-green-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-green-800",
"alpha": 10
}
},
"add-inline": {
"light": {
"kind": "token",
"token": "v2-green-600",
"alpha": 20
},
"dark": {
"kind": "token",
"token": "v2-green-600",
"alpha": 40
}
},
"add-bar": {
"light": {
"kind": "token",
"token": "v2-green-900",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-green-400",
"alpha": 100
}
},
"add-number": {
"light": {
"kind": "token",
"token": "v2-green-900",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-green-400",
"alpha": 100
}
},
"del-row": {
"light": {
"kind": "token",
"token": "v2-red-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-red-600",
"alpha": 10
}
},
"del-gutter": {
"light": {
"kind": "token",
"token": "v2-red-600",
"alpha": 5
},
"dark": {
"kind": "token",
"token": "v2-red-600",
"alpha": 10
}
},
"del-inline": {
"light": {
"kind": "token",
"token": "v2-red-600",
"alpha": 20
},
"dark": {
"kind": "token",
"token": "v2-red-600",
"alpha": 40
}
},
"del-bar": {
"light": {
"kind": "token",
"token": "v2-red-800",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-red-500",
"alpha": 100
}
},
"del-number": {
"light": {
"kind": "token",
"token": "v2-red-800",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-red-500",
"alpha": 100
}
},
"number": {
"light": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
}
},
"fold-text": {
"light": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-faint",
"alpha": 100
}
},
"add-inline-text": {
"light": {
"kind": "token",
"token": "v2-green-1200",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-base",
"alpha": 100
}
},
"del-inline-text": {
"light": {
"kind": "token",
"token": "v2-red-1200",
"alpha": 100
},
"dark": {
"kind": "token",
"token": "v2-text-text-base",
"alpha": 100
}
}
}
}
@@ -0,0 +1,2 @@
// Temporary diff color tuning output. Generated by diff-color-review-artifacts/tuner; delete with the tuner.
export const diffColorTuningCSS = ``
+51 -6
View File
@@ -1,6 +1,7 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js"
import { lineCommentStyles } from "../components/line-comment-styles"
import { diffColorTuningCSS } from "./diff-color-tuning"
export type DiffProps<T = {}> = FileDiffOptions<T, undefined> & {
before: FileContents
@@ -22,19 +23,29 @@ const unsafeCSS = `
color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-mixer)),
color-mix(in lab, var(--diffs-bg) 95%, var(--diffs-mixer))
);
--diffs-bg-addition-emphasis-override: light-dark(
rgb(from var(--v2-green-600) r g b / 0.2),
rgb(from var(--v2-green-600) r g b / 0.4)
);
--diffs-bg-deletion-emphasis-override: light-dark(
rgb(from var(--v2-red-600) r g b / 0.2),
rgb(from var(--v2-red-600) r g b / 0.4)
);
--diffs-fg-number-override: var(--v2-text-text-faint);
--diffs-fg-number-addition-override: light-dark(var(--v2-green-900), var(--v2-green-400));
--diffs-fg-number-deletion-override: light-dark(var(--v2-red-800), var(--v2-red-500));
}
[data-diff],
[data-file] {
/* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
--diffs-bg-deletion-override: light-dark(
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
rgb(from var(--v2-red-600) r g b / 0.05),
rgb(from var(--v2-red-600) r g b / 0.1)
);
--diffs-bg-deletion-number-override: var(--diffs-bg-deletion-override);
--diffs-bg-addition-override: light-dark(
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
rgb(from var(--v2-green-600) r g b / 0.05),
rgb(from var(--v2-green-800) r g b / 0.1)
);
--diffs-bg-addition-number-override: var(--diffs-bg-addition-override);
--diffs-selection-base: var(--v2-background-bg-accent);
@@ -56,9 +67,13 @@ const unsafeCSS = `
width: 2px;
}
[data-indicators='bars'] [data-column-number][data-line-type='change-addition']::before {
background-color: light-dark(var(--v2-green-900), var(--v2-green-400));
}
[data-indicators='bars'] [data-column-number][data-line-type='change-deletion']::before {
background-image: none;
background-color: var(--diffs-deletion-base);
background-color: light-dark(var(--v2-red-800), var(--v2-red-500));
}
[data-background] [data-column-number] {
@@ -66,6 +81,35 @@ const unsafeCSS = `
--mix-dark: 80%;
}
/* Changed-row overrides are final translucent fills, so Pierre must not blend them into the background again. */
[data-background]
:is([data-line], [data-no-newline], [data-column-number], [data-gutter-buffer]):is(
[data-line-type='change-addition'],
[data-line-type='change-deletion']
) {
--mix-light: 0%;
--mix-dark: 0%;
}
/* Syntax colors are inline styles on token spans, so emphasized text needs !important to stay legible on its highlight. */
[data-line-type='change-addition'] [data-diff-span],
[data-line-type='change-addition'] [data-diff-span] * {
color: light-dark(var(--v2-green-1200), var(--v2-text-text-base)) !important;
}
[data-line-type='change-deletion'] [data-diff-span],
[data-line-type='change-deletion'] [data-diff-span] * {
color: light-dark(var(--v2-red-1200), var(--v2-text-text-base)) !important;
}
:is([data-separator-content], [data-expand-button]) {
color: var(--v2-text-text-faint);
}
[data-expand-button]:hover {
color: var(--diffs-fg);
}
[data-diff-type='split'] [data-additions],
[data-diff-type='split'] [data-additions] [data-gutter],
[data-diff-type='split'] [data-deletions],
@@ -201,6 +245,7 @@ const unsafeCSS = `
${lineCommentStyles}
${diffColorTuningCSS}
`
export function createDefaultOptions<T>(style: FileDiffOptions<T, undefined>["diffStyle"]) {