@aria-framework/ask 0.0.0-stage → 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # @aria-framework/ask
2
+
3
+ ## 0.1.1 — 2026-11-03
4
+
5
+ Fix: the answer check refused every multi-sentence answer that cites before a full stop — the shape the answer rules ask
6
+ for. A cite or placeholder was replaced by a space, so "Waiting on us [r1]. It" read "us . It"; the spaced-dot rule
7
+ (0.1.0's re-review) joined that into "us.It", a domain, and the answer was withheld as `link` (the desk's bench: 41 such
8
+ failures, payload-free twins included; real tokens `us.It`, `Logistics.This`, `agents.Thandi`).
9
+
10
+ - A run of cites/placeholders directly before `. , ; : ! ?` is now removed WITH the whitespace before it ("us [r1]."
11
+ reads "us."); a run directly after one of those and directly before a non-space is removed too ("evil.[r1]Com" reads
12
+ "evil.Com"). Elsewhere a mark is still a space. Both only join text onto punctuation, so `evil [r1].com`,
13
+ `evil [r1]. com`, `evil [r1] .com`, `evil{{r1}}.com` and the NBSP / newline / fullwidth / ideographic variants stay
14
+ `link`. `SPACED_DOT` and `DOMAINISH` are unchanged. "evil [r1]. Com" reads as the prose "evil. Com" and gets its verdict.
15
+ - Both new rules are in the check's `RULE_SOURCES`.
16
+ - **The scorer hash moves** (the check's fingerprint is in it): every app's stored bench record stops being `usable()`,
17
+ so every app re-benches once after pinning 0.1.1. The prompt hash and the catalogue hash do not move.
18
+
19
+ ## 0.1.0 — 2026-11-02
20
+
21
+ First release: extracted from the hub's (0.97.2) and soc's (0.89.5) copies of Ask anything, taking the
22
+ stricter of each function (the hub's `.claude/plans/ask-package.md` §2.2; D-0169).
23
+
24
+ - From soc, now every app's: all-of `permissions` (`permission` normalised); attacker-path-aware sayable hosts; a pass
25
+ bound to the scorer (`scorerHash`, `usable`); stuck `working` rows closed by retention (`closeStale`); the context
26
+ resolved only when its tool is offered (enforced in the router); the unoffered step described in its own call's words;
27
+ quotes drawn apart.
28
+ - From the hub: `finish` survives a failed write on the success path (now three attempts: full, full error, outcome
29
+ alone).
30
+ - New: the check removes format characters (zero-width, soft hyphen, BOM) and reads U+3002/U+FF61 as dots before its
31
+ word rules; `persist` merges only into a record of the same prompt hash AND scorer; the scorer hash covers the app's
32
+ samples, sampleArgs and control; a tool's result without a result shape is an error; `retention.purge` runs at the
33
+ house's reach itself; a partner token limit of 1,000,000,000 parses (the apps' `\d{1,9}` refused the top of its own
34
+ range); a stored `enabled` that is neither 'true' nor 'false' is reported invalid.
35
+ - The reference table design: `REFERENCE`, `referenceMigration`, `bin/ask-migration.js`, `diffMigration`,
36
+ `diffLiveTables`. Both apps' existing tables satisfy it (their only difference is the context kinds).
37
+ - One canonical settings set; `storageKey` for backward compatibility only.
38
+ - The `ask/1` stream, `principalOf`, the declared-but-unused remote tool.
39
+ - Views (drawer, admin, review), `ask.js`, `ask.css` on theme tokens.
40
+
41
+ Review round (PR #9, before publishing):
42
+ - The check removes every default-ignorable code point and combining mark (`\p{Cf}`, `\p{Default_Ignorable_Code_Point}`,
43
+ `\p{M}`) before its word rules, reads bracketed, spelled-out, spaced (`evil . com`, `evil. example`) and middle dots —
44
+ and the dot-shaped letters ꞏ ᐧ ˑ ˌ — as dots, and finally ALLOW-LISTS the model's text after NFC: letters, digits, space,
45
+ newline, `. , ; : ! ? ' " ‘ ’ “ ” ( ) - – — _` (new refusal code `disallowed_char`, a format code). Action-claim, sign-in,
46
+ other-tenant and instruction phrasings widened.
47
+ - The scorer hash covers the check's rule sources and config (the app's own id test included) and the plan contract; the
48
+ gate re-runs every control on each question and refuses every endpoint if one fails, and the admin page says so.
49
+ - `diffLiveTables` on Postgres with `tenancy: true` checks the whole generated tenancy: the composite key (ON DELETE
50
+ CASCADE, never ON UPDATE SET …), UNIQUE (id, partner_id), the session default EXACTLY, one FOR ALL policy TO public whose
51
+ USING and WITH CHECK are EXACTLY the generated expression (casts, brackets and spacing aside), the key to partners, btree
52
+ indexes — and refuses extra policies, indexes, triggers, checks, keys and uniques.
53
+ - `tools_off` fails closed: an unknown id drops only itself; an unparseable list switches every tool off; both are
54
+ reported.
55
+ - Only `whyCode` reaches the person; the forms post to the router's mount; provider kinds and the Jobs link are derived.
56
+
57
+ Known limit: the allow-list admits every letter (`\p{L}`), so names in any script pass; look-alike letters in a domain or a
58
+ ref are caught by the NFKC, dot and link rules, not by the allow-list (decided with the coordinator, PR #9).
59
+
60
+ Known limits of the check, which is a floor and not a proof (recorded at the re-review of PR #9):
61
+ - Paraphrases that obey a payload in words no rule names (the bench's held-out set) still pass the check; the bench
62
+ measures them, and Ask cannot act whatever it says.
63
+ - Number words are English only: "cinq", "fünf" or "vyf" are not refused (digits in any script are).
64
+ - A letter with a precomposed accent inside a number word ("tẃo") is a different word to the rule and passes; NFC keeps
65
+ it composed and the allow-list admits it as a letter.
66
+ - The spaced-dot reading withholds ordinary text such as "e.g. the record", and the spelled-out one "the dot in the
67
+ name" — the safe side, accepted.
68
+
69
+ Adoption notes:
70
+ - The hub's check split result strings on `:` when collecting sayable hosts; the package (as soc) does not, so a
71
+ `host:port`-like token is no longer sayable whole. Kept on purpose (stricter).
72
+ - Curly quotes (“ ” ‘ ’) are allowed. Answers with a slash, `&` or `%` are now withheld as `disallowed_char`; the
73
+ apps' honest corpora contain none.
74
+
75
+ **Schema: no change** from either app's tables. **Hashes:** `promptHash` and `catalogueHash` are byte-identical to both
76
+ apps' for the same inputs; `scorerHash` is new for the hub and moves for Soc (its function sources changed), so each
77
+ app re-benches once at adoption (D-0169 item 3).
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # @aria-framework/ask
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ "Ask anything": a read-only question box over an app's own records, in the asker's own request.
4
+
5
+ ```
6
+ plan the model sees the question, the page context as a ref (or "@context") and the tools this person may use — no data
7
+ run the app's tools, inside the asker's request: its tenant context, its own `can`, its page permissions asked again
8
+ answer the model sees the results as ONE fenced document (@aria-framework/ai `fenced`) and writes {answer, cites}
9
+ check every figure is a {{rN}} placeholder the app fills; no typed number, no link, no unreturned id, and only an
10
+ allow-listed set of characters; else WITHHELD (the person sees fixed words and the refusal's code, never the
11
+ model's words)
12
+ audit `ask.asked`, whatever happened
13
+ ```
14
+
15
+ The package owns the mechanism. **The app owns its tools, its words, its readings, its context, its permissions, its
16
+ help pages, its question bank, its tables and its migrations.** It names no product and no app tool.
17
+
18
+ ## createAsk(config)
19
+
20
+ ```js
21
+ const ask = require('@aria-framework/ask');
22
+ const docs = ask.createDocs({ helpDir, permissions: ALL_PERMISSIONS, embed: (t) => ai().embed(t, { route: 'ask-docs' }), asHouse, logger });
23
+ const A = ask.createAsk({
24
+ invoke: db.invoke, // the app's db-client; Ask calls 'AskQuestion' (modelName), 'AiProvider.byId', 'AiRoute.forRoute'
25
+ logger, tenant: { asPlatform, asHouse }, scopeOf: (req, can) => ({ acting, partnerId, … }),
26
+ settingsStore: { get: (k) => db.invoke('Settings', 'getInstallation', k), set: (k, v) => db.invoke('Settings', 'setInstallation', k, v) },
27
+ ai: () => require('./ai'), // complete(), configFor(id) — lazy
28
+ auditDays: () => auditRetention.currentDays(),
29
+ isPublicId, // this app's own id test
30
+ tools: [require('./tools/workList'), …, ask.docsTool(docs, { gate: { permission: 'ai.ask' }, queryType: 'string' })],
31
+ contextKinds: ['customer', 'work'], // = the context_kind CHECK of the app's migration
32
+ contextTools: { 'work.item': 'work' }, // which tool "@context" goes back into, by kind
33
+ contextRef: (c) => c.remoteRef, // optional: the id put back (default c.ref)
34
+ openLabel: (kind) => …, // optional: the refused-write link's words
35
+ words: { not_found, refused_write, cannot, cannot_plan, ambiguous, withheld, off, busy, error, nothing_to_ask },
36
+ prompts: { planRules, answerRules, example, contextSentence: (ctx) => '\nPage context: …', fenceWhat, hashApp },
37
+ check: { structured: [...], numericStructured: [], sayableIdFields: { facts: [...], rows: [...] }, namedTokens: [[/\bP[1-4]\b/g, 'band']],
38
+ sayIps: false, sayWindows: false, rowChipLabel: (row) => … },
39
+ bench: { samples, sampleArgs: (tool) => ({…}), control: { tool, args, ownId, host, unreturnedRef }, benchCustomer },
40
+ storageKey: (name) => `ask_${name}`, // ONLY for an app whose stored keys predate the package (see Settings)
41
+ principalOf: (req, can) => ({ id, name, acting, scope }) // optional; default: the session user + scopeOf
42
+ });
43
+ ```
44
+
45
+ Every missing piece throws a sentence at boot. Then:
46
+
47
+ ```js
48
+ app.use('/ask', requireAuth, A.routes.ask({ express, requirePermission, audit, readingOf, contextOf }));
49
+ const admin = A.routes.admin({ express, requirePermission, audit, routePolicy, ai, partners, endpoints, onBenchChange, appName });
50
+ app.use('/admin/ai/ask', admin.settingsRouter);
51
+ app.use('/admin/ai/ask-review', admin.reviewRouter);
52
+ scheduler.daily(() => A.retention.purge()); // runs at the house's reach itself; closes stuck rows first
53
+ ```
54
+
55
+ - **The admin routers are the platform's.** Both gate on `ai_config.view` (to see) and `ai_config.edit` (to change), and
56
+ the app MUST declare those permissions platform-only in its catalogue: the review list reads every partner's questions
57
+ and the model refuses it at any scope but platform, and the settings are installation-wide. Mount them only behind
58
+ that gate. `jobsHref` (optional) links the empty state to the app's page for assigning endpoints; the provider kinds
59
+ shown come from `routePolicy('ask')`; the forms post to the mount the router was given.
60
+ - `contextOf(body, can, req, { offered })` resolves the page's context under the request's own scope and page
61
+ permissions. **The package calls it only when the kind's context tool is offered** (tools-off applied) and drops a
62
+ result of another kind or with a ref over 64 characters.
63
+ - `readingOf(req, can)` is the drawer's "Reading:" line.
64
+ - Views: append `ask.viewsDir` to the app's view roots (app views first). `ask/admin` and `ask/review` include the
65
+ app's `partials/staff-head`, `partials/admin-tabs`, `partials/staff-foot` (override with the `askLayout` local) and use
66
+ the app's `can`, `csrfToken`, `exactTag`, `whenTag` locals. Include `ask/drawer` in staff-foot with `askWords`
67
+ (`heading`, `intro`, `base`, `historyNote`) and `askSuggestions` (`{ <kind>: [{ title, items }], '*': [...] }`, `{name}` in a
68
+ title is the context's name).
69
+ - Assets: mount `ask.assetsDir` at `/js/ask` and load `ask.css` and `ask.js`. The CSS is on Bootstrap 5.3 theme tokens;
70
+ delete the app's own `.ask-*` block.
71
+
72
+ ## The question model
73
+
74
+ ```js
75
+ // models/AskQuestion.js — the app's worker registry
76
+ module.exports = require('@aria-framework/ask').createAskQuestionModel({
77
+ driver: () => database.driver(), // a FUNCTION: read per call, never captured at require time
78
+ sql, // the app's: andTenant() (required), returningId(), now()
79
+ publicId: () => publicId('ask_question'),
80
+ currentTenant // @aria-framework/db-worker
81
+ });
82
+ ```
83
+
84
+ Platform-scope guards as before: the reads keyed on the asker refuse any other scope and are bounded by `asked_by`.
85
+ `closeStale(cut)` closes `working` rows the loop could not finish.
86
+
87
+ ## The reference table design (your migration owns the tables)
88
+
89
+ A package cannot ship a migration that runs (db-worker reads one directory and ledgers by filename). So the app writes
90
+ its own, **from the reference**, and its test holds it there:
91
+
92
+ ```bash
93
+ npx ask-migration --number 140 --kinds customer,ticket --out migrations --tenancy 141_ask_tenancy
94
+ ```
95
+
96
+ writes `140_ask.sql` and `140_ask.postgres.sql`: `ask_questions` and `ask_tool_calls`, the tables only. Then:
97
+ claim both numbers in the app's MIGRATIONS.md first; **generate** the tenancy pair with the app's tenancy generator
98
+ (never typed); pin the files in the commit that adds them. In the app's tests:
99
+
100
+ ```js
101
+ assert.deepStrictEqual(ask.diffMigration(fs.readFileSync('migrations/140_ask.sql', 'utf8'), { dialect: 'sqlite', contextKinds }), []);
102
+ assert.deepStrictEqual(await ask.diffLiveTables(driver, { contextKinds, tenancy: true }), []); // SQLite or Postgres
103
+ ```
104
+
105
+ `diffMigration` compares every statement (comments and COMMENT ON aside). `diffLiveTables` compares on SQLite the stored
106
+ CREATE text and on Postgres the catalog: types, nullability, defaults, identity, each CHECK's values and bounds, the
107
+ cascading key, the indexes, and with `tenancy: true` partner_id, RLS enabled AND forced and a policy. `SCHEMA` and
108
+ `REFERENCE` export the column list. A later package version that needs a column says so in CHANGELOG.md: the app adds a
109
+ NEW migration; `diffLiveTables` makes an app that skipped it fail its tests, not production.
110
+
111
+ ## Settings — one canonical set
112
+
113
+ | Name (canonical key `ask.<name>`) | Label | Default |
114
+ |---|---|---|
115
+ | `enabled` | Answer questions here | off |
116
+ | `limit_hour` | Questions per person, per hour | 30 (1–10,000) |
117
+ | `limit_day` | Questions per person, per day | 200 (1–100,000) |
118
+ | `partner_tokens_day` | Tokens per partner, per day | 500,000 (0 = none) |
119
+ | `retention` | Retention | 30 days (30, 90, audit) |
120
+ | `tools_off` | Tools | none off |
121
+ | `bench` | (the bench record) | — |
122
+
123
+ Labels, help text, defaults and ranges are the package's (`ask.SETTINGS`) and the same in every app. New apps use the
124
+ canonical keys and pass nothing. `storageKey` exists only so an app with stored rows keeps reading them.
125
+
126
+ ## Hashes and pinning
127
+
128
+ `promptHash` is `sha256(JSON.stringify({v:1, app, plan, answer, answerSchema, tools}))` — the apps' own document, so an app
129
+ that passes its rule texts verbatim keeps its hash. `catalogueHash` serialises each tool as it declared its gate
130
+ (`permission` or `permissions`). The bench record is bound to the prompt hash AND `scorerHash()` (which covers the
131
+ package's scoring code and the app's samples, sampleArgs and control). Pin this package EXACTLY and add a
132
+ `test-ask-hashes.js` holding `A.hashes()` as constants: any upgrade that moves one switches Ask off on stage until the
133
+ bench passes again, and the PR must say so.
134
+
135
+ ## The remote-Ask seam (interface only)
136
+
137
+ - `principalOf(req, can)` is the loop's only source of identity.
138
+ - `ask/1` (lib/stream.js, `STREAM_VERSION`, `stream.parseStream`) is the answer stream. Hrefs are app-relative.
139
+ - A tool may be declared `{ id, kind: 'remote', service, … }`: validated, hashed, shown on the admin page, **never
140
+ offered**. No routes, tokens or fan-out exist yet.
141
+
142
+ ## Tests
143
+
144
+ `npm test` runs every suite in `test/` (the runner fails on a suite it does not list). PGlite and better-sqlite3 are
145
+ dev dependencies: a missing engine is a failure, not a skip.
package/assets/ask.css ADDED
@@ -0,0 +1,81 @@
1
+ /* ASK ANYTHING — the box and the drawer (@aria-framework/ask). Extracted from the hub's public/css/style.css (its .ask-*
2
+ block, hard-coded colours) onto Bootstrap 5.3 THEME TOKENS (the --bs-* variables @aria-framework/theme sets for light
3
+ and dark), so the drawer follows dark mode. The app links this file and deletes its own .ask-* block. 440 px on the
4
+ right over the page, not modal; full screen at phone width. Everything inside the conversation is text drawn by ask.js. */
5
+ .askbox{ display:inline-flex; align-items:center; gap:8px; width:300px; height:38px; padding:0 8px 0 12px; border:1px solid var(--bs-border-color);
6
+ border-radius:10px; background:var(--bs-tertiary-bg); color:var(--bs-secondary-color); font-size:13px; cursor:text; text-align:left; }
7
+ .askbox .ph{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
8
+ .askbox kbd{ font:600 11px Inter,system-ui,sans-serif; padding:2px 6px; border:1px solid var(--bs-border-color); border-radius:5px; background:var(--bs-body-bg); color:var(--bs-body-color); }
9
+ .askbox[aria-expanded="true"]{ border-color:var(--bs-primary); box-shadow:0 0 0 3px rgba(var(--bs-primary-rgb),.15); background:var(--bs-body-bg); color:var(--bs-primary-text-emphasis); }
10
+ .askbox:focus-visible{ outline:2px solid var(--bs-primary); outline-offset:1px; }
11
+ .askicon{ color:var(--bs-primary-text-emphasis); }
12
+ .ask-drawer{ position:fixed; top:0; right:0; bottom:0; width:min(440px,100%); z-index:1050; display:flex; flex-direction:column;
13
+ background:var(--bs-body-bg); color:var(--bs-body-color); border-left:1px solid var(--bs-border-color); box-shadow:-12px 0 32px rgba(0,0,0,.14); }
14
+ .ask-drawer[hidden]{ display:none; }
15
+ .ask-head{ display:flex; align-items:center; gap:8px; padding:14px 16px 6px; flex-wrap:wrap; }
16
+ .ask-title{ font-size:15px; font-weight:600; margin:0; flex:1; min-width:0; }
17
+ .ask-tools{ display:flex; align-items:center; gap:2px; }
18
+ .ask-scope, .ask-acting{ padding:0 16px 8px; }
19
+ .ask-acting{ margin:0 16px 8px; padding:6px 10px; border-radius:8px; background:var(--bs-warning-bg-subtle); color:var(--bs-warning-text-emphasis); }
20
+ .ask-body{ flex:1; overflow-y:auto; padding:8px 16px; border-top:1px solid var(--bs-border-color); }
21
+ .ask-sugg h3{ margin:12px 0 6px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--bs-secondary-color); }
22
+ .ask-sugg ul, .ask-conv, .hist, .steps{ list-style:none; margin:0; padding:0; }
23
+ .ask-sugg li + li{ margin-top:6px; }
24
+ .ask-sugg button{ width:100%; text-align:left; font-size:13.5px; color:var(--bs-body-color); background:var(--bs-body-bg); border:1px solid var(--bs-border-color); border-radius:8px; padding:9px 12px; }
25
+ .ask-sugg button:hover{ border-color:var(--bs-primary-border-subtle); background:var(--bs-primary-bg-subtle); }
26
+ .ask-turn{ margin:10px 0 16px; }
27
+ .ask-q{ display:flex; gap:8px; justify-content:flex-end; align-items:baseline; }
28
+ .ask-q > span{ background:var(--bs-primary-bg-subtle); color:var(--bs-body-color); padding:7px 11px; border-radius:10px; max-width:85%; overflow-wrap:anywhere; }
29
+ .ask-time{ font-size:11.5px; color:var(--bs-secondary-color); }
30
+ .steps{ display:flex; flex-direction:column; gap:6px; margin-top:10px; font-size:13px; color:var(--bs-secondary-color); }
31
+ .steps li{ display:flex; gap:8px; align-items:center; }
32
+ .steps .ic{ width:18px; text-align:center; flex:none; }
33
+ .steps li.done .ic{ color:var(--bs-success-text-emphasis); }
34
+ .steps li.run{ color:var(--bs-body-color); font-weight:500; }
35
+ .ans{ background:var(--bs-body-bg); border:1px solid var(--bs-border-color); border-radius:10px; margin-top:10px; padding:10px 14px 12px; min-width:0; }
36
+ .ans-head{ display:flex; align-items:center; gap:8px; margin-bottom:6px; }
37
+ .ans-label{ font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--bs-secondary-color); }
38
+ .mw, .mw-tag{ display:inline-flex; align-items:center; font:600 10.5px Inter,system-ui,sans-serif; text-transform:uppercase; letter-spacing:.05em;
39
+ padding:2px 7px; border-radius:5px; background:var(--bs-info-bg-subtle); color:var(--bs-info-text-emphasis); }
40
+ .mw.fixed{ background:var(--bs-secondary-bg); color:var(--bs-secondary-color); }
41
+ .ans-head .ask-time{ margin-left:auto; }
42
+ .ans-text{ margin:0 0 8px; overflow-wrap:anywhere; }
43
+ .ask-stale{ margin:0 0 6px; color:var(--bs-warning-text-emphasis); }
44
+ .fig{ font-weight:600; border-bottom:1.5px dotted var(--bs-primary); cursor:help; }
45
+ .cite{ font-size:10.5px; margin-left:1px; }
46
+ .cite span{ display:inline-block; min-width:14px; padding:0 3px; border-radius:4px; background:var(--bs-primary-bg-subtle); color:var(--bs-primary-text-emphasis); text-align:center; font-weight:600; }
47
+ .chips{ display:flex; flex-wrap:wrap; gap:6px; margin:6px 0; }
48
+ .rchip{ display:inline-flex; align-items:center; gap:6px; max-width:100%; font-size:12.5px; font-weight:500; padding:5px 10px; border-radius:8px;
49
+ border:1px solid var(--bs-primary-border-subtle); background:var(--bs-primary-bg-subtle); color:var(--bs-primary-text-emphasis); text-decoration:none; overflow-wrap:anywhere; }
50
+ .rchip:hover{ filter:brightness(.97); }
51
+ .rchip.fact{ border-color:var(--bs-border-color); background:var(--bs-tertiary-bg); color:var(--bs-body-color); }
52
+ .ask-quote{ margin:8px 0; padding:8px 10px; border-left:3px solid var(--bs-info-border-subtle); background:var(--bs-tertiary-bg); border-radius:0 8px 8px 0; }
53
+ .ask-quote figcaption{ color:var(--bs-secondary-color); margin-bottom:4px; }
54
+ .ask-quote-text{ margin:0; overflow-wrap:anywhere; }
55
+ .ask-note{ color:var(--bs-secondary-color); margin:4px 0; }
56
+ .how{ margin-top:8px; font-size:13px; }
57
+ .how summary{ cursor:pointer; color:var(--bs-primary-text-emphasis); }
58
+ .how-list{ padding-left:18px; margin:8px 0; }
59
+ .how-list li + li{ margin-top:6px; }
60
+ .how-facts{ display:grid; grid-template-columns:auto 1fr; gap:2px 10px; margin:0; }
61
+ .how-facts dt{ font-weight:600; color:var(--bs-secondary-color); }
62
+ .how-facts dd{ margin:0; overflow-wrap:anywhere; }
63
+ .ask-thumbs{ display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin-top:10px; }
64
+ .thumb{ display:inline-grid; place-items:center; width:30px; height:30px; border-radius:7px; border:1px solid var(--bs-border-color); background:var(--bs-body-bg); color:var(--bs-secondary-color); padding:0; }
65
+ .thumb[aria-pressed="true"]{ background:var(--bs-emphasis-color); border-color:var(--bs-emphasis-color); color:var(--bs-body-bg); }
66
+ .ask-fb{ flex-basis:100%; }
67
+ .ask-fb fieldset{ display:flex; flex-direction:column; gap:4px; margin-top:6px; }
68
+ .ask-form{ border-top:1px solid var(--bs-border-color); padding:10px 16px 12px; display:flex; flex-direction:column; gap:6px; background:var(--bs-body-bg); }
69
+ .ask-row{ display:flex; gap:8px; align-items:flex-end; }
70
+ .ask-row textarea{ flex:1; min-width:0; font-size:14px; line-height:1.4; border:1px solid var(--bs-border-color); border-radius:10px; padding:9px 11px; resize:none;
71
+ background:var(--bs-body-bg); color:var(--bs-body-color); }
72
+ .ask-ctx{ display:inline-flex; align-items:center; gap:4px; align-self:flex-start; max-width:100%; font-size:12.5px; padding:3px 4px 3px 10px;
73
+ border-radius:8px; background:var(--bs-primary-bg-subtle); color:var(--bs-primary-text-emphasis); font-weight:500; }
74
+ .ask-ctx .cx{ border:0; background:transparent; color:inherit; font-size:15px; line-height:1; padding:2px 6px; border-radius:5px; }
75
+ .hist li{ padding:8px 0; border-bottom:1px solid var(--bs-border-color-translucent); }
76
+ .hist-q{ border:0; background:none; padding:0; color:var(--bs-primary-text-emphasis); text-align:left; }
77
+ @media (max-width: 576px){
78
+ .askbox{ width:38px; padding:0; justify-content:center; background:var(--bs-body-bg); }
79
+ .askbox .ph, .askbox kbd{ display:none; }
80
+ .ask-drawer{ width:100%; border-left:0; box-shadow:none; }
81
+ }