@lincyaw/host 0.0.0-stage → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/control.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { SESSION } from '@lincyaw/kernel/contracts'
2
+ import type { ApprovalRequest, ServiceToken, Unsubscribe } from '@lincyaw/kernel/contracts'
3
+ import { createStore } from './store.ts'
4
+ import type { Store } from './store.ts'
5
+ import type { Navigation, NavigationPosition, ServerClient, StatusRegistry, ViewRole } from './contracts.ts'
6
+ import { clientDocument, clientEventSubscription, documentHeader, pluginRoutePath } from './server.ts'
7
+
8
+ export interface PendingApproval {
9
+ id: string
10
+ request: ApprovalRequest
11
+ }
12
+
13
+ export interface ApprovalState {
14
+ pending: Store<PendingApproval[]>
15
+ answer(id: string, allow: boolean): Promise<void>
16
+ }
17
+
18
+ const pending = createStore<PendingApproval[]>([])
19
+ let answering: (id: string, allow: boolean) => Promise<void> = async () => {}
20
+
21
+ export const approvals: ApprovalState = {
22
+ pending,
23
+ answer: (id, allow) => answering(id, allow),
24
+ }
25
+
26
+ function roleOf(position: NavigationPosition): ViewRole {
27
+ if (position.view === 'presenter') return 'presenter'
28
+ if (position.view === 'present') return 'audience'
29
+ if (position.view !== undefined) return 'reader'
30
+ return typeof location !== 'undefined' && /\/presenter(?:\/|$)/.test(location.pathname) ? 'presenter' : 'audience'
31
+ }
32
+
33
+ export function connectControl(client: ServerClient, host: {
34
+ navigation: Navigation
35
+ status: StatusRegistry
36
+ service<T>(token: ServiceToken<T>): T | undefined
37
+ }): Unsubscribe {
38
+ const { navigation, status } = host
39
+ const document = clientDocument(client)
40
+ const id = Math.random().toString(36).slice(2, 12)
41
+ const control = `${pluginRoutePath(client, 'presmium.server')}control`
42
+ const post = (body: Record<string, unknown>) => fetch(control, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', 'Presmium-Document': documentHeader(document) },
45
+ body: JSON.stringify({ window: id, ...body }),
46
+ }).then(async response => ({ ok: response.ok, status: response.status, text: await response.text() }))
47
+ answering = async (approval, allow) => {
48
+ const response = await post({ type: 'answer', id: approval, allow })
49
+ if (!response.ok) throw new Error(`the server did not take the answer (${response.status})`)
50
+ pending.set(pending.getSnapshot().filter(p => p.id !== approval))
51
+ }
52
+ const subscribe = clientEventSubscription(client)
53
+ const source = subscribe ? undefined : new EventSource(`${control}?window=${id}&document=${documentHeader(document)}`)
54
+ const handlers = new Map<string, (data: string) => void>()
55
+ const on = <T>(event: string, handle: (data: T) => void) => {
56
+ const receive = (data: string) => {
57
+ try {
58
+ handle(JSON.parse(data) as T)
59
+ } catch (error) {
60
+ console.error(`[presmium] control channel: bad ${event} message`, error)
61
+ }
62
+ }
63
+ handlers.set(event, receive)
64
+ source?.addEventListener(event, message => receive((message as MessageEvent<string>).data))
65
+ }
66
+ on<PendingApproval>('approval', (approval) => {
67
+ if (!pending.getSnapshot().some(p => p.id === approval.id)) pending.set([...pending.getSnapshot(), approval])
68
+ })
69
+ on<{ id: string }>('settled', (settled) => {
70
+ pending.set(pending.getSnapshot().filter(p => p.id !== settled.id))
71
+ })
72
+ on<NavigationPosition>('go', position => navigation.go(position))
73
+ on<{ action: 'start' | 'end', name?: string }>('session', ({ action, name }) => {
74
+ const session = host.service(SESSION)
75
+ if (!session) return
76
+ void (action === 'start' ? session.create(name ?? '') : session.end()).catch((error: unknown) => {
77
+ console.error('[presmium] the classroom command failed', error)
78
+ })
79
+ })
80
+
81
+ let reported = ''
82
+ const report = () => {
83
+ const position = navigation.position.getSnapshot()
84
+ const summaries = Object.fromEntries(status.list().map((provider) => {
85
+ try {
86
+ return [provider.id, provider.read()]
87
+ } catch {
88
+ return [provider.id, null]
89
+ }
90
+ }))
91
+ const body = JSON.stringify({ position, role: roleOf(position), status: summaries })
92
+ if (body === reported) return
93
+ reported = body
94
+ void post({ type: 'report', ...JSON.parse(body) as Record<string, unknown> }).catch(() => undefined)
95
+ }
96
+ const opened = () => { reported = ''; report() }
97
+ source?.addEventListener('open', opened)
98
+ const stopEvents = subscribe?.({ topic: 'viewer-control', document, window: id }, message => {
99
+ if (message.type === 'open') opened()
100
+ else if (message.type === 'event') handlers.get(message.event)?.(message.data)
101
+ })
102
+ let soon: ReturnType<typeof setTimeout> | undefined
103
+ const stopWatch = navigation.position.subscribe(() => {
104
+ soon ??= setTimeout(() => {
105
+ soon = undefined
106
+ report()
107
+ }, 200)
108
+ })
109
+ const timer = setInterval(report, 1000)
110
+ return () => {
111
+ stopWatch()
112
+ clearTimeout(soon)
113
+ clearInterval(timer)
114
+ stopEvents?.(); source?.close()
115
+ pending.set([])
116
+ }
117
+ }
@@ -0,0 +1,16 @@
1
+ import { useEffect } from 'react'
2
+ import { createStore, useStore } from '../store.ts'
3
+ import { useHost } from '../host.ts'
4
+ export interface Appearance { readonly dark: boolean, setDark(dark: boolean): void, toggle(): void }
5
+ const darkClass = createStore(false)
6
+ let observer: MutationObserver | undefined
7
+ function observe() { if (typeof document === 'undefined' || observer) return; const root = document.documentElement; const read = () => darkClass.set(root.classList.contains('dark')); read(); observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ['class'] }) }
8
+ export function useAppearance(): Appearance {
9
+ const host = useHost()
10
+ const observed = useStore(darkClass)
11
+ const own = host.useDarkMode?.()
12
+ useEffect(observe, [])
13
+ const dark = own?.[0] ?? observed
14
+ const setDark = (on: boolean) => { if (own) own[1](on); else document.documentElement.classList.toggle('dark', on) }
15
+ return { dark, setDark, toggle: () => setDark(!dark) }
16
+ }
@@ -0,0 +1,31 @@
1
+ import { createContext, createElement as h, useContext } from 'react'
2
+ import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+
5
+ const CaptionContainer = createContext<HTMLElement | null>(null)
6
+
7
+ /** The viewer supplies a stable caption surface on its scaled slide canvas. */
8
+ export function PmCaptionProvider({ container, children }: { container: HTMLElement | null, children?: ReactNode }) {
9
+ return h(CaptionContainer.Provider, { value: container }, children)
10
+ }
11
+
12
+ export interface CaptionProps extends HTMLAttributes<HTMLElement> {
13
+ label: string
14
+ lines: readonly string[]
15
+ hint?: string
16
+ overlay?: boolean
17
+ }
18
+
19
+ /** Read-only caption text; its caller owns the source and playback position. */
20
+ export function PmCaption({ label, lines, hint, overlay = false, className, onKeyDown, ...props }: CaptionProps) {
21
+ const container = useContext(CaptionContainer)
22
+ const keyDown = (event: KeyboardEvent<HTMLElement>) => {
23
+ onKeyDown?.(event)
24
+ if (overlay && !event.defaultPrevented && !event.nativeEvent.isComposing && !event.altKey && !event.ctrlKey && !event.metaKey
25
+ && ['Home', 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', ' '].includes(event.key)) event.stopPropagation()
26
+ }
27
+ const caption = h('section', { ...props, onKeyDown: keyDown, className: ['pm-ui-caption', className].filter(Boolean).join(' '), 'data-overlay': overlay || undefined, 'aria-label': label, 'aria-live': 'polite', 'aria-atomic': true, tabIndex: overlay ? 0 : props.tabIndex },
28
+ hint ? h('p', { className: 'pm-ui-caption__hint' }, hint) : null,
29
+ lines.map((text, index) => h('p', { key: index, className: 'pm-ui-caption__line' }, text)))
30
+ return overlay ? container && createPortal(caption, container) : caption
31
+ }
@@ -0,0 +1,143 @@
1
+ import { createContext, createElement as h, forwardRef, useContext, useEffect, useId, useRef } from 'react'
2
+ import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
3
+ import * as Dialog from '@radix-ui/react-dialog'
4
+ import * as Popover from '@radix-ui/react-popover'
5
+ import * as Dropdown from '@radix-ui/react-dropdown-menu'
6
+ import * as Tabs from '@radix-ui/react-tabs'
7
+ import * as Tooltip from '@radix-ui/react-tooltip'
8
+ import * as RadioGroup from '@radix-ui/react-radio-group'
9
+ import { useLocale } from '../locale.ts'
10
+ import { HOST_MESSAGES } from '../messages.ts'
11
+ import { ICONS } from '@lincyaw/design'
12
+ import type { IconName } from '@lincyaw/design'
13
+ import type { ToolbarPlacement } from '../contracts.ts'
14
+ export type ButtonVariant = 'default' | 'primary' | 'accent' | 'ghost' | 'danger'
15
+ export type ControlDensity = 'compact' | 'regular' | 'touch'
16
+ export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'outline'
17
+ export type PopoverPlacement = 'below-end' | 'below-start' | 'above-end' | 'above-start' | 'above' | 'below'
18
+ export interface Choice { value: string, label: string, icon?: IconName, disabled?: boolean }
19
+ export interface MenuItem { id: string, label: string, icon?: IconName, danger?: boolean, disabled?: boolean, shortcut?: string, checked?: boolean, separator?: boolean }
20
+ const cls = (...values: (string | false | undefined)[]) => values.filter(Boolean).join(' ')
21
+ export function PmIcon({ name, size: _size, strokeWidth = 1.7, ...attrs }: { name: IconName, size?: number | string, strokeWidth?: number } & HTMLAttributes<SVGElement>) {
22
+ return h('svg', { ...attrs, className: cls('pm-ui-icon', attrs.className), viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false }, ICONS[name]?.map(([tag, attributes], key) => h(tag, { ...attributes, key })))
23
+ }
24
+ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant, small?: boolean, icon?: IconName, density?: ControlDensity, loading?: boolean }
25
+ export const PmButton = forwardRef<HTMLButtonElement, ButtonProps>(function PmButton({ variant = 'default', small, density = small ? 'compact' : 'regular', loading, icon, className, children, disabled, ...rest }, ref) {
26
+ return h('button', { ...rest, ref, type: rest.type ?? 'button', 'data-density': density, 'aria-busy': loading || undefined, disabled: disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true', className: cls('pm-ui-button', variant !== 'default' && `pm-ui-button--${variant}`, className) },
27
+ h('span', { className: cls('pm-ui-button__content', !icon && loading !== undefined && 'pm-ui-button__content--loading-text') },
28
+ icon || loading !== undefined ? h('span', { className: 'pm-ui-button__icon', 'aria-hidden': true }, loading ? h(PmIcon, { name: 'loading', className: 'pm-ui-loading' }) : icon ? h(PmIcon, { name: icon }) : null) : null,
29
+ children))
30
+ })
31
+ export function PmTooltip({ label, shortcut, children }: { label: string, shortcut?: string, children: ReactNode }) {
32
+ return h(Tooltip.Provider, { delayDuration: 300, skipDelayDuration: 0, children:
33
+ h(Tooltip.Root, {}, h(Tooltip.Trigger, { asChild: true }, children), h(Tooltip.Portal, {},
34
+ h(Tooltip.Content, { className: 'pm-ui-tooltip pm-ui', side: 'top', sideOffset: 6, collisionPadding: 8 }, label, shortcut ? h('kbd', { className: 'pm-ui-shortcut' }, shortcut) : null))) })
35
+ }
36
+ export const PmIconButton = forwardRef<HTMLButtonElement, ButtonProps & { icon: IconName, label: string, hint?: string, shortcut?: string }>(function PmIconButton({ icon, label, hint, shortcut, small, density = small ? 'compact' : 'regular', loading, className, disabled, ...rest }, ref) {
37
+ const button = h('button', { ...rest, ref, type: 'button', 'data-density': density, 'aria-label': label, 'aria-busy': loading || undefined, disabled: disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true', className: cls('pm-ui-icon-button', className) }, h(PmIcon, { name: loading ? 'loading' : icon, className: loading ? 'pm-ui-loading' : undefined }))
38
+ return h(PmTooltip, { label: hint ?? label, shortcut, children: button })
39
+ })
40
+ export function PmBadge({ tone = 'neutral', pill, children, className, ...rest }: HTMLAttributes<HTMLSpanElement> & { tone?: Tone, pill?: boolean }) { return h('span', { ...rest, className: cls('pm-ui-badge', tone !== 'neutral' && `pm-ui-badge--${tone}`, pill && 'pm-ui-badge--pill', className) }, children) }
41
+ export function PmCard({ variant = 'default', className, children, ...rest }: HTMLAttributes<HTMLDivElement> & { variant?: string }) { return h('div', { ...rest, className: cls('pm-ui-card', variant !== 'default' && `pm-ui-card--${variant}`, className) }, children) }
42
+ export function PmField({ label, inline, group, children }: { label: string, inline?: boolean, group?: boolean, children?: ReactNode }) { return h(group ? 'div' : 'label', { className: cls('pm-ui-field', inline && 'pm-ui-field--inline'), ...(group ? { role: 'group', 'aria-label': label } : {}) }, h('span', { className: 'pm-ui-field__label' }, label), children) }
43
+ interface OverlayProps {
44
+ open: boolean
45
+ label: string
46
+ closeLabel?: string
47
+ name?: string
48
+ children?: ReactNode
49
+ trigger?: ReactNode
50
+ actions?: ReactNode
51
+ description?: string
52
+ footer?: ReactNode
53
+ busy?: boolean
54
+ onOpenChange?(open: boolean): void
55
+ onClose?(): void
56
+ onOpenAutoFocus?(event: Event): void
57
+ onCloseAutoFocus?(event: Event): void
58
+ }
59
+ function DialogSurface({ open, label, closeLabel, name, children, actions, trigger, description, footer, busy, onOpenChange, onClose, onOpenAutoFocus, onCloseAutoFocus, drawer = false }: OverlayProps & { drawer?: boolean }) {
60
+ const kind = drawer ? 'pm-ui-drawer' : 'pm-ui-dialog'
61
+ const locale = useLocale()
62
+ const id = useId()
63
+ const focusTarget = useRef<HTMLElement | null>(typeof document === 'undefined' ? null : document.activeElement as HTMLElement)
64
+ useEffect(() => {
65
+ if (open) return
66
+ const remember = (event: FocusEvent) => {
67
+ if (event.target instanceof HTMLElement && event.target !== document.body && event.target.closest('[data-pm-dialog]')?.getAttribute('data-pm-dialog') !== id) focusTarget.current = event.target
68
+ }
69
+ document.addEventListener('focusin', remember, true)
70
+ return () => document.removeEventListener('focusin', remember, true)
71
+ }, [id, open])
72
+ return h(Dialog.Root, { open, modal: true, onOpenChange: next => { if (!next && busy) return; onOpenChange?.(next); if (!next) onClose?.() } },
73
+ trigger ? h(Dialog.Trigger, { asChild: true }, trigger) : null,
74
+ h(Dialog.Portal, {}, h(Dialog.Overlay, { className: 'pm-ui-dialog-overlay' }),
75
+ h(Dialog.Content, {
76
+ className: cls(kind, 'pm-ui', name && `${name}__panel`), ...{ 'data-density': 'regular', 'data-pm-dialog': id }, 'aria-busy': busy || undefined,
77
+ ...description ? {} : { 'aria-describedby': undefined },
78
+ onInteractOutside: event => event.preventDefault(),
79
+ onEscapeKeyDown: event => { event.stopPropagation(); if (busy) event.preventDefault() },
80
+ onOpenAutoFocus: event => {
81
+ onOpenAutoFocus?.(event)
82
+ if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return
83
+ const panel = event.target
84
+ if (panel.contains(document.activeElement)) { event.preventDefault(); return }
85
+ const target = [...panel.querySelectorAll<HTMLElement>(`.${kind}__body :is(button, input, textarea, select, a[href], [tabindex]), .pm-ui-dialog__footer :is(button, input, textarea, select, a[href], [tabindex])`)].find(element => element.tabIndex >= 0 && !element.closest('[inert], [hidden]') && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
86
+ if (target) { event.preventDefault(); target.focus() }
87
+ },
88
+ onCloseAutoFocus: event => {
89
+ onCloseAutoFocus?.(event)
90
+ if (event.defaultPrevented) return
91
+ event.preventDefault()
92
+ const available = (element: HTMLElement | null): element is HTMLElement => Boolean(element?.isConnected && element !== document.body && !element.closest(`[data-pm-dialog="${id}"], [inert], [hidden]`) && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
93
+ const target = available(focusTarget.current) ? focusTarget.current : [...document.querySelectorAll<HTMLElement>('[role="toolbar"] button, main button')].find(available)
94
+ target?.focus()
95
+ },
96
+ }, h('div', { className: `${kind}__header` },
97
+ h(Dialog.Title, { className: `${kind}__title` }, label), actions,
98
+ h(Dialog.Close, { asChild: true }, h(PmIconButton, { icon: 'close', label: closeLabel ?? HOST_MESSAGES[locale].close, small: true, disabled: busy }))),
99
+ description ? h(Dialog.Description, { className: 'pm-ui-dialog__description' }, description) : null,
100
+ h('div', { className: `${kind}__body` }, children),
101
+ footer ? h('div', { className: 'pm-ui-dialog__footer' }, footer) : null)))
102
+ }
103
+ export function PmDialog(props: OverlayProps) { return h(DialogSurface, props) }
104
+ export function PmDrawer(props: OverlayProps) { return h(DialogSurface, { ...props, drawer: true }) }
105
+ const placement = (value: PopoverPlacement) => ({ side: value.startsWith('above') ? 'top' as const : 'bottom' as const, align: value.endsWith('start') ? 'start' as const : 'end' as const })
106
+ export function PmPopover({ open, label, name, children, trigger, placement: place = 'below-end', onOpenChange }: OverlayProps & { placement?: PopoverPlacement }) { return h(Popover.Root, { open, onOpenChange }, trigger ? h(Popover.Trigger, { asChild: true }, trigger) : null, h(Popover.Portal, {}, h(Popover.Content, { ...placement(place), className: cls('pm-ui-popover pm-ui-floating pm-ui', name), 'aria-label': label, onEscapeKeyDown: event => event.stopPropagation() }, children))) }
107
+ export function PmMenu({ label, items, icon = 'more', placement: place = 'below-end', onSelect }: { label: string, items: MenuItem[], icon?: IconName, placement?: PopoverPlacement, onSelect?(id: string): void }) {
108
+ return h(Dropdown.Root, {},
109
+ h(Dropdown.Trigger, { asChild: true }, h(PmIconButton, { icon, label, small: true })),
110
+ h(Dropdown.Portal, {}, h(Dropdown.Content, { ...placement(place), className: 'pm-ui-menu pm-ui-floating pm-ui', ...{ 'data-density': 'compact' }, 'aria-label': label, onEscapeKeyDown: event => event.stopPropagation() },
111
+ items.map(item => h(Dropdown.Group, { key: item.id }, item.separator ? h(Dropdown.Separator, { className: 'pm-ui-menu__separator' }) : null,
112
+ item.checked === undefined ? h(Dropdown.Item, { disabled: item.disabled, textValue: item.label, className: cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'), onSelect: () => onSelect?.(item.id) },
113
+ item.icon ? h(PmIcon, { name: item.icon }) : null, item.label, item.shortcut ? h('kbd', { className: 'pm-ui-shortcut' }, item.shortcut) : null)
114
+ : h(Dropdown.CheckboxItem, { checked: item.checked, disabled: item.disabled, textValue: item.label, className: cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger'), onSelect: () => onSelect?.(item.id) },
115
+ h(Dropdown.ItemIndicator, {}, h(PmIcon, { name: 'check' })), item.label))))))
116
+ }
117
+ export function PmTabs({ value, modelValue, tabs, label, children, onValueChange, iconOnly, density = 'compact' }: { value?: string, modelValue?: string, tabs: Choice[], label: string, children?: ReactNode, iconOnly?: boolean, density?: ControlDensity, onValueChange?(value: string): void }) {
118
+ return h(Tabs.Root, { value: value ?? modelValue, onValueChange, activationMode: 'manual', className: 'pm-ui-tabs-root', ...{ 'data-density': density } },
119
+ h(Tabs.List, { className: 'pm-ui-tabs', 'aria-label': label }, tabs.map(tab => {
120
+ const trigger = h(Tabs.Trigger, { key: tab.value, value: tab.value, className: 'pm-ui-tab', disabled: tab.disabled, 'aria-label': tab.label, ...{ 'data-icon-only': iconOnly || undefined } }, tab.icon ? h(PmIcon, { name: tab.icon }) : null, iconOnly ? null : tab.label)
121
+ return iconOnly ? h(PmTooltip, { key: tab.value, label: tab.label, children: trigger }) : trigger
122
+ })), children)
123
+ }
124
+ export function PmTabPanel({ value, children, forceMount, hidden }: { value: string, children?: ReactNode, forceMount?: true, hidden?: boolean }) { return h(Tabs.Content, { value, forceMount, hidden, className: 'pm-ui-tab-panel' }, children) }
125
+ export function PmSegmented({ value, modelValue, choices, items, label, onValueChange, density = 'compact', disabled }: { value?: string, modelValue?: string, choices?: Choice[], items?: Choice[], label: string, density?: ControlDensity, disabled?: boolean, onValueChange?(value: string): void }) {
126
+ return h(RadioGroup.Root, { className: 'pm-ui-segmented', 'aria-label': label, value: value ?? modelValue, onValueChange, orientation: 'horizontal', ...{ 'data-density': density }, disabled },
127
+ (choices ?? items ?? []).map(item => h(RadioGroup.Item, { key: item.value, value: item.value, className: 'pm-ui-segmented__option', disabled: item.disabled }, item.icon ? h(PmIcon, { name: item.icon }) : null, item.label)))
128
+ }
129
+ export function PmNotice({ tone = 'neutral', children, className, ...rest }: HTMLAttributes<HTMLDivElement> & { tone?: Tone }) { return h('div', { ...rest, className: cls('pm-ui-notice', tone !== 'neutral' && `pm-ui-notice--${tone}`, className), role: tone === 'danger' ? 'alert' : 'status' }, children) }
130
+ export function PmEmpty({ title, icon, action, children }: { title?: string, icon?: IconName, action?: ReactNode, children?: ReactNode }) { return h('div', { className: 'pm-ui-empty' }, icon ? h(PmIcon, { name: icon }) : null, title ? h('p', { className: 'pm-ui-empty__title' }, title) : null, children, action) }
131
+ /** Loading in the shape of the content: `document` headings and paragraphs, `list` rows, `preview` a slide-shaped area. */
132
+ export function PmSkeleton({ label, shape = 'document' }: { label: string, shape?: 'document' | 'list' | 'preview' }) {
133
+ const bar = (kind?: string, key?: number) => h('span', { key, className: 'pm-ui-skeleton__bar', 'data-kind': kind })
134
+ const paragraph = (lines: number, key: number) => h('span', { key, className: 'pm-ui-skeleton__group' }, Array.from({ length: lines }, (_, index) => bar(undefined, index)))
135
+ const bars = shape === 'list' ? Array.from({ length: 5 }, (_, index) => bar('row', index))
136
+ : shape === 'preview' ? [bar('media', 0), paragraph(2, 1)]
137
+ : [bar('heading', 0), paragraph(4, 1), paragraph(3, 2), bar('heading', 3), paragraph(4, 4)]
138
+ return h('div', { className: 'pm-ui-skeleton', 'data-shape': shape, role: 'status', 'aria-busy': 'true' }, h('span', { className: 'pm-ui-sr-only' }, label), h('span', { className: 'pm-ui-skeleton', 'aria-hidden': 'true' }, bars))
139
+ }
140
+ export const ToolbarPlacementContext = createContext<ToolbarPlacement>('bar')
141
+ export const provideToolbarPlacement = ToolbarPlacementContext.Provider
142
+ export function useToolbarPlacement() { return useContext(ToolbarPlacementContext) }
143
+ export function PmToolbarEntry({ title, label = title ?? '', name, icon = 'more', open = false, onOpenChange, children }: { title?: string, name?: string, label?: string, closeLabel?: string, icon?: IconName, open?: boolean, onOpenChange?(open: boolean): void, children?: ReactNode }) { const where = useToolbarPlacement(); return h(PmPopover, { open, onOpenChange, name, label, placement: where === 'status' ? 'above-start' : 'below-end', trigger: h(PmIconButton, { icon, label, small: true, className: name && `${name}__toggle`, 'aria-expanded': open }) }, children) }
@@ -0,0 +1,9 @@
1
+ @import '@lincyaw/design/design.css';
2
+
3
+ .pm-toolbar { display: inline-flex; align-items: center; gap: var(--pm-space-2); }
4
+ .pm-toolbar[data-placement='status'] { gap: var(--pm-space-4); }
5
+ .pm-presenter-panels { position: fixed; right: var(--pm-space-2); bottom: var(--pm-overlay-bar, var(--pm-space-12)); z-index: var(--pm-z-overlay-view); max-width: var(--pm-aside-width); max-height: 50vh; overflow: auto; padding: var(--pm-space-1) var(--pm-space-3); border: var(--pm-size-hairline) solid var(--pm-color-line); border-radius: var(--pm-radius-small); background: var(--pm-color-surface); color: var(--pm-color-ink); font-size: var(--pm-text-sm); box-shadow: var(--pm-shadow-float); }
6
+ .pm-presenter-panels--docked { position: static; max-width: none; max-height: none; margin-top: var(--pm-space-4); box-shadow: none; }
7
+ .pm-presenter-panels summary { cursor: pointer; font-weight: 600; }
8
+ .pm-presenter-panels__badge { display: inline-block; padding: 0 var(--pm-space-1); border-radius: var(--pm-radius-pill); background: var(--pm-color-danger); color: var(--pm-color-surface); text-align: center; }
9
+ .pm-presenter-panels h3 { margin: var(--pm-space-2) 0 var(--pm-space-1); font-size: var(--pm-text-md); }
@@ -0,0 +1,28 @@
1
+ import { Children, createElement as h, useState } from 'react'
2
+ import type { CSSProperties, ReactNode } from 'react'
3
+ import { PmTabs, PmTabPanel } from './components.ts'
4
+
5
+ export interface ContentLayoutProps { title?: string, children?: ReactNode }
6
+ export interface ColumnsLayoutProps extends ContentLayoutProps { columnWidths?: number[] }
7
+ const heading = (title?: string) => title ? h('h3', { className: 'pm-layout__title' }, title) : null
8
+
9
+ /** Authored column proportions are content geometry; narrow surfaces stack the same children. */
10
+ export function PmColumns({ title, columnWidths, children }: ColumnsLayoutProps) {
11
+ const items = Children.toArray(children)
12
+ const columns = items.map((_, index) => `minmax(0, ${Number.isFinite(columnWidths?.[index]) && columnWidths![index]! > 0 ? columnWidths![index] : 1}fr)`).join(' ')
13
+ const style: CSSProperties & { '--pm-layout-columns': string } = { '--pm-layout-columns': columns || 'minmax(0, 1fr)' }
14
+ return h('div', { className: 'pm-layout-columns', style }, heading(title), items.map((item, index) => h('div', { className: 'pm-layout__item', key: index }, item)))
15
+ }
16
+ export function PmStack({ title, children }: ContentLayoutProps) {
17
+ return h('div', { className: 'pm-layout-stack' }, heading(title), children)
18
+ }
19
+ export interface TabLayoutEntry { id: string, title: string, content: ReactNode, visible?: boolean }
20
+ export function PmTabGroup({ title, label, tabs, staticView = false }: { title?: string, label: string, tabs: TabLayoutEntry[], staticView?: boolean }) {
21
+ const [chosen, setChosen] = useState<string>()
22
+ if (staticView) return h('div', { className: 'pm-layout-stack', 'data-layout-static': 'tabs' }, heading(title), tabs.map(tab => h('section', { key: tab.id, className: 'pm-layout-stack' }, h('h4', { className: 'pm-layout__title' }, tab.title), tab.content)))
23
+ const visible = tabs.filter(tab => tab.visible !== false)
24
+ const active = visible.find(tab => tab.id === chosen)?.id ?? visible[0]?.id ?? ''
25
+ return h('div', { className: 'pm-layout-stack' }, heading(title), h(PmTabs, {
26
+ label: title || label, value: active, tabs: visible.map(tab => ({ value: tab.id, label: tab.title })), onValueChange: setChosen,
27
+ }, tabs.map(tab => h(PmTabPanel, { key: tab.id, value: tab.id, forceMount: true, hidden: tab.id !== active || tab.visible === false }, tab.content))))
28
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ interface ImportMetaEnv {
2
+ BASE_URL: string
3
+ }
4
+
5
+ interface ImportMeta {
6
+ readonly env: ImportMetaEnv
7
+ }
@@ -0,0 +1,18 @@
1
+ import { Component, createElement as h } from 'react'
2
+ import type { ReactNode, ErrorInfo } from 'react'
3
+ import { errorMessage } from '@lincyaw/kernel'
4
+ import { createStore } from './store.ts'
5
+ import { HOST_MESSAGES } from './messages.ts'
6
+ import { currentLocale } from './locale.ts'
7
+ export interface ComponentError { component: string, message: string }
8
+ export const componentErrorRevision = createStore(0)
9
+ const errors = new Map<object, ComponentError>()
10
+ export function componentErrors(): readonly ComponentError[] { return [...errors.values()] }
11
+ export class PmBoundary extends Component<{ name?: string, resetKey?: unknown, children?: ReactNode }, { error?: ComponentError }> {
12
+ state: { error?: ComponentError } = {}
13
+ static getDerivedStateFromError(error: unknown) { return { error: { component: 'component', message: errorMessage(error) } } }
14
+ componentDidCatch(error: unknown, _info: ErrorInfo) { const problem = { component: this.props.name ?? 'component', message: errorMessage(error) }; errors.set(this, problem); componentErrorRevision.set(n => n + 1); this.setState({ error: problem }); console.error(`[presmium] ${problem.component} failed`, error) }
15
+ componentDidUpdate(before: Readonly<{ name?: string, resetKey?: unknown }>) { if (before.resetKey !== this.props.resetKey && this.state.error) { errors.delete(this); componentErrorRevision.set(n => n + 1); this.setState({ error: undefined }) } }
16
+ componentWillUnmount() { if (errors.delete(this)) componentErrorRevision.set(n => n + 1) }
17
+ render() { const error = this.state.error; return error ? h('div', { className: 'pm-boundary-error', role: 'alert', 'data-pm-error': error.component }, HOST_MESSAGES[currentLocale()].componentError(error.component, error.message)) : this.props.children }
18
+ }
@@ -0,0 +1,4 @@
1
+ import { createContext } from 'react'
2
+ import type { HostAdapter } from './contracts.ts'
3
+ export const HOST = createContext<HostAdapter | undefined>(undefined)
4
+ export const installed: { adapter?: HostAdapter } = {}
package/src/host.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { createElement, useContext, useEffect, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { stepStateAt } from '@lincyaw/kernel'
4
+ import type { ServiceToken, StepRange, StepState } from '@lincyaw/kernel/contracts'
5
+ import { HOST, installed } from './host-key.ts'
6
+ import type { HostAdapter, SharedStateScope } from './contracts.ts'
7
+ import { useStepScope } from './step-scope.ts'
8
+ import { runtimeRevision } from './runtime.ts'
9
+ import { createStore, useStore } from './store.ts'
10
+ import type { Store } from './store.ts'
11
+ import { PmBoundary } from './error-boundary.ts'
12
+
13
+ export function installHost(adapter: HostAdapter): void { installed.adapter = adapter }
14
+ export function HostProvider({ adapter, children }: { adapter: HostAdapter, children?: ReactNode }) {
15
+ return createElement(HOST.Provider, { value: adapter }, children)
16
+ }
17
+ export function useHost(): HostAdapter {
18
+ const host = useContext(HOST) ?? installed.adapter
19
+ if (!host) throw new Error('[presmium] no host installed')
20
+ return host
21
+ }
22
+ export function useDoc() { const host = useHost(); if (!host.useDoc) throw new Error("[presmium] this host has no document"); return host.useDoc() }
23
+ export function useStep(): number { return useHost().useStep() }
24
+ export function useStepState(range: StepRange): StepState { const step = useStep(); const scope = useStepScope(); return stepStateAt(range, scope.localStepAt(step)) }
25
+ export function useCursor() { return useHost().useCursor() }
26
+ export function useViewRole() { return useHost().useViewRole() }
27
+ export function usePresentationMode() { const host = useHost(); host.useViewRole(); return host.mode }
28
+ export function useService<T>(token: ServiceToken<T>): T | undefined { useStore(runtimeRevision); return useHost().getService(token) }
29
+ export function useStartReport() { useStore(runtimeRevision); return useHost().startReport() }
30
+ export function useStaticRender() { return useHost().useStaticRender() }
31
+ export function useSectionConfig(pluginId: string) { return useHost().useSectionConfig(pluginId) }
32
+ export interface SharedStateOptions { scope?: SharedStateScope }
33
+ export function useSharedState<T>(key: string, initial: T, options: SharedStateOptions = {}) { return useHost().useSharedState(key, initial, options.scope ?? 'local') }
34
+ export function usePrefersReducedMotion(): boolean {
35
+ const [reduced, setReduced] = useState(() => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches)
36
+ useEffect(() => { const query = matchMedia('(prefers-reduced-motion: reduce)'); const update = () => setReduced(query.matches); update(); query.addEventListener('change', update); return () => query.removeEventListener('change', update) }, [])
37
+ return reduced
38
+ }
39
+
40
+ const detachedShared = new Map<string, Store<unknown>>()
41
+ export function getSharedStateStore<T>(key: string, initial: T, options: SharedStateOptions = {}): Store<T> {
42
+ const scope = options.scope ?? 'local'
43
+ if (installed.adapter?.sharedStateStore) return installed.adapter.sharedStateStore(key, initial, scope)
44
+ const id = `${scope}:${key}`
45
+ let store = detachedShared.get(id); if (!store) { store = createStore<unknown>(initial); detachedShared.set(id, store) }
46
+ return store as Store<T>
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ /// <reference path="./styles.d.ts" />
2
+ import './design/design.css'
3
+
4
+ export { useActions } from './actions.ts'
5
+ export type { ActionOptions, ComponentAction } from './actions.ts'
6
+ export { blockAt, blockElement, blockKey, codeLines, lineNumbers, linesText, splitBlockKey } from './blocks.ts'
7
+ export type { BlockPoint } from './blocks.ts'
8
+ export { componentErrors, componentErrorRevision, PmBoundary } from './error-boundary.ts'
9
+ export type { ComponentError } from './error-boundary.ts'
10
+ export {
11
+ PmBadge,
12
+ PmButton,
13
+ PmCard,
14
+ PmDialog,
15
+ PmDrawer,
16
+ PmEmpty,
17
+ PmSkeleton,
18
+ PmField,
19
+ PmIcon,
20
+ PmIconButton,
21
+ PmMenu,
22
+ PmNotice,
23
+ PmPopover,
24
+ PmSegmented,
25
+ PmTabs,
26
+ PmTabPanel,
27
+ PmTooltip,
28
+ PmToolbarEntry,
29
+ provideToolbarPlacement,
30
+ useToolbarPlacement,
31
+ } from './design/components.ts'
32
+ export type { ButtonVariant, Choice, ControlDensity, MenuItem, PopoverPlacement, Tone } from './design/components.ts'
33
+ export { PmColumns, PmStack, PmTabGroup } from './design/layout.ts'
34
+ export { PmCaption, PmCaptionProvider } from './design/caption.ts'
35
+ export type { CaptionProps } from './design/caption.ts'
36
+ export type { ContentLayoutProps, ColumnsLayoutProps, TabLayoutEntry } from './design/layout.ts'
37
+ export { iconMarkup, ICONS } from '@lincyaw/design'
38
+ export type { IconName } from '@lincyaw/design'
39
+ export { useAppearance } from './design/appearance.ts'
40
+ export type { Appearance } from './design/appearance.ts'
41
+ export {
42
+ installHost,
43
+ HostProvider,
44
+ useStep,
45
+ useDoc,
46
+ useCursor,
47
+ useHost,
48
+ usePrefersReducedMotion,
49
+ usePresentationMode,
50
+ useSectionConfig,
51
+ useService,
52
+ useSharedState,
53
+ getSharedStateStore,
54
+ useStartReport,
55
+ useStaticRender,
56
+ useStepState,
57
+ useViewRole,
58
+ } from './host.ts'
59
+ export type { SharedStateOptions } from './host.ts'
60
+ export { pluginsOf, presmiumReport, presmiumService, registerPresmiumPlugin, reportPluginFailure, setHostActive, startHost, stopHost } from './runtime.ts'
61
+ export type { HostStart } from './runtime.ts'
62
+ export { currentLocale, useLocale, useMessages } from './locale.ts'
63
+ export type { Catalog } from './locale.ts'
64
+ export { PmApprovals } from './approvals.ts'
65
+ export { claimFirst } from './claim-first.ts'
66
+ export { createServerClient, pluginRoutePath, provideServer, useServer, serverClient } from './server.ts'
67
+ export { PmPresenterPanels } from './panels.ts'
68
+ export { useStepTime } from './step-clock.ts'
69
+ export type { StepTimeOptions } from './step-clock.ts'
70
+ export { StepScope, useBlockStepRange, useStepScope } from './step-scope.ts'
71
+ export { useSessionSnapshot } from './session.ts'
72
+ export type { StepScope as StepScopeValue } from './step-scope.ts'
73
+ export { createSharedStates } from './shared-state.ts'
74
+ export type { SharedStates } from './shared-state.ts'
75
+ export { PmToolbar } from './panels.ts'
76
+ export { NAVIGATION, OPERATIONS, PLAYBACK_CONTROL, PRODUCTION_PLAYBACK, STATUS, STEP_CLOCK, SYNC_TRANSPORTS, TIMED_CAPTIONS, TOOLBAR } from './contracts.ts'
77
+ export type { TimedCaptions } from './contracts.ts'
78
+ export { joinWindows } from './windows.ts'
79
+ export { setSiteBase, siteBase } from './site.ts'
80
+ export type { WindowChannel } from './windows.ts'
81
+ export type {
82
+ HostAdapter,
83
+ Navigation,
84
+ NavigationPosition,
85
+ Operations,
86
+ PlaybackControl,
87
+ PlaybackLease,
88
+ ProductionPlayback,
89
+ ReplayHandler,
90
+ ServerClient,
91
+ SharedStateScope,
92
+ StatusProvider,
93
+ StatusRegistry,
94
+ StepClock,
95
+ StepClockDriver,
96
+ SyncPublish,
97
+ SyncTransport,
98
+ SyncTransports,
99
+ Toolbar,
100
+ ToolbarItem,
101
+ ToolbarPlacement,
102
+ ToolbarSlot,
103
+ ViewRole,
104
+ } from './contracts.ts'
105
+ export { localeOf } from '@lincyaw/kernel'
106
+ export type { Locale } from '@lincyaw/kernel/contracts'
107
+ export { codeLinesBox } from './code-lines-box.ts'
108
+ export { createResidentWorker } from './resident-worker.ts'
109
+ export type { ResidentWorker, ResidentWorkerFailure, ResidentWorkerOptions, ResidentWorkerOutcome } from './resident-worker.ts'
110
+
111
+ export { createStore, useStore, observe } from './store.ts'
112
+ export type { Store } from './store.ts'
113
+
114
+ export { runtimeRevision } from "./runtime.ts"
package/src/locale.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { useEffect } from 'react'
2
+ import { localeOf } from '@lincyaw/kernel'
3
+ import type { Locale } from '@lincyaw/kernel/contracts'
4
+ import { installed } from './host-key.ts'
5
+ import { createStore, useStore } from './store.ts'
6
+ export type Catalog<T> = Record<Locale, T>
7
+ const language = createStore<string | undefined>(undefined)
8
+ let observer: MutationObserver | undefined
9
+ function observeLanguage() {
10
+ if (typeof document === 'undefined' || observer) return
11
+ language.set(document.documentElement.lang)
12
+ observer = new MutationObserver(() => language.set(document.documentElement.lang))
13
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
14
+ }
15
+ export function currentLocale(): Locale {
16
+ const lang = typeof document === 'undefined' ? undefined : document.documentElement.lang
17
+ return localeOf(lang || installed.adapter?.lang) ?? localeOf(typeof navigator === 'undefined' ? undefined : navigator.language) ?? 'en'
18
+ }
19
+ export function useLocale(): Locale { useStore(language); useEffect(observeLanguage, []); return currentLocale() }
20
+ export function useMessages<T>(catalog: Catalog<T>): T { return catalog[useLocale()] }