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
13 changes: 13 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@
--breakpoint-sm: 40rem;
--breakpoint-lg: 64rem;
--breakpoint-wide: 68.75rem;

/* Z-index scale tokens:
z-1 - Navigation menu indicator (z-1)
z-toast - Toast notifications viewport (z-100)
z-shortcuts - Keyboard shortcut indicator pill (z-700)
z-overlay - Modal dialogs, command palette & backdrop overlays (z-800)
z-skiplink - Skip to main content focus link (z-1000)
*/
--z-index-1: 1;
--z-index-toast: 100;
--z-index-shortcuts: 700;
--z-index-overlay: 800;
--z-index-skiplink: 1000;
}

/* =========================================================
Expand Down
2 changes: 1 addition & 1 deletion src/components/studio/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export const AppShell = () => {
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[1000] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:ring-2 focus:ring-saffron focus:ring-offset-2 focus:ring-offset-background"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-skiplink focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:ring-2 focus:ring-saffron focus:ring-offset-2 focus:ring-offset-background"
>
Skip to main content
</a>
Expand Down
19 changes: 18 additions & 1 deletion src/components/studio/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import { Overlay } from '@/components/studio/ui/shared-primitives'
import type { ViewId } from '@/lib/studio/types'
import { getEntityTypeMeta } from '@/lib/studio/entity-types'
import { translate } from '@/lib/i18n/messages/palette'
import { cn } from '@/lib/utils'

/** Standard top margin class for the command palette overlay dialog. */
export const COMMAND_PALETTE_TOP_MARGIN_CLASS = 'mt-[12vh]'

/** Standard width class for the command palette overlay dialog. */
export const COMMAND_PALETTE_WIDTH_CLASS = 'w-[640px]'

