@rokkit/app 1.14.0 → 1.15.0

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.
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The info band: up to three columns on a wash of one role — a framing column (eyebrow,
4
+ * large mark, display title, lead), a how-to column (steps, each mark in a paper circle) and
5
+ * a stat column after a hairline (a value, its total and a meter). Each column is data, or a
6
+ * snippet drawn in the column's frame. Text stays ink; only the wash and the eyebrows take
7
+ * the role.
8
+ */
9
+ import type { Snippet } from 'svelte'
10
+ import type { GlyphMode, InfoColumn, InfoRole, InfoSlot } from '../types/screen-shell.js'
11
+
12
+ interface Props {
13
+ columns: InfoSlot[]
14
+ role?: InfoRole
15
+ glyphs?: GlyphMode
16
+ class?: string
17
+ }
18
+
19
+ const { columns, role = 'accent', glyphs = 'text', class: className = '' }: Props = $props()
20
+
21
+ const isSnippet = (slot: InfoSlot): slot is Snippet => typeof slot === 'function'
22
+ const percent = (progress: number) => `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`
23
+ </script>
24
+
25
+ {#snippet mark(kanji: string | undefined, icon: string | undefined, part: string)}
26
+ {#if glyphs === 'icon' && icon}
27
+ <span {...{ [`data-screen-info-${part}`]: '' }} class={icon} aria-hidden="true"></span>
28
+ {:else if kanji}
29
+ <span {...{ [`data-screen-info-${part}`]: '' }} aria-hidden="true">{kanji}</span>
30
+ {/if}
31
+ {/snippet}
32
+
33
+ {#snippet column(info: InfoColumn)}
34
+ <span data-screen-info-label>{info.label}</span>
35
+ {#if info.kanji || info.icon || info.title}
36
+ <div data-screen-info-heading>
37
+ {@render mark(info.kanji, info.icon, 'mark')}
38
+ {#if info.title}<p data-screen-info-title>{info.title}</p>{/if}
39
+ </div>
40
+ {/if}
41
+ {#if info.value !== undefined}
42
+ <div data-screen-info-figure>
43
+ <span data-screen-info-value>{info.value}</span>
44
+ {#if info.total !== undefined}<span data-screen-info-total>/ {info.total}</span>{/if}
45
+ </div>
46
+ {/if}
47
+ {#if info.progress !== undefined}
48
+ <div
49
+ data-screen-info-meter
50
+ role="meter"
51
+ aria-label={info.label}
52
+ aria-valuemin={0}
53
+ aria-valuemax={1}
54
+ aria-valuenow={info.progress}
55
+ >
56
+ <span data-screen-info-meter-fill style:inline-size={percent(info.progress)}></span>
57
+ </div>
58
+ {/if}
59
+ {#if info.steps?.length}
60
+ <ol data-screen-info-steps>
61
+ {#each info.steps as step, index (index)}
62
+ <li data-screen-info-step>
63
+ <span data-screen-info-step-seal>{@render mark(step.kanji, step.icon, 'step-mark')}</span>
64
+ <div data-screen-info-step-body>
65
+ <span data-screen-info-step-title>{step.title}</span>
66
+ {#if step.text}<span data-screen-info-step-text>{step.text}</span>{/if}
67
+ </div>
68
+ </li>
69
+ {/each}
70
+ </ol>
71
+ {/if}
72
+ {#if info.text}<p data-screen-info-text>{info.text}</p>{/if}
73
+ {/snippet}
74
+
75
+ <div data-screen-info data-role={role} class={className || undefined}>
76
+ {#each columns.slice(0, 3) as slot, index (index)}
77
+ <div data-screen-info-column>
78
+ {#if isSnippet(slot)}
79
+ {@render slot()}
80
+ {:else}
81
+ {@render column(slot)}
82
+ {/if}
83
+ </div>
84
+ {/each}
85
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { GlyphMode, InfoRole, InfoSlot } from '../types/screen-shell.js';
2
+ interface Props {
3
+ columns: InfoSlot[];
4
+ role?: InfoRole;
5
+ glyphs?: GlyphMode;
6
+ class?: string;
7
+ }
8
+ declare const ScreenInfo: import("svelte").Component<Props, {}, "">;
9
+ type ScreenInfo = ReturnType<typeof ScreenInfo>;
10
+ export default ScreenInfo;
@@ -0,0 +1,323 @@
1
+ <script lang="ts">
2
+ /**
3
+ * One screen, laid out from data: a title bar (mark, title, context, then search, the
4
+ * screen's own buttons and the band toggles), a description, the info / stats / filters
5
+ * bands, a body and a sidebar that each scroll on their own, and a footer. The shell fills
6
+ * its container; nothing but the body and the sidebar scrolls.
7
+ *
8
+ * Layout only, and it persists nothing: `panels` and `filter` are bindable (and emitted
9
+ * through `onpanels` / `onfilter`); the host stores them by `viewId`.
10
+ */
11
+ import { untrack, type Snippet } from 'svelte'
12
+ import { StatCard, Toolbar, type ToolbarItem } from '@rokkit/ui'
13
+ import { FormRenderer } from '@rokkit/forms'
14
+ import { messages } from '@rokkit/states'
15
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
16
+ import ScreenInfo from './ScreenInfo.svelte'
17
+ import type {
18
+ FilterApi,
19
+ FilterSchema,
20
+ FilterState,
21
+ FooterParts,
22
+ PanelId,
23
+ PanelLabels,
24
+ ScreenShellProps,
25
+ ShellStat,
26
+ StatMemory
27
+ } from '../types/screen-shell.js'
28
+ import {
29
+ PANEL_IDS,
30
+ applyStat,
31
+ changedKeys,
32
+ clearStat,
33
+ isOpen,
34
+ presentPanels,
35
+ resolveToolbar,
36
+ setFilterValue,
37
+ statSpan,
38
+ togglePanel
39
+ } from '../utils/screen-shell.js'
40
+
41
+ const DEFAULT_ICONS: PanelLabels = {
42
+ info: DEFAULT_STATE_ICONS.state.info,
43
+ stats: DEFAULT_STATE_ICONS.view.chart,
44
+ filters: 'i-glyph:search-filter',
45
+ side: 'i-glyph:sidebar'
46
+ }
47
+ const SEARCH_DELAY = 250
48
+
49
+ let {
50
+ viewId,
51
+ mark,
52
+ title,
53
+ context,
54
+ description,
55
+ search = {},
56
+ toolbar,
57
+ info,
58
+ infoRole = 'accent',
59
+ stats,
60
+ filters,
61
+ sidebar,
62
+ sidebarSide = 'right',
63
+ footer,
64
+ children,
65
+ panels = $bindable({}),
66
+ onpanels,
67
+ filter = $bindable({ values: {}, stat: null }),
68
+ onfilter,
69
+ glyphs = 'text',
70
+ icons,
71
+ labels,
72
+ class: className = ''
73
+ }: ScreenShellProps = $props()
74
+
75
+ const text = $derived({ ...messages.screen, ...labels })
76
+ const values = $derived(filter?.values ?? {})
77
+ const statList = $derived(stats ?? [])
78
+
79
+ // ─── Panels ────────────────────────────────────────────────────
80
+
81
+ const has = $derived<Record<PanelId, boolean>>({
82
+ info: Boolean(info?.length),
83
+ stats: stats !== undefined,
84
+ filters: Boolean(filters),
85
+ side: Boolean(sidebar)
86
+ })
87
+ const present = $derived(presentPanels(has))
88
+ const shown = (id: PanelId) => has[id] && isOpen(panels, id)
89
+
90
+ function flip(id: PanelId) {
91
+ const next = togglePanel(panels, id, present)
92
+ panels = next
93
+ onpanels?.(next)
94
+ }
95
+
96
+ // ─── Toolbar ───────────────────────────────────────────────────
97
+
98
+ const items = $derived(
99
+ resolveToolbar(toolbar, {
100
+ present,
101
+ panels,
102
+ labels: text,
103
+ icons: { ...DEFAULT_ICONS, ...icons },
104
+ busy: text.busy
105
+ })
106
+ )
107
+
108
+ function runItem(value: unknown, item: ToolbarItem) {
109
+ if (item.type === 'toggle' && PANEL_IDS.includes(value as PanelId))
110
+ return flip(value as PanelId)
111
+ ;(item.onclick as (() => void) | undefined)?.()
112
+ }
113
+
114
+ // ─── Filter ────────────────────────────────────────────────────
115
+
116
+ let memory: StatMemory | null = null
117
+
118
+ function commit(step: { filter: FilterState; memory: StatMemory | null }) {
119
+ memory = step.memory
120
+ filter = step.filter
121
+ onfilter?.(step.filter)
122
+ }
123
+
124
+ const current = (): FilterState => ({ values, stat: filter?.stat ?? null })
125
+ const setValue = (key: string, value: unknown) =>
126
+ commit(setFilterValue(current(), key, value, { stats: statList, memory }))
127
+ const clearValues = () => commit({ filter: { values: {}, stat: null }, memory: null })
128
+ const clearActive = () => commit(clearStat(current(), statList, memory))
129
+ const pickStat = (stat: ShellStat) => commit(applyStat(current(), stat, memory, statList))
130
+
131
+ const api: FilterApi = {
132
+ get values() {
133
+ return values
134
+ },
135
+ set: setValue,
136
+ clear: clearValues
137
+ }
138
+
139
+ /** A FormRenderer update: every key that changed goes through the same merge rule. */
140
+ function updateFromForm(next: Record<string, unknown>) {
141
+ for (const key of changedKeys(values, next)) setValue(key, next[key])
142
+ }
143
+
144
+ const formData = $derived({ ...values })
145
+ const activeStat = $derived(statList.find((s) => s.id === filter?.stat && s.filter))
146
+ const filterSchema = $derived(
147
+ filters && typeof filters !== 'function' ? (filters as FilterSchema) : null
148
+ )
149
+ const filterSnippet = $derived(
150
+ typeof filters === 'function' ? (filters as Snippet<[FilterApi]>) : null
151
+ )
152
+ const filterActions = $derived(
153
+ resolveToolbar(
154
+ { add: filterSchema?.actions ?? [] },
155
+ {
156
+ present: [],
157
+ panels,
158
+ labels: text,
159
+ icons: { ...DEFAULT_ICONS, ...icons },
160
+ busy: text.busy
161
+ }
162
+ )
163
+ )
164
+
165
+ // ─── Search ────────────────────────────────────────────────────
166
+
167
+ const searchOn = $derived(search !== false)
168
+ const placeholder = $derived((search && search.placeholder) || text.search)
169
+ const delay = $derived(search ? (search.delay ?? SEARCH_DELAY) : SEARCH_DELAY)
170
+ let query = $state(untrack(() => String(filter?.values?.q ?? '')))
171
+ let timer: ReturnType<typeof setTimeout> | undefined
172
+
173
+ // A q set from outside (a restored filter, a reset) shows in the field — but not while the
174
+ // user's own text is still waiting out the debounce.
175
+ $effect(() => {
176
+ const q = String(values.q ?? '')
177
+ untrack(() => {
178
+ if (timer === undefined && q !== query) query = q
179
+ })
180
+ })
181
+
182
+ $effect(() => () => clearTimeout(timer))
183
+
184
+ function onsearch() {
185
+ clearTimeout(timer)
186
+ timer = setTimeout(() => {
187
+ timer = undefined
188
+ setValue('q', query)
189
+ }, delay)
190
+ }
191
+
192
+ // ─── Stats & footer ────────────────────────────────────────────
193
+
194
+ const reserve = $derived(statList.some((s) => Boolean(s.description)))
195
+ const footerParts = $derived(
196
+ footer && typeof footer !== 'function' ? (footer as FooterParts) : null
197
+ )
198
+ const footerSnippet = $derived(typeof footer === 'function' ? (footer as Snippet) : null)
199
+ const FOOTER_PARTS = ['left', 'mid', 'right'] as const
200
+ </script>
201
+
202
+ {#snippet glyph(kanji: string, icon: string | undefined)}
203
+ {#if glyphs === 'icon' && icon}
204
+ <span data-screen-mark class={icon} aria-hidden="true"></span>
205
+ {:else}
206
+ <span data-screen-mark aria-hidden="true">{kanji}</span>
207
+ {/if}
208
+ {/snippet}
209
+
210
+ {#snippet side()}
211
+ {#if sidebar && shown('side')}
212
+ <aside data-screen-sidebar data-side={sidebarSide} aria-label={text.side}>
213
+ {@render sidebar()}
214
+ </aside>
215
+ {/if}
216
+ {/snippet}
217
+
218
+ <div data-screen-shell data-view={viewId} class={className || undefined}>
219
+ <header data-screen-header>
220
+ <div data-screen-titlebar>
221
+ {@render glyph(mark.kanji, mark.icon)}
222
+ <h1 data-screen-title>{title}</h1>
223
+ {#if context}<span data-screen-context>{context}</span>{/if}
224
+ {#if searchOn || items.length}
225
+ <div data-screen-tools>
226
+ {#if searchOn}
227
+ <label data-screen-search>
228
+ <span
229
+ data-screen-search-icon
230
+ class={DEFAULT_STATE_ICONS.action.search}
231
+ aria-hidden="true"
232
+ ></span>
233
+ <input
234
+ type="search"
235
+ bind:value={query}
236
+ oninput={onsearch}
237
+ {placeholder}
238
+ aria-label={placeholder}
239
+ />
240
+ </label>
241
+ {/if}
242
+ {#if items.length}
243
+ <Toolbar {items} label={title} width="fit" showDividers={false} onclick={runItem} />
244
+ {/if}
245
+ </div>
246
+ {/if}
247
+ </div>
248
+ {#if description}<p data-screen-description>{description}</p>{/if}
249
+
250
+ {#if info?.length && shown('info')}
251
+ <ScreenInfo columns={info} role={infoRole} {glyphs} />
252
+ {/if}
253
+
254
+ {#if stats !== undefined && shown('stats')}
255
+ <div data-screen-stats aria-label={text.stats} role="group">
256
+ {#if stats === null}
257
+ <p data-screen-stats-reading>{text.reading}</p>
258
+ {:else}
259
+ {#each stats as stat (stat.id)}
260
+ <StatCard
261
+ label={stat.title}
262
+ value={stat.value}
263
+ tone={stat.tone}
264
+ description={stat.description}
265
+ reserveDescription={reserve}
266
+ span={statSpan(stat)}
267
+ href={stat.filter ? undefined : stat.href}
268
+ icon={glyphs === 'icon' ? stat.icon : undefined}
269
+ mark={stat.kanji}
270
+ onclick={stat.filter ? () => pickStat(stat) : undefined}
271
+ pressed={filter?.stat === stat.id}
272
+ data-stat={stat.id}
273
+ />
274
+ {/each}
275
+ {/if}
276
+ </div>
277
+ {/if}
278
+
279
+ {#if filters && shown('filters')}
280
+ <div data-screen-filters role="group" aria-label={text.filters}>
281
+ {#if filterSnippet}
282
+ {@render filterSnippet(api)}
283
+ {:else if filterSchema}
284
+ <FormRenderer
285
+ data={formData}
286
+ schema={filterSchema.schema}
287
+ layout={filterSchema.layout}
288
+ onupdate={updateFromForm}
289
+ />
290
+ {#if filterActions.length}
291
+ <div data-screen-filter-actions>
292
+ <Toolbar items={filterActions} label={text.filters} width="fit" onclick={runItem} />
293
+ </div>
294
+ {/if}
295
+ {/if}
296
+ {#if activeStat}
297
+ <span data-screen-filter-chip>
298
+ {text.fromStat}: <strong>{activeStat.title}</strong>
299
+ <button type="button" onclick={clearActive}>{text.clear}</button>
300
+ </span>
301
+ {/if}
302
+ </div>
303
+ {/if}
304
+ </header>
305
+
306
+ <div data-screen-main>
307
+ {#if sidebarSide === 'left'}{@render side()}{/if}
308
+ <div data-screen-body role="region" aria-label={title}>
309
+ {@render children()}
310
+ </div>
311
+ {#if sidebarSide !== 'left'}{@render side()}{/if}
312
+ </div>
313
+
314
+ {#if footerSnippet}
315
+ <footer data-screen-footer>{@render footerSnippet()}</footer>
316
+ {:else if footerParts}
317
+ <footer data-screen-footer>
318
+ {#each FOOTER_PARTS as part (part)}
319
+ {#if footerParts[part]}<span data-part={part}>{footerParts[part]}</span>{/if}
320
+ {/each}
321
+ </footer>
322
+ {/if}
323
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { ScreenShellProps } from '../types/screen-shell.js';
2
+ declare const ScreenShell: import("svelte").Component<ScreenShellProps, {}, "filter" | "panels">;
3
+ type ScreenShell = ReturnType<typeof ScreenShell>;
4
+ export default ScreenShell;
@@ -1,3 +1,5 @@
1
1
  export { default as ThemeSwitcherToggle } from './ThemeSwitcherToggle.svelte';
2
2
  export { default as TableOfContents } from './TableOfContents.svelte';
3
3
  export { default as SkinSwitcherToggle } from './SkinSwitcherToggle.svelte';
4
+ export { default as ScreenShell } from './ScreenShell.svelte';
5
+ export { default as ScreenInfo } from './ScreenInfo.svelte';
@@ -1,3 +1,5 @@
1
1
  export { default as ThemeSwitcherToggle } from './ThemeSwitcherToggle.svelte';
2
2
  export { default as TableOfContents } from './TableOfContents.svelte';
3
3
  export { default as SkinSwitcherToggle } from './SkinSwitcherToggle.svelte';
4
+ export { default as ScreenShell } from './ScreenShell.svelte';
5
+ export { default as ScreenInfo } from './ScreenInfo.svelte';
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents } from './components/index.js';
1
+ export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents, ScreenShell, ScreenInfo } from './components/index.js';
2
2
  export * from './types/index.js';
3
3
  export { ColorModeManager, resolveMode, type ColorMode, type ResolvedMode } from './utils/color-mode.svelte.js';
4
+ export { PANEL_IDS, isOpen, presentPanels, togglePanel, resolveToolbar, statSpan, applyStat, clearStat, setFilterValue, changedKeys } from './utils/screen-shell.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Components
2
- export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents } from './components/index.js';
2
+ export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents, ScreenShell, ScreenInfo } from './components/index.js';
3
3
  // Types
4
4
  export * from './types/index.js';
5
5
  // Utilities
6
6
  export { ColorModeManager, resolveMode } from './utils/color-mode.svelte.js';
7
+ export { PANEL_IDS, isOpen, presentPanels, togglePanel, resolveToolbar, statSpan, applyStat, clearStat, setFilterValue, changedKeys } from './utils/screen-shell.js';
@@ -1,2 +1,3 @@
1
1
  export * from './theme-switcher.js';
2
2
  export * from './skin-switcher.js';
3
+ export * from './screen-shell.js';
@@ -1,2 +1,3 @@
1
1
  export * from './theme-switcher.js';
2
2
  export * from './skin-switcher.js';
3
+ export * from './screen-shell.js';
@@ -0,0 +1,165 @@
1
+ /**
2
+ * ScreenShell Types
3
+ *
4
+ * The data a screen hands its shell: a title bar (mark, title, context, search, toolbar), an
5
+ * optional description, info / stats / filters bands, a body and a sidebar that each scroll on
6
+ * their own, and a footer. The shell is layout only; it persists nothing — `panels` and
7
+ * `filter` are bindable and the host stores them.
8
+ */
9
+ import type { Snippet } from 'svelte';
10
+ import type { ToolbarItem } from '@rokkit/ui';
11
+ /** The bands a screen can open and close from its toolbar, in toolbar order. */
12
+ export type PanelId = 'info' | 'stats' | 'filters' | 'side';
13
+ /** Which bands are open. A missing key is open, so a first visit opens everything. */
14
+ export type PanelState = Partial<Record<PanelId, boolean>>;
15
+ /** Per-panel strings (toggle labels) or icon classes. */
16
+ export type PanelLabels = Record<PanelId, string>;
17
+ /** A screen's mark: the kanji, or the icon when the shell is in icon mode and one is given. */
18
+ export interface ShellMark {
19
+ kanji: string;
20
+ icon?: string;
21
+ }
22
+ /** Which glyph a mark draws: the text (kanji) or, when it has one, the icon. */
23
+ export type GlyphMode = 'text' | 'icon';
24
+ /**
25
+ * A screen's own button: an icon, its label as the tooltip and the accessible name. A
26
+ * `disabled` string is the reason, shown with the label. A rokkit Toolbar `button` item.
27
+ */
28
+ export interface ShellAction {
29
+ id: string;
30
+ label: string;
31
+ icon: string;
32
+ onclick?: () => void;
33
+ href?: string;
34
+ busy?: boolean;
35
+ disabled?: string | boolean;
36
+ [key: string]: unknown;
37
+ }
38
+ /**
39
+ * The toolbar: the default (one toggle per band the view has) extended with `add` (left of the
40
+ * toggles) and `remove` (drops a default by id); or a full list the screen owns.
41
+ */
42
+ export type ToolbarSpec = {
43
+ add?: Array<ShellAction | ToolbarItem>;
44
+ remove?: string[];
45
+ } | ToolbarItem[];
46
+ /** One step in an info column: a mark in a paper circle, a title and a line. */
47
+ export interface InfoStep {
48
+ kanji: string;
49
+ icon?: string;
50
+ title: string;
51
+ text?: string;
52
+ }
53
+ /**
54
+ * One info column. `label` is the eyebrow and `text` the lead — the contract's minimum. A
55
+ * column may also carry a large mark and a display title (the framing column), steps (the
56
+ * how-to column) or a value with an optional total and progress (the stat column).
57
+ */
58
+ export interface InfoColumn {
59
+ label: string;
60
+ text?: string;
61
+ kanji?: string;
62
+ icon?: string;
63
+ title?: string;
64
+ steps?: InfoStep[];
65
+ value?: string | number;
66
+ total?: string | number;
67
+ /** 0–1: how far along, drawn as a meter. */
68
+ progress?: number;
69
+ }
70
+ /** A column is data, or a snippet that draws it inside the band's column frame. */
71
+ export type InfoSlot = InfoColumn | Snippet;
72
+ /** The role whose wash tints the info band. */
73
+ export type InfoRole = 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger';
74
+ export type ShellStatTone = 'ink' | 'danger' | 'warning' | 'success' | 'muted';
75
+ /** One stat card. With `filter` it is a toggle that sets the filter; with `href` a link. */
76
+ export interface ShellStat {
77
+ id: string;
78
+ /** Shown capitalised by the theme, not typed in capitals. */
79
+ title: string;
80
+ kanji?: string;
81
+ icon?: string;
82
+ value: string | number;
83
+ tone?: ShellStatTone;
84
+ description?: string;
85
+ /** Columns of 12 the card takes; computed from its content when absent. */
86
+ span?: number;
87
+ filter?: {
88
+ key: string;
89
+ value: unknown;
90
+ };
91
+ href?: string;
92
+ }
93
+ /** The filter values, the one source of truth, and the active stat's id. */
94
+ export interface FilterState {
95
+ values: Record<string, unknown>;
96
+ stat?: string | null;
97
+ }
98
+ /** What a filter band snippet is handed. `clear()` empties every value. */
99
+ export interface FilterApi {
100
+ values: Record<string, unknown>;
101
+ set(key: string, value: unknown): void;
102
+ clear(): void;
103
+ }
104
+ /** A filter band drawn by `@rokkit/forms` FormRenderer; `actions` are its toolbar. */
105
+ export interface FilterSchema {
106
+ schema: Record<string, unknown>;
107
+ layout: Record<string, unknown>;
108
+ actions?: ShellAction[];
109
+ }
110
+ /** What the shell remembers about an applied stat, so clearing it restores the key. */
111
+ export interface StatMemory {
112
+ /** The stat it was taken for; it applies only while that stat is the active one. */
113
+ stat: string;
114
+ key: string;
115
+ had: boolean;
116
+ value: unknown;
117
+ }
118
+ export interface FooterParts {
119
+ left?: string;
120
+ mid?: string;
121
+ right?: string;
122
+ }
123
+ export interface ScreenShellLabels extends PanelLabels {
124
+ search: string;
125
+ fromStat: string;
126
+ clear: string;
127
+ reading: string;
128
+ busy: string;
129
+ }
130
+ export interface ScreenShellProps {
131
+ /** `<surface>.<area>[.<view>]` — the host keys its stored panels by it. */
132
+ viewId?: string;
133
+ mark: ShellMark;
134
+ title: string;
135
+ context?: string;
136
+ description?: string;
137
+ /** On by default; `false` only for a view with nothing to search. Writes `values.q`. */
138
+ search?: {
139
+ placeholder?: string;
140
+ delay?: number;
141
+ } | false;
142
+ toolbar?: ToolbarSpec;
143
+ info?: InfoSlot[];
144
+ /** The info band's wash: the role it tints with (default `accent`). */
145
+ infoRole?: InfoRole;
146
+ /** `null` = still reading (the band says so). */
147
+ stats?: ShellStat[] | null;
148
+ filters?: Snippet<[FilterApi]> | FilterSchema;
149
+ sidebar?: Snippet;
150
+ /** Which side the sidebar sits on (default `right`). */
151
+ sidebarSide?: 'left' | 'right';
152
+ footer?: Snippet | FooterParts;
153
+ children: Snippet;
154
+ panels?: PanelState;
155
+ onpanels?: (next: PanelState) => void;
156
+ filter?: FilterState;
157
+ onfilter?: (next: FilterState) => void;
158
+ /** Draw marks as kanji (`text`, default) or as their icons (`icon`). */
159
+ glyphs?: GlyphMode;
160
+ /** Toggle icon classes per band. */
161
+ icons?: Partial<PanelLabels>;
162
+ /** String overrides; defaults come from `messages.screen`. */
163
+ labels?: Partial<ScreenShellLabels>;
164
+ class?: string;
165
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ /**
2
+ * ScreenShell — the pure rules behind the shell: which bands are open, what the toolbar holds,
3
+ * how wide a stat card is, and how a stat's filter merges into the filter values.
4
+ */
5
+ import type { ToolbarItem } from '@rokkit/ui';
6
+ import type { PanelId, PanelState, PanelLabels, ToolbarSpec, ShellStat, FilterState, StatMemory } from '../types/screen-shell.js';
7
+ /** The bands a screen can toggle, in toolbar order. */
8
+ export declare const PANEL_IDS: readonly PanelId[];
9
+ /** Whether a band is open: a missing key is open (first visit: everything open). */
10
+ export declare function isOpen(panels: PanelState | undefined, id: PanelId): boolean;
11
+ /** The bands the view has, in toolbar order. */
12
+ export declare function presentPanels(has: Partial<Record<PanelId, boolean>>): PanelId[];
13
+ /**
14
+ * The whole panel state after one band flips, for the bands the view has. A band the view
15
+ * does not have is never stored, open or closed.
16
+ */
17
+ export declare function togglePanel(panels: PanelState | undefined, id: PanelId, present: PanelId[]): PanelState;
18
+ interface ToolbarContext {
19
+ present: PanelId[];
20
+ panels: PanelState | undefined;
21
+ labels: PanelLabels;
22
+ icons: PanelLabels;
23
+ /** Appended to a busy action's label. */
24
+ busy?: string;
25
+ }
26
+ /**
27
+ * The toolbar's items: the screen's own buttons, a separator, then one toggle per band the
28
+ * view has (`info · stats · filters · side`), or the full list a screen passes.
29
+ */
30
+ export declare function resolveToolbar(spec: ToolbarSpec | undefined, ctx: ToolbarContext): ToolbarItem[];
31
+ /**
32
+ * The columns (of 12) a stat card takes: a short title and no description → 1; a longer
33
+ * title, a long value or a short description → 2; a description over 32 characters → 3. A
34
+ * stat that names its span keeps it (1–12).
35
+ */
36
+ export declare function statSpan(stat: Pick<ShellStat, 'title' | 'value' | 'description' | 'span'>): number;
37
+ interface FilterStep {
38
+ filter: FilterState;
39
+ memory: StatMemory | null;
40
+ }
41
+ /**
42
+ * Clear the active stat: its key goes back to the value it had before the stat was applied
43
+ * (from `memory`), or is removed when the shell holds no memory for it.
44
+ */
45
+ export declare function clearStat(filter: FilterState, stats: ShellStat[], memory: StatMemory | null): FilterStep;
46
+ /**
47
+ * Click a stat: apply its filter (set its key, mark it active), or — when it is already the
48
+ * active one — clear it. A stat with no filter changes nothing. `stats` (the band) lets a
49
+ * previously active stat the shell holds no memory for be cleared by its key.
50
+ */
51
+ export declare function applyStat(filter: FilterState, stat: ShellStat, memory: StatMemory | null, stats?: ShellStat[]): FilterStep;
52
+ /**
53
+ * Set one filter value by hand. Changing the active stat's key clears the stat (the new value
54
+ * stays); changing any other key leaves it active.
55
+ */
56
+ export declare function setFilterValue(filter: FilterState, key: string, value: unknown, { stats, memory }: {
57
+ stats: ShellStat[];
58
+ memory: StatMemory | null;
59
+ }): FilterStep;
60
+ /** The keys whose value differs between two value maps (added and removed keys included). */
61
+ export declare function changedKeys(before: Record<string, unknown>, after: Record<string, unknown>): string[];
62
+ export {};
@@ -0,0 +1,166 @@
1
+ /** The bands a screen can toggle, in toolbar order. */
2
+ export const PANEL_IDS = ['info', 'stats', 'filters', 'side'];
3
+ const isPanelId = (value) => PANEL_IDS.includes(value);
4
+ // =============================================================================
5
+ // Panels
6
+ // =============================================================================
7
+ /** Whether a band is open: a missing key is open (first visit: everything open). */
8
+ export function isOpen(panels, id) {
9
+ return panels?.[id] !== false;
10
+ }
11
+ /** The bands the view has, in toolbar order. */
12
+ export function presentPanels(has) {
13
+ return PANEL_IDS.filter((id) => has[id]);
14
+ }
15
+ /**
16
+ * The whole panel state after one band flips, for the bands the view has. A band the view
17
+ * does not have is never stored, open or closed.
18
+ */
19
+ export function togglePanel(panels, id, present) {
20
+ return Object.fromEntries(present.map((p) => [p, p === id ? !isOpen(panels, p) : isOpen(panels, p)]));
21
+ }
22
+ const SEPARATOR = { type: 'separator' };
23
+ /** Why an action cannot run now: its disabled reason, else the busy note while it runs. */
24
+ function unavailableNote(action, busy) {
25
+ if (typeof action.disabled === 'string' && action.disabled)
26
+ return action.disabled;
27
+ return action.busy ? busy : '';
28
+ }
29
+ /** A screen action (or any toolbar item) as a Toolbar item: value = id, a button by default. */
30
+ function toToolbarItem(item, busy = '') {
31
+ const action = item;
32
+ const note = unavailableNote(action, busy);
33
+ return {
34
+ ...item,
35
+ value: item.value ?? item.id,
36
+ type: item.type ?? 'button',
37
+ label: note ? `${action.label} — ${note}` : action.label,
38
+ disabled: Boolean(action.disabled || action.busy)
39
+ };
40
+ }
41
+ /** One toggle per band, pressed while the band is open. */
42
+ function panelToggle(id, ctx) {
43
+ return {
44
+ id,
45
+ value: id,
46
+ type: 'toggle',
47
+ icon: ctx.icons[id],
48
+ label: ctx.labels[id],
49
+ active: isOpen(ctx.panels, id)
50
+ };
51
+ }
52
+ /** A full list: kept as is, except a band toggle follows the band (dropped when it is absent). */
53
+ function resolveFullList(spec, ctx) {
54
+ return spec
55
+ .filter((item) => item.type !== 'toggle' || !isPanelId(item.value) || ctx.present.includes(item.value))
56
+ .map((item) => item.type === 'toggle' && isPanelId(item.value)
57
+ ? { ...item, active: isOpen(ctx.panels, item.value) }
58
+ : item.type === 'separator' || item.type === 'spacer'
59
+ ? item
60
+ : toToolbarItem(item, ctx.busy));
61
+ }
62
+ /**
63
+ * The toolbar's items: the screen's own buttons, a separator, then one toggle per band the
64
+ * view has (`info · stats · filters · side`), or the full list a screen passes.
65
+ */
66
+ export function resolveToolbar(spec, ctx) {
67
+ if (Array.isArray(spec))
68
+ return resolveFullList(spec, ctx);
69
+ const { add = [], remove = [] } = spec ?? {};
70
+ const removed = new Set(remove);
71
+ const toggles = ctx.present.filter((id) => !removed.has(id)).map((id) => panelToggle(id, ctx));
72
+ const added = add
73
+ .filter((item) => !removed.has(item.id))
74
+ .map((item) => toToolbarItem(item, ctx.busy));
75
+ const separator = added.length && toggles.length ? [SEPARATOR] : [];
76
+ return [...added, ...separator, ...toggles];
77
+ }
78
+ // =============================================================================
79
+ // Stats
80
+ // =============================================================================
81
+ /** Card width thresholds (characters) — the contract's stand-in for measuring the text. */
82
+ const SHORT_TITLE = 11;
83
+ const SHORT_DESCRIPTION = 32;
84
+ const SHORT_VALUE = 6;
85
+ const MAX_SPAN = 12;
86
+ /**
87
+ * The columns (of 12) a stat card takes: a short title and no description → 1; a longer
88
+ * title, a long value or a short description → 2; a description over 32 characters → 3. A
89
+ * stat that names its span keeps it (1–12).
90
+ */
91
+ export function statSpan(stat) {
92
+ if (stat.span)
93
+ return Math.max(1, Math.min(MAX_SPAN, Math.round(stat.span)));
94
+ const description = stat.description ?? '';
95
+ if (description.length > SHORT_DESCRIPTION)
96
+ return 3;
97
+ if (description || stat.title.length > SHORT_TITLE || String(stat.value).length > SHORT_VALUE)
98
+ return 2;
99
+ return 1;
100
+ }
101
+ /** A copy of `values` with `key` set, or removed when it had none. */
102
+ function restore(values, memory) {
103
+ const next = { ...values };
104
+ if (memory.had)
105
+ next[memory.key] = memory.value;
106
+ else
107
+ delete next[memory.key];
108
+ return next;
109
+ }
110
+ /** The memory, when it is the active stat's; a memory taken for another stat is stale. */
111
+ function liveMemory(filter, memory) {
112
+ return memory && memory.stat === filter.stat ? memory : null;
113
+ }
114
+ /** The filter key the active stat set: from the shell's memory, else from the stat itself. */
115
+ function activeKey(filter, stats, memory) {
116
+ const live = liveMemory(filter, memory);
117
+ if (live)
118
+ return live.key;
119
+ return stats.find((s) => s.id === filter.stat)?.filter?.key;
120
+ }
121
+ /**
122
+ * Clear the active stat: its key goes back to the value it had before the stat was applied
123
+ * (from `memory`), or is removed when the shell holds no memory for it.
124
+ */
125
+ export function clearStat(filter, stats, memory) {
126
+ if (!filter.stat)
127
+ return { filter, memory: null };
128
+ const key = activeKey(filter, stats, memory);
129
+ const used = liveMemory(filter, memory) ??
130
+ (key ? { stat: filter.stat, key, had: false, value: undefined } : null);
131
+ const values = used ? restore(filter.values, used) : { ...filter.values };
132
+ return { filter: { values, stat: null }, memory: null };
133
+ }
134
+ /**
135
+ * Click a stat: apply its filter (set its key, mark it active), or — when it is already the
136
+ * active one — clear it. A stat with no filter changes nothing. `stats` (the band) lets a
137
+ * previously active stat the shell holds no memory for be cleared by its key.
138
+ */
139
+ export function applyStat(filter, stat, memory, stats = [stat]) {
140
+ if (!stat.filter)
141
+ return { filter, memory };
142
+ if (filter.stat === stat.id)
143
+ return clearStat(filter, stats, memory);
144
+ const base = filter.stat ? clearStat(filter, stats, memory).filter : filter;
145
+ const { key, value } = stat.filter;
146
+ return {
147
+ filter: { values: { ...base.values, [key]: value }, stat: stat.id },
148
+ memory: { stat: stat.id, key, had: key in base.values, value: base.values[key] }
149
+ };
150
+ }
151
+ /**
152
+ * Set one filter value by hand. Changing the active stat's key clears the stat (the new value
153
+ * stays); changing any other key leaves it active.
154
+ */
155
+ export function setFilterValue(filter, key, value, { stats, memory }) {
156
+ const values = { ...filter.values, [key]: value };
157
+ const stat = filter.stat ?? null;
158
+ if (stat && activeKey(filter, stats, memory) === key)
159
+ return { filter: { values, stat: null }, memory: null };
160
+ return { filter: { values, stat }, memory };
161
+ }
162
+ /** The keys whose value differs between two value maps (added and removed keys included). */
163
+ export function changedKeys(before, after) {
164
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
165
+ return [...keys].filter((key) => !Object.is(before[key], after[key]));
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/app",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "App-level controls for Rokkit applications - theme management and UI chrome",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,10 +38,11 @@
38
38
  "build": "svelte-package -i src -o dist"
39
39
  },
40
40
  "dependencies": {
41
- "@rokkit/actions": "1.14.0",
42
- "@rokkit/core": "1.14.0",
43
- "@rokkit/states": "1.14.0",
44
- "@rokkit/ui": "1.14.0"
41
+ "@rokkit/actions": "1.15.0",
42
+ "@rokkit/core": "1.15.0",
43
+ "@rokkit/forms": "1.15.0",
44
+ "@rokkit/states": "1.15.0",
45
+ "@rokkit/ui": "1.15.0"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "svelte": "^5.0.0"