diff --git a/design-system/scripts/adoption-baseline.json b/design-system/scripts/adoption-baseline.json index 22538a4e5..05c7e9572 100644 --- a/design-system/scripts/adoption-baseline.json +++ b/design-system/scripts/adoption-baseline.json @@ -1,14 +1,14 @@ { "Alert": 1, "Avatar": 0, - "Badge": 14, + "Badge": 15, "Button": 60, "Card": 1, "Chip": 3, "ConfirmDialog": 14, - "CountryFlag": 7, + "CountryFlag": 8, "Dialog": 8, - "EmptyState": 1, + "EmptyState": 2, "EntityLogo": 18, "FormField": 2, "Input": 14, @@ -19,9 +19,9 @@ "ProviderIcon": 7, "SectionLabel": 7, "SettingRow": 3, - "Skeleton": 1, + "Skeleton": 2, "Table": 6, "Tabs": 0, - "TabStrip": 3, + "TabStrip": 4, "Tooltip": 0 } diff --git a/openspec/changes/job-page-company-tab/.openspec.yaml b/openspec/changes/job-page-company-tab/.openspec.yaml new file mode 100644 index 000000000..f161d5cc4 --- /dev/null +++ b/openspec/changes/job-page-company-tab/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-16 diff --git a/openspec/changes/job-page-company-tab/design.md b/openspec/changes/job-page-company-tab/design.md new file mode 100644 index 000000000..25593dfd7 --- /dev/null +++ b/openspec/changes/job-page-company-tab/design.md @@ -0,0 +1,156 @@ +## Context + +`JobView.svelte` renders a job in two columns: a sticky sidebar of salary, actions and +facets, and a content column holding the model-written summary and the description. The +company appears only as a name, a logo and a link in the row above the title +(`JobView.svelte:230`). + +Everything the Company tab needs already exists: + +- `GET /api/v1/companies/:slug`, reached through `api.getCompany(slug, limit, offset)`, + returns `{ company, jobs, referral_available }`. +- `CompanyFacts.svelte` and `CompanyAbout.svelte` render a company's scalar facts and its + full description. Both are *present-only*: given a company with nothing to show they + render no markup at all, so neither can leave an empty box behind. + +The repo has previously been bitten by two things this change walks straight into, both +recorded in `GhostChecklist.svelte`'s history: wrapping an `aria-controls` target in +`{#if}` leaves a dangling IDREF on first paint, and the HTML `hidden` *attribute* loses +to a Tailwind display utility because the two have equal specificity and utilities are +emitted later in the stylesheet. + +## Goals / Non-Goals + +**Goals:** + +- Let a visitor read who the employer is without leaving the posting. +- Reuse the company page's rendering rather than growing a second one. +- Keep the company's copy out of the job page's crawlable HTML. + +**Non-Goals:** + +- No backend work. No new endpoint, no new field on the job wire shape. +- No URL for the tab. The tab is page state, not a route. +- No company job count in the panel. `Company` in `web/src/lib/types.ts` does not carry + `job_count` (only `CompanyListItem` does), and adding it to render one label is not + worth the wire change. +- No change to the sidebar, the header row, or the existing company link. + +## Decisions + +### The tab is page state, not a route + +The job page's other secondary surfaces are sub-routes: `/jobs/[slug]/fit`, +`/copies`, `/discussion`. The Company tab deliberately is not. + +A sub-route would need `noindex, follow` and a canonical back to the posting — the +treatment `copies/+page.svelte` already carries — because its content is a duplicate of +`/companies/`. A thin duplicate page that must be excluded from the index is a page +that should not exist. Local state costs nothing to exclude. + +The cost is that the tab is not linkable or restorable across a reload. For a panel whose +content has a canonical home one click away, that is an acceptable loss. + +*Alternative considered:* a sub-route with `noindex`. Rejected — more moving parts, and a +crawlable URL we would then have to spend effort keeping out of the index. + +### Fetch on first activation, cached for the visit + +`api.getCompany(slug, 1, 0)` is called the first time the tab is activated, and the +resolved company is held in component state. `limit=1` is the smallest fetch the endpoint +allows: the API clamps `limit` to at least 1 in `pageParams`, so a zero-job request is not +expressible. The one returned job is discarded. This is the same compromise +`web/src/routes/companies/[slug]/+page.server.ts` already documents, and it is a note for +the same future backend company-entity-only path, not a new debt. + +*Alternative considered:* prefetch on hover. Rejected as premature — the endpoint is fast +and a click already feels immediate; hover prefetch spends requests on every passing +cursor. + +### The strip is the design system's `TabStrip`; both contents stay mounted + +`TabStrip` (`design-system/src/tab-strip.svelte`) already owns everything a hand-rolled +row would have to re-earn: `role="tablist"`, a roving tabindex, arrow-key and Home/End +movement, and an overflow-scrolling row that degrades on a narrow viewport. It is the +component `/my/profile` uses. Writing a second tablist here would mean a second copy of +that keyboard contract, and the two would diverge. + +`TabStrip` points every tab's `aria-controls` at ONE panel id, which the call site owns. +That is what disarms the `aria-valid-attr-value` failure the repo hit in +`GhostChecklist.svelte`: the panel element is unconditional, so the IDREF cannot dangle. +The id comes from `$props.id()` — Svelte's SSR-stable per-instance primitive — rather than +a hardcoded string, so a second `JobView` on one page cannot claim the same panel. Note +`$props.id()` is only legal as a variable declaration's initializer; it cannot be +interpolated inline. + +Inside that panel, the two contents are toggled with `class={active ? 'block' : 'hidden'}` +rather than `{#if}`. Unmounting the company content would discard a fetch the visitor +already waited through, and re-render the description on every switch back. The `hidden` +*attribute* is deliberately not used: `[hidden] { display: none }` from preflight and a +Tailwind display utility have equal specificity, and the utility is emitted later, so the +utility wins and the element stays visible. + +Keeping the description mounted has no SEO cost: it is the default-selected content and +is in the server-rendered HTML either way. + +### The panel is keyed on the company slug + +`JobView` is not remounted when the route parameter changes, so a client-side navigation +to another job would otherwise leave the previous company's data in the panel. The panel +is wrapped in `{#key companySlug}`, the same treatment `VoteControl` already gets in this +file for the same reason. + +### Panel states + +`idle → loading → (loaded | empty | error)`. + +`empty` is decided after the fetch, by asking whether the company has anything worth +showing. It cannot be known before the fetch, which is why the tab is offered +unconditionally to any job with a company slug and the emptiness is reported inside the +panel rather than by withdrawing the tab. Withdrawing it would collapse the strip under +the visitor's cursor immediately after their click. + +`error` leaves the rest of the page untouched; the panel reports the failure and offers +the link to the company page, which is the content the visitor wanted anyway. + +### The present-only conditions move into a pure module + +`CompanyFacts` and `CompanyAbout` each decide internally whether they have anything to +render — the facts/badges lists and the trimmed description are `$derived` inside the +components. The panel's `empty` state has to ask the same question, and a second copy of +those conditions would drift: the panel would announce details, both cards would render +nothing, and the visitor would get a heading over a void. + +So the derivations move to a new `web/src/lib/companyDetails.ts` — `companyFacts`, +`companyBadges`, `companyDescription`, and a `hasCompanyDetails` predicate composed from +them. Both cards consume it, and the panel asks `hasCompanyDetails`. One definition, one +place to change. + +This also puts the feature's only real logic somewhere it can be tested. `web`'s vitest +runs in `environment: 'node'` with no Svelte compilation (`web/vitest.config.ts`), so a +component cannot be rendered in a test — but a pure module can, and the repo already +keeps company logic this way in `companyFacetModel.ts` beside `companyFacetModel.test.ts`. +Everything else in this change is markup and wiring, verified by `svelte-check` and by +inspection in a real browser. + +## Risks / Trade-offs + +- **A visitor clicks Company and gets "we don't have details yet".** → Unavoidable + without a per-job flag on the wire shape, which is a backend change this feature does + not justify. The panel always offers the link onward, so the click is never a dead end. + If the empty rate turns out to be high in practice, the fix is a boolean on the job + projection, and the seam for it is the panel's `empty` state. + +- **The tab is not restorable across reload or shareable.** → Accepted, per the routing + decision above. `/companies/` is the shareable surface. + +- **`limit=1` fetches a job nobody uses.** → One row, already the endpoint's floor. + Documented at the call site so it is not mistaken for carelessness. + +- **The strip changes the content column's vertical rhythm on every job page.** → Verified + visually against both a long and a short description before merge, plus the no-company + case where the strip must be absent entirely. + +## Open Questions + +None. diff --git a/openspec/changes/job-page-company-tab/proposal.md b/openspec/changes/job-page-company-tab/proposal.md new file mode 100644 index 000000000..c0e196c61 --- /dev/null +++ b/openspec/changes/job-page-company-tab/proposal.md @@ -0,0 +1,52 @@ +## Why + +A job page tells you everything about the role and almost nothing about who you would +work for. The catalogue already holds curated company facts and a full company summary +(`companies.company_info`, populated by the YC and hirebase importers), but today they +are reachable only by leaving the posting for `/companies/`. Somebody deciding +whether a role is worth an application has to abandon the page to answer "who are these +people?" — and often does not come back. + +## What Changes + +- The job detail page gains a two-tab strip over its content column: **Description** + (the current content, default) and **Company**. +- The Company tab loads the company on first activation and shows the same facts card + and About summary the company page already renders, plus a link through to the + company's full page. +- The strip appears only on jobs that carry a `company_slug`. Jobs without one render + exactly as they do today. +- The company content is deliberately **not** server-rendered. Inlining a company's + summary into the HTML of every one of its postings would put hundreds of near-identical + pages in competition with `/companies/`, which is the page that should rank for + that company. Lazy loading keeps the copy out of the crawlable HTML while the existing + server-rendered link to `/companies/` continues to carry the internal-link value. + +No breaking changes. + +## Capabilities + +### New Capabilities + +None. This adds a second surface for a capability that already exists. + +### Modified Capabilities + +- `company-info-display`: currently specifies company-info rendering on the company + detail page only. A requirement is added for the job detail page surface — a lazily + loaded Company tab, its absent/empty/failed states, and the rule that this surface is + never server-rendered. + +## Impact + +- `web/src/lib/components/JobView.svelte` — the content column gains the tab strip. +- `web/src/lib/components/JobCompanyPanel.svelte` — new; owns the fetch, the panel + states, and reuses `CompanyFacts` and `CompanyAbout`. +- No backend change. The panel calls the existing `GET /api/v1/companies/:slug` through + `api.getCompany`. +- No change to canonical URLs, `robots`, sitemaps, or the job route's `+page.svelte`. +- Verification is by `pnpm --dir web test` over the lifted derivations, then + `svelte-check` and headless-Chrome inspection for the markup and wiring. `web`'s vitest + runs in `environment: 'node'` with no Svelte compilation, so a component itself cannot + be rendered in a test — which is why the feature's only real logic was lifted into a + plain module where it can be. diff --git a/openspec/changes/job-page-company-tab/specs/company-info-display/spec.md b/openspec/changes/job-page-company-tab/specs/company-info-display/spec.md new file mode 100644 index 000000000..6ab80582a --- /dev/null +++ b/openspec/changes/job-page-company-tab/specs/company-info-display/spec.md @@ -0,0 +1,101 @@ +## ADDED Requirements + +### Requirement: Job page surfaces company info behind a lazily loaded tab + +The job detail page SHALL render its content column behind a tab strip of exactly two +tabs, `Description` and `Company`, with `Description` selected on load. The `Description` +tab SHALL hold the content the column renders today (the model-written summary, when +present, followed by the job description). + +The tab strip SHALL be rendered only when the job carries a company slug. A job without +one SHALL render its content column exactly as it did before this change, with no tab +strip and no company surface. + +The `Company` tab SHALL fetch the company only when it is first activated, and SHALL +reuse that result for every later activation within the same page visit. Once loaded, it +SHALL render the company's facts card and About summary — the same components the company +detail page renders — followed by a link to that company's page. + +The tab strip SHALL NOT be removed or restructured as a result of loading, whatever the +outcome: a tab that was offered before the click SHALL still be present after it. + +#### Scenario: Company tab loads on first activation + +- **WHEN** a visitor opens a job whose company is known and clicks the `Company` tab +- **THEN** the company is fetched, and the panel shows the company's facts card, its + About summary, and a link to the company's page + +#### Scenario: Company is fetched at most once per visit + +- **WHEN** a visitor switches to the `Company` tab, back to `Description`, and to + `Company` again +- **THEN** the company is fetched once and the second activation renders the cached + result without a further request + +#### Scenario: Job with no known company has no tab strip + +- **WHEN** a visitor opens a job that carries no company slug +- **THEN** no tab strip is rendered and the content column shows the summary and + description exactly as it does without this feature + +#### Scenario: Company with no facts and no description + +- **WHEN** the fetched company has neither company-info facts nor a description +- **THEN** the `Company` tab remains present and its panel states that no details are + held for that company yet, alongside the link to the company's page + +#### Scenario: Company fetch fails + +- **WHEN** the request for the company fails +- **THEN** the panel reports that the company details could not be loaded and offers the + link to the company's page, and the rest of the job page is unaffected + +#### Scenario: Navigating to another job discards the previous company + +- **WHEN** a visitor loads the `Company` tab for one job and then navigates client-side + to a job at a different company +- **THEN** the panel returns to its unloaded state and shows no data from the previous + company + +### Requirement: The job page company surface is never server-rendered + +Company facts and the company description SHALL NOT appear in the server-rendered HTML of +a job page. They SHALL be fetched by the browser only, and only in response to the visitor +activating the `Company` tab. + +This keeps a company's summary out of the crawlable HTML of every posting that company +has open, so those pages do not compete with the company's own page for it. The job page's +server-rendered link to the company page SHALL be retained as the crawlable path between +the two. + +#### Scenario: Server-rendered job page carries no company copy + +- **WHEN** a job page is requested and its server-rendered HTML is inspected before any + script runs +- **THEN** the HTML contains no company facts and no company description, and it does + contain the link to the company's page + +### Requirement: The tab strip is operable by keyboard and exposed to assistive technology + +The tab strip SHALL expose the standard tab pattern: a tab list, one tab per selectable +view carrying its selected state, and a tab panel the tabs drive. The panel id each tab +references SHALL resolve to an element in the document at every point in the page's life, +including before either tab has been activated. + +The content of the unselected tab SHALL remain in the document, hidden by style rather +than removed, so that switching away from the Company tab does not discard a company the +visitor has already waited for. + +The left and right arrow keys SHALL move the selection between tabs. + +#### Scenario: A tab's panel reference always resolves + +- **WHEN** a job page with a tab strip finishes its first render, before any tab is + clicked +- **THEN** each tab's referenced panel id resolves to an element in the document, and the + content of the unselected tab is present but not displayed + +#### Scenario: Arrow keys move between tabs + +- **WHEN** a tab has keyboard focus and the visitor presses the right or left arrow key +- **THEN** the selection moves to the adjacent tab and its panel is shown diff --git a/openspec/changes/job-page-company-tab/tasks.md b/openspec/changes/job-page-company-tab/tasks.md new file mode 100644 index 000000000..ee0af9843 --- /dev/null +++ b/openspec/changes/job-page-company-tab/tasks.md @@ -0,0 +1,46 @@ +## 1. Present-only conditions as a pure module + +- [x] 1.1 Write `web/src/lib/companyDetails.test.ts` first: `companyFacts`, + `companyBadges`, `companyDescription` and `hasCompanyDetails` over a company with + everything, a company with nothing, and the partial cases in between (facts but no + description, description but no facts, badges only, whitespace-only description). +- [x] 1.2 Add `web/src/lib/companyDetails.ts` until those tests pass, lifting the + derivations out of `CompanyFacts.svelte` and `CompanyAbout.svelte` unchanged. +- [x] 1.3 Rewire `CompanyFacts.svelte` and `CompanyAbout.svelte` to consume the module so + there is one definition of "has anything to show", not three. + +## 2. Company panel + +- [x] 2.1 Create `web/src/lib/components/JobCompanyPanel.svelte` taking a company slug and + name, with the `idle → loading → (loaded | empty | error)` state machine and a single + `api.getCompany(slug, 1, 0)` call fired on first activation and cached thereafter. + Document at the call site why `limit=1` and why the returned job is discarded. +- [x] 2.2 Render the loaded state: `CompanyFacts` and `CompanyAbout`, followed by an + "All jobs at →" link resolving to `/companies/[slug]`. +- [x] 2.3 Render the loading skeleton, the empty state ("We don't have details on + yet." plus the link) and the error state ("Couldn't load company details" plus the + link), choosing `empty` via `hasCompanyDetails`. + +## 3. Tab strip on the job page + +- [x] 3.1 In `web/src/lib/components/JobView.svelte`, wrap the content column's existing + summary and description in a `Description` panel and add the `Company` panel beside it, + rendering the strip only when `job.company_slug` is set. +- [x] 3.2 Wire the tab semantics: `role="tablist"`/`role="tab"`/`role="tabpanel"`, + `aria-selected`, `aria-controls` and `id` pairs from `$props.id()`, and left/right + arrow-key movement between tabs. +- [x] 3.3 Toggle panel visibility with the Tailwind `hidden`/`block` utilities, keeping + both panels mounted — not `{#if}`, and not the `hidden` attribute. Key the company panel + on the company slug so a client-side navigation to another job resets it. + +## 4. Verification + +- [x] 4.1 `pnpm --dir web test` green (the 1006-test baseline plus the new module's), then + `pnpm --dir web check` and the repo's eslint over the touched files, clean of new issues. +- [x] 4.2 Visually verify in headless Chrome against a locally served job page: the strip + renders, the Company tab loads and shows facts plus About, a job with no company slug + shows no strip, and the empty-company case reads correctly. +- [x] 4.3 Assert over CDP, before any click, that each tab's `aria-controls` resolves to a + real element and that the inactive panel computes to `display: none`. +- [x] 4.4 Confirm the server-rendered HTML of a job page contains no company description + or facts, and still contains the link to `/companies/`. diff --git a/web/src/lib/companyDetails.test.ts b/web/src/lib/companyDetails.test.ts new file mode 100644 index 000000000..ff660a4fd --- /dev/null +++ b/web/src/lib/companyDetails.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'vitest'; +import type { Company, CompanyInfo } from './types'; +import { + companyBadges, + companyDescription, + companyFacts, + hasCompanyDetails, +} from './companyDetails'; + +// A company carries a good deal of scoring/voting bookkeeping none of these +// derivations look at, so the fixture fills it once and each test overrides only +// the fields under examination. +function company(over: Partial = {}, info?: CompanyInfo): Company { + return { + slug: 'acme', + name: 'Acme', + collections: [], + created_at: null, + updated_at: null, + upvote_count: 0, + downvote_count: 0, + my_vote: 0, + feedback_count: 0, + feedback_rating_avg: null, + ...over, + ...(info ? { company_info: info } : {}), + }; +} + +describe('companyDescription', () => { + test('is the trimmed company-info description', () => { + expect(companyDescription(company({}, { description: ' Acme builds tools. ' }))).toBe( + 'Acme builds tools.' + ); + }); + + test('is empty when the company has no company-info at all', () => { + expect(companyDescription(company())).toBe(''); + }); + + test('is empty when the description is only whitespace', () => { + expect(companyDescription(company({}, { description: ' \n ' }))).toBe(''); + }); +}); + +describe('companyBadges', () => { + test('names the three curated YC flags in a fixed order', () => { + expect( + companyBadges(company({}, { top_company: true, is_hiring: true, stage: 'Seed' })) + ).toEqual(['YC Top Company', 'Hiring', 'Seed-stage']); + }); + + test('omits the flags that are absent or false', () => { + expect(companyBadges(company({}, { top_company: false, is_hiring: true }))).toEqual(['Hiring']); + }); + + test('is empty for an uncurated company', () => { + expect(companyBadges(company())).toEqual([]); + }); +}); + +describe('companyFacts', () => { + test('is empty for a company with nothing recorded', () => { + expect(companyFacts(company())).toEqual([]); + }); + + test('orders the scalar columns founded, employees, headquarters, type', () => { + const facts = companyFacts( + company({ + year_founded: 2015, + employee_count: 1200, + hq_country: 'US', + organization_type: 'Private', + }) + ); + expect(facts).toEqual([ + { term: 'Founded', value: '2015' }, + { term: 'Employees', value: '1,200' }, + { term: 'Headquarters', value: 'United States', flag: 'US' }, + { term: 'Type', value: 'Private' }, + ]); + }); + + test('composes a stock listing from exchange and symbol', () => { + expect(companyFacts(company({}, { stock: { exchange: 'NASDAQ', symbol: 'ACME' } }))).toEqual([ + { term: 'Listed', value: 'NASDAQ: ACME' }, + ]); + }); + + test('falls back to the bare symbol when the exchange is unknown', () => { + expect(companyFacts(company({}, { stock: { symbol: 'ACME' } }))).toEqual([ + { term: 'Listed', value: 'ACME' }, + ]); + }); + + test('drops a stock entry that carries no symbol', () => { + expect(companyFacts(company({}, { stock: { exchange: 'NASDAQ' } }))).toEqual([]); + }); + + test('composes a funding line from the present parts only', () => { + expect( + companyFacts(company({}, { funding: { type: 'Series C', amount: 250_000_000, year: 2021 } })) + ).toEqual([{ term: 'Funding', value: 'Series C · $250M · 2021' }]); + expect(companyFacts(company({}, { funding: { type: 'Seed' } }))).toEqual([ + { term: 'Funding', value: 'Seed' }, + ]); + }); + + test('scales a funding amount to K, M and B', () => { + const amountOf = (amount: number) => + companyFacts(company({}, { funding: { amount } }))[0]?.value; + expect(amountOf(500_000)).toBe('$500K'); + expect(amountOf(250_000_000)).toBe('$250M'); + expect(amountOf(1_200_000_000)).toBe('$1.2B'); + expect(amountOf(2_000_000_000)).toBe('$2B'); + expect(amountOf(750)).toBe('$750'); + }); + + test('lists the parent company and joins the subsidiaries', () => { + expect( + companyFacts(company({}, { parent: 'Globex', subsidiaries: ['Initech', 'Umbrella'] })) + ).toEqual([ + { term: 'Parent', value: 'Globex' }, + { term: 'Subsidiaries', value: 'Initech, Umbrella' }, + ]); + }); + + test('ignores an empty subsidiaries list', () => { + expect(companyFacts(company({}, { subsidiaries: [] }))).toEqual([]); + }); +}); + +describe('hasCompanyDetails', () => { + test('is false when the company has no facts, no badges and no description', () => { + expect(hasCompanyDetails(company())).toBe(false); + }); + + test('is true on a scalar fact alone', () => { + expect(hasCompanyDetails(company({ year_founded: 2015 }))).toBe(true); + }); + + test('is true on a curated badge alone', () => { + expect(hasCompanyDetails(company({}, { is_hiring: true }))).toBe(true); + }); + + test('is true on a description alone', () => { + expect(hasCompanyDetails(company({}, { description: 'Acme builds tools.' }))).toBe(true); + }); + + test('is false when the only description is whitespace', () => { + expect(hasCompanyDetails(company({}, { description: ' ' }))).toBe(false); + }); +}); diff --git a/web/src/lib/companyDetails.ts b/web/src/lib/companyDetails.ts new file mode 100644 index 000000000..bc6287688 --- /dev/null +++ b/web/src/lib/companyDetails.ts @@ -0,0 +1,78 @@ +// The "what do we actually know about this company" derivations, lifted out of the +// components that render them. Three surfaces ask the same question — the facts card, +// the About card, and the job page's Company tab, which must decide whether to show +// its panel or say it holds nothing — and a second copy of these conditions would +// drift: the tab would promise details while both cards rendered nothing. +// +// Kept free of runes and `$app/*` so it runs under the repo's node-environment vitest +// (web/vitest.config.ts), the same shape as companyFacetModel.ts beside it. +import { countryLabel } from './facets'; +import type { Company } from './types'; + +/** One row of the facts card: a term, its display value, and — Headquarters only — + * the ISO country code whose flag precedes the value. */ +export type CompanyFact = { term: string; value: string; flag?: string }; + +/** The company's full summary, or '' when it holds none. */ +export function companyDescription(company: Company): string { + return company.company_info?.description?.trim() ?? ''; +} + +/** The curated YC-directory flags, as display labels. */ +export function companyBadges(company: Company): string[] { + const info = company.company_info ?? {}; + return [ + info.top_company ? 'YC Top Company' : null, + info.is_hiring ? 'Hiring' : null, + info.stage ? `${info.stage}-stage` : null, + ].filter((b): b is string => !!b); +} + +/** Compact money label: $250M, $1.2B, $500K. */ +function formatAmount(n: number): string { + if (n >= 1_000_000_000) return `$${(n / 1_000_000_000).toFixed(n % 1_000_000_000 ? 1 : 0)}B`; + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M`; + if (n >= 1_000) return `$${Math.round(n / 1_000)}K`; + return `$${n}`; +} + +/** The company's scalar facts, in display order. Present-only: an absent field drops + * out of the list rather than becoming a blank row. */ +export function companyFacts(company: Company): CompanyFact[] { + const info = company.company_info ?? {}; + + const funding = info.funding + ? [info.funding.type, info.funding.amount ? formatAmount(info.funding.amount) : null, info.funding.year] + .filter(Boolean) + .join(' · ') + : ''; + // "NASDAQ: ACME", or just "ACME" when the exchange is unknown. + const stock = info.stock?.symbol + ? [info.stock.exchange, info.stock.symbol].filter(Boolean).join(': ') + : ''; + + return [ + company.year_founded ? { term: 'Founded', value: String(company.year_founded) } : null, + company.employee_count + ? { term: 'Employees', value: company.employee_count.toLocaleString() } + : null, + company.hq_country + ? { term: 'Headquarters', value: countryLabel(company.hq_country), flag: company.hq_country } + : null, + company.organization_type ? { term: 'Type', value: company.organization_type } : null, + stock ? { term: 'Listed', value: stock } : null, + funding ? { term: 'Funding', value: funding } : null, + info.parent ? { term: 'Parent', value: info.parent } : null, + info.subsidiaries?.length ? { term: 'Subsidiaries', value: info.subsidiaries.join(', ') } : null, + ].filter((f): f is CompanyFact => !!f); +} + +/** Whether the company has anything at all worth rendering. False means every card + * below would render nothing, so a caller must not put a heading above them. */ +export function hasCompanyDetails(company: Company): boolean { + return ( + companyFacts(company).length > 0 || + companyBadges(company).length > 0 || + companyDescription(company) !== '' + ); +} diff --git a/web/src/lib/components/CompanyAbout.svelte b/web/src/lib/components/CompanyAbout.svelte index ac68eb098..da5face1f 100644 --- a/web/src/lib/components/CompanyAbout.svelte +++ b/web/src/lib/components/CompanyAbout.svelte @@ -1,13 +1,14 @@ {#if facts.length || badges.length} diff --git a/web/src/lib/components/JobCompanyPanel.svelte b/web/src/lib/components/JobCompanyPanel.svelte new file mode 100644 index 000000000..7dd7e5fa2 --- /dev/null +++ b/web/src/lib/components/JobCompanyPanel.svelte @@ -0,0 +1,153 @@ + + + +{#snippet companyLink()} + + All jobs at {name} → + +{/snippet} + +{#if pending} +
+ + +
+{:else if failed} + +{:else if empty} + +{:else if company} +
+ {#if badges.length} +
    + {#each badges as badge (badge)} +
  • {badge}
  • + {/each} +
+ {/if} + + + {#if facts.length} +
+ {#each facts as fact (fact.term)} +
+
+ {fact.term} +
+
+ {#if fact.flag}{/if} + {fact.value} +
+
+ {/each} +
+ {/if} + + {#if description} +
+

+ {description} +

+ {#if clampable || expanded} + + {/if} +
+ {/if} + +
{@render companyLink()}
+
+{/if} diff --git a/web/src/lib/components/JobView.svelte b/web/src/lib/components/JobView.svelte index 334393249..66958dccd 100644 --- a/web/src/lib/components/JobView.svelte +++ b/web/src/lib/components/JobView.svelte @@ -10,10 +10,11 @@ import { track } from '$lib/analytics'; import type { Job, UserJob } from '$lib/types'; import { companyLogoUrl } from '$lib/logo'; - import { Badge, Button, Chip, EntityLogo } from '$lib/ui'; + import { Badge, Button, Chip, EntityLogo, TabStrip, tabStripId } from '$lib/ui'; import { formatDate } from '$lib/utils'; import BackerBadge from './BackerBadge.svelte'; import CountryFlagStack from './CountryFlagStack.svelte'; + import JobCompanyPanel from './JobCompanyPanel.svelte'; import JobDescription from './JobDescription.svelte'; import JobMatch from './JobMatch.svelte'; import { supersedesReality } from '$lib/ghost'; @@ -65,6 +66,29 @@ const views = $derived(job.view_count ?? 0); const applies = $derived(job.applied_count ?? 0); + // The content column is tabbed whenever we know the employer, so "who are these + // people?" is answerable without leaving the posting. Page state, not a route: the + // company copy already has a canonical home at /companies/, and a second URL + // serving it would be a thin duplicate we'd then have to keep out of the index. + const CONTENT_TABS = [ + { id: 'description', label: 'Description' }, + { id: 'company', label: 'Company' }, + ] as const; + type ContentTab = (typeof CONTENT_TABS)[number]['id']; + + const companySlug = $derived(job.company_slug ?? ''); + let contentTab = $state('description'); + // Per-instance so a second JobView on one page can't claim the same panel, which + // would leave both strips' aria-controls pointing at the first one's panel. + const panelId = $props.id(); + // Reset when the visitor navigates client-side to another job: JobView is not + // remounted on a param change, so the Company tab would otherwise stay selected + // over a company they never asked about. + $effect(() => { + void job.public_slug; + contentTab = 'description'; + }); + // Funnel view — captured for everyone (unlike the authed-only server record // below). Keyed on the slug alone so it fires once per job and never re-emits // when an unrelated dependency (e.g. auth state) changes mid-view. @@ -206,6 +230,22 @@ {/snippet} + +{#snippet descriptionContent()} + {#if e.summary} + +
+

Summary

+

{e.summary}

+
+ {/if} + + +{/snippet} + -
-

Summary

-

{e.summary}

-
+ {#if companySlug} + (contentTab = id)} + label="Job details" + {panelId} + /> + +
+
+ {@render descriptionContent()} +
+
+ {#key companySlug} + + {/key} +
+
+ {:else} + {@render descriptionContent()} {/if} - -