Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/design/0147-unmaterialized-file-frontier.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,24 +210,24 @@ The buffer is real even before disk exists.
The editor opens immediately with an empty buffer. The footer names the state:

```text
/repo/foo.txt [clean | main | fs:unmaterialized | target:main]
/repo/foo.txt [basis:reading | head:basis | worldline:main | export:pending | admit:main | tick:t0]
```

After typing:

```text
/repo/foo.txt [dirty | main+local | fs:unmaterialized | target:main]
/repo/foo.txt [basis:reading | head:local | worldline:main | export:pending | admit:main | tick:t0]
```

The history/worldline drawer shows:

```text
Worldlines
projection: canonical@t0 + local optimistic | braid active | phase:unconfirmed
s phase r name basis head delta evidence note
> settled C main main 0 +0/-0 canonical@t0 clear
unconfirmed L local canonical@t0 - +local/-0 request:3 optimistic
unconfirmed B visible braid main+local - +local/-0 canonical@t0 active
s phase r name basis head span evidence note
> settled C main main 0 tick:t0 canonical@t0 clear
unconfirmed L local canonical@t0 - local request:3 optimistic
unconfirmed B visible braid main+local - local canonical@t0 active
```

### First Save
Expand Down Expand Up @@ -393,7 +393,7 @@ Deliverables:

- split host path observation from file load;
- open missing paths with empty initial text;
- footer/history label `fs:unmaterialized`;
- footer/history label `export:pending`;
- command-line regression for `:edit missing.txt`;
- no host file write before save.

Expand Down Expand Up @@ -442,7 +442,7 @@ Deliverables:

Lower modes must expose the same facts without relying on color:

- footer text uses explicit `fs:unmaterialized`, `external-frontier`, and
- footer text uses explicit `export:pending`, `external-frontier`, and
`braid active` labels;
- history drawer rows include `External Edit` as text, not just color;
- `:why` states whether the visible buffer is canonical, local, external, or
Expand Down
2 changes: 1 addition & 1 deletion docs/topics/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Jim UI documents use three terminal width profiles:
| Page | Surface |
| --- | --- |
| [Title Screen](title-screen.md) | Startup scene, title browser, and first file-open path. |
| [Editor Chrome](editor-chrome.md) | Source viewport, gutter, status footer, dirty state, and cursor position. |
| [Editor Chrome](editor-chrome.md) | Source viewport, gutter, status footer, causal posture, and cursor position. |
| [Settings Menu](settings-menu.md) | F2 settings drawer, keyboard controls, diagnostics, and change feedback. |
| [Command Line And Completions](command-line-and-completions.md) | Normal-mode `:` input, command help, file completion, and inline suggestions. |
| [Drawers And Panels](drawers-and-panels.md) | File tree, Graft outline, Echo history, diagnostics, and inline why panels. |
Expand Down
16 changes: 9 additions & 7 deletions docs/topics/ui/editor-chrome.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Editor Chrome

Editor chrome is the persistent frame around the current buffer: source
viewport, gutter, header title, footer hints, dirty markers, and coordinate
viewport, gutter, header title, footer hints, causal markers, and coordinate
readouts.

<img src="./editor-chrome.svg" alt="Editor chrome layout across wide, narrow, and xs terminal profiles." />
Expand Down Expand Up @@ -44,8 +44,9 @@ Line number modes:
| `Off` | Hide line numbers. |

Future gutter work should add theme-token-controlled dimming and modified-line
markers. Modified-line markers need a real saved-buffer baseline; they should
not be faked from the generic dirty flag.
markers. Modified and removed line markers should be projections of Echo edit
receipts relative to the current causal basis or checkpoint; they should not be
derived from Git diff, host-file comparison, or a single projection-changed flag.

## Footer

Expand All @@ -54,16 +55,17 @@ The footer is the main low-friction status surface. It should show:
- current mode;
- `line:col` cursor position when a source cursor is active;
- mode-specific hints;
- dirty and materialization posture;
- target branch or runtime posture when available;
- causal basis and head posture;
- export or materialization posture;
- worldline, admission, and tick posture when available;
- command-line input when `:` mode is active.

When settings or another non-source overlay owns focus, the footer should show
that surface's focus state instead of leaking the editor cursor coordinate.

