@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.283",
3
+ "version": "0.55.285",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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); sticky so it survives horizontal
46
- // scroll on narrow screens.
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
- position: sticky;
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(renderedHtml, () => {
172
- void nextTick(syncOverlayScrollbars);
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
- for (const placement of ["bottom-right", "top-left", "top-right"]) {
58
- expect(scss).toContain(`&[data-placement="${placement}"]`);
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
- // Left/right use logical properties so RTL mirrors the pair while
61
- // the card stays at the same corner of the glass.
62
- expect(ruleBlock(".hk-screen-identify")).toMatch(/inset-inline-start:/);
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
- const controller = new AbortController();
336
- const timer = scheduleCronAfter(() => controller.abort(), GEO_API_TIMEOUT_MS);
337
- return fetch(GEO_API_URL, {
338
- signal: controller.signal,
339
- cache: "no-store",
340
- })
341
- .then((r) => (r.ok ? r.json() : null))
342
- .finally(() => timer.disconnect());
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