Skip to content
Merged
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
33 changes: 25 additions & 8 deletions apps/calendar/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -685,16 +685,23 @@ body.is-resizing .calendar-resize {
}

/* Density-aware content gating. The block chip is sized by inline
* `height`; when the chip is too short for its content the meta /
* location lines hide so text stops bleeding past the chip's bottom
* edge into the next hour's slot. `data-density` is set by week-view.ts:
* - tight (<35min): title only
* - compact (<55min): title + meta
* - roomy (≥55min): full content (title + meta + location)
* `height`; when the chip is too short for its content the meta lines
* hide so text stops bleeding past the chip's bottom edge into the next
* hour's slot — an ungated line doesn't overflow cleanly, it makes the
* flex column shrink EVERY meta line into mid-glyph clipping. Every
* `.cal-chip__meta` variant (time, location, guests, tz) must appear in
* these bands. `data-density` is set by week-view.tsx (ChipDensity):
* - tight (<44min): title only
* - compact (<72min): title + time
* - roomy (<100min): title + time + location
* - spacious (≥100min): full content (adds guests + timezone)
*/
.cal-chip--block[data-density="tight"] .cal-chip__meta,
.cal-chip--block[data-density="tight"] .cal-chip__meta--location,
.cal-chip--block[data-density="compact"] .cal-chip__meta--location {
.cal-chip--block[data-density="compact"] .cal-chip__meta--location,
.cal-chip--block[data-density="compact"] .cal-chip__meta--guests,
.cal-chip--block[data-density="compact"] .cal-chip__meta--tz,
.cal-chip--block[data-density="roomy"] .cal-chip__meta--guests,
.cal-chip--block[data-density="roomy"] .cal-chip__meta--tz {
display: none;
}

Expand Down Expand Up @@ -1136,6 +1143,16 @@ body.is-resizing .calendar-resize {
transition: opacity 80ms ease;
}

/* In the column-flex block chip the ⋯ must NOT be a flow child: even at
* opacity 0 its 28px box stacks under the title and flex-shrinks the meta
* lines into mid-glyph clips (916b probe: a 64px chip's time line measured
* 6px tall). Overlay it top-right instead — the chip is position:relative. */
.cal-chip--block .cal-chip__more {
position: absolute;
top: var(--space-0_5);
inset-inline-end: var(--space-0_5);
}

.cal-chip:hover .cal-chip__more,
.cal-chip:focus-within .cal-chip__more,
.cal-chip .cal-chip__more[aria-expanded="true"] {
Expand Down
27 changes: 27 additions & 0 deletions apps/calendar/src/ui/react/event-chip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// @vitest-environment jsdom
/**
* Block-chip density bands — each band's content must FIT its minimum chip
* height (64px/h budget), or the chip's flex column shrinks the meta lines
* into mid-glyph clipping. Guards the dark-sweep 2026-07-29 find: a ~70min
* chip showed its time line as clipped ascender tips because guests/tz meta
* were rendered at every density (the gating predated those lines).
*/

import { describe, expect, it } from "vitest";
import { ChipDensity, densityForDuration } from "./event-chip";

describe("densityForDuration", () => {
it("maps durations to the four content bands", () => {
expect(densityForDuration(20)).toBe(ChipDensity.Tight);
expect(densityForDuration(43)).toBe(ChipDensity.Tight);
expect(densityForDuration(44)).toBe(ChipDensity.Compact);
// The 60-minute repro chip (64px) fits exactly title (26px) + time
// (46px); location (66px) and guests/tz (106px) must stay gated.
expect(densityForDuration(60)).toBe(ChipDensity.Compact);
expect(densityForDuration(71)).toBe(ChipDensity.Compact);
expect(densityForDuration(72)).toBe(ChipDensity.Roomy);
expect(densityForDuration(99)).toBe(ChipDensity.Roomy);
expect(densityForDuration(100)).toBe(ChipDensity.Spacious);
expect(densityForDuration(150)).toBe(ChipDensity.Spacious);
});
});
32 changes: 31 additions & 1 deletion apps/calendar/src/ui/react/event-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@ const RECURRENCE_LABELS = recurrenceLabels();

export type EventChipMode = "compact" | "block" | "row";

/** Block-chip content gating band (`data-density`). Each band names the
* content that FITS the chip's inline height — the CSS hides the rest so
* meta lines never flex-shrink into mid-glyph clipping (dark-sweep
* 2026-07-29: a 1h chip squeezed its time line to ascender tips because
* guests/tz lines were never gated and the ⋯ button sat in column flow).
* Bands are picked from the event's duration in `week-view.tsx`. */
export enum ChipDensity {
/** <44min — title only. */
Tight = "tight",
/** 44–72min — title + time. */
Compact = "compact",
/** 72–100min — title + time + location. */
Roomy = "roomy",
/** ≥100min — full content (adds guests + timezone). */
Spacious = "spacious",
}

/** Pick the band for a block chip of `durationMins`. Bands must match the
* pixel budget: 64px/h ≈ 1.07px/min against 18px line boxes + 2px gaps +
* 8px block padding (916b probe numbers) — title 26px, +time 46px,
* +location 66px, +guests/tz 106px. Every band's content has to FIT its
* minimum chip height, or the flex column squeezes the meta lines into
* mid-glyph clipping. */
export function densityForDuration(durationMins: number): ChipDensity {
if (durationMins < 44) return ChipDensity.Tight;
if (durationMins < 72) return ChipDensity.Compact;
if (durationMins < 100) return ChipDensity.Roomy;
return ChipDensity.Spacious;
}

export type EventChipProps = {
item: ScheduledItem;
mode: EventChipMode;
Expand All @@ -38,7 +68,7 @@ export type EventChipProps = {
/** Inline style (block-mode positioning + density). */
style?: CSSProperties;
/** Density hint (block mode) → `data-density`. */
density?: string;
density?: ChipDensity;
/** Forwarded to the chip button (drag wiring in week/month views). */
buttonRef?: Ref<HTMLButtonElement>;
};
Expand Down
4 changes: 2 additions & 2 deletions apps/calendar/src/ui/react/week-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useEffect, useRef } from "react";
import { t } from "../../i18n/t";
import type { CompiledDayView, CompiledWeekView, WeekDayBucket } from "../../logic/compile-view";
import type { ScheduledItem } from "../../logic/scheduled-item";
import { EventChip } from "./event-chip";
import { EventChip, densityForDuration } from "./event-chip";
import { beginBlockDrag } from "./use-chip-drag";
import type { ViewCallbacks } from "./view-callbacks";

Expand Down Expand Up @@ -243,7 +243,7 @@ function TimedBlock({
item.end === null ? startMins + 30 : minutesIntoDayFromEpoch(item.end, dayStart),
);
const heightMins = Math.max(20, endMins - startMins);
const density = heightMins < 35 ? "tight" : heightMins < 55 ? "compact" : "roomy";
const density = densityForDuration(heightMins);
const pxPerMin = HOUR_HEIGHT_PX / 60;

const draggable = !item.isRecurringInstance && !item.readonly;
Expand Down
22 changes: 21 additions & 1 deletion apps/code-editor/src/ui/code-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { CodeFileRow } from "../logic/code-projection";
import { getCodeBuffer } from "../logic/code-y-buffer";
import { SyntaxThemePreference } from "../logic/syntax-theme";
import { LanguageKey } from "../types/code-file";
import { createCodePane } from "./code-pane";
import { createCodePane, prefersDarkScheme } from "./code-pane";
import type { DiffViewMode } from "./diff-view";

const NOOP_LABELS = {
Expand Down Expand Up @@ -484,3 +484,23 @@ describe("createCodePane", () => {
pane.dispose();
});
});

describe("prefersDarkScheme — the shell appearance wins over the OS media query", () => {
// The preload paints the resolved appearance as `color-scheme` on <body>
// (#brainstorm-tokens). Reading the OS-level `prefers-color-scheme` first
// is exactly how a Dark-appearance shell on a light-mode OS shipped
// github-light tokens on a near-black editor (dark-sweep 2026-07-29).
it("follows the body's computed color-scheme in both directions", () => {
document.body.style.setProperty("color-scheme", "dark");
expect(prefersDarkScheme()).toBe(true);
document.body.style.setProperty("color-scheme", "light");
expect(prefersDarkScheme()).toBe(false);
document.body.style.removeProperty("color-scheme");
});

it("falls back to the media query when no scheme is painted (no shell)", () => {
document.body.style.removeProperty("color-scheme");
// jsdom's matchMedia never matches — the no-shell fallback is light.
expect(prefersDarkScheme()).toBe(false);
});
});
26 changes: 22 additions & 4 deletions apps/code-editor/src/ui/code-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* itself is destroyed (e.g. last file deleted).
*/

import { shellAppearanceDark } from "@brainstorm-os/sdk/code-highlight";
import {
attachFindBar,
attachFindShortcuts,
Expand Down Expand Up @@ -1002,6 +1003,19 @@ export function createCodePane(opts: CodePaneOptions): CodePaneController {
}
gutter.addEventListener("click", onGutterClick);

// Appearance flip while the pane is open: the preload rewrites `<body>`'s
// `color-scheme` and announces it. An `Auto` syntax theme must re-tokenise
// or the overlay keeps painting the departing palette on the new surface.
const onAppearanceChanged = (): void => {
if (state.syntaxTheme !== SyntaxThemePreference.Auto) return;
if (state.binding)
void tokenizeAndPaint(
state.foldView ? textarea.value : state.binding.snapshot(),
state.row.language,
);
};
window.addEventListener("brainstorm:theme-changed", onAppearanceChanged);

// ── Find & replace (B9.3 — shared FindBar over @codemirror/search) ────

function scrollOffsetIntoView(offset: number): void {
Expand Down Expand Up @@ -1234,6 +1248,7 @@ export function createCodePane(opts: CodePaneOptions): CodePaneController {
textarea.removeEventListener("click", onCaretMove);
textarea.removeEventListener("select", onCaretMove);
gutter.removeEventListener("click", onGutterClick);
window.removeEventListener("brainstorm:theme-changed", onAppearanceChanged);
hover.dispose();
completion.dispose();
state.binding?.dispose();
Expand Down Expand Up @@ -1319,12 +1334,15 @@ function renderGutterLine(line: GutterLine, fold?: GutterFoldInfo): HTMLElement
}

/**
* Whether the renderer is currently in dark mode. The shell theme is the
* source of truth — we read `prefers-color-scheme` here because the shell's
* `color-scheme: light dark` declaration is what the renderer honours. The
* `Auto` syntax-theme preference resolves through this.
* Whether the renderer is currently in dark mode. The SHELL theme is the
* source of truth and can disagree with the OS setting — read the painted
* appearance first (`shellAppearanceDark`, see its doc for why `<body>`);
* the OS-level media query is only the no-shell fallback (unit tests,
* detached docs). The `Auto` syntax-theme preference resolves through this.
*/
export function prefersDarkScheme(): boolean {
const painted = shellAppearanceDark();
if (painted !== null) return painted;
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
45 changes: 38 additions & 7 deletions packages/editor/src/plugins/code-highlight-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@
*
* Every block gets an overlay even when its language is unknown / still
* loading (a plain, uncoloured paint) so the transparent block text is always
* backed by something readable. Theme follows the app's resolved `color-scheme`
* (set on the document root from the active theme's background luminance), not
* the OS — so light tokens never land on a dark code background.
* backed by something readable. Theme follows the SHELL-painted appearance —
* `<body>`'s computed `color-scheme` via `shellAppearanceDark` — not the OS
* and not `<html>` (whose computed value is the app's own constant
* "light dark") — so light tokens never land on a dark code background and
* dark tokens never land on a light one.
*/

import { HighlightTheme, type ThemedToken, tokenizeShiki } from "@brainstorm-os/sdk/code-highlight";
import {
HighlightTheme,
type ThemedToken,
shellAppearanceDark,
tokenizeShiki,
} from "@brainstorm-os/sdk/code-highlight";
import { CodeNode } from "@lexical/code";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $getRoot, type LexicalEditor } from "lexical";
Expand All @@ -36,26 +43,42 @@ import { createPortal } from "react-dom";

export const CODE_HIGHLIGHT_ROOT_CLASS = "notes--code-highlighted";

/** `@lexical/code` language id → Shiki grammar id. Identity for most; the
* one rename is `bash → shellscript` (Shiki's id for shell). Languages not
* shipped by the SDK tokenizer map to `null` → plain (uncoloured) paint. */
/** `@lexical/code` language id → Shiki grammar id. Identity for most, plus
* the fence aliases people actually type (```ts / ```js / ```py / ```sh —
* a fenced block stores its info string verbatim, and an unmapped alias
* silently painted plain). Languages not shipped by the SDK tokenizer map
* to `null` → plain (uncoloured) paint. */
const SHIKI_ID: Readonly<Record<string, string>> = Object.freeze({
javascript: "javascript",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
typescript: "typescript",
ts: "typescript",
jsx: "jsx",
tsx: "tsx",
python: "python",
py: "python",
json: "json",
jsonc: "jsonc",
html: "html",
css: "css",
markdown: "markdown",
md: "markdown",
bash: "shellscript",
sh: "shellscript",
shell: "shellscript",
zsh: "shellscript",
shellscript: "shellscript",
sql: "sql",
go: "go",
rust: "rust",
rs: "rust",
java: "java",
cpp: "cpp",
yaml: "yaml",
yml: "yaml",
toml: "toml",
});

function shikiIdFor(language: string | null | undefined): string | null {
Expand All @@ -64,6 +87,14 @@ function shikiIdFor(language: string | null | undefined): string | null {
}

function resolvedTheme(): HighlightTheme {
// The shell-painted `<body>` appearance is the source of truth
// (`shellAppearanceDark` — reading `<html>` here returned the constant
// "light dark" from the host app's own `:root` rule, which the old
// `includes("dark")` read as permanently dark: github-dark tokens on
// light-theme Notes). The `<html>` read survives only as the no-shell
// fallback.
const painted = shellAppearanceDark();
if (painted !== null) return painted ? HighlightTheme.Dark : HighlightTheme.Light;
if (typeof document === "undefined") return HighlightTheme.Light;
const scheme = getComputedStyle(document.documentElement).colorScheme || "";
return scheme.includes("dark") ? HighlightTheme.Dark : HighlightTheme.Light;
Expand Down
21 changes: 21 additions & 0 deletions packages/sdk/src/code-highlight/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ export enum HighlightTheme {
Dark = "github-dark",
}

/**
* The SHELL-painted appearance for this document, or `null` when no shell
* painted one (unit tests, detached docs). The preload writes the resolved
* appearance as `color-scheme` on `<body>` (the `#brainstorm-tokens` style);
* every app's own stylesheet says `:root { color-scheme: light dark }`, which
* out-specifies that rule on `<html>` — so `<html>`'s computed value is the
* constant "light dark" while `<body>` (no competing declaration) carries the
* real appearance. Consumers resolving a highlight theme MUST prefer this
* over the OS-level `prefers-color-scheme` media query: the shell theme can
* disagree with the OS setting, and reading the media query first is exactly
* how a Dark-appearance shell on a light-mode OS shipped github-light tokens
* on a near-black editor (dark-sweep 2026-07-29).
*/
export function shellAppearanceDark(): boolean | null {
if (typeof document === "undefined" || !document.body) return null;
const scheme = getComputedStyle(document.body).colorScheme?.trim();
if (scheme === "dark") return true;
if (scheme === "light") return false;
return null;
}

/** Grammar-chunk loader: resolve a Shiki language id to its `LanguageInput`,
* or `null` when unknown / unavailable (the consumer falls back to plain). */
export type LoadLanguageChunk = (shikiId: string) => Promise<LanguageInput | null>;
Expand Down
36 changes: 36 additions & 0 deletions packages/sdk/src/code-highlight/shell-appearance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
/**
* `shellAppearanceDark` — the shell paints the resolved appearance as
* `color-scheme` on `<body>` (#brainstorm-tokens); this helper is how every
* highlight consumer reads it. Guards the dark-sweep 2026-07-29 regression
* class: resolving a highlight theme from the OS media query (or from
* `<html>`, masked by the app's `:root { color-scheme: light dark }`) painted
* the wrong Shiki palette on the opposite surface.
*/

import { afterEach, describe, expect, it } from "vitest";
import { shellAppearanceDark } from "./index";

describe("shellAppearanceDark", () => {
afterEach(() => {
document.body.style.removeProperty("color-scheme");
});

it("reads the painted body appearance in both directions", () => {
document.body.style.setProperty("color-scheme", "dark");
expect(shellAppearanceDark()).toBe(true);
document.body.style.setProperty("color-scheme", "light");
expect(shellAppearanceDark()).toBe(false);
});

it("returns null when no shell painted a scheme (tests, detached docs)", () => {
expect(shellAppearanceDark()).toBeNull();
});

it("treats the un-resolved 'light dark' pair as not painted", () => {
// The app's own `:root { color-scheme: light dark }` inherits onto
// `<body>` when the shell's rule is absent — that is NOT an appearance.
document.body.style.setProperty("color-scheme", "light dark");
expect(shellAppearanceDark()).toBeNull();
});
});
Loading
Loading