Skip to content

Commit 002a652

Browse files
committed
Apply the final review fixes: value-out API, type exports, d.ts hygiene
The input text becomes a bindable value prop and acceptNextWord fires an onaccept callback with the accepted word and resulting text — the first integration need of any host app. CompleteFn, CaretState and AcceptDetail are exported from the package root (CaretState moves to input/types.ts so svelte-package emits it). Shipped .d.ts files no longer carry CSS side-effect imports (scripts/fix-dts.mjs runs inside npm run package), and pack-smoke now compiles a strict consumer with skipLibCheck:false — including the README complete() sample verbatim, which is now typed. The autofocus mount effect reads its prop untracked so later toggles cannot steal focus; the CompletionCache eviction guard accepts empty-string keys; postcss graduates from a hoisting accident to a declared devDependency. Docs are brought back to the truth: reduced-motion behavior stated as it really is, the waiting-snippet claim scoped to the thinking indicator, minChars strict-greater semantics documented, the npm hero image absolutized, and the stale legacy.css bundle-order comment in core.css rewritten.
1 parent 3f9e07e commit 002a652

13 files changed

Lines changed: 475 additions & 52 deletions

CONTRACT.md

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const meta: EffectMeta = {
4747
};
4848
```
4949

50-
`defineEffect(effect)` is an identity helper: it returns its argument unchanged and, in dev, warns
50+
`defineEffect(effect)` is an identity helper: it returns its argument unchanged and warns at runtime
5151
if `meta.name` is not kebab-case (a malformed name silently matches no CSS). The `name` you choose is
5252
the value that lands in `data-glow` and that every selector in your `index.css` keys on — it MUST be
5353
globally unique across the effects a given app loads.
@@ -285,11 +285,17 @@ honored.
285285

286286
Known limitation for `0.1.0`, recorded here so it is not mistaken for a bug. `core.css` carries a
287287
`@media (prefers-reduced-motion: reduce)` block that sets `animation: none` on the base anatomy
288-
(`.field::before`, `.caret`, `.torch-beam`, `.beam`, `.next-word`, `.tail`). Those rules are (0,2,0),
289-
so a per-effect module's own animation — set at `.stage[data-glow='x'] .field[data-glow] .beam-veil`
290-
(0,5,0) — **overrides** the reduced-motion kill. Net effect today: the default torch layers quiet
291-
down under reduced-motion, but each effect's signature animations keep running (a deliberate
292-
visual-identity choice, not real support).
288+
(`.field::before`, `.caret.active`, `.caret.thinking`, `.torch-beam`, `.beam`, `.next-word`,
289+
`.tail`). Those rules are only (0,1,0)/(0,2,0), so they win **only where nothing outranks them**.
290+
291+
What actually stops today: the **caret blink/think** (`.caret.active`/`.caret.thinking` at (0,2,0),
292+
tied by the media rule which comes later) and the **word/tail entrances** (`.next-word`/`.tail` at
293+
(0,1,0), same story). What keeps animating: the **torch beams** and the **field wash** — the anatomy
294+
that drives them is applied at `.stage[data-glow] .field[data-glow] .torch-haze` (0,5,0) and the
295+
field-flow `::before` at ≥(0,4,1), both of which outrank the media kill. And a per-effect module's
296+
own animation — set at the same (0,5,0) band — likewise **overrides** the reduced-motion kill. Net
297+
effect: caret and entrance motion quiet down, but the beams, field wash, and each effect's signature
298+
animation keep running (a deliberate visual-identity choice for now, not full support).
293299

294300
**If your effect must honor reduced-motion**, add your own scoped kill:
295301

@@ -471,11 +477,14 @@ Replaces the six `.torch-*` beams inside `.caret-origin-glow`:
471477
```
472478

