@celestia-island/hikari 0.55.283 → 0.55.285
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/HkAmbientLayer.styles.test.ts +19 -0
- package/src/components/HkCalendarHeatmap.scss +13 -6
- package/src/components/HkMarkdownRenderer.chips.test.tsx +178 -0
- package/src/components/HkMarkdownRenderer.scss +38 -0
- package/src/components/HkMarkdownRenderer.tsx +34 -3
- package/src/components/HkScreenIdentifyCard.styles.test.ts +28 -5
- package/src/components/calendarHeatmapRail.contract.test.ts +63 -0
- package/src/components/inlineChips.ts +170 -0
- package/src/index.ts +11 -0
- package/src/theme/useSolarTime.test.ts +14 -0
- package/src/theme/useSolarTime.ts +23 -9
package/package.json
CHANGED
|
@@ -35,6 +35,25 @@ describe("HkAmbientLayer pointer contract", () => {
|
|
|
35
35
|
const blockStart = declarations.lastIndexOf('[data-pointer-events="auto"]', autoAt);
|
|
36
36
|
expect(blockStart).toBeGreaterThan(-1);
|
|
37
37
|
expect(declarations.slice(blockStart, autoAt)).toContain("> *");
|
|
38
|
+
// EVERY ruleset is checked by SCOPE, not proximity: any block that
|
|
39
|
+
// states pointer-events:auto must be the child-scoped opt-in, and any
|
|
40
|
+
// other block stating pointer-events at all must say none (an earlier
|
|
41
|
+
// round flagged both the first-only and the proximity-based shapes).
|
|
42
|
+
const rulesets = [...declarations.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
|
|
43
|
+
([, selector, body]) => ({ selector: selector.trim(), body }),
|
|
44
|
+
);
|
|
45
|
+
expect(rulesets.length).toBeGreaterThan(0);
|
|
46
|
+
for (const { selector, body } of rulesets) {
|
|
47
|
+
if (!/pointer-events/.test(body)) continue;
|
|
48
|
+
if (/pointer-events:\s*auto/.test(body)) {
|
|
49
|
+
expect(selector).toContain('[data-pointer-events="auto"]');
|
|
50
|
+
expect(selector).toContain("> *");
|
|
51
|
+
} else {
|
|
52
|
+
expect(body).toMatch(/pointer-events:\s*none/);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// The wrapper's own block states the ghost rule exactly once.
|
|
56
|
+
expect(declarations.match(/pointer-events:\s*none/g)).toHaveLength(1);
|
|
38
57
|
});
|
|
39
58
|
|
|
40
59
|
it("sits on the HUD ordinal band, below its root's document elevation", () => {
|
|
@@ -42,12 +42,20 @@
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// Weekday rail: the first (Monday) and last (Sunday) rows carry the
|
|
45
|
-
// labels (the wowsp convention)
|
|
46
|
-
//
|
|
45
|
+
// labels (the wowsp convention). `align-self: flex-end` rows-aligns
|
|
46
|
+
// the rail with the GRID, not with the -cells box: -cells is one
|
|
47
|
+
// marker strip taller than the grid (the month labels ride above
|
|
48
|
+
// it), so a stretched rail started its seven rows on the markers and
|
|
49
|
+
// every label sat one strip too high (2026-11-05 user report from
|
|
50
|
+
// the chest quota card). The rail's natural height (7 cells + 6
|
|
51
|
+
// gaps) equals the grid's exactly, so end-alignment is row-exact.
|
|
52
|
+
// No sticky mask on purpose: a hard-coded surface can't match every
|
|
53
|
+
// host card (it painted a visible stripe behind the labels), and
|
|
54
|
+
// sticky without a mask lets cells slide beneath the labels during
|
|
55
|
+
// horizontal scroll — the rail scrolls with the calendar instead
|
|
56
|
+
// (the GitHub convention).
|
|
47
57
|
&-rail {
|
|
48
|
-
|
|
49
|
-
left: 0;
|
|
50
|
-
z-index: 1;
|
|
58
|
+
align-self: flex-end;
|
|
51
59
|
display: flex;
|
|
52
60
|
flex-direction: column;
|
|
53
61
|
flex-shrink: 0;
|
|
@@ -55,7 +63,6 @@
|
|
|
55
63
|
font-size: 10px;
|
|
56
64
|
line-height: 14px;
|
|
57
65
|
color: var(--hi-color-muted);
|
|
58
|
-
background: var(--hi-color-surface);
|
|
59
66
|
|
|
60
67
|
> span {
|
|
61
68
|
white-space: nowrap;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkMarkdownRenderer from "./HkMarkdownRenderer";
|
|
5
|
+
import { applyInlineChipsToBody, inlineChipDisplay, parseInlineChips } from "./inlineChips";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Inline chip pass contract tests (the opt-in `inlineChips` mode):
|
|
9
|
+
* - the parser splits `{{ token }}` / `@ref` forms and preserves the
|
|
10
|
+
* accessible text (tokens canonicalize their spacing, refs stay raw,
|
|
11
|
+
* plain runs round-trip byte-exactly)
|
|
12
|
+
* - the boundary guards hold: emails, doubled `@`, code spans, code
|
|
13
|
+
* fences and link text never chip
|
|
14
|
+
* - the component applies the pass only when the prop is on
|
|
15
|
+
*
|
|
16
|
+
* House style: raw createApp mounts torn down after each case; the
|
|
17
|
+
* async marked/DOMPurify settle uses a bounded poll.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const mounts: { app: ReturnType<typeof createApp>; container: HTMLElement }[] = [];
|
|
21
|
+
|
|
22
|
+
function mount(props: Record<string, unknown>) {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const app = createApp({ render: () => h(HkMarkdownRenderer, props as never) });
|
|
26
|
+
app.mount(container);
|
|
27
|
+
mounts.push({ app, container });
|
|
28
|
+
return container;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function settleFor(root: HTMLElement, selector: string, tries = 100): Promise<void> {
|
|
32
|
+
for (let i = 0; i < tries; i += 1) {
|
|
33
|
+
if (root.querySelector(selector)) return;
|
|
34
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
35
|
+
await nextTick();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
for (const { app, container } of mounts) {
|
|
41
|
+
app.unmount();
|
|
42
|
+
container.remove();
|
|
43
|
+
}
|
|
44
|
+
mounts.length = 0;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("parseInlineChips", () => {
|
|
48
|
+
it("splits tokens, refs and plain runs in source order", () => {
|
|
49
|
+
expect(parseInlineChips("Badge: {{container_badge}} then @system/permission-mode")).toEqual([
|
|
50
|
+
{ kind: "text", text: "Badge: " },
|
|
51
|
+
{ kind: "token", token: "container_badge" },
|
|
52
|
+
{ kind: "text", text: " then " },
|
|
53
|
+
{ kind: "ref", raw: "@system/permission-mode" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("tokenizes with the shared grammar rules (bare ids only)", () => {
|
|
58
|
+
// Richer engine grammars stay literal — same rule as HkTemplateText.
|
|
59
|
+
expect(parseInlineChips("{{ a.b }} {{ x | filter }} {{ ok }}")).toEqual([
|
|
60
|
+
{ kind: "text", text: "{{ a.b }} {{ x | filter }} " },
|
|
61
|
+
{ kind: "token", token: "ok" },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("guards the @ boundary: emails and doubled @ stay text", () => {
|
|
66
|
+
expect(parseInlineChips("mail foo@bar.com now")).toEqual([
|
|
67
|
+
{ kind: "text", text: "mail foo@bar.com now" },
|
|
68
|
+
]);
|
|
69
|
+
expect(parseInlineChips("user@@host")).toEqual([{ kind: "text", text: "user@@host" }]);
|
|
70
|
+
expect(parseInlineChips("word@notachip")).toEqual([{ kind: "text", text: "word@notachip" }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("refs stop at punctuation and match bare handles", () => {
|
|
74
|
+
expect(parseInlineChips("see @system/x, then @demiurge.")).toEqual([
|
|
75
|
+
{ kind: "text", text: "see " },
|
|
76
|
+
{ kind: "ref", raw: "@system/x" },
|
|
77
|
+
{ kind: "text", text: ", then " },
|
|
78
|
+
{ kind: "ref", raw: "@demiurge" },
|
|
79
|
+
{ kind: "text", text: "." },
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("chip display canonicalizes tokens and keeps refs raw", () => {
|
|
84
|
+
expect(inlineChipDisplay({ kind: "token", token: "current_datetime" })).toBe("{{ current_datetime }}");
|
|
85
|
+
expect(inlineChipDisplay({ kind: "ref", raw: "@system/related-todos" })).toBe("@system/related-todos");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("applyInlineChipsToBody", () => {
|
|
90
|
+
it("chips prose but never enters code, pre or links", () => {
|
|
91
|
+
const root = document.createElement("div");
|
|
92
|
+
root.innerHTML =
|
|
93
|
+
"<p>Badge {{container_badge}} ref @system/permission-mode mail a@b.com</p>" +
|
|
94
|
+
"<pre><code>{{ keep_literal }}</code></pre>" +
|
|
95
|
+
"<p><a href=\"https://x/@at/in-url\">link @text</a></p>";
|
|
96
|
+
const chips = applyInlineChipsToBody(root);
|
|
97
|
+
const chipEls = [...root.querySelectorAll("span.hk-tpl-chip")];
|
|
98
|
+
// Two prose chips only: the token and the ref (email, code, link excluded).
|
|
99
|
+
expect(chips).toBe(2);
|
|
100
|
+
expect(chipEls.map((el) => el.textContent)).toEqual(["{{ container_badge }}", "@system/permission-mode"]);
|
|
101
|
+
expect(chipEls[0]!.hasAttribute("data-ref")).toBe(false);
|
|
102
|
+
expect(chipEls[1]!.hasAttribute("data-ref")).toBe(true);
|
|
103
|
+
// Literal surfaces untouched.
|
|
104
|
+
expect(root.querySelector("code")!.textContent).toBe("{{ keep_literal }}");
|
|
105
|
+
expect(root.querySelector("a")!.textContent).toBe("link @text");
|
|
106
|
+
// Idempotent: a second pass finds nothing new to chip.
|
|
107
|
+
expect(applyInlineChipsToBody(root)).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("HkMarkdownRenderer inlineChips", () => {
|
|
112
|
+
it("chips tokens and refs when the prop is on", async () => {
|
|
113
|
+
const container = mount({
|
|
114
|
+
content: "Badge: {{container_badge}}\n\nRead @system/related-todos first.",
|
|
115
|
+
inlineChips: true,
|
|
116
|
+
});
|
|
117
|
+
await settleFor(container, ".hk-tpl-chip");
|
|
118
|
+
const chips = [...container.querySelectorAll("span.hk-tpl-chip")];
|
|
119
|
+
expect(chips.map((el) => el.textContent)).toEqual(["{{ container_badge }}", "@system/related-todos"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders no chips by default (existing consumers unchanged)", async () => {
|
|
123
|
+
const container = mount({ content: "Badge: {{container_badge}} ref @system/related-todos" });
|
|
124
|
+
await settleFor(container, ".hk-markdown-body p");
|
|
125
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
126
|
+
await nextTick();
|
|
127
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(0);
|
|
128
|
+
expect(container.textContent).toContain("{{container_badge}}");
|
|
129
|
+
expect(container.textContent).toContain("@system/related-todos");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("guards the grammar boundaries the two implementations must share", () => {
|
|
133
|
+
// Multi-level refs, `@` at either end, a leading `_`/`-` segment.
|
|
134
|
+
expect(parseInlineChips("@a/b/c")).toEqual([{ kind: "ref", raw: "@a/b/c" }]);
|
|
135
|
+
expect(parseInlineChips("@sole")).toEqual([{ kind: "ref", raw: "@sole" }]);
|
|
136
|
+
expect(parseInlineChips("mail @")).toEqual([{ kind: "text", text: "mail @" }]);
|
|
137
|
+
expect(parseInlineChips("@_private @-x @--flag")).toEqual([
|
|
138
|
+
{ kind: "text", text: "@_private @-x @--flag" },
|
|
139
|
+
]);
|
|
140
|
+
// Never chips inside a bare URL (npm-scoped packages are common in
|
|
141
|
+
// prompt prose) — the Rust mirror guards this too.
|
|
142
|
+
expect(parseInlineChips("see https://www.npmjs.com/package/@celestia-island/hikari")).toEqual([
|
|
143
|
+
{ kind: "text", text: "see https://www.npmjs.com/package/@celestia-island/hikari" },
|
|
144
|
+
]);
|
|
145
|
+
// Carriage returns inside the braces are still the same token.
|
|
146
|
+
expect(parseInlineChips("{{\rname\r}}")).toEqual([{ kind: "token", token: "name" }]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("un-chips when the prop flips back off (same mount)", async () => {
|
|
150
|
+
// The chip pass mutates the DOM in place, so a naive watcher leaves the
|
|
151
|
+
// chips behind when the prop goes back to false — this drives the real
|
|
152
|
+
// toggle on ONE mounted instance.
|
|
153
|
+
const flag = ref(true);
|
|
154
|
+
const content = "Badge {{x}} and @a/b";
|
|
155
|
+
const container = document.createElement("div");
|
|
156
|
+
document.body.appendChild(container);
|
|
157
|
+
const app = createApp({ render: () => h(HkMarkdownRenderer, { content, inlineChips: flag.value }) });
|
|
158
|
+
app.mount(container);
|
|
159
|
+
mounts.push({ app, container });
|
|
160
|
+
|
|
161
|
+
await settleFor(container, ".hk-tpl-chip");
|
|
162
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(2);
|
|
163
|
+
|
|
164
|
+
flag.value = false;
|
|
165
|
+
await nextTick();
|
|
166
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
167
|
+
await nextTick();
|
|
168
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(0);
|
|
169
|
+
expect(container.textContent).toContain("{{x}}");
|
|
170
|
+
expect(container.textContent).toContain("@a/b");
|
|
171
|
+
|
|
172
|
+
// ...and back on, still one mount.
|
|
173
|
+
flag.value = true;
|
|
174
|
+
await nextTick();
|
|
175
|
+
await settleFor(container, ".hk-tpl-chip");
|
|
176
|
+
expect(container.querySelectorAll(".hk-tpl-chip").length).toBe(2);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -207,3 +207,41 @@
|
|
|
207
207
|
@include backdrop-filter(blur(4px));
|
|
208
208
|
z-index: 10;
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
/* ── Inline chips (opt-in `inlineChips` prop) ────────────────────────
|
|
212
|
+
Fill-template tokens (`{{ name }}`) and asset references (`@level/name`,
|
|
213
|
+
bare `@name`) chipped inside rendered prose. The look is the family's
|
|
214
|
+
`.hk-tpl-chip` (HkTemplateField.scss is canonical); duplicated here
|
|
215
|
+
SCOPED to the markdown body so a markdown-only consumer does not pull
|
|
216
|
+
the template editor's sheet. Refs take the muted variant: a reference
|
|
217
|
+
points elsewhere, it is not a fillable value, so it must not read as
|
|
218
|
+
a template token. */
|
|
219
|
+
.hk-markdown-body .hk-tpl-chip {
|
|
220
|
+
display: inline-flex;
|
|
221
|
+
align-items: center;
|
|
222
|
+
padding: 0 var(--space-6, 6px);
|
|
223
|
+
border-radius: var(--radius-sm, 4px);
|
|
224
|
+
border: 1px solid color-mix(in srgb, var(--hi-color-primary) 32%, transparent);
|
|
225
|
+
background: color-mix(in srgb, var(--hi-color-primary) 12%, transparent);
|
|
226
|
+
color: color-mix(in srgb, var(--hi-color-primary) 70%, rgb(0 0 0));
|
|
227
|
+
font-family: var(--font-mono, monospace);
|
|
228
|
+
font-size: 0.8em;
|
|
229
|
+
line-height: 1.5;
|
|
230
|
+
white-space: nowrap;
|
|
231
|
+
/* NO user-select here: rendered prose IS content (see the sheet's own
|
|
232
|
+
* `user-select(text)` policy at the top and styles/selection.scss's
|
|
233
|
+
* "markdown body" content container). The editor chip in
|
|
234
|
+
* HkTemplateField.scss disables selection for caret/click handling;
|
|
235
|
+
* copying a PROMPT BODY must keep every `{{token}}` / `@ref` in the
|
|
236
|
+
* clipboard, so this scoped copy must not carry that rule. */
|
|
237
|
+
|
|
238
|
+
[data-theme="dark"] & {
|
|
239
|
+
color: color-mix(in srgb, var(--hi-color-primary) 60%, rgb(255 255 255));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
&[data-ref] {
|
|
243
|
+
border-color: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 40%, transparent);
|
|
244
|
+
background: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 10%, transparent);
|
|
245
|
+
color: var(--hi-color-muted, #6c6c6c);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, defineComponent, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
|
2
2
|
import type { Renderer, Tokens } from "marked";
|
|
3
3
|
import HSpinner from "./HkSpinner";
|
|
4
|
+
import { applyInlineChipsToBody } from "./inlineChips";
|
|
4
5
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
5
6
|
import { asIndexable } from "../utils/typing";
|
|
6
7
|
import "./HkMarkdownRenderer.scss";
|
|
@@ -118,10 +119,26 @@ export default defineComponent({
|
|
|
118
119
|
content: { type: String, required: true },
|
|
119
120
|
loading: { type: Boolean, default: false },
|
|
120
121
|
plain: { type: Boolean, default: false },
|
|
122
|
+
/**
|
|
123
|
+
* Opt-in inline chip pass: fill-template tokens (`{{ name }}`) and
|
|
124
|
+
* asset references (`@level/name`, bare `@name`) inside the
|
|
125
|
+
* rendered prose chip up as `.hk-tpl-chip` spans (refs carry
|
|
126
|
+
* `data-ref`, the muted variant). Applied AFTER sanitize, as pure
|
|
127
|
+
* DOM surgery with `textContent` only — the pass cannot introduce
|
|
128
|
+
* markup, and `code`/`pre`/`a` subtrees are skipped so literal
|
|
129
|
+
* code and link text stay verbatim. Off by default: every existing
|
|
130
|
+
* consumer renders exactly as before.
|
|
131
|
+
*/
|
|
132
|
+
inlineChips: { type: Boolean, default: false },
|
|
121
133
|
},
|
|
122
134
|
setup(props) {
|
|
123
135
|
const renderedHtml = ref("");
|
|
124
136
|
const parseError = ref(false);
|
|
137
|
+
// Monotonic parse counter: `renderedHtml` does not change when the
|
|
138
|
+
// SAME body is re-parsed for a prop flip, so a watch on it would never
|
|
139
|
+
// fire — and the chip pass mutates the DOM in place, leaving chips
|
|
140
|
+
// behind after `inlineChips` goes back to false.
|
|
141
|
+
const parseSeq = ref(0);
|
|
125
142
|
|
|
126
143
|
async function parse() {
|
|
127
144
|
if (!props.content) {
|
|
@@ -138,11 +155,13 @@ export default defineComponent({
|
|
|
138
155
|
} catch {
|
|
139
156
|
parseError.value = true;
|
|
140
157
|
renderedHtml.value = renderPlain(props.content);
|
|
158
|
+
} finally {
|
|
159
|
+
parseSeq.value += 1;
|
|
141
160
|
}
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
watch(
|
|
145
|
-
() => [props.content, props.plain],
|
|
164
|
+
() => [props.content, props.plain, props.inlineChips],
|
|
146
165
|
() => { void parse(); },
|
|
147
166
|
{ immediate: true },
|
|
148
167
|
);
|
|
@@ -168,8 +187,20 @@ export default defineComponent({
|
|
|
168
187
|
}
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
watch(
|
|
172
|
-
void nextTick(
|
|
190
|
+
watch(parseSeq, () => {
|
|
191
|
+
void nextTick(() => {
|
|
192
|
+
const body = bodyRef.value;
|
|
193
|
+
if (!body) return;
|
|
194
|
+
// Re-seat the DOM on the pristine rendered HTML first: the chip
|
|
195
|
+
// pass mutates it in place, so a prop flip must start from the
|
|
196
|
+
// un-chipped markup (assigning the string Vue already patched is
|
|
197
|
+
// idempotent; when the body did not change it is the only write).
|
|
198
|
+
body.innerHTML = renderedHtml.value;
|
|
199
|
+
// The chip pass runs BEFORE the overlay scan: it splits text
|
|
200
|
+
// nodes, so the scan must reconcile against the final DOM.
|
|
201
|
+
if (props.inlineChips) applyInlineChipsToBody(body);
|
|
202
|
+
syncOverlayScrollbars();
|
|
203
|
+
});
|
|
173
204
|
});
|
|
174
205
|
|
|
175
206
|
onBeforeUnmount(() => {
|
|
@@ -54,12 +54,35 @@ describe("HkScreenIdentifyCard layout contract", () => {
|
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
it("parks the card in every corner through placement attributes", () => {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// Each non-default corner must OVERRIDE the exact edges that move it
|
|
58
|
+
// there — an earlier round flagged this guard as presence-only, so a
|
|
59
|
+
// placement block that forgot its edge rule stayed green.
|
|
60
|
+
const corners: Array<[string, RegExp[]]> = [
|
|
61
|
+
// End-side corners must ALSO release the logical start edge — an
|
|
62
|
+
// over-constrained inset would resolve to the start edge and leave
|
|
63
|
+
// the card bottom-left no matter the placement (R1 V-3 probe).
|
|
64
|
+
["bottom-right", [/inset-inline-end:\s*var\(--space-16/, /inset-inline-start:\s*auto/]],
|
|
65
|
+
["top-left", [/top:\s*var\(--space-16/, /bottom:\s*auto/]],
|
|
66
|
+
[
|
|
67
|
+
"top-right",
|
|
68
|
+
[
|
|
69
|
+
/inset-inline-end:\s*var\(--space-16/,
|
|
70
|
+
/inset-inline-start:\s*auto/,
|
|
71
|
+
/top:\s*var\(--space-16[\s\S]*bottom:\s*auto/,
|
|
72
|
+
],
|
|
73
|
+
],
|
|
74
|
+
];
|
|
75
|
+
for (const [placement, edges] of corners) {
|
|
76
|
+
const block = ruleBlock(`&[data-placement="${placement}"]`);
|
|
77
|
+
for (const edge of edges) {
|
|
78
|
+
expect(block).toMatch(edge);
|
|
79
|
+
}
|
|
59
80
|
}
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
81
|
+
// The default corner's own block must not have been turned into a
|
|
82
|
+
// catch-all: it keeps the bottom-left seat via the logical edge only.
|
|
83
|
+
const card = ruleBlock(".hk-screen-identify");
|
|
84
|
+
expect(card).toMatch(/inset-inline-start:\s*var\(--space-16/);
|
|
85
|
+
expect(card).toMatch(/bottom:\s*var\(--space-16/);
|
|
63
86
|
});
|
|
64
87
|
|
|
65
88
|
it("rides hikari text tokens for every font size except the numeral", () => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the HkCalendarHeatmap weekday rail geometry
|
|
3
|
+
* (2026-11-05 user report from the chest quota card: the Monday/Sunday
|
|
4
|
+
* labels sat one marker strip too high, and a hard-coded surface fill
|
|
5
|
+
* painted a visible stripe behind them).
|
|
6
|
+
*
|
|
7
|
+
* jsdom does no layout, so the row alignment itself cannot be asserted
|
|
8
|
+
* here; these pins hold the two sheet decisions the fix rests on:
|
|
9
|
+
*
|
|
10
|
+
* 1. `align-self: flex-end` — the -cells box is one marker strip taller
|
|
11
|
+
* than the grid (the month labels ride above it); a stretched rail
|
|
12
|
+
* starts its seven rows on the markers. The rail's natural height
|
|
13
|
+
* (7 cells + 6 gaps) equals the grid's exactly, so end-alignment is
|
|
14
|
+
* row-exact. This also assumes the grid stays the LAST child of
|
|
15
|
+
* -cells with no trailing padding — the third pin below.
|
|
16
|
+
* 2. no sticky mask — a hard-coded surface can't match every host
|
|
17
|
+
* card, and sticky without a mask lets cells slide beneath the
|
|
18
|
+
* labels during horizontal scroll. The rail scrolls with the
|
|
19
|
+
* calendar (the GitHub convention).
|
|
20
|
+
*/
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const sheet = readFileSync(join(here, "HkCalendarHeatmap.scss"), "utf-8");
|
|
28
|
+
|
|
29
|
+
// The `&-rail` rule up to its closing brace (two-space indent inside
|
|
30
|
+
// the `.hk-calendar-heatmap` block) — keeps the assertions scoped to
|
|
31
|
+
// the rail (a background elsewhere in the sheet is legitimate).
|
|
32
|
+
const rail = sheet.match(/&-rail \{[\s\S]*?\n \}/)?.[0] ?? "";
|
|
33
|
+
// Same for `-cells`, whose last-child geometry the alignment relies on.
|
|
34
|
+
const cells = sheet.match(/&-cells \{[\s\S]*?\n \}/)?.[0] ?? "";
|
|
35
|
+
|
|
36
|
+
describe("HkCalendarHeatmap weekday rail contract", () => {
|
|
37
|
+
it("extracts the rail rule from the sheet", () => {
|
|
38
|
+
expect(rail, "the &-rail rule must exist").toContain("&-rail {");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("bottom-aligns the rail with the grid, not the markers row", () => {
|
|
42
|
+
expect(rail, "rail must carry align-self: flex-end").toContain(
|
|
43
|
+
"align-self: flex-end",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("carries no sticky mask (no hard-coded surface fill)", () => {
|
|
48
|
+
expect(rail, "rail must not paint a hard-coded surface").not.toContain(
|
|
49
|
+
"background:",
|
|
50
|
+
);
|
|
51
|
+
expect(rail, "rail must not go sticky").not.toContain("position: sticky");
|
|
52
|
+
expect(rail, "sticky's scroll pin has no business left behind").not.toContain(
|
|
53
|
+
"z-index",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("keeps the grid the flush last child of -cells", () => {
|
|
58
|
+
expect(cells, "the &-cells rule must exist").toContain("&-cells {");
|
|
59
|
+
expect(cells, "trailing padding would break the end-alignment").not.toContain(
|
|
60
|
+
"padding",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inlineChips — the read-only chip pass for markdown-embedded prompt
|
|
3
|
+
* text (`HkMarkdownRenderer`'s opt-in `inlineChips` mode).
|
|
4
|
+
*
|
|
5
|
+
* Prompt assets (entelecheia's five prompt levels) embed two reference
|
|
6
|
+
* forms in their prose:
|
|
7
|
+
* - fill-template tokens `{{ name }}` — the shared fill-template
|
|
8
|
+
* grammar (`templateGrammar.ts` parses them; only bare
|
|
9
|
+
* `[A-Za-z0-9_]+` identifiers are tokens, everything richer stays
|
|
10
|
+
* literal);
|
|
11
|
+
* - asset references `@level/name` (`@system/permission-mode`) and
|
|
12
|
+
* bare `@name` handles — the engine's cross-asset mention form.
|
|
13
|
+
*
|
|
14
|
+
* This module splits a plain-text run into literal text and chip
|
|
15
|
+
* segments; the caller renders chips as `.hk-tpl-chip` spans (the
|
|
16
|
+
* family's chip look). Chips preserve the REFERENCE, not the exact
|
|
17
|
+
* bytes: tokens render the canonical `{{ name }}` spelling (so `{{name}}`
|
|
18
|
+
* displays as `{{ name }}` — the same rule HkTemplateText applies, and
|
|
19
|
+
* `parseTemplate`/`serializeTemplate` remain byte-exact for the value
|
|
20
|
+
* itself) while refs render their raw matched text.
|
|
21
|
+
*
|
|
22
|
+
* Boundary guards (an `@` must NOT chip):
|
|
23
|
+
* - preceded by a word character (`foo@bar.com` — emails stay text);
|
|
24
|
+
* - preceded by another `@` (`user@@host` stays text);
|
|
25
|
+
* refs never absorb trailing punctuation (`.`/`'`/`,` are not in the
|
|
26
|
+
* charset) and never match inside `code`/`pre`/links — the renderer's
|
|
27
|
+
* DOM walk skips those subtrees.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { parseTemplate, formatTokenDisplay } from "./templateGrammar";
|
|
31
|
+
|
|
32
|
+
export interface HkInlineChipTextSegment {
|
|
33
|
+
kind: "text";
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HkInlineChipTokenSegment {
|
|
38
|
+
kind: "token";
|
|
39
|
+
/** The bare identifier (`container_badge`). */
|
|
40
|
+
token: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface HkInlineChipRefSegment {
|
|
44
|
+
kind: "ref";
|
|
45
|
+
/** The exact raw matched text (`@system/permission-mode`). */
|
|
46
|
+
raw: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type HkInlineChipSegment =
|
|
50
|
+
| HkInlineChipTextSegment
|
|
51
|
+
| HkInlineChipTokenSegment
|
|
52
|
+
| HkInlineChipRefSegment;
|
|
53
|
+
|
|
54
|
+
/** `@ref` grammar: `@` (not preceded by a word char or another `@`),
|
|
55
|
+
* one path segment, then `/segment` repeats. Punctuation ends the
|
|
56
|
+
* match naturally (dot and quote are outside the charset). */
|
|
57
|
+
const REF_RE = /(?<![A-Za-z0-9_@])@[A-Za-z0-9][A-Za-z0-9_-]*(?:\/[A-Za-z0-9][A-Za-z0-9_-]*)*/g;
|
|
58
|
+
|
|
59
|
+
/** Split literal text runs into text + `@ref` segments (tokens are
|
|
60
|
+
* already extracted by the template pass before this runs). */
|
|
61
|
+
function splitRefs(text: string, push: (seg: HkInlineChipSegment) => void): void {
|
|
62
|
+
let cursor = 0;
|
|
63
|
+
REF_RE.lastIndex = 0;
|
|
64
|
+
for (let m = REF_RE.exec(text); m; m = REF_RE.exec(text)) {
|
|
65
|
+
if (m.index > cursor) {
|
|
66
|
+
push({ kind: "text", text: text.slice(cursor, m.index) });
|
|
67
|
+
}
|
|
68
|
+
// URL guard: an `@` inside a bare URL is part of the URL. marked
|
|
69
|
+
// autolinks most of these (and `<a>` is a chip skip surface), but the
|
|
70
|
+
// rule must not depend on the engine's autolink heuristic — npm-scoped
|
|
71
|
+
// package URLs are common in prompt prose.
|
|
72
|
+
let tokenStart = m.index;
|
|
73
|
+
while (tokenStart > 0 && !/\s/.test(text[tokenStart - 1]!)) tokenStart -= 1;
|
|
74
|
+
if (text.slice(tokenStart, m.index).includes("://")) {
|
|
75
|
+
push({ kind: "text", text: m[0] });
|
|
76
|
+
cursor = m.index + m[0].length;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
push({ kind: "ref", raw: m[0] });
|
|
80
|
+
cursor = m.index + m[0].length;
|
|
81
|
+
}
|
|
82
|
+
if (cursor < text.length) {
|
|
83
|
+
push({ kind: "text", text: text.slice(cursor) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split a plain-text run into literal text, `{{ token }}` and `@ref`
|
|
89
|
+
* segments, in source order. The empty string parses to an empty list;
|
|
90
|
+
* a run with no chip forms round-trips as one text segment.
|
|
91
|
+
*/
|
|
92
|
+
export function parseInlineChips(text: string): HkInlineChipSegment[] {
|
|
93
|
+
const out: HkInlineChipSegment[] = [];
|
|
94
|
+
const push = (seg: HkInlineChipSegment) => {
|
|
95
|
+
// Merge adjacent text runs so the caller can cheaply detect the
|
|
96
|
+
// "nothing to chip" case (one text segment).
|
|
97
|
+
const last = out[out.length - 1];
|
|
98
|
+
if (seg.kind === "text" && last?.kind === "text") {
|
|
99
|
+
last.text += seg.text;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
out.push(seg);
|
|
103
|
+
};
|
|
104
|
+
for (const seg of parseTemplate(text)) {
|
|
105
|
+
if (seg.kind === "token") {
|
|
106
|
+
push({ kind: "token", token: seg.token });
|
|
107
|
+
} else {
|
|
108
|
+
splitRefs(seg.text, push);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The canonical chip text for a segment (tokens spell `{{ name }}`,
|
|
115
|
+
* refs keep their raw form — both copy back to the source). */
|
|
116
|
+
export function inlineChipDisplay(seg: HkInlineChipTokenSegment | HkInlineChipRefSegment): string {
|
|
117
|
+
return seg.kind === "token" ? formatTokenDisplay(seg.token) : seg.raw;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Subtrees the chip pass must never enter: literal code (the token is
|
|
121
|
+
* documentation there, not a live form), links (the `@` may be part of
|
|
122
|
+
* a URL or handle path the anchor already contextualizes) and chips
|
|
123
|
+
* themselves (idempotency — a second pass must not re-chip). */
|
|
124
|
+
const CHIP_SKIP_SELECTOR = "code, pre, a, script, style, textarea, .hk-tpl-chip";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walk `root`'s text nodes and wrap chip forms in `.hk-tpl-chip`
|
|
128
|
+
* spans. Pure DOM surgery on already-sanitized markup: the only nodes
|
|
129
|
+
* created are spans + text, built with `textContent` (no HTML
|
|
130
|
+
* parsing), so the pass cannot reintroduce markup. Tokens get the
|
|
131
|
+
* canonical `{{ name }}` spelling; refs carry `data-ref` (the muted
|
|
132
|
+
* chip variant) and their exact raw text.
|
|
133
|
+
*
|
|
134
|
+
* Returns the number of chips created (0 when the mode is off or the
|
|
135
|
+
* body carries no chip forms) — a cheap assertion hook for tests.
|
|
136
|
+
*/
|
|
137
|
+
export function applyInlineChipsToBody(root: HTMLElement | null | undefined): number {
|
|
138
|
+
if (!root) return 0;
|
|
139
|
+
const walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
|
|
140
|
+
const targets: Text[] = [];
|
|
141
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
142
|
+
const value = node.nodeValue ?? "";
|
|
143
|
+
// Cheap prefilter: chips only ever introduce `{` or `@`.
|
|
144
|
+
if (!/[{@]/.test(value)) continue;
|
|
145
|
+
const parent = (node as Text).parentElement;
|
|
146
|
+
if (parent?.closest?.(CHIP_SKIP_SELECTOR)) continue;
|
|
147
|
+
targets.push(node as Text);
|
|
148
|
+
}
|
|
149
|
+
let chips = 0;
|
|
150
|
+
for (const node of targets) {
|
|
151
|
+
const segments = parseInlineChips(node.nodeValue ?? "");
|
|
152
|
+
if (segments.length === 0 || segments.every((seg) => seg.kind === "text")) continue;
|
|
153
|
+
const frag = document.createDocumentFragment();
|
|
154
|
+
for (const seg of segments) {
|
|
155
|
+
if (seg.kind === "text") {
|
|
156
|
+
frag.append(seg.text);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
chips += 1;
|
|
160
|
+
const chip = document.createElement("span");
|
|
161
|
+
chip.className = "hk-tpl-chip";
|
|
162
|
+
chip.setAttribute("data-static", "");
|
|
163
|
+
chip.textContent = inlineChipDisplay(seg);
|
|
164
|
+
if (seg.kind === "ref") chip.setAttribute("data-ref", "");
|
|
165
|
+
frag.append(chip);
|
|
166
|
+
}
|
|
167
|
+
node.replaceWith(frag);
|
|
168
|
+
}
|
|
169
|
+
return chips;
|
|
170
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -913,6 +913,17 @@ export {
|
|
|
913
913
|
type HkTemplateSegment,
|
|
914
914
|
type HkTemplateTokenDef,
|
|
915
915
|
} from "./components/templateGrammar";
|
|
916
|
+
// The chip grammar lives beside the fill-template grammar it reuses; both
|
|
917
|
+
// are exported so hosts can reason about (and test) reference forms
|
|
918
|
+
// without mounting a renderer.
|
|
919
|
+
export {
|
|
920
|
+
parseInlineChips,
|
|
921
|
+
inlineChipDisplay,
|
|
922
|
+
applyInlineChipsToBody,
|
|
923
|
+
type HkInlineChipSegment,
|
|
924
|
+
type HkInlineChipTokenSegment,
|
|
925
|
+
type HkInlineChipRefSegment,
|
|
926
|
+
} from "./components/inlineChips";
|
|
916
927
|
export { HkErrorLanding as HkErrorLanding } from "./components/HkErrorLanding";
|
|
917
928
|
export { HkErrorReportingOverlay as HkErrorReportingOverlay } from "./errorReporting";
|
|
918
929
|
export { HkAdminShell as HkAdminShell } from "./components/HkAdminShell";
|
|
@@ -514,6 +514,20 @@ describe("solar IP insight (the strategy dialog's readout)", () => {
|
|
|
514
514
|
expect(mod.solarIpInsightFresh(insight.fetchedAt + 11 * 60 * 1000)).toBe(false);
|
|
515
515
|
});
|
|
516
516
|
|
|
517
|
+
it("a probe and the ip cascade's geo resolution racing share one lookup GET", async () => {
|
|
518
|
+
mod.setSolarLocationStrategy("ip");
|
|
519
|
+
mod.setGeolocationProvider(async () => null);
|
|
520
|
+
const fetchMock = lookupFetch();
|
|
521
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
522
|
+
// The dialog probe and the theme clock's resolution race the same
|
|
523
|
+
// lookup — the body fetch dedupes them into one network request and
|
|
524
|
+
// both consumers converge on the same insight.
|
|
525
|
+
await Promise.all([mod.probeSolarIpInsight(), mod.getGeolocation()]);
|
|
526
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
527
|
+
expect(mod.solarIpInsight.value?.ip).toBe("203.0.113.7");
|
|
528
|
+
expect(mod.solarIpProbeState.value).toBe("ok");
|
|
529
|
+
});
|
|
530
|
+
|
|
517
531
|
it("the ip strategy's own geo resolution records the insight from the same body", async () => {
|
|
518
532
|
mod.setSolarLocationStrategy("ip");
|
|
519
533
|
mod.setGeolocationProvider(async () => null);
|
|
@@ -330,16 +330,30 @@ const GEO_API_TIMEOUT_MS = 4000;
|
|
|
330
330
|
/** One fetch of the lookup body with the shared timeout — the single
|
|
331
331
|
* network path for both consumers (the "ip" strategy's geo resolution
|
|
332
332
|
* and the strategy dialog's insight readout), so narrowing, abort
|
|
333
|
-
* handling and the no-store contract live in exactly one place.
|
|
333
|
+
* handling and the no-store contract live in exactly one place.
|
|
334
|
+
* Concurrent callers SHARE the in-flight GET (a probe and an apply's
|
|
335
|
+
* geo resolution racing each other used to issue two identical
|
|
336
|
+
* requests); failures propagate to every joiner. */
|
|
337
|
+
let ipBodyInflight: Promise<unknown> | null = null;
|
|
338
|
+
|
|
334
339
|
async function fetchIpLookupBody(): Promise<unknown> {
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
340
|
+
if (ipBodyInflight) return ipBodyInflight;
|
|
341
|
+
const inflight = (async () => {
|
|
342
|
+
const controller = new AbortController();
|
|
343
|
+
const timer = scheduleCronAfter(() => controller.abort(), GEO_API_TIMEOUT_MS);
|
|
344
|
+
return fetch(GEO_API_URL, {
|
|
345
|
+
signal: controller.signal,
|
|
346
|
+
cache: "no-store",
|
|
347
|
+
})
|
|
348
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
349
|
+
.finally(() => timer.disconnect());
|
|
350
|
+
})();
|
|
351
|
+
ipBodyInflight = inflight;
|
|
352
|
+
try {
|
|
353
|
+
return await inflight;
|
|
354
|
+
} finally {
|
|
355
|
+
ipBodyInflight = null;
|
|
356
|
+
}
|
|
343
357
|
}
|
|
344
358
|
|
|
345
359
|
/** The trimmed-string guard for the untrusted lookup body: only a
|