The lower-right status segment currently reports workspace and worldline
posture. It is not a line-diff counter unless the implementation has a
saved-text baseline to compare against.
posture. It is not a Git diff counter; modified and removed line evidence must
come from Echo receipts admitted after the displayed basis.

## Implementation Map

Expand Down
2 changes: 1 addition & 1 deletion docs/topics/ui/editor-chrome.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/topics/ui/overview.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 46 additions & 0 deletions spec/source-viewer.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@ import { pathToFileURL } from "node:url";
import { REPO_ROOT, ensureDistBuilt } from "./dist-helpers.mjs";

const SOURCE_VIEWER_PATH = path.join(REPO_ROOT, "dist", "ui", "source-viewer.js");
const THEMES_PATH = path.join(REPO_ROOT, "dist", "ui", "jedit-themes.js");
const STYLE_PATH = path.join(REPO_ROOT, "dist", "ui", "jedit-theme.js");

async function loadSourceViewerModule() {
await ensureDistBuilt();
return import(pathToFileURL(SOURCE_VIEWER_PATH).href);
}

async function loadThemesModule() {
await ensureDistBuilt();
return {
style: await import(pathToFileURL(STYLE_PATH).href),
themes: await import(pathToFileURL(THEMES_PATH).href),
};
}

test("source viewer paints a stable line-number gutter before source text", async () => {
const { createSurface } = await import("@flyingrobots/bijou");
const sourceViewer = await loadSourceViewerModule();
Expand Down Expand Up @@ -74,6 +84,42 @@ test("source viewer can paint cursor-relative line numbers", async () => {
assert.equal(rowText(surface, 4).startsWith("+2│ line-5"), true);
});

test("source viewer keeps light-theme gutter cells on the workspace surface", async () => {
const { createSurface } = await import("@flyingrobots/bijou");
const [sourceViewer, themeModules] = await Promise.all([
loadSourceViewerModule(),
loadThemesModule(),
]);
const theme = themeModules.themes.resolveInitialJeditTheme("morning");
const surface = createSurface(16, 1, { char: ".", empty: false });

sourceViewer.renderSourceViewer(
surface,
{
lines: ["alpha"],
cursorRow: 0,
cursorCol: 0,
scrollRow: 0,
scrollCol: 0,
mode: "normal",
},
undefined,
{
viewport: { width: 16, height: 1 },
leftPad: 0,
topPad: 0,
theme,
},
);

const comment = theme.source.get(themeModules.style.JEDIT_SOURCE_TOKEN.Comment);

assert.deepEqual(surface.get(0, 0).fgRGB, comment?.fgRGB);
assert.deepEqual(surface.get(0, 0).bgRGB, theme.surface.workspace.bgRGB);
assert.deepEqual(surface.get(1, 0).bgRGB, theme.surface.workspace.bgRGB);
assert.deepEqual(surface.get(2, 0).bgRGB, theme.surface.workspace.bgRGB);
});

function sourceViewerTheme() {
const workspace = token("#f0f6fc", "#0d1117");
const gutter = token("#8b949e", "#0d1117");
Expand Down
96 changes: 96 additions & 0 deletions spec/theme-switch.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ const CANONICAL_VARIABLE_NAMES = [
const LUMINANCE_RED_WEIGHT = 0.2126;
const LUMINANCE_GREEN_WEIGHT = 0.7152;
const LUMINANCE_BLUE_WEIGHT = 0.0722;
const MIN_SURFACE_TEXT_CONTRAST = 4.5;
const MIN_ACCENT_TEXT_CONTRAST = 3.0;
const CONTRAST_LUMINANCE_OFFSET = 0.05;
const SRGB_CHANNEL_MAX = 255;
const SRGB_LINEAR_BREAKPOINT = 0.03928;
const SRGB_LINEAR_DIVISOR = 12.92;
const SRGB_LINEAR_OFFSET = 0.055;
const SRGB_LINEAR_SCALE = 1.055;
const SRGB_LINEAR_EXPONENT = 2.4;

async function loadThemesModule() {
await ensureDistBuilt();
Expand Down Expand Up @@ -132,6 +141,30 @@ test("authored light and dark variants override generated companions", async ()
assert.equal(solarizedRoundTrip.name, "solarized-dark");
});

test("jedit surface text clears contrast for built-in and companion themes", async () => {
const { themes } = await loadThemesModule();

for (const theme of themesWithGeneratedCompanions(themes)) {
for (const [surfaceName, token] of Object.entries(theme.surface)) {
const ratio = colorContrastRatio(token.fgRGB, token.bgRGB);
assert.ok(
ratio >= MIN_SURFACE_TEXT_CONTRAST,
`${theme.name} ${surfaceName} contrast ratio ${ratio.toFixed(2)}`,
);
}
}
});

test("jedit rendered accent text clears contrast for built-in and companion themes", async () => {
const { themes } = await loadThemesModule();

for (const theme of themesWithGeneratedCompanions(themes)) {
assertTokenGroupContrast(theme.name, "source", theme.source, theme.surface.workspace.bgRGB);
assertTokenGroupContrast(theme.name, "markdown", theme.markdown, theme.surface.workspace.bgRGB);
assertObjectTokenContrast(theme.name, "chrome", theme.chrome, theme.surface.workspace.bgRGB);
}
});

test("built-in jedit theme tokens map back to named variables and effect metadata", async () => {
const { themes, style } = await loadThemesModule();

Expand Down Expand Up @@ -193,6 +226,69 @@ function colorLuminance(color) {
);
}

function themesWithGeneratedCompanions(themes) {
const all = new Map();
for (const theme of themes.availableJeditThemes()) {
all.set(theme.name, theme);
const companion = themes.oppositeJeditTheme(theme);
all.set(companion.name, companion);
}
return all.values();
}

function colorContrastRatio(first, second) {
const firstLuminance = relativeColorLuminance(first);
const secondLuminance = relativeColorLuminance(second);
const lighter = Math.max(firstLuminance, secondLuminance);
const darker = Math.min(firstLuminance, secondLuminance);
return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET);
}

function assertTokenGroupContrast(themeName, groupName, tokens, fallbackBackground) {
for (const [tokenName, token] of tokens) {
assertRenderedTokenContrast(
themeName,
`${groupName}.${tokenName.description ?? String(tokenName)}`,
token,
fallbackBackground,
);
}
}

function assertObjectTokenContrast(themeName, groupName, tokens, fallbackBackground) {
for (const [tokenName, token] of Object.entries(tokens)) {
assertRenderedTokenContrast(
themeName,
`${groupName}.${tokenName}`,
token,
fallbackBackground,
);
}
}

function assertRenderedTokenContrast(themeName, tokenName, token, fallbackBackground) {
const ratio = colorContrastRatio(token.fgRGB, token.bgRGB ?? fallbackBackground);
assert.ok(
ratio >= MIN_ACCENT_TEXT_CONTRAST,
`${themeName} ${tokenName} contrast ratio ${ratio.toFixed(2)}`,
);
}

function relativeColorLuminance(color) {
return (
linearChannel(color[0]) * LUMINANCE_RED_WEIGHT +
linearChannel(color[1]) * LUMINANCE_GREEN_WEIGHT +
linearChannel(color[2]) * LUMINANCE_BLUE_WEIGHT
);
}

function linearChannel(channel) {
const scaled = channel / SRGB_CHANNEL_MAX;
return scaled <= SRGB_LINEAR_BREAKPOINT
? scaled / SRGB_LINEAR_DIVISOR
: ((scaled + SRGB_LINEAR_OFFSET) / SRGB_LINEAR_SCALE) ** SRGB_LINEAR_EXPONENT;
}

function assertCompleteBasePalette(theme) {
const variableNames = [...theme.variables.keys()];
for (const variableName of CANONICAL_VARIABLE_NAMES) {
Expand Down
6 changes: 6 additions & 0 deletions spec/workspace-app-echo-cutover.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
openedHarness,
twoFileHarness,
} from './workspace-echo-test-utils.mjs';
import { importDist } from './workspace-helpers.mjs';

test('real workspace app path opens files through production text authority', async () => {
const harness = await createWorkspaceEchoAppHarness({
Expand Down Expand Up @@ -367,6 +368,7 @@ test('real workspace app path inserts canonical spacebar token in insert mode',
});

test('real workspace app path saves by exporting and checkpointing production text', async () => {
const footerPosture = await importDist('app', 'workspace', 'workspace-footer-posture.js');
const harness = await openedHarness({ exportText: 'saved from Echo' });

await harness.key('i');
Expand All @@ -379,6 +381,10 @@ test('real workspace app path saves by exporting and checkpointing production te
assert.equal(harness.calls.checkpoint.length, 1);
assert.equal(harness.model.textAuthority.lastExportReadingId, 'reading:export');
assert.equal(harness.model.textAuthority.lastCheckpointId, 'checkpoint:save');
assert.match(
footerPosture.workspaceFooterTextPosture(harness.model),
/basis:checkpoint \| head:checkpoint \| worldline:main \| export:host/,
);
});

test('real workspace app path keeps obstruction honest without retrying', async () => {
Expand Down
7 changes: 5 additions & 2 deletions spec/workspace-command-line.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,10 @@ test("enter dispatches edit for missing paths as unmaterialized buffers", async
assert.equal(opened.textAuthority.materialization, "unmaterialized");
assert.equal(opened.editor.dirty, false);
assert.deepEqual(opened.editor.lines, [""]);
assert.match(rendered, /\/repo\/foo\.txt\s+\[clean \| main \| fs:unmaterialized/);
assert.match(
rendered,
/\/repo\/foo\.txt\s+\[basis:reading \| head:basis \| worldline:main \| export:pending/,
);
});

test("enter dispatches write and wq commands through production save", async () => {
Expand Down Expand Up @@ -724,7 +727,7 @@ test("blocked production wq remains open with honest materialization status", as
assert.equal(blockedModel.textAuthority.dirty, true);
assert.equal(blockedModel.textAuthority.materialization, "unmaterialized");
assert.equal(blockedModel.editor.dirty, true);
assert.match(footer, /dirty \| main \| fs:unmaterialized/);
assert.match(footer, /basis:reading \| head:local \| worldline:main \| export:pending/);
});

test("pending production intent queues wq save without arming quit confirmation", async () => {
Expand Down
26 changes: 22 additions & 4 deletions spec/workspace-footer.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,31 @@ test('workspace footer renders command-line hints on the painted secondary row',
assert.equal(rowText(surface, 1).trim(), '[tab accept · enter run · esc cancel]');
});

test('workspace footer applies theme foreground and background to painted text', async () => {
const footer = await loadFooterModule();
const token = {
fg: '#22272e',
fgRGB: [34, 39, 46],
bg: '#dedad0',
bgRGB: [222, 218, 208],
foregroundVariables: [],
backgroundVariables: [],
};
const surface = footer.renderWorkspaceFooter(idleNormalState(), 24, token);
const cell = surface.get(0, 0);

assert.equal(cell.char, 'N');
assert.deepEqual(cell.fgRGB, token.fgRGB);
assert.deepEqual(cell.bgRGB, token.bgRGB);
});

test('workspace footer pins editor posture to the lower-right corner when it fits', async () => {
const footer = await loadFooterModule();
const posture = 'clean | main | fs:materialized | target:main | +0/-0';
const posture = 'basis:reading | head:basis | worldline:main | export:host | admit:main | tick:t0';
const surface = footer.renderWorkspaceFooter({
...idleNormalState(),
textPosture: posture,
}, 96, {});
}, 132, {});
const secondary = rowText(surface, 1);

assert.equal(secondary.startsWith('/repo/notes/todo.md'), true);
Expand All @@ -176,10 +194,10 @@ test('workspace footer pins editor posture to the lower-right corner when it fit
test('workspace footer posture fit uses terminal display width for wide glyphs', async () => {
const footerPosture = await loadFooterPostureModule();
const editorPath = '/repo/界.md';
const textPosture = 'dirty | main';
const textPosture = 'basis:reading | head:local';
const requiredWidth = visibleLength(`${editorPath} [${textPosture}]`);

assert.equal(requiredWidth, 26);
assert.equal(requiredWidth, 40);
assert.equal(footerPosture.editorFooterPostureFits(editorPath, textPosture, requiredWidth - 1), false);
assert.equal(footerPosture.editorFooterPostureFits(editorPath, textPosture, requiredWidth), true);
});
Expand Down
Loading
Loading