@hasna/contacts 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.
Files changed (85) hide show
  1. package/README.md +93 -13
  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 +479 -149
  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/cloud/http-storage.d.ts +20 -12
  22. package/dist/cloud/http-storage.d.ts.map +1 -1
  23. package/dist/cloud/resolver-inputs.d.ts +51 -0
  24. package/dist/cloud/resolver-inputs.d.ts.map +1 -0
  25. package/dist/db/paths.d.ts +4 -4
  26. package/dist/db/paths.d.ts.map +1 -1
  27. package/dist/generated/storage-kit/backend.d.ts +19 -0
  28. package/dist/generated/storage-kit/backend.d.ts.map +1 -0
  29. package/dist/generated/storage-kit/index.d.ts +2 -1
  30. package/dist/generated/storage-kit/index.d.ts.map +1 -1
  31. package/dist/generated/storage-kit/migrations.d.ts +21 -0
  32. package/dist/generated/storage-kit/migrations.d.ts.map +1 -1
  33. package/dist/generated/storage-kit/own.d.ts +11 -0
  34. package/dist/generated/storage-kit/own.d.ts.map +1 -0
  35. package/dist/generated/storage-kit/pool.d.ts +5 -17
  36. package/dist/generated/storage-kit/pool.d.ts.map +1 -1
  37. package/dist/generated/storage-kit/query.d.ts +1 -1
  38. package/dist/generated/storage-kit/query.d.ts.map +1 -1
  39. package/dist/generated/storage-kit/tls.d.ts +30 -3
  40. package/dist/generated/storage-kit/tls.d.ts.map +1 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +148 -28
  44. package/dist/lib/audience-contract.d.ts +9 -9
  45. package/dist/lib/compact-output.d.ts +67 -0
  46. package/dist/lib/compact-output.d.ts.map +1 -0
  47. package/dist/mcp/handlers/core.d.ts.map +1 -1
  48. package/dist/mcp/index.d.ts +11 -1
  49. package/dist/mcp/index.d.ts.map +1 -1
  50. package/dist/mcp/index.js +490 -212
  51. package/dist/mcp/profile.d.ts +8 -0
  52. package/dist/mcp/profile.d.ts.map +1 -0
  53. package/dist/mcp/register-tools.d.ts +3 -1
  54. package/dist/mcp/register-tools.d.ts.map +1 -1
  55. package/dist/mcp/startup-gate.d.ts +48 -0
  56. package/dist/mcp/startup-gate.d.ts.map +1 -0
  57. package/dist/mcp/storage-tools.d.ts +2 -1
  58. package/dist/mcp/storage-tools.d.ts.map +1 -1
  59. package/dist/mcp/tools.d.ts +7 -45
  60. package/dist/mcp/tools.d.ts.map +1 -1
  61. package/dist/sdk/index.d.ts +70 -3
  62. package/dist/sdk/index.d.ts.map +1 -1
  63. package/dist/sdk/index.js +990 -7
  64. package/dist/server/index.js +231 -59
  65. package/dist/server/pg-store.d.ts +1 -0
  66. package/dist/server/pg-store.d.ts.map +1 -1
  67. package/dist/store/index.d.ts.map +1 -1
  68. package/docs/chrome-autofill.md +45 -0
  69. package/extension/background.js +284 -0
  70. package/extension/content.js +387 -0
  71. package/extension/detect.js +228 -0
  72. package/extension/fill.js +112 -0
  73. package/extension/icons/128.png +0 -0
  74. package/extension/icons/16.png +0 -0
  75. package/extension/icons/32.png +0 -0
  76. package/extension/icons/48.png +0 -0
  77. package/extension/icons/icon.svg +17 -0
  78. package/extension/manifest.json +54 -0
  79. package/extension/popup.css +78 -0
  80. package/extension/popup.html +47 -0
  81. package/extension/popup.js +166 -0
  82. package/hasna.contract.json +20 -6
  83. package/package.json +10 -6
  84. package/dist/lib/config.d.ts +0 -7
  85. package/dist/lib/config.d.ts.map +0 -1
@@ -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,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#2E4A7A"/>
5
+ <stop offset="1" stop-color="#172C4F"/>
6
+ </linearGradient>
7
+ <linearGradient id="fg" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0" stop-color="#FFFFFF"/>
9
+ <stop offset="1" stop-color="#D6E1F5"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect x="4" y="4" width="120" height="120" rx="30" fill="url(#bg)"/>
13
+ <circle cx="64" cy="50" r="19" fill="url(#fg)"/>
14
+ <path d="M28 104c3-22 18-33 36-33s33 11 36 33c0 3-2 5-5 5H33c-3 0-5-2-5-5z" fill="url(#fg)"/>
15
+ <circle cx="96" cy="36" r="11" fill="#E8B04B"/>
16
+ <path d="M91 36l3.5 3.5L101.5 32" fill="none" stroke="#172C4F" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
17
+ </svg>
@@ -0,0 +1,54 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Hasna Contacts",
4
+ "version": "0.9.0",
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,78 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #f6f8fb;
4
+ --surface: #ffffff;
5
+ --fg: #172033;
6
+ --muted: #66707f;
7
+ --line: #e0e6ee;
8
+ --brand: #2e4a7a;
9
+ --brand-2: #172c4f;
10
+ --brand-soft: #e9eff8;
11
+ --accent: #8fb0e6;
12
+ --danger: #9b3229;
13
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 8px 24px rgba(23, 44, 79, 0.06);
14
+ --radius: 12px;
15
+ font: 14px / 1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
16
+ color: var(--fg);
17
+ background: var(--bg);
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --bg: #12171f;
22
+ --surface: #1a2130;
23
+ --fg: #e6ebf3;
24
+ --muted: #97a1b1;
25
+ --line: #2a3446;
26
+ --brand: #4a6ea8;
27
+ --brand-2: #2e4a7a;
28
+ --brand-soft: #1f2a3d;
29
+ --accent: #6f93cc;
30
+ --danger: #f08a80;
31
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
32
+ }
33
+ }
34
+ * { box-sizing: border-box; }
35
+ body { margin: 0; width: 344px; min-height: 200px; display: flex; flex-direction: column; }
36
+ h1, h2, p { margin: 0; }
37
+ code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--brand-soft); padding: 1px 5px; border-radius: 5px; }
38
+ .muted { color: var(--muted); }
39
+ .small { font-size: 12px; }
40
+
41
+ .top { display: flex; align-items: center; gap: 10px; padding: 14px 16px 12px; border-bottom: 1px solid var(--line); background: var(--surface); }
42
+ .logo { border-radius: 8px; flex: none; }
43
+ .brand { min-width: 0; flex: 1; }
44
+ h1 { font-size: 15px; font-weight: 650; letter-spacing: -0.2px; line-height: 1.2; }
45
+ .page { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
46
+ .page[data-ready="true"]::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #2f9e6a; margin-right: 6px; vertical-align: 1px; }
47
+
48
+ main { padding: 12px 12px 4px; flex: 1; }
49
+ .state { padding: 22px 10px; text-align: center; color: var(--muted); }
50
+ .state h2 { color: var(--fg); font-size: 14px; font-weight: 650; margin-bottom: 6px; }
51
+ .state p + p { margin-top: 8px; }
52
+ .spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid var(--line); border-top-color: var(--brand); border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 10px; }
53
+ @keyframes spin { to { transform: rotate(360deg); } }
54
+
55
+ .search { display: block; width: 100%; margin: 0 0 10px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--fg); font: 14px system-ui, sans-serif; }
56
+ .search:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
57
+
58
+ .page-actions { display: flex; flex-direction: column; gap: 6px; padding: 0 4px 12px; }
59
+ .chip-btn { align-self: flex-start; border: 1px solid var(--line); background: var(--surface); color: var(--brand); font: 600 12px system-ui, sans-serif; padding: 6px 10px; border-radius: 999px; cursor: pointer; }
60
+ .chip-btn:hover, .chip-btn:focus-visible { border-color: var(--accent); outline: none; }
61
+
62
+ .contacts { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow: auto; }
63
+ .contact { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: inherit; font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow); transition: transform 0.12s ease, border-color 0.12s ease; }
64
+ .contact:hover:not(:disabled), .contact:focus-visible { border-color: var(--accent); transform: translateY(-1px); outline: none; }
65
+ .contact:disabled { cursor: default; opacity: 0.72; box-shadow: none; }
66
+ .contact .avatar { flex: none; width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, var(--brand), var(--brand-2)); color: #fff; font: 600 12px system-ui, sans-serif; display: grid; place-items: center; }
67
+ .contact .meta { flex: 1; min-width: 0; }
68
+ .contact .name { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
69
+ .contact .sub { display: block; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
70
+ .contact .arrow { flex: none; color: var(--muted); font-size: 16px; line-height: 1; }
71
+ .contact.busy .arrow { animation: pulse 0.9s ease-in-out infinite; }
72
+ @keyframes pulse { 50% { opacity: 0.3; } }
73
+ .footnote { padding: 12px 6px 4px; }
74
+
75
+ footer { padding: 6px 16px 12px; min-height: 30px; }
76
+ #status { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
77
+ #status[data-kind="error"] { color: var(--danger); }
78
+ #status[data-kind="ok"] { color: #2f9e6a; }
@@ -0,0 +1,47 @@
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
+ <img class="logo" src="icons/48.png" width="28" height="28" alt="">
12
+ <div class="brand">
13
+ <h1>Contacts</h1>
14
+ <p id="page" class="page">Checking this page…</p>
15
+ </div>
16
+ </header>
17
+
18
+ <main>
19
+ <section id="view-connecting" class="state">
20
+ <span class="spinner" aria-hidden="true"></span>
21
+ <p>Connecting to Contacts…</p>
22
+ </section>
23
+
24
+ <section id="view-setup" class="state" hidden>
25
+ <h2>Not connected</h2>
26
+ <p>Contacts uses the <code>contacts</code> CLI already on this station. Run <code>contacts browser install</code> in a terminal, then reopen Contacts.</p>
27
+ <p id="setup-reason" class="muted"></p>
28
+ </section>
29
+
30
+ <section id="view-contacts" hidden>
31
+ <input id="query" class="search" type="search" placeholder="Search contacts…" autocomplete="off" aria-label="Search contacts">
32
+ <div id="page-actions" class="page-actions" hidden>
33
+ <button id="detect-frames" class="chip-btn" type="button">Detect embedded fields</button>
34
+ <span class="muted small">Chrome asks before Contacts looks inside embedded content.</span>
35
+ </div>
36
+ <ul id="contacts" class="contacts" aria-label="Contacts"></ul>
37
+ <p id="empty" class="muted" hidden>No contacts match. Try another name, company or email.</p>
38
+ <p class="muted small footnote">Tap a contact to fill name, email, phone, company and address fields. Nothing is submitted.</p>
39
+ </section>
40
+ </main>
41
+
42
+ <footer>
43
+ <p id="status" role="status" aria-live="polite"></p>
44
+ </footer>
45
+ <script type="module" src="popup.js"></script>
46
+ </body>
47
+ </html>