@volter/twin-tiktok 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +310 -0
  3. package/client/tiktok-consent.tsx +154 -0
  4. package/client/tiktok-mirror.css +137 -0
  5. package/client/tiktok-mirror.tsx +492 -0
  6. package/dist/client/tiktok-consent.bundle.js +18 -0
  7. package/dist/client/tiktok-consent.d.ts +47 -0
  8. package/dist/client/tiktok-consent.js +20 -0
  9. package/dist/client/tiktok-consent.tsx +154 -0
  10. package/dist/client/tiktok-mirror.bundle.js +487 -0
  11. package/dist/client/tiktok-mirror.css +137 -0
  12. package/dist/client/tiktok-mirror.d.ts +42 -0
  13. package/dist/client/tiktok-mirror.js +315 -0
  14. package/dist/client/tiktok-mirror.tsx +492 -0
  15. package/dist/src/cli.d.ts +2 -0
  16. package/dist/src/cli.js +44 -0
  17. package/dist/src/index.d.ts +22 -0
  18. package/dist/src/index.js +167 -0
  19. package/dist/src/tiktok-blobs.d.ts +66 -0
  20. package/dist/src/tiktok-blobs.js +161 -0
  21. package/dist/src/tiktok-budget.d.ts +56 -0
  22. package/dist/src/tiktok-budget.js +136 -0
  23. package/dist/src/tiktok-capabilities.d.ts +7 -0
  24. package/dist/src/tiktok-capabilities.js +1855 -0
  25. package/dist/src/tiktok-conformance.d.ts +11 -0
  26. package/dist/src/tiktok-conformance.js +498 -0
  27. package/dist/src/tiktok-connector.d.ts +158 -0
  28. package/dist/src/tiktok-connector.js +600 -0
  29. package/dist/src/tiktok-consent-ui.d.ts +19 -0
  30. package/dist/src/tiktok-consent-ui.js +127 -0
  31. package/dist/src/tiktok-errors.d.ts +78 -0
  32. package/dist/src/tiktok-errors.js +175 -0
  33. package/dist/src/tiktok-ids.d.ts +16 -0
  34. package/dist/src/tiktok-ids.js +48 -0
  35. package/dist/src/tiktok-media.d.ts +7 -0
  36. package/dist/src/tiktok-media.js +86 -0
  37. package/dist/src/tiktok-mirror-ui.d.ts +49 -0
  38. package/dist/src/tiktok-mirror-ui.js +159 -0
  39. package/dist/src/tiktok-pkce.d.ts +25 -0
  40. package/dist/src/tiktok-pkce.js +56 -0
  41. package/dist/src/tiktok-posting.d.ts +100 -0
  42. package/dist/src/tiktok-posting.js +599 -0
  43. package/dist/src/tiktok-sample-mp4.d.ts +10 -0
  44. package/dist/src/tiktok-sample-mp4.js +55 -0
  45. package/dist/src/tiktok-scopes.d.ts +29 -0
  46. package/dist/src/tiktok-scopes.js +106 -0
  47. package/dist/src/tiktok-server.d.ts +28 -0
  48. package/dist/src/tiktok-server.js +89 -0
  49. package/dist/src/tiktok-store.d.ts +164 -0
  50. package/dist/src/tiktok-store.js +451 -0
  51. package/dist/src/tiktok-twin.d.ts +70 -0
  52. package/dist/src/tiktok-twin.js +1197 -0
  53. package/dist/src/tiktok-user.d.ts +28 -0
  54. package/dist/src/tiktok-user.js +174 -0
  55. package/package.json +74 -0
  56. package/src/cli.ts +43 -0
  57. package/src/index.ts +270 -0
  58. package/src/tiktok-blobs.ts +217 -0
  59. package/src/tiktok-budget.ts +163 -0
  60. package/src/tiktok-capabilities.ts +2022 -0
  61. package/src/tiktok-conformance.ts +526 -0
  62. package/src/tiktok-connector.ts +637 -0
  63. package/src/tiktok-consent-ui.ts +146 -0
  64. package/src/tiktok-errors.ts +197 -0
  65. package/src/tiktok-ids.ts +51 -0
  66. package/src/tiktok-journey.uitest.ts +305 -0
  67. package/src/tiktok-media.ts +89 -0
  68. package/src/tiktok-mirror-ui.ts +167 -0
  69. package/src/tiktok-pkce.ts +61 -0
  70. package/src/tiktok-posting.ts +617 -0
  71. package/src/tiktok-sample-mp4.ts +54 -0
  72. package/src/tiktok-scopes.ts +122 -0
  73. package/src/tiktok-server.ts +100 -0
  74. package/src/tiktok-store.ts +543 -0
  75. package/src/tiktok-twin.ts +1361 -0
  76. package/src/tiktok-user.ts +137 -0
@@ -0,0 +1,154 @@
1
+ // TikTok's AUTHORIZATION PAGE, as React — the vendor's own product UI, not a dashboard mirror.
2
+ //
3
+ // These components are the ONE renderer for the screen: the twin server-renders them with
4
+ // `renderToStaticMarkup` to answer `GET https://www.tiktok.com/v2/auth/authorize`, the capability
5
+ // verifies render the SAME exported components over the SAME projection, and the Playwright
6
+ // journey drives the HTML they emit. There is no second implementation to drift from.
7
+ //
8
+ // NO BROWSER BUNDLE, DELIBERATELY. The whole screen is a plain `<form>`: one checkbox per
9
+ // requested scope and two submit buttons. Nothing on it needs JavaScript, so this pack ships no
10
+ // browser entry and no committed bundle — the served page is server-rendered markup plus an
11
+ // inline stylesheet (`tiktok-consent-ui.ts`). That keeps the serve path free of a bundler
12
+ // (runtime contract R12b) and free of a generated artifact to drift, and it means `curl`,
13
+ // redirect-following HTTP libraries and headless browsers all complete an OAuth round trip
14
+ // against this twin identically.
15
+ //
16
+ // WHY THE CHECKBOXES ARE THE DESIGN. TikTok's documented callback carries a `scopes` parameter —
17
+ // "the authorization scope(s) which the user has granted" — and the token response's `scope` is
18
+ // "the scopes the user has agreed to authorize". Both are only meaningful if the granted set can
19
+ // be a SUBSET of the requested set, so the decision this page collects is per scope. LAYOUT
20
+ // PROVENANCE: the app/account header, the permission list and the Cancel/Continue pair reproduce
21
+ // TikTok's authorization sheet as widely screenshotted; the live DOM was not captured from a
22
+ // vendor artefact, so visual fidelity is certified by the journey filmstrip review and pinned by
23
+ // `tiktok.consent.optional_scope_toggles` / `tiktok.consent.button_wording` (todo), never
24
+ // asserted as vendor fact.
25
+
26
+ export type ConsentAccount = {
27
+ /** The account's `union_id` — the twin's subject key for a TikTok human. */
28
+ unionId: string;
29
+ username: string;
30
+ displayName: string;
31
+ };
32
+ // NO `avatarUrl` HERE, deliberately (§9 nit): the state builder used to carry one that nothing
33
+ // rendered — a field on the view model that no pixel depends on is a data-coupling claim with
34
+ // nothing behind it. The live sheet does show an avatar; serving one would mean an `<img>` at a
35
+ // tiktokcdn.com URL, and a twin never reaches a vendor CDN (see TikTokMark, drawn rather than
36
+ // fetched). When the avatar is worth rendering it comes back as bytes the twin serves, with a
37
+ // data-coupled UI capability that asserts the seeded persona's own image — not before.
38
+
39
+ export type ConsentScopeRow = {
40
+ scope: string;
41
+ label: string;
42
+ product: string;
43
+ known: boolean;
44
+ };
45
+
46
+ export type ConsentApp = {
47
+ clientKey: string;
48
+ name: string;
49
+ };
50
+
51
+ export type ConsentView = {
52
+ requestId: string;
53
+ /** Where the twin is reachable — the form action posts back here. */
54
+ origin: string;
55
+ app: ConsentApp;
56
+ /** The tiktok.com session this browser is signed in as — the account that will consent. */
57
+ account: ConsentAccount;
58
+ scopes: ConsentScopeRow[];
59
+ /** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. */
60
+ redirectHost: string;
61
+ };
62
+
63
+ /** The TikTok note mark, drawn rather than fetched — a twin never reaches out to a vendor CDN. */
64
+ function TikTokMark() {
65
+ return (
66
+ <span className="tt-mark" aria-hidden="true">
67
+ ♪
68
+ </span>
69
+ );
70
+ }
71
+
72
+ /** The authorization page — TikTok's consent sheet for the app named by the pending request. */
73
+ export function ConsentPage({ view }: { view: ConsentView }) {
74
+ const appName = view.app.name;
75
+ return (
76
+ <div className="card">
77
+ <header className="tt-header">
78
+ <TikTokMark />
79
+ <span className="tt-wordmark">TikTok</span>
80
+ </header>
81
+ <div className="signed-in">
82
+ <span className="account-text">
83
+ {/* A partially-pulled persona may lack a display name or handle; the fallback shows the
84
+ REAL union id rather than an empty pill or a bare "@" (never an invented placeholder). */}
85
+ <span className="account-name">{view.account.displayName || view.account.username || view.account.unionId}</span>
86
+ {view.account.username ? <span className="account-username">@{view.account.username}</span> : null}
87
+ </span>
88
+ </div>
89
+ <h1 className="title">
90
+ <strong className="app-name">{appName}</strong> would like to access your TikTok account
91
+ </h1>
92
+ <form method="POST" action={`${view.origin}/_twin/consent`} className="consent-form">
93
+ <input type="hidden" name="auth_request" value={view.requestId} />
94
+ <p className="scope-lead">This will allow {appName} to:</p>
95
+ <ul className="scope-list">
96
+ {view.scopes.map((row) => (
97
+ <li key={row.scope} className="scope-row">
98
+ <label className="scope-label">
99
+ <input className="scope-check" type="checkbox" name="scope" value={row.scope} defaultChecked />
100
+ <span className="scope-text">{row.label}</span>
101
+ </label>
102
+ {row.known ? null : <span className="scope-tag scope-tag-unknown">not in the twin&apos;s scope catalog</span>}
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ <div className="actions">
107
+ <button className="btn btn-secondary" type="submit" name="decision" value="deny">
108
+ Cancel
109
+ </button>
110
+ <button className="btn btn-primary" type="submit" name="decision" value="allow">
111
+ Continue
112
+ </button>
113
+ </div>
114
+ </form>
115
+ <p className="legal">
116
+ You&apos;ll be redirected to <strong className="redirect-host">{view.redirectHost}</strong>. You can remove this
117
+ app at any time from Manage app permissions in your TikTok settings.
118
+ </p>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export type ErrorPageProps = {
124
+ status: number;
125
+ /** The OAuth error code the failure classifies as (e.g. `invalid_request`). */
126
+ code: string;
127
+ detail: string;
128
+ /** The offending parameter, echoed for the developer. */
129
+ requestParam?: string;
130
+ };
131
+
132
+ /**
133
+ * The authorize-endpoint error PAGE — what a browser sees when the twin cannot trust the
134
+ * redirect_uri (unknown client_key, unregistered callback) and therefore must not bounce the error
135
+ * back to it. TikTok's live page for this case was not captured, so this page states the failure
136
+ * plainly and machine-readably rather than imitating unverified vendor prose
137
+ * (`tiktok.authorize.error_page_fidelity`, todo).
138
+ */
139
+ export function ErrorPage({ status, code, detail, requestParam }: ErrorPageProps) {
140
+ return (
141
+ <div className="card card-error">
142
+ <header className="tt-header">
143
+ <TikTokMark />
144
+ <span className="tt-wordmark">TikTok</span>
145
+ </header>
146
+ <h1 className="title">Something went wrong</h1>
147
+ <p className="error-detail">You weren&apos;t able to give access to the app. {detail}</p>
148
+ <p className="error-code">
149
+ Error {status}: {code}
150
+ </p>
151
+ {requestParam ? <p className="error-request">Request details: {requestParam}</p> : null}
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,137 @@
1
+ /* TikTok mirror — tiktok.com's own look, copied on purpose. A vendor-mirror stylesheet: exempt from the
2
+ Volter brand (the .githooks brand check reads only the Volter-branded sheets). */
3
+ :root {
4
+ --bg: #fff;
5
+ --text: #161823;
6
+ --muted: rgba(22, 24, 35, 0.5);
7
+ --line: rgba(22, 24, 35, 0.12);
8
+ --fill: rgba(22, 24, 35, 0.06);
9
+ --fill-hover: rgba(22, 24, 35, 0.12);
10
+ --red: #fe2c55;
11
+ --red-hover: #ef2950;
12
+ --cyan: #25f4ee;
13
+ --font: "TikTokFont", "ProximaNova", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 16px; line-height: 1.35; }
17
+ a { color: inherit; text-decoration: none; }
18
+ button { font-family: inherit; color: inherit; }
19
+ code { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
20
+ .muted { color: var(--muted); }
21
+ .loading { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
22
+
23
+ /* ── the logo ────────────────────────────────────────────────────────────── */
24
+ .logo { display: inline-flex; align-items: center; gap: 4px; color: #000; }
25
+ .logo-word { font-size: 27px; font-weight: 800; letter-spacing: -0.8px; }
26
+
27
+ /* ── layout: the left nav and the main column ────────────────────────────── */
28
+ .layout { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
29
+ .sidenav { position: sticky; top: 0; height: 100vh; padding: 16px 8px 12px 16px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--line); }
30
+ .nav-logo { padding: 2px 8px 14px; }
31
+ .nav-search { margin: 0 8px 10px 0; height: 40px; border-radius: 92px; background: var(--fill); color: var(--muted); display: flex; align-items: center; padding: 0 16px; font-size: 15px; }
32
+ .nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 8px; border: 0; background: transparent; border-radius: 6px; font-size: 18px; font-weight: 700; cursor: pointer; text-align: left; }
33
+ .nav-item:hover { background: var(--fill); }
34
+ .nav-item.active { color: var(--red); }
35
+ .nav-item .avatar { font-size: 14px !important; }
36
+ .nav-foot { margin-top: auto; padding: 12px 8px 0; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 8px; font-size: 12px; }
37
+ .nav-signout { border: 0; background: transparent; padding: 0; text-align: left; font-size: 13px; font-weight: 600; color: var(--muted); cursor: pointer; }
38
+ .nav-signout:hover { color: var(--text); }
39
+ .main { min-width: 0; padding: 32px 24px 48px 40px; }
40
+
41
+ /* ── avatars, badges ─────────────────────────────────────────────────────── */
42
+ .avatar { flex: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; user-select: none; }
43
+ .verified { position: relative; display: inline-flex; color: #20d5ec; flex: none; }
44
+ .verified-check { position: absolute; inset: 0; color: #fff; }
45
+
46
+ /* ── the profile header ──────────────────────────────────────────────────── */
47
+ .profile { max-width: 1320px; }
48
+ .profile-header { display: flex; gap: 28px; align-items: flex-start; }
49
+ .profile-info { display: flex; flex-direction: column; gap: 14px; padding-top: 6px; min-width: 0; }
50
+ .profile-names { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
51
+ .profile-username { margin: 0; font-size: 32px; line-height: 38px; font-weight: 700; }
52
+ .profile-nickname { margin: 0; font-size: 18px; font-weight: 600; }
53
+ .profile-buttons { display: flex; gap: 8px; }
54
+ .btn { height: 40px; min-width: 104px; padding: 0 20px; border-radius: 8px; border: 0; font-size: 16px; font-weight: 600; cursor: pointer; }
55
+ .btn-red { background: var(--red); color: #fff; }
56
+ .btn-red:hover { background: var(--red-hover); }
57
+ .btn-gray { background: var(--fill); }
58
+ .btn-gray:hover { background: var(--fill-hover); }
59
+ .profile-stats { display: flex; gap: 20px; font-size: 16px; }
60
+ .stat strong { font-weight: 700; margin-right: 6px; }
61
+ .stat-label { color: var(--muted); }
62
+ .profile-bio { margin: 0; white-space: pre-wrap; max-width: 560px; }
63
+ .profile-missing { padding: 120px 0; text-align: center; }
64
+
65
+ /* ── tabs, sort ──────────────────────────────────────────────────────────── */
66
+ .profile-tabs { margin-top: 28px; display: flex; align-items: flex-end; border-bottom: 1px solid var(--line); }
67
+ .ptab { display: inline-flex; align-items: center; gap: 6px; height: 44px; min-width: 140px; justify-content: center; border: 0; background: transparent; font-size: 18px; font-weight: 600; color: var(--muted); cursor: pointer; position: relative; }
68
+ .ptab:hover { color: var(--text); }
69
+ .ptab.active { color: var(--text); }
70
+ .ptab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--text); }
71
+ .sort { margin-left: auto; margin-bottom: 6px; display: flex; gap: 2px; padding: 2px; border-radius: 6px; background: var(--fill); font-size: 14px; font-weight: 600; }
72
+ .sort span { padding: 6px 12px; border-radius: 4px; color: var(--muted); }
73
+ .sort .sort-on { background: #fff; color: var(--text); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
74
+
75
+ /* ── the grid: 9:16 tiles ────────────────────────────────────────────────── */
76
+ .grid { margin-top: 16px; display: grid; grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); gap: 24px 16px; }
77
+ .tile { position: relative; aspect-ratio: 9 / 16; border-radius: 4px; overflow: hidden; background: #000; cursor: pointer; }
78
+ .tile-video { width: 100%; height: 100%; object-fit: cover; display: block; }
79
+ .tile::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 30%; background: linear-gradient(transparent, rgba(0, 0, 0, 0.5)); pointer-events: none; }
80
+ .tile-views { position: absolute; z-index: 1; left: 10px; bottom: 10px; display: inline-flex; align-items: center; gap: 4px; color: #fff; font-size: 16px; font-weight: 700; }
81
+ .tile-views svg { fill: none; stroke: #fff; }
82
+ .tile-fallback { position: absolute; inset: 0; padding: 16px; display: flex; align-items: center; justify-content: center; text-align: center; color: #fff; font-weight: 600; background: linear-gradient(160deg, #2b2b35, #111); }
83
+ .caption .tag { font-weight: 700; }
84
+ .empty { padding: 96px 0; text-align: center; }
85
+ .empty h3 { margin: 0 0 8px; font-size: 24px; }
86
+ .show-more { margin: 24px auto 0; display: block; height: 40px; padding: 0 24px; border: 0; border-radius: 8px; background: var(--fill); font-weight: 600; cursor: pointer; }
87
+
88
+ /* ── the vertical player ─────────────────────────────────────────────────── */
89
+ .player-page { position: fixed; inset: 0; background: #000; color: #fff; overflow: hidden; }
90
+ .player-backdrop { position: absolute; inset: -40px; width: calc(100% + 80px); height: calc(100% + 80px); object-fit: cover; filter: blur(40px) brightness(0.3) saturate(0.8); transform: scale(1.1); }
91
+ .player-close { position: absolute; z-index: 3; top: 20px; left: 20px; width: 40px; height: 40px; border: 0; border-radius: 50%; background: rgba(84, 84, 84, 0.5); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
92
+ .player-close:hover { background: rgba(120, 120, 120, 0.6); }
93
+ .player-logo { position: absolute; z-index: 3; top: 22px; left: 76px; }
94
+ .player-logo .logo { color: #fff; }
95
+ .player-logo .logo-word { font-size: 24px; }
96
+ .player-stage { position: relative; z-index: 2; height: 100%; display: flex; align-items: flex-end; justify-content: center; gap: 16px; padding: 12px 0; }
97
+ .player-frame { position: relative; height: 100%; aspect-ratio: 9 / 16; border-radius: 16px; overflow: hidden; background: #000; cursor: pointer; }
98
+ .player-video { width: 100%; height: 100%; object-fit: cover; display: block; }
99
+ .player-sound-toggle { position: absolute; z-index: 2; top: 14px; left: 14px; width: 36px; height: 36px; border: 0; border-radius: 50%; background: rgba(84, 84, 84, 0.5); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
100
+ .player-paused { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.85); pointer-events: none; }
101
+ .player-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 40%; background: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); pointer-events: none; }
102
+ .player-caption { position: absolute; left: 16px; right: 16px; bottom: 44px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; line-height: 20px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); pointer-events: none; }
103
+ .player-author { display: inline-flex; align-items: center; gap: 4px; font-size: 17px; font-weight: 700; }
104
+ .player-text { max-height: 60px; overflow: hidden; }
105
+ .player-sound { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; }
106
+ .player-bar { position: absolute; left: 12px; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 10px; }
107
+ .player-toggle { width: 24px; height: 24px; border: 0; padding: 0; background: transparent; color: #fff; cursor: pointer; }
108
+ .player-track { flex: 1; height: 3px; border-radius: 3px; background: rgba(255, 255, 255, 0.34); overflow: hidden; }
109
+ .player-progress { height: 100%; background: #fff; }
110
+ .player-time { font-size: 12px; font-variant-numeric: tabular-nums; }
111
+ .player-missing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; }
112
+ .rail { display: flex; flex-direction: column; align-items: center; gap: 14px; padding-bottom: 40px; }
113
+ .rail-avatar { position: relative; margin-bottom: 10px; }
114
+ .rail-avatar .avatar { border: 1px solid #fff; }
115
+ .rail-plus { position: absolute; left: 50%; bottom: -10px; transform: translateX(-50%); width: 22px; height: 22px; border-radius: 50%; background: var(--red); color: #fff; font-size: 18px; font-weight: 700; line-height: 21px; text-align: center; }
116
+ .rail-item { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 12px; min-height: 70px; }
117
+ .rail-btn { width: 48px; height: 48px; border-radius: 50%; background: rgba(84, 84, 84, 0.5); display: flex; align-items: center; justify-content: center; color: #fff; }
118
+ .player-nav { position: absolute; z-index: 3; right: 24px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 16px; }
119
+ .player-nav button { width: 48px; height: 48px; border: 0; border-radius: 50%; background: rgba(84, 84, 84, 0.5); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
120
+ .player-nav button:disabled { opacity: 0.35; cursor: default; }
121
+
122
+ /* ── the login form ──────────────────────────────────────────────────────── */
123
+ .login-backdrop { min-height: 100vh; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; }
124
+ .login { width: 483px; max-width: 94vw; background: #fff; border-radius: 8px; padding: 48px 48px 0; display: flex; flex-direction: column; align-items: stretch; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25); }
125
+ .login-logo { display: flex; justify-content: center; margin-bottom: 8px; }
126
+ .login-form { display: flex; flex-direction: column; gap: 12px; padding-bottom: 32px; }
127
+ .login-form h1 { margin: 8px 0 12px; text-align: center; font-size: 32px; font-weight: 700; }
128
+ .login-sub { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
129
+ .login-input { height: 44px; padding: 0 12px; border-radius: 4px; border: 1px solid rgba(22, 24, 35, 0.12); background: rgba(22, 24, 35, 0.06); font-size: 16px; font-family: inherit; outline: none; }
130
+ .login-input:focus { border-color: rgba(22, 24, 35, 0.2); }
131
+ .login-error { color: var(--red); font-size: 13px; }
132
+ .btn-login { height: 46px; border: 0; border-radius: 4px; background: var(--red); color: #fff; font-size: 16px; font-weight: 600; cursor: pointer; margin-top: 12px; }
133
+ .btn-login:disabled { background: rgba(22, 24, 35, 0.06); color: rgba(22, 24, 35, 0.34); cursor: default; }
134
+ .login-note { font-size: 12px; line-height: 16px; }
135
+ .link { border: 0; background: transparent; color: var(--muted); font-size: 13px; cursor: pointer; padding: 0; text-decoration: underline; }
136
+ .login-foot { margin: 0 -48px; padding: 18px; border-top: 1px solid var(--line); text-align: center; font-size: 15px; }
137
+ .link-red { color: var(--red); font-weight: 600; }