diff --git a/apps/calendar/src/styles.css b/apps/calendar/src/styles.css index ded28b01..b6fdb7e5 100644 --- a/apps/calendar/src/styles.css +++ b/apps/calendar/src/styles.css @@ -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; } @@ -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"] { diff --git a/apps/calendar/src/ui/react/event-chip.test.tsx b/apps/calendar/src/ui/react/event-chip.test.tsx new file mode 100644 index 00000000..aa5cd05d --- /dev/null +++ b/apps/calendar/src/ui/react/event-chip.test.tsx @@ -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); + }); +}); diff --git a/apps/calendar/src/ui/react/event-chip.tsx b/apps/calendar/src/ui/react/event-chip.tsx index 9b45fdf4..947d196a 100644 --- a/apps/calendar/src/ui/react/event-chip.tsx +++ b/apps/calendar/src/ui/react/event-chip.tsx @@ -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; @@ -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; }; diff --git a/apps/calendar/src/ui/react/week-view.tsx b/apps/calendar/src/ui/react/week-view.tsx index 29fc88a9..9dffcc79 100644 --- a/apps/calendar/src/ui/react/week-view.tsx +++ b/apps/calendar/src/ui/react/week-view.tsx @@ -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"; @@ -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; diff --git a/apps/code-editor/src/ui/code-pane.test.ts b/apps/code-editor/src/ui/code-pane.test.ts index f5d0fff5..0da46dc7 100644 --- a/apps/code-editor/src/ui/code-pane.test.ts +++ b/apps/code-editor/src/ui/code-pane.test.ts @@ -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 = { @@ -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 + // (#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); + }); +}); diff --git a/apps/code-editor/src/ui/code-pane.ts b/apps/code-editor/src/ui/code-pane.ts index 7dfd944c..b5b44fb8 100644 --- a/apps/code-editor/src/ui/code-pane.ts +++ b/apps/code-editor/src/ui/code-pane.ts @@ -16,6 +16,7 @@ * itself is destroyed (e.g. last file deleted). */ +import { shellAppearanceDark } from "@brainstorm-os/sdk/code-highlight"; import { attachFindBar, attachFindShortcuts, @@ -1002,6 +1003,19 @@ export function createCodePane(opts: CodePaneOptions): CodePaneController { } gutter.addEventListener("click", onGutterClick); + // Appearance flip while the pane is open: the preload rewrites ``'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 { @@ -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(); @@ -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 ``); + * 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; } diff --git a/packages/editor/src/plugins/code-highlight-plugin.tsx b/packages/editor/src/plugins/code-highlight-plugin.tsx index 4b734848..c835a289 100644 --- a/packages/editor/src/plugins/code-highlight-plugin.tsx +++ b/packages/editor/src/plugins/code-highlight-plugin.tsx @@ -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 — + * ``'s computed `color-scheme` via `shellAppearanceDark` — not the OS + * and not `` (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"; @@ -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> = 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 { @@ -64,6 +87,14 @@ function shikiIdFor(language: string | null | undefined): string | null { } function resolvedTheme(): HighlightTheme { + // The shell-painted `` appearance is the source of truth + // (`shellAppearanceDark` — reading `` 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 `` 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; diff --git a/packages/sdk/src/code-highlight/index.ts b/packages/sdk/src/code-highlight/index.ts index 0506e796..479d4341 100644 --- a/packages/sdk/src/code-highlight/index.ts +++ b/packages/sdk/src/code-highlight/index.ts @@ -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 `` (the `#brainstorm-tokens` style); + * every app's own stylesheet says `:root { color-scheme: light dark }`, which + * out-specifies that rule on `` — so ``'s computed value is the + * constant "light dark" while `` (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; diff --git a/packages/sdk/src/code-highlight/shell-appearance.test.ts b/packages/sdk/src/code-highlight/shell-appearance.test.ts new file mode 100644 index 00000000..c35cc30e --- /dev/null +++ b/packages/sdk/src/code-highlight/shell-appearance.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +/** + * `shellAppearanceDark` — the shell paints the resolved appearance as + * `color-scheme` on `` (#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 + * ``, 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 + // `` when the shell's rule is absent — that is NOT an appearance. + document.body.style.setProperty("color-scheme", "light dark"); + expect(shellAppearanceDark()).toBeNull(); + }); +}); diff --git a/packages/shell/src/renderer/dashboard/widgets-layer.test.tsx b/packages/shell/src/renderer/dashboard/widgets-layer.test.tsx index 259f1e54..6664f6f6 100644 --- a/packages/shell/src/renderer/dashboard/widgets-layer.test.tsx +++ b/packages/shell/src/renderer/dashboard/widgets-layer.test.tsx @@ -86,6 +86,26 @@ describe("DashboardWidgetsLayer — apps:changed refresh (F-380)", () => { expect(host.querySelector(".dashboard-widgets__title")?.textContent).toBe("Recent Notes"); }); + it("re-requests the header glyph after apps:changed — a 404 during the reinstall window heals", async () => { + // The header AppIcon's src must carry the apps epoch: an icon request + // racing the installer's uninstall→install window 404s, AppIcon falls + // back to initials, and a CONSTANT src would pin that fallback for the + // session (the dashboard "RN" glyph, dark-sweep 2026-07-29). + await act(async () => { + root.render(); + }); + await flush(); + const glyphSrc = () => + host.querySelector(".app-icon-glyph__image")?.getAttribute("src"); + expect(glyphSrc()).toBe(`brainstorm://app-icon/${encodeURIComponent(NOTES)}?e=0`); + + await act(async () => { + for (const fire of appsChangedListeners) fire(); + }); + await flush(); + expect(glyphSrc()).toBe(`brainstorm://app-icon/${encodeURIComponent(NOTES)}?e=1`); + }); + it("renders a record stranded off-surface back inside the viewport (F-379)", async () => { // The pre-7.3b migration bug baked ×10 positions into stored records // (session 375: left 336px / top 4016px on an 800px screen). PR #92 diff --git a/packages/shell/src/renderer/dashboard/widgets-layer.tsx b/packages/shell/src/renderer/dashboard/widgets-layer.tsx index c6306432..faa916ed 100644 --- a/packages/shell/src/renderer/dashboard/widgets-layer.tsx +++ b/packages/shell/src/renderer/dashboard/widgets-layer.tsx @@ -844,11 +844,16 @@ const WidgetCard = memo(function WidgetCardImpl(props: WidgetCardProps) { > {/* The owning app's brand glyph — the `glyph` variant drops the squircle tile + glass, so the header reads as a bare mark (the - full tile was visual noise at this size). */} + full tile was visual noise at this size). `appsEpoch` in the + URL makes a failed load retry after every (re)install: a + request racing the installer's uninstall→install window 404s, + AppIcon falls back to initials, and a constant src pins that + fallback for the whole session (the same F-380 edge the + iframe entry and titles already re-resolve for). */} diff --git a/tests/visual/specs/whiteboard-resize.spec.ts b/tests/visual/specs/whiteboard-resize.spec.ts index f321039e..6d47a234 100644 --- a/tests/visual/specs/whiteboard-resize.spec.ts +++ b/tests/visual/specs/whiteboard-resize.spec.ts @@ -66,9 +66,10 @@ test("whiteboard node resize snaps its moving edge to a neighbour", async () => // Two stickies at a known separation (the same dev seeding the perf // suite uses — deterministic, no menu-chrome selectors). await wb.evaluate(() => { - ( - window as unknown as { __brainstormWhiteboardDev: WbDev } - ).__brainstormWhiteboardDev.seedGrid(2, { cols: 2, cell: 300 }); + (window as unknown as { __brainstormWhiteboardDev: WbDev }).__brainstormWhiteboardDev.seedGrid( + 2, + { cols: 2, cell: 300 }, + ); }); await expect(wb.locator(".whiteboard__node")).toHaveCount(2, { timeout: 10_000 }); diff --git a/tests/visual/specs/whiteboard-snap.spec.ts b/tests/visual/specs/whiteboard-snap.spec.ts index d4ae282d..896f15fb 100644 --- a/tests/visual/specs/whiteboard-snap.spec.ts +++ b/tests/visual/specs/whiteboard-snap.spec.ts @@ -61,9 +61,10 @@ test("whiteboard drag snaps to a neighbour + draws an alignment guide", async () // Add menu via `.whiteboard__add-trigger`, a selector the React-chrome // migration retired — dev seeding is deterministic and chrome-free.) await wb.evaluate(() => { - ( - window as unknown as { __brainstormWhiteboardDev: WbDev } - ).__brainstormWhiteboardDev.seedGrid(2, { cols: 2, cell: 240 }); + (window as unknown as { __brainstormWhiteboardDev: WbDev }).__brainstormWhiteboardDev.seedGrid( + 2, + { cols: 2, cell: 240 }, + ); }); await expect(wb.locator(".whiteboard__node")).toHaveCount(2, { timeout: 10_000 });