interface CmdItem {
id: string
Expand Down Expand Up @@ -130,7 +137,17 @@ export const CommandPalette = ({ onEntitySelect }: CommandPaletteProps) => {
if (!commandOpen) return null

return (
<Overlay open={commandOpen} onClose={close} aria-label={translate('palette.ariaLabel')} variant="center" className="mt-[12vh] w-[640px] max-w-[92vw] overflow-hidden rounded-xl border border-border bg-popover shadow-2xl">
<Overlay
open={commandOpen}
onClose={close}
aria-label={translate('palette.ariaLabel')}
variant="center"
className={cn(
COMMAND_PALETTE_TOP_MARGIN_CLASS,
COMMAND_PALETTE_WIDTH_CLASS,
'max-w-[92vw] overflow-hidden rounded-xl border border-border bg-popover shadow-2xl',
)}
>
<CommandPrimitive
loop
label={translate('palette.ariaLabel')}
Expand Down
2 changes: 1 addition & 1 deletion src/components/studio/shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ export const ShortcutsDialog = (): React.JSX.Element => {
exit={{ opacity: 0, y: 8 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.15 }}
className={cn(
'fixed bottom-4 left-4 z-[700] flex items-center gap-2 rounded-full border border-saffron/40 bg-popover px-3 py-1.5 shadow-lifted',
'fixed bottom-4 left-4 z-shortcuts flex items-center gap-2 rounded-full border border-saffron/40 bg-popover px-3 py-1.5 shadow-lifted',
)}
aria-live="polite"
>
Expand Down
10 changes: 9 additions & 1 deletion src/components/studio/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ const SidebarNav = () => {
)
}

/** Sidebar width geometry constant. */
export const SIDEBAR_WIDTH_CLASS = 'w-[248px]'

/** Sidebar with brand, search trigger, navigation, and footer controls. */
export const Sidebar = () => {
const isMacPlatform = useIsMacPlatform()
Expand All @@ -185,7 +188,12 @@ export const Sidebar = () => {
const { theme, setTheme } = useTheme()

return (
<aside className="hidden h-full w-[248px] shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground lg:flex">
<aside
className={cn(
'hidden h-full shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground lg:flex',
SIDEBAR_WIDTH_CLASS,
)}
>
{/* Brand */}
<div className="flex items-center gap-2.5 px-5 pt-5 pb-4">
<img src="/logo.svg" alt="" className="h-9 w-9" />
Expand Down
2 changes: 1 addition & 1 deletion src/components/studio/ui/overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export const Overlay = ({
<div
onClick={handleBackdropClick}
className={cn(
'fixed inset-0 z-[800] bg-ink/30 backdrop-blur-sm animate-in fade-in duration-150',
'fixed inset-0 z-overlay bg-ink/30 backdrop-blur-sm animate-in fade-in duration-150',
getBackdropClasses(variant),
)}
>
Expand Down
16 changes: 8 additions & 8 deletions src/components/studio/views/graph-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useStudioStore } from '@/lib/studio/store'
import { seedGraph } from '@/lib/studio/seed-data'
import { placeGraphNodes } from '@/lib/studio/graph-layout'
import { placeGraphNodes, computeCircularLayout } from '@/lib/studio/graph-layout'
import { canvasSize, canvasViewBox } from '@/lib/studio/graph-viewport'
import { getEntityTypeDefs, getEntityTypeMeta } from '@/lib/studio/entity-types'
import { translate as entityTypesT } from '@/lib/i18n/messages/entity-types'
Expand All @@ -23,6 +23,10 @@ import {
type GraphLayout,
} from '@/lib/studio/graph-snapshot'
import { FOCUS_MODE_FILTER_STYLE, GraphEdgeElement } from './graph-elements'
import { truncateGraphemes } from '@/lib/text'

/** Maximum character/grapheme count for node labels before truncation. */
export const GRAPH_LABEL_MAX = 24

/** Interactive knowledge graph view with force, circular, and hierarchical layouts. */
export const GraphView = () => {
Expand Down Expand Up @@ -68,12 +72,8 @@ export const GraphView = () => {
// Apply layout transforms
const positioned = useMemo(() => {
if (layout === 'circular') {
const cx = 400, cy = 280, r = 200
return nodes.map((n, i) => ({
...n,
x: cx + r * Math.cos((2 * Math.PI * i) / nodes.length),
y: cy + r * Math.sin((2 * Math.PI * i) / nodes.length),
}))
const baseCanvas = canvasSize(nodes)
return computeCircularLayout(nodes, baseCanvas)
}
if (layout === 'hierarchical') {
const cols = Math.ceil(Math.sqrt(nodes.length))
Expand Down Expand Up @@ -384,7 +384,7 @@ const handleGraphKeyDown = useCallback(
isSelected ? 'fill-ink' : 'fill-ink-soft',
)}
>
{n.label.length > 24 ? `${n.label.slice(0, 22)}…` : n.label}
{truncateGraphemes(n.label, GRAPH_LABEL_MAX)}
</text>
</g>
</g>
Expand Down
6 changes: 5 additions & 1 deletion src/components/studio/views/triz-matrix-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { TextInput } from '../ui/shared-primitives'
import { useReducedMotion } from '@/lib/studio/use-reduced-motion'
import { filterParams } from './triz-view-utils'
import { translate } from '@/lib/i18n/messages/triz'
import { truncateGraphemes } from '@/lib/text'

/** Maximum character/grapheme count for TRIZ row labels before truncation. */
export const TRIZ_LABEL_MAX = 12

type FilteredParameter = ReturnType<typeof filterParams>[number]

Expand Down Expand Up @@ -88,7 +92,7 @@ const MatrixRow = ({
title={rowLabel}
>
<span className="mr-1 font-mono text-caption">{rowIndex + 1}</span>
{rowLabel.length > 12 ? `${rowLabel.slice(0, 12)}…` : rowLabel}
{truncateGraphemes(rowLabel, TRIZ_LABEL_MAX)}
</td>
{filtered.map(({ index: colIndex, label: colLabel }) => {
const key = `${rowIndex}-${colIndex}`
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/navigation-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 z-toast flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
Expand Down
36 changes: 36 additions & 0 deletions src/lib/studio/graph-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
BASE_BAND_CAPACITY,
BASE_PLACEMENT_BAND,
baseNodePosition,
computeCircularLayout,
placeGraphNodes,
placementBand,
resolveNodePosition,
Expand Down Expand Up @@ -132,6 +133,41 @@ describe('placementBand', () => {
})
})

describe('computeCircularLayout', () => {
it('returns empty array when given no nodes', () => {
expect(computeCircularLayout([], { width: 800, height: 560 })).toEqual([])
})

it('centers nodes on the canvas center for any canvas size', () => {
const testNodes: GraphNode[] = [
{ id: '1', label: 'N1', type: 'concept', x: 0, y: 0 },
{ id: '2', label: 'N2', type: 'concept', x: 0, y: 0 },
{ id: '3', label: 'N3', type: 'concept', x: 0, y: 0 },
{ id: '4', label: 'N4', type: 'concept', x: 0, y: 0 },
]

const canvas = { width: 1000, height: 600 }
const circular = computeCircularLayout(testNodes, canvas)

const expectedCx = 1000 / 2
const expectedCy = 600 / 2
const expectedR = Math.min(expectedCx, expectedCy) * 0.7

// Average position of nodes on a full circle should equal (cx, cy)
const avgX = circular.reduce((sum, n) => sum + n.x, 0) / circular.length
const avgY = circular.reduce((sum, n) => sum + n.y, 0) / circular.length

expect(avgX).toBeCloseTo(expectedCx, 5)
expect(avgY).toBeCloseTo(expectedCy, 5)

// Distance of each node from center should equal expectedR
for (const node of circular) {
const dist = Math.hypot(node.x - expectedCx, node.y - expectedCy)
expect(dist).toBeCloseTo(expectedR, 5)
}
})
})

describe('placeGraphNodes', () => {
it('keeps authored seed positions', () => {
const seedNode = seedGraph.nodes[0] as GraphNode
Expand Down
20 changes: 20 additions & 0 deletions src/lib/studio/graph-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,26 @@ const byEntityId = (a: Entity, b: Entity): number => a.id.localeCompare(b.id)
* are placed in grows with their count, so a large library spreads out instead of
* crowding into sub-click-safe gaps (see `placementBand`).
*/
/**
* Computes circular layout node positions centered at (canvas.width / 2, canvas.height / 2)
* with radius derived from Math.min(cx, cy) * radiusFactor.
*/
export const computeCircularLayout = (
nodes: readonly GraphNode[],
canvas: { width: number; height: number },
radiusFactor = 0.7,
): GraphNode[] => {
if (nodes.length === 0) return []
const cx = canvas.width / 2
const cy = canvas.height / 2
const r = Math.min(cx, cy) * radiusFactor
return nodes.map((n, i) => ({
...n,
x: cx + r * Math.cos((2 * Math.PI * i) / nodes.length),
y: cy + r * Math.sin((2 * Math.PI * i) / nodes.length),
}))
}

export const placeGraphNodes = (
entities: readonly Entity[],
seedNodes: readonly GraphNode[],
Expand Down
36 changes: 36 additions & 0 deletions src/lib/text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { truncateGraphemes } from './text'

describe('truncateGraphemes', () => {
it('returns empty string for falsy input', () => {
expect(truncateGraphemes('', 10)).toBe('')
})

it('does not truncate text within maxGraphemes limit', () => {
expect(truncateGraphemes('Hello World', 20)).toBe('Hello World')
expect(truncateGraphemes('Short', 5)).toBe('Short')
})

it('truncates standard ASCII text when exceeding limit', () => {
expect(truncateGraphemes('Hello World Today', 10)).toBe('Hello Wor…')
})

it('handles multi-byte and emoji graphemes without splitting surrogates', () => {
// 👨‍👩‍👧‍👦 is 1 grapheme cluster (family emoji)
const textWithEmojis = '👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦'
const truncated = truncateGraphemes(textWithEmojis, 3)
expect(truncated).toBe('👨‍👩‍👧‍👦👨‍👩‍👧‍👦…')
})

it('truncates graph labels (max 24) safely', () => {
const longLabel = 'This is a very long graph node label that exceeds limits'
const truncated = truncateGraphemes(longLabel, 24)
expect(truncated).toBe('This is a very long gra…')
})

it('truncates TRIZ labels (max 12) safely', () => {
const longParam = 'Temperature / Thermal Expansion'
const truncated = truncateGraphemes(longParam, 12)
expect(truncated).toBe('Temperature…')
})
})
43 changes: 43 additions & 0 deletions src/lib/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Grapheme-safe text utilities using Intl.Segmenter.
*/

let graphemeSegmenter: Intl.Segmenter | null = null

function getGraphemeSegmenter(): Intl.Segmenter | null {
if (graphemeSegmenter !== null) return graphemeSegmenter
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
try {
graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
return graphemeSegmenter
} catch {
return null
}
}
return null
}

/**
* Truncates `text` to at most `maxGraphemes` user-perceived graphemes,
* appending `ellipsis` if truncated.
*/
export function truncateGraphemes(
text: string,
maxGraphemes: number,
ellipsis = '…',
): string {
if (!text) return ''
const segmenter = getGraphemeSegmenter()
if (segmenter) {
const segments = Array.from(segmenter.segment(text))
if (segments.length <= maxGraphemes) return text
const cutCount = Math.max(1, maxGraphemes - ellipsis.length)
Comment thread
d-oit marked this conversation as resolved.
return segments.slice(0, cutCount).map((s) => s.segment).join('') + ellipsis
}

// Fallback for legacy environments without Intl.Segmenter
const graphemes = Array.from(text)
Comment thread
d-oit marked this conversation as resolved.
if (graphemes.length <= maxGraphemes) return text
const cutCount = Math.max(1, maxGraphemes - ellipsis.length)
return graphemes.slice(0, cutCount).join('') + ellipsis
}
Loading