@celestia-island/hikari 0.55.203 → 0.55.205
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/components/HkAdminHeader.tsx +9 -0
- package/src/components/HkMarkdownRenderer.scss +37 -0
- package/src/components/HkMarkdownRenderer.tsx +34 -17
- package/src/components/HkWindowControls.scss +6 -1
- package/src/components/HkWindowControls.tsx +11 -0
- package/src/components/__tests__/hkMarkdownTableWrap.test.ts +82 -0
- package/src/components/paneSwap.contract.test.ts +44 -1
- package/src/components/windowControlsEdgeInset.contract.test.ts +125 -0
- package/src/styles/admin-tokens.scss +34 -0
package/package.json
CHANGED
|
@@ -20,6 +20,14 @@ export const HkAdminHeader = defineComponent({
|
|
|
20
20
|
title: { type: String, default: "" },
|
|
21
21
|
showHamburger: { type: Boolean, default: false },
|
|
22
22
|
compact: { type: Boolean, default: false },
|
|
23
|
+
/** Desktop shell face whose header IS the window title bar (Tauri
|
|
24
|
+
* decorations:false): pass true iff the header renders the
|
|
25
|
+
* HkWindowControls cluster, so the right inset follows the
|
|
26
|
+
* window-controls chrome contract (.s-glass-header--chrome /
|
|
27
|
+
* --hk-window-controls-edge-inset) instead of the content inset —
|
|
28
|
+
* a standard-mode cluster glued to the frame corner is the flush
|
|
29
|
+
* variant's look worn by the wrong mode (field report 2026-10-25). */
|
|
30
|
+
chrome: { type: Boolean, default: false },
|
|
23
31
|
actions: { type: Array as PropType<VNode[]>, default: () => [] },
|
|
24
32
|
/** Signed-in identity. Drives the avatar fallback letter, the
|
|
25
33
|
* dropdown identity block and the pending-state branch — it is
|
|
@@ -192,6 +200,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
192
200
|
"s-glass-header",
|
|
193
201
|
"s-balanced-header",
|
|
194
202
|
props.compact ? "s-glass-header--compact" : "",
|
|
203
|
+
props.chrome ? "s-glass-header--chrome" : "",
|
|
195
204
|
]}
|
|
196
205
|
>
|
|
197
206
|
<div ref={userTriggerRef} class="s-admin-header-user s-header-wing-start">
|
|
@@ -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 () => (
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// standard: borderless ghost buttons at the header chrome's own size
|
|
4
4
|
// step (2rem boxes, 16px glyphs, 0.375rem gaps) — no borders, no
|
|
5
5
|
// dividers, no container chrome; the close hover is a small rounded
|
|
6
|
-
// red wash. Reads as one family with gear/search icon buttons.
|
|
6
|
+
// red wash. Reads as one family with gear/search icon buttons. The
|
|
7
|
+
// HOST keeps the canonical right edge inset
|
|
8
|
+
// (--hk-window-controls-edge-inset, declared in styles/admin-tokens)
|
|
9
|
+
// — a standard cluster never rides the frame; gluing it to the corner
|
|
10
|
+
// is the flush mode's look worn by the wrong mode (field report
|
|
11
|
+
// 2026-10-25: the chest login strip had no right padding at all).
|
|
7
12
|
//
|
|
8
13
|
// flush: edge-riding squares for thin captions — the host zeroes its
|
|
9
14
|
// own right padding (the s-header-bar--chrome contract pattern); the
|
|
@@ -15,6 +15,17 @@ import "./HkWindowControls.scss";
|
|
|
15
15
|
* with comfortable gaps. The whole top-right reads as one family of
|
|
16
16
|
* identical buttons; the close hover is a small rounded red wash.
|
|
17
17
|
* THE mode for content-height headers (3rem-class bars).
|
|
18
|
+
* HOST CONTRACT: this mode never rides the window edge (that is the
|
|
19
|
+
* flush variant's job) — the host keeps the canonical inset
|
|
20
|
+
* `var(--hk-window-controls-edge-inset)` (≤1023px:
|
|
21
|
+
* `--hk-window-controls-edge-inset-compact`) between the cluster
|
|
22
|
+
* and the frame — token values live once in styles/admin-tokens,
|
|
23
|
+
* not restated here. A standard cluster glued to the frame corner
|
|
24
|
+
* is the wrong mode's look (field report 2026-10-25: the chest
|
|
25
|
+
* login strip rendered with no end padding at all);
|
|
26
|
+
* `.s-glass-header--chrome` is the in-library implementation of the
|
|
27
|
+
* contract for admin headers, and the chat face sources its
|
|
28
|
+
* `--chrome` padding from the same tokens.
|
|
18
29
|
*
|
|
19
30
|
* - `flush` — edge-riding squares that fill the host's height (the
|
|
20
31
|
* host must zero its own right padding so the close button rides
|
|
@@ -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
|
|
@@ -113,7 +129,34 @@ describe("pane-swap contract", () => {
|
|
|
113
129
|
// wrapped by the standard above or justified here.
|
|
114
130
|
"HkTabs.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
115
131
|
"HkSettingsDialog.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
116
|
-
|
|
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",
|
|
117
160
|
"HkAdminShell.tsx", // content slot is host-owned; hosts key it (chest wraps HkCrossfade)
|
|
118
161
|
"HkThemePanelContent.tsx", // forwards to HTabs (HkTabs), inherits the mechanism
|
|
119
162
|
"HkMenuPanel.tsx", // menu items, not a pane swap
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Static pins for the window-controls chrome-edge contract (field report
|
|
8
|
+
* 2026-10-25: the chest login strip rendered the standard HkWindowControls
|
|
9
|
+
* cluster glued to the frame corner — zero right padding — while the chat
|
|
10
|
+
* face had ruled the inset to 12px/8px and the console face kept a full
|
|
11
|
+
* 24px of dead air; three faces, three ad-hoc computations of the same
|
|
12
|
+
* number). happy-dom has no layout engine, so — adminHeaderChrome-style —
|
|
13
|
+
* these read the declarations themselves; reverting any pinned value keeps
|
|
14
|
+
* the rest of the suite green, so each pin below is the only red-if-broken
|
|
15
|
+
* evidence for its contract. A deliberate rewrite is expected to update
|
|
16
|
+
* them.
|
|
17
|
+
*
|
|
18
|
+
* Pinned here:
|
|
19
|
+
* 1. the canonical tokens exist at :root (admin-tokens.scss top level —
|
|
20
|
+
* a nested declaration would not reach host headers);
|
|
21
|
+
* 2. .s-glass-header--chrome sources BOTH bands from the tokens (the
|
|
22
|
+
* in-library implementation of the host contract);
|
|
23
|
+
* 3. HkAdminHeader actually hangs the class from the `chrome` prop
|
|
24
|
+
* (the prop without the class, or vice versa, ships the 24px inset);
|
|
25
|
+
* 4. the standard-mode HOST CONTRACT is documented on HkWindowControls
|
|
26
|
+
* (the doc is the contract's prose home — keep it in sync).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
|
|
31
|
+
const adminTokens = readFileSync(join(here, "..", "styles", "admin-tokens.scss"), "utf8");
|
|
32
|
+
const adminHeader = readFileSync(join(here, "HkAdminHeader.tsx"), "utf8");
|
|
33
|
+
const windowControls = readFileSync(join(here, "HkWindowControls.tsx"), "utf8");
|
|
34
|
+
|
|
35
|
+
/** Brace depth of `code` at character index `at` (0 = top level). */
|
|
36
|
+
function braceDepthAt(code: string, at: number): number {
|
|
37
|
+
let depth = 0;
|
|
38
|
+
for (let i = 0; i < at; i += 1) {
|
|
39
|
+
if (code[i] === "{") depth += 1;
|
|
40
|
+
else if (code[i] === "}") depth -= 1;
|
|
41
|
+
}
|
|
42
|
+
return depth;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Declarations of every rule whose prelude IS `selector` (whole-prelude
|
|
46
|
+
* match, comments stripped), with whether it sits inside an at-block. */
|
|
47
|
+
function allRuleDeclarations(
|
|
48
|
+
source: string,
|
|
49
|
+
selector: string,
|
|
50
|
+
): Array<{ nested: boolean; decls: Array<[string, string]> }> {
|
|
51
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
52
|
+
const out: Array<{ nested: boolean; decls: Array<[string, string]> }> = [];
|
|
53
|
+
for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
|
|
54
|
+
const open = code.indexOf("{", at + selector.length);
|
|
55
|
+
if (open < 0) continue;
|
|
56
|
+
if (code.slice(at + selector.length, open).trim() !== "") continue;
|
|
57
|
+
let boundary = at - 1;
|
|
58
|
+
while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
|
|
59
|
+
if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
|
|
60
|
+
const nested = braceDepthAt(code, at) > 0;
|
|
61
|
+
const body = code.slice(open + 1, code.indexOf("}", open));
|
|
62
|
+
const decls: Array<[string, string]> = [];
|
|
63
|
+
for (const part of body.split(";")) {
|
|
64
|
+
const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
|
|
65
|
+
if (m) decls.push([m[1], m[2].replace(/\s+/g, " ")]);
|
|
66
|
+
}
|
|
67
|
+
out.push({ nested, decls });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("window-controls chrome-edge contract (the three faces had drifted)", () => {
|
|
73
|
+
it("declares the canonical edge-inset tokens at :root top level", () => {
|
|
74
|
+
// :root is a top-level rule; a token declared inside an at-block (or
|
|
75
|
+
// renamed) silently falls every consumer back to its var() fallback —
|
|
76
|
+
// the token must exist AND sit where it cascades.
|
|
77
|
+
const rootRules = allRuleDeclarations(adminTokens, ":root").filter((r) => !r.nested);
|
|
78
|
+
expect(rootRules.length, ":root must be declared at top level").toBeGreaterThan(0);
|
|
79
|
+
const joined = rootRules.flatMap((r) => r.decls);
|
|
80
|
+
expect(joined).toContainEqual(["--hk-window-controls-edge-inset", "0.75rem"]);
|
|
81
|
+
expect(joined).toContainEqual(["--hk-window-controls-edge-inset-compact", "0.5rem"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sources the chrome header's desktop inset from the token (logical property)", () => {
|
|
85
|
+
// padding-INLINE-END, not padding-right: in a dir="rtl" host the
|
|
86
|
+
// caption row flips to the visual left and the inset must flip with
|
|
87
|
+
// it (same reasoning as the RTL-mirrored titlebar close button).
|
|
88
|
+
const rules = allRuleDeclarations(adminTokens, ".s-glass-header.s-glass-header--chrome").filter(
|
|
89
|
+
(r) => !r.nested,
|
|
90
|
+
);
|
|
91
|
+
expect(rules.length, ".s-glass-header--chrome must be declared at top level").toBe(1);
|
|
92
|
+
expect(rules[0].decls).toContainEqual([
|
|
93
|
+
"padding-inline-end",
|
|
94
|
+
"var(--hk-window-controls-edge-inset, 0.75rem)",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("sources the chrome header's ≤1023px inset from the compact token", () => {
|
|
99
|
+
const rules = allRuleDeclarations(adminTokens, ".s-glass-header.s-glass-header--chrome").filter(
|
|
100
|
+
(r) => r.nested,
|
|
101
|
+
);
|
|
102
|
+
expect(rules.length, "the compact band must override the chrome inset").toBe(1);
|
|
103
|
+
expect(rules[0].decls).toContainEqual([
|
|
104
|
+
"padding-inline-end",
|
|
105
|
+
"var(--hk-window-controls-edge-inset-compact, 0.5rem)",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("hangs the chrome class from the HkAdminHeader chrome prop", () => {
|
|
110
|
+
// The prop without the class (or the class without a producer) leaves
|
|
111
|
+
// the console face on the base 24px inset — both halves are the pin.
|
|
112
|
+
expect(adminHeader).toMatch(/chrome:\s*\{\s*type:\s*Boolean,\s*default:\s*false\s*\}/);
|
|
113
|
+
expect(adminHeader).toMatch(
|
|
114
|
+
/props\.chrome\s*\?\s*"s-glass-header--chrome"\s*:\s*""/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("documents the standard-mode host contract on HkWindowControls", () => {
|
|
119
|
+
// The doc comment is the contract's prose home: the token name and the
|
|
120
|
+
// never-flush rule must stay stated where hosts read the component.
|
|
121
|
+
expect(windowControls).toContain("HOST CONTRACT");
|
|
122
|
+
expect(windowControls).toContain("--hk-window-controls-edge-inset");
|
|
123
|
+
expect(windowControls).toContain("never rides the window edge");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
/* ── Layout tokens ─ */
|
|
10
10
|
--s-footer-height: 3rem;
|
|
11
11
|
--s-header-height: 3rem;
|
|
12
|
+
|
|
13
|
+
/* ── Window-control chrome insets ─
|
|
14
|
+
* The right padding a host title-bar header keeps between the standard
|
|
15
|
+
* HkWindowControls cluster and the window edge (canonical home since
|
|
16
|
+
* the chest login strip shipped the cluster glued to the frame corner —
|
|
17
|
+
* field report 2026-10-25). The standard cluster NEVER rides the edge
|
|
18
|
+
* (that is the flush variant's job), so every chrome face sources its
|
|
19
|
+
* padding from here instead of restating half-of-the-header-inset per
|
|
20
|
+
* face — the faces had drifted three ways (chat 12px/8px, console a
|
|
21
|
+
* full 24px of dead air, auth 0px). Values are the 2026-10-23 ruling's
|
|
22
|
+
* halvings of the base/compact header insets, promoted to tokens. */
|
|
23
|
+
--hk-window-controls-edge-inset: 0.75rem; /* desktop: half of the 24px base header inset */
|
|
24
|
+
--hk-window-controls-edge-inset-compact: 0.5rem; /* ≤1023px: half of the 16px compact header inset */
|
|
12
25
|
/* Layout/type/motion scale tokens (--space-*, --text-*, --blur-*, --z-*,
|
|
13
26
|
* --duration-*, --ease-*) used to be mirrored here ("keep both in sync")
|
|
14
27
|
* — they now live once in styles/theme/scale.scss, which every entry
|
|
@@ -437,6 +450,27 @@
|
|
|
437
450
|
padding: 0 var(--space-16);
|
|
438
451
|
}
|
|
439
452
|
|
|
453
|
+
/* Chrome variant (HkAdminHeader `chrome` prop — the desktop face whose
|
|
454
|
+
* header IS the window title bar): the inline-end inset follows the
|
|
455
|
+
* window-controls contract token, not the content inset — the default
|
|
456
|
+
* 24px reads as dead air next to the standard HkWindowControls cluster
|
|
457
|
+
* (the same 2026-10-23 user direction that halved the chat face's gap).
|
|
458
|
+
* LOGICAL property, like the RTL-mirrored titlebar close button (the
|
|
459
|
+
* [dir="rtl"] .hk-titlebar-btn-close rule in components/HkTitleBar.scss):
|
|
460
|
+
* in a dir="rtl" host the caption row flips to the visual left and the
|
|
461
|
+
* inset must flip with it, not stay on the physical right. (0,2,0)
|
|
462
|
+
* outranks the base rule and the compact band alike, so the chrome
|
|
463
|
+
* inset wins regardless of load order. */
|
|
464
|
+
.s-glass-header.s-glass-header--chrome {
|
|
465
|
+
padding-inline-end: var(--hk-window-controls-edge-inset, 0.75rem);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
@media (max-width: 1023px) {
|
|
469
|
+
.s-glass-header.s-glass-header--chrome {
|
|
470
|
+
padding-inline-end: var(--hk-window-controls-edge-inset-compact, 0.5rem);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
440
474
|
/* ── Balanced header columns (the ONE three-zone header layout) ─────────
|
|
441
475
|
* The shared geometry behind every top bar that owns a header-centre
|
|
442
476
|
* group: the frontend chat header (chest ChatLayout's .s-header-bar-*)
|