473479
**Gating (important):** the `waiting` snippet renders **only where the default torch stack would**
474-
i.e. only when the caret glow is shown. That means it renders when a suggestion is showing
475-
(`ready`, `data-mode='ready'`) or while thinking with `loadingGlow='torch'`
476-
(`data-mode='waiting'`). With `loadingGlow='field'` or `loadingGlow='none'`, the `.caret-origin-glow`
477-
wrapper is not rendered at all, so **neither the snippet nor the default beams appear**style the
478-
thinking indicator via the field instead (§6).
480+
i.e. only inside the caret glow, and only in its `data-mode='waiting'` form: the `waiting` state
481+
with `loadingGlow='torch'`. While **thinking** with `loadingGlow='field'` or `loadingGlow='none'`,
482+
no `data-mode='waiting'` caret glow is rendered, so **neither the snippet nor the default beams
483+
appear** as the thinking indicator — style the field instead (§6).
484+
485+
Note this scopes only the *thinking* indicator. When a suggestion is showing (`ready`), the caret
486+
glow still renders in its `data-mode='ready'` form regardless of `loadingGlow` — but that READY
487+
subtree is not what the `waiting` snippet targets; the snippet is for the thinking phase.
479488

480489
---
481490

@@ -500,6 +509,13 @@ cache, monotonic stale-response guard, and the shared `AbortSignal` (which fires
500509
superseded or the input blurs — honor it). Return the raw completion string; the component normalizes
501510
it exactly like an endpoint response. `demo` mode never calls `complete`.
502511

512+
**Cache-invalidation caveat.** The component clears its LRU cache when the completion *source* key
513+
changes, but that key tracks `complete` by **presence** (`fn` vs `none`), not by identity. Swapping
514+
one custom `complete` function for a *different* one (both truthy) does **not** clear the cache, so a
515+
key already cached by the previous function is still served from cache. If two custom sources can
516+
return different completions for the same input, force a fresh fetch by also changing `endpoint` or
517+
`completionMode`, or by remounting.
518+
503519
Custom effect names + persisted settings: an app that lets users pick effects should validate a
504520
persisted name against the effects it actually registered (built-ins from `GLOW_VARIANTS` plus its
505521
own), falling back to `torch` for an unknown name — the component itself accepts any string for

README.md

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
![light-input demo](static/demo.gif)
1+
![light-input demo](https://raw.githubusercontent.com/ui4ai/light-input/main/static/demo.gif)
22

33
# @ui4ai/light-input
44

@@ -92,16 +92,22 @@ in one prop. The explicit form is the escape hatch — an explicit `glow` or `la
9292
machinery. When `complete` is set it replaces the `endpoint` fetch for `llm` mode:
9393

9494
```svelte
95-
<script>
96-
const complete = async (text, { signal }) => {
95+
<script lang="ts">
96+
import { GhostInput, type CompleteFn } from '@ui4ai/light-input';
97+
98+
const complete: CompleteFn = async (text, { signal }) => {
9799
const r = await fetch('/my/endpoint', { method: 'POST', body: text, signal });
98-
return (await r.json()).completion; // raw continuation; the component normalizes it
100+
const data = (await r.json()) as { completion: string };
101+
return data.completion; // raw continuation; the component normalizes it
99102
};
100103
</script>
101104
102105
<GhostInput {complete} />
103106
```
104107

108+
`CompleteFn` is exported from the package root, so your `complete` type-checks against the exact
109+
signature the component expects (`(text: string, ctx: { signal: AbortSignal }) => Promise<string>`).
110+
105111
**Thinking indicator.** `loadingGlow` chooses where the "thinking" light shows while the model works:
106112
`"torch"` (a weak flashlight warming up from the caret — the default), `"field"` (the whole input
107113
warms), or `"none"` (invisible loading).
@@ -127,9 +133,12 @@ indicator's contents, and every other node stays byte-identical to the built-in
127133
The `waiting` snippet renders only where the default torch stack would — so with `loadingGlow="none"`
128134
neither the snippet nor the default beams appear (style the field instead).
129135

130-
**Other props:** `theme` (`"dark"` | `"light"`), `lightFlow` (keep the flow/particle motion drifting;
131-
`false` for a steadier reveal), `completionMode` (`"llm"` | `"demo"`), `minChars` (2), `maxChars`
132-
(2000), `debounceMs` (260), `placeholder`, `touchAccept` (the mobile accept button).
136+
**Other props:** `value` (two-way bindable input text — `bind:value`), `onaccept` (callback
137+
`({ word, text }) => void`, fired when a word is accepted), `theme` (`"dark"` | `"light"`),
138+
`lightFlow` (keep the flow/particle motion drifting; `false` for a steadier reveal), `completionMode`
139+
(`"llm"` | `"demo"`), `minChars` (2 — compared **strictly greater-than**, so a prediction needs
140+
`value.trim().length > minChars`, i.e. 3+ non-space characters at the default), `maxChars` (2000),
141+
`debounceMs` (260), `placeholder`, `touchAccept` (the mobile accept button).
133142

134143
## Authoring your own effect
135144

@@ -153,8 +162,8 @@ export default defineEffect({ meta }); // add `layer` for a DOM effect
153162
<GhostInput effect={myEffect} />
154163
```
155164

156-
`defineEffect` returns its argument unchanged and, in dev, warns if `meta.name` is not kebab-case (the
157-
name becomes the `data-glow` selector every rule keys on).
165+
`defineEffect` returns its argument unchanged and warns at runtime if `meta.name` is not kebab-case
166+
(the name becomes the `data-glow` selector every rule keys on).
158167

159168
## The effects
160169

@@ -209,15 +218,16 @@ This repo is both the published library (`src/lib`) and its demo app (`src/route
209218
project.
210219

211220
```
221+
CONTRACT.md the effect-authoring reference (repo root; also shipped in the tarball)
212222
src/lib/ the package (built by @sveltejs/package into dist/)
213223
GhostInput.svelte the component
214224
input/ the completion state machine (debounce, cache, abort, glyphs, selection)
225+
types.ts public snippet/callback types (CaretState, AcceptDetail)
215226
effects/
216227
core.css the shared anatomy every effect builds on
217228
<name>/ one folder per effect: index.css, meta.ts, index.ts, optional Layer.svelte
218229
index.ts the ALL-39 registry (the ./effects entry)
219230
types.ts, define.ts the effect contract types + defineEffect()
220-
CONTRACT.md the effect-authoring reference (also shipped in the tarball)
221231
src/routes/ the demo app (landing gallery + a local OpenRouter proxy, not packaged)
222232
scripts/ packaging + QA tooling (below)
223233
```
@@ -254,13 +264,17 @@ the `@ui4ai` scope.
254264

255265
### Known limitation — `prefers-reduced-motion`
256266

257-
The core anatomy honors `prefers-reduced-motion: reduce` for the default (torch) layers, but a
258-
per-effect module's own infinite animations **intentionally override it** — a `data-glow`-scoped
259-
module rule at (0,5,0) beats the media query's base-anatomy `animation: none` at (0,2,0). This is a
260-
deliberate parity choice for this `0.1.0`: the effects keep their visual identity, and full reduced-
261-
motion support (adding scoped `animation: none` under the media query for each module) is tracked as a
262-
follow-up. If motion sensitivity matters for your app, prefer `torch` or set `lightFlow={false}` to
263-
calm the flow/particle layers.
267+
Reduced-motion support is **partial** in `0.1.0`. `core.css` carries a
268+
`@media (prefers-reduced-motion: reduce)` block, but its `animation: none` rules are low-specificity
269+
(0,1,0)/(0,2,0), so they only win where nothing outranks them. In practice today: the **caret
270+
blink/think** and the **word / tail entrance** animations stop, but the **torch beams** and the
271+
**field wash** keep animating — the anatomy that drives them lives at `data-glow`-scoped (0,5,0)
272+
rules that outrank the media kill (and per-effect modules layer their own (0,5,0) animations on top).
273+
274+
So an effect keeps its visual identity under reduced-motion rather than going still. Full parity —
275+
emitting a scoped `animation: none` at (0,5,0) under the media query for the core beams and every
276+
module — is a tracked follow-up. If motion sensitivity matters for your app, prefer `torch` and set
277+
`lightFlow={false}` to calm the flow/particle layers.
264278

265279
## License
266280

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
"test": "vitest run",
7575
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
7676
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
77-
"package": "svelte-kit sync && svelte-package && publint",
77+
"package": "svelte-kit sync && svelte-package && node scripts/fix-dts.mjs && publint",
7878
"prepublishOnly": "npm run check && npm test && npm run package",
7979
"pack-smoke": "node scripts/pack-smoke.mjs",
8080
"qa:oracle": "node scripts/qa/style-oracle.mjs",
@@ -92,6 +92,7 @@
9292
"@types/node": "^25.9.3",
9393
"happy-dom": "^20.10.6",
9494
"playwright": "^1.61.1",
95+
"postcss": "^8.5.15",
9596
"publint": "^0.3.21",
9697
"svelte": "^5.56.3",
9798
"svelte-check": "^4.6.0",

scripts/fix-dts.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env node
2+
// scripts/fix-dts.mjs
3+
//
4+
// Post-`svelte-package` step: strip side-effect CSS imports from the emitted declaration files.
5+
//
6+
// svelte-package copies the component/effect source imports verbatim into the generated `.d.ts`,
7+
// including the side-effect CSS imports that carry the skin at runtime:
8+
//
9+
// dist/GhostInput.svelte.d.ts -> import './effects/core.css';
10+
// dist/effects/<name>/index.d.ts -> import '../core.css'; + import './index.css';
11+
//
12+
// A `.d.ts` is type-checked by any consumer whose tsconfig has `skipLibCheck: false`. TypeScript
13+
// then tries to resolve those `.css` specifiers as modules and fails — TS2882 ("module 'X' can only
14+
// be default-imported using esModuleInterop" is the sibling; the concrete failure here is TS2307
15+
// "cannot find module './index.css'") — a hard error the consumer never asked for. The CSS is a
16+
// pure runtime side effect; it has no place in the declaration graph. So we delete exactly those
17+
// side-effect-import lines from every shipped `.d.ts`.
18+
//
19+
// SAFETY: only whole lines that ARE a bare side-effect CSS import are removed
20+
// (`import '<path>.css';` / `import "<path>.css";`, leading whitespace allowed). Lines that merely
21+
// mention a `.css` path inside a comment or a JSDoc block (e.g. the `defineEffect` example in
22+
// effects/define.d.ts) are left untouched — the regex requires the line to START with `import`.
23+
// `.d.ts.map` files are irrelevant (they map declarations, not imports) and are left alone.
24+
//
25+
// Idempotent: running it twice is a no-op. Exits non-zero if the dist dir is missing.
26+
//
27+
// Usage: node scripts/fix-dts.mjs (invoked by the `package` npm script after svelte-package)
28+
29+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
30+
import path from 'node:path';
31+
import process from 'node:process';
32+
33+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
34+
const DIST = path.join(REPO, 'dist');
35+
36+
// A whole line that is a side-effect CSS import: optional indent, `import`, a quoted specifier that
37+
// ends in `.css`, a semicolon, optional trailing whitespace. No import binding (that would be a
38+
// typed import, which we never generate for CSS).
39+
const CSS_SIDE_EFFECT_IMPORT = /^\s*import\s+(['"])[^'"]+\.css\1\s*;\s*$/;
40+
41+
function walk(dir) {
42+
const out = [];
43+
for (const entry of readdirSync(dir)) {
44+
const full = path.join(dir, entry);
45+
const st = statSync(full);
46+
if (st.isDirectory()) out.push(...walk(full));
47+
else if (full.endsWith('.d.ts')) out.push(full);
48+
}
49+
return out;
50+
}
51+
52+
if (!existsSync(DIST)) {
53+
console.error('[fix-dts] dist/ not found — run svelte-package first.');
54+
process.exit(1);
55+
}
56+
57+
const files = walk(DIST);
58+
let touched = 0;
59+
let removedLines = 0;
60+
61+
for (const file of files) {
62+
const src = readFileSync(file, 'utf8');
63+
// Preserve the original EOL style; svelte-package emits \n.
64+
const lines = src.split('\n');
65+
const kept = lines.filter((line) => {
66+
if (CSS_SIDE_EFFECT_IMPORT.test(line)) {
67+
removedLines++;
68+
return false;
69+
}
70+
return true;
71+
});
72+
if (kept.length !== lines.length) {
73+
writeFileSync(file, kept.join('\n'));
74+
touched++;
75+
}
76+
}
77+
78+
console.log(
79+
`[fix-dts] scanned ${files.length} .d.ts file(s); stripped ${removedLines} side-effect CSS ` +
80+
`import line(s) from ${touched} file(s).`
81+
);

0 commit comments

Comments
 (0)