@celestia-island/hikari 0.55.202 → 0.55.204
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/package.json +1 -1
- package/src/animation/registerAnimations.ts +3 -0
- package/src/components/HkAdminShell.tsx +18 -3
- package/src/components/HkAdminTablePage.tsx +47 -30
- package/src/components/HkMarkdownRenderer.scss +37 -0
- package/src/components/HkMarkdownRenderer.tsx +34 -17
- package/src/components/HkPaneHolder.scss +33 -0
- package/src/components/HkPaneHolder.test.tsx +99 -0
- package/src/components/HkPaneHolder.tsx +57 -0
- package/src/components/__tests__/hkMarkdownTableWrap.test.ts +82 -0
- package/src/components/paneSwap.contract.test.ts +58 -1
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -66,6 +66,9 @@ export function isCssAnimationsEnabled(): boolean {
|
|
|
66
66
|
registerCssAnimation("s-nav-item-badge-pulse", { infinite: true });
|
|
67
67
|
// styles/admin-tokens.scss — auth card entrance (plays once).
|
|
68
68
|
registerCssAnimation("s-auth-card-in");
|
|
69
|
+
// HkPaneHolder's reveal (opacity-only, one shot) — the keep-mounted pane
|
|
70
|
+
// slot's entry, the dissolve-family language for persistent panes.
|
|
71
|
+
registerCssAnimation("hk-pane-holder-in");
|
|
69
72
|
// styles/admin-tokens.scss — status-bar recovery flash (plays once):
|
|
70
73
|
// the traffic light's green background blink when a dropped connection
|
|
71
74
|
// comes back.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computed, defineComponent, ref } from "vue";
|
|
2
|
-
import { HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
|
|
1
|
+
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
|
|
3
3
|
import { provideActionBar } from "../composables/useActionBar";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
|
|
@@ -162,6 +162,13 @@ export const HkAdminShell = defineComponent({
|
|
|
162
162
|
* verbatim at every width unless the page opts into
|
|
163
163
|
* `contentBleedOnMobile`. */
|
|
164
164
|
contentPadding: { type: String, default: "1.5rem" },
|
|
165
|
+
/** Identity of the content page on stage (task-4, 2026-10-24):
|
|
166
|
+
* pass the current view/route key and the content area swaps pages
|
|
167
|
+
* through the family crossfade — the library owns the page-slot
|
|
168
|
+
* mechanism instead of every host hand-wrapping it. Omitted (or
|
|
169
|
+
* null) renders the slot bare: canvas-like hosts that mount one
|
|
170
|
+
* persistent stage opt out. */
|
|
171
|
+
contentKey: { type: [String, Number] as PropType<string | number | null>, default: null },
|
|
165
172
|
/** Whether the content on screen is a CANVAS — a 2D board or the 3D
|
|
166
173
|
* scene — that must span the full phone width. OPT-IN, declared by
|
|
167
174
|
* the page that renders the canvas (2026-10-04 user direction: the
|
|
@@ -257,7 +264,15 @@ export const HkAdminShell = defineComponent({
|
|
|
257
264
|
{/* Padding lives INSIDE the scroll viewport (an inner
|
|
258
265
|
wrapper) so card box-shadows are not clipped at the
|
|
259
266
|
viewport edges. */}
|
|
260
|
-
<div style={contentStyle.value}>
|
|
267
|
+
<div style={contentStyle.value}>
|
|
268
|
+
{props.contentKey != null ? (
|
|
269
|
+
<HkCrossfade swapKey={props.contentKey} class="s-admin-shell-swap">
|
|
270
|
+
{slots.content?.()}
|
|
271
|
+
</HkCrossfade>
|
|
272
|
+
) : (
|
|
273
|
+
slots.content?.()
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
261
276
|
</HScrollContainer>
|
|
262
277
|
</main>
|
|
263
278
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineComponent, type Component, type PropType } from "vue";
|
|
2
2
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
3
|
+
import HkCrossfade from "./HkCrossfade";
|
|
3
4
|
import { HkPageHeader } from "./HkPageHeader";
|
|
4
5
|
import type { HkRowManageGate } from "./HkTable";
|
|
5
6
|
|
|
@@ -98,37 +99,53 @@ export const HkAdminTablePage = defineComponent({
|
|
|
98
99
|
* render alongside the error branch (its precondition — rows on
|
|
99
100
|
* screen — is exactly what the error branch removes). */}
|
|
100
101
|
{props.staleError && !props.error ? <HAlert message={props.staleError} /> : null}
|
|
101
|
-
{
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
emit("manage", row, opener)
|
|
126
|
-
}
|
|
102
|
+
{/* The body is a page slot with three generations — error,
|
|
103
|
+
loading/empty, content (task-4, 2026-10-24): the family
|
|
104
|
+
crossfade owns the exchange, so every admin table page in
|
|
105
|
+
every host dissolves between them instead of popping. The
|
|
106
|
+
key collapses loading-vs-empty into one "placeholder"
|
|
107
|
+
generation: spinner → empty is a data arrival, not a slot
|
|
108
|
+
identity change worth animating. */}
|
|
109
|
+
<HkCrossfade
|
|
110
|
+
swapKey={
|
|
111
|
+
props.error ? "error"
|
|
112
|
+
: props.loading && !props.rows.length ? "placeholder"
|
|
113
|
+
: !props.rows.length ? "placeholder"
|
|
114
|
+
: "content"
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
{props.error ? (
|
|
118
|
+
<HAlert message={props.error} />
|
|
119
|
+
) : props.loading && !props.rows.length ? (
|
|
120
|
+
<HSpinner center />
|
|
121
|
+
) : !props.rows.length ? (
|
|
122
|
+
<HEmptyState
|
|
123
|
+
boxed
|
|
124
|
+
title={emptyTitle}
|
|
125
|
+
description={props.emptyDescription}
|
|
127
126
|
>
|
|
128
|
-
{
|
|
129
|
-
</
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
{emptySlots}
|
|
128
|
+
</HEmptyState>
|
|
129
|
+
) : slots.body ? (
|
|
130
|
+
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
131
|
+
) : (
|
|
132
|
+
<HCard padded={false}>
|
|
133
|
+
<HTable
|
|
134
|
+
columns={props.columns}
|
|
135
|
+
rows={props.rows}
|
|
136
|
+
rowKey={props.rowKey}
|
|
137
|
+
draggable={props.draggable}
|
|
138
|
+
manageable={props.manageable}
|
|
139
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
140
|
+
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
141
|
+
emit("manage", row, opener)
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
{slots}
|
|
145
|
+
</HTable>
|
|
146
|
+
</HCard>
|
|
147
|
+
)}
|
|
148
|
+
</HkCrossfade>
|
|
132
149
|
{slots["create-modal"]?.()}
|
|
133
150
|
{slots["edit-modal"]?.()}
|
|
134
151
|
</div>
|
|
@@ -78,6 +78,43 @@
|
|
|
78
78
|
margin: 0;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/* Markdown tables size to their content, so a wide one (many columns, or
|
|
82
|
+
a single unbreakable cell) pushes every ancestor's min-content width
|
|
83
|
+
past its box — clipping hosts (the modal reading region, chat bubbles,
|
|
84
|
+
report cards) then leave the far columns unreachable. The renderer wraps
|
|
85
|
+
every table in the same two-level shape as code above: this wrap is the
|
|
86
|
+
rail HOST (track host for the scroller below — it contains ONLY the
|
|
87
|
+
scrolling box, so the rail hugs the table instead of spanning the whole
|
|
88
|
+
markdown body, useOverlayScrollbar's host contract) and carries the
|
|
89
|
+
table's vertical margin, because a scroll container never collapses
|
|
90
|
+
margins with its children and the margin must keep collapsing with the
|
|
91
|
+
surrounding flow exactly as the bare table's did. */
|
|
92
|
+
.hk-md-table-wrap {
|
|
93
|
+
position: relative;
|
|
94
|
+
margin: 0.75em 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* The horizontal rail itself — the viewport the overlay scrollbar attaches
|
|
98
|
+
to. Only a genuinely overflowing table grows the scroll; normal tables
|
|
99
|
+
keep their full-width layout. Same hidden-native-chrome contract as
|
|
100
|
+
.hk-md-code (the 2026-10-01 native-bar regression rule). */
|
|
101
|
+
.hk-md-table-scroll {
|
|
102
|
+
overflow-x: auto;
|
|
103
|
+
scrollbar-width: none;
|
|
104
|
+
|
|
105
|
+
&::-webkit-scrollbar {
|
|
106
|
+
display: none;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* The railed table renders flush inside its scroller — its old vertical
|
|
111
|
+
margin lives on the host wrap above. Outranks `.hk-markdown table`
|
|
112
|
+
(two classes + one type vs one class + one type); GFM tables cannot
|
|
113
|
+
nest, so no other table is ever matched. */
|
|
114
|
+
.hk-markdown .hk-md-table-wrap table {
|
|
115
|
+
margin: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
.hk-md-code-lang {
|
|
82
119
|
position: absolute;
|
|
83
120
|
top: 0.25em;
|
|
@@ -61,7 +61,8 @@ async function renderMarkdown(content: string): Promise<string> {
|
|
|
61
61
|
return renderPlain(content);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const
|
|
64
|
+
const RendererCtor = (marked as unknown as { Renderer: new () => Record<string, unknown> }).Renderer;
|
|
65
|
+
const renderer = new RendererCtor();
|
|
65
66
|
|
|
66
67
|
(renderer as Record<string, unknown>).code = function (tok: { text: string; lang?: string }) {
|
|
67
68
|
const highlighted = tryHighlight(tok.text, tok.lang);
|
|
@@ -74,6 +75,21 @@ async function renderMarkdown(content: string): Promise<string> {
|
|
|
74
75
|
return `<div class="hk-md-code-wrap"><div class="hk-md-code">${langLabel}<pre><code class="hljs${langAttr}">${highlighted}</code></pre></div></div>`;
|
|
75
76
|
};
|
|
76
77
|
|
|
78
|
+
(renderer as Record<string, unknown>).table = function (token: unknown) {
|
|
79
|
+
// Tables size to their content: a wide one (many columns, or a single
|
|
80
|
+
// unbreakable cell) pushes every ancestor's min-content width past its
|
|
81
|
+
// box, and clipping hosts (modal reading region, chat bubbles, report
|
|
82
|
+
// cards) leave the far columns unreachable. Same two-level shape as the
|
|
83
|
+
// code override above: `.hk-md-table-wrap` is the rail HOST (a plain
|
|
84
|
+
// positioned box wrapping only the scroller — the engine anchors the
|
|
85
|
+
// track in the viewport's parent), `.hk-md-table-scroll` is the
|
|
86
|
+
// viewport that actually rails. Rail the TABLE itself — normal tables
|
|
87
|
+
// keep their full-width layout; only a genuinely overflowing one grows
|
|
88
|
+
// the horizontal scroll (see .hk-md-table-scroll).
|
|
89
|
+
const inner = (RendererCtor.prototype as { table: (t: unknown) => string }).table.call(this, token);
|
|
90
|
+
return `<div class="hk-md-table-wrap"><div class="hk-md-table-scroll">${inner}</div></div>`;
|
|
91
|
+
};
|
|
92
|
+
|
|
77
93
|
const raw = marked.parse(content, { renderer, async: false });
|
|
78
94
|
const html = typeof raw === "string" ? raw : "";
|
|
79
95
|
return DOMPurify.sanitize(html);
|
|
@@ -114,33 +130,34 @@ export default defineComponent({
|
|
|
114
130
|
{ immediate: true },
|
|
115
131
|
);
|
|
116
132
|
|
|
117
|
-
// Overlay scrollbars (shared chrome) on markdown
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
133
|
+
// Overlay scrollbars (shared chrome) on the markdown blocks that can
|
|
134
|
+
// rail horizontally: code blocks and table scrollers. The body is
|
|
135
|
+
// innerHTML-rendered and fully replaced on every parse, so the
|
|
136
|
+
// attachment set is reconciled after each render pass: handles whose
|
|
137
|
+
// block left the DOM detach, fresh blocks attach.
|
|
121
138
|
const bodyRef = ref<HTMLElement>();
|
|
122
|
-
const
|
|
139
|
+
const overlayScrollbars: Array<{ el: HTMLElement; handle: OverlayScrollbarHandle }> = [];
|
|
123
140
|
|
|
124
|
-
function
|
|
125
|
-
for (let i =
|
|
126
|
-
if (!
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
function syncOverlayScrollbars() {
|
|
142
|
+
for (let i = overlayScrollbars.length - 1; i >= 0; i--) {
|
|
143
|
+
if (!overlayScrollbars[i].el.isConnected) {
|
|
144
|
+
overlayScrollbars[i].handle.detach();
|
|
145
|
+
overlayScrollbars.splice(i, 1);
|
|
129
146
|
}
|
|
130
147
|
}
|
|
131
|
-
for (const el of bodyRef.value?.querySelectorAll<HTMLElement>(".hk-md-code") ?? []) {
|
|
132
|
-
if (
|
|
133
|
-
|
|
148
|
+
for (const el of bodyRef.value?.querySelectorAll<HTMLElement>(".hk-md-code, .hk-md-table-scroll") ?? []) {
|
|
149
|
+
if (overlayScrollbars.some((c) => c.el === el)) continue;
|
|
150
|
+
overlayScrollbars.push({ el, handle: attachOverlayScrollbars(el, { axis: "horizontal" }) });
|
|
134
151
|
}
|
|
135
152
|
}
|
|
136
153
|
|
|
137
154
|
watch(renderedHtml, () => {
|
|
138
|
-
void nextTick(
|
|
155
|
+
void nextTick(syncOverlayScrollbars);
|
|
139
156
|
});
|
|
140
157
|
|
|
141
158
|
onBeforeUnmount(() => {
|
|
142
|
-
for (const c of
|
|
143
|
-
|
|
159
|
+
for (const c of overlayScrollbars) c.handle.detach();
|
|
160
|
+
overlayScrollbars.length = 0;
|
|
144
161
|
});
|
|
145
162
|
|
|
146
163
|
return () => (
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// HkPaneHolder — the keep-mounted pane slot (see the TSX doc for when to
|
|
2
|
+
// choose it over the dissolve primitives).
|
|
3
|
+
|
|
4
|
+
.hk-pane-holder {
|
|
5
|
+
display: none;
|
|
6
|
+
|
|
7
|
+
// Active: back in the layout with the family dissolve's own language —
|
|
8
|
+
// a one-shot opacity reveal. No transform: a pane sliding in would
|
|
9
|
+
// disagree with every crossfade around it.
|
|
10
|
+
&[data-active] {
|
|
11
|
+
display: block;
|
|
12
|
+
animation: hk-pane-holder-in var(--duration-normal, 0.3s) ease-out;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@keyframes hk-pane-holder-in {
|
|
17
|
+
from { opacity: 0; }
|
|
18
|
+
to { opacity: 1; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Opt-outs: the global animation switch is the host's, reduced motion is
|
|
22
|
+
// the OS's — the holder obeys both (an inline-less rule; the animation is
|
|
23
|
+
// declared here in the sheet, so unlike HkTransitionContainer's inline
|
|
24
|
+
// duration a media query CAN reach it).
|
|
25
|
+
html[data-css-animations="0"] .hk-pane-holder[data-active] {
|
|
26
|
+
animation: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media (prefers-reduced-motion: reduce) {
|
|
30
|
+
.hk-pane-holder[data-active] {
|
|
31
|
+
animation-duration: var(--duration-fast, 0.15s);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import sass from "sass";
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
|
8
|
+
|
|
9
|
+
import HkPaneHolder from "./HkPaneHolder";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
const containers: HTMLElement[] = [];
|
|
14
|
+
|
|
15
|
+
function mountHolder(active: boolean): {
|
|
16
|
+
app: App;
|
|
17
|
+
root: HTMLElement;
|
|
18
|
+
child: HTMLElement;
|
|
19
|
+
setActive: (v: boolean) => void;
|
|
20
|
+
} {
|
|
21
|
+
const container = document.createElement("div");
|
|
22
|
+
document.body.appendChild(container);
|
|
23
|
+
containers.push(container);
|
|
24
|
+
const isActive = ref(active);
|
|
25
|
+
const Child = defineComponent({
|
|
26
|
+
setup() {
|
|
27
|
+
// The point of the holder: the pane's instance state SURVIVES
|
|
28
|
+
// deactivation. A counter proves the component never unmounted.
|
|
29
|
+
const mounts = ref(0);
|
|
30
|
+
const tick = () => { mounts.value += 1; };
|
|
31
|
+
return () => h("button", { type: "button", onClick: tick, "data-mounts": String(mounts.value) }, "pane");
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const Wrapper = defineComponent({
|
|
35
|
+
render: () =>
|
|
36
|
+
h(HkPaneHolder, { active: isActive.value }, { default: () => h(Child) }),
|
|
37
|
+
});
|
|
38
|
+
const app = createApp(Wrapper);
|
|
39
|
+
app.mount(container);
|
|
40
|
+
const root = container.firstElementChild as HTMLElement;
|
|
41
|
+
const child = root.firstElementChild as HTMLElement;
|
|
42
|
+
return { app, root, child, setActive: (v: boolean) => { isActive.value = v; } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
for (const c of containers.splice(0)) c.remove();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("HkPaneHolder — DOM contract", () => {
|
|
50
|
+
it("renders the holder class and the data-active attribute only when active", async () => {
|
|
51
|
+
const t = mountHolder(false);
|
|
52
|
+
expect(t.root.classList.contains("hk-pane-holder")).toBe(true);
|
|
53
|
+
expect(t.root.hasAttribute("data-active")).toBe(false);
|
|
54
|
+
expect(t.child).toBeDefined(); // mounted, not unmounted
|
|
55
|
+
t.setActive(true);
|
|
56
|
+
await nextTick();
|
|
57
|
+
expect(t.root.hasAttribute("data-active")).toBe(true);
|
|
58
|
+
t.app.unmount();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps component state across deactivate/reactivate (the entire point)", async () => {
|
|
62
|
+
const t = mountHolder(true);
|
|
63
|
+
(t.child as HTMLButtonElement).click();
|
|
64
|
+
(t.child as HTMLButtonElement).click();
|
|
65
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
66
|
+
expect(t.child.getAttribute("data-mounts")).toBe("2");
|
|
67
|
+
t.setActive(false);
|
|
68
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
69
|
+
t.setActive(true);
|
|
70
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
71
|
+
const childAfter = t.root.firstElementChild as HTMLElement;
|
|
72
|
+
// Same instance: the counter did not reset to 0.
|
|
73
|
+
expect(childAfter.getAttribute("data-mounts")).toBe("2");
|
|
74
|
+
t.app.unmount();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("HkPaneHolder — stylesheet contract", () => {
|
|
79
|
+
const css = sass.compile(join(here, "HkPaneHolder.scss")).css;
|
|
80
|
+
const scss = readFileSync(join(here, "HkPaneHolder.scss"), "utf-8");
|
|
81
|
+
|
|
82
|
+
it("hides the base holder and reveals on [data-active]", () => {
|
|
83
|
+
expect(css).toMatch(/\.hk-pane-holder\s*\{[^}]*display:\s*none/s);
|
|
84
|
+
expect(css).toMatch(/\.hk-pane-holder\[data-active\]\s*\{[^}]*display:\s*block/s);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reveals with opacity only — no transform, ever", () => {
|
|
88
|
+
const keyframe = /@keyframes\s+hk-pane-holder-in\s*\{([^}]*)\}/s.exec(css);
|
|
89
|
+
expect(keyframe).not.toBeNull();
|
|
90
|
+
expect(keyframe![1]).toContain("opacity");
|
|
91
|
+
expect(keyframe![1]).not.toContain("transform");
|
|
92
|
+
expect(keyframe![1]).not.toContain("translate");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("obeys the global animation switch and reduced motion", () => {
|
|
96
|
+
expect(css).toMatch(/html\[data-css-animations="0"\][^{]*\{[^}]*animation:\s*none/s);
|
|
97
|
+
expect(css).toMatch(/prefers-reduced-motion[^{]*\{[^}]*animation-duration/s);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkPaneHolder.scss";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HkPaneHolder — the keep-mounted pane slot.
|
|
7
|
+
*
|
|
8
|
+
* The third pane-swap surface, for the class of content the dissolve
|
|
9
|
+
* primitives cannot serve: PERSISTENT panes whose instance must survive
|
|
10
|
+
* while another pane is active — draft stores (an editor whose unsaved
|
|
11
|
+
* state lives in the component), canvases/WebGL stages that are too
|
|
12
|
+
* expensive to rebuild, or sockets that must not unplug.
|
|
13
|
+
*
|
|
14
|
+
* `HkCrossfade` / `HkTransitionContainer` swap by keying: the leaving
|
|
15
|
+
* generation UNMOUNTS when the dissolve completes — exactly wrong for a
|
|
16
|
+
* draft store. This holder instead keeps every pane in the tree and
|
|
17
|
+
* toggles reachability: `display: none` while inactive (out of layout,
|
|
18
|
+
* inert), `display: block` plus a one-shot opacity reveal when it
|
|
19
|
+
* becomes active. The reveal is opacity-ONLY at the family's
|
|
20
|
+
* `--duration-normal`, matching the dissolve language — no movement,
|
|
21
|
+
* ever.
|
|
22
|
+
*
|
|
23
|
+
* Contract:
|
|
24
|
+
* - `active` is a boolean, not a key: the holder IS one pane. A tab
|
|
25
|
+
* surface renders one holder per persistent pane beside the ordinary
|
|
26
|
+
* panel slots it already has.
|
|
27
|
+
* - The inactive pane is `display: none` (inert, out of the layout,
|
|
28
|
+
* unreadable by assistive tech) but its component instances, state
|
|
29
|
+
* and watchers all LIVE — that is the entire point.
|
|
30
|
+
* - The reveal animation is `hk-pane-holder-in` (opacity 0→1, one
|
|
31
|
+
* shot, registered with the animation registry); reduced motion and
|
|
32
|
+
* the global `data-css-animations="0"` switch clamp it to instant,
|
|
33
|
+
* so the holder never animates for users who opted out.
|
|
34
|
+
* - No leave animation: the outgoing pane simply hides (it is usually
|
|
35
|
+
* being covered by an incoming ordinary panel whose own dissolve is
|
|
36
|
+
* the visible exchange).
|
|
37
|
+
*/
|
|
38
|
+
export const HkPaneHolder = defineComponent({
|
|
39
|
+
name: "HkPaneHolder",
|
|
40
|
+
props: {
|
|
41
|
+
/** Whether THIS pane is the active one. */
|
|
42
|
+
active: { type: Boolean, required: true },
|
|
43
|
+
tag: { type: String, default: "div" },
|
|
44
|
+
},
|
|
45
|
+
setup(props, { slots }) {
|
|
46
|
+
return () => {
|
|
47
|
+
const Tag = props.tag as "div" | "section";
|
|
48
|
+
return (
|
|
49
|
+
<Tag class="hk-pane-holder" data-active={props.active || undefined}>
|
|
50
|
+
{slots.default?.()}
|
|
51
|
+
</Tag>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export default HkPaneHolder;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Source contract for the markdown table rail (2026-10-24: a wide table —
|
|
8
|
+
* many columns or one unbreakable cell — pushed every ancestor's
|
|
9
|
+
* min-content width past its box, and clipping hosts (the chest report
|
|
10
|
+
* modal's reading region, chat bubbles, report cards) left the far columns
|
|
11
|
+
* unreachable).
|
|
12
|
+
*
|
|
13
|
+
* vitest never resolves stylesheets, and the renderer's marked/DOMPurify
|
|
14
|
+
* are lazy dynamic imports, so this pins the moving parts as source
|
|
15
|
+
* contracts in the same idiom as hkMarkdownLang.test.ts:
|
|
16
|
+
*
|
|
17
|
+
* - the renderer overrides `table` to wrap the emitted table in the
|
|
18
|
+
* two-level rail shape (host + scroller), delegating to the base
|
|
19
|
+
* Renderer for the table markup itself;
|
|
20
|
+
* - the stylesheet gives the scroller the horizontal rail with the
|
|
21
|
+
* hidden-native-chrome contract (the 2026-10-01 native-bar rule) and
|
|
22
|
+
* makes the wrap a positioned rail host carrying the vertical margin;
|
|
23
|
+
* - the overlay-scrollbar reconciliation covers the scroller alongside
|
|
24
|
+
* `.hk-md-code`, so the rail gets the shared chrome and detaches with
|
|
25
|
+
* the block.
|
|
26
|
+
*/
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const tsx = readFileSync(join(here, "../HkMarkdownRenderer.tsx"), "utf-8");
|
|
29
|
+
const scss = readFileSync(join(here, "../HkMarkdownRenderer.scss"), "utf-8");
|
|
30
|
+
|
|
31
|
+
describe("markdown table rail contract", () => {
|
|
32
|
+
it("the renderer wraps tables in the rail via the base renderer", () => {
|
|
33
|
+
// The override must DELEGATE (base table markup) and then wrap — a
|
|
34
|
+
// replacement that dropped the delegation would lose marked's table
|
|
35
|
+
// rendering; a wrap without the override is the dead-selector shape.
|
|
36
|
+
// The wrap is TWO-level (`.hk-md-table-wrap` host containing the
|
|
37
|
+
// `.hk-md-table-scroll` viewport): the overlay engine anchors its
|
|
38
|
+
// track in the viewport's PARENT, so a single-level wrap would rail
|
|
39
|
+
// against the whole `.hk-markdown-body` instead of the table.
|
|
40
|
+
expect(tsx).toMatch(
|
|
41
|
+
/return\s*`<div class="hk-md-table-wrap"><div class="hk-md-table-scroll">\$\{inner\}<\/div><\/div>`;/,
|
|
42
|
+
);
|
|
43
|
+
expect(tsx).toMatch(
|
|
44
|
+
/RendererCtor\.prototype as \{ table: \(t: unknown\) => string \}\)\.table\.call\(this, token\)/,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("the rail stylesheet keeps native chrome hidden on the scroller", () => {
|
|
49
|
+
expect(scss).toMatch(
|
|
50
|
+
/\.hk-md-table-scroll\s*\{[^}]*overflow-x:\s*auto/,
|
|
51
|
+
);
|
|
52
|
+
expect(scss).toMatch(
|
|
53
|
+
/\.hk-md-table-scroll\s*\{[^}]*scrollbar-width:\s*none/,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("the wrap is a positioned host carrying the table's vertical margin", () => {
|
|
58
|
+
// Host: wraps ONLY the scroller and is positioned, so the rail hugs
|
|
59
|
+
// the table (no guard promotion of `.hk-markdown-body`); the margin
|
|
60
|
+
// lives here because the scroll box never collapses margins with its
|
|
61
|
+
// children — this keeps normal tables' surrounding rhythm identical
|
|
62
|
+
// to the pre-wrap bare table.
|
|
63
|
+
expect(scss).toMatch(
|
|
64
|
+
/\.hk-md-table-wrap\s*\{[^}]*position:\s*relative/,
|
|
65
|
+
);
|
|
66
|
+
expect(scss).toMatch(
|
|
67
|
+
/\.hk-md-table-wrap\s*\{[^}]*margin:\s*0\.75em\s+0/,
|
|
68
|
+
);
|
|
69
|
+
expect(scss).toMatch(
|
|
70
|
+
// `0\s*;` — a bare `margin:\s*0` would also prefix-match the
|
|
71
|
+
// bare-table `margin: 0.75em 0` and false-pass the very double-margin
|
|
72
|
+
// regression this reset exists to prevent.
|
|
73
|
+
/\.hk-markdown \.hk-md-table-wrap table\s*\{[^}]*margin:\s*0\s*;/,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("the overlay reconciliation covers the rail alongside code blocks", () => {
|
|
78
|
+
expect(tsx).toMatch(
|
|
79
|
+
/querySelectorAll<HTMLElement>\("\.hk-md-code, \.hk-md-table-scroll"\)/,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -11,6 +11,22 @@ const componentsDir = here;
|
|
|
11
11
|
* Pane-swap contract — the enforcement half of the family's transition
|
|
12
12
|
* primitives (user audit 2026-10-23).
|
|
13
13
|
*
|
|
14
|
+
* THE DECISION TABLE (which surface for which class of slot):
|
|
15
|
+
*
|
|
16
|
+
* ┌──────────────────────────────┬───────────────────────────────┐
|
|
17
|
+
* │ the content can UNMOUNT │ HkTransitionContainer (frame │
|
|
18
|
+
* │ when it leaves the stage │ must morph) / HkCrossfade │
|
|
19
|
+
* │ │ (same-box dissolve) │
|
|
20
|
+
* ├──────────────────────────────┼───────────────────────────────┤
|
|
21
|
+
* │ the instance must SURVIVE │ HkPaneHolder (draft stores, │
|
|
22
|
+
* │ (draft store, canvas, sock) │ canvases, sockets) │
|
|
23
|
+
* ├──────────────────────────────┼───────────────────────────────┤
|
|
24
|
+
* │ a WIZARD STEPPER inside a │ HkStepFlow — see its exception│
|
|
25
|
+
* │ modal sheet │ below; it is a sibling with a │
|
|
26
|
+
* │ │ different contract, not a │
|
|
27
|
+
* │ │ rogue pane slot │
|
|
28
|
+
* └──────────────────────────────┴───────────────────────────────┘
|
|
29
|
+
*
|
|
14
30
|
* THE RULE. A "pane slot" is a container whose visible content is selected
|
|
15
31
|
* by a state key while the container stays put: tabs, settings sections,
|
|
16
32
|
* step bodies, view areas. Every such slot must swap through the standard
|
|
@@ -51,6 +67,11 @@ const STANDARD_PANE_HOSTS: Array<{ file: string; marker: string; why: string }>
|
|
|
51
67
|
marker: "<HkTransitionContainer",
|
|
52
68
|
why: "settings sections are a page-scale pane slot (migrated 2026-10-23)",
|
|
53
69
|
},
|
|
70
|
+
{
|
|
71
|
+
file: "HkAdminTablePage.tsx",
|
|
72
|
+
marker: "<HkCrossfade",
|
|
73
|
+
why: "the error/placeholder/content body is a page slot every admin table page inherits (2026-10-24)",
|
|
74
|
+
},
|
|
54
75
|
];
|
|
55
76
|
|
|
56
77
|
/** Justified bespoke mechanisms: "<scss file>: <animation identifier>" → reason. */
|
|
@@ -66,6 +87,14 @@ const SANCTIONED_SWAP_KEYFRAMES: Record<string, string> = {
|
|
|
66
87
|
* animation must be argued, decorative pulses and spinners do not. */
|
|
67
88
|
const SWAP_VOCABULARY = /(pane|swap|crossfade|morph|sheet)/;
|
|
68
89
|
|
|
90
|
+
/** The primitives' own stylesheets ARE the sanctioned mechanism — their
|
|
91
|
+
* keyframes are the family language itself, not bespoke swaps. */
|
|
92
|
+
const PRIMITIVE_SHEETS = new Set([
|
|
93
|
+
"HkCrossfade.scss",
|
|
94
|
+
"HkTransitionContainer.scss",
|
|
95
|
+
"HkPaneHolder.scss",
|
|
96
|
+
]);
|
|
97
|
+
|
|
69
98
|
function readComponent(file: string): string {
|
|
70
99
|
return readFileSync(join(componentsDir, file), "utf-8");
|
|
71
100
|
}
|
|
@@ -100,7 +129,34 @@ describe("pane-swap contract", () => {
|
|
|
100
129
|
// wrapped by the standard above or justified here.
|
|
101
130
|
"HkTabs.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
102
131
|
"HkSettingsDialog.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
103
|
-
|
|
132
|
+
// HkStepFlow is NOT a rogue pane slot — it is a sibling mechanism
|
|
133
|
+
// with a contract the generic primitives deliberately do not
|
|
134
|
+
// offer (2026-10-24 deep-dive ruling; do not re-litigate without
|
|
135
|
+
// reading this):
|
|
136
|
+
// 1. SEQUENTIAL two-window choreography (slide with the hosting
|
|
137
|
+
// sheet's height FROZEN, then the morph plays ALONE) — the
|
|
138
|
+
// inversion of HkTransitionContainer's concurrent morph, born
|
|
139
|
+
// from the 2026-09-23 round-16 user directive after the
|
|
140
|
+
// concurrent form overloaded phone GPU raster threads
|
|
141
|
+
// (whole-region black flashes no layering fix absorbed).
|
|
142
|
+
// 2. A direction axis (translateX travel + RTL mirroring) that
|
|
143
|
+
// HkCrossfade's contract forbids — its tests pin
|
|
144
|
+
// opacity-only, no transform, ever.
|
|
145
|
+
// 3. The hk-stepflow-swap sheet protocol: HkModal freezes its
|
|
146
|
+
// height pin on "swap", safety-unfreezes (ms+650), and
|
|
147
|
+
// height-morphs alone on "morph".
|
|
148
|
+
// 4. Six round-numbered field incidents live in its logic:
|
|
149
|
+
// r16 align-self:start (grid stretch zeroed every shrink's
|
|
150
|
+
// delta), r17 near-instant clip sweep (moving clip edges
|
|
151
|
+
// re-raster per frame), r18 announce-AFTER-patch (a shrink
|
|
152
|
+
// read the leaving body's geometry and no-opped), r22
|
|
153
|
+
// Android-autofill strobe (dropFlowFocus before the swap),
|
|
154
|
+
// r24 sticky-pin vs modal gutter-cover layering, and
|
|
155
|
+
// preemptSwap keeping whichever body is actually visible
|
|
156
|
+
// (opacity > 0.05) when interrupted mid-slide.
|
|
157
|
+
// Merging any of this into the generic container would change its
|
|
158
|
+
// semantics for every existing call site. Permanent exception.
|
|
159
|
+
"HkStepFlow.tsx",
|
|
104
160
|
"HkAdminShell.tsx", // content slot is host-owned; hosts key it (chest wraps HkCrossfade)
|
|
105
161
|
"HkThemePanelContent.tsx", // forwards to HTabs (HkTabs), inherits the mechanism
|
|
106
162
|
"HkMenuPanel.tsx", // menu items, not a pane swap
|
|
@@ -124,6 +180,7 @@ describe("pane-swap contract", () => {
|
|
|
124
180
|
for (const match of text.matchAll(/@keyframes\s+([\w-]+)/g)) {
|
|
125
181
|
const name = match[1];
|
|
126
182
|
if (!SWAP_VOCABULARY.test(name)) continue;
|
|
183
|
+
if (PRIMITIVE_SHEETS.has(file)) continue;
|
|
127
184
|
const key = `${file}: ${name}`;
|
|
128
185
|
if (!SANCTIONED_SWAP_KEYFRAMES[key]) offenders.push(`@keyframes ${key}`);
|
|
129
186
|
}
|
package/src/index.ts
CHANGED
|
@@ -614,6 +614,7 @@ export { default as HkCopyBadge } from "./components/HkCopyBadge";
|
|
|
614
614
|
export { default as HkConfirmDialog } from "./components/HkConfirmDialog";
|
|
615
615
|
export { default as HkCrossfade } from "./components/HkCrossfade";
|
|
616
616
|
export { default as HkTransitionContainer } from "./components/HkTransitionContainer";
|
|
617
|
+
export { default as HkPaneHolder } from "./components/HkPaneHolder";
|
|
617
618
|
export { default as HkWindowControls, isApplePlatform } from "./components/HkWindowControls";
|
|
618
619
|
export { default as HkStatusRow } from "./components/HkStatusRow";
|
|
619
620
|
export type { HkStatusRowTone } from "./components/HkStatusRow";
|