The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.
79 errors, and two optional markers cleared 63 of them.
`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.
Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.
The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.
Measured against `dde4ba6`:
typecheck 6 errors, unchanged; no new error anywhere
lint exit 0, 0 errors, 289 warnings
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 83/83 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
103 lines
3.8 KiB
TypeScript
103 lines
3.8 KiB
TypeScript
/**
|
|
* Viewport facts the Owliver workspace is laid out from.
|
|
*
|
|
* Separate from both the panel and its context because both need them: the
|
|
* panel decides which presentation to render, the provider decides which state
|
|
* to start in, and a breakpoint that two modules each keep their own copy of is
|
|
* a breakpoint that will eventually disagree with itself. Keeping it here also
|
|
* keeps the two from importing each other in a cycle.
|
|
*/
|
|
import * as React from 'react';
|
|
|
|
/**
|
|
* Below this width there is no room for a column *beside* the dashboard — a
|
|
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stops
|
|
* being a column at all and becomes an overlay over the page.
|
|
*
|
|
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
|
|
* column out acceptably, so they keep the two-column workspace and only phones
|
|
* change presentation. Exported so the provider can start a phone with the
|
|
* workspace closed from the same number the layout switches on — two places
|
|
* asking "is this a phone" must never be able to disagree.
|
|
*/
|
|
export const STACK_BREAKPOINT = 768;
|
|
export const PHONE_QUERY = `(max-width: ${STACK_BREAKPOINT - 1}px)`;
|
|
|
|
/** Tracks viewport width so the panel can be clamped. */
|
|
export function useViewportWidth() {
|
|
const [width, setWidth] = React.useState(() =>
|
|
typeof window === 'undefined' ? 1440 : window.innerWidth
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
const onResize = () => setWidth(window.innerWidth);
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, []);
|
|
|
|
return width;
|
|
}
|
|
|
|
/**
|
|
* Whether the shell is in its phone layout.
|
|
*
|
|
* `matchMedia` rather than a width comparison, so the switch happens on exactly
|
|
* the same boundary the stylesheet uses. `window.innerWidth` is not that
|
|
* boundary on a phone: when anything on the page overflows, mobile browsers
|
|
* widen the layout viewport to fit it, `innerWidth` reports the widened value,
|
|
* and the layout that was supposed to rescue the page reads the viewport as a
|
|
* tablet and hands it a 400px sidebar instead. A media query is evaluated
|
|
* against the media, not against the content, so it cannot be talked out of the
|
|
* mobile layout by the very overflow the mobile layout exists to prevent.
|
|
*/
|
|
export function useIsPhone() {
|
|
const [phone, setPhone] = React.useState(() => {
|
|
try {
|
|
return window.matchMedia(PHONE_QUERY).matches;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
const mql = window.matchMedia(PHONE_QUERY);
|
|
const onChange = (e) => setPhone(e.matches);
|
|
setPhone(mql.matches);
|
|
mql.addEventListener('change', onChange);
|
|
return () => mql.removeEventListener('change', onChange);
|
|
}, []);
|
|
|
|
return phone;
|
|
}
|
|
|
|
/**
|
|
* The height the user can actually see, while a keyboard is open.
|
|
*
|
|
* `100dvh` follows the address bar but knows nothing about the on-screen
|
|
* keyboard: with the keyboard up, a `100dvh` sheet keeps its full height and
|
|
* puts its own composer underneath the keys — the one control the sheet exists
|
|
* to offer. `visualViewport` is the region left over, so the sheet is sized to
|
|
* that and the composer stays on screen.
|
|
*
|
|
* Returns `null` where the API is missing, and the caller falls back to the
|
|
* `100dvh` class. Nothing here is a fixed pixel height.
|
|
*/
|
|
export function useVisualViewport() {
|
|
const [box, setBox] = React.useState(null);
|
|
|
|
React.useEffect(() => {
|
|
const vv = window.visualViewport;
|
|
if (!vv) return undefined;
|
|
const update = () => setBox({ height: Math.round(vv.height), offsetTop: Math.round(vv.offsetTop) });
|
|
update();
|
|
vv.addEventListener('resize', update);
|
|
vv.addEventListener('scroll', update);
|
|
return () => {
|
|
vv.removeEventListener('resize', update);
|
|
vv.removeEventListener('scroll', update);
|
|
};
|
|
}, []);
|
|
|
|
return box;
|
|
}
|