@hasna/contacts 0.8.1 → 0.9.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.
Files changed (56) hide show
  1. package/README.md +10 -0
  2. package/dist/browser/cli.d.ts +4 -0
  3. package/dist/browser/cli.d.ts.map +1 -0
  4. package/dist/browser/identity.d.ts +3 -0
  5. package/dist/browser/identity.d.ts.map +1 -0
  6. package/dist/browser/install.d.ts +32 -0
  7. package/dist/browser/install.d.ts.map +1 -0
  8. package/dist/browser/native.d.ts +3 -0
  9. package/dist/browser/native.d.ts.map +1 -0
  10. package/dist/browser/native.js +822 -0
  11. package/dist/browser/protocol.d.ts +26 -0
  12. package/dist/browser/protocol.d.ts.map +1 -0
  13. package/dist/browser/values.d.ts +18 -0
  14. package/dist/browser/values.d.ts.map +1 -0
  15. package/dist/cli/commands/core.d.ts.map +1 -1
  16. package/dist/cli/index.js +408 -127
  17. package/dist/cli/status-domain.preload.d.ts +1 -1
  18. package/dist/cli/status-domain.preload.d.ts.map +1 -1
  19. package/dist/cli/status-fixture.d.ts +17 -0
  20. package/dist/cli/status-fixture.d.ts.map +1 -0
  21. package/dist/index.js +43 -4
  22. package/dist/lib/audience-contract.d.ts +9 -9
  23. package/dist/lib/compact-output.d.ts +67 -0
  24. package/dist/lib/compact-output.d.ts.map +1 -0
  25. package/dist/mcp/handlers/core.d.ts.map +1 -1
  26. package/dist/mcp/index.d.ts +2 -1
  27. package/dist/mcp/index.d.ts.map +1 -1
  28. package/dist/mcp/index.js +378 -194
  29. package/dist/mcp/profile.d.ts +8 -0
  30. package/dist/mcp/profile.d.ts.map +1 -0
  31. package/dist/mcp/register-tools.d.ts +3 -1
  32. package/dist/mcp/register-tools.d.ts.map +1 -1
  33. package/dist/mcp/storage-tools.d.ts +2 -1
  34. package/dist/mcp/storage-tools.d.ts.map +1 -1
  35. package/dist/mcp/tools.d.ts +7 -45
  36. package/dist/mcp/tools.d.ts.map +1 -1
  37. package/dist/sdk/index.js +1 -1
  38. package/dist/server/index.js +30 -16
  39. package/dist/server/pg-store.d.ts +1 -0
  40. package/dist/server/pg-store.d.ts.map +1 -1
  41. package/dist/store/index.d.ts.map +1 -1
  42. package/docs/chrome-autofill.md +45 -0
  43. package/extension/background.js +284 -0
  44. package/extension/content.js +392 -0
  45. package/extension/detect.js +228 -0
  46. package/extension/fill.js +112 -0
  47. package/extension/icons/128.png +0 -0
  48. package/extension/icons/16.png +0 -0
  49. package/extension/icons/32.png +0 -0
  50. package/extension/icons/48.png +0 -0
  51. package/extension/icons/icon.svg +7 -0
  52. package/extension/manifest.json +54 -0
  53. package/extension/popup.css +71 -0
  54. package/extension/popup.html +50 -0
  55. package/extension/popup.js +166 -0
  56. package/package.json +9 -5
@@ -0,0 +1,228 @@
1
+ // Contacts — shared identity/address field detection rules for the in-page picker.
2
+ //
3
+ // Loaded before content.js as a classic content script and loadable under
4
+ // node:vm in tests. It is PURE: it reads form-control attributes and labels,
5
+ // never values, never the Contacts API. The allowlist and aliases mirror
6
+ // fill.js, which stays self-contained because chrome.scripting serializes it.
7
+ (function (root, factory) {
8
+ if (typeof module !== "undefined" && module.exports) module.exports = factory();
9
+ else root.ContactsDetect = factory();
10
+ })(typeof globalThis !== "undefined" ? globalThis : this, function () {
11
+ const IDENTITY_FIELDS = new Set([
12
+ "name",
13
+ "given-name",
14
+ "additional-name",
15
+ "family-name",
16
+ "honorific-prefix",
17
+ "nickname",
18
+ "organization",
19
+ "organization-title",
20
+ "email",
21
+ "tel",
22
+ "tel-national",
23
+ "url",
24
+ "bday",
25
+ ]);
26
+ const ADDRESS_FIELDS = new Set([
27
+ "street-address",
28
+ "address-line1",
29
+ "address-line2",
30
+ "address-level1",
31
+ "address-level2",
32
+ "postal-code",
33
+ "country",
34
+ "country-name",
35
+ ]);
36
+ const FIELDS = new Set([...IDENTITY_FIELDS, ...ADDRESS_FIELDS]);
37
+ // Fields that make a form worth a Contacts badge on their own.
38
+ const BADGE_FIELDS = new Set(["name", "given-name", "family-name", "email", "tel", "tel-national", "organization"]);
39
+ const ALIASES = {
40
+ name: "name",
41
+ fullname: "name",
42
+ yourname: "name",
43
+ contactname: "name",
44
+ firstname: "given-name",
45
+ givenname: "given-name",
46
+ fname: "given-name",
47
+ forename: "given-name",
48
+ middlename: "additional-name",
49
+ lastname: "family-name",
50
+ familyname: "family-name",
51
+ surname: "family-name",
52
+ lname: "family-name",
53
+ nickname: "nickname",
54
+ title: "honorific-prefix",
55
+ salutation: "honorific-prefix",
56
+ company: "organization",
57
+ companyname: "organization",
58
+ organization: "organization",
59
+ organisation: "organization",
60
+ employer: "organization",
61
+ jobtitle: "organization-title",
62
+ position: "organization-title",
63
+ role: "organization-title",
64
+ email: "email",
65
+ emailaddress: "email",
66
+ mail: "email",
67
+ phone: "tel",
68
+ phonenumber: "tel",
69
+ telephone: "tel",
70
+ tel: "tel",
71
+ mobile: "tel",
72
+ mobilenumber: "tel",
73
+ cell: "tel",
74
+ website: "url",
75
+ url: "url",
76
+ homepage: "url",
77
+ birthday: "bday",
78
+ dateofbirth: "bday",
79
+ dob: "bday",
80
+ address: "address-line1",
81
+ address1: "address-line1",
82
+ addressline1: "address-line1",
83
+ streetaddress: "street-address",
84
+ street: "address-line1",
85
+ address2: "address-line2",
86
+ addressline2: "address-line2",
87
+ apartment: "address-line2",
88
+ suite: "address-line2",
89
+ city: "address-level2",
90
+ town: "address-level2",
91
+ locality: "address-level2",
92
+ state: "address-level1",
93
+ province: "address-level1",
94
+ region: "address-level1",
95
+ county: "address-level1",
96
+ zip: "postal-code",
97
+ zipcode: "postal-code",
98
+ postcode: "postal-code",
99
+ postalcode: "postal-code",
100
+ country: "country",
101
+ countrycode: "country",
102
+ countryname: "country-name",
103
+ };
104
+ const SKIP_TYPES = new Set([
105
+ "hidden",
106
+ "password",
107
+ "submit",
108
+ "button",
109
+ "checkbox",
110
+ "radio",
111
+ "file",
112
+ "image",
113
+ "reset",
114
+ "range",
115
+ "color",
116
+ ]);
117
+
118
+ function normalize(value) {
119
+ return String(value || "")
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9]/g, "");
122
+ }
123
+ function attr(el, name) {
124
+ try {
125
+ return typeof el.getAttribute === "function" ? el.getAttribute(name) : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+ function tagName(el) {
131
+ return String(el.tagName || el.localName || "").toLowerCase();
132
+ }
133
+
134
+ /** True for an enabled, writable text-like control worth inspecting. */
135
+ function isCandidate(el) {
136
+ if (!el) return false;
137
+ const tag = tagName(el);
138
+ if (tag !== "input" && tag !== "select" && tag !== "textarea") return false;
139
+ if (el.disabled || el.readOnly) return false;
140
+ if (tag === "input" && SKIP_TYPES.has(String(el.type || "text").toLowerCase())) return false;
141
+ if (attr(el, "aria-hidden") === "true") return false;
142
+ return true;
143
+ }
144
+
145
+ /**
146
+ * Map one control to a supported field name, or null. Autocomplete tokens win
147
+ * (an explicit unrelated token is authoritative); aliases fall back to the
148
+ * control's name/id, then its aria-label, placeholder and associated label.
149
+ * Input types email/tel/url count as hints when nothing else names the field.
150
+ */
151
+ function classify(el) {
152
+ if (!isCandidate(el)) return null;
153
+ const tokens = String(attr(el, "autocomplete") || "")
154
+ .toLowerCase()
155
+ .split(/\s+/);
156
+ const auto = tokens.find((t) => FIELDS.has(t));
157
+ if (auto) return auto;
158
+ if (tokens.includes("off") === false && tokens.some((t) => t && t !== "on")) return null;
159
+ const hints = [el.name, el.id, attr(el, "aria-label"), el.placeholder];
160
+ const labels = el.labels && el.labels.length ? Array.from(el.labels) : [];
161
+ for (const label of labels) hints.push(label.textContent);
162
+ for (const hint of hints) {
163
+ const key = normalize(hint);
164
+ if (key && ALIASES[key]) return ALIASES[key];
165
+ }
166
+ const type = String(el.type || "").toLowerCase();
167
+ if (type === "email") return "email";
168
+ if (type === "tel") return "tel";
169
+ if (type === "url") return "url";
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Every supported control under `root`, including open shadow roots. The
175
+ * shadow-host walk is bounded by `budget` elements per call so a very large
176
+ * document costs one bounded pass, never an unbounded tree walk.
177
+ */
178
+ function collect(root, out = [], budget = Infinity) {
179
+ if (!root || typeof root.querySelectorAll !== "function") return out;
180
+ for (const el of root.querySelectorAll("input,select,textarea")) {
181
+ const field = classify(el);
182
+ if (field) out.push({ el, field });
183
+ }
184
+ let seen = 0;
185
+ for (const host of root.querySelectorAll("*")) {
186
+ if (++seen > budget) break;
187
+ if (host.shadowRoot) collect(host.shadowRoot, out, budget);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /** Controls that get a picker badge: the name, the email, then the address. */
193
+ function anchors(list, max = 3) {
194
+ const order = ["name", "given-name", "email", "address-line1", "street-address", "tel", "organization"];
195
+ const chosen = [];
196
+ for (const field of order) {
197
+ const hit = list.find((x) => x.field === field && !chosen.includes(x));
198
+ if (hit) chosen.push(hit);
199
+ if (chosen.length >= max) break;
200
+ }
201
+ return chosen;
202
+ }
203
+
204
+ function summarize(list) {
205
+ const identity = list.filter((x) => IDENTITY_FIELDS.has(x.field)).length;
206
+ const address = list.length - identity;
207
+ return {
208
+ total: list.length,
209
+ identity,
210
+ address,
211
+ contact: list.some((x) => BADGE_FIELDS.has(x.field)),
212
+ };
213
+ }
214
+
215
+ return {
216
+ FIELDS,
217
+ IDENTITY_FIELDS,
218
+ ADDRESS_FIELDS,
219
+ BADGE_FIELDS,
220
+ ALIASES,
221
+ normalize,
222
+ isCandidate,
223
+ classify,
224
+ collect,
225
+ anchors,
226
+ summarize,
227
+ };
228
+ });
@@ -0,0 +1,112 @@
1
+ // This function is serialized by chrome.scripting; keep it self-contained.
2
+ // It runs in an isolated world, in a previously selected documentId only.
3
+ export function fillDocument(expectedOrigin, values, mode = "fill") {
4
+ if (location.origin !== expectedOrigin) return { status: "origin_changed", fields: [] };
5
+ const allowed = new Set([
6
+ "name",
7
+ "given-name",
8
+ "additional-name",
9
+ "family-name",
10
+ "honorific-prefix",
11
+ "nickname",
12
+ "organization",
13
+ "organization-title",
14
+ "email",
15
+ "tel",
16
+ "tel-national",
17
+ "url",
18
+ "bday",
19
+ "street-address",
20
+ "address-line1",
21
+ "address-line2",
22
+ "address-level1",
23
+ "address-level2",
24
+ "postal-code",
25
+ "country",
26
+ "country-name",
27
+ ]);
28
+ const aliases = {
29
+ name: "name", fullname: "name", yourname: "name", contactname: "name",
30
+ firstname: "given-name", givenname: "given-name", fname: "given-name", forename: "given-name",
31
+ middlename: "additional-name",
32
+ lastname: "family-name", familyname: "family-name", surname: "family-name", lname: "family-name",
33
+ nickname: "nickname", title: "honorific-prefix", salutation: "honorific-prefix",
34
+ company: "organization", companyname: "organization", organization: "organization", organisation: "organization", employer: "organization",
35
+ jobtitle: "organization-title", position: "organization-title", role: "organization-title",
36
+ email: "email", emailaddress: "email", mail: "email",
37
+ phone: "tel", phonenumber: "tel", telephone: "tel", tel: "tel", mobile: "tel", mobilenumber: "tel", cell: "tel",
38
+ website: "url", url: "url", homepage: "url",
39
+ birthday: "bday", dateofbirth: "bday", dob: "bday",
40
+ address: "address-line1", address1: "address-line1", addressline1: "address-line1", streetaddress: "street-address", street: "address-line1",
41
+ address2: "address-line2", addressline2: "address-line2", apartment: "address-line2", suite: "address-line2",
42
+ city: "address-level2", town: "address-level2", locality: "address-level2",
43
+ state: "address-level1", province: "address-level1", region: "address-level1", county: "address-level1",
44
+ zip: "postal-code", zipcode: "postal-code", postcode: "postal-code", postalcode: "postal-code",
45
+ country: "country", countrycode: "country", countryname: "country-name",
46
+ };
47
+ const skipTypes = ["hidden", "password", "submit", "button", "checkbox", "radio", "file", "image", "reset", "range", "color"];
48
+ const nodes = [];
49
+ function walk(root) {
50
+ for (const el of root.querySelectorAll("*")) {
51
+ if (el.matches("input,select,textarea")) nodes.push(el);
52
+ if (el.shadowRoot) walk(el.shadowRoot);
53
+ }
54
+ }
55
+ walk(document);
56
+ const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
57
+ const groups = new Map();
58
+ for (const el of nodes) {
59
+ const tokens = (el.getAttribute("autocomplete") || "").toLowerCase().split(/\s+/);
60
+ let field = tokens.find((t) => allowed.has(t));
61
+ if (!field) {
62
+ if (tokens.some((t) => t && t !== "on" && t !== "off")) continue;
63
+ const hints = [el.name, el.id, el.getAttribute("aria-label"), el.placeholder];
64
+ for (const label of el.labels ? Array.from(el.labels) : []) hints.push(label.textContent);
65
+ for (const hint of hints) {
66
+ const key = norm(hint);
67
+ if (key && aliases[key]) { field = aliases[key]; break; }
68
+ }
69
+ if (!field) {
70
+ const type = String(el.type || "").toLowerCase();
71
+ field = type === "email" ? "email" : type === "tel" ? "tel" : type === "url" ? "url" : undefined;
72
+ }
73
+ }
74
+ if (!field || el.matches(":disabled") || el.readOnly || skipTypes.includes(el.type)) continue;
75
+ if (el.closest('[inert],[aria-hidden="true"]')) continue;
76
+ const style = getComputedStyle(el);
77
+ if (style.visibility !== "visible" || Number(style.opacity) === 0 || !el.getClientRects().length) continue;
78
+ if (el.checkVisibility && !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
79
+ if (!groups.has(field)) groups.set(field, []);
80
+ groups.get(field).push(el);
81
+ }
82
+ if (mode === "inspect") return { status: "ready", fields: [...groups].map(([field, els]) => ({ field, count: els.length })) };
83
+ const results = [];
84
+ for (const [field, els] of groups) {
85
+ if (els.length !== 1) { results.push({ field, status: "ambiguous" }); continue; }
86
+ const key = field === "country-name" ? "country" : field === "tel-national" ? "tel" : field;
87
+ const value = values[key];
88
+ if (!value) { results.push({ field, status: "unavailable" }); continue; }
89
+ const el = els[0];
90
+ let next = value;
91
+ if (field === "bday" && el.type === "date") next = value.slice(0, 10);
92
+ if (el instanceof HTMLSelectElement) {
93
+ const candidates = [norm(next)];
94
+ if (field === "country" || field === "country-name") {
95
+ try { candidates.push(norm(new Intl.DisplayNames(["en"], { type: "region" }).of(next.toUpperCase()))); } catch {}
96
+ }
97
+ const options = [...el.options].filter((o) => !o.disabled && (candidates.includes(norm(o.value)) || candidates.includes(norm(o.textContent))));
98
+ if (options.length !== 1) { results.push({ field, status: "option_missing_or_ambiguous" }); continue; }
99
+ next = options[0].value;
100
+ } else if (el.maxLength > 0 && next.length > el.maxLength) {
101
+ results.push({ field, status: "too_short" }); continue;
102
+ }
103
+ const proto = el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
104
+ try {
105
+ Object.getOwnPropertyDescriptor(proto, "value").set.call(el, next);
106
+ el.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
107
+ el.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
108
+ results.push({ field, status: el.value === next ? "filled" : "rejected" });
109
+ } catch { results.push({ field, status: "rejected" }); }
110
+ }
111
+ return { status: results.some((x) => x.status === "filled") ? "filled" : "no_fields_filled", fields: results };
112
+ }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
2
+ <rect x="4" y="4" width="120" height="120" rx="28" fill="#0A0A0A"/>
3
+ <g fill="none" stroke="#FFFFFF" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
4
+ <circle cx="64" cy="50" r="17"/>
5
+ <path d="M32 100c4-20 17-30 32-30s28 10 32 30"/>
6
+ </g>
7
+ </svg>
@@ -0,0 +1,54 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Hasna Contacts",
4
+ "version": "0.9.1",
5
+ "description": "Fill names, emails, phones, companies and addresses from your Contacts. Detects identity fields on the page and offers your contacts inline; nothing is filled until you pick one.",
6
+ "minimum_chrome_version": "119",
7
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzMCnB26YET/ZdPDrwxAnrgeekYVPjoWHUEFUHpiYHFtgjyuBsh63wTlw+nBn0730e84FbbWJ04WYzy9TQcaZK9haaGOALouQa9KTzufHC6JB1T7KdlnPGDgiIcUxsqXn18Rc3J8eWCYB4vX0wc0OgevLDjvbR917XwUpNFxv8jkVpe/yyyRvhG+MYFw9Mwy2j2FMqNHwmtlGXWEt9ZmcTUx0No2dCxbRGdZA9QzcpPgL2i2BTul1eQpLfc2kSbaBMtVrrwPXz0Ara0qjXQP2RLyHlj0t1f0W29AVm6mjRwVGxw/3M4kXfOsUpeEBSQxC8M3KMRd1sYgJ2GFNwmY/fwIDAQAB",
8
+ "icons": {
9
+ "16": "icons/16.png",
10
+ "32": "icons/32.png",
11
+ "48": "icons/48.png",
12
+ "128": "icons/128.png"
13
+ },
14
+ "permissions": [
15
+ "activeTab",
16
+ "scripting",
17
+ "nativeMessaging",
18
+ "storage",
19
+ "tabs",
20
+ "webNavigation",
21
+ "alarms"
22
+ ],
23
+ "host_permissions": [
24
+ "https://*/*",
25
+ "http://127.0.0.1/*",
26
+ "http://localhost/*"
27
+ ],
28
+ "content_scripts": [
29
+ {
30
+ "matches": ["https://*/*", "http://127.0.0.1/*", "http://localhost/*"],
31
+ "js": ["detect.js", "content.js"],
32
+ "run_at": "document_idle",
33
+ "all_frames": true,
34
+ "match_about_blank": false
35
+ }
36
+ ],
37
+ "background": {
38
+ "service_worker": "background.js",
39
+ "type": "module"
40
+ },
41
+ "action": {
42
+ "default_popup": "popup.html",
43
+ "default_title": "Contacts",
44
+ "default_icon": {
45
+ "16": "icons/16.png",
46
+ "32": "icons/32.png",
47
+ "48": "icons/48.png",
48
+ "128": "icons/128.png"
49
+ }
50
+ },
51
+ "content_security_policy": {
52
+ "extension_pages": "script-src 'self'; object-src 'none'; connect-src 'none'"
53
+ }
54
+ }
@@ -0,0 +1,71 @@
1
+ /* Contacts — monochrome system. One ink, one paper, hairlines, inverted hover. */
2
+ :root {
3
+ color-scheme: light dark;
4
+ --paper: #ffffff;
5
+ --ink: #0a0a0a;
6
+ --muted: #6f6f6f;
7
+ --line: #e4e4e4;
8
+ --line-strong: #0a0a0a;
9
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
10
+ --sans: system-ui, -apple-system, "Segoe UI", "Helvetica Neue", sans-serif;
11
+ --r: 4px;
12
+ font: 13px / 1.45 var(--sans);
13
+ color: var(--ink);
14
+ background: var(--paper);
15
+ -webkit-font-smoothing: antialiased;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root { --paper: #0a0a0a; --ink: #f4f4f4; --muted: #8c8c8c; --line: #262626; --line-strong: #f4f4f4; }
19
+ }
20
+ * { box-sizing: border-box; }
21
+ [hidden] { display: none !important; }
22
+ body { margin: 0; width: 336px; min-height: 180px; display: flex; flex-direction: column; background: var(--paper); }
23
+ h1, h2, p { margin: 0; }
24
+ code { font: 11px var(--mono); border: 1px solid var(--line); padding: 1px 5px; border-radius: var(--r); }
25
+ .muted { color: var(--muted); }
26
+ .small { font-size: 11px; }
27
+ .label { font: 10px var(--mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
28
+
29
+ .top { display: flex; align-items: center; gap: 10px; padding: 14px 14px 12px; border-bottom: 1px solid var(--line-strong); }
30
+ .mark { width: 22px; height: 22px; flex: none; background: var(--ink); color: var(--paper); border-radius: 5px; display: grid; place-items: center; }
31
+ .mark svg { width: 14px; height: 14px; display: block; }
32
+ .brand { min-width: 0; flex: 1; }
33
+ h1 { font: 11px var(--mono); letter-spacing: 0.18em; text-transform: uppercase; line-height: 1.2; }
34
+ .page { margin-top: 3px; font: 10.5px var(--mono); letter-spacing: 0.04em; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
35
+ .page[data-ready="true"] { color: var(--ink); }
36
+ .page[data-ready="true"]::before { content: ""; display: inline-block; width: 6px; height: 6px; background: var(--ink); margin-right: 7px; vertical-align: 1px; }
37
+
38
+ main { flex: 1; padding: 10px 14px 6px; }
39
+ .state { padding: 26px 8px 22px; text-align: center; color: var(--muted); }
40
+ .state h2 { color: var(--ink); font: 11px var(--mono); letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 8px; }
41
+ .state p + p { margin-top: 8px; }
42
+ .spinner { display: inline-block; width: 14px; height: 14px; border: 1px solid var(--line); border-top-color: var(--ink); border-radius: 50%; animation: spin 0.9s linear infinite; margin-bottom: 10px; }
43
+ @keyframes spin { to { transform: rotate(360deg); } }
44
+
45
+ .search { display: block; width: 100%; margin: 0 0 10px; padding: 9px 0 9px 22px; border: 0; border-bottom: 1px solid var(--line-strong); border-radius: 0; background: transparent url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236f6f6f' stroke-width='1.6'><circle cx='9' cy='9' r='5.5'/><path d='M13.5 13.5L17 17'/></svg>") left center/14px no-repeat; color: var(--ink); font: 13px var(--sans); }
46
+ .search::placeholder { color: var(--muted); }
47
+ .search:focus-visible { outline: none; border-bottom-width: 2px; }
48
+ .search::-webkit-search-cancel-button { -webkit-appearance: none; }
49
+
50
+ .page-actions { display: flex; flex-direction: column; gap: 6px; padding: 0 0 12px; }
51
+ .chip-btn { align-self: flex-start; border: 1px solid var(--line-strong); background: transparent; color: var(--ink); font: 10px var(--mono); letter-spacing: 0.12em; text-transform: uppercase; padding: 6px 10px; border-radius: var(--r); cursor: pointer; }
52
+ .chip-btn:hover, .chip-btn:focus-visible { background: var(--ink); color: var(--paper); outline: none; }
53
+
54
+ .section-label { display: flex; justify-content: space-between; align-items: baseline; padding: 4px 0 8px; }
55
+ .contacts { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); max-height: 300px; overflow: auto; }
56
+ .contact { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 6px 9px 4px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--ink); font: inherit; text-align: left; cursor: pointer; transition: background 0.1s ease, color 0.1s ease; }
57
+ .contact:hover:not(:disabled), .contact:focus-visible { background: var(--ink); color: var(--paper); outline: none; }
58
+ .contact:disabled { cursor: default; color: var(--muted); }
59
+ .contact .avatar { width: 28px; height: 28px; border: 1px solid currentColor; border-radius: 3px; display: grid; place-items: center; font: 10px var(--mono); letter-spacing: 0.06em; flex: none; }
60
+ .contact .meta { flex: 1; min-width: 0; }
61
+ .contact .name { display: block; font-weight: 500; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
62
+ .contact .sub { display: block; margin-top: 1px; font: 10px var(--mono); letter-spacing: 0.04em; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
63
+ .contact .arrow { flex: none; font: 10px var(--mono); letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.7; }
64
+ .contact.busy .arrow { animation: pulse 0.9s ease-in-out infinite; }
65
+ @keyframes pulse { 50% { opacity: 0.2; } }
66
+ .footnote { padding: 12px 0 4px; font-size: 11px; }
67
+
68
+ footer { padding: 8px 14px 12px; min-height: 30px; border-top: 1px solid var(--line); }
69
+ #status { font: 10.5px var(--mono); letter-spacing: 0.03em; color: var(--muted); overflow-wrap: anywhere; }
70
+ #status[data-kind="error"] { color: var(--ink); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
71
+ #status[data-kind="ok"] { color: var(--ink); }
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Contacts</title>
7
+ <link rel="stylesheet" href="popup.css">
8
+ </head>
9
+ <body>
10
+ <header class="top">
11
+ <span class="mark" aria-hidden="true">
12
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="10" cy="7.5" r="3.2"/><path d="M4.5 16.5c.6-3.6 2.9-5.5 5.5-5.5s4.9 1.9 5.5 5.5"/></svg>
13
+ </span>
14
+ <div class="brand">
15
+ <h1>Contacts</h1>
16
+ <p id="page" class="page">Checking this page…</p>
17
+ </div>
18
+ </header>
19
+
20
+ <main>
21
+ <section id="view-connecting" class="state">
22
+ <span class="spinner" aria-hidden="true"></span>
23
+ <p class="label">Connecting</p>
24
+ </section>
25
+
26
+ <section id="view-setup" class="state" hidden>
27
+ <h2>Not connected</h2>
28
+ <p>Contacts uses the <code>contacts</code> CLI already on this station. Run <code>contacts browser install</code>, then reopen Contacts.</p>
29
+ <p id="setup-reason" class="muted small"></p>
30
+ </section>
31
+
32
+ <section id="view-contacts" hidden>
33
+ <input id="query" class="search" type="search" placeholder="Search name, company, email" autocomplete="off" aria-label="Search contacts">
34
+ <div id="page-actions" class="page-actions" hidden>
35
+ <button id="detect-frames" class="chip-btn" type="button">Detect embedded fields</button>
36
+ <span class="muted small">Chrome asks before Contacts looks inside embedded content.</span>
37
+ </div>
38
+ <div class="section-label"><span class="label">Contacts</span><span class="label">Tap to fill</span></div>
39
+ <ul id="contacts" class="contacts" aria-label="Contacts"></ul>
40
+ <p id="empty" class="muted small" hidden>No contacts match. Try another name, company or email.</p>
41
+ <p class="muted footnote">Fills name, email, phone, company and address. Nothing is submitted.</p>
42
+ </section>
43
+ </main>
44
+
45
+ <footer>
46
+ <p id="status" role="status" aria-live="polite"></p>
47
+ </footer>
48
+ <script type="module" src="popup.js"></script>
49
+ </body>
50
+ </html>