Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] e1c14ca7ac Bump hono from 4.12.18 to 4.12.25
Bumps [hono](https://github.com/honojs/hono) from 4.12.18 to 4.12.25.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.25)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.25
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 03:52:58 +00:00
26 changed files with 295 additions and 775 deletions
@@ -4,12 +4,11 @@ import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
export function EmptyStateText({ children, className }: Props) {
return (
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div className="w-full h-full pb-2">
<div
className={classNames(
className,
+6 -112
View File
@@ -64,9 +64,7 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { FieldDef } from "./core/Editor/filter/extension";
import { filter } from "./core/Editor/filter/extension";
import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import {
@@ -81,7 +79,6 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
import { IconButton } from "./core/IconButton";
import type { InputHandle } from "./core/Input";
import { Input } from "./core/Input";
import { EmptyStateText } from "./EmptyStateText";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { GitDropdown } from "./git/GitDropdown";
import { gitCallbacks } from "./git/callbacks";
@@ -111,7 +108,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -230,7 +227,7 @@ function Sidebar({ className }: { className?: string }) {
);
const clearFilterText = useCallback(() => {
setSidebarFilterText("");
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` });
requestAnimationFrame(() => {
filterRef.current?.focus();
});
@@ -255,13 +252,6 @@ function Sidebar({ className }: { className?: string }) {
[],
);
const applyFilterExample = useCallback((text: string) => {
setSidebarFilterText(text);
requestAnimationFrame(() => {
filterRef.current?.focus();
});
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
@@ -664,43 +654,8 @@ function Sidebar({ className }: { className?: string }) {
)}
</div>
{allHidden ? (
<div className="p-3 text-sm text-center">
{(emptyFilterSuggestions?.length ?? 0) > 0 ? (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
>
<div>
No results, but found matches for{" "}
{emptyFilterSuggestions?.map((suggestion, i) => (
<span key={suggestion.field}>
{i > 0 && " or "}
<button
type="button"
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)}
>
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
{suggestion.filterText}
</InlineCode>
</button>
</span>
))}
</div>
</EmptyStateText>
) : (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
>
<div>
No results for{" "}
<InlineCode className="inline-block max-w-36 truncate align-middle">
{filterText.text}
</InlineCode>
</div>
</EmptyStateText>
)}
<div className="italic text-text-subtle p-3 text-sm text-center">
No results for <InlineCode>{filterText.text}</InlineCode>
</div>
) : (
<Tree
@@ -831,48 +786,7 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
key: "",
});
type SidebarFilterSuggestion = {
field: string;
filterText: string;
};
function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
function getSidebarSuggestionValue(ast: Ast | null) {
if (ast == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
if (ast.type === "Field") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
return null;
}
function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
}
const sidebarSuggestionFieldOrder = [
"url",
"folder",
"method",
"type",
"grpc_service",
"grpc_method",
"name",
];
const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
>((get) => {
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom);
@@ -893,11 +807,9 @@ const sidebarTreeAtom = atom<
}
const queryAst = parseQuery(filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst);
// returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {};
const suggestionFields = new Set<string>();
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
@@ -909,13 +821,6 @@ const sidebarTreeAtom = atom<
if (!value) continue;
allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value);
if (
isLeafNode &&
suggestionValue != null &&
sidebarFieldMatchesValue(value, suggestionValue)
) {
suggestionFields.add(field);
}
}
if (queryAst != null) {
@@ -969,18 +874,7 @@ const sidebarTreeAtom = atom<
values: Array.from(values).filter((v) => v.length < 20),
});
}
const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
})
.map((field) => ({
field,
filterText: formatFieldFilter(field, suggestionValue ?? ""),
}));
return [root, fields, suggestions] as const;
return [root, fields] as const;
});
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
@@ -15,9 +15,8 @@ export interface FilterOptions {
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
}
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
const VALUE_IDENT = /\S+$/;
const VALUE_IDENT_ONLY = /^\S+$/;
const IDENT = /[A-Za-z0-9_/]+$/;
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
function normalizeFields(fields: FieldDef[]): {
fieldNames: string[];
@@ -32,37 +31,14 @@ function normalizeFields(fields: FieldDef[]): {
return { fieldNames, fieldMap };
}
function wordBefore(
doc: string,
pos: number,
pattern: RegExp,
): { from: number; to: number; text: string } | null {
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
const upto = doc.slice(0, pos);
const m = upto.match(pattern);
const m = upto.match(IDENT);
if (!m) return null;
const from = pos - m[0].length;
return { from, to: pos, text: m[0] };
}
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
const w = wordBefore(doc, pos, FIELD_IDENT);
const from = w?.from ?? pos;
const beforeToken = doc[from - 1];
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
return { from, includeAt: true };
}
if (beforeToken === "@") {
const beforeAt = doc[from - 2];
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
return { from, includeAt: false };
}
}
return null;
}
function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
@@ -105,7 +81,7 @@ function contextInfo(stateDoc: string, pos: number) {
if (inValue) {
// word before the colon = field name
const beforeColon = stateDoc.slice(0, lastColon);
const m = beforeColon.match(FIELD_IDENT);
const m = beforeColon.match(IDENT);
fieldName = m ? m[0] : null;
// nothing (or only spaces) typed after the colon?
@@ -117,16 +93,15 @@ function contextInfo(stateDoc: string, pos: number) {
}
/** Build a completion list for field names */
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
function fieldNameCompletions(fieldNames: string[]): Completion[] {
return fieldNames.map((name) => ({
label: name,
type: "property",
apply: (view, _completion, from, to) => {
// Leave cursor right after the field filter colon.
const insert = `${includeAt ? "@" : ""}${name}:`;
// Insert "name:" (leave cursor right after colon)
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + insert.length },
changes: { from, to, insert: `${name}:` },
selection: { anchor: from + name.length + 1 },
});
startCompletion(view);
},
@@ -140,7 +115,7 @@ function fieldValueCompletions(
if (!def || !def.values) return null;
const vals = Array.isArray(def.values) ? def.values : def.values();
return vals.map((v) => ({
label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v,
type: "constant",
}));
@@ -157,13 +132,14 @@ function makeCompletionSource(opts: FilterOptions) {
return null;
}
const w = wordBefore(doc, pos);
const from = w?.from ?? pos;
const to = pos;
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
// In field value position
if (inValue && fieldName) {
const w = wordBefore(doc, pos, VALUE_IDENT);
const from = w?.from ?? pos;
const to = pos;
const valDefs = fieldMap[fieldName];
const vals = fieldValueCompletions(valDefs);
@@ -186,11 +162,7 @@ function makeCompletionSource(opts: FilterOptions) {
}
// Not in a value: suggest field names (and maybe boolean ops)
const completion = fieldCompletionFrom(doc, pos);
if (completion == null) return null;
const { from, includeAt } = completion;
const to = pos;
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
const options: Completion[] = fieldNameCompletions(fieldNames);
return { from, to, options, filter: true };
};
@@ -2,11 +2,10 @@
@skip { space+ }
@tokens {
space { $[ \t\r\n]+ }
space { std.whitespace+ }
LParen { "(" }
RParen { ")" }
At { "@" }
Colon { ":" }
Not { "-" | "NOT" }
@@ -17,10 +16,8 @@
// "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' }
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
// negation, but '-' may appear after the first character.
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
// field/word characters (keep generous for URLs/paths)
Word { $[A-Za-z0-9_]+ }
@precedence { Not, And, Or, Word }
}
@@ -63,12 +60,12 @@ Field {
}
FieldName {
At? Word
Word
}
FieldValue {
Phrase
| FieldValueWord
| Term
}
Term {
@@ -1,42 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./filter";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Query") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("filter grammar", () => {
test("parses URL-like field values as one value", () => {
const nodes = getNodeNames("@url:yaak.app/foo-bar");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses punctuation-heavy field values as one value", () => {
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses operator-looking field values as one value", () => {
const negativeValueNodes = getNodeNames("@url:-foo");
const operatorWordNodes = getNodeNames("@url:AND");
expect(negativeValueNodes).not.toContain("⚠");
expect(negativeValueNodes).toContain("FieldValueWord");
expect(operatorWordNodes).not.toContain("⚠");
expect(operatorWordNodes).toContain("FieldValueWord");
});
});
@@ -1,22 +1,27 @@
/* oxlint-disable */
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
import { LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
maxTerm: 27,
states:
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
stateData:
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
nodeNames:
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
maxTerm: 25,
nodeProps: [
["openedBy", 8,"LParen"],
["closedBy", 9,"RParen"]
["openedBy", 8, "LParen"],
["closedBy", 9, "RParen"],
],
propSources: [highlight],
skippedNodes: [0,22],
skippedNodes: [0, 20],
repeatNodeCount: 3,
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
tokenizers: [0, 1],
topRules: {"Query":[0,1]},
tokenPrec: 145
})
tokenData:
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
tokenizers: [0],
topRules: { Query: [0, 1] },
tokenPrec: 145,
});
@@ -1,43 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { formatFieldFilter } from "./format";
import { evaluate, parseQuery } from "./query";
function matchesFormattedUrl(value: string) {
return evaluate(parseQuery(formatFieldFilter("url", value)), {
fields: { url: value },
});
}
describe("formatFieldFilter", () => {
test("keeps URL-like values bare", () => {
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
});
test("keeps non-syntax punctuation bare", () => {
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
});
test("keeps values that start with an operator token bare", () => {
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("keeps boolean operator words bare", () => {
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
expect(formatFieldFilter("url", "or")).toBe("@url:or");
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
expect(matchesFormattedUrl("AND")).toBe(true);
});
test("escapes quoted values", () => {
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
expect(matchesFormattedUrl('say "hi"')).toBe(true);
});
test("quotes values that start with a quote", () => {
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
expect(matchesFormattedUrl('"hi"')).toBe(true);
});
});
@@ -1,7 +0,0 @@
const bareFieldValue = /^[^\s"]\S*$/;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -16,7 +16,6 @@ export const highlight = styleTags({
Phrase: t.string, // "quoted string"
// Fields
"FieldName/At": t.attributeName,
"FieldName/Word": t.attributeName,
"FieldValue/FieldValueWord": t.attributeValue,
"FieldValue/Term/Word": t.attributeValue,
});
@@ -30,8 +30,7 @@ type Tok =
| { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c);
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
export function tokenize(input: string): Tok[] {
const toks: Tok[] = [];
@@ -43,13 +42,7 @@ export function tokenize(input: string): Tok[] {
const readWord = () => {
let s = "";
while (i < n && isWordChar(peek())) s += advance();
return s;
};
const readFieldValue = () => {
let s = "";
while (i < n && !isSpace(peek())) s += advance();
while (i < n && isIdent(peek())) s += advance();
return s;
};
@@ -92,9 +85,6 @@ export function tokenize(input: string): Tok[] {
if (c === ":") {
toks.push({ kind: "COLON" });
i++;
if (peek() && !isSpace(peek()) && peek() !== `"`) {
toks.push({ kind: "WORD", text: readFieldValue() });
}
continue;
}
if (c === `"`) {
@@ -109,7 +99,7 @@ export function tokenize(input: string): Tok[] {
}
// WORD / AND / OR / NOT
if (isWordStart(c)) {
if (isIdent(c)) {
const w = readWord();
const upper = w.toUpperCase();
if (upper === "AND") toks.push({ kind: "AND" });
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import type { Appearance } from "@yaakapp-internal/theme";
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
import type { Appearance } from "../lib/theme/appearance";
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance";
export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
@@ -1,6 +1,6 @@
import { settingsAtom } from "@yaakapp-internal/models";
import { resolveAppearance } from "@yaakapp-internal/theme";
import { useAtomValue } from "jotai";
import { resolveAppearance } from "../lib/theme/appearance";
import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedAppearance() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { getResolvedTheme, getThemes } from "../lib/themes";
import { getResolvedTheme, getThemes } from "../lib/theme/themes";
import { usePluginsKey } from "./usePlugins";
import { usePreferredAppearance } from "./usePreferredAppearance";
+8
View File
@@ -0,0 +1,8 @@
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";
@@ -1,11 +1,10 @@
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import {
defaultDarkTheme,
defaultLightTheme,
resolveAppearance,
type Appearance,
} from "@yaakapp-internal/theme";
import { invokeCmd } from "./tauri";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import { invokeCmd } from "../tauri";
import type { Appearance } from "./appearance";
import { resolveAppearance } from "./appearance";
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
export async function getThemes() {
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
+9
View File
@@ -0,0 +1,9 @@
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeTheme,
getThemeCSS,
indent,
setThemeOnDocument,
} from "@yaakapp-internal/theme";
+1
View File
@@ -0,0 +1 @@
export { YaakColor } from "@yaakapp-internal/theme";
+4 -7
View File
@@ -2,14 +2,11 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models";
import type { Appearance } from "@yaakapp-internal/theme";
import {
applyThemeToDocument,
getCSSAppearance,
subscribeToPreferredAppearance,
} from "@yaakapp-internal/theme";
import { getSettings } from "./lib/settings";
import { getResolvedTheme } from "./lib/themes";
import type { Appearance } from "./lib/theme/appearance";
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
import { getResolvedTheme } from "./lib/theme/themes";
import { applyThemeToDocument } from "@yaakapp-internal/theme";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long
+4 -4
View File
@@ -8865,9 +8865,9 @@
}
},
"node_modules/hono": {
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"version": "4.12.25",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -17076,7 +17076,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.14",
"hono": "^4.12.25",
"zod": "^3.25.76"
},
"devDependencies": {
-1
View File
@@ -75,7 +75,6 @@
"start": "npm run client:dev",
"client:build": "node scripts/run-build.mjs client",
"client:dev": "node scripts/run-dev.mjs client",
"client:bundle": "node scripts/run-build.mjs client --config crates-tauri/yaak-app-client/tauri.release.conf.json --no-sign",
"proxy:build": "node scripts/run-build.mjs proxy",
"proxy:dev": "node scripts/run-dev.mjs proxy",
"migration": "node scripts/create-migration.cjs",
+80 -80
View File
@@ -18,12 +18,12 @@ export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId:
export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };
export type CallHttpAuthenticationResponse = {
export type CallHttpAuthenticationResponse = {
/**
* HTTP headers to add to the request. Existing headers will be replaced, while
* new headers will be added.
*/
setHeaders?: Array<HttpHeader>,
setHeaders?: Array<HttpHeader>,
/**
* Query parameters to add to the request. Existing params will be replaced, while
* new params will be added.
@@ -78,7 +78,7 @@ export type ExportHttpRequestRequest = { httpRequest: HttpRequest, };
export type ExportHttpRequestResponse = { content: string, };
export type FileFilter = { name: string,
export type FileFilter = { name: string,
/**
* File extensions to require
*/
@@ -100,149 +100,149 @@ export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hid
export type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };
export type FormInputBase = {
export type FormInputBase = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputCheckbox = {
export type FormInputCheckbox = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputEditor = {
export type FormInputEditor = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Don't show the editor gutter (line numbers, folds, etc.)
*/
hideGutter?: boolean,
hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputFile = {
export type FormInputFile = {
/**
* The title of the file selection window
*/
title: string,
title: string,
/**
* Allow selecting multiple files
*/
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -250,63 +250,63 @@ description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, hidden?: boolean, };
export type FormInputHttpRequest = {
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputKeyValue = {
export type FormInputKeyValue = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -314,36 +314,36 @@ description?: string, };
export type FormInputMarkdown = { content: string, hidden?: boolean, };
export type FormInputSelect = {
export type FormInputSelect = {
/**
* The options that will be available in the select input
*/
options: Array<FormInputSelectOption>,
options: Array<FormInputSelectOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -351,44 +351,44 @@ description?: string, };
export type FormInputSelectOption = { label: string, value: string, };
export type FormInputText = {
export type FormInputText = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Placeholder for the text input
*/
password?: boolean,
password?: boolean,
/**
* Whether to allow newlines in the input, like a <textarea/>
*/
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -474,7 +474,7 @@ export type ListOpenWorkspacesResponse = { workspaces: Array<WorkspaceInfo>, };
export type OpenExternalUrlRequest = { url: string, };
export type OpenWindowRequest = { url: string,
export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
@@ -486,15 +486,15 @@ export type PromptFormRequest = { id: string, title: string, description?: strin
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**
* Text to add to the confirmation button
*/
confirmText?: string, password?: boolean,
confirmText?: string, password?: boolean,
/**
* Text to add to the cancel button
*/
cancelText?: string,
cancelText?: string,
/**
* Require the user to enter a non-empty value
*/
@@ -524,12 +524,12 @@ export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
/**
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
@@ -546,23 +546,23 @@ export type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, }
export type TemplateRenderResponse = { data: JsonValue, };
export type Theme = {
export type Theme = {
/**
* How the theme is identified. This should never be changed
*/
id: string,
id: string,
/**
* The friendly name of the theme to be displayed to the user
*/
label: string,
label: string,
/**
* Whether the theme will be used for dark or light appearance
*/
dark: boolean,
dark: boolean,
/**
* The default top-level colors for the theme
*/
base: ThemeComponentColors,
base: ThemeComponentColors,
/**
* Optionally override theme for individual UI components for more control
*/
+2 -2
View File
@@ -53,7 +53,7 @@ export const defaultLightTheme: Theme = {
dark: false,
base: {
surface: "hsl(0,0%,100%)",
surfaceHighlight: "hsl(218,24%,92%)",
surfaceHighlight: "hsl(218,24%,87%)",
text: "hsl(217,24%,10%)",
textSubtle: "hsl(217,24%,40%)",
textSubtlest: "hsl(217,24%,58%)",
@@ -70,7 +70,7 @@ export const defaultLightTheme: Theme = {
sidebar: {
surface: "hsl(220,20%,98%)",
border: "hsl(217,22%,88%)",
surfaceHighlight: "hsl(217,25%,94%)",
surfaceHighlight: "hsl(217,25%,90%)",
},
},
};
-3
View File
@@ -12,9 +12,6 @@ export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./wi
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeColorVariables,
completeFullColorVariables,
completePartialColorVariables,
completeTheme,
getThemeCSS,
indent,
+107 -127
View File
@@ -47,10 +47,18 @@ export type YaakTheme = {
export type YaakColorKey = keyof ThemeComponentColors;
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
type ComponentName = keyof NonNullable<Theme["components"]>;
type ComponentName = keyof NonNullable<YaakTheme["components"]>;
type CSSVariables = Record<YaakColorKey, string | undefined>;
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
function themeVariables(
theme: Theme,
component?: ComponentName,
base?: CSSVariables,
): CSSVariables | null {
const cmp =
component == null
? theme.base
: (theme.components?.[component] ?? ({} as ThemeComponentColors));
const color = (value: string | undefined) => yc(theme, value);
const vars: CSSVariables = {
surface: cmp.surface,
@@ -58,12 +66,12 @@ export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariabl
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border,
borderSubtle: cmp.borderSubtle,
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(),
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(),
borderFocus: color(cmp.info)?.translucify(0.5)?.css(),
text: cmp.text,
textSubtle: cmp.textSubtle,
textSubtlest: cmp.textSubtlest,
textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(),
textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(),
shadow:
cmp.shadow ??
YaakColor.black()
@@ -78,126 +86,95 @@ export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariabl
danger: cmp.danger,
};
const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
const surface = themeColor(vars.surface ?? themeSurface.css());
const reference = surface.compositeOver(themeSurface);
const seed = themeColor(vars.surface ?? vars.surfaceHighlight ?? vars.border ?? surface.css());
const textBase = seed.desaturate(0.6).opacify(1);
const borderBase = seed.opacify(1);
const text = vars.text ?? textBase.withContrast(reference, 11).css();
const textColor = themeColor(text);
return normalizeColorVariables(theme, {
...vars,
text,
textSubtle: vars.textSubtle ?? textColor.lower(0.2).css(),
textSubtlest: vars.textSubtlest ?? textColor.lower(0.4).css(),
border: vars.border ?? borderBase.desaturate(0.2).withContrast(reference, 3).css(),
borderSubtle:
vars.borderSubtle ?? borderBase.desaturate(0.2).withContrast(reference, 1.2).css(),
});
}
export function completePartialColorVariables(
theme: Theme,
cmp: Partial<CSSVariables>,
): CSSVariables {
const color = (value: string | undefined) => yc(theme, value);
const text = color(cmp.text);
return normalizeColorVariables(theme, {
surface: cmp.surface,
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border ?? color(cmp.surface)?.lift(0.11).css(),
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06).css(),
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5).css(),
text: cmp.text,
textSubtle: cmp.textSubtle ?? text?.lower(0.3).css(),
textSubtlest: cmp.textSubtlest ?? text?.lower(0.5).css(),
shadow:
cmp.shadow ??
YaakColor.black()
.translucify(theme.dark ? 0.7 : 0.93)
.css(),
primary: cmp.primary,
secondary: cmp.secondary,
info: cmp.info,
success: cmp.success,
notice: cmp.notice,
warning: cmp.warning,
danger: cmp.danger,
});
}
export const completeColorVariables = completeFullColorVariables;
function normalizeColorVariables(theme: Theme, vars: CSSVariables): CSSVariables {
const normalized: CSSVariables = {} as CSSVariables;
for (const [key, value] of Object.entries(vars)) {
normalized[key as YaakColorKey] = value == null ? undefined : yc(theme, value).css();
if (!value && base?.[key as YaakColorKey]) {
vars[key as YaakColorKey] = base[key as YaakColorKey];
}
}
return normalized;
return vars;
}
function templateTagColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
text: color.liftMax().lower(0.05).css(),
textSubtle: color.liftMax().lower(0.08).css(),
function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
return {
text: color.lift(0.7).css(),
textSubtle: color.lift(0.4).css(),
textSubtlest: color.css(),
surface: color.lower(0.2).translucify(0.8).css(),
border: color.translucify(0.6).css(),
borderSubtle: color.translucify(0.8).css(),
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
});
};
}
function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
return {
text: color.lift(0.8).css(),
textSubtle: color.lift(0.8).translucify(0.3).css(),
surface: color.translucify(0.9).css(),
surfaceHighlight: color.translucify(0.8).css(),
});
border: color.lift(0.3).translucify(0.6).css(),
};
}
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
return {
text: color.lift(0.8).css(),
textSubtle: color.translucify(0.3).css(),
textSubtlest: color.translucify(0.6).css(),
surface: color.translucify(0.95).css(),
surfaceHighlight: color.translucify(0.85).css(),
border: color.lift(0.3).translucify(0.8).css(),
});
};
}
function _inputCSS(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
const theme: Partial<ThemeComponentColors> = {
border: color.css(),
};
return theme;
}
function buttonSolidColorVariables(
theme: Theme,
color: YaakColor,
color: YaakColor | null,
isDefault = false,
): CSSVariables {
const vars: Partial<CSSVariables> = {
): Partial<CSSVariables> {
if (color == null) return {};
const theme: Partial<ThemeComponentColors> = {
text: "white",
surface: color.lower(0.3).css(),
surfaceHighlight: color.lower(0.1).css(),
border: color.css(),
};
if (isDefault) {
vars.surface = undefined;
vars.surfaceHighlight = color.lift(0.08).css();
theme.text = undefined;
theme.surface = undefined;
theme.surfaceHighlight = color.lift(0.08).css();
}
return completeFullColorVariables(theme, vars);
return theme;
}
function buttonBorderColorVariables(
theme: Theme,
color: YaakColor,
color: YaakColor | null,
isDefault = false,
): CSSVariables {
): Partial<CSSVariables> {
if (color == null) return {};
const vars: Partial<CSSVariables> = {
text: color.desaturate(0.4).lift(1).css(),
textSubtle: color.desaturate(0.4).lift(0.55).css(),
text: color.lift(0.8).css(),
textSubtle: color.lift(0.55).css(),
textSubtlest: color.lift(0.4).translucify(0.6).css(),
surfaceHighlight: color.translucify(0.8).css(),
borderSubtle: color.translucify(0.5).css(),
border: color.translucify(0.3).css(),
@@ -208,7 +185,7 @@ function buttonBorderColorVariables(
vars.border = color.lift(0.5).css();
}
return completeFullColorVariables(theme, vars);
return vars;
}
function variablesToCSS(
@@ -225,8 +202,9 @@ function variablesToCSS(
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
}
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
return variablesToCSS(`.x-theme-${component}`, vars);
function componentCSS(theme: Theme, component: ComponentName): string | null {
if (theme.components == null) return null;
return variablesToCSS(`.x-theme-${component}`, themeVariables(theme, component));
}
function buttonCSS(
@@ -238,11 +216,8 @@ function buttonCSS(
if (color == null) return null;
return [
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
variablesToCSS(
`.x-theme-button--border--${colorKey}`,
buttonBorderColorVariables(theme, color),
),
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color)),
variablesToCSS(`.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color)),
].join("\n\n");
}
@@ -254,7 +229,7 @@ function bannerCSS(
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(color));
}
function toastCSS(
@@ -265,7 +240,7 @@ function toastCSS(
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(color));
}
function templateTagCSS(
@@ -276,10 +251,7 @@ function templateTagCSS(
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(
`.x-theme-templateTag--${colorKey}`,
templateTagColorVariables(theme, color),
);
return variablesToCSS(`.x-theme-templateTag--${colorKey}`, templateTagColorVariables(color));
}
export function getThemeCSS(theme: Theme): string {
@@ -292,26 +264,18 @@ export function getThemeCSS(theme: Theme): string {
let themeCSS = "";
try {
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
const baseSurface = yc(theme, theme.base.surface);
const baseCss = variablesToCSS(null, themeVariables(theme));
themeCSS = [
baseCss,
...Object.entries(components).map(([key, value]) =>
componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
...Object.keys(components).map((key) => componentCSS(theme, key as ComponentName)),
variablesToCSS(
".x-theme-button--solid--default",
buttonSolidColorVariables(yc(theme, theme.base.surface), true),
),
variablesToCSS(
".x-theme-button--border--default",
buttonBorderColorVariables(yc(theme, theme.base.surface), true),
),
baseSurface == null
? null
: variablesToCSS(
".x-theme-button--solid--default",
buttonSolidColorVariables(theme, baseSurface, true),
),
baseSurface == null
? null
: variablesToCSS(
".x-theme-button--border--default",
buttonBorderColorVariables(theme, baseSurface, true),
),
...Object.keys(colors).map((key) =>
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
),
@@ -396,10 +360,26 @@ function yc<T extends string | null | undefined>(
export function completeTheme(theme: Theme): Theme {
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
const color = (value: string | null | undefined) => yc(theme, value);
for (const [key, value] of Object.entries(fallback)) {
theme.base[key as YaakColorKey] ??= value;
}
theme.base.primary ??= fallback.primary;
theme.base.secondary ??= fallback.secondary;
theme.base.info ??= fallback.info;
theme.base.success ??= fallback.success;
theme.base.notice ??= fallback.notice;
theme.base.warning ??= fallback.warning;
theme.base.danger ??= fallback.danger;
theme.base.surface ??= fallback.surface;
theme.base.surfaceHighlight ??= color(theme.base.surface)?.lift(0.06)?.css();
theme.base.surfaceActive ??= color(theme.base.primary)?.lower(0.2).translucify(0.8).css();
theme.base.border ??= color(theme.base.surface)?.lift(0.12)?.css();
theme.base.borderSubtle ??= color(theme.base.border)?.lower(0.08)?.css();
theme.base.text ??= fallback.text;
theme.base.textSubtle ??= color(theme.base.text)?.lower(0.3)?.css();
theme.base.textSubtlest ??= color(theme.base.text)?.lower(0.5)?.css();
return theme;
}
+17 -251
View File
@@ -3,9 +3,9 @@ import parseColor from "parse-color";
export class YaakColor {
private readonly appearance: "dark" | "light" = "light";
private lightness = 0;
private chroma = 0;
private hue = 0;
private saturation = 0;
private lightness = 0;
private alpha = 1;
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
@@ -22,11 +22,11 @@ export class YaakColor {
}
static white(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").lower(999);
return new YaakColor("rgb(0,0,0)", "light").lower(1);
}
static black(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").lift(999);
return new YaakColor("rgb(0,0,0)", "light").lift(1);
}
set(cssColor: string): YaakColor {
@@ -35,22 +35,11 @@ export class YaakColor {
const [r, g, b, a] = hexToRgba(cssColor);
fixedCssColor = `rgba(${r},${g},${b},${a})`;
}
const oklch = parseOklch(fixedCssColor);
if (oklch != null) {
this.lightness = oklch.lightness;
this.chroma = oklch.chroma;
this.hue = oklch.hue;
this.alpha = oklch.alpha;
return this;
}
const { rgba } = parseColor(fixedCssColor);
const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]);
this.lightness = lightness;
this.chroma = chroma;
this.hue = hue;
this.alpha = rgba[3] ?? 1;
const { hsla } = parseColor(fixedCssColor);
this.hue = hsla[0];
this.saturation = hsla[1];
this.lightness = hsla[2];
this.alpha = hsla[3] ?? 1;
return this;
}
@@ -58,10 +47,6 @@ export class YaakColor {
return new YaakColor(this.css(), this.appearance);
}
themeColor(cssColor: string): YaakColor {
return new YaakColor(cssColor, this.appearance);
}
lower(mod: number): YaakColor {
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
}
@@ -70,21 +55,6 @@ export class YaakColor {
return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
}
liftMax(): YaakColor {
return this.lift(999);
}
lowerMax(): YaakColor {
return this.lower(999);
}
themeSurface(): YaakColor {
return new YaakColor(
this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)",
this.appearance,
);
}
minLightness(n: number): YaakColor {
const color = this.clone();
if (color.lightness < n) {
@@ -99,25 +69,25 @@ export class YaakColor {
translucify(mod: number): YaakColor {
const color = this.clone();
color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
color.alpha = color.alpha - color.alpha * mod;
return color;
}
opacify(mod: number): YaakColor {
const color = this.clone();
color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1);
color.alpha = this.alpha + (100 - this.alpha) * mod;
return color;
}
desaturate(mod: number): YaakColor {
const color = this.clone();
color.chroma = color.chroma - color.chroma * mod;
color.saturation = color.saturation - color.saturation * mod;
return color;
}
saturate(mod: number): YaakColor {
const color = this.clone();
color.chroma = this.chroma + this.chroma * mod;
color.saturation = this.saturation + (100 - this.saturation) * mod;
return color;
}
@@ -125,233 +95,29 @@ export class YaakColor {
return this.lightness > color.lightness;
}
contrastRatio(background: YaakColor): number {
const foreground = this.alpha < 1 ? this.compositeOver(background) : this;
const foregroundLuminance = foreground.relativeLuminance();
const backgroundLuminance = background.relativeLuminance();
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
withContrast(background: YaakColor, minContrast: number): YaakColor {
const darker = this.clone();
darker.lightness = 0;
darker.chroma = 0;
darker.hue = 0;
const lighter = this.clone();
lighter.lightness = 100;
lighter.chroma = 0;
lighter.hue = 0;
const darkerContrast = darker.contrastRatio(background);
const lighterContrast = lighter.contrastRatio(background);
let useLighterColor = lighterContrast >= darkerContrast;
// Saturated accent surfaces often read better with white text even when
// black has the higher numeric contrast. Keep yellow-ish light accents dark
// by requiring white to clear a modest contrast floor first.
if (minContrast >= 3 && lighterContrast >= 2.5) {
useLighterColor = true;
}
const selectedContrast = useLighterColor ? lighterContrast : darkerContrast;
if (selectedContrast < minContrast) {
return useLighterColor ? lighter : darker;
}
let minLightness = 0;
let maxLightness = 100;
const color = this.clone();
for (let i = 0; i < 24; i += 1) {
color.lightness = (minLightness + maxLightness) / 2;
const contrast = color.contrastRatio(background);
if (useLighterColor) {
if (contrast >= minContrast) {
maxLightness = color.lightness;
} else {
minLightness = color.lightness;
}
} else if (contrast >= minContrast) {
minLightness = color.lightness;
} else {
maxLightness = color.lightness;
}
}
color.lightness = useLighterColor ? maxLightness : minLightness;
return color;
}
compositeOver(background: YaakColor): YaakColor {
const [fgR, fgG, fgB] = this.rgb();
const [bgR, bgG, bgB] = background.rgb();
const alpha = this.alpha + background.alpha * (1 - this.alpha);
if (alpha <= 0) {
return YaakColor.transparent();
}
const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha;
const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha;
const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha;
return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance);
}
css(): string {
const [r, g, b] = this.rgb();
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
return rgbaToHex(r, g, b, this.alpha);
}
hexNoAlpha(): string {
const [r, g, b] = this.rgb();
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
return rgbaToHexNoAlpha(r, g, b);
}
private relativeLuminance(): number {
const [r, g, b] = this.rgb();
const red = srgbToLinear(r / 255);
const green = srgbToLinear(g / 255);
const blue = srgbToLinear(b / 255);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
private rgb(): [number, number, number] {
return oklchToRgb(this.lightness, this.chroma, this.hue);
}
private _lighten(mod: number): YaakColor {
const color = this.clone();
color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100);
color.lightness = this.lightness + (100 - this.lightness) * mod;
return color;
}
private _darken(mod: number): YaakColor {
const color = this.clone();
color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
color.lightness = this.lightness - this.lightness * mod;
return color;
}
}
function parseOklch(
cssColor: string,
): { lightness: number; chroma: number; hue: number; alpha: number } | null {
const match = cssColor
.trim()
.match(
/^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i,
);
if (match == null) return null;
const lightness = parseOklchLightness(match[1]);
const chroma = parseCssNumber(match[2], 1);
const hue = normalizeHue(parseCssNumber(match[3].replace(/deg$/i, ""), 1));
const alpha = parseCssNumber(match[4] ?? match[5] ?? "1", 1);
if (
!Number.isFinite(lightness) ||
!Number.isFinite(chroma) ||
!Number.isFinite(hue) ||
!Number.isFinite(alpha)
) {
return null;
}
return {
lightness: clamp(lightness, 0, 100),
chroma: Math.max(0, chroma),
hue,
alpha: clamp(alpha, 0, 1),
};
}
function parseCssNumber(value: string, percentScale: number): number {
const normalized = value.trim();
if (normalized.endsWith("%")) {
return (Number.parseFloat(normalized) / 100) * percentScale;
}
return Number.parseFloat(normalized);
}
function parseOklchLightness(value: string): number {
const parsed = parseCssNumber(value, 100);
return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100;
}
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
const red = srgbToLinear(r / 255);
const green = srgbToLinear(g / 255);
const blue = srgbToLinear(b / 255);
const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue;
const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue;
const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue;
const lRoot = Math.cbrt(l);
const mRoot = Math.cbrt(m);
const sRoot = Math.cbrt(s);
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot;
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot;
const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot;
return [
lightness * 100,
Math.sqrt(a * a + okb * okb),
normalizeHue(radToDeg(Math.atan2(okb, a))),
];
}
function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] {
const l = clamp(lightness, 0, 100) / 100;
const a = Math.cos(degToRad(hue)) * chroma;
const b = Math.sin(degToRad(hue)) * chroma;
const lRoot = l + 0.3963377774 * a + 0.2158037573 * b;
const mRoot = l - 0.1055613458 * a - 0.0638541728 * b;
const sRoot = l - 0.0894841775 * a - 1.291485548 * b;
const lCube = lRoot * lRoot * lRoot;
const mCube = mRoot * mRoot * mRoot;
const sCube = sRoot * sRoot * sRoot;
const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;
return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255];
}
function srgbToLinear(value: number): number {
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb(value: number): number {
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
return clamp(srgb, 0, 1);
}
function normalizeHue(value: number): number {
const hue = value % 360;
return hue < 0 ? hue + 360 : hue;
}
function degToRad(value: number): number {
return (value * Math.PI) / 180;
}
function radToDeg(value: number): number {
return (value * 180) / Math.PI;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function rgbaToHex(r: number, g: number, b: number, a: number): string {
const toHex = (n: number): string => {
const hex = Number(Math.round(n)).toString(16);
+1 -1
View File
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.14",
"hono": "^4.12.25",
"zod": "^3.25.76"
},
"devDependencies": {