-
-
Notifications
You must be signed in to change notification settings - Fork 77
Add a lazily loaded Company tab to the job page #2000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
3a35383
Add OpenSpec change: job-page company tab
strelov1 6e58e56
Lift company present-only derivations into $lib/companyDetails
strelov1 529bfdf
Add a lazily loaded Company tab to the job page
strelov1 fed801d
Derive the company panel's pending state instead of tracking it
strelov1 ebffe0a
Correct the proposal's verification note; mark tasks done
strelov1 e17b088
Lay the company panel out for a wide column: no cards, facts as columns
strelov1 257c3ac
Re-measure the summary clamp on resize
strelov1 3b84b68
Record the design-system adoption gain from the company panel
strelov1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| schema: spec-driven | ||
| created: 2026-08-16 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<slug>`. 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/<slug>` 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<slug>`. 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/<slug>`, 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/<slug>` 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. | ||
101 changes: 101 additions & 0 deletions
101
openspec/changes/job-page-company-tab/specs/company-info-display/spec.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <name> →" link resolving to `/companies/[slug]`. | ||
| - [x] 2.3 Render the loading skeleton, the empty state ("We don't have details on <name> | ||
| 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/<slug>`. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Align the Company panel component contract.
The proposal, requirement, and task list require
CompanyFactsandCompanyAbout.JobCompanyPanel.svelteinstead implements a separate wide layout. Keep one contract.openspec/changes/job-page-company-tab/proposal.md#L14-L16: describe the intended wide layout and shared company-detail derivations.openspec/changes/job-page-company-tab/specs/company-info-display/spec.md#L14-L17: require equivalent facts and summary content, or require the existing components.openspec/changes/job-page-company-tab/tasks.md#L18-L19: update the completed task to match the selected implementation.web/src/lib/components/JobCompanyPanel.svelte#L99-L152: composeCompanyFactsandCompanyAboutif exact component reuse remains required.📍 Affects 4 files
openspec/changes/job-page-company-tab/proposal.md#L14-L16(this comment)openspec/changes/job-page-company-tab/specs/company-info-display/spec.md#L14-L17openspec/changes/job-page-company-tab/tasks.md#L18-L19web/src/lib/components/JobCompanyPanel.svelte#L99-L152🤖 Prompt for AI Agents