This commit is contained in:
Laan Tungir
2026-07-27 19:01:14 -04:00
parent 990da0dfda
commit 9d8cd74bc9
4 changed files with 368 additions and 0 deletions
+12
View File
@@ -22,6 +22,7 @@
"onCommand:nostr.publishEventFile",
"onCommand:nostr.validateEventFile",
"onCommand:nostr.publishLongForm",
"onCommand:nostr.publishTextNote",
"onCommand:nostr.validateFrontMatter",
"onCommand:nostr.fetchRelays",
"onCommand:nostr.selectBackend",
@@ -82,6 +83,12 @@
"category": "Nostr",
"enablement": "editorLangId == markdown"
},
{
"command": "nostr.publishTextNote",
"title": "Nostr: Publish Current File as Text Note",
"category": "Nostr",
"enablement": "resourceScheme == file || resourceScheme == untitled"
},
{
"command": "nostr.validateFrontMatter",
"title": "Nostr: Validate Front-Matter",
@@ -132,6 +139,11 @@
"when": "editorLangId == markdown",
"group": "nostr@2"
},
{
"command": "nostr.publishTextNote",
"when": "resourceScheme == file || resourceScheme == untitled",
"group": "nostr@3"
},
{
"command": "nostr.insertNoteHeader",
"when": "editorLangId == markdown",
+159
View File
@@ -0,0 +1,159 @@
# Sidebar Aesthetics Plan
Apply the design rules from `~/lt/aesthetics/WEB.md` to the Nostr sidebar webview in [`src/ui/sidebar.ts`](../src/ui/sidebar.ts:1).
## Context
`WEB.md` defines a minimalist web aesthetic:
- **Monochrome first**: black, white, one red accent, plus one muted grey. No other hues.
- **Monospaced by default**: one fixed-width family for body, buttons, inputs, labels.
- **Minimal CSS surface**: few variables, few classes, reuse shell styles.
- **Readable over clever**: 1px borders, single radius token, no gradients/shadows as decoration.
- **Grayscale media**: images desaturated at rest, color returns on hover.
- **Dark mode = swapped variables**, not swapped styles.
- **Spacing ladder**: 5, 10, 15, 20, 40 px only.
- **Transitions**: 0.2s for interactive feedback; 0.5s max for slide-outs.
- **Accessibility**: tab-reachable, accent focus ring, alt text, reduced-motion respected.
The current sidebar is a VS Code webview that leans on `--vscode-*` theme variables with a generic VS Code look: mixed monospace/sans, blue-ish buttons, 2px radii, ad-hoc spacing (6px, 8px, 12px, 16px), no grayscale on avatars, no transitions.
## Reconciliation: WEB.md tokens vs VS Code theme
The sidebar lives inside VS Code, so it must respect the user's light/dark theme. We map the WEB.md four-token palette onto VS Code theme variables so the monochrome+accent system still tracks the active theme:
| WEB.md token | Sidebar value |
|---------------------|------------------------------------------------------------|
| `--primary-color` | `var(--vscode-foreground)` |
| `--secondary-color` | `var(--vscode-sideBar-background)` |
| `--accent-color` | `var(--vscode-statusBarItem-errorBackground, #ff0000)` — red, used only for hover/active/focus/destructive |
| `--muted-color` | `var(--vscode-descriptionForeground)` |
| `--border-color` | `var(--vscode-panel-border, var(--muted-color))` |
This keeps the "monochrome + one red scalpel" intent while remaining theme-aware. The accent resolves to red in most themes; where a theme overrides the error background we still get a single semantic accent.
## Changes
All changes are confined to the inline `<style>` and HTML structure inside [`NostrSidebarProvider.getHtml()`](../src/ui/sidebar.ts:196). No host-side TypeScript logic changes are required.
### 1. Variable block (`:root`)
Replace the current ad-hoc `:root` with the reconciled token set plus the WEB.md minimums:
- `--font-family: var(--vscode-editor-font-family), "Courier", monospace;` (monospace everywhere)
- `--primary-color`, `--secondary-color`, `--accent-color`, `--muted-color`, `--border-color` as in the table above.
- `--border-radius: 5px;` (single token, replaces the current 2px)
- `--border-width: 1px;` and `--border: var(--border-width) solid var(--border-color);`
- `--avatar-size-md: 32px;` (identity avatar)
- `--image-grayscale: 100%;` and `--image-grayscale-hover: 20%;`
- Spacing ladder documented as a comment: `5, 10, 15, 20, 40`.
### 2. Typography
- `* { font-family: var(--font-family); box-sizing: border-box; }`
- Body uses `var(--font-family)` and `var(--vscode-font-size)`.
- Section headings (`h2`) keep `11px`, `font-weight: 600`, uppercase, `letter-spacing: 0.5px`, color `var(--muted-color)` — already close to WEB.md's "UI chrome 14px / compact 12px" guidance; we keep 11px for the uppercase label style.
- Relay URLs and npub already use the editor font; ensure all inputs/buttons/selects also inherit `var(--font-family)`.
### 3. Buttons
Adopt the WEB.md `.btn` model adapted to the sidebar's full-width buttons:
- Default: `color: var(--primary-color); background: var(--secondary-color); border: 1px solid var(--primary-color); border-radius: var(--border-radius); padding: 8px 10px; font-weight: bold; cursor: pointer; transition: border-color 0.2s, background-color 0.2s, color 0.2s;`
- `:hover` → `border-color: var(--accent-color);` (never fill red on hover).
- `:active` → `background: var(--accent-color); color: var(--secondary-color); border-color: var(--accent-color);`
- `:disabled` → `opacity: 0.5; cursor: not-allowed;` (no recolor).
- `.secondary` → swap to muted border: `border-color: var(--muted-color); color: var(--muted-color);` hover still goes to accent border.
- Remove the old `--btn-bg`/`--btn2-bg` blue button styling.
### 4. Inputs / select
- Share border, radius, padding with buttons so they line up.
- `border: var(--border); border-radius: var(--border-radius); padding: 8px 10px; background: var(--vscode-inputBackground); color: var(--vscode-inputForeground);`
- `:focus` → `outline: none; border-color: var(--accent-color);` (accent focus ring, per WEB.md §7 and §14).
- Labels (`.field-label`) stay muted grey, above inputs, smaller than input text.
### 5. Avatar / images
- Global `img { filter: grayscale(var(--image-grayscale)); transition: filter 0.2s; }` and `img:hover { filter: grayscale(var(--image-grayscale-hover)); }`.
- `.avatar` and `.avatar-placeholder` use `--avatar-size-md: 32px`, `border-radius: var(--border-radius)` by default (square-with-radius per WEB.md §8; round variant only if we later opt in via `999px`).
- Avatar `alt` text set to the profile name or `"avatar"` (currently `alt="avatar"` — keep, but prefer name when available).
### 6. Spacing normalization
Migrate current values onto the ladder:
| Current | New |
|--------|-----|
| `margin-bottom: 16px` (section) | `20px` |
| `margin: 0 0 8px 0` (h2) | `0 0 10px 0` |
| `padding: 6px 10px` (button) | `8px 10px` |
| `margin-bottom: 6px` (button) | `10px` |
| `padding: 4px 6px` (input) | `8px 10px` |
| `margin: 4px 0 2px` (label) | `5px 0` |
| `padding: 12px` (body) | `10px` (or `15px`) |
| `gap: 6px` (relay row) | `10px` |
| `gap: 8px` (identity row) | `10px` |
### 7. Relay list and identity block
- Relay rows: keep flex row, `gap: 10px`, `padding: 5px 0`, monospace URL with `text-overflow: ellipsis; white-space: nowrap;` (already present).
- Identity block: treat as a single card — `border: var(--border); border-radius: var(--border-radius); padding: 10px; margin-bottom: 15px;` containing the avatar row, name, npub, backend label, and Sign Out button. This matches WEB.md §9 card conventions (1px border, 5–10px radius, 12px-ish padding, vertical flex).
- Empty-state text stays italic muted grey (WEB.md allows italic for empty-state).
### 8. Transitions and reduced motion
- Add `transition: border-color 0.2s, background-color 0.2s, color 0.2s, filter 0.2s;` to buttons, inputs, checkboxes, and images.
- Add `@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }`.
### 9. Accessibility pass
- Ensure every button/input/select has visible focus: accent border on inputs; for buttons add `:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 1px; }`.
- Relay checkboxes remain native (WEB.md §7: don't reinvent).
- Confirm tab order: Actions → Relays → Fetch button → Identity fields → Sign In/Out.
- Avatar `alt` text uses profile name when available, else `"avatar"`.
### 10. CSP
No CSP change needed. `style-src 'unsafe-inline'` already permits the inline styles; `img-src https: data:` already permits avatar loading. We are not adding any external resources.
## Out of scope
- No changes to host-side message handling, state shape, or commands.
- No new files; everything stays in [`sidebar.ts`](../src/ui/sidebar.ts:1).
- No layout shell refactor (the sidebar is a single pane, not the five-region web shell from WEB.md §4 — that section informs token choices, not structure).
- No dark-mode class toggle: VS Code drives light/dark via theme variables, which our token mapping already honors.
## Verification
1. Build the extension (`npm run build` via [`esbuild.mjs`](../esbuild.mjs:1)) and load it in VS Code.
2. Open the Nostr sidebar and confirm:
- All text is monospaced.
- Buttons are outlined monochrome; hover shows red border; click flashes red fill.
- Inputs show red border on focus.
- Avatar (when signed in with a profile picture) is grayscale until hover.
- Spacing feels even (no 6/8/12/16 oddities).
- Disabled buttons are dimmed, not recolored.
3. Toggle a light and a dark VS Code theme; confirm the palette tracks correctly and the accent stays red.
4. Tab through the sidebar; confirm visible focus rings and logical order.
5. Enable OS reduced-motion; confirm no transitions animate.
## Mermaid overview
```mermaid
flowchart LR
A[WEB.md rules] --> B[Token mapping]
B --> C[Typography: monospace]
B --> D[Buttons: outline + red hover/active]
B --> E[Inputs: accent focus]
B --> F[Avatar: grayscale + hover reveal]
B --> G[Spacing ladder 5/10/15/20/40]
B --> H[Transitions 0.2s + reduced-motion]
C --> I[sidebar.ts inline style + HTML]
D --> I
E --> I
F --> I
G --> I
H --> I
I --> J[Build + manual verify in VS Code]
```
+187
View File
@@ -73,6 +73,11 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
cmdPublishLongForm(state, config),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.publishTextNote", () =>
cmdPublishTextNote(state, config),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.validateFrontMatter", () =>
cmdValidateFrontMatter(),
@@ -860,6 +865,188 @@ async function cmdPublishLongForm(state: StateService, config: ConfigService): P
);
}
// ---------------------------------------------------------------------------
// Publish current file as a kind 1 text note
// ---------------------------------------------------------------------------
/**
* Command: publish the active file as a kind 1 (short text) note.
*
* Smart content detection:
* - If the file is JSON and parses to an object with a `content` string
* (and optional `tags`), those are used as the note's content/tags. This
* lets users author a note as `{"content": "...", "tags": [["t","nostr"]]}`
* for full control over tags.
* - Otherwise (plain text, markdown, or any non-JSON file), the raw file
* text is used verbatim as the note content with no tags.
*
* The active editor's language id is not restricted — any text file works.
*/
async function cmdPublishTextNote(state: StateService, config: ConfigService): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage("No active editor.");
return;
}
// Require sign-in.
let signer = getSigner(state, config);
if (!signer) {
const action = await vscode.window.showErrorMessage(
"Sign in to Nostr first before publishing.",
"Sign In",
);
if (action === "Sign In") {
await vscode.commands.executeCommand("nostr.signIn");
}
signer = getSigner(state, config);
if (!signer) {
return;
}
}
const text = editor.document.getText();
// Detect JSON vs plain text.
let content: string;
let tags: string[][] = [];
const trimmed = text.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
let parsed: unknown = undefined;
try {
parsed = JSON.parse(text);
} catch {
// Not valid JSON — fall through to plain-text handling.
}
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.content === "string") {
content = obj.content;
if (Array.isArray(obj.tags)) {
tags = (obj.tags as unknown[]).filter(
(t): t is string[] => Array.isArray(t) && t.every((x) => typeof x === "string"),
);
}
} else {
// JSON object without a content field — use the raw text as content.
content = text;
}
} else if (parsed !== undefined) {
// Valid JSON but not an object (e.g. a string or array) — stringify it.
content = JSON.stringify(parsed);
} else {
// JSON.parse failed — treat as plain text.
content = text;
}
} else {
content = text;
}
if (content.length === 0) {
vscode.window.showErrorMessage("The active file is empty — nothing to publish.");
return;
}
const now = Math.floor(Date.now() / 1000);
const template: EventTemplate = {
kind: 1,
created_at: now,
tags,
content,
};
// Validate the built template.
const validation = validateEventTemplate(template);
if (!validation.valid) {
vscode.window.showErrorMessage(`Built event is invalid: ${validation.errors.join("; ")}`);
return;
}
// Sign.
let signed: VerifiedEvent;
try {
signed = await signer.signEvent(template);
} catch (err) {
vscode.window.showErrorMessage(`Signing failed: ${(err as Error).message}`);
return;
}
// Relays.
const relays = sidebarProvider ? sidebarProvider.getEnabledRelays() : config.relays;
if (relays.length === 0) {
vscode.window.showErrorMessage(
"No relays enabled. Toggle relays in the Nostr sidebar or set `nostr.relays` in settings.",
);
return;
}
// Confirmation dialog.
const selectedRelays = config.publishConfirm
? await confirmTextNotePublish(signed, relays, signer)
: relays;
if (selectedRelays.length === 0) {
vscode.window.showInformationMessage("Publish cancelled: no relays selected.");
return;
}
// Publish.
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Publishing text note…",
cancellable: false,
},
async () => {
const results = await publishToRelays(signed, selectedRelays, config.publishTimeoutMs);
showPublishResults(signed, results);
},
);
}
/** Confirmation dialog for kind 1 text note publish. */
async function confirmTextNotePublish(
signed: VerifiedEvent,
relays: string[],
signer: NostrSigner,
): Promise<string[]> {
const npub = hexToNpub(signed.pubkey);
const preview = signed.content.length > 200
? signed.content.slice(0, 200) + "…"
: signed.content;
const previewItems: vscode.QuickPickItem[] = [
{ label: `kind: ${signed.kind}` },
{ label: `pubkey: ${npub}` },
{ label: `id: ${signed.id}` },
{ label: `tags: ${signed.tags.length}` },
{ label: `content: ${signed.content.length} chars` },
{ label: `preview: ${preview.replace(/\n/g, " ")}` },
{ label: `signer: ${signer.describe()}` },
{ label: `relays: ${relays.join(", ")}` },
{ label: "" },
];
const relayItems: vscode.QuickPickItem[] = relays.map((r) => ({ label: r, picked: true }));
const pick = await vscode.window.showQuickPick([...previewItems, ...relayItems], {
canPickMany: true,
placeHolder: "Preview text note. Toggle relays, then confirm.",
ignoreFocusOut: true,
title: "Confirm Kind 1 Publish",
});
if (!pick) return [];
const selected = pick
.filter((item) => item.label.startsWith("wss://") || item.label.startsWith("ws://"))
.map((item) => item.label);
if (selected.length === 0) return [];
const confirm = await vscode.window.showQuickPick(["Publish", "Cancel"], {
placeHolder: `Publish to ${selected.length} relay(s)?`,
ignoreFocusOut: true,
});
return confirm === "Publish" ? selected : [];
}
/** Confirmation dialog for long-form publish with NIP-23 preview fields. */
async function confirmLongFormPublish(
signed: VerifiedEvent,
+10
View File
@@ -13,6 +13,7 @@ type WebviewMessage =
| { type: "createEvent" }
| { type: "publishCurrentFile" }
| { type: "validateCurrentFile" }
| { type: "publishTextNote" }
| { type: "publishLongForm" }
| { type: "validateFrontMatter" }
| { type: "insertNoteHeader" }
@@ -51,6 +52,7 @@ interface SidebarState {
relays: { url: string; enabled: boolean }[];
canPublish: boolean;
canPublishLongForm: boolean;
canPublishTextNote: boolean;
}
/**
@@ -117,6 +119,9 @@ export class NostrSidebarProvider implements vscode.WebviewViewProvider {
case "publishCurrentFile":
await vscode.commands.executeCommand("nostr.publishEventFile");
break;
case "publishTextNote":
await vscode.commands.executeCommand("nostr.publishTextNote");
break;
case "validateCurrentFile":
await vscode.commands.executeCommand("nostr.validateEventFile");
break;
@@ -162,6 +167,7 @@ export class NostrSidebarProvider implements vscode.WebviewViewProvider {
const editor = vscode.window.activeTextEditor;
const canPublish = !!editor && editor.document.languageId === "json";
const canPublishLongForm = !!editor && editor.document.languageId === "markdown";
const canPublishTextNote = !!editor && editor.document.uri.scheme !== "output";
const pubkey = this.state.getPubkeyHex();
let signerBackend = "local";
if (this.config.signerBackend === "nsigner") {
@@ -182,6 +188,7 @@ export class NostrSidebarProvider implements vscode.WebviewViewProvider {
relays: relayList.map((url) => ({ url, enabled: this.enabledRelays.has(url) })),
canPublish,
canPublishLongForm,
canPublishTextNote,
};
this.view.webview.postMessage({ type: "state", state: snap });
}
@@ -281,6 +288,7 @@ export class NostrSidebarProvider implements vscode.WebviewViewProvider {
<h2>Actions</h2>
<button id="createBtn">+ New Event</button>
<button id="insertNoteHeaderBtn" disabled>Insert Note Header</button>
<button id="publishTextNoteBtn" disabled>Publish Current File as Text Note</button>
<button id="publishLongFormBtn" disabled>Publish Current File as Long-Form Note</button>
<button id="validateFrontMatterBtn" class="secondary" disabled>Validate Front-Matter</button>
<button id="publishBtn" disabled>Publish Current JSON File</button>
@@ -365,6 +373,7 @@ export class NostrSidebarProvider implements vscode.WebviewViewProvider {
$("createBtn").addEventListener("click", () => vscode.postMessage({ type: "createEvent" }));
$("publishBtn").addEventListener("click", () => vscode.postMessage({ type: "publishCurrentFile" }));
$("validateBtn").addEventListener("click", () => vscode.postMessage({ type: "validateCurrentFile" }));
$("publishTextNoteBtn").addEventListener("click", () => vscode.postMessage({ type: "publishTextNote" }));
$("publishLongFormBtn").addEventListener("click", () => vscode.postMessage({ type: "publishLongForm" }));
$("validateFrontMatterBtn").addEventListener("click", () => vscode.postMessage({ type: "validateFrontMatter" }));
$("insertNoteHeaderBtn").addEventListener("click", () => vscode.postMessage({ type: "insertNoteHeader" }));
@@ -414,6 +423,7 @@ export class NostrSidebarProvider implements vscode.WebviewViewProvider {
// Action button states
$("publishBtn").disabled = !s.canPublish;
$("validateBtn").disabled = !s.canPublish;
$("publishTextNoteBtn").disabled = !s.canPublishTextNote;
$("publishLongFormBtn").disabled = !s.canPublishLongForm;
$("validateFrontMatterBtn").disabled = !s.canPublishLongForm;
$("insertNoteHeaderBtn").disabled = !s.canPublishLongForm;