@nxisai/agent-harness 0.7.0 → 0.9.0
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/LICENSE +201 -0
- package/README.md +17 -5
- package/dist/a2ui/cards.d.ts +3 -0
- package/dist/a2ui/cards.d.ts.map +1 -1
- package/dist/a2ui/cards.js +11 -2
- package/dist/a2ui/cards.js.map +1 -1
- package/dist/harness/cta.d.ts +27 -0
- package/dist/harness/cta.d.ts.map +1 -0
- package/dist/harness/cta.js +43 -0
- package/dist/harness/cta.js.map +1 -0
- package/dist/harness/harness.d.ts +33 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +84 -2
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/recommend.d.ts +46 -0
- package/dist/harness/recommend.d.ts.map +1 -0
- package/dist/harness/recommend.js +144 -0
- package/dist/harness/recommend.js.map +1 -0
- package/dist/harness/records.d.ts +70 -0
- package/dist/harness/records.d.ts.map +1 -0
- package/dist/harness/records.js +209 -0
- package/dist/harness/records.js.map +1 -0
- package/dist/harness/renderer.d.ts +11 -1
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/router/coordinator.d.ts +6 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +14 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +25 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +39 -0
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/inbound.d.ts +88 -0
- package/dist/harness/tools/inbound.d.ts.map +1 -0
- package/dist/harness/tools/inbound.js +156 -0
- package/dist/harness/tools/inbound.js.map +1 -0
- package/dist/harness/tools/site-pages.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +154 -76
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/result-pick.d.ts +52 -0
- package/dist/ui/result-pick.d.ts.map +1 -0
- package/dist/ui/result-pick.js +196 -0
- package/dist/ui/result-pick.js.map +1 -0
- package/package.json +1 -1
- package/src/a2ui/cards.ts +14 -2
- package/src/harness/cta.ts +54 -0
- package/src/harness/harness.ts +93 -2
- package/src/harness/recommend.ts +194 -0
- package/src/harness/records.ts +237 -0
- package/src/harness/renderer.ts +12 -1
- package/src/harness/router/coordinator.ts +14 -0
- package/src/harness/tools/action-tools.ts +41 -0
- package/src/harness/tools/inbound.ts +189 -0
- package/src/harness/tools/site-pages.ts +1 -1
- package/src/index.ts +16 -0
- package/src/ui/agent-fullscreen.ts +162 -66
- package/src/ui/result-pick.ts +233 -0
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { mountSurface } from '../a2ui/host.js';
|
|
24
24
|
import { emitCardsSurface } from '../a2ui/cards.js';
|
|
25
|
+
import { actionCta } from '../harness/cta.js';
|
|
25
26
|
import { emitToolForm } from '../a2ui/form.js';
|
|
26
27
|
import { A2UI_THEME_CSS } from '../a2ui/theme.js';
|
|
27
28
|
// Compiled from agent.tw.css at build time (scripts/css-to-ts.mjs). It is a
|
|
@@ -34,11 +35,14 @@ import { AgentHarness, isAbort } from '../harness/harness.js';
|
|
|
34
35
|
import { WebMcpTools } from '../harness/tools/webmcp.js';
|
|
35
36
|
import { extractResult, proseAnswer, missingRequired, toolSchema, } from '../harness/tools/schema.js';
|
|
36
37
|
import { connectTools } from '../harness/tools/source.js';
|
|
37
|
-
import { chooserOf, formOptions, handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
|
|
38
|
+
import { chooserOf, formOptions, formsFor, handoffFor, isSiteTool, siteFormFor, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
|
|
38
39
|
import { resolveSlots } from '../harness/slots.js';
|
|
39
40
|
import { isHandoffTool } from '../handoff/tools.js';
|
|
40
41
|
import { actionCard, fieldLabel, fieldsFromSlots, ACTION_CARD_CSS, } from './action-card.js';
|
|
42
|
+
import { resultsAnswer, RESULTS_ANSWER_CSS } from './result-pick.js';
|
|
43
|
+
import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
|
|
41
44
|
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
45
|
+
import { findHandoffForm, startFormMessage, startMessage, takeInboundHandoff } from '../harness/tools/inbound.js';
|
|
42
46
|
import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
|
|
43
47
|
import { pageForEntity } from '../harness/tools/site-pages.js';
|
|
44
48
|
import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
@@ -413,6 +417,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
413
417
|
// belongs to is the one open.
|
|
414
418
|
void this._resumePendingAction();
|
|
415
419
|
this._resumePendingVisit();
|
|
420
|
+
// Sent here from the NxisAI app to use one of this page's forms.
|
|
421
|
+
void this._arriveFromHandoff();
|
|
416
422
|
this._els.launcher.addEventListener('click', () => this._open());
|
|
417
423
|
this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
|
|
418
424
|
// A handoff left open on the last page carries on here.
|
|
@@ -1302,7 +1308,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1302
1308
|
return bubble;
|
|
1303
1309
|
}
|
|
1304
1310
|
// ── Turn loop ──────────────────────────────────────────────────────────────
|
|
1305
|
-
|
|
1311
|
+
/**
|
|
1312
|
+
* Send what is in the composer.
|
|
1313
|
+
*
|
|
1314
|
+
* @param call - A tool call already decided, run without asking the model
|
|
1315
|
+
* (a handoff names its form). Falls back to an ordinary turn when no agent
|
|
1316
|
+
* here holds the tool.
|
|
1317
|
+
*/
|
|
1318
|
+
async _onSend(call) {
|
|
1306
1319
|
const goal = this._els.input.value.trim();
|
|
1307
1320
|
if (!goal || this._turnActive)
|
|
1308
1321
|
return;
|
|
@@ -1338,7 +1351,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1338
1351
|
await this._connectTools();
|
|
1339
1352
|
this._syncProfileContext();
|
|
1340
1353
|
// One agent, or several with a router in front: the signatures match.
|
|
1341
|
-
|
|
1354
|
+
const engine = this._coordinator ?? this._harness;
|
|
1355
|
+
const begun = call ? await engine.begin(goal, call.name, call.args, conv.messages) : null;
|
|
1356
|
+
if (!begun || begun.decision === 'unavailable')
|
|
1357
|
+
await engine.turn(goal, conv.messages);
|
|
1342
1358
|
// A remote endpoint reports which model answered; the name beside the
|
|
1343
1359
|
// input follows it rather than keeping whatever was configured.
|
|
1344
1360
|
this._paintProvenance();
|
|
@@ -1372,9 +1388,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1372
1388
|
ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
|
|
1373
1389
|
confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
|
|
1374
1390
|
left: (tool, destination) => this._cardLeft(tool, destination),
|
|
1375
|
-
results: (tool, res) => {
|
|
1391
|
+
results: (tool, res, extra) => {
|
|
1376
1392
|
const conv = this._active();
|
|
1377
|
-
const item = this._buildResultsItem(tool, res);
|
|
1393
|
+
const item = this._buildResultsItem(tool, res, extra?.pick);
|
|
1378
1394
|
// A read that answers in words — "you are on the home page", "already open" — has no records
|
|
1379
1395
|
// to draw, and an empty result card reading "No results found." contradicts the answer it
|
|
1380
1396
|
// just gave. Say what it said.
|
|
@@ -2254,70 +2270,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2254
2270
|
* return the first that yields records. Shapes covered: query → results[],
|
|
2255
2271
|
* list_types → types[], get → result{}, or a bare entity/array. */
|
|
2256
2272
|
_extractItems(res) {
|
|
2257
|
-
const
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
// serialized to a string, possibly more than once. Diving into both
|
|
2262
|
-
// `structuredContent` and each `content[].text` covers all of them.
|
|
2263
|
-
const visit = (raw, depth = 0) => {
|
|
2264
|
-
if (raw == null || depth > 5)
|
|
2265
|
-
return;
|
|
2266
|
-
const val = raw;
|
|
2267
|
-
if (typeof val === 'string') {
|
|
2268
|
-
const t = val.trim();
|
|
2269
|
-
if (!t)
|
|
2270
|
-
return;
|
|
2271
|
-
let parsed;
|
|
2272
|
-
try {
|
|
2273
|
-
parsed = JSON.parse(t);
|
|
2274
|
-
}
|
|
2275
|
-
catch {
|
|
2276
|
-
return; // plain (non-JSON) text — nothing to extract
|
|
2277
|
-
}
|
|
2278
|
-
// Parsed value may itself be an object/array OR another JSON string
|
|
2279
|
-
// (double-encoded); recurse either way.
|
|
2280
|
-
return visit(parsed, depth + 1);
|
|
2281
|
-
}
|
|
2282
|
-
if (!val || typeof val !== 'object')
|
|
2283
|
-
return;
|
|
2284
|
-
sources.push(val);
|
|
2285
|
-
const v = val;
|
|
2286
|
-
if (v.structuredContent != null)
|
|
2287
|
-
visit(v.structuredContent, depth + 1);
|
|
2288
|
-
if (Array.isArray(v.content)) {
|
|
2289
|
-
for (const c of v.content) {
|
|
2290
|
-
if (c && typeof c === 'object')
|
|
2291
|
-
visit(c.text, depth + 1);
|
|
2292
|
-
}
|
|
2293
|
-
}
|
|
2294
|
-
};
|
|
2295
|
-
visit(res);
|
|
2296
|
-
for (const data of sources) {
|
|
2297
|
-
const items = this._itemsFrom(data);
|
|
2298
|
-
if (items.length)
|
|
2299
|
-
return items;
|
|
2300
|
-
}
|
|
2301
|
-
console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
|
|
2302
|
-
return [];
|
|
2303
|
-
}
|
|
2304
|
-
/** Find the record array within one parsed payload. */
|
|
2305
|
-
_itemsFrom(data) {
|
|
2306
|
-
const objs = (arr) => arr.filter((x) => x && typeof x === 'object');
|
|
2307
|
-
if (Array.isArray(data))
|
|
2308
|
-
return objs(data);
|
|
2309
|
-
if (!data || typeof data !== 'object')
|
|
2310
|
-
return [];
|
|
2311
|
-
const d = data;
|
|
2312
|
-
for (const key of ['results', 'types', 'items', 'entities', 'data']) {
|
|
2313
|
-
if (Array.isArray(d[key]))
|
|
2314
|
-
return objs(d[key]);
|
|
2315
|
-
}
|
|
2316
|
-
if (d.result && typeof d.result === 'object')
|
|
2317
|
-
return [d.result];
|
|
2318
|
-
if (d['@id'] != null || d['@type'] != null || d.name != null)
|
|
2319
|
-
return [d];
|
|
2320
|
-
return [];
|
|
2273
|
+
const items = extractRecords(res);
|
|
2274
|
+
if (!items.length)
|
|
2275
|
+
console.warn('[nxis-mcp] no items extracted from tool result', { res });
|
|
2276
|
+
return items;
|
|
2321
2277
|
}
|
|
2322
2278
|
/** Build a display card from a single record (a JSON-LD entity or a type row). */
|
|
2323
2279
|
_entityCard(entity) {
|
|
@@ -2376,15 +2332,83 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2376
2332
|
const id = entity['@id'] != null ? str(entity['@id']) : undefined;
|
|
2377
2333
|
const found = pageForEntity(entity, this._sitePages, location.origin);
|
|
2378
2334
|
const page = found ? { url: found.url, label: found.name } : undefined;
|
|
2379
|
-
|
|
2335
|
+
// What the answer view compares by, beyond the rating and hours its heading already shows.
|
|
2336
|
+
const facts = recordFacts(entity);
|
|
2337
|
+
const status = openStatus(entity) ?? undefined;
|
|
2338
|
+
const lines = [
|
|
2339
|
+
facts.services.length ? facts.services.slice(0, 3).map((x) => x.name).join(', ') : facts.knows.slice(0, 3).join(', '),
|
|
2340
|
+
facts.since ? `In business since ${facts.since}` : '',
|
|
2341
|
+
facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
|
|
2342
|
+
].filter((l) => !!l);
|
|
2343
|
+
const action = actionCta(entity, title);
|
|
2344
|
+
return {
|
|
2345
|
+
title,
|
|
2346
|
+
type,
|
|
2347
|
+
rating,
|
|
2348
|
+
emergency,
|
|
2349
|
+
sub,
|
|
2350
|
+
rows,
|
|
2351
|
+
id,
|
|
2352
|
+
...(page ? { page } : {}),
|
|
2353
|
+
...(facts.rating !== undefined ? { stars: facts.rating } : {}),
|
|
2354
|
+
...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
|
|
2355
|
+
...(status ? { status: { open: status.open, label: status.label } } : {}),
|
|
2356
|
+
...(facts.phone ? { phone: facts.phone } : {}),
|
|
2357
|
+
...(action ? { action } : {}),
|
|
2358
|
+
facts: lines,
|
|
2359
|
+
};
|
|
2380
2360
|
}
|
|
2381
|
-
_buildResultsItem(tool, res) {
|
|
2361
|
+
_buildResultsItem(tool, res, pick) {
|
|
2382
2362
|
const items = this._extractItems(res);
|
|
2383
2363
|
const cards = items.slice(0, 12).map((e) => this._entityCard(e));
|
|
2384
2364
|
const note = !cards.length
|
|
2385
2365
|
? 'No results found.'
|
|
2386
2366
|
: `${cards.length} result${cards.length === 1 ? '' : 's'}`;
|
|
2387
|
-
return { kind: 'results', title: humanize(tool.name), note, cards };
|
|
2367
|
+
return { kind: 'results', title: humanize(tool.name), note, cards, ...(pick && pick.index < cards.length ? { pick } : {}) };
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Results as an answer: the recommended pick with its reasons and the others
|
|
2371
|
+
* to compare, or a short list that opens per result. Records of things
|
|
2372
|
+
* only: a listing of types or counts keeps the plain cards.
|
|
2373
|
+
*/
|
|
2374
|
+
_answerEl(item) {
|
|
2375
|
+
// Records of things: a pick, or at least one result with a rating, hours, a phone or a page.
|
|
2376
|
+
// A sparse record beside them (an index entry with only a name) still shows, by name.
|
|
2377
|
+
const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
|
|
2378
|
+
if (!item.pick && !things.length)
|
|
2379
|
+
return null;
|
|
2380
|
+
const cards = item.cards.map((c) => {
|
|
2381
|
+
// The record's own action first: the same map and prompt as the Typed Retrieval skill and the NxisAI
|
|
2382
|
+
// app. A record that states none falls back to a form found on this page for it.
|
|
2383
|
+
const form = c.action ? undefined : formsFor(c.title)[0];
|
|
2384
|
+
const quote = form && /quote/.test(form.noun);
|
|
2385
|
+
return {
|
|
2386
|
+
title: c.title,
|
|
2387
|
+
stars: c.stars,
|
|
2388
|
+
reviews: c.reviews,
|
|
2389
|
+
status: c.status,
|
|
2390
|
+
phone: c.phone,
|
|
2391
|
+
page: c.page,
|
|
2392
|
+
facts: c.facts ?? [],
|
|
2393
|
+
...(c.action
|
|
2394
|
+
? { form: { label: c.action.label, message: c.action.message } }
|
|
2395
|
+
: form
|
|
2396
|
+
? {
|
|
2397
|
+
form: quote
|
|
2398
|
+
? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
|
|
2399
|
+
: { label: `Use their ${form.noun}`, message: `Start the ${form.noun} for ${c.title}` },
|
|
2400
|
+
}
|
|
2401
|
+
: {}),
|
|
2402
|
+
};
|
|
2403
|
+
});
|
|
2404
|
+
return resultsAnswer(cards, item.pick, {
|
|
2405
|
+
ask: (text) => {
|
|
2406
|
+
if (this._turnActive)
|
|
2407
|
+
return;
|
|
2408
|
+
this._els.input.value = text;
|
|
2409
|
+
void this._onSend();
|
|
2410
|
+
},
|
|
2411
|
+
});
|
|
2388
2412
|
}
|
|
2389
2413
|
/** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
|
|
2390
2414
|
* supporting line, then mono-labelled detail rows. */
|
|
@@ -2406,6 +2430,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2406
2430
|
...(c.emergency ? [{ label: '24/7' }] : []),
|
|
2407
2431
|
],
|
|
2408
2432
|
rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
|
|
2433
|
+
actions: c.action ? [c.action] : [],
|
|
2409
2434
|
}));
|
|
2410
2435
|
}
|
|
2411
2436
|
/** Results rendered through A2UI instead of the bespoke card markup. */
|
|
@@ -2418,7 +2443,15 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2418
2443
|
}
|
|
2419
2444
|
try {
|
|
2420
2445
|
const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
|
|
2421
|
-
const mounted = mountSurface(wrap, messages)
|
|
2446
|
+
const mounted = mountSurface(wrap, messages, (event) => {
|
|
2447
|
+
const name = event?.event?.name ?? event?.name ?? '';
|
|
2448
|
+
const ctx = (event?.event?.context ?? event?.context ?? {});
|
|
2449
|
+
// A card's action button: send its message as the visitor, as the inline card does.
|
|
2450
|
+
if (name === 'nxis.ask' && typeof ctx.message === 'string' && !this._turnActive) {
|
|
2451
|
+
this._els.input.value = ctx.message;
|
|
2452
|
+
void this._onSend();
|
|
2453
|
+
}
|
|
2454
|
+
});
|
|
2422
2455
|
if (!mounted)
|
|
2423
2456
|
throw new Error('no surface produced');
|
|
2424
2457
|
this._surfaces.push(mounted);
|
|
@@ -2444,9 +2477,14 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2444
2477
|
* surface; a2ui="a2ui-results" opts this element back into it for comparison.
|
|
2445
2478
|
*/
|
|
2446
2479
|
_resultsEl(item) {
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2480
|
+
if (this.getAttribute('a2ui') === 'a2ui-results')
|
|
2481
|
+
return this._resultsElA2ui(item);
|
|
2482
|
+
if (!this._classicPrompts && item.cards.length) {
|
|
2483
|
+
const answer = this._answerEl(item);
|
|
2484
|
+
if (answer)
|
|
2485
|
+
return answer;
|
|
2486
|
+
}
|
|
2487
|
+
return this._resultsElLegacy(item);
|
|
2450
2488
|
}
|
|
2451
2489
|
_resultsElLegacy(item) {
|
|
2452
2490
|
const wrap = document.createElement('div');
|
|
@@ -3118,6 +3156,43 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3118
3156
|
if (this.mode !== 'fullscreen')
|
|
3119
3157
|
this._open();
|
|
3120
3158
|
}
|
|
3159
|
+
/**
|
|
3160
|
+
* Open on the form a link from the NxisAI app names.
|
|
3161
|
+
*
|
|
3162
|
+
* In a new conversation: the person arrives from another app, and nothing
|
|
3163
|
+
* on this site led here. The agent starts the form in its own words, so the
|
|
3164
|
+
* link only chooses which form; what the person wanted waits in the
|
|
3165
|
+
* composer for them to send, change or delete.
|
|
3166
|
+
*/
|
|
3167
|
+
async _arriveFromHandoff() {
|
|
3168
|
+
const handoff = takeInboundHandoff();
|
|
3169
|
+
if (!handoff)
|
|
3170
|
+
return;
|
|
3171
|
+
this._newConversation();
|
|
3172
|
+
this._open();
|
|
3173
|
+
this._setStatus('Opening the form…');
|
|
3174
|
+
// The site's own tools first: they reach a form from any page, from the
|
|
3175
|
+
// site's records, so they work where the page registers nothing itself.
|
|
3176
|
+
await this._connectTools();
|
|
3177
|
+
const site = siteFormFor(handoff.actionId);
|
|
3178
|
+
const start = site
|
|
3179
|
+
? startFormMessage(site.noun, site.choice)
|
|
3180
|
+
: await findHandoffForm(handoff).then((o) => (o.status === 'ready' ? startMessage(o.tool) : null));
|
|
3181
|
+
this._setStatus('');
|
|
3182
|
+
if (!start) {
|
|
3183
|
+
this._appendMsg('assistant', "I couldn't find that form on this page. Tell me what you need and I'll help from here.");
|
|
3184
|
+
}
|
|
3185
|
+
else if (!this._turnActive) {
|
|
3186
|
+
this._els.input.value = start;
|
|
3187
|
+
// The form is known, so it opens without the model choosing it: left to
|
|
3188
|
+
// choose, a model sometimes asks for the details in prose instead.
|
|
3189
|
+
await this._onSend(site ? { name: site.tool, args: site.args } : undefined);
|
|
3190
|
+
}
|
|
3191
|
+
if (handoff.intent && !this._els.input.value) {
|
|
3192
|
+
this._els.input.value = handoff.intent;
|
|
3193
|
+
this._autosize();
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3121
3196
|
async _resumePendingAction() {
|
|
3122
3197
|
const outcome = await resumePendingAction({
|
|
3123
3198
|
onStart: (pending) => {
|
|
@@ -3702,6 +3777,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3702
3777
|
/* The action card: forms and handoffs as one message. */
|
|
3703
3778
|
${ACTION_CARD_CSS}
|
|
3704
3779
|
|
|
3780
|
+
/* Search results as an answer: a pick and why, or a short list. */
|
|
3781
|
+
${RESULTS_ANSWER_CSS}
|
|
3782
|
+
|
|
3705
3783
|
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
3706
3784
|
.launcher {
|
|
3707
3785
|
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|