@the-portland-company/shell 0.86.0 → 0.88.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/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ ## 0.88.0
2
+
3
+ Shared form primitives, so apps stop hand-rolling them.
4
+
5
+ - **New exports** `Button`, `Input`, `Select`, `Textarea` (plus `ButtonProps`, `ButtonVariant`, `ButtonSize`, `InputProps`, `SelectProps`, `TextareaProps`).
6
+ - **Button.** Variants `primary | secondary | ghost | danger`; sizes `md` (44px) and `sm` (36px); `loading` and `disabled`; `href` / `as` link-buttons; forwards its ref. Primary uses the `--app-btn-primary-*` tokens (Specs buttonPrimary).
7
+ - **Input, Select, Textarea.** `label`, `helperText`, `error`, `invalid`; 44px control height for Input and Select; forward their refs; `--app-field-*` tokens.
8
+ - **Rule.** Apps use these instead of a local Button / Input / Select / Textarea (README "Form primitives").
9
+ - **Native subpath.** `Button`, `Input`, `Select`, `Textarea`, `Card`, `PageHeader`, `FormColumn`, `BreadcrumbBar`, `HeaderTabBar` (and their types) are also exported from `@the-portland-company/shell/native`, which has no Chakra or react-router peer. The native `PageHeader` and `BreadcrumbBar` render breadcrumb links as plain anchors. The main entry is unchanged.
10
+ - Tests: `__tests__/formPrimitives.test.tsx`.
11
+
12
+ ## 0.87.1
13
+
14
+ Release fix only; same code as 0.87.0, which never reached npm. The 0.87.0 version bump regenerated `package-lock.json` with a newer npm that dropped two optional `yaml@2.9.1` entries, so the publish gate's `npm ci` refused it. The lock file is restored from 0.86.0 with only its version changed.
15
+
16
+ ## 0.87.0
17
+
18
+ The top-bar search works in Relationship Mode. Spec cited: Reference/UI-Platform "Top bar — required elements" (Relationship search returns the Account's Contacts only) and Data-System/UVP §7.1 (a result opens the Contact Card).
19
+
20
+ - **Why.** Reported by Ben 2026-10-18: clicking the search box in Contacts did nothing. The frame passed only a placeholder, so `NavTopBar` drew a read-only input in every app.
21
+ - **Relationship Mode.** The box takes typing; Enter opens Contacts → All Contacts filtered to the query: `?mode=relationship&page=contacts&tab=contacts&section=table&org_id=<tail>&q=<query>`. Same origin from inside Contacts, the `-staging` twin from a staging host. No app wiring and no new prop (an app never passes chrome configuration).
22
+ - **Campaign and Petition.** Unchanged: decorative until their search is built (Campaign voter lookup is Campaign-Voter-Record §3).
23
+ - **New export** `relationshipSearchHref(query, { orgId, fromApp, hostname })`.
24
+ - **Needs** Contacts to read `q` into its table search (politogy-contacts, same day).
25
+ - Tests: `__tests__/relationshipSearch.test.ts`, `__tests__/topbarSearch.test.tsx`.
26
+
1
27
  ## 0.86.0
2
28
 
3
29
  Campaign Mode gets a Tribes row. Spec cited: Specs/Tribes §2.3–§2.4 and Specs/Left-Navigation §9.2 (decided 2026-10-16 by Ben: Campaign and Relationship Tribes are separate; Campaign Tribes live in Campaign Mode).
package/README.md CHANGED
@@ -171,6 +171,52 @@ All `<ShellChrome>` props are optional — only supply what your app has. The ch
171
171
 
172
172
  ---
173
173
 
174
+ ## Form primitives
175
+
176
+ Shared `Button`, `Input`, `Select` and `Textarea`, styled from the shell's tokens (`--app-btn-*`, `--app-field-*`, `--color-*`). 5px radius, 12px horizontal padding.
177
+
178
+ | Export | Props |
179
+ |---|---|
180
+ | `<Button>` | `variant`: `primary` (default) \| `secondary` \| `ghost` \| `danger`; `size`: `md` (44px, default) \| `sm` (36px); `loading` (spinner, `aria-busy`, blocks clicks); `disabled`; `href` or `as` for a link-button (e.g. `as={Link} to="/x"`); `type` defaults to `button`; forwards its ref. |
181
+ | `<Input>` | All native input props plus `label`, `helperText`, `error`, `invalid`, `wrapperClassName`. 44px tall. Forwards its ref to the `<input>`. |
182
+ | `<Select>` | Same as `Input`, with `<option>` children. 44px tall. Forwards its ref to the `<select>`. |
183
+ | `<Textarea>` | Same as `Input`, 88px minimum height. Forwards its ref to the `<textarea>`. |
184
+
185
+ An `error` message marks the control invalid (`aria-invalid`, red border) and replaces the helper text; the label, helper and error are wired with `htmlFor` and `aria-describedby`.
186
+
187
+ These primitives (and `Card`, `PageHeader`, `FormColumn`) are also exported from `@the-portland-company/shell/native`, which needs no Chakra or react-router peer. Apps without those peers should import from `native`.
188
+
189
+ **Rule:** apps must use these instead of a local `Button`, `Input`, `Select` or `Textarea`. Do not hand-roll form controls or button styles in an app; if a control needs a new variant or state, add it here so every app gets it.
190
+
191
+ ## Enforcement
192
+
193
+ Spec: Components — shared primitives are mandatory. Apps must not hand-roll Button/Card/Input/Select/Textarea, and must not fall behind the shell version. Both are checked mechanically.
194
+
195
+ Adopt in three lines:
196
+
197
+ ```js
198
+ // eslint.config.js (ESLint 9+ flat config)
199
+ import shell from '@the-portland-company/shell/eslint'
200
+ export default [...shell.configs.recommended /* , your other configs */]
201
+ ```
202
+
203
+ ```json
204
+ // package.json
205
+ { "scripts": { "lint": "eslint . && shell-version-check" } }
206
+ ```
207
+
208
+ Run `shell-version-check` in CI too (it is a package `bin`, so `npx shell-version-check` works after install).
209
+
210
+ | Rule (`politogy-shell/…`) | Fails on |
211
+ |---|---|
212
+ | `no-local-primitives` | Declaring or exporting a local `Button`, `Card`, `Input`, `Select`, `Textarea`, `PageHeader` or `FormColumn` (re-exports from the shell are fine). |
213
+ | `no-raw-control-styling` | JSX `<button>`, `<input>`, `<select>`, `<textarea>` with `className` or `style`. Allowed in a file that imports the shell primitive of the same kind (wrapping). |
214
+ | `no-global-control-classes` | `className` strings matching `\b(btn\|input)(-[a-z]+)?\b`, including inside `clsx(...)`, ternaries and template literals. |
215
+
216
+ Escape hatch: put `// shell-primitive-exempt: <reason>` on the line above (or the same line as) the offending code. A reason is required. Prefer adding the missing variant to the shell instead.
217
+
218
+ `shell-version-check` exits 1 when the app's declared shell range cannot install the latest published version (`npm view`, 10s timeout; exit 2 if that lookup fails). `--offline` compares against the installed shell version instead; `--cwd=<dir>` checks another package. The shell package itself does not enable these rules.
219
+
174
220
  ## CSS imports
175
221
 
176
222
  ```tsx
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // Fails when the app's declared @the-portland-company/shell range cannot
3
+ // satisfy the latest published minor (or, with --offline, the installed one).
4
+ // Exit: 0 ok, 1 behind, 2 could not determine.
5
+ import { readFileSync, existsSync, realpathSync } from 'node:fs'
6
+ import { execFileSync } from 'node:child_process'
7
+ import { resolve, join } from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+
10
+ export const PKG = '@the-portland-company/shell'
11
+
12
+ const V = /^v?(\d+)\.(\d+)\.(\d+)/
13
+ export function parseVersion(s) {
14
+ const m = V.exec(String(s).trim())
15
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
16
+ }
17
+ const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
18
+
19
+ // One comparator token -> predicate list. Supports ^ ~ >= > <= < = x-ranges and *.
20
+ function comparator(tok) {
21
+ tok = tok.trim()
22
+ if (tok === '' || tok === '*' || tok === 'x' || tok === 'latest') return [() => true]
23
+ const m = /^(\^|~|>=|<=|>|<|=)?\s*v?(\d+|x|\*)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?(?:-[\w.]+)?$/.exec(tok)
24
+ if (!m) throw new Error(`Unsupported range "${tok}"`)
25
+ const op = m[1] || ''
26
+ const wild = (x) => x === undefined || x === 'x' || x === '*'
27
+ const p = [m[2], m[3], m[4]].map((x) => (wild(x) ? null : Number(x)))
28
+ const lo = [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0]
29
+ const ge = (v) => cmp(v, lo) >= 0
30
+ if (p[0] === null) return [() => true]
31
+ if (op === '>=') return [ge]
32
+ if (op === '>') return [(v) => (p[1] === null ? v[0] > p[0] : p[2] === null ? v[0] > p[0] || (v[0] === p[0] && v[1] > p[1]) : cmp(v, lo) > 0)]
33
+ if (op === '<') return [(v) => cmp(v, lo) < 0]
34
+ if (op === '<=') return [(v) => (p[1] === null ? v[0] <= p[0] : p[2] === null ? v[0] < p[0] || (v[0] === p[0] && v[1] <= p[1]) : cmp(v, lo) <= 0)]
35
+ let upper
36
+ if (op === '^') {
37
+ upper = p[0] > 0 || p[1] === null ? [p[0] + 1, 0, 0] : p[1] > 0 || p[2] === null ? [0, p[1] + 1, 0] : [0, 0, p[2] + 1]
38
+ } else if (op === '~') {
39
+ upper = p[1] === null ? [p[0] + 1, 0, 0] : [p[0], p[1] + 1, 0]
40
+ } else {
41
+ // exact or x-range
42
+ upper = p[1] === null ? [p[0] + 1, 0, 0] : p[2] === null ? [p[0], p[1] + 1, 0] : [p[0], p[1], p[2] + 1]
43
+ }
44
+ return [ge, (v) => cmp(v, upper) < 0]
45
+ }
46
+
47
+ export function satisfies(version, range) {
48
+ const v = parseVersion(version)
49
+ if (!v) throw new Error(`Bad version "${version}"`)
50
+ const r = String(range).trim()
51
+ if (/^(npm:|file:|link:|git|https?:|workspace:)/.test(r)) throw new Error(`Cannot evaluate range "${r}"`)
52
+ return r.split('||').some((set) => {
53
+ const toks = set.trim().replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1').split(/\s+/)
54
+ return toks.every((t) => comparator(t).every((f) => f(v)))
55
+ })
56
+ }
57
+
58
+ export function declaredRange(pkgJson) {
59
+ for (const k of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
60
+ if (pkgJson[k] && pkgJson[k][PKG]) return pkgJson[k][PKG]
61
+ }
62
+ return null
63
+ }
64
+
65
+ /** Pure decision: returns { ok, message }. */
66
+ export function evaluate({ range, target, source }) {
67
+ if (satisfies(target, range)) {
68
+ return { ok: true, message: `ok: ${PKG} "${range}" accepts ${source} ${target}.` }
69
+ }
70
+ const [maj, min] = parseVersion(target)
71
+ return {
72
+ ok: false,
73
+ message:
74
+ `FAIL: ${PKG} is declared as "${range}", which cannot install ${source} ${target}.\n` +
75
+ `Upgrade: npm install ${PKG}@^${maj}.${min}.0 (apps must not fall behind the shell).`,
76
+ }
77
+ }
78
+
79
+ function main(argv) {
80
+ const offline = argv.includes('--offline')
81
+ const cwdArg = argv.find((a) => a.startsWith('--cwd='))
82
+ const cwd = resolve(cwdArg ? cwdArg.slice(6) : process.cwd())
83
+ const pj = join(cwd, 'package.json')
84
+ if (!existsSync(pj)) {
85
+ console.error(`shell-version-check: no package.json in ${cwd}`)
86
+ return 2
87
+ }
88
+ const range = declaredRange(JSON.parse(readFileSync(pj, 'utf8')))
89
+ if (!range) {
90
+ console.error(`shell-version-check: ${PKG} is not a dependency of this package.`)
91
+ return 2
92
+ }
93
+ let target, source
94
+ try {
95
+ if (offline) {
96
+ const ip = join(cwd, 'node_modules', ...PKG.split('/'), 'package.json')
97
+ if (!existsSync(ip)) throw new Error(`${PKG} is not installed (run npm install)`)
98
+ target = JSON.parse(readFileSync(ip, 'utf8')).version
99
+ source = 'installed'
100
+ } else {
101
+ const out = execFileSync('npm', ['view', PKG, 'version'], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
102
+ target = out.trim()
103
+ source = 'latest published'
104
+ }
105
+ const res = evaluate({ range, target, source })
106
+ ;(res.ok ? console.log : console.error)(res.message)
107
+ return res.ok ? 0 : 1
108
+ } catch (e) {
109
+ console.error(`shell-version-check: could not determine version (${e.message.split('\n')[0]}). Use --offline to compare against the installed shell.`)
110
+ return 2
111
+ }
112
+ }
113
+
114
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
115
+ process.exit(main(process.argv.slice(2)))
116
+ }
117
+ export { main }
@@ -4549,6 +4549,67 @@ function LoadingScreen({
4549
4549
  }
4550
4550
  );
4551
4551
  }
4552
+
4553
+ // src/url-state.ts
4554
+ var URL_STATE_MODES = ["relationship", "campaign", "petition"];
4555
+ var URL_STATE_KEYS = ["mode", "page", "tab", "section", "org_id"];
4556
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4557
+ function isUrlStateMode(v) {
4558
+ return typeof v === "string" && URL_STATE_MODES.includes(v);
4559
+ }
4560
+ function searchOf(input) {
4561
+ if (input instanceof URLSearchParams) return new URLSearchParams(input);
4562
+ return new URLSearchParams();
4563
+ }
4564
+ var slugOrNull = (v) => v && SLUG_RE.test(v) ? v : null;
4565
+ function shortOrgId(v) {
4566
+ if (!v) return null;
4567
+ const tail = orgIdTail(v);
4568
+ if (tail) return tail;
4569
+ const t = String(v).trim().toLowerCase();
4570
+ return /^[0-9a-f]{6,7}$/.test(t) ? t : null;
4571
+ }
4572
+ function readUrlState(input) {
4573
+ const params = searchOf(input);
4574
+ const mode = params.get("mode");
4575
+ const orgRaw = params.get("org_id");
4576
+ const feature = new URLSearchParams();
4577
+ for (const [k, v] of params) if (!URL_STATE_KEYS.includes(k)) feature.append(k, v);
4578
+ return {
4579
+ mode: isUrlStateMode(mode) ? mode : null,
4580
+ page: slugOrNull(params.get("page")),
4581
+ tab: slugOrNull(params.get("tab")),
4582
+ section: slugOrNull(params.get("section")),
4583
+ org_id: shortOrgId(orgRaw),
4584
+ feature
4585
+ };
4586
+ }
4587
+ function buildSearch(state) {
4588
+ const out = new URLSearchParams();
4589
+ for (const key of URL_STATE_KEYS) {
4590
+ const v = state[key];
4591
+ if (v) out.set(key, v);
4592
+ }
4593
+ for (const [k, v] of state.feature) out.append(k, v);
4594
+ const s = out.toString();
4595
+ return s ? `?${s}` : "";
4596
+ }
4597
+ function writeUrlState(current, patch) {
4598
+ const state = readUrlState(current);
4599
+ if (patch.mode !== void 0) state.mode = patch.mode;
4600
+ if (patch.page !== void 0) state.page = patch.page;
4601
+ if (patch.tab !== void 0) state.tab = patch.tab;
4602
+ if (patch.section !== void 0) state.section = patch.section;
4603
+ if (patch.org_id !== void 0) state.org_id = shortOrgId(patch.org_id);
4604
+ if (patch.feature) {
4605
+ for (const [k, v] of Object.entries(patch.feature)) {
4606
+ if (URL_STATE_KEYS.includes(k)) continue;
4607
+ if (v === null) state.feature.delete(k);
4608
+ else state.feature.set(k, v);
4609
+ }
4610
+ }
4611
+ return buildSearch(state);
4612
+ }
4552
4613
  function parseCssUrl(value) {
4553
4614
  if (!value) return null;
4554
4615
  const m = /^url\(\s*(['"]?)(.*?)\1\s*\)$/i.exec(value.trim());
@@ -4686,6 +4747,22 @@ var MODE_SEARCH_PLACEHOLDER = {
4686
4747
  campaign: "Search voters, walk lists, polls",
4687
4748
  petition: "Search petitions, sheets, circulators"
4688
4749
  };
4750
+ function relationshipSearchHref(query, opts = {}) {
4751
+ const q = query.trim();
4752
+ if (!q) return null;
4753
+ const search = writeUrlState("", {
4754
+ mode: "relationship",
4755
+ page: "contacts",
4756
+ tab: "contacts",
4757
+ section: "table",
4758
+ org_id: opts.orgId ?? null,
4759
+ feature: { q }
4760
+ });
4761
+ if (opts.fromApp === "contacts") return `/${search}`;
4762
+ const staging = (opts.hostname ?? "").toLowerCase().includes("staging");
4763
+ const origin = staging ? "https://contacts-staging.politogyvrm.com" : POLITOGY_APPS.contacts.origin;
4764
+ return `${origin}/${search}`;
4765
+ }
4689
4766
  function PolitogyAppFrame({
4690
4767
  app,
4691
4768
  pathname,
@@ -4847,6 +4924,8 @@ function FrameBody({
4847
4924
  }, [cookieMode]);
4848
4925
  const coreOrgs = chrome?.orgs ?? [];
4849
4926
  const activeCoreOrg = coreOrgs.find((o) => o.id === chrome?.orgId) ?? null;
4927
+ const searchMode = appMode ?? mode.id ?? "relationship";
4928
+ const [searchText, setSearchText] = react.useState("");
4850
4929
  const navDisplay = chrome?.navDisplay ?? "both";
4851
4930
  const navMode = appMode ?? (app !== CORE_APP && isStandardNavMode(mode.id) ? mode.id : "relationship");
4852
4931
  const activeOrgId = chrome?.orgId ?? null;
@@ -4959,11 +5038,25 @@ function FrameBody({
4959
5038
  userMenu,
4960
5039
  onSignOut: handleSignOut,
4961
5040
  // Reference/UI-Platform "Top bar — required elements": Universal
4962
- // search is the only thing on the left end of the bar. No app wires
4963
- // search yet, so this stays decorative (`NavTopBarProps.search` — "an
4964
- // app that has not wired search yet still gets the correct chrome").
5041
+ // search is the only thing on the left end of the bar. In
5042
+ // Relationship Mode it searches the Account's Contacts: Enter opens
5043
+ // Contacts → All Contacts filtered to the query, from every app, with
5044
+ // no app wiring (an app never passes chrome configuration). Campaign
5045
+ // and Petition search are not built yet and stay decorative.
4965
5046
  search: {
4966
- placeholder: data?.searchPlaceholder ?? MODE_SEARCH_PLACEHOLDER[appMode ?? mode.id ?? "relationship"] ?? "Search"
5047
+ placeholder: data?.searchPlaceholder ?? MODE_SEARCH_PLACEHOLDER[searchMode] ?? "Search",
5048
+ ...searchMode === "relationship" ? {
5049
+ value: searchText,
5050
+ onChange: setSearchText,
5051
+ onSubmit: (value) => {
5052
+ const href = relationshipSearchHref(value, {
5053
+ orgId: activeCoreOrg?.id ?? session.accountId ?? null,
5054
+ fromApp: app,
5055
+ hostname: typeof window === "undefined" ? "" : window.location.hostname
5056
+ });
5057
+ if (href) window.location.assign(href);
5058
+ }
5059
+ } : {}
4967
5060
  },
4968
5061
  // No Build notes control (Reference/UI-Platform, decided 2026-10-03).
4969
5062
  // The action cluster is IDENTICAL everywhere — core included. Page
@@ -5692,6 +5785,7 @@ exports.footerLinks = footerLinks;
5692
5785
  exports.looksLikeEmail = looksLikeEmail;
5693
5786
  exports.modeLandingHref = modeLandingHref;
5694
5787
  exports.orgSwitchLanding = orgSwitchLanding;
5788
+ exports.relationshipSearchHref = relationshipSearchHref;
5695
5789
  exports.resolveIdentity = resolveIdentity;
5696
5790
  exports.standardNavIcons = standardNavIcons;
5697
5791
  exports.useChromeFooter = useChromeFooter;