From 8d6250d0c0a42b2ca27894f781bb0a70dff1812d Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 30 Apr 2026 13:14:55 -0400 Subject: [PATCH 01/25] build: shadcn primitives, Tailwind tokens, Monaco unification, datastore baselines, core hooks - Install shadcn/ui primitives (Dialog, Tooltip, Dropdown, Tabs, etc.), Zustand, jsdom, Inter font - Add cloud-dashboard Tailwind tokens; drop CRA boilerplate; switch to Inter - Unify Monaco editor: shared theme, multi-editor instance support, manual layout observer - Track datastore baseline for "modified" detection - Add useDocumentIdentity + useResolvedTheme hooks - Add top-bar chrome components (auth-slot, breadcrumb-pill, document-link, theme-toggle) --- package.json | 16 +- src/App.css | 39 - src/components/EditorDisplay.tsx | 320 +++++--- src/components/ShareLoader.tsx | 2 +- src/components/auth-slot.tsx | 14 + src/components/breadcrumb-pill.tsx | 64 ++ src/components/document-link.tsx | 44 ++ .../relationshipeditor/tuplelang.ts | 2 +- src/components/theme-toggle.tsx | 47 ++ src/components/ui/button.tsx | 18 +- src/components/ui/checkbox.tsx | 30 + src/components/ui/combobox.tsx | 237 ++++++ src/components/ui/command.tsx | 184 +++++ src/components/ui/context-menu.tsx | 250 +++++++ src/components/ui/dialog.tsx | 156 ++++ src/components/ui/dropdown-menu.tsx | 257 +++++++ src/components/ui/input.tsx | 25 + src/components/ui/label.tsx | 22 + src/components/ui/popover.tsx | 87 +++ src/components/ui/progress.tsx | 29 + src/components/ui/table.tsx | 114 +++ src/components/ui/tabs.tsx | 57 +- src/components/ui/tooltip.tsx | 56 ++ src/hooks/use-document-identity.test.tsx | 69 ++ src/hooks/use-document-identity.ts | 43 ++ src/hooks/use-resolved-theme.ts | 24 + src/index.css | 82 +- src/index.tsx | 3 + src/services/datastore.test.ts | 50 ++ src/services/datastore.ts | 67 +- src/spicedb-common/lang/dslang.ts | 125 +++- yarn.lock | 708 ++++++++++++++++-- 32 files changed, 2987 insertions(+), 254 deletions(-) create mode 100644 src/components/auth-slot.tsx create mode 100644 src/components/breadcrumb-pill.tsx create mode 100644 src/components/document-link.tsx create mode 100644 src/components/theme-toggle.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/combobox.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/context-menu.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/use-document-identity.test.tsx create mode 100644 src/hooks/use-document-identity.ts create mode 100644 src/hooks/use-resolved-theme.ts create mode 100644 src/services/datastore.test.ts diff --git a/package.json b/package.json index 26ac21b..8867e51 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@aws-sdk/client-s3": "^3.997.0", "@bufbuild/protobuf": "^2.4.0", "@dagrejs/dagre": "^2.0.4", + "@fontsource/inter": "^5", "@fontsource/roboto": "^5.1.1", "@glideapps/glide-data-grid": "^6.0.3", "@glideapps/glide-data-grid-cells": "^6.0.3", @@ -30,9 +31,17 @@ "@monaco-editor/react": "^4.7.0", "@posthog/react": "^1.8.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-pacer": "^0.20.0", "@tanstack/react-router": "^1.163.2", @@ -43,6 +52,7 @@ "ansi-to-html": "^0.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "d3-scale-chromatic": "^2.0.0", "dequal": "^2.0.2", "file-saver": "^2.0.5", @@ -58,8 +68,8 @@ "react-cookie": "^8.0.1", "react-dom": "^18.3.1", "react-joyride": "^2.5.3", - "react-reflex": "^4.2.7", "react-responsive-carousel": "^3.2.23", + "serve": "^14.2.6", "sjcl": "^1.0.8", "sonner": "^2.0.7", "string-to-color": "^2.2.2", @@ -71,7 +81,8 @@ "use-deep-compare": "^1.1.0", "use-deep-compare-effect": "^1.8.1", "yaml": "^2.0.1", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.12" }, "devDependencies": { "@testing-library/react": "^14.0.0", @@ -93,6 +104,7 @@ "cypress": "^12.9.0", "cypress-wait-until": "^1.7.2", "globals": "^15.14.0", + "jsdom": "^25", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", "oxlint-tsgolint": "^0.15.0", diff --git a/src/App.css b/src/App.css index 329cc34..97a6f1d 100644 --- a/src/App.css +++ b/src/App.css @@ -3,42 +3,3 @@ body { overflow: hidden; } - -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/components/EditorDisplay.tsx b/src/components/EditorDisplay.tsx index 1ce9f24..e27f9b1 100644 --- a/src/components/EditorDisplay.tsx +++ b/src/components/EditorDisplay.tsx @@ -1,23 +1,22 @@ import { TextRange } from "@authzed/spicedb-parser-js"; import { Editor, DiffEditor, useMonaco } from "@monaco-editor/react"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; -import { useNavigate, useLocation } from "@tanstack/react-router"; +import { useLocation } from "@tanstack/react-router"; import lineColumn from "line-column"; import * as monaco from "monaco-editor"; import { useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; -import "react-reflex/styles.css"; -import { useMediaQuery } from "@/hooks/use-media-query"; +import { useResolvedTheme } from "@/hooks/use-resolved-theme"; import { ScrollLocation, useCookieService } from "../services/cookieservice"; import { DataStore, DataStoreItem, DataStoreItemKind } from "../services/datastore"; import { LocalParseState } from "../services/localparse"; import { Services } from "../services/services"; import registerDSLanguage, { - DS_DARK_THEME_NAME, DS_LANGUAGE_NAME, - DS_THEME_NAME, + PLAYGROUND_DARK_THEME_NAME, + PLAYGROUND_LIGHT_THEME_NAME, } from "../spicedb-common/lang/dslang"; import { RelationshipFound } from "../spicedb-common/parsing"; import { @@ -25,12 +24,18 @@ import { DeveloperWarning, } from "../spicedb-common/protodefs/developer/v1/developer_pb"; +import { useDrawerStore } from "./drawer/state"; import { ERROR_SOURCE_TO_ITEM } from "./panels/errordisplays"; -import registerTupleLanguage, { - TUPLE_DARK_THEME_NAME, - TUPLE_LANGUAGE_NAME, - TUPLE_THEME_NAME, -} from "./relationshipeditor/tuplelang"; +import registerTupleLanguage, { TUPLE_LANGUAGE_NAME } from "./relationshipeditor/tuplelang"; + +// Module-level singletons for one-shot language registration. Monaco's +// `register*` calls are global; calling them on every editor mount can stack +// providers (each registration adds a new completion/definition/semantic-tokens +// provider rather than replacing). The `latestLocalParseStateRef` lets the +// registered tuple completion provider always read the most recent parse +// state without re-registering. +let languagesRegistered = false; +const latestLocalParseStateRef: { current: LocalParseState | null } = { current: null }; export type EditorDisplayProps = { datastore: DataStore; @@ -61,63 +66,50 @@ export function EditorDisplay(props: EditorDisplayProps) { const monacoRef = useMonaco(); const [monacoReady, setMonacoReady] = useState(false); const [localIndex, setLocalIndex] = useState(0); - const localParseState = useRef(props.services.localParseService.state); - // Effect: Register the languages in monaco. + // Keep the module-level ref in sync so the (one-shot) tuple completion + // provider always reads the latest parse state. + latestLocalParseStateRef.current = props.services.localParseService.state; + + // Effect: Register the languages in monaco. Runs ONCE per page load — Monaco + // language providers are global state and re-registering stacks providers. useEffect(() => { if (monacoRef) { - registerDSLanguage(monacoRef); - registerTupleLanguage(monacoRef, () => localParseState.current); + if (!languagesRegistered) { + registerDSLanguage(monacoRef); + registerTupleLanguage(monacoRef, () => latestLocalParseStateRef.current!); + languagesRegistered = true; + } setMonacoReady(true); } }, [monacoRef]); useEffect(() => { - localParseState.current = props.services.localParseService.state; + latestLocalParseStateRef.current = props.services.localParseService.state; }, [props.services.localParseService.state]); - const navigate = useNavigate(); const location = useLocation(); const datastore = props.datastore; const currentItem = props.currentItem; const editorRefs = useRef>({}); + const containerRef = useRef(null); // Select the theme and language. - const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + const resolvedTheme = useResolvedTheme(); + const prefersDarkMode = resolvedTheme === "dark"; + // A single unified theme is used for every editor instance regardless of + // language. Monaco's `setTheme` is global — applying different themes per + // editor causes the last mount/update to win and visually corrupt the + // others. The unified theme contains rules for every language we render. const themeName = useMemo(() => { if (props.themeName) { return props.themeName; } - - switch (currentItem?.kind) { - case DataStoreItemKind.SCHEMA: - // Schema. - return prefersDarkMode ? DS_DARK_THEME_NAME : DS_THEME_NAME; - - case DataStoreItemKind.RELATIONSHIPS: - // Validation tuples. - return prefersDarkMode ? TUPLE_DARK_THEME_NAME : TUPLE_THEME_NAME; - - case DataStoreItemKind.EXPECTED_RELATIONS: - // Expected Relations YAML. - return prefersDarkMode ? "vs-dark" : "vs"; - - case DataStoreItemKind.ASSERTIONS: - // Assertions YAML. - return prefersDarkMode ? "vs-dark" : "vs"; - - case undefined: - // Schema. - return prefersDarkMode ? DS_DARK_THEME_NAME : DS_THEME_NAME; - - default: - console.log(`Unknown item kind ${currentItem?.kind} in theme name`); - return "vs"; - } - }, [prefersDarkMode, currentItem?.kind, props.themeName]); + return prefersDarkMode ? PLAYGROUND_DARK_THEME_NAME : PLAYGROUND_LIGHT_THEME_NAME; + }, [prefersDarkMode, props.themeName]); const languageName = useMemo(() => { switch (currentItem?.kind) { @@ -156,15 +148,19 @@ export function EditorDisplay(props: EditorDisplayProps) { // ms. To avoid this behavior, we tell React we want these updates to occur immediately // via the `flushSync` call. // See: https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#automatic-batching + // + // NOTE: We do NOT navigate the URL based on the edited item's pathname here. + // With the editor-groups split layout the URL is owned by the primary group's + // active tab. Typing in the secondary group must not change the URL — doing + // so triggers the URL->primary bridge in FullPlayground which would force the + // primary group to switch to whatever document the secondary is editing, + // causing both Monaco editors to swap models on every keystroke (perceived + // as a hard freeze). `datastore.update` never mutates `pathname`, so the + // previous navigate-on-mismatch was also dead code for the single-editor + // case. flushSync(() => { setLocalIndex(localIndex + 1); - - // TODO: this shouldn't be necessary. Moving to redux may make this less painful. - const updated = datastore.update(currentItem!, value || ""); - if (updated && updated.pathname !== location.pathname) { - void navigate({ to: updated.pathname, replace: true }); - } - + datastore.update(currentItem!, value || ""); props.datastoreUpdated(); }); }; @@ -211,18 +207,28 @@ export function EditorDisplay(props: EditorDisplayProps) { // Generate markers for warnings. if (currentItem.kind === DataStoreItemKind.SCHEMA) { props.services.problemService.warnings.forEach((warning: DeveloperWarning) => { - const line = lines[warning.line - 1]; - const index = line.indexOf(warning.sourceCode, warning.column - 1); - if (monacoRef) { - markers.push({ - startLineNumber: warning.line, - startColumn: index + 1, - endLineNumber: warning.line, - endColumn: index + warning.sourceCode.length + 1, - message: warning.message, - severity: monacoRef.MarkerSeverity.Warning, - }); + const lineText = lines[warning.line - 1]; + if (lineText === undefined || !monacoRef) { + return; + } + // Locate the source code on the line, starting near the reported column. + // Falls back to a full-line search if the reported column lands past the token. + const searchFrom = Math.max(0, (warning.column ?? 1) - 1); + let index = lineText.indexOf(warning.sourceCode, searchFrom); + if (index < 0) { + index = lineText.indexOf(warning.sourceCode); } + if (index < 0) { + return; + } + markers.push({ + startLineNumber: warning.line, + startColumn: index + 1, + endLineNumber: warning.line, + endColumn: index + warning.sourceCode.length + 1, + message: warning.message, + severity: monacoRef.MarkerSeverity.Warning, + }); }); } @@ -241,48 +247,70 @@ export function EditorDisplay(props: EditorDisplayProps) { let column = de.column; let endColumn = column; - if (de.context) { - // If there is no line information, then search for the first occurrence of the context. + // Trim leading/trailing whitespace from the context. Note: per the + // developer.v1 proto, `context` may be a broad string (e.g. the full + // relationship for relationship issues, or the object type name for + // schema issues) — NOT necessarily the offending token. So we only + // use it to refine width when it's a clean single-word token; otherwise + // we fall back to a tight 1-char squiggle at the reported column. + const rawContext = de.context ?? ""; + const trimmedContext = rawContext.trim(); + const isSingleWordToken = !!trimmedContext && !/\s/.test(trimmedContext); + + if (isSingleWordToken) { + // If there is no line information, search the entire document for the + // first occurrence of the trimmed context. if (!line) { - const index = contents.indexOf(de.context); - if (index !== undefined && index >= 0) { + const index = contents.indexOf(trimmedContext); + if (index >= 0) { const found = finder.fromIndex(index); if (found) { line = found.line; column = found.col; - endColumn = column + de.context.length; + endColumn = column + trimmedContext.length; } } } else { - // If there is, ensure the position is still valid. - endColumn = column + de.context.length; - const index = finder.toIndex(line, column); - if (index === undefined) { - return; + // Anchor to the actual occurrence of the trimmed context on (or near) + // the reported line. This is robust against off-by-one / 0-vs-1 + // indexed columns coming from different error producers. + const lineText = lines[line - 1] ?? ""; + const searchFrom = Math.max(0, (column ?? 1) - 1); + let onLineIndex = lineText.indexOf(trimmedContext, searchFrom); + if (onLineIndex < 0) { + onLineIndex = lineText.indexOf(trimmedContext); } - - if (contents.substring(index, de.context.length + index) !== de.context) { - const updatedIndex = contents.indexOf(de.context, index); - if (updatedIndex < index) { - return; - } - - const translated = finder.fromIndex(updatedIndex); - if (translated?.line !== line) { - return; - } - - line = translated.line; - column = translated.col; - endColumn = column + de.context.length; + if (onLineIndex >= 0) { + column = onLineIndex + 1; + endColumn = column + trimmedContext.length; + } else { + // Token not found on the reported line — trust the column and + // underline a single character there. + endColumn = (column ?? 1) + 1; } } + } else { + // Context is empty or multi-word (e.g. a full relationship string). + // Trust the reported line/column and use a tight 1-char squiggle. + // A narrow marker is far better than a wrong wide one. + if (line && column !== undefined) { + endColumn = column + 1; + } } if (!line || column === undefined) { return; } + // Clamp endColumn to the line's actual content length so the squiggle + // does not visually run onto the next line when context is empty or + // miscalculated. + const targetLineText = lines[line - 1] ?? ""; + const maxEndColumn = targetLineText.length + 1; + if (endColumn <= column || endColumn > maxEndColumn) { + endColumn = Math.max(column + 1, Math.min(endColumn, maxEndColumn)); + } + if (monacoRef) { markers.push({ startLineNumber: line, @@ -323,11 +351,55 @@ export function EditorDisplay(props: EditorDisplayProps) { { wait: 250 }, ); + // Manual layout: explicitly drive editor.layout() from a ResizeObserver on the + // container. This avoids contention between Monaco's per-instance internal + // observer (`automaticLayout: true`) when multiple editors are visible at once + // (e.g. in split view), which can stall the UI. + const resizeObserversRef = useRef>({}); + + const attachResizeObserver = (editor: monaco.editor.IStandaloneCodeEditor, key: string) => { + // Observe our React-owned outer wrapper, NOT the Monaco internal DOM. + // Monaco caches its rendered size after each layout() call, so observing + // its own DOM means resize events from CSS-driven parent shrinkage + // (e.g. drawer resize) are missed — the outer flex container shrinks + // but Monaco's frozen-size DOM doesn't, leading to overlap. Observing + // containerRef catches every CSS layout change since it's the outer + //
that we render in JSX. + const observeTarget = containerRef.current; + if (!observeTarget) return; + // Tear down any prior observer for this key before re-attaching. + resizeObserversRef.current[key]?.disconnect(); + // Dedupe identical sizes and coalesce to a single rAF to avoid the + // ResizeObserver -> editor.layout() -> reflow -> ResizeObserver feedback + // loop that can stall the UI when two editors are visible at once. + let lastWidth = 0; + let lastHeight = 0; + let rafId: number | null = null; + const ro = new ResizeObserver((entries) => { + const entry = entries[entries.length - 1]; + if (!entry) return; + const { width, height } = entry.contentRect; + if (width === lastWidth && height === lastHeight) return; + lastWidth = width; + lastHeight = height; + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + editor.layout({ width: lastWidth, height: lastHeight }); + }); + }); + ro.observe(observeTarget); + resizeObserversRef.current[key] = ro; + // Initial layout so the editor sizes itself once parent is laid out. + editor.layout(); + }; + const handleEditorMounted = (editor: monaco.editor.IStandaloneCodeEditor) => { if (currentItem !== undefined && props.diff === undefined) { + const itemId = currentItem.id; editorRefs.current = { ...editorRefs.current, - [currentItem.id]: editor, + [itemId]: editor, }; editor.onDidChangeCursorPosition((e: monaco.editor.ICursorPositionChangedEvent) => { @@ -341,11 +413,66 @@ export function EditorDisplay(props: EditorDisplayProps) { debouncedSetEditorScroll([e.scrollTop, e.scrollLeft]); }); + attachResizeObserver(editor, itemId); + + // Clean up our refs when this editor instance is disposed (e.g. when + // the host pane unmounts). Avoids unbounded growth of editorRefs and + // dangling ResizeObservers pointing at detached DOM. + editor.onDidDispose(() => { + if (editorRefs.current[itemId] === editor) { + delete editorRefs.current[itemId]; + } + resizeObserversRef.current[itemId]?.disconnect(); + delete resizeObserversRef.current[itemId]; + }); + updateMarkers(); updatePosition(); } }; + const handleDiffEditorMounted = (editor: monaco.editor.IStandaloneDiffEditor) => { + if (currentItem === undefined) return; + const modified = editor.getModifiedEditor(); + const key = `${currentItem.id}-diff`; + attachResizeObserver(modified, key); + modified.onDidDispose(() => { + resizeObserversRef.current[key]?.disconnect(); + delete resizeObserversRef.current[key]; + }); + }; + + // Tear down all observers on unmount. + useEffect(() => { + return () => { + Object.values(resizeObserversRef.current).forEach((ro) => ro.disconnect()); + resizeObserversRef.current = {}; + }; + }, []); + + // Drawer-driven relayout: the bottom drawer's resize handle mutates zustand + // state synchronously during mousemove, but the drawer's height change + // doesn't reliably propagate as a contentRect change to ResizeObserver + // observed on our outer wrapper during a drag (the browser batches resize + // observation and may skip frames mid-drag). Subscribe directly to the + // drawer's open/active-panel/per-panel-height state and force a relayout + // on every change, on the next animation frame so layout has settled. + const drawerOpen = useDrawerStore((s) => s.open); + const drawerActivePanel = useDrawerStore((s) => s.activePanel); + const drawerHeight = useDrawerStore((s) => + s.activePanel ? s.perPanelHeight[s.activePanel] : 0, + ); + useEffect(() => { + const editors = editorRefs.current; + if (Object.keys(editors).length === 0) return; + const rafId = requestAnimationFrame(() => { + for (const ed of Object.values(editors)) { + ed.layout(); + } + }); + return () => cancelAnimationFrame(rafId); + }, [drawerOpen, drawerActivePanel, drawerHeight]); + const updatePosition = () => { const editors = editorRefs.current; if (currentItem?.id === undefined || !(currentItem?.id in editors)) { @@ -419,16 +546,21 @@ export function EditorDisplay(props: EditorDisplayProps) { updateMarkers(); } - // NOTE: We only care if the currentItem changes or the errors change. + // NOTE: We depend on the actual problem arrays (not just stateKey/count) + // so the markers re-render whenever errors/warnings change identity even + // if the count happens to stay the same (e.g. one error replaced by another). // eslint-disable-next-line react-hooks/exhaustive-deps }, [ currentItem?.pathname, props.services.problemService.isUpdating, - props.services.problemService.stateKey, + props.services.problemService.requestErrors, + props.services.problemService.warnings, + props.services.problemService.validationErrors, + props.services.problemService.invalidRelationships, ]); return ( -
+
{monacoReady && currentItem && (
{props.diff ? ( @@ -454,10 +586,14 @@ export function EditorDisplay(props: EditorDisplayProps) { fontSize: props.fontSize, scrollBeyondLastLine: props.scrollBeyondLastLine ?? (props.disableScrolling === true ? false : true), + automaticLayout: false, }} original={props.diff} modified={currentItem?.editableContents} language={languageName} + originalModelPath={`${currentItem.id}-original`} + modifiedModelPath={currentItem.id} + onMount={handleDiffEditorMounted} /> ) : ( )}
diff --git a/src/components/ShareLoader.tsx b/src/components/ShareLoader.tsx index cc6fca5..38b7285 100644 --- a/src/components/ShareLoader.tsx +++ b/src/components/ShareLoader.tsx @@ -1,7 +1,6 @@ import { useNavigate, useLocation } from "@tanstack/react-router"; import { CircleX } from "lucide-react"; import React, { useEffect, useState } from "react"; -import "react-reflex/styles.css"; import { toast } from "sonner"; import { useConfirmDialog } from "../playground-ui/ConfirmDialogProvider"; @@ -139,6 +138,7 @@ export function ShareLoader(props: { assertionsYaml: shareData.assertions_yaml || "", verificationYaml: shareData.validation_yaml || "", }); + datastore.setBaseline("shared", shareReference); } if (!props.sharedRequired) { diff --git a/src/components/auth-slot.tsx b/src/components/auth-slot.tsx new file mode 100644 index 0000000..bf2bb15 --- /dev/null +++ b/src/components/auth-slot.tsx @@ -0,0 +1,14 @@ +/** + * AuthSlot — reserved space on the far right of the top bar for a future + * avatar / sign-in affordance. Today renders nothing visible but maintains + * fixed layout width so authentication can drop in without reflow. + */ +export function AuthSlot() { + return